docs: multi-recipient age encryption (recipients/reencrypt) + bump 0.6.2.0 (EGB-283)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-24 13:25:33 -07:00
parent b09f94c92f
commit f7576a3eae
5 changed files with 125 additions and 7 deletions

View file

@ -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/), 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. 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 <age1…> [--name N]` adds a key and immediately
re-encrypts the whole store; `secrets recipients rm <key|name> [--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 ## [0.6.1.0] - 2026-06-08
### Changed ### Changed

View file

@ -16,7 +16,7 @@ cd ~/my-project && ./secrets pull # Pull + decrypt .env* files
```bash ```bash
brew install bats-core 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) ./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 ## 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) - Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
- Storage: Private git repo at `~/.secrets/` - 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 - 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` - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
- Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`) - 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 <path>`) 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 - Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features
## Project Structure ## Project Structure
@ -77,7 +95,8 @@ hooks/pre-commit # Pre-commit hook template
test/ test/
secrets.bats # bats-core test suite (133 tests) secrets.bats # bats-core test suite (133 tests)
manifest.bats # EGB-677 .secrets.json manifest tests (78 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 test_helper.bash # Shared setup/teardown
README.md # User-facing documentation README.md # User-facing documentation
CLAUDE.md # This file CLAUDE.md # This file

View file

@ -129,13 +129,15 @@ secrets pull
### Sharing with teammates ### 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) 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) 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. 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 ## Usage
### Daily workflow ### Daily workflow
@ -168,12 +170,16 @@ secrets clear
| `secrets run <command>` | Pull secrets, run a command, then clear secrets when it exits | | `secrets run <command>` | Pull secrets, run a command, then clear secrets when it exits |
| `secrets list` | Show all projects that have stored secrets | | `secrets list` | Show all projects that have stored secrets |
| `secrets rm <project>` | Delete a project's secrets from the store | | `secrets rm <project>` | 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 [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 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 [--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 --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 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 <age1…> [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set |
| `secrets recipients rm <key\|name> [--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 ### 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 `<project>/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 `<name>.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. On `secrets push` the file is encrypted into `<project>/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 `<name>.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:<you>/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 ## Safety features
- **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C - **`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 ## Development
```bash ```bash
# Run the test suite (237 tests across three files) # Run the test suite (272 tests across four files)
brew install bats-core brew install bats-core
bats test/ bats test/

View file

@ -1 +1 @@
0.6.1.0 0.6.2.0

View file

@ -2695,6 +2695,10 @@ Usage:
secrets migrate [--dry-run] Copy-forward this project's v1 blobs to store format v2 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 --status Survey every project's v2 readiness (finalize gate)
secrets migrate --finalize Drop v1 blobs and mark the store v2 (after verify) 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 which Show the active store, manifest, and external entries
secrets where Alias for `which` secrets where Alias for `which`
secrets status Alias for `which` secrets status Alias for `which`