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:
parent
ae52b8c077
commit
e4cd524483
2 changed files with 70 additions and 0 deletions
47
secrets
47
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 "--- <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 ──
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue