#!/usr/bin/env bash # # recage — securely query/edit age-encrypted recfile databases # (supports BOTH symmetric [passphrase] and asymmetric [keypair]) # # Usage: # recage [args...] # # Tools: recsel recinf recfmt recset recfix edit # ## ── Encryption mode is AUTO-DETECTED from the file header ─────────────────── # Override with RECAGE_SYMMETRIC=1 or RECAGE_ASYMMETRIC=1 if ever needed. # # ASYMMETRIC files: # RECAGE_IDENTITY path to age identity file (private key). # Defaults to ~/.config/age/identity.txt, else prompts. # RECAGE_RECIPIENTS recipients file for re-encryption (optional; # derived from the identity if omitted). # # SYMMETRIC files: # Passphrase is ALWAYS entered interactively at the terminal. # (No passphrase-file or environment-variable option exists, by design.) # 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 # # 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 } # --------------------------------------------------------------------------- # resolve_identity # # Determines the age identity (private key) file to use, in priority order: # 1. $RECAGE_IDENTITY (explicit user override) # 2. ~/.config/age/identity.txt (XDG default location) # 3. Interactive prompt (only if a terminal is attached) # # Sets the global RECAGE_IDENTITY on success; dies otherwise. # --------------------------------------------------------------------------- resolve_identity() { local default_id="${XDG_CONFIG_HOME:-$HOME/.config}/age/identity.txt" # 1. Explicit override wins. if [ -n "${RECAGE_IDENTITY:-}" ]; then [ -f "$RECAGE_IDENTITY" ] || die "identity file '$RECAGE_IDENTITY' not found" return 0 fi # 2. Standard default location. if [ -f "$default_id" ]; then RECAGE_IDENTITY="$default_id" printf 'recage: using default identity %s\n' "$RECAGE_IDENTITY" >&2 return 0 fi # 3. Prompt — but only if we truly have an interactive terminal. # Read from /dev/tty (not stdin) so this still works when the # tool's stdin is a pipe (e.g. recsel reading a query). if [ -r /dev/tty ] && [ -t 1 ]; then local answer printf 'recage: no identity at %s\n' "$default_id" >&2 printf 'recage: enter path to your age identity file: ' >&2 IFS= read -r answer &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) resolve_identity ;; symmetric) # Passphrase supplied interactively. # Ensure a terminal exists, or age cannot prompt. [ -r /dev/tty ] || die "symmetric db needs an interactive terminal for the passphrase" ;; esac # --------------------------------------------------------------------------- # 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. 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) # Fully interactive: age prompts on /dev/tty — most secure. age -d -o "$out" "$DB" || die "decryption failed (wrong passphrase or corrupt db)" ;; esac } resolve_recipients() { 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) # age -p prompts for the passphrase and asks you to confirm it. age -e -p -o "$newenc" "$src" || die "re-encryption failed; original DB untouched" ;; esac chmod --reference="$DB" "$newenc" 2>/dev/null || chmod 600 "$newenc" mv -f "$newenc" "$DB" } # --------------------------------------------------------------------------- # 5. 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" # --------------------------------------------------------------------------- # 6. 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