From f7576a3eaecd6c07b76889d638179d0f4b377af7 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Wed, 24 Jun 2026 13:25:33 -0700 Subject: [PATCH] docs: multi-recipient age encryption (recipients/reencrypt) + bump 0.6.2.0 (EGB-283) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 31 +++++++++++++++++++++++ CLAUDE.md | 25 ++++++++++++++++--- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++--- VERSION | 2 +- secrets | 4 +++ 5 files changed, 125 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 774c55a..8a71b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. +## [0.6.2.0] - 2026-06-24 + +### Added + +- **Multi-recipient age encryption (EGB-283)** — a store-scoped, committed + `recipients.txt` (age `-R` format, with `# name` comment lines) lets one + store encrypt every blob to N age public keys — one per team member. + `secrets recipients add [--name N]` adds a key and immediately + re-encrypts the whole store; `secrets recipients rm [--yes]` + removes one and re-encrypts; `secrets recipients list` shows the current + set (or a note that the store is still single-key). A new `secrets + reencrypt` command re-encrypts every blob to the current recipients without + changing the set (idempotent heal / backfill after a manual edit). Absence + of `recipients.txt` preserves exact legacy single-key behavior; the first + `recipients add` on a legacy store bootstraps the file seeded with the + local pubkey plus the new key. `init` now seeds `recipients.txt` born-multi + with the freshly generated pubkey. + +### Changed + +- **`secrets rekey` on a multi-recipient store** no longer generates a new + keypair — instead it re-encrypts all blobs to the current `recipients.txt` + set (the shared `_reencrypt_all` engine). On a legacy store (no + `recipients.txt`) `rekey` keeps today's generate-new-keypair behavior. +- **`secrets which`** now prints a `recipients: N (name, …)` line, or + `recipients: single-key (no recipients.txt)` for a legacy store. +- **`secrets verify` / `verify --all`** assert that each blob's age + recipient-stanza count equals the number of entries in `recipients.txt` + (skipped on legacy stores). Exits non-zero on any count mismatch so it can + gate CI or a migration. + ## [0.6.1.0] - 2026-06-08 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 176fd12..7fc4bb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ cd ~/my-project && ./secrets pull # Pull + decrypt .env* files ```bash brew install bats-core -bats test/ # runs secrets.bats + manifest.bats + migrate.bats +bats test/ # runs secrets.bats + manifest.bats + migrate.bats + recipients.bats ./test/run-security.sh # security regression subset + operator sign-off (see below) ``` @@ -56,7 +56,7 @@ skips security specialist + red team, and Step 11 skips adversarial review. ## Architecture -Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate. +Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, recipients, reencrypt. - Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable) - Storage: Private git repo at `~/.secrets/` @@ -67,6 +67,24 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek - External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`, merged not overwritten — EGB-531) and whole binary files (type `file`, e.g. an Android upload keystore — EGB-652); see below - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq` - Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`) +- Multi-recipient (EGB-283): a store-scoped, committed `recipients.txt` (age `-R` + format, `# name` comments) lets one store encrypt every blob to N age keys — + one per team member. Managed via `secrets recipients add/rm/list`; absence of + the file ⇒ legacy single-key behavior (recipients = the pubkey derived from + `key.txt`). The file is parsed by us (never `age -R `) into a validated + `RECIPIENT_ARGS` array (native age X25519 only, `age1[0-9a-z]{58}`; SSH + recipients rejected; symlinked file refused) — same conservative posture as + `.secrets-store`/`.secrets-files`. `_load_recipients` populates the array; + every encrypt site routes through it. Any recipient change re-encrypts the + WHOLE store in one commit via the shared `_reencrypt_all` engine (also used by + the new `secrets reencrypt` and by `rekey` on a multi-recipient store, where + rekey re-encrypts to the set with NO new keypair; legacy stores keep rekey's + generate-new-keypair behavior). `init` seeds `recipients.txt` born-multi. + `which` prints `recipients: N`; `verify`/`verify --all` assert each blob's + age recipient-stanza count equals `recipients.txt`'s length. Removal takes + effect going forward — git history stays readable by an old key, so rotate + genuinely-sensitive values. Decryption is unchanged (each member uses their + own `key.txt`). - Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features ## Project Structure @@ -77,7 +95,8 @@ hooks/pre-commit # Pre-commit hook template test/ secrets.bats # bats-core test suite (133 tests) manifest.bats # EGB-677 .secrets.json manifest tests (78 tests) - migrate.bats # EGB-703 store-format-v2 migration tests (26 tests) + migrate.bats # EGB-703 store-format-v2 migration tests (31 tests) + recipients.bats # EGB-283 multi-recipient age encryption tests (30 tests) test_helper.bash # Shared setup/teardown README.md # User-facing documentation CLAUDE.md # This file diff --git a/README.md b/README.md index ee177b3..2a1ddfa 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,15 @@ secrets pull ### Sharing with teammates -To share secrets with a teammate, they need: +**Simple approach (shared key):** To share secrets with a teammate, they need: 1. Access to your private `my-secrets` GitHub repo (add them as a collaborator) 2. A copy of `key.txt` (send it to them directly — AirDrop, USB, or in-person) Everyone on the team uses the same key. When anyone runs `secrets push`, the encrypted files are updated and everyone else can `secrets pull` to get the latest version. +**Per-teammate keys (recommended for teams):** Use `secrets recipients add` so each person keeps their own private key — no key sharing needed. See [Onboarding and offboarding teammates](#onboarding-and-offboarding-teammates) below. + ## Usage ### Daily workflow @@ -168,12 +170,16 @@ secrets clear | `secrets run ` | Pull secrets, run a command, then clear secrets when it exits | | `secrets list` | Show all projects that have stored secrets | | `secrets rm ` | Delete a project's secrets from the store | -| `secrets rekey` | Generate a new encryption key and re-encrypt everything | +| `secrets rekey` | Generate a new encryption key and re-encrypt everything (single-key store) or re-encrypt to the current recipients without changing keys (multi-recipient store) | | `secrets verify [project]` | Check the current project's `.secrets.json` against the store (missing/orphaned blobs) and decrypt every blob. `[project]` overrides the store directory name; the manifest is still read from the current directory | | `secrets verify --all` | Decrypt-test every blob in every project — a store-wide integrity sweep | | `secrets migrate [--dry-run]` | Copy-forward this project's encrypted blobs to store format v2 (non-destructive; manifest-free; `--dry-run` previews) | | `secrets migrate --status` | Survey every project's v2 readiness; exits non-zero until the whole store is finalize-ready | | `secrets migrate --finalize` | Drop the old v1 blobs and mark the store v2 — runs once, store-wide, after `verify` is green and every machine is upgraded | +| `secrets recipients list` | List the store's recipient public keys (and names if set) | +| `secrets recipients add [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set | +| `secrets recipients rm [--yes]` | Remove a recipient and re-encrypt the store; `--yes` required when removing your own key | +| `secrets reencrypt` | Re-encrypt every blob to the current recipients (idempotent — useful after a manual edit or partial failure) | ### Automatic project detection @@ -448,6 +454,64 @@ Some external secrets are whole binary files — an Android upload keystore, a c On `secrets push` the file is encrypted into `/external/`. On `secrets pull` it is restored to the same path with mode `600`; if a different version already exists there, it is backed up to `.secrets-bak` first. The same path rules apply (inside `$HOME`, no `..`, no symlinks). Like merged Gradle keys, restored files are permanent plaintext on disk — `secrets clear` does not remove them. +### Onboarding and offboarding teammates + +By default every team member uses the **same** `key.txt` (one shared private key). The multi-recipient feature lets each teammate have their **own** keypair while still sharing one store — so you never hand out a secret key to a new hire, and removing an ex-teammate's access is one command. + +#### Onboarding a teammate + +```bash +# 1. Teammate generates their own keypair on their machine (never shares the private key) +age-keygen -o ~/.secrets/key.txt # writes key.txt; prints the public key + +# 2. Teammate sends you their PUBLIC key (printed by age-keygen, starts with age1…) +# — over Slack, email, whatever. Public keys are not secret. + +# 3. An existing member adds the public key to the store +secrets recipients add age1theirpublickey --name alice +# => Adds alice to recipients.txt, re-encrypts every blob to the full set, pushes. + +# 4. Teammate clones the store repo and drops their key.txt in place +git clone git@github.com:/my-secrets.git ~/.secrets +# (key.txt already generated in step 1 — nothing to copy) + +# 5. Teammate pulls into any project +cd ~/myapp +secrets pull +# => Their key matches one recipient stanza in every blob — it just works. +``` + +Run `secrets recipients list` to confirm who has access: + +``` +alice age1theirpublickey… +you age1yourpublickey… +``` + +#### Offboarding a teammate + +```bash +# Remove the recipient by name (or public key) and re-encrypt the store +secrets recipients rm alice +# => Removes alice from recipients.txt, re-encrypts every blob, pushes. +# New blobs are no longer readable by alice's key. +``` + +> **Important:** git history can't be un-shared. If alice had access during a period when genuinely sensitive values were stored, rotate those values now (update them in the external system and run `secrets push`). The re-encrypt prevents future access; history is permanent. + +#### Managing recipients + +```bash +secrets recipients list # show all recipient keys and names +secrets recipients add age1… # add a key (bootstraps recipients.txt on a legacy store) +secrets recipients add age1… --name bob # attach a human-readable label +secrets recipients rm bob # remove by name +secrets recipients rm age1… # remove by public key +secrets reencrypt # re-encrypt to current recipients (idempotent heal) +``` + +`secrets which` shows a `recipients: N (alice, bob, …)` line so you can always confirm the active set from any project directory. + ## Safety features - **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C @@ -501,7 +565,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` ## Development ```bash -# Run the test suite (237 tests across three files) +# Run the test suite (272 tests across four files) brew install bats-core bats test/ diff --git a/VERSION b/VERSION index 44e7f9a..e1e4873 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.1.0 +0.6.2.0 diff --git a/secrets b/secrets index 421fc5f..e250d1b 100755 --- a/secrets +++ b/secrets @@ -2695,6 +2695,10 @@ Usage: secrets migrate [--dry-run] Copy-forward this project's v1 blobs to store format v2 secrets migrate --status Survey every project's v2 readiness (finalize gate) secrets migrate --finalize Drop v1 blobs and mark the store v2 (after verify) + secrets recipients list List the store's recipient keys + secrets recipients add KEY [--name N] Add a recipient and re-encrypt the store + secrets recipients rm KEY|NAME [--yes] Remove a recipient and re-encrypt the store + secrets reencrypt Re-encrypt every blob to the current recipients secrets which Show the active store, manifest, and external entries secrets where Alias for `which` secrets status Alias for `which`