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.
59 KiB
- 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
- Understanding the Two Modes (Explained Simply)
- The Key Security Challenge with Passphrases
- The Updated Script
- Usage Examples for Both Modes
- Critical Caveats About Symmetric Mode (The Honest Fine Print)
- Suggested Follow-Up Question for Deeper Exploration
- How age Files Reveal Their Lock Type (Explained Simply)
- The Two Header Formats (A Small Complication)
- The Detection Function
- Wiring It Into the Mode Selection
- Now Detection Is Automatic
- Important Security Caveats (The Honest Fine Print)
- Suggested Follow-Up Question for Deeper Exploration
- 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.
- I think having the possibility of a RECAGE_PASSPHRASE_FILE is insecure. Let's remove that option.
- What does step 4 "Passphrase plumbing" do now that we have removed the RECAGE_PASSPHRASE_FILE variable?
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:
- Opens the safe (decrypts with
age) - Hands the document to the accountant (pipes to
rec*tools) - Never leaves the document lying on the desk (avoids writing plaintext to disk)
- 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
#!/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.
How to Use It
# 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
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:
- 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/tmpon 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). recfix=/=recsetedit in-place, so they need a real file, not a pipe. This is why we can't use pure process substitution for writes.- 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.
- 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:
- Best: Let
ageprompt interactively (passphrase stays in the terminal only). - Acceptable: Read passphrase from a file descriptor or a 0600 file the script feeds to
agevia a temporary named pipe /ttyredirection—*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
#!/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
Usage Examples for Both Modes
# ── 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
Critical Caveats About Symmetric Mode (The Honest Fine Print)
Like knowing your combination padlock's limitations before trusting it with valuables:
- The interactive prompt is genuinely the most secure choice. When
agereads from/dev/tty, your passphrase touches nothing persistent—not disk, notps, not environment. Prefer it whenever a human is present. agereads 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 youragebuild (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 specificageversion first.- 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
0600permissions and the security of the machine. Never commit it to git; consider a secrets manager instead. - Re-encryption in symmetric mode prompts twice interactively (once to decrypt, once to set the new passphrase), because
age -palways confirms the passphrase when encrypting. With a passphrase file this is seamless. - Mode is not auto-detected for encryption. While
age -dfigures out the mode when decrypting, the script relies on yourRECAGE_SYMMETRIC/RECAGE_IDENTITYsettings 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:
- Binary format: header begins literally with
age-encryption.org/v1. - ASCII-armored format (
-aflag): 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:
# ---------------------------------------------------------------------------
# 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
}
Wiring It Into the Mode Selection
Replace the earlier "decide encryption mode" block (step 1) with this auto-detect-first, user-override-second logic:
# ---------------------------------------------------------------------------
# 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
Everything else in the script (secure workdir, cleanup, decrypt_to,
encrypt_from, dispatch) stays exactly as before.
Now Detection Is Automatic
# 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
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:
- 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
X25519file asscrypt,agewill simply fail to decrypt (wrong method) — you get an error, not a breach. The label can misdirect you, but it cannot trickageinto accepting forged content, because the actual ciphertext is protected by an authentication tag (ageis authenticated encryption). It's like a padlock that only opens with the genuine key regardless of what the label claims. - 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 —
ageitself rejects the wrong key. This is why keeping the override flags matters: they let you, the human, assert ground truth when needed. scryptwork-factor bombs. A malicious symmetric file could specify an enormousscryptwork factor in its header (like a padlock deliberately designed to take an hour to turn), causingageto burn CPU/RAM when you attempt decryption — a denial-of-service. Modernagecaps this, but be wary of decrypting untrusted files. Our detector doesn't decrypt, so detection is safe; the risk is only at the actualage -dstep, which you control.base64 -dvsbase64 -D. GNU (Ubuntu) uses-dto decode; BSD/macOS uses-D. The detector tries both, like carrying two adapter plugs so it works in either country.- 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 -dactually 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:
- Did I already tell you where it is? → Honor
RECAGE_IDENTITYif set (explicit override always wins). - Is it on the usual hook by the door? → Check the standard spot:
~/.config/age/identity.txt. - 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:
# ---------------------------------------------------------------------------
# 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"
}
- 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:
# 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
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
# ── 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)
- Important Details (The Honest Fine Print)
- Why read from
/dev/tty, not stdin? Your recfile tools sometimes consume stdin (for example, piping a query intorecsel). If we prompted viareadon normal stdin, we'd either steal the tool's input or read the pipe data as a filename. Reading from/dev/ttyis like having a dedicated intercom to the person at the door, separate from the mail slot where data comes in. This is the same techniquesshandsudouse to prompt for passwords even inside pipelines. - 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. - Manual
~expansion. The shell only expands~when it writes the word, not whenreadcaptures 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. XDG_CONFIG_HOMErespected. If you've relocated your config directory (some users setXDG_CONFIG_HOME), we follow it, only falling back to~/.configwhen it's unset. This respects the XDG Base Directory convention that both macOS and Ubuntu tools increasingly honor.- 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
rootor 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:
- The
RECAGE_PASSPHRASE_FILEpermission-check block. - The
feed_passphrase_fifofunction and all FIFO plumbing. - The passphrase-file branches inside
decrypt_toandencrypt_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:
# ---------------------------------------------------------------------------
# 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"
}
- The Simplified Mode-Validation Block
The RECAGE_PASSPHRASE_FILE permission check is gone entirely:
# 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
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:
# ── 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.)
- Behaviour Now
# ── 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)
- Important Details (The Honest Fine Print)
- 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.
- 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 isage'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. - The passphrase never touches our workdir. Only the decrypted recfile lives briefly in the RAM-backed workdir (
/dev/shmon 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. - 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.
agecan'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):
# ---------------------------------------------------------------------------
# 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" ) &
}
- 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:
-
decrypt_to— in the simplified version, the symmetric branch is now just:age -d -o "$out" "$DB"No call to
feed_passphrase_fifo. ✓ -
encrypt_from— the symmetric branch is now just:age -e -p -o "$newenc" "$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.
*