From 9e2a563059eb2a7ed101995633672ded6a635fcd Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Wed, 24 Jun 2026 12:12:14 -0700 Subject: [PATCH 01/20] docs: multi-recipient age encryption design spec (EGB-283) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...4-multi-recipient-age-encryption-design.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-multi-recipient-age-encryption-design.md diff --git a/docs/superpowers/specs/2026-06-24-multi-recipient-age-encryption-design.md b/docs/superpowers/specs/2026-06-24-multi-recipient-age-encryption-design.md new file mode 100644 index 0000000..2141dea --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-multi-recipient-age-encryption-design.md @@ -0,0 +1,230 @@ +# 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