Files
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

345 lines
11 KiB
Bash

#!/usr/bin/env bash
#
# recage — securely query/edit age-encrypted recfile databases
# (supports BOTH symmetric [passphrase] and asymmetric [keypair])
#
# Usage:
# recage <db.age> <tool> [args...]
#
# Tools: recsel recinf recfmt recset recfix edit
#
## ── Encryption mode is AUTO-DETECTED from the file header ───────────────────
# Override with RECAGE_SYMMETRIC=1 or RECAGE_ASYMMETRIC=1 if ever needed.
#
# ASYMMETRIC files:
# RECAGE_IDENTITY path to age identity file (private key).
# Defaults to ~/.config/age/identity.txt, else prompts.
# RECAGE_RECIPIENTS recipients file for re-encryption (optional;
# derived from the identity if omitted).
#
# SYMMETRIC files:
# Passphrase is ALWAYS entered interactively at the terminal.
# (No passphrase-file or environment-variable option exists, by design.)
#
set -euo pipefail
# ---------------------------------------------------------------------------
# 0. Preconditions & helpers
# ---------------------------------------------------------------------------
die() {
printf 'recage: %s\n' "$*" >&2
exit 1
}
command -v age >/dev/null 2>&1 || die "'age' not found in PATH"
DB="${1:-}"
TOOL="${2:-}"
[ -n "$DB" ] || die "no database file given"
[ -n "$TOOL" ] || die "no tool given (recsel, recset, recfix, recinf, recfmt, edit)"
shift 2
ARGS=("$@")
[ -f "$DB" ] || die "database '$DB' not found"
# ---------------------------------------------------------------------------
# detect_age_mode <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
}
# ---------------------------------------------------------------------------
# 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"
}
# ---------------------------------------------------------------------------
# 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)
resolve_identity
;;
symmetric)
# Passphrase supplied interactively.
# Ensure a terminal exists, or age cannot prompt.
[ -r /dev/tty ] ||
die "symmetric db needs an interactive terminal for the passphrase"
;;
esac
# ---------------------------------------------------------------------------
# 2. Secure RAM-backed working directory.
# ---------------------------------------------------------------------------
pick_secure_tmpdir() {
local base
if [ -d /dev/shm ] && [ -w /dev/shm ]; then
base=/dev/shm # Linux RAM disk
else
base="${TMPDIR:-/tmp}" # macOS fallback (see caveats)
fi
mktemp -d "${base%/}/recage.XXXXXXXX"
}
WORKDIR="$(pick_secure_tmpdir)"
chmod 700 "$WORKDIR"
# ---------------------------------------------------------------------------
# 3. Robust secure cleanup on ANY exit.
# ---------------------------------------------------------------------------
cleanup() {
local f
if [ -d "$WORKDIR" ]; then
find "$WORKDIR" -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do
if command -v shred >/dev/null 2>&1; then
shred -u "$f" 2>/dev/null || rm -f "$f"
elif rm -P "$f" 2>/dev/null; then
:
else
rm -f "$f"
fi
done
rm -rf "$WORKDIR"
fi
}
trap cleanup EXIT INT TERM HUP
# ---------------------------------------------------------------------------
# 4. Decrypt / encrypt primitives for each mode.
# ---------------------------------------------------------------------------
decrypt_to() {
local out="$1"
case "$MODE_CRYPT" in
asymmetric)
age -d -i "$RECAGE_IDENTITY" -o "$out" "$DB" ||
die "decryption failed (wrong key or corrupted db)"
;;
symmetric)
# Fully interactive: age prompts on /dev/tty — most secure.
age -d -o "$out" "$DB" ||
die "decryption failed (wrong passphrase or corrupt db)"
;;
esac
}
resolve_recipients() {
if [ -n "${RECAGE_RECIPIENTS:-}" ]; then
[ -f "$RECAGE_RECIPIENTS" ] || die "recipients file not found"
printf -- '-R\n%s\n' "$RECAGE_RECIPIENTS"
else
local pub
pub="$(age-keygen -y "$RECAGE_IDENTITY" 2>/dev/null)" ||
die "cannot derive recipient; set RECAGE_RECIPIENTS"
printf -- '-r\n%s\n' "$pub"
fi
}
encrypt_from() {
local src="$1"
local newenc="$WORKDIR/db.age.new"
case "$MODE_CRYPT" in
asymmetric)
# shellcheck disable=SC2046
age -e $(resolve_recipients) -o "$newenc" "$src" ||
die "re-encryption failed; original DB untouched"
;;
symmetric)
# age -p prompts for the passphrase and asks you to confirm it.
age -e -p -o "$newenc" "$src" ||
die "re-encryption failed; original DB untouched"
;;
esac
chmod --reference="$DB" "$newenc" 2>/dev/null || chmod 600 "$newenc"
mv -f "$newenc" "$DB"
}
# ---------------------------------------------------------------------------
# 5. Classify the recfile tool.
# ---------------------------------------------------------------------------
case "$TOOL" in
recsel | recinf | recfmt) MODE="read" ;;
recset | recfix) MODE="write" ;;
edit) MODE="edit" ;;
*) die "unsupported tool '$TOOL'" ;;
esac
[ "$MODE" = "edit" ] || command -v "$TOOL" >/dev/null 2>&1 ||
die "'$TOOL' not found in PATH"
PLAINTEXT="$WORKDIR/db.rec"
(
umask 077
: >"$PLAINTEXT"
)
decrypt_to "$PLAINTEXT"
# ---------------------------------------------------------------------------
# 6. Dispatch.
# ---------------------------------------------------------------------------
case "$MODE" in
read)
"$TOOL" "${ARGS[@]}" "$PLAINTEXT"
;;
write)
BEFORE="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')"
"$TOOL" "${ARGS[@]}" "$PLAINTEXT"
AFTER="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')"
[ "$BEFORE" != "$AFTER" ] && {
encrypt_from "$PLAINTEXT"
printf 'recage: changes re-encrypted into %s\n' "$DB" >&2
}
;;
edit)
: "${EDITOR:=vi}"
BEFORE="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')"
"$EDITOR" "$PLAINTEXT"
command -v recfix >/dev/null 2>&1 && {
recfix --check "$PLAINTEXT" || die "recfix check failed; NOT saving"
}
AFTER="$(shasum -a 256 "$PLAINTEXT" | awk '{print $1}')"
if [ "$BEFORE" != "$AFTER" ]; then
encrypt_from "$PLAINTEXT"
printf 'recage: edits re-encrypted into %s\n' "$DB" >&2
else
printf 'recage: no changes.\n' >&2
fi
;;
esac