# EGB-283 — Multi-recipient age encryption **Date:** 2026-06-24 **Issue:** [EGB-283](https://linear.app/egbt/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 `# ` 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 )`, 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 [--name