72069fe99b
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.
345 lines
11 KiB
Bash
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
|