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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Ignore these MacOS files
|
||||
.DS_Store
|
||||
@@ -0,0 +1,344 @@
|
||||
#!/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
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user