- Fix 1 (IMPORTANT): _recipients_rm and _recipients_add now call _load_recipients BEFORE any mutation. A hand-corrupted recipients.txt dies at validation, leaving the file untouched — prevents inconsistent state where the file is changed but blobs are not re-encrypted. On a legacy store (no recipients.txt), _load_recipients succeeds via the derived-pubkey path so the bootstrap path still works. - Fix 2 (MINOR): Guard _check_blob_recipient_count behind a successful decrypt in both _verify_all and _verify_project — an undecryptable blob no longer produces a spurious "encrypted to 0 recipients" finding. Reword _verify_all summary to "failed (decrypt or recipient-count)" since both failure modes now increment the counter. - Fix 3 (MINOR): Correct README offboarding comment from "New blobs are no longer readable" (contradicts the re-encrypt of EVERY blob) to "Existing blobs are re-encrypted; the removed key can no longer decrypt them." - Fix 4 (MINOR): cmd_reencrypt prints an advisory when no recipients.txt exists (single-key store), so the operator knows they can add teammates. - Fix 5 (MINOR): Test coverage for ambiguous-name rm refusing to remove when multiple recipients share a --name label. Tests: 5 new tests in test/recipients.bats (34 total, all pass). Full suite 276 tests: 5 known pre-existing failures (3 mode-600/stat, 2 jq-PATH), none new. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
407 lines
14 KiB
Bash
407 lines
14 KiB
Bash
#!/usr/bin/env bats
|
|
load test_helper
|
|
|
|
# A throwaway second identity for "another teammate".
|
|
make_second_identity() {
|
|
age-keygen -o "$TEST_TMPDIR/bob.txt" 2>/dev/null
|
|
BOB_PUB=$(age-keygen -y "$TEST_TMPDIR/bob.txt")
|
|
}
|
|
|
|
@test "push with only-self recipients.txt encrypts to the store key (born-multi)" {
|
|
init_with_remote
|
|
# init now seeds recipients.txt with self — born-multi store.
|
|
[ -e "$SECRETS_DIR/recipients.txt" ]
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
# Blob must still decrypt with the store's own key.
|
|
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "push with a hand-written recipients.txt encrypts to every listed key" {
|
|
init_with_remote
|
|
make_second_identity
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '# self\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -eq 0 ]
|
|
# Bob (a recipient) can decrypt the pushed blob with HIS key.
|
|
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -eq 0 ]
|
|
# And the store key still can too.
|
|
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "push refuses a recipients.txt with an invalid key" {
|
|
init_with_remote
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '%s\nnot-an-age-key\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"Invalid recipient"* ]] || false
|
|
}
|
|
|
|
@test "push refuses a symlinked recipients.txt" {
|
|
init_with_remote
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
|
|
# Remove the born-multi recipients.txt so we can replace it with a symlink.
|
|
rm -f "$SECRETS_DIR/recipients.txt"
|
|
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"symlink"* ]] || false
|
|
}
|
|
|
|
@test "recipients list on a legacy store shows the single derived key" {
|
|
init_with_remote
|
|
# Simulate a legacy store by removing the born-multi recipients.txt.
|
|
rm -f "$SECRETS_DIR/recipients.txt"
|
|
run "$SECRETS_BIN" recipients list
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"single-key"* ]] || false
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
[[ "$output" == *"$STORE_PUB"* ]] || false
|
|
}
|
|
|
|
@test "recipients list shows names and keys from recipients.txt" {
|
|
init_with_remote
|
|
make_second_identity
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '# alice\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
|
run "$SECRETS_BIN" recipients list
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"recipients: 2"* ]] || false
|
|
[[ "$output" == *"alice"* ]] || false
|
|
[[ "$output" == *"bob"* ]] || false
|
|
}
|
|
|
|
@test "reencrypt re-encrypts existing blobs to a newly added recipient line" {
|
|
init_with_remote
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push # single-key blob (project name = "myproj"; blob at $SECRETS_DIR/myproj/.env.age)
|
|
[ "$status" -eq 0 ]
|
|
make_second_identity
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
|
# Bob cannot read the old single-key blob yet.
|
|
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -ne 0 ]
|
|
run "$SECRETS_BIN" reencrypt
|
|
[ "$status" -eq 0 ]
|
|
# Now he can.
|
|
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "rekey on a multi-recipient store keeps recipients and the same key" {
|
|
init_with_remote
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
before=$(cat "$SECRETS_DIR/key.txt")
|
|
make_second_identity
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
|
run "$SECRETS_BIN" rekey
|
|
[ "$status" -eq 0 ]
|
|
# No new keypair was generated.
|
|
[ "$(cat "$SECRETS_DIR/key.txt")" = "$before" ]
|
|
# Both recipients can decrypt.
|
|
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "rekey on a legacy store still rotates to a new key (unchanged)" {
|
|
init_with_remote
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
before=$(cat "$SECRETS_DIR/key.txt")
|
|
# Simulate a legacy store by removing the born-multi recipients.txt.
|
|
rm -f "$SECRETS_DIR/recipients.txt"
|
|
run "$SECRETS_BIN" rekey
|
|
[ "$status" -eq 0 ]
|
|
[ "$(cat "$SECRETS_DIR/key.txt")" != "$before" ]
|
|
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "recipients add bootstraps a legacy store and re-encrypts" {
|
|
init_with_remote
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
# Simulate a legacy store (no recipients.txt) so recipients add triggers the
|
|
# bootstrap branch (if [ ! -e "$RECIPIENTS_FILE" ]) rather than the append path.
|
|
rm -f "$SECRETS_DIR/recipients.txt"
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
|
[ "$status" -eq 0 ]
|
|
[ -e "$SECRETS_DIR/recipients.txt" ]
|
|
# recipients.txt now has self + bob (2 keys).
|
|
run "$SECRETS_BIN" recipients list
|
|
[[ "$output" == *"recipients: 2"* ]] || false
|
|
[[ "$output" == *"bob"* ]] || false
|
|
# Existing blob re-encrypted: bob can read it.
|
|
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "recipients add rejects a non-age key" {
|
|
init_with_remote
|
|
run "$SECRETS_BIN" recipients add "ssh-ed25519 AAAAfoo"
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"valid age recipient"* ]] || false
|
|
}
|
|
|
|
@test "recipients add rejects a duplicate" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
|
[ "$status" -eq 0 ]
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB"
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"already present"* ]] || false
|
|
}
|
|
|
|
@test "recipients add rejects an unsafe --name" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name 'bob; rm -rf ~'
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"Invalid --name"* ]] || false
|
|
}
|
|
|
|
@test "recipients add rejects a whitespace-only --name" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name ' '
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"Invalid --name"* ]] || false
|
|
}
|
|
|
|
@test "recipients add --name with no value errors" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"--name requires a value"* ]] || false
|
|
}
|
|
|
|
@test "recipients rm removes a recipient and re-encrypts to the rest" {
|
|
init_with_remote
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
|
run "$SECRETS_BIN" recipients rm bob
|
|
[ "$status" -eq 0 ]
|
|
run "$SECRETS_BIN" recipients list
|
|
[[ "$output" == *"recipients: 1"* ]] || false
|
|
# Store key still reads its own blobs.
|
|
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "recipients rm refuses to remove the last recipient" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # store = self + bob
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
run "$SECRETS_BIN" recipients rm bob # back to self only
|
|
[ "$status" -eq 0 ]
|
|
run "$SECRETS_BIN" recipients rm "$STORE_PUB" # would be the last
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"last recipient"* ]] || false
|
|
}
|
|
|
|
@test "recipients rm of your own key requires --yes" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
run "$SECRETS_BIN" recipients rm "$STORE_PUB"
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"your own key"* ]] || false
|
|
run "$SECRETS_BIN" recipients rm "$STORE_PUB" --yes
|
|
[ "$status" -eq 0 ]
|
|
}
|
|
|
|
@test "recipients rm of a non-existent target errors" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
|
run "$SECRETS_BIN" recipients rm carol
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"No recipient matches"* ]] || false
|
|
}
|
|
|
|
@test "init seeds recipients.txt with the new store key (born-multi)" {
|
|
run "$SECRETS_BIN" init
|
|
[ "$status" -eq 0 ]
|
|
[ -e "$SECRETS_DIR/recipients.txt" ]
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
run cat "$SECRETS_DIR/recipients.txt"
|
|
[[ "$output" == *"$STORE_PUB"* ]] || false
|
|
}
|
|
|
|
@test "which reports the recipient count" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" which
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"recipients: 2"* ]] || false
|
|
[[ "$output" == *"bob"* ]] || false
|
|
}
|
|
|
|
@test "which reports single-key for a legacy store" {
|
|
init_with_remote
|
|
rm -f "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" which
|
|
[ "$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
|
|
}
|
|
|
|
@test "SECURITY: a dangling symlink recipients.txt is refused, not silently ignored" {
|
|
init_with_remote
|
|
rm -f "$SECRETS_DIR/recipients.txt"
|
|
ln -s "$TEST_TMPDIR/does-not-exist.txt" "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"symlink"* ]] || false
|
|
}
|
|
|
|
@test "SECURITY: recipients.txt with shell metacharacters is rejected, no execution" {
|
|
init_with_remote
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '%s\nage1$(touch %s/pwned)\n' "$STORE_PUB" "$TEST_TMPDIR" > "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -ne 0 ]
|
|
[ ! -e "$TEST_TMPDIR/pwned" ]
|
|
[[ "$output" == *"Invalid recipient"* ]] || false
|
|
}
|
|
|
|
@test "SECURITY: recipients.txt line that looks like an extra age flag is rejected" {
|
|
init_with_remote
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '%s\n-i /etc/passwd\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"Invalid recipient"* ]] || false
|
|
# The push must have aborted before encrypting — no blob should exist.
|
|
[ ! -e "$SECRETS_DIR/myproj/.env.age" ]
|
|
}
|
|
|
|
@test "SECURITY: control/ANSI characters in recipients.txt are rejected" {
|
|
init_with_remote
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '%s\nage1%b\n' "$STORE_PUB" 'aaaa\033[31mevil' > "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"Invalid recipient"* ]] || false
|
|
}
|
|
|
|
@test "SECURITY: recipients add rejects a key with embedded whitespace" {
|
|
init_with_remote
|
|
run "$SECRETS_BIN" recipients add "age1aaaa bbbb"
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"valid age recipient"* ]] || false
|
|
}
|
|
|
|
# ── Fix 1: validate-before-mutate ────────────────────────────────────────────
|
|
|
|
@test "recipients rm dies without mutating a hand-corrupted recipients.txt" {
|
|
init_with_remote
|
|
make_second_identity
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # valid: self + bob
|
|
# Corrupt the file by hand.
|
|
printf 'age1-not-a-valid-key\n' >> "$SECRETS_DIR/recipients.txt"
|
|
before=$(cat "$SECRETS_DIR/recipients.txt")
|
|
run "$SECRETS_BIN" recipients rm bob
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"Invalid recipient"* ]] || false
|
|
# File unchanged (no half-mutation).
|
|
[ "$(cat "$SECRETS_DIR/recipients.txt")" = "$before" ]
|
|
}
|
|
|
|
@test "recipients add dies without mutating a hand-corrupted recipients.txt" {
|
|
init_with_remote
|
|
make_second_identity
|
|
printf 'age1-not-a-valid-key\n' >> "$SECRETS_DIR/recipients.txt" # init seeded self; now corrupt
|
|
before=$(cat "$SECRETS_DIR/recipients.txt")
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"Invalid recipient"* ]] || false
|
|
[ "$(cat "$SECRETS_DIR/recipients.txt")" = "$before" ]
|
|
}
|
|
|
|
# ── Fix 4: reencrypt advisory on a legacy store ───────────────────────────────
|
|
|
|
@test "reencrypt on a legacy store prints a single-key advisory" {
|
|
init_with_remote
|
|
rm -f "$SECRETS_DIR/recipients.txt"
|
|
create_project_dir myproj
|
|
run "$SECRETS_BIN" push
|
|
run "$SECRETS_BIN" reencrypt
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"single-key"* ]] || false
|
|
}
|
|
|
|
# ── Fix 5: ambiguous-name rm coverage ────────────────────────────────────────
|
|
|
|
@test "recipients rm by an ambiguous name is refused" {
|
|
init_with_remote
|
|
make_second_identity
|
|
age-keygen -o "$TEST_TMPDIR/carol.txt" 2>/dev/null
|
|
CAROL_PUB=$(age-keygen -y "$TEST_TMPDIR/carol.txt")
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name dup
|
|
run "$SECRETS_BIN" recipients add "$CAROL_PUB" --name dup
|
|
run "$SECRETS_BIN" recipients rm dup
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"matches"* ]] || false
|
|
}
|
|
|
|
@test "SECURITY: a symlinked recipients.txt is refused on add and rm too" {
|
|
init_with_remote
|
|
make_second_identity
|
|
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
|
printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
|
|
rm -f "$SECRETS_DIR/recipients.txt"
|
|
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
|
|
run "$SECRETS_BIN" recipients add "$BOB_PUB"
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"symlink"* ]] || false
|
|
}
|