diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbeeb37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Ignore these MacOS files +.DS_Store diff --git a/recage b/recage new file mode 100644 index 0000000..5cece70 --- /dev/null +++ b/recage @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +# +# recage — securely query/edit age-encrypted recfile databases +# (supports BOTH symmetric [passphrase] and asymmetric [keypair]) +# +# Usage: +# recage [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 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" + +# --------------------------------------------------------------------------- +# detect_age_mode +# +# 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 +} + +# --------------------------------------------------------------------------- +# 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 + +# --------------------------------------------------------------------------- +# 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" "$DB" <"$PASS_FIFO" 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" "$src" <"$PASS_FIFO" 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