:PROPERTIES: :GPTEL_MODEL: claude-opus-4-8 :GPTEL_BACKEND: Claude :GPTEL_SYSTEM: You are a top programming expert who provides precise answers, avoiding ambiguous responses. "Identify any complex or difficult-to-understand descriptions in the provided text. Rewrite these descriptions to make them clearer and more accessible. Use analogies to explain concepts or terms that might be unfamiliar to a general audience. Ensure that the analogies are relatable, easy to understand." "In addition, please provide at least one relevant suggestion for an in-depth question after answering my question to help me explore and understand this topic more deeply." Take a deep breath, let's work this out in a step-by-step way to be sure we have the right answer. If there's a perfect solution, I'll tip $200! Many thanks to these AI whisperers: :GPTEL_TOOLS: :GPTEL_MAX_TOKENS: 10000 :GPTEL_BOUNDS: ((response (1364 11688) (11799 26182) (26308 36586) (36817 44194) (44299 53444) (53555 59339))) :END: *** I have a recfile database that is encrypted in age. I would like to be able to query this database using recsel, as well as invoke the other utilities like recfix, recfmt, recinf, and recset on this encrypted database. Write a script that allows me to interact with this database. Focus on being secure. Make sure this script can work on macOS and Ubuntu 26.04. * Secure Encrypted Recfile Database Wrapper Let me first clarify the challenge in plain terms, then provide the solution. ** The Core Problem (Explained Simply) Think of your encrypted recfile like a *locked safe*. The =recsel=, =recfix=, and other tools are like *accountants* who can read and edit documents—but they can't open safes. Your script needs to be the *trusted assistant* who: 1. Opens the safe (decrypts with =age=) 2. Hands the document to the accountant (pipes to =rec*= tools) 3. *Never leaves the document lying on the desk* (avoids writing plaintext to disk) 4. Re-locks the safe if changes were made (re-encrypts) The security challenge is like handling classified documents: you must ensure the decrypted content lives *only in memory* (RAM), never touching the hard drive where it could be recovered later. ** Security Design Decisions | Concern | Analogy | Solution | |---------|---------|----------| | Plaintext on disk | Leaving safe contents on desk | Use memory-backed temp dirs / process substitution | | Temp file leaks | Photocopies left in trash | =trap= cleanup + secure deletion | | Password in =ps= output | Shouting your PIN in public | Pass keys via files/stdin, never CLI args | | Race conditions on write | Two people editing one doc | Atomic rename with re-encryption | ** The Script #+begin_src bash #!/usr/bin/env bash # # recage — securely query/edit age-encrypted recfile databases # # Usage: # recage recsel [args...] # read-only query # recage recinf [args...] # read-only info # recage recfmt [args...] # read-only format # recage recfix [args...] # may modify (re-encrypts) # recage recset [args...] # modifies (re-encrypts) # recage edit # open $EDITOR, re-encrypt on save # # Environment: # RECAGE_IDENTITY path to age identity file (private key) # RECAGE_RECIPIENTS path to age recipients file (public keys), OR # defaults to deriving recipient from identity # set -euo pipefail # --------------------------------------------------------------------------- # 0. Preconditions # --------------------------------------------------------------------------- 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" : "${RECAGE_IDENTITY:?set RECAGE_IDENTITY to your age identity file}" [ -f "$RECAGE_IDENTITY" ] || die "identity file '$RECAGE_IDENTITY' not found" # --------------------------------------------------------------------------- # 1. Choose a RAM-backed working directory (no plaintext on persistent disk) # macOS: no tmpfs, but we minimise exposure + secure-wipe. # Linux: prefer /dev/shm (RAM-backed). # --------------------------------------------------------------------------- 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 fi mktemp -d "${base%/}/recage.XXXXXXXX" } WORKDIR="$(pick_secure_tmpdir)" chmod 700 "$WORKDIR" # --------------------------------------------------------------------------- # 2. Robust cleanup: securely wipe & remove on ANY exit (even Ctrl-C / kill). # --------------------------------------------------------------------------- cleanup() { local f # Overwrite regular files before deletion where possible. if [ -d "$WORKDIR" ]; then find "$WORKDIR" -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do # 'shred' on Linux; 'rm -P' on macOS; fall back to dd overwrite. 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 dd if=/dev/urandom of="$f" bs=1k \ count="$(( ($(wc -c <"$f" 2>/dev/null || echo 0) / 1024) + 1 ))" \ conv=notrunc 2>/dev/null || true rm -f "$f" fi done rm -rf "$WORKDIR" fi } trap cleanup EXIT INT TERM HUP # --------------------------------------------------------------------------- # 3. Determine recipients for re-encryption (write operations only). # --------------------------------------------------------------------------- resolve_recipients() { if [ -n "${RECAGE_RECIPIENTS:-}" ]; then [ -f "$RECAGE_RECIPIENTS" ] || die "recipients file not found" printf -- '-R\n%s\n' "$RECAGE_RECIPIENTS" else # Derive public recipient(s) from the identity file. local pub pub="$(age-keygen -y "$RECAGE_IDENTITY" 2>/dev/null)" \ || die "cannot derive recipient from identity; set RECAGE_RECIPIENTS" printf -- '-r\n%s\n' "$pub" fi } # --------------------------------------------------------------------------- # 4. Classify tools as read-only vs. potentially-writing. # --------------------------------------------------------------------------- case "$TOOL" in recsel|recinf|recfmt) MODE="read" ;; recset|recfix) MODE="write" ;; edit) MODE="edit" ;; *) die "unsupported tool '$TOOL'" ;; esac if [ "$MODE" != "read" ] && [ "$MODE" != "edit" ]; then command -v "$TOOL" >/dev/null 2>&1 || die "'$TOOL' not found in PATH" elif [ "$MODE" = "read" ]; then command -v "$TOOL" >/dev/null 2>&1 || die "'$TOOL' not found in PATH" fi PLAINTEXT="$WORKDIR/db.rec" ( umask 077; : > "$PLAINTEXT" ) # create with 0600 before writing # --------------------------------------------------------------------------- # 5. Decrypt into the secure workdir. # --------------------------------------------------------------------------- age -d -i "$RECAGE_IDENTITY" -o "$PLAINTEXT" "$DB" \ || die "decryption failed (wrong key or corrupted db)" # --------------------------------------------------------------------------- # 6. Dispatch. # --------------------------------------------------------------------------- reencrypt() { local src="$1" local newenc="$WORKDIR/db.age.new" # shellcheck disable=SC2046 age -e $(resolve_recipients) -o "$newenc" "$src" \ || die "re-encryption failed; original DB left untouched" # Atomic-ish replace: preserve original perms/ownership where possible. chmod --reference="$DB" "$newenc" 2>/dev/null || chmod 600 "$newenc" mv -f "$newenc" "$DB" } case "$MODE" in read) # Read-only: run tool directly against plaintext, discard afterwards. "$TOOL" "${ARGS[@]}" "$PLAINTEXT" ;; write) # recset/recfix may modify in place. Snapshot to detect changes. BEFORE="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')" "$TOOL" "${ARGS[@]}" "$PLAINTEXT" AFTER="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')" if [ "$BEFORE" != "$AFTER" ]; then reencrypt "$PLAINTEXT" printf 'recage: changes re-encrypted into %s\n' "$DB" >&2 fi ;; edit) : "${EDITOR:=vi}" BEFORE="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')" "$EDITOR" "$PLAINTEXT" # Validate before committing — don't re-encrypt a broken recfile. if command -v recfix >/dev/null 2>&1; then recfix --check "$PLAINTEXT" || die "recfix check failed; NOT saving" fi AFTER="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')" if [ "$BEFORE" != "$AFTER" ]; then reencrypt "$PLAINTEXT" printf 'recage: edits re-encrypted into %s\n' "$DB" >&2 else printf 'recage: no changes.\n' >&2 fi ;; esac # EXIT trap performs secure cleanup. #+end_src ** How to Use It #+begin_src bash # One-time setup export RECAGE_IDENTITY="$HOME/.config/recage/key.txt" # your age private key chmod 600 "$RECAGE_IDENTITY" # Query (read-only, nothing re-encrypted) recage contacts.age recsel -e "Age > 30" -p Name,Email # Get metadata recage contacts.age recinf # Modify a field (auto re-encrypts on change) recage contacts.age recset -e "Name = 'Bob'" -f Email -S bob@new.com # Full interactive edit in your $EDITOR recage contacts.age edit #+end_src ** Important Caveats (The Honest Fine Print) Like a bank vault that's only as secure as the guard's habits, this script has real-world limits you must understand: 1. *macOS has no =tmpfs=.* Unlike Linux's =/dev/shm= (a genuine RAM disk—data vanishes on power loss and never hits your SSD), macOS falls back to =/tmp= on disk. The script /shreds/ the file after, but on *SSDs with wear-leveling, shredding is unreliable* (the drive may write to a different physical cell). Think of it like trying to erase pencil on a page that magically copies itself elsewhere. For maximum macOS security, use a manually created RAM disk (see follow-up question below). 2. *=recfix=/=recset= edit in-place*, so they need a real file, not a pipe. This is why we can't use pure process substitution for writes. 3. *Ubuntu 26.04* isn't released yet at time of writing; this uses only POSIX-standard and long-stable GNU tools, so it should work regardless. 4. *Swap/hibernation* can still page RAM to disk. On a threat model requiring defense against forensic disk recovery, ensure *encrypted swap* is enabled. --- ** Suggested Follow-Up Question for Deeper Exploration > *"How can I create a truly RAM-backed, encrypted scratch space on macOS (using =hdiutil= to make an in-memory RAM disk) so that my decrypted recfile never touches the SSD at all—and how would I integrate that into the =recage= script conditionally?"* This would take you deeper into macOS-specific secure-memory handling (=hdiutil attach -nomount ram://...=), letting you achieve the same =/dev/shm=-level guarantee that Linux gets for free—closing the biggest security gap in the current script. If you'd like, I can also show a *process-substitution-only variant* for read-only queries that /never/ creates any temp file at all. Would that be helpful? *** I would like this to be able to work with recfiles encrypted with age symmetrically and asymmetrically. - Supporting Both Symmetric and Asymmetric age Encryption * Understanding the Two Modes (Explained Simply) Think of =age= encryption like two different ways to lock a diary: | Mode | Analogy | How you unlock | |------|---------|----------------| | /Asymmetric/ (key pair) | A /mailbox with a slot/: anyone can drop mail in (public key), only you have the mailbox key to take it out (private key) | Provide your /identity file/ (private key) | | /Symmetric/ (passphrase) | A /combination padlock/: the same secret combination both locks and unlocks | Provide your /passphrase/ | The tricky part: =age= /auto-detects/ which mode a file uses when decrypting, but /you/ must tell it which mode when /encrypting/. And critically—*passphrases must never appear in the process list/ (like not shouting your padlock combo across a room), so we feed them through a secure channel. * The Key Security Challenge with Passphrases =age= normally prompts for a passphrase /interactively on the terminal/ (=/dev/tty=). This is actually the /most secure/ option because the passphrase never touches disk, environment variables, or the process list. But it breaks automation. Our strategy—like a /tiered security clearance/: 1. /Best:/ Let =age= prompt interactively (passphrase stays in the terminal only). 2. /Acceptable:/ Read passphrase from a /file descriptor/ or a /0600 file/ the script feeds to =age= via a temporary named pipe / =tty= redirection—*never/ as a command-line argument. The danger to avoid: passphrases as CLI arguments (=--passphrase secret=) show up in =ps aux= for /every user on the system to see/—like writing your PIN on a whiteboard. * The Updated Script #+begin_src bash #!/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 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 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" # --------------------------------------------------------------------------- # 1. Decide encryption mode. # --------------------------------------------------------------------------- # Priority: # - RECAGE_SYMMETRIC=1 -> symmetric # - RECAGE_IDENTITY set (and !=symmetric) -> asymmetric # - otherwise -> symmetric (interactive prompt) # --------------------------------------------------------------------------- if [ "${RECAGE_SYMMETRIC:-0}" = "1" ]; then MODE_CRYPT="symmetric" elif [ -n "${RECAGE_IDENTITY:-}" ]; then MODE_CRYPT="asymmetric" [ -f "$RECAGE_IDENTITY" ] || die "identity file '$RECAGE_IDENTITY' not found" else MODE_CRYPT="symmetric" fi # If a passphrase file is provided, validate its permissions (defence-in-depth). if [ -n "${RECAGE_PASSPHRASE_FILE:-}" ]; then [ -f "$RECAGE_PASSPHRASE_FILE" ] || die "passphrase file not found" # Warn loudly if the passphrase file is world/group readable. 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" < "$PASS_FIFO" "$DB" 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" < "$PASS_FIFO" "$src" 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 #+end_src * Usage Examples for Both Modes #+begin_src bash # ── ASYMMETRIC (key pair) ──────────────────────────────────────────── export RECAGE_IDENTITY="$HOME/.config/recage/key.txt" recage contacts.age recsel -e "Age > 30" -p Name,Email recage contacts.age recset -e "Name = 'Bob'" -f Email -S bob@new.com # ── SYMMETRIC (passphrase), interactive — MOST SECURE ──────────────── export RECAGE_SYMMETRIC=1 recage secrets.age recsel -p Login,Password # ↑ age prompts: "Enter passphrase:" on your terminal. # For a write op, it prompts once to decrypt, then again to re-encrypt. # ── SYMMETRIC via passphrase file (for automation) ─────────────────── umask 077 printf '%s' 'my-long-diceware-passphrase' > ~/.config/recage/pass.txt chmod 600 ~/.config/recage/pass.txt export RECAGE_SYMMETRIC=1 export RECAGE_PASSPHRASE_FILE="$HOME/.config/recage/pass.txt" recage secrets.age recinf #+end_src * Critical Caveats About Symmetric Mode (The Honest Fine Print) Like knowing your combination padlock's limitations before trusting it with valuables: 1. /The interactive prompt is genuinely the most secure choice./ When =age= reads from =/dev/tty=, your passphrase touches /nothing/ persistent—not disk, not =ps=, not environment. Prefer it whenever a human is present. 2. /=age= reads passphrases from the terminal, not stdin, by default./ This is why the script includes a /fallback/: if the FIFO-piped passphrase isn't accepted by your =age= build (behaviour has varied across versions), it falls back to prompting you. This is a /safety net/, like a backup key—it ensures you're never locked out, but it may surprise you with a prompt during automation. Test your specific =age= version first. 3. /A passphrase file is a stored secret./ You've essentially /written your padlock combination on a card in your wallet/. It's convenient but only as safe as the file's =0600= permissions and the security of the machine. Never commit it to git; consider a secrets manager instead. 4. /Re-encryption in symmetric mode prompts twice interactively/ (once to decrypt, once to set the new passphrase), because =age -p= always confirms the passphrase when encrypting. With a passphrase file this is seamless. 5. /Mode is not auto-detected for encryption./ While =age -d= figures out the mode when /decrypting/, the script relies on /your/ =RECAGE_SYMMETRIC= / =RECAGE_IDENTITY= settings to know how to /re-encrypt/. Mixing them up would try to re-lock the diary with the wrong kind of lock—the script guards against this by using one consistent mode per invocation. --- * Suggested Follow-Up Question for Deeper Exploration > /"How can I make =recage= robustly auto-detect whether a given =.age= file was encrypted symmetrically or asymmetrically by inspecting its header (the =age-encryption.org/v1= stanzas like =scrypt= vs =X25519=), so users don't have to set =RECAGE_SYMMETRIC= manually—and what are the security trade-offs of trusting that header?"/ This dives into the /age file format internals/: a symmetric file contains an =scrypt= recipient stanza, while asymmetric files contain =X25519= stanzas. Parsing the ASCII-armored or binary header would let the script pick the right re-encryption mode automatically—but you'd explore why /trusting attacker-controllable header bytes/ to choose cryptographic behaviour requires careful validation. *** I would like recage to be able to detect whether the file was encrypted symmetrically or asymmetrically automatically. - Auto-Detecting the Encryption Mode * How age Files Reveal Their Lock Type (Explained Simply) Every =age= file starts with a small /header/—think of it like the /label on a locked box/ that says what kind of lock it uses. You don't need the key to /read the label/; you just look at it. The header lists one or more /recipient stanzas/, each naming a locking method: | Stanza keyword | Lock type | What it means | |----------------|-----------|---------------| | =scrypt= | Symmetric | A /passphrase/ padlock (=scrypt= is the algorithm that turns your passphrase into a key) | | =X25519= | Asymmetric | A /key-pair/ mailbox lock | | =ssh-rsa= / =ssh-ed25519= | Asymmetric | An /SSH-key/ variant of the mailbox lock | /The key insight:/ an =scrypt= file is /special/—the age spec /requires/ it to be the /only/ recipient. So the rule is simple: /if you see =scrypt=, it's symmetric; anything else is asymmetric./ * The Two Header Formats (A Small Complication) =age= files come in two "envelopes," like a letter that's either /sealed plainly/ or /written in a printable code/: 1. /Binary/ format: header begins literally with =age-encryption.org/v1=. 2. /ASCII-armored/ format (=-a= flag): the whole file is wrapped in =-----BEGIN AGE ENCRYPTED FILE-----= and Base64-encoded, like a PEM certificate. The real header is /hidden inside the Base64/. Our detector must handle /both/—for armored files, we peek by /decoding just the first chunk/ of Base64, like /unfolding only the top of a letter/ to read its heading without opening the whole thing. * The Detection Function Here is the self-contained detector to add to =recage=: #+begin_src bash # --------------------------------------------------------------------------- # 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 } #+end_src * Wiring It Into the Mode Selection Replace the earlier "decide encryption mode" block (step 1) with this /auto-detect-first, user-override-second/ logic: #+begin_src bash # --------------------------------------------------------------------------- # 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 #+end_src Everything else in the script (secure workdir, cleanup, =decrypt_to=, =encrypt_from=, dispatch) stays exactly as before. * Now Detection Is Automatic #+begin_src bash # No mode flags needed — recage reads the label and figures it out: export RECAGE_IDENTITY="$HOME/.config/recage/key.txt" recage contacts.age recsel -p Name # header shows X25519 -> asymmetric recage secrets.age recinf # header shows scrypt -> symmetric # (prompts for passphrase) # Override only if you ever need to (e.g. an unusual custom plugin stanza): RECAGE_ASYMMETRIC=1 recage weird.age recinf #+end_src * Important Security Caveats (The Honest Fine Print) You correctly sensed in our earlier discussion that /trusting header bytes/ deserves scrutiny. Here's the nuanced truth: 1. /The header is /unauthenticated/ until you actually decrypt./ Think of the label on the box as written in /erasable ink by whoever sealed it/. An attacker who can swap your file could, in principle, change the label. /But/ here's why it's still safe for /our/ purpose: the label only decides /which key we try/, not whether decryption /succeeds/. If someone relabels an =X25519= file as =scrypt=, =age= will simply /fail to decrypt/ (wrong method) — you get an error, not a breach. The label can /misdirect/ you, but it /cannot trick =age= into accepting forged content/, because the actual ciphertext is protected by an authentication tag (=age= is authenticated encryption). It's like a padlock that /only opens with the genuine key/ regardless of what the label claims. 2. /The real danger would be if detection influenced /where a secret goes/, not just which lock we try./ Our detector only picks the /decryption method/. It never routes your passphrase or private key based on attacker data in a way that could leak them — =age= itself rejects the wrong key. This is why /keeping the override flags/ matters: they let /you/, the human, assert ground truth when needed. 3. /=scrypt= work-factor bombs./ A malicious symmetric file could specify an /enormous =scrypt= work factor/ in its header (like a padlock deliberately designed to take an hour to turn), causing =age= to burn CPU/RAM when you attempt decryption — a denial-of-service. Modern =age= caps this, but be wary of decrypting /untrusted/ files. Our detector doesn't decrypt, so /detection/ is safe; the risk is only at the actual =age -d= step, which you control. 4. /=base64 -d= vs =base64 -D=./ GNU (Ubuntu) uses =-d= to decode; BSD/macOS uses =-D=. The detector /tries both/, like carrying two adapter plugs so it works in either country. 5. /Detection reads only 4 KiB/, never the body. It /cannot/ tell whether your key or passphrase is correct — only /which family/ of lock is fitted. Correctness is proven only when =age -d= actually runs. --- * Suggested Follow-Up Question for Deeper Exploration > /"When re-encrypting an asymmetric file that originally had /multiple recipients/ (several =X25519= stanzas, e.g. a file shared with three colleagues), how can =recage= preserve /all/ the original recipients rather than collapsing to just my own key — and how would it discover those recipient public keys, given that age headers store only opaque per-recipient wrapped keys and /not/ the recipients' public identities?"/ This opens a genuinely deep and subtle corner of =age=: unlike some crypto systems, an age header /deliberately does not record who the recipients are/ (a privacy feature — the box doesn't list who has keys). So preserving multi-recipient access across an edit requires you to /maintain a separate recipients file/ as the source of truth. You'd explore the tension between /recipient privacy/ and /operational convenience/, and why =RECAGE_RECIPIENTS= becomes essential rather than optional for shared databases. *** I would like the script to look in the default location for age identity files: ~/.config/age/identity.txt as the default without having to export a new variable. Otherwise, prompt for the location of the identity.txt file. - Sensible Default Identity Location with Interactive Fallback - The Logic (Explained Simply) Think of finding your house key like a /checklist you run through in order/: 1. /Did I already tell you where it is?/ → Honor =RECAGE_IDENTITY= if set (explicit override always wins). 2. /Is it on the usual hook by the door?/ → Check the standard spot: =~/.config/age/identity.txt=. 3. /Neither? Then ask me./ → Prompt the user to type the path. This "look in the obvious place, then ask" pattern is exactly how well-behaved Unix tools work—like how =git= checks =~/.gitconfig= without you specifying it every time. One subtlety: prompting only makes sense when a /human is present/ (an interactive terminal). In a script or cron job, there's no one to answer, so we must /fail cleanly/ instead of hanging forever waiting for input—like a doorbell that gives up ringing if nobody's home rather than ringing all night. - The Identity-Resolution Function Add this function, then call it only when the mode is asymmetric: #+begin_src bash # --------------------------------------------------------------------------- # 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 < /dev/tty || die "no identity provided" # Expand a leading ~ to $HOME (read does not do this for us). case "$answer" in "~") answer="$HOME" ;; "~/"*) answer="$HOME/${answer#\~/}" ;; esac [ -n "$answer" ] || die "no identity provided" [ -f "$answer" ] || die "identity file '$answer' not found" RECAGE_IDENTITY="$answer" return 0 fi # Non-interactive and nothing found: fail clearly. die "no age identity found (looked at \$RECAGE_IDENTITY and $default_id); \ run interactively or set RECAGE_IDENTITY" } #+end_src - Wiring It Into the Mode Validation In the mode-validation =case= block (from the auto-detect version), the asymmetric branch becomes a single clean call: #+begin_src bash # Validate the requirements for the chosen mode. case "$MODE_CRYPT" in asymmetric) resolve_identity # ← replaces the old inline identity check ;; symmetric) : # passphrase supplied interactively or via RECAGE_PASSPHRASE_FILE ;; esac #+end_src That's the only change. Because =resolve_identity= sets the global =RECAGE_IDENTITY=, the existing =decrypt_to= and =resolve_recipients= functions keep working unchanged. - Behaviour Now #+begin_src bash # ── Case A: key in the default spot — zero configuration ───────────── # ~/.config/age/identity.txt exists recage contacts.age recsel -p Name,Email # → recage: auto-detected asymmetric encryption # → recage: using default identity /home/you/.config/age/identity.txt # ── Case B: explicit override still works ──────────────────────────── RECAGE_IDENTITY=/media/usb/backup-key.txt recage contacts.age recinf # ── Case C: no default, human present — you get asked ──────────────── recage contacts.age recsel -p Name # → recage: no identity at /home/you/.config/age/identity.txt # → recage: enter path to your age identity file: _ # ── Case D: no default, running in cron/pipe — fails cleanly ───────── echo "" | recage contacts.age recsel -p Name # no /dev/tty for prompt # → recage: no age identity found (...); run interactively or set RECAGE_IDENTITY # (exits non-zero instead of hanging) #+end_src - Important Details (The Honest Fine Print) 1. /Why read from =/dev/tty=, not stdin?/ Your recfile tools sometimes /consume stdin/ (for example, piping a query into =recsel=). If we prompted via =read= on normal stdin, we'd either /steal the tool's input/ or /read the pipe data as a filename/. Reading from =/dev/tty= is like /having a dedicated intercom to the person at the door/, separate from the mail slot where data comes in. This is the same technique =ssh= and =sudo= use to prompt for passwords even inside pipelines. 2. /Why check =[ -t 1 ]= (is stdout a terminal)?/ It's a proxy for "is a human watching?" Combined with =[ -r /dev/tty ]=, we avoid the classic bug where a background job /blocks forever/ on a prompt nobody can see—like a checkout kiosk freezing because it's waiting for a signature from an empty room. 3. /Manual =~= expansion./ The shell only expands =~= when /it/ writes the word, not when =read= captures typed text. So if you type =~/keys/id.txt=, the literal tilde would otherwise be treated as a folder named =~=. We expand it ourselves—like /translating a nickname into the person's full legal name/ before looking them up. 4. /=XDG_CONFIG_HOME= respected./ If you've relocated your config directory (some users set =XDG_CONFIG_HOME=), we follow it, only falling back to =~/.config= when it's unset. This respects the /XDG Base Directory/ convention that both macOS and Ubuntu tools increasingly honor. 5. /Symmetric mode is unaffected./ Identity resolution runs /only/ for asymmetric files, so passphrase-protected databases never trigger a spurious key prompt—the auto-detector already routed them correctly. --- - Suggested Follow-Up Question for Deeper Exploration > /"age supports /multiple identities in a single file/ and even /identity files that are themselves encrypted/ (e.g. a passphrase-protected identity, or one held on a YubiKey via the =age-plugin-yubikey=). How can =recage= gracefully handle an identity file that requires its /own/ unlock step, and how do I let =age= try /several/ candidate identity files (=-i key1 -i key2=) so one command works across keys stored in different places?"/ This leads into two rich areas: /chained unlocking/ (your private key is itself behind a lock, like a /key kept inside a smaller safe/), and /identity multiplexing/ (offering =age= a keyring of several identities and letting it find the one that fits—useful when the same database might be decryptable by your laptop key /or/ your hardware token). You'd explore how =age='s plugin architecture (=age-plugin-*=) extends identities to hardware-backed keys, and the UX challenge of prompting for those nested unlocks through =/dev/tty=. *** I think having the possibility of a RECAGE_PASSPHRASE_FILE is insecure. Let's remove that option. - Removing =RECAGE_PASSPHRASE_FILE= — Interactive-Only Passphrases - Why This Is the Right Call (Explained Simply) You've made a sound security judgment. Storing a passphrase in a file is like /writing your padlock combination on a sticky note/—no matter how carefully you hide the note (=chmod 600=), it now /exists as a physical artifact/ that can be: - Backed up accidentally (Time Machine, =rsync=, git) - Read by =root= or any process running as you - Recovered from disk forensically - Leaked in a directory listing shared by mistake By contrast, an /interactively typed passphrase/ is like /whispering the combination directly into the lock/—it lives only in the terminal and =age='s memory for the moments it's needed, then vanishes. This is genuinely the /gold standard/ for passphrase handling. The bonus: removing this option makes the script /simpler and easier to audit/, which is itself a security win. Less code means fewer places for bugs to hide—like a house with fewer doors has fewer locks to worry about. - What Gets Removed We delete three things entirely: 1. The =RECAGE_PASSPHRASE_FILE= permission-check block. 2. The =feed_passphrase_fifo= function and all FIFO plumbing. 3. The passphrase-file branches inside =decrypt_to= and =encrypt_from=. What remains for symmetric mode is a single, clean path: /let =age= prompt on the terminal/. - The Simplified Symmetric Primitives Replace the previous =decrypt_to= and =encrypt_from= with these: #+begin_src bash # --------------------------------------------------------------------------- # decrypt_to # Decrypts $DB into the given path using the detected mode. # Symmetric mode: age prompts for the passphrase on the terminal (/dev/tty). # --------------------------------------------------------------------------- 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) # age reads the passphrase from the controlling terminal. # Nothing is stored; nothing appears in argv or the environment. age -d -o "$out" "$DB" \ || die "decryption failed (wrong passphrase or corrupt db)" ;; esac } # --------------------------------------------------------------------------- # resolve_recipients — asymmetric re-encryption target(s). # --------------------------------------------------------------------------- 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 # Re-encrypts into $DB using the detected mode, then atomically replaces it. # Symmetric mode: age prompts for (and confirms) the passphrase interactively. # --------------------------------------------------------------------------- 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" } #+end_src - The Simplified Mode-Validation Block The =RECAGE_PASSPHRASE_FILE= permission check is gone entirely: #+begin_src bash # Validate the requirements for the chosen mode. case "$MODE_CRYPT" in asymmetric) resolve_identity ;; symmetric) # Passphrase is always supplied interactively by age itself. # Ensure a terminal exists, or age cannot prompt. [ -r /dev/tty ] \ || die "symmetric db needs an interactive terminal for the passphrase" ;; esac #+end_src Note the small but important addition: for symmetric files we now /verify a terminal exists/. Without =RECAGE_PASSPHRASE_FILE=, there's no non-interactive path, so we fail /clearly and early/ in a cron job rather than having =age= hang or error cryptically—like a vending machine that shows "CASH ONLY" up front instead of eating your card. - Updated Documentation Header Trim the environment section at the top of the script to reflect reality: #+begin_src bash # ── 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.) #+end_src - Behaviour Now #+begin_src bash # ── Symmetric: always prompts, never stores ───────────────────────── recage secrets.age recsel -p Login,Password # → recage: auto-detected symmetric encryption # → Enter passphrase: _ (typed at the terminal, echoed hidden) # ── Symmetric write: prompts to decrypt, then prompts + confirms to re-lock recage secrets.age recset -e "Login = 'admin'" -f Password -S 'newsecret' # → Enter passphrase: (to open) # → Enter passphrase: (to re-encrypt) # → Confirm passphrase: (age double-checks) # ── Symmetric in a non-interactive context: fails cleanly ─────────── recage secrets.age recinf < /dev/null # in a script with no tty # → recage: symmetric db needs an interactive terminal for the passphrase # (exits non-zero immediately) #+end_src - Important Details (The Honest Fine Print) 1. /A subtle trade-off you should know:/ removing the passphrase file means symmetric databases /cannot be used in unattended automation/ (cron, CI, systemd timers). This is a /deliberate/ security-for-convenience trade. If you /do/ later need automation, the /correct/ answer is /not/ to bring back the plaintext file—it's to switch that database to /asymmetric/ encryption, where the private key can be protected by the OS and hardware. Think of it as: /passphrases are for humans at keyboards; key files are for machines/. 2. /Re-encryption prompts twice in symmetric mode/, and that's unavoidable with =age -p= (once to unlock, once to set the new lock, plus a confirmation). This is =age='s design, not our script's—like a bank requiring you to re-enter your PIN when changing it. If this friction bothers you for frequent edits, asymmetric mode re-encrypts /silently/ with no prompt at all. 3. /The passphrase never touches our workdir./ Only the /decrypted recfile/ lives briefly in the RAM-backed workdir (=/dev/shm= on Linux). The passphrase itself flows /directly between your terminal and =age=/—the script never sees it, stores it, or logs it. This is the cleanest possible arrangement. 4. /Consistency between decrypt and re-encrypt./ In symmetric mode you must type the /same/ passphrase when re-encrypting that you used to decrypt—otherwise you'll silently change the database's passphrase. =age= can't warn you about this because, by design, it doesn't remember the old one. Type carefully, like /making sure you reset a combination lock to a number you'll remember/. --- - Suggested Follow-Up Question for Deeper Exploration > /"Since typing the same passphrase twice during a symmetric write invites the risk of /accidentally changing/ the database's passphrase (if I f-fat-finger the second entry), how could =recage= safely /verify/ that my re-encryption passphrase matches the original—/without/ ever storing the passphrase—perhaps by doing a throwaway test-decrypt of the freshly written file before committing it over the original?"/ This opens a genuinely elegant /verify-before-commit/ pattern: after re-encrypting to =db.age.new=, the script could immediately attempt to /decrypt that new file/ and compare its contents (via hash) to the plaintext it just encrypted. If they match, the new passphrase "works" and the file is sound; if the round-trip fails, you've caught a typo /before/ overwriting your only good copy. You'd explore the security nuance of /how many times a passphrase gets entered/, whether a round-trip verification leaks anything, and how to give the user a safety net without ever persisting the secret—turning a footgun into a guardrail. *** What does step 4 "Passphrase plumbing" do now that we have removed the RECAGE_PASSPHRASE_FILE variable? - Step 4 "Passphrase Plumbing" — Now Entirely Obsolete - Short Answer /It does nothing—it should be deleted completely./ The entire "Passphrase plumbing" block existed for /one and only one/ purpose: to feed a passphrase from =RECAGE_PASSPHRASE_FILE= into =age= through a named pipe (FIFO) without exposing it on the command line. Now that =RECAGE_PASSPHRASE_FILE= is gone, this machinery has /nothing to plumb/—like keeping the pipes for a sink you've torn out. Leaving it in would be /dead code/, and dead code is a security liability (it confuses auditors and can be accidentally reactivated later). - What Specifically Gets Removed Delete this /entire/ block (the old step 4): #+begin_src bash # --------------------------------------------------------------------------- # 4. Passphrase plumbing (symmetric mode only). ← DELETE ALL OF THIS # ... # --------------------------------------------------------------------------- PASS_FIFO="" feed_passphrase_fifo() { PASS_FIFO="$WORKDIR/pass.fifo" mkfifo -m 600 "$PASS_FIFO" ( cat "$RECAGE_PASSPHRASE_FILE" > "$PASS_FIFO" ) & } #+end_src - Why It's Safe to Remove (Explained Simply) Recall what that FIFO trick was solving. =age= won't accept a passphrase as a command-line argument (good—that would leak in =ps=). So to /automate/ symmetric decryption, we built a /secret back-channel/: a named pipe is like a /pneumatic tube between two rooms/—the passphrase file's contents got sucked through the tube into =age='s mouth, never appearing in public. But you correctly decided that /storing the passphrase in a file at all/ is the real danger. Once we removed the file, there's /nothing to send through the tube/. The only remaining way a passphrase reaches =age= is the way it was always meant to: /you type it at the terminal/, and =age= reads it directly from =/dev/tty=. No tube, no plumbing, no back-channel needed—=age= handles the terminal prompt entirely on its own. - Confirming Nothing Else Depended On It This is the important verification step—/pulling out a pipe is only safe if nothing else was connected to it/. Let's check the two former callers: 1. /=decrypt_to=/ — in the simplified version, the symmetric branch is now just: #+begin_src bash age -d -o "$out" "$DB" #+end_src No call to =feed_passphrase_fifo=. ✓ 2. /=encrypt_from=/ — the symmetric branch is now just: #+begin_src bash age -e -p -o "$newenc" "$src" #+end_src No call to =feed_passphrase_fifo=. ✓ Since /both/ former callers were already rewritten in the last step to prompt interactively, the =feed_passphrase_fifo= function is now referenced /nowhere/. It's a completely orphaned function—safe to delete with zero ripple effects. - Renumbering (Cosmetic but Tidy) With the old step 4 gone, the subsequent steps shift up by one. Your script's comment numbering becomes: | Old # | New # | Section | |-------+-------+--------------------------------------| | 0 | 0 | Preconditions & helpers | | 1 | 1 | Decide encryption mode (auto-detect) | | 2 | 2 | Secure RAM-backed working directory | | 3 | 3 | Robust secure cleanup | | /4/ | /—/ | /Passphrase plumbing (DELETED)/ | | 5 | 4 | Decrypt / encrypt primitives | | 6 | 5 | Classify the recfile tool | | 7 | 6 | Dispatch | This is purely cosmetic—the shell doesn't care about comment numbers—but renumbering keeps the script /readable/, like /re-numbering the pages after tearing one out of a binder/ so the table of contents still makes sense. - One Subtle Cleanup Bonus There's a quiet /security improvement/ hiding here. The old FIFO created a named pipe (=pass.fifo=) inside your workdir. Even though a FIFO holds no data at rest, its /existence/ in =/dev/shm= was one more artifact for the cleanup =trap= to handle and one more thing an observer could notice. Removing the plumbing means your workdir now contains /only/ the decrypted recfile and nothing else—a /smaller, cleaner footprint/, like a workspace with only the one document you're actually using on the desk. - Summary Step 4 "Passphrase plumbing" now does /nothing/ and is /unreferenced dead code/. The correct action is to /delete the entire block/, verify (as we did) that =decrypt_to= and =encrypt_from= no longer call it, and optionally renumber the following steps. The passphrase now flows the simplest and safest possible way: /straight from your fingers to =age= via the terminal/, with no intermediary machinery at all. --- - Suggested Follow-Up Question for Deeper Exploration > /"Now that the workdir contains only the decrypted recfile, could I eliminate even /that/ on-disk artifact for /read-only/ operations (=recsel=, =recinf=, =recfmt=) by using Bash /process substitution/—feeding =age -d= output directly as =<(...)= so the plaintext lives purely in a kernel pipe buffer and never becomes a file at all? What are the limits (e.g. tools that =seek()= or need a real path) that would force a fallback to the temp-file approach?"/ This dives into a /meaningful security refinement/: for reads, you may be able to skip the RAM-disk file entirely, since =recsel= and friends can often consume a stream. Process substitution (=recsel ... <(age -d ...)=) hands the tool a =/dev/fd/63=-style pseudo-path backed by a /kernel pipe/, so the plaintext never materializes as even a =/dev/shm= file. You'd explore /which recfile tools tolerate a non-seekable stream/ versus which /demand a real, rewindable file/ (writes and in-place edits certainly do), and how to detect and gracefully fall back—achieving the theoretical minimum on-disk exposure for the common read-only case. ***