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:
parent
b09f94c92f
commit
f7576a3eae
5 changed files with 125 additions and 7 deletions
31
CHANGELOG.md
31
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/),
|
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
|
||||||
|
|
|
||||||
25
CLAUDE.md
25
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
70
README.md
70
README.md
|
|
@ -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/
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.6.1.0
|
0.6.2.0
|
||||||
|
|
|
||||||
4
secrets
4
secrets
|
|
@ -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`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue