diff --git a/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md b/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md index 96a1b3a..54a38d8 100644 --- a/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md +++ b/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md @@ -62,7 +62,7 @@ make_second_identity() { # No recipients.txt was created by push. [ ! -e "$SECRETS_DIR/recipients.txt" ] # Blob decrypts with the store's own key. - run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/work/myproj/.env.age" + run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age" [ "$status" -eq 0 ] } @@ -75,10 +75,10 @@ make_second_identity() { 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/work/myproj/.env.age" + 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/work/myproj/.env.age" + run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age" [ "$status" -eq 0 ] } @@ -361,18 +361,18 @@ git commit -m "feat: secrets recipients list (EGB-283)" @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 + 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/work/myproj/.env.age" + 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/work/myproj/.env.age" + run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age" [ "$status" -eq 0 ] } @@ -389,7 +389,7 @@ git commit -m "feat: secrets recipients list (EGB-283)" # 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/work/myproj/.env.age" + run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age" [ "$status" -eq 0 ] } @@ -401,7 +401,7 @@ git commit -m "feat: secrets recipients list (EGB-283)" run "$SECRETS_BIN" rekey [ "$status" -eq 0 ] [ "$(cat "$SECRETS_DIR/key.txt")" != "$before" ] - run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/work/myproj/.env.age" + run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age" [ "$status" -eq 0 ] } ``` @@ -562,7 +562,7 @@ git commit -m "feat: shared _reencrypt_all + reencrypt cmd + dual rekey (EGB-283 [[ "$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/work/myproj/.env.age" + run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age" [ "$status" -eq 0 ] } @@ -696,7 +696,7 @@ git commit -m "feat: secrets recipients add (EGB-283)" 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/work/myproj/.env.age" + run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age" [ "$status" -eq 0 ] } @@ -1023,12 +1023,42 @@ git commit -m "feat: verify asserts blob recipient-count matches recipients.txt ### Task 8: Security regression fixtures for `recipients.txt` **Files:** -- Modify: `test/recipients.bats` (adversarial fixtures only — no `secrets` changes expected; if a case slips through, fix the rail in `secrets` here) +- Modify: `secrets` (one ordering fix in `_load_recipients`, carried over from the Task 1 review); `test/recipients.bats` (adversarial fixtures; if any other case slips through, fix the rail in `secrets` here) **Interfaces:** none new — exercises `_load_recipients` / `_recipients_add` rails via the CLI. > Policy reminder: these are ordinary defensive regression tests. Do NOT dispatch red-team/adversarial-review subagents and do NOT run `./test/run-security.sh` — that is operator-local. +- [ ] **Step 0: Fix the dangling-symlink ordering gap (from Task 1 review)** + +In `_load_recipients`, the `[ ! -e "$RECIPIENTS_FILE" ]` check runs BEFORE the `[ -L "$RECIPIENTS_FILE" ]` check. A *dangling* symlink (link exists, target missing) makes `[ ! -e ]` true, so it bypasses the symlink refusal and silently degrades to legacy single-key mode. Swap the order so the symlink check comes first: + +```bash +_load_recipients() { + RECIPIENT_ARGS=() + if [ -L "$RECIPIENTS_FILE" ]; then + die "Refusing to read symlinked $RECIPIENTS_FILE_NAME (security)." + fi + if [ ! -e "$RECIPIENTS_FILE" ]; then + RECIPIENT_ARGS=(-r "$(get_pubkey)") + return 0 + fi + # … rest unchanged (line-by-line parse + validate) … +``` + +Add a regression test alongside the other SECURITY fixtures: + +```bash +@test "SECURITY: a dangling symlink recipients.txt is refused, not silently ignored" { + init_with_remote + 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 +} +``` + - [ ] **Step 1: Write the fixtures** ```bash