Files
zrec/recage-claude-opus.org
billmanz 72069fe99b 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 08:11:35 -04:00

1168 lines
59 KiB
Org Mode

: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 <db.age> recsel [args...] # read-only query
# recage <db.age> recinf [args...] # read-only info
# recage <db.age> recfmt [args...] # read-only format
# recage <db.age> recfix [args...] # may modify (re-encrypts)
# recage <db.age> recset [args...] # modifies (re-encrypts)
# recage <db.age> 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 <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"
# ---------------------------------------------------------------------------
# 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 <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
}
#+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 <output-file>
# 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 <plaintext-file>
# 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.
***