#!/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
