diff --git a/secrets b/secrets index 77ee0bf..5711842 100755 --- a/secrets +++ b/secrets @@ -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 "--- " 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 was +# encrypted to exactly 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 ── diff --git a/test/recipients.bats b/test/recipients.bats index 433d46a..c02888c 100644 --- a/test/recipients.bats +++ b/test/recipients.bats @@ -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 +}