feat: verify asserts blob recipient-count matches recipients.txt (EGB-283)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-24 13:10:53 -07:00
parent ae52b8c077
commit e4cd524483
2 changed files with 70 additions and 0 deletions

47
secrets
View file

@ -2266,12 +2266,45 @@ _verify_blob_decrypts() {
age -d -i "$KEY_FILE" "$1" >/dev/null 2>&1
}
# Count age recipient stanzas ("-> ...") in a blob's header. The age v1 header
# is ASCII and ends at the "--- <mac>" line, so reading line-by-line stops
# before any binary body. Echoes the count.
_blob_recipient_count() {
local f="$1" line count=0
while IFS= read -r line; do
case "$line" in
'--- '*) break ;;
'-> '*) count=$((count + 1)) ;;
esac
done < "$f"
echo "$count"
}
# If the store is multi-recipient (expected non-empty), assert <blob> was
# encrypted to exactly <expected> recipients. Echoes a FINDING and returns 1 on
# mismatch; returns 0 otherwise (incl. legacy stores where expected is empty).
_check_blob_recipient_count() {
local blob="$1" rel="$2" expected="$3"
[ -n "$expected" ] || return 0
local actual; actual=$(_blob_recipient_count "$blob")
if [ "$actual" != "$expected" ]; then
echo "FINDING: $rel is encrypted to $actual recipient(s) but $RECIPIENTS_FILE_NAME has $expected — run 'secrets reencrypt'." >&2
return 1
fi
return 0
}
# `secrets verify --all` — store-wide decrypt sweep. Decrypt-tests every blob
# in every project. No manifest consistency check: the store carries only
# ciphertext (manifests live in each project's repo), so orphan/missing
# detection is impossible store-wide. This is the migration integrity gate.
_verify_all() {
local checked=0 failed=0 dir project f rel
local rexpected=""
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
_load_recipients # validates; dies on a bad recipients.txt
rexpected=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
fi
for dir in "$SECRETS_DIR"/*/; do
[ -d "$dir" ] || continue
project=$(basename "$dir")
@ -2287,6 +2320,9 @@ _verify_all() {
echo "FAIL: $rel does not decrypt with the current key." >&2
failed=$((failed + 1))
fi
if ! _check_blob_recipient_count "$f" "${f#"$SECRETS_DIR"/}" "$rexpected"; then
failed=$((failed + 1))
fi
done < <(find "$dir" -type f -name '*.age')
done
if [ "$checked" -eq 0 ]; then
@ -2319,6 +2355,11 @@ _verify_project() {
local pdir="$SECRETS_DIR/$project"
local findings=0 checked=0
local rexpected=""
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
_load_recipients # validates; dies on a bad recipients.txt
rexpected=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
fi
# `expected` accumulates the store-relative blob paths the manifest implies,
# newline-framed (leading + trailing \n per entry) so the orphan walk can
# test membership. bash 3.2 has no associative arrays — this string-set +
@ -2349,6 +2390,9 @@ _verify_project() {
echo "FINDING: blob for '$rel' ($project/$rel.age) does not decrypt with the current key." >&2
findings=$((findings + 1))
fi
if ! _check_blob_recipient_count "$blob" "$project/$rel.age" "$rexpected"; then
findings=$((findings + 1))
fi
done < <(jq -r '.dotenv // [] | .[]' "$manifest")
# ── external entries: missing-blob + decrypt ──
@ -2375,6 +2419,9 @@ _verify_project() {
echo "FINDING: external blob for '$epath' ($project/$erel) does not decrypt with the current key." >&2
findings=$((findings + 1))
fi
if ! _check_blob_recipient_count "$eblob" "$project/$erel" "$rexpected"; then
findings=$((findings + 1))
fi
done < <(_json_external_entries "$manifest")
# ── orphan detection: any stored blob the manifest doesn't account for ──

View file

@ -267,3 +267,26 @@ make_second_identity() {
[ "$status" -eq 0 ]
[[ "$output" == *"recipients: single-key"* ]] || false
}
@test "verify --all passes on a healthy multi-recipient store" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # re-encrypts to 2
run "$SECRETS_BIN" verify --all
[ "$status" -eq 0 ]
}
@test "verify flags a blob whose recipient count drifted" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push # single-key blob (1 stanza)
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
# Declare 2 recipients but do NOT re-encrypt — the on-disk blob still has 1.
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" verify --all
[ "$status" -ne 0 ]
[[ "$output" == *"recipient"* ]] || false
}