230 lines
13 KiB
Markdown
230 lines
13 KiB
Markdown
# 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 `# <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 unchanged** — `age -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.)
|