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
|
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
|
# `secrets verify --all` — store-wide decrypt sweep. Decrypt-tests every blob
|
||||||
# in every project. No manifest consistency check: the store carries only
|
# in every project. No manifest consistency check: the store carries only
|
||||||
# ciphertext (manifests live in each project's repo), so orphan/missing
|
# ciphertext (manifests live in each project's repo), so orphan/missing
|
||||||
# detection is impossible store-wide. This is the migration integrity gate.
|
# detection is impossible store-wide. This is the migration integrity gate.
|
||||||
_verify_all() {
|
_verify_all() {
|
||||||
local checked=0 failed=0 dir project f rel
|
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
|
for dir in "$SECRETS_DIR"/*/; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
project=$(basename "$dir")
|
project=$(basename "$dir")
|
||||||
|
|
@ -2287,6 +2320,9 @@ _verify_all() {
|
||||||
echo "FAIL: $rel does not decrypt with the current key." >&2
|
echo "FAIL: $rel does not decrypt with the current key." >&2
|
||||||
failed=$((failed + 1))
|
failed=$((failed + 1))
|
||||||
fi
|
fi
|
||||||
|
if ! _check_blob_recipient_count "$f" "${f#"$SECRETS_DIR"/}" "$rexpected"; then
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
done < <(find "$dir" -type f -name '*.age')
|
done < <(find "$dir" -type f -name '*.age')
|
||||||
done
|
done
|
||||||
if [ "$checked" -eq 0 ]; then
|
if [ "$checked" -eq 0 ]; then
|
||||||
|
|
@ -2319,6 +2355,11 @@ _verify_project() {
|
||||||
local pdir="$SECRETS_DIR/$project"
|
local pdir="$SECRETS_DIR/$project"
|
||||||
|
|
||||||
local findings=0 checked=0
|
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,
|
# `expected` accumulates the store-relative blob paths the manifest implies,
|
||||||
# newline-framed (leading + trailing \n per entry) so the orphan walk can
|
# newline-framed (leading + trailing \n per entry) so the orphan walk can
|
||||||
# test membership. bash 3.2 has no associative arrays — this string-set +
|
# 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
|
echo "FINDING: blob for '$rel' ($project/$rel.age) does not decrypt with the current key." >&2
|
||||||
findings=$((findings + 1))
|
findings=$((findings + 1))
|
||||||
fi
|
fi
|
||||||
|
if ! _check_blob_recipient_count "$blob" "$project/$rel.age" "$rexpected"; then
|
||||||
|
findings=$((findings + 1))
|
||||||
|
fi
|
||||||
done < <(jq -r '.dotenv // [] | .[]' "$manifest")
|
done < <(jq -r '.dotenv // [] | .[]' "$manifest")
|
||||||
|
|
||||||
# ── external entries: missing-blob + decrypt ──
|
# ── 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
|
echo "FINDING: external blob for '$epath' ($project/$erel) does not decrypt with the current key." >&2
|
||||||
findings=$((findings + 1))
|
findings=$((findings + 1))
|
||||||
fi
|
fi
|
||||||
|
if ! _check_blob_recipient_count "$eblob" "$project/$erel" "$rexpected"; then
|
||||||
|
findings=$((findings + 1))
|
||||||
|
fi
|
||||||
done < <(_json_external_entries "$manifest")
|
done < <(_json_external_entries "$manifest")
|
||||||
|
|
||||||
# ── orphan detection: any stored blob the manifest doesn't account for ──
|
# ── orphan detection: any stored blob the manifest doesn't account for ──
|
||||||
|
|
|
||||||
|
|
@ -267,3 +267,26 @@ make_second_identity() {
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[[ "$output" == *"recipients: single-key"* ]] || false
|
[[ "$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