Files
zrec/recage
T
billmanz 7d8c6d63c7 Added recage.
To make interacting with a age-encrypted database a bit more
ergonomic, use a bash script that infers the encryption type then
passes the output to the recutil function.
2026-07-06 07:44:56 -04:00

339 lines
11 KiB
Bash

#!/usr/bin/env bash
#
# recage — securely query/edit age-encrypted recfile databases
# (supports BOTH symmetric [passphrase] and asymmetric [keypair])
#
# Usage:
# recage <db.age> <tool> [args...]
#
# Tools: recsel recinf recfmt recset recfix edit
#
# ── Encryption mode selection ───────────────────────────────────────────────
# ASYMMETRIC (default if RECAGE_IDENTITY is set):
# RECAGE_IDENTITY path to age identity file (private key)
# RECAGE_RECIPIENTS path to recipients file (public keys) for re-encryption
# (optional; derived from identity if omitted)
#
# SYMMETRIC (used if RECAGE_SYMMETRIC=1, or if no identity is provided):
# RECAGE_SYMMETRIC=1 force passphrase mode
# RECAGE_PASSPHRASE_FILE <path> read passphrase from a 0600 file (optional)
# (if neither, age prompts interactively on the terminal — most secure)
#
set -euo pipefail
# ---------------------------------------------------------------------------
# 0. Preconditions & helpers
# ---------------------------------------------------------------------------
die() {
printf 'recage: %s\n' "$*" >&2
exit 1
}
command -v age >/dev/null 2>&1 || die "'age' not found in PATH"
DB="${1:-}"
TOOL="${2:-}"
[ -n "$DB" ] || die "no database file given"
[ -n "$TOOL" ] || die "no tool given (recsel, recset, recfix, recinf, recfmt, edit)"
shift 2
ARGS=("$@")
[ -f "$DB" ] || die "database '$DB' not found"
# ---------------------------------------------------------------------------
# detect_age_mode <file>
#
# Reads ONLY the age header (never the ciphertext body, never a key) and
# prints "symmetric" or "asymmetric" to stdout. Returns non-zero if the
# file doesn't look like an age file at all.
#
# Rule (per the age spec):
# - An "scrypt" stanza => symmetric (and it MUST be the sole recipient).
# - Any X25519 / ssh-* stanza => asymmetric.
# ---------------------------------------------------------------------------
detect_age_mode() {
local file="$1"
local header magic
# Read enough bytes to comfortably cover the header stanzas.
# Headers are tiny; 4 KiB is far more than enough.
magic="$(dd if="$file" bs=1 count=64 2>/dev/null || true)"
case "$magic" in
# ---- ASCII-armored envelope ------------------------------------
'-----BEGIN AGE ENCRYPTED FILE'*)
# Strip the armor lines, Base64-decode the first portion,
# and inspect the decoded header text.
header="$(
sed -n '/-----BEGIN AGE ENCRYPTED FILE-----/,/-----END/p' "$file" |
sed '1d;$d' |
{ base64 -d 2>/dev/null || base64 -D 2>/dev/null; } |
dd bs=1 count=4096 2>/dev/null || true
)"
;;
# ---- Binary envelope -------------------------------------------
'age-encryption.org/v1'*)
header="$(dd if="$file" bs=1 count=4096 2>/dev/null || true)"
;;
# ---- Not an age file -------------------------------------------
*)
return 1
;;
esac
# The header must announce the age version to be trustworthy.
case "$header" in
*'age-encryption.org/v1'*) : ;;
*) return 1 ;;
esac
# Look for stanza type lines: they read like "-> scrypt ..." or
# "-> X25519 ...". We scan the recipient stanza lines.
if printf '%s' "$header" | grep -q '^-> scrypt '; then
printf 'symmetric\n'
return 0
fi
if printf '%s' "$header" |
grep -Eq '^-> (X25519|ssh-rsa|ssh-ed25519|piv-p256) '; then
printf 'asymmetric\n'
return 0
fi
# Header present but no recognised stanza — refuse to guess.
return 2
}
# ---------------------------------------------------------------------------
# 1. Decide encryption mode — AUTO-DETECT, with optional manual override.
# ---------------------------------------------------------------------------
# Precedence:
# 1. Explicit override (RECAGE_SYMMETRIC=1 OR RECAGE_ASYMMETRIC=1)
# 2. Header auto-detection
# 3. Sane fallback + clear error if ambiguous
# ---------------------------------------------------------------------------
MODE_CRYPT=""
if [ "${RECAGE_SYMMETRIC:-0}" = "1" ] && [ "${RECAGE_ASYMMETRIC:-0}" = "1" ]; then
die "RECAGE_SYMMETRIC and RECAGE_ASYMMETRIC are mutually exclusive"
elif [ "${RECAGE_SYMMETRIC:-0}" = "1" ]; then
MODE_CRYPT="symmetric"
elif [ "${RECAGE_ASYMMETRIC:-0}" = "1" ]; then
MODE_CRYPT="asymmetric"
else
# Auto-detect from the file header.
if detected="$(detect_age_mode "$DB")"; then
MODE_CRYPT="$detected"
printf 'recage: auto-detected %s encryption\n' "$MODE_CRYPT" >&2
else
rc=$?
case "$rc" in
1) die "'$DB' does not appear to be an age-encrypted file" ;;
2) die "cannot determine age mode from header; set \
RECAGE_SYMMETRIC=1 or RECAGE_ASYMMETRIC=1 explicitly" ;;
*) die "age mode detection failed" ;;
esac
fi
fi
# Validate the requirements for the chosen mode.
case "$MODE_CRYPT" in
asymmetric)
: "${RECAGE_IDENTITY:?asymmetric file needs RECAGE_IDENTITY (private key)}"
[ -f "$RECAGE_IDENTITY" ] || die "identity file '$RECAGE_IDENTITY' not found"
;;
symmetric)
# Passphrase supplied interactively or via RECAGE_PASSPHRASE_FILE.
: # nothing mandatory here; interactive prompt is the default.
;;
esac
# (passphrase-file permission check from the previous version follows here)
if [ -n "${RECAGE_PASSPHRASE_FILE:-}" ]; then
[ -f "$RECAGE_PASSPHRASE_FILE" ] || die "passphrase file not found"
perms="$(stat -f '%Lp' "$RECAGE_PASSPHRASE_FILE" 2>/dev/null ||
stat -c '%a' "$RECAGE_PASSPHRASE_FILE" 2>/dev/null || echo '')"
case "$perms" in
600 | 400) : ;;
*) printf 'recage: WARNING: passphrase file perms are %s (want 600)\n' \
"$perms" >&2 ;;
esac
fi
# ---------------------------------------------------------------------------
# 2. Secure RAM-backed working directory.
# ---------------------------------------------------------------------------
pick_secure_tmpdir() {
local base
if [ -d /dev/shm ] && [ -w /dev/shm ]; then
base=/dev/shm # Linux RAM disk
else
base="${TMPDIR:-/tmp}" # macOS fallback (see caveats)
fi
mktemp -d "${base%/}/recage.XXXXXXXX"
}
WORKDIR="$(pick_secure_tmpdir)"
chmod 700 "$WORKDIR"
# ---------------------------------------------------------------------------
# 3. Robust secure cleanup on ANY exit.
# ---------------------------------------------------------------------------
cleanup() {
local f
if [ -d "$WORKDIR" ]; then
find "$WORKDIR" -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do
if command -v shred >/dev/null 2>&1; then
shred -u "$f" 2>/dev/null || rm -f "$f"
elif rm -P "$f" 2>/dev/null; then
:
else
rm -f "$f"
fi
done
rm -rf "$WORKDIR"
fi
}
trap cleanup EXIT INT TERM HUP
# ---------------------------------------------------------------------------
# 4. Passphrase plumbing (symmetric mode only).
#
# We NEVER put the passphrase on a command line. age reads a passphrase
# from the controlling terminal by default. To supply one non-interactively
# and securely, we hand age a passphrase via a private named pipe (FIFO)
# inside our 0700 workdir, fed by a background writer. The passphrase thus
# lives only in RAM (Linux /dev/shm) and the process's memory — never in
# argv, and never in a persistent file we created.
# ---------------------------------------------------------------------------
PASS_FIFO=""
feed_passphrase_fifo() {
# Reads passphrase from the 0600 file and writes it once into a FIFO.
PASS_FIFO="$WORKDIR/pass.fifo"
mkfifo -m 600 "$PASS_FIFO"
# Background writer: dumps the passphrase then closes.
(cat "$RECAGE_PASSPHRASE_FILE" >"$PASS_FIFO") &
}
# ---------------------------------------------------------------------------
# 5. Decrypt / encrypt primitives for each mode.
# ---------------------------------------------------------------------------
decrypt_to() {
local out="$1"
case "$MODE_CRYPT" in
asymmetric)
age -d -i "$RECAGE_IDENTITY" -o "$out" "$DB" ||
die "decryption failed (wrong key or corrupted db)"
;;
symmetric)
if [ -n "${RECAGE_PASSPHRASE_FILE:-}" ]; then
feed_passphrase_fifo
# age reads the passphrase from the FIFO as if from a file.
age -d -o "$out" "$DB" <"$PASS_FIFO" 2>/dev/null ||
{
# Some age builds need the passphrase on the tty, not stdin.
# Fall back to interactive if the piped form is unsupported.
age -d -o "$out" "$DB" ||
die "decryption failed (wrong passphrase or corrupt db)"
}
else
# Fully interactive: age prompts on /dev/tty — most secure.
age -d -o "$out" "$DB" ||
die "decryption failed (wrong passphrase or corrupt db)"
fi
;;
esac
}
resolve_recipients() { # asymmetric re-encryption target(s)
if [ -n "${RECAGE_RECIPIENTS:-}" ]; then
[ -f "$RECAGE_RECIPIENTS" ] || die "recipients file not found"
printf -- '-R\n%s\n' "$RECAGE_RECIPIENTS"
else
local pub
pub="$(age-keygen -y "$RECAGE_IDENTITY" 2>/dev/null)" ||
die "cannot derive recipient; set RECAGE_RECIPIENTS"
printf -- '-r\n%s\n' "$pub"
fi
}
encrypt_from() {
local src="$1"
local newenc="$WORKDIR/db.age.new"
case "$MODE_CRYPT" in
asymmetric)
# shellcheck disable=SC2046
age -e $(resolve_recipients) -o "$newenc" "$src" ||
die "re-encryption failed; original DB untouched"
;;
symmetric)
if [ -n "${RECAGE_PASSPHRASE_FILE:-}" ]; then
feed_passphrase_fifo
age -e -p -o "$newenc" "$src" <"$PASS_FIFO" 2>/dev/null ||
age -e -p -o "$newenc" "$src" ||
die "re-encryption failed; original DB untouched"
else
# Interactive: age asks you to type the passphrase (twice).
age -e -p -o "$newenc" "$src" ||
die "re-encryption failed; original DB untouched"
fi
;;
esac
chmod --reference="$DB" "$newenc" 2>/dev/null || chmod 600 "$newenc"
mv -f "$newenc" "$DB"
}
# ---------------------------------------------------------------------------
# 6. Classify the recfile tool.
# ---------------------------------------------------------------------------
case "$TOOL" in
recsel | recinf | recfmt) MODE="read" ;;
recset | recfix) MODE="write" ;;
edit) MODE="edit" ;;
*) die "unsupported tool '$TOOL'" ;;
esac
[ "$MODE" = "edit" ] || command -v "$TOOL" >/dev/null 2>&1 ||
die "'$TOOL' not found in PATH"
PLAINTEXT="$WORKDIR/db.rec"
(
umask 077
: >"$PLAINTEXT"
)
decrypt_to "$PLAINTEXT"
# ---------------------------------------------------------------------------
# 7. Dispatch.
# ---------------------------------------------------------------------------
case "$MODE" in
read)
"$TOOL" "${ARGS[@]}" "$PLAINTEXT"
;;
write)
BEFORE="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')"
"$TOOL" "${ARGS[@]}" "$PLAINTEXT"
AFTER="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')"
[ "$BEFORE" != "$AFTER" ] && {
encrypt_from "$PLAINTEXT"
printf 'recage: changes re-encrypted into %s\n' "$DB" >&2
}
;;
edit)
: "${EDITOR:=vi}"
BEFORE="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')"
"$EDITOR" "$PLAINTEXT"
command -v recfix >/dev/null 2>&1 && {
recfix --check "$PLAINTEXT" || die "recfix check failed; NOT saving"
}
AFTER="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')"
if [ "$BEFORE" != "$AFTER" ]; then
encrypt_from "$PLAINTEXT"
printf 'recage: edits re-encrypted into %s\n' "$DB" >&2
else
printf 'recage: no changes.\n' >&2
fi
;;
esac