Creation of zrec-age, bug fix to zrec.

Fixed up the $mand bug in zrec. zrec-age seems to work in testing.
I still wouldn't keep something mission critical in there, but I
will start storing info in the patient database. I think
incorporating onepassword cli for password prompts could be very
useful too.
This commit is contained in:
billmanz
2026-06-30 20:12:12 -04:00
parent 4894ada68f
commit 5052677fd1
3 changed files with 1267 additions and 416 deletions
Executable
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env bash
#
# zrec-age - Encrypted-recfile wrapper around zrec.
#
# Decrypts an age-encrypted recfile to a temporary plaintext file, runs zrec
# against it, then re-encrypts the result back to the original location.
#
# Usage:
# zrec-age [options] <encrypted-recfile> [record-type]
#
# Options:
# -i <identity-file> age identity (private key) file for DECRYPTION.
# May be repeated. (Implies key-based mode.)
# -r <recipient> age recipient (public key) for ENCRYPTION.
# May be repeated. (Implies key-based mode.)
# -R <recipients-file> File containing one recipient per line.
# -p Use passphrase (symmetric) mode for both
# decrypt and encrypt. (Default if no -i/-r/-R given.)
# -z <path-to-zrec> Path to the zrec script (default: looks on PATH,
# then alongside this script).
# -h Show this help.
#
# Environment:
# ZREC_BIN Overrides the zrec location (same as -z).
#
# Notes:
# * In passphrase mode you will be prompted up to three times by age
# (decrypt, then encrypt). This is unavoidable with symmetric age.
# * Plaintext only ever lives in a 0700 temp directory and is removed
# (best-effort shredded) on exit.
#
set -o pipefail
# ---------------------------------------------------------------------------
# Globals
# ---------------------------------------------------------------------------
PROG="$(basename "$0")"
WORKDIR=""
PLAINTEXT=""
PASSPHRASE_MODE="auto" # auto | yes | no
declare -a IDENTITIES=()
declare -a RECIPIENTS=()
declare -a RECIPIENT_FILES=()
ZREC_BIN="${ZREC_BIN:-}"
# ---------------------------------------------------------------------------
# Logging helpers (all to stderr; stdout stays clean)
# ---------------------------------------------------------------------------
log() { printf '%s\n' "$*" >&2; }
err() { printf 'Error: %s\n' "$PROG" "$*" >&2; }
die() {
err "$*"
exit 1
}
usage() {
sed -n '3,40p' "$0" | sed 's/^# \{0,1\}//' >&2
exit "${1:-1}"
}
# ---------------------------------------------------------------------------
# Dependency checks
# ---------------------------------------------------------------------------
check_deps() {
command -v age >/dev/null 2>&1 || die "'age' not found. Install it (brew install age / apt install age)."
if [ -z "$ZREC_BIN" ]; then
if command -v zrec >/dev/null 2>&1; then
ZREC_BIN="$(command -v zrec)"
else
# Fall back to a zrec sitting next to this wrapper.
local self_dir
self_dir="$(cd "$(dirname "$0")" && pwd)"
if [ -x "$self_dir/zrec" ]; then
ZREC_BIN="$self_dir/zrec"
fi
fi
fi
[ -n "$ZREC_BIN" ] && [ -x "$ZREC_BIN" ] ||
die "Could not find an executable 'zrec' (use -z or set ZREC_BIN)."
}
# ---------------------------------------------------------------------------
# Cross-platform secure temp dir creation.
# ---------------------------------------------------------------------------
make_workdir() {
# mktemp -d differs slightly across platforms but -d <template> is portable.
WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/zrec-age.XXXXXX")" ||
die "Failed to create temporary directory."
chmod 700 "$WORKDIR"
}
# ---------------------------------------------------------------------------
# Best-effort secure delete (cross-platform).
# ---------------------------------------------------------------------------
secure_rm() {
local f="$1"
[ -f "$f" ] || return 0
if command -v shred >/dev/null 2>&1; then
shred -u "$f" 2>/dev/null && return 0
fi
# macOS has no shred; overwrite once then unlink as a fallback.
if command -v dd >/dev/null 2>&1; then
local size
size="$(wc -c <"$f" 2>/dev/null | tr -d ' ')"
if [ -n "$size" ] && [ "$size" -gt 0 ]; then
dd if=/dev/urandom of="$f" bs="$size" count=1 conv=notrunc 2>/dev/null || true
fi
fi
rm -f "$f"
}
# ---------------------------------------------------------------------------
# Cleanup trap: shred plaintext, remove workdir. Runs on ANY exit.
# ---------------------------------------------------------------------------
cleanup() {
[ -n "$PLAINTEXT" ] && secure_rm "$PLAINTEXT"
[ -n "$WORKDIR" ] && [ -d "$WORKDIR" ] && rm -rf "$WORKDIR"
}
trap cleanup EXIT INT TERM HUP
# ---------------------------------------------------------------------------
# Build the age recipient argument array for encryption.
# Echoes nothing; populates a global array via name. We just assemble here.
# ---------------------------------------------------------------------------
build_recipient_args() {
AGE_ENC_ARGS=()
local r f
for r in "${RECIPIENTS[@]}"; do
AGE_ENC_ARGS+=(-r "$r")
done
for f in "${RECIPIENT_FILES[@]}"; do
[ -f "$f" ] || die "Recipients file not found: $f"
AGE_ENC_ARGS+=(-R "$f")
done
}
# ---------------------------------------------------------------------------
# Decrypt encrypted file -> $PLAINTEXT
# ---------------------------------------------------------------------------
decrypt_file() {
local encfile="$1"
if [ "$PASSPHRASE_MODE" = "yes" ]; then
log "Decrypting (passphrase mode)..."
age --decrypt -o "$PLAINTEXT" "$encfile" ||
die "Decryption failed (wrong passphrase or corrupt file?)."
else
local -a id_args=()
local id
for id in "${IDENTITIES[@]}"; do
[ -f "$id" ] || die "Identity file not found: $id"
id_args+=(-i "$id")
done
[ "${#id_args[@]}" -gt 0 ] || die "Key-based decryption requires at least one -i <identity>."
log "Decrypting (key mode)..."
age --decrypt "${id_args[@]}" -o "$PLAINTEXT" "$encfile" ||
die "Decryption failed (wrong identity or corrupt file?)."
fi
}
# ---------------------------------------------------------------------------
# Encrypt $PLAINTEXT -> encrypted file (atomic replace via temp + mv).
# ---------------------------------------------------------------------------
encrypt_file() {
local encfile="$1"
local tmp_enc="$WORKDIR/out.age"
if [ "$PASSPHRASE_MODE" = "yes" ]; then
log "Re-encrypting (passphrase mode)..."
age --passphrase -o "$tmp_enc" "$PLAINTEXT" ||
die "Re-encryption failed; original file left UNCHANGED."
else
build_recipient_args
[ "${#AGE_ENC_ARGS[@]}" -gt 0 ] ||
die "Key-based encryption requires at least one -r/-R recipient."
log "Re-encrypting (key mode)..."
age --encrypt "${AGE_ENC_ARGS[@]}" -o "$tmp_enc" "$PLAINTEXT" ||
die "Re-encryption failed; original file left UNCHANGED."
fi
# Preserve original permissions where possible.
if [ -f "$encfile" ]; then
# Copy mode from the original encrypted file onto the new ciphertext.
if command -v stat >/dev/null 2>&1; then
local mode
# GNU stat vs BSD stat.
mode="$(stat -c '%a' "$encfile" 2>/dev/null || stat -f '%Lp' "$encfile" 2>/dev/null)"
[ -n "$mode" ] && chmod "$mode" "$tmp_enc" 2>/dev/null || true
fi
fi
# Atomic replace.
mv -f "$tmp_enc" "$encfile" ||
die "Failed to move new ciphertext into place; original may be intact, check $tmp_enc."
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
parse_args() {
while getopts ":i:r:R:pz:h" opt; do
case "$opt" in
i)
IDENTITIES+=("$OPTARG")
PASSPHRASE_MODE="no"
;;
r)
RECIPIENTS+=("$OPTARG")
PASSPHRASE_MODE="no"
;;
R)
RECIPIENT_FILES+=("$OPTARG")
PASSPHRASE_MODE="no"
;;
p) PASSPHRASE_MODE="yes" ;;
z) ZREC_BIN="$OPTARG" ;;
h) usage 0 ;;
\?) die "Unknown option: -$OPTARG" ;;
:) die "Option -$OPTARG requires an argument." ;;
esac
done
shift $((OPTIND - 1))
[ "$#" -ge 1 ] || usage 1
ENCFILE="$1"
shift
# Remaining args (record-type) get forwarded to zrec verbatim.
ZREC_EXTRA_ARGS=("$@")
# Resolve auto mode -> passphrase if no keys supplied.
[ "$PASSPHRASE_MODE" = "auto" ] && PASSPHRASE_MODE="yes"
# Sanity: in key mode, ensure we can both decrypt and re-encrypt.
if [ "$PASSPHRASE_MODE" = "no" ]; then
[ "${#IDENTITIES[@]}" -gt 0 ] ||
die "Key mode: provide -i <identity> for decryption."
if [ "${#RECIPIENTS[@]}" -eq 0 ] && [ "${#RECIPIENT_FILES[@]}" -eq 0 ]; then
die "Key mode: provide -r/-R recipient(s) for re-encryption."
fi
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
parse_args "$@"
check_deps
[ -f "$ENCFILE" ] || die "Encrypted recfile '$ENCFILE' does not exist."
[ -r "$ENCFILE" ] || die "Encrypted recfile '$ENCFILE' is not readable."
make_workdir
PLAINTEXT="$WORKDIR/recfile.rec"
decrypt_file "$ENCFILE"
# Snapshot to detect whether zrec actually changed anything.
local before_sum after_sum
before_sum="$(cksum <"$PLAINTEXT")"
log ""
log "Launching zrec on decrypted copy..."
log "----------------------------------------"
# Run zrec interactively against the plaintext temp file.
if "$ZREC_BIN" "$PLAINTEXT" "${ZREC_EXTRA_ARGS[@]}"; then
after_sum="$(cksum <"$PLAINTEXT")"
if [ "$before_sum" = "$after_sum" ]; then
log "No changes detected; skipping re-encryption."
else
encrypt_file "$ENCFILE"
log "Encrypted recfile updated: $ENCFILE"
fi
else
local rc=$?
err "zrec exited with status $rc; original encrypted file left UNCHANGED."
exit "$rc"
fi
}
main "$@"