secrets/docs/superpowers/specs/2026-06-24-multi-recipient-age-encryption-design.md
Brian Majewski 9e2a563059 docs: multi-recipient age encryption design spec (EGB-283)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:12:14 -07:00

13 KiB

EGB-283 — Multi-recipient age encryption

Date: 2026-06-24 Issue: EGB-283 — secrets: multi-recipient age encryption (multiple keys per file) Status: Design approved, ready for implementation plan Related: EGB-281 (multi-store), EGB-677/EGB-703 (manifest + store-format v2)

Problem

Today every blob in a store is encrypted to exactly one age public key (age -r "$pubkey", where $pubkey is derived from the store's single key.txt). The whole team shares one private key. EGB-281's multi-store work lets users separate work/personal/client secrets into different stores, each with its own key — but within a single store there is still only one key, so onboarding/offboarding a teammate means sharing or rotating one secret by hand.

age natively supports multiple recipients: age -r KEY1 -r KEY2 -o file.age input writes one recipient stanza per key, and any matching identity decrypts. This lets a single store have N members, each with their own keypair. Adding/removing a teammate becomes a re-encrypt against the current recipient set — no shared password.

Goals

  • A store can encrypt every blob to N recipient public keys.
  • Recipient set is managed with first-class commands (secrets recipients add/rm/list).
  • The recipient set is singular and consistent per store: every blob is always readable by exactly the current set.
  • Fully backward compatible: existing single-key stores keep working untouched; the feature is opt-in and detected by file presence (no store-format-marker bump).
  • Decryption path is unchanged (members use their own key.txt).

Non-goals (YAGNI — explicit scope cuts)

  • SSH recipients (ssh-ed25519 / ssh-rsa). Native age X25519 keys cover the team-key use case; SSH adds a parsing/format axis. Clean future follow-up.
  • Per-file or per-project recipient subsets. The whole store shares one recipient set.
  • Key discovery / distribution. Public keys are pasted in out of band, exactly as key.txt is shared today.
  • Merging recipients into a project-level config (.secrets.json / .secrets-files). See "Why recipients are not in the project manifest" below.

Design decisions (resolved during brainstorming)

  1. Storage: committed recipients.txt at the store root, managed via secrets recipients add/rm/list subcommands.
  2. Re-encrypt scope: add/rm re-encrypt the entire store immediately to the new set in one commit. The store is always consistent.
  3. Backward compatibility: absence of recipients.txt ⇒ exact current single-key behavior. First recipients add on a legacy store bootstraps the file seeded with the local pubkey plus the new key. init going forward seeds recipients.txt with the freshly generated pubkey (born-multi).
  4. rekey semantics: on a multi-recipient store, rekey becomes "re-encrypt all to the current recipients.txt set" (no new keypair). On a legacy store it keeps today's behavior (generate a new keypair, re-encrypt to it). One shared re-encrypt engine.
  5. Store config shape: keep recipients.txt as its own plain, age-native file (jq-free), alongside the existing one-line .secrets-format marker — matching the repo's small-single-purpose-plain-file convention. Not folded into a JSON store-config.

Why recipients are not in the project manifest

The tool has two config planes in two different git repos:

Plane Location Files Scope
Project $PWD (the project's own repo) .secrets.json (absorbs legacy .secrets-files), .secrets-store What this project syncs
Store $SECRETS_DIR (~/.secrets) .secrets-format, recipients.txt (new) Metadata about the encrypted repo

Recipients are store-scoped — who can decrypt this store, shared by every project in it. Putting them in a project-level manifest would let each project carry its own copy and diverge, the exact inconsistency the "always re-encrypt the whole store to one set" rule prevents. It also collides with the deliberate EGB-703 decision that the store holds no project manifest. So the recipient set lives with the store, next to .secrets-format.

recipients.txt format and security rails

Lives at $SECRETS_DIR/recipients.txt, committed (public keys are not secret; the store .gitignore only blocks key.txt and plaintext env files, so the file is tracked automatically). age -R format: one recipient per line, # comment and blank lines allowed.

# alice (laptop)
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9qsxxxxxx
# bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqxxxxxx

We do not pass the file path to age -R. A committed file is an injection surface, so the tool parses it itself into a validated indexed array RECIPIENT_ARGS=(-r age1… -r age1…), mirroring the conservative posture of .secrets-store / .secrets-files:

  • Each non-comment, non-blank line (after trim) must match a native age X25519 recipient exactly: ^age1[0-9a-z]{58}$. Anything else — SSH recipients, shell metacharacters, whitespace inside the token, control/ANSI characters, -r-injection look-alikes — is rejected with a clear error. No shell expansion, ever.
  • A symlinked recipients.txt is refused (same rail as the manifests).
  • --name labels (written as # <name> comment lines above the key) are restricted to [A-Za-z0-9 ._-]; anything else is rejected. This blocks comment-injection into the file.
  • The parser is pure bash (bash-3.2 safe). Indexed arrays are fine on bash 3.2; only associative arrays are bash-4.

Validation is the single source of truth — both the recipients subcommands and _load_recipients (below) route through the same validator, so an externally hand-edited malicious file is caught on the next encrypt, not just at add time.

Components

_load_recipients() — populate RECIPIENT_ARGS

Called once per command that encrypts. Populates the global indexed array RECIPIENT_ARGS:

  • recipients.txt present → validated array of every key in the file (error out on any invalid line; refuse an empty/all-comment file).
  • absent (legacy store) → (-r <derived-local-pubkey>), identical to today's single-recipient behavior.

_reencrypt_all() — shared re-encrypt engine

Factored out of today's cmd_rekey decrypt→re-encrypt loop:

  1. Decrypt every *.age in the store (recursive find -type f -name '*.age', covering nested manifest dotenv blobs and external/ blobs) with the local key.txt into a tmpdir. The operator must be a current recipient; a decryption failure aborts with the old state preserved.
  2. Re-encrypt each file with age "${RECIPIENT_ARGS[@]}" back to its relpath.
  3. ensure_store_protections, git add -A, commit, push (if a remote exists).

All recipient-changing paths call it:

Command Behavior
recipients add / rm edit recipients.txt_load_recipients_reencrypt_all
rekey (multi-recipient store) _reencrypt_all to current set, no new keypair
rekey (legacy store) today's behavior: generate new keypair, set recipients to it, re-encrypt
reencrypt (new, idempotent) _reencrypt_all — heal/backfill after a manual edit

secrets recipients subcommand

  • recipients list — prints names + keys from recipients.txt (read-only, jq-free). On a legacy store, prints the single derived pubkey with a "single-key (no recipients.txt)" note.
  • recipients add <age1…> [--name <label>] — validates the key, refuses duplicates, appends (with the optional # <label> comment), bootstraps the file with {local pubkey, new key} if the store is still legacy, then _reencrypt_all.
  • recipients rm <age1…|name> — removes the matching entry, then _reencrypt_all. Guards: refuses to remove the last recipient; removing your own key (which would lock you out of future pulls) requires --yes. Removal takes effect going forward — git history can't be un-shared (documented, same caveat as today's rekey).

Encryption call-site changes

Every existing age -r "$pubkey" -o … site becomes age "${RECIPIENT_ARGS[@]}" -o …:

  • push_dir_to_project (dotenv) and the nested manifest dotenv path
  • push_external_files (external properties and file blobs)
  • cmd_init (seeds the store; born-multi)
  • cmd_rekey re-encrypt loop (now _reencrypt_all)

Functions that currently take pubkey as a positional arg are updated to rely on the RECIPIENT_ARGS global populated by _load_recipients at command entry, avoiding array-passing gymnastics on bash 3.2. Decryption paths (pull, verify) are unchangedage -d -i "$KEY_FILE" already tries the identity against all recipient stanzas.

init / which / verify integration

  • init writes recipients.txt seeded with the freshly generated pubkey (born-multi), staged like .secrets-format.
  • which prints a recipients: N (alice, bob, …) line, or recipients: single-key (no recipients.txt) for a legacy store.
  • verify already decrypt-tests with the local key — works as-is for a member. Added cheap invariant: count the -> X25519 recipient stanzas in each blob header and assert it equals the number of entries in recipients.txt. age exposes no way to list which recipients a file targets (X25519 stanzas are ephemeral), so a count check is the strongest machine-checkable invariant; full-identity consistency relies on the always-re-encrypt-all rule. The check is skipped on legacy stores (no recipients.txt).

Data flow

Onboarding a teammate

  1. Teammate runs age-keygen locally, sends their public key out of band.
  2. An existing member: secrets recipients add age1theirpub --name them → store re-encrypts to {existing…, them} in one commit, pushed.
  3. Teammate clones the store repo, drops their own key.txt in place, and secrets pull works — their key matches one stanza in every blob.

Offboarding

  1. secrets recipients rm them → store re-encrypts to the remaining set, pushed. New blobs are no longer readable by the removed key. (Historical git revisions remain readable by their old key — rotate any still-sensitive secret values, same as today.)

Error handling

  • Invalid/duplicate key on add → reject before any re-encrypt; store untouched.
  • Invalid line discovered by _load_recipients during any encrypt → abort the command with a clear pointer to the offending line; nothing written.
  • _reencrypt_all decryption failure (operator not a current recipient, or corrupt blob) → abort, old store state preserved (mirrors today's rekey safety).
  • rm last recipient → refused. rm own key → requires --yes.
  • Empty/all-comment recipients.txt → treated as an error (a store with zero recipients can encrypt nothing).

Testing

New test/recipients.bats suite plus additions to existing suites:

  • Happy path: init born-multi; add bootstraps a legacy store; add/rm round-trip; a blob encrypted to 3 keys decrypts with each of the 3 identities; list output; rekey on a multi-recipient store keeps the set and generates no new key; reencrypt is idempotent.
  • Backward compat: a legacy store (no recipients.txt) still pushes/pulls/rekeys exactly as before; an old single-key client decrypts a recipients.txt-store where its key is a recipient.
  • verify: stanza-count invariant passes on a healthy multi-recipient store and flags a blob whose recipient count drifted.
  • Security regression fixtures (recipients.txt is a new committed attack surface): non-age / malformed keys, shell metacharacters, control/ANSI characters, -r-injection look-alikes, symlinked recipients.txt, bad --name, remove-last-recipient, remove-self-without---yes.

Per the repo security-review policy (.ship-policy.json, CLAUDE.md): these adversarial fixtures are written as ordinary bats regression tests, not AI red-team/adversarial-review passes. Before any ship/PR, the human operator runs ./test/run-security.sh and completes the SIGNOFF prompt; ./test/run-security.sh is operator-local and is not run on the user's behalf.

Backward compatibility / migration

No store-format-marker bump. Multi-recipient is purely additive and detected by the presence of recipients.txt, mirroring the .secrets-format "absence implies v1" pattern. Existing stores keep working with zero action; a store becomes multi-recipient the first time recipients add (or init on a fresh store) writes recipients.txt.

Documentation

  • CLAUDE.md Architecture section: add a multi-recipient bullet (store-scoped recipients.txt, recipients subcommand, shared _reencrypt_all engine, rekey dual semantics, security rails).
  • README.md: onboarding/offboarding a teammate; recipients add/rm/list.
  • Test counts in CLAUDE.md Project Structure updated.

Open questions

None blocking. (SSH-recipient support and a JSON store-config remain possible future follow-ups, explicitly out of scope here.)