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.txtis 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)
- Storage: committed
recipients.txtat the store root, managed viasecrets recipients add/rm/listsubcommands. - Re-encrypt scope:
add/rmre-encrypt the entire store immediately to the new set in one commit. The store is always consistent. - Backward compatibility: absence of
recipients.txt⇒ exact current single-key behavior. Firstrecipients addon a legacy store bootstraps the file seeded with the local pubkey plus the new key.initgoing forward seedsrecipients.txtwith the freshly generated pubkey (born-multi). rekeysemantics: on a multi-recipient store,rekeybecomes "re-encrypt all to the currentrecipients.txtset" (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.- Store config shape: keep
recipients.txtas its own plain, age-native file (jq-free), alongside the existing one-line.secrets-formatmarker — 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.txtis refused (same rail as the manifests). --namelabels (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.txtpresent → 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:
- Decrypt every
*.agein the store (recursivefind -type f -name '*.age', covering nested manifest dotenv blobs andexternal/blobs) with the localkey.txtinto a tmpdir. The operator must be a current recipient; a decryption failure aborts with the old state preserved. - Re-encrypt each file with
age "${RECIPIENT_ARGS[@]}"back to its relpath. 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 fromrecipients.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'srekey).
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 pathpush_external_files(externalpropertiesandfileblobs)cmd_init(seeds the store; born-multi)cmd_rekeyre-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 unchanged — age -d -i "$KEY_FILE" already tries the identity against all recipient stanzas.
init / which / verify integration
initwritesrecipients.txtseeded with the freshly generated pubkey (born-multi), staged like.secrets-format.whichprints arecipients: N (alice, bob, …)line, orrecipients: single-key (no recipients.txt)for a legacy store.verifyalready decrypt-tests with the local key — works as-is for a member. Added cheap invariant: count the-> X25519recipient stanzas in each blob header and assert it equals the number of entries inrecipients.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 (norecipients.txt).
Data flow
Onboarding a teammate
- Teammate runs
age-keygenlocally, sends their public key out of band. - An existing member:
secrets recipients add age1theirpub --name them→ store re-encrypts to{existing…, them}in one commit, pushed. - Teammate clones the store repo, drops their own
key.txtin place, andsecrets pullworks — their key matches one stanza in every blob.
Offboarding
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_recipientsduring any encrypt → abort the command with a clear pointer to the offending line; nothing written. _reencrypt_alldecryption failure (operator not a current recipient, or corrupt blob) → abort, old store state preserved (mirrors today'srekeysafety).rmlast recipient → refused.rmown 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:
initborn-multi;addbootstraps a legacy store;add/rmround-trip; a blob encrypted to 3 keys decrypts with each of the 3 identities;listoutput;rekeyon a multi-recipient store keeps the set and generates no new key;reencryptis 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.txtis a new committed attack surface): non-age / malformed keys, shell metacharacters, control/ANSI characters,-r-injection look-alikes, symlinkedrecipients.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.mdArchitecture section: add a multi-recipient bullet (store-scopedrecipients.txt,recipientssubcommand, shared_reencrypt_allengine,rekeydual semantics, security rails).README.md: onboarding/offboarding a teammate;recipients add/rm/list.- Test counts in
CLAUDE.mdProject Structure updated.
Open questions
None blocking. (SSH-recipient support and a JSON store-config remain possible future follow-ups, explicitly out of scope here.)