Merge pull request 'v0.7.5.0 feat: multi-recipient age encryption (recipients/reencrypt, EGB-283)' (#14) from brian/egb-283-secrets-multi-recipient-age-encryption-multiple-keys-per into main
This commit is contained in:
commit
2558ea3c23
9 changed files with 2369 additions and 22 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/),
|
||||
and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
|
||||
|
||||
## [0.7.5.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.7.4.0] - 2026-06-18
|
||||
|
||||
### Added
|
||||
|
|
|
|||
23
CLAUDE.md
23
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 + upgrade.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, upgrade.
|
||||
Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, recipients, reencrypt, upgrade.
|
||||
|
||||
- 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 <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
|
||||
|
||||
## Project Structure
|
||||
|
|
@ -79,6 +97,7 @@ test/
|
|||
manifest.bats # EGB-677 .secrets.json manifest tests (83 tests)
|
||||
migrate.bats # EGB-703 store-format-v2 migration tests (35 tests)
|
||||
upgrade.bats # EGB-716 `secrets upgrade` self-update tests (8 tests)
|
||||
recipients.bats # EGB-283 multi-recipient age encryption tests (34 tests)
|
||||
test_helper.bash # Shared setup/teardown
|
||||
README.md # User-facing documentation
|
||||
CLAUDE.md # This file
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -129,7 +129,7 @@ to run `secrets pull` in any project.
|
|||
|
||||
### 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 secrets repo (add them as a collaborator)
|
||||
2. A copy of `key.txt` (send it directly — AirDrop, USB, or in-person)
|
||||
|
|
@ -148,6 +148,8 @@ git -C ~/dev/secrets pull
|
|||
If your store was last written by a newer client than yours, `secrets` prints a
|
||||
one-line version-skew nudge — that's your cue to run the command above.
|
||||
|
||||
**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
|
||||
|
|
@ -181,12 +183,16 @@ secrets clear
|
|||
| `secrets list` | Show all projects that have stored secrets |
|
||||
| `secrets list --json` | Same listing as a machine-readable JSON object (`{store, projects[].entries[]}`, each entry `dotenv`/`external`) for tooling and CI. JSON goes to stdout; notices to stderr |
|
||||
| `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 --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` | **Optional GC** — drop the old v1 blobs and mark the store pure v2. Never required: upgraded clients dual-write and read-fall-back, so not finalizing never cuts anyone off |
|
||||
| `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) |
|
||||
| `secrets upgrade` | Self-update the tool: `git pull --ff-only` on the `secrets` checkout, report old → new version, then re-check store version-skew. No auto-update, no background checks |
|
||||
| `secrets upgrade --check` | Report whether an update is available (without pulling); changes nothing |
|
||||
|
||||
|
|
@ -478,6 +484,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.
|
||||
|
||||
### 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.
|
||||
# Existing blobs are re-encrypted; the removed key can no longer decrypt them.
|
||||
```
|
||||
|
||||
> **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
|
||||
|
|
@ -531,7 +595,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/
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.7.4.0
|
||||
0.7.5.0
|
||||
|
|
|
|||
1207
docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md
Normal file
1207
docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 `# <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.)
|
||||
413
secrets
413
secrets
|
|
@ -17,7 +17,9 @@ set -euo pipefail
|
|||
# while letting .secrets-store files take precedence per project.
|
||||
_USER_SECRETS_DIR="${SECRETS_DIR:-}"
|
||||
SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}"
|
||||
RECIPIENTS_FILE_NAME="recipients.txt"
|
||||
KEY_FILE="$SECRETS_DIR/key.txt"
|
||||
RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Set by resolve_store(). Reports which rule chose SECRETS_DIR.
|
||||
|
|
@ -99,6 +101,88 @@ get_pubkey() {
|
|||
age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE"
|
||||
}
|
||||
|
||||
# A native age X25519 recipient: "age1" + exactly 58 chars of [0-9a-z].
|
||||
# This is also the injection rail — it cannot hold shell metacharacters,
|
||||
# whitespace, control chars, or extra flags. SSH recipients are intentionally
|
||||
# unsupported (EGB-283 scope cut).
|
||||
_validate_age_recipient() {
|
||||
case "$1" in
|
||||
age1*) : ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
local body="${1#age1}"
|
||||
[ "${#body}" -eq 58 ] || return 1
|
||||
case "$body" in
|
||||
*[!0-9a-z]*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# Recipient display names become "# <name>" comment lines in recipients.txt.
|
||||
# Restrict to a safe charset so a name can't inject extra lines/metacharacters.
|
||||
_validate_recipient_name() {
|
||||
case "$1" in
|
||||
*[!A-Za-z0-9\ ._-]*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Populate the global RECIPIENT_ARGS array with one "-r <key>" per store
|
||||
# recipient. recipients.txt present -> validated keys from the file (the store
|
||||
# is multi-recipient). Absent -> the single pubkey derived from key.txt (legacy
|
||||
# single-key store, exactly today's behavior). We parse the file ourselves
|
||||
# (never `age -R <path>`) because it is committed = an injection surface; every
|
||||
# line is validated and the file is refused if symlinked. Dies on any problem.
|
||||
RECIPIENT_ARGS=()
|
||||
_load_recipients() {
|
||||
RECIPIENT_ARGS=()
|
||||
if [ -L "$RECIPIENTS_FILE" ]; then
|
||||
die "Refusing to read symlinked $RECIPIENTS_FILE_NAME (security)."
|
||||
fi
|
||||
if [ ! -e "$RECIPIENTS_FILE" ]; then
|
||||
RECIPIENT_ARGS=(-r "$(get_pubkey)")
|
||||
return 0
|
||||
fi
|
||||
local line trimmed n=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
trimmed="${line#"${line%%[![:space:]]*}"}" # lstrip
|
||||
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rstrip
|
||||
[ -z "$trimmed" ] && continue
|
||||
case "$trimmed" in '#'*) continue ;; esac
|
||||
if ! _validate_age_recipient "$trimmed"; then
|
||||
die "Invalid recipient in $RECIPIENTS_FILE_NAME: '$trimmed' (expected a native age key: age1...)."
|
||||
fi
|
||||
RECIPIENT_ARGS+=(-r "$trimmed")
|
||||
n=$((n + 1))
|
||||
done < "$RECIPIENTS_FILE"
|
||||
if [ "$n" -eq 0 ]; then
|
||||
die "$RECIPIENTS_FILE_NAME has no recipients — a store must have at least one. Run 'secrets recipients add <age1...>'."
|
||||
fi
|
||||
}
|
||||
|
||||
# Emit "<key>\t<name>" for each recipient in recipients.txt. <name> is the most
|
||||
# recent preceding "# <name>" comment, or empty. Read-only; no validation
|
||||
# (callers that need rails call _load_recipients separately).
|
||||
_recipients_dump() {
|
||||
[ -e "$RECIPIENTS_FILE" ] || return 0
|
||||
local line trimmed name=""
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
trimmed="${line#"${line%%[![:space:]]*}"}"
|
||||
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
|
||||
[ -z "$trimmed" ] && continue
|
||||
case "$trimmed" in
|
||||
'#'*)
|
||||
name="${trimmed#\#}"
|
||||
name="${name#"${name%%[![:space:]]*}"}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\t%s\n' "$trimmed" "$name"
|
||||
name=""
|
||||
;;
|
||||
esac
|
||||
done < "$RECIPIENTS_FILE"
|
||||
}
|
||||
|
||||
derive_project_name() {
|
||||
local explicit="${1:-}"
|
||||
if [ -n "$explicit" ]; then
|
||||
|
|
@ -300,6 +384,7 @@ resolve_store() {
|
|||
|
||||
SECRETS_DIR="$resolved"
|
||||
KEY_FILE="$SECRETS_DIR/key.txt"
|
||||
RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME"
|
||||
STORE_SOURCE="$source"
|
||||
}
|
||||
|
||||
|
|
@ -727,7 +812,7 @@ merge_gradle_keys() {
|
|||
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if
|
||||
# there is no usable manifest. Dies on unsafe targets or all-missing keys.
|
||||
push_external_files() {
|
||||
local root="$1" project="$2" pubkey="$3"
|
||||
local root="$1" project="$2"
|
||||
# Entries come from .secrets.json (EGB-677) plus any legacy
|
||||
# .secrets-files entries the manifest doesn't cover yet.
|
||||
local entries
|
||||
|
|
@ -749,10 +834,12 @@ push_external_files() {
|
|||
# EGB-652: whole-file sync — encrypt the file verbatim (binary-safe).
|
||||
mkdir -p "$SECRETS_DIR/$project/external"
|
||||
local fslug; fslug=$(_secrets_files_slug "$mpath")
|
||||
# EGB-712 dual-write × EGB-283 multi-recipient: write every target
|
||||
# (v2 + any v1 twin) encrypted to the full recipient set.
|
||||
local wt
|
||||
while IFS= read -r wt; do
|
||||
[ -n "$wt" ] || continue
|
||||
age -r "$pubkey" -o "$wt" "$expanded"
|
||||
age "${RECIPIENT_ARGS[@]}" -o "$wt" "$expanded"
|
||||
done < <(_external_blob_write_targets "$project" "$fslug" file)
|
||||
info "Encrypted file $mpath"
|
||||
pushed=$((pushed + 1))
|
||||
|
|
@ -782,10 +869,12 @@ push_external_files() {
|
|||
fi
|
||||
mkdir -p "$SECRETS_DIR/$project/external"
|
||||
local slug; slug=$(_secrets_files_slug "$mpath")
|
||||
# EGB-712 dual-write × EGB-283 multi-recipient: write every target
|
||||
# (v2 + any v1 twin) encrypted to the full recipient set.
|
||||
local wt
|
||||
while IFS= read -r wt; do
|
||||
[ -n "$wt" ] || continue
|
||||
age -r "$pubkey" -o "$wt" "$tmp"
|
||||
age "${RECIPIENT_ARGS[@]}" -o "$wt" "$tmp"
|
||||
done < <(_external_blob_write_targets "$project" "$slug" "$mtype")
|
||||
rm -f "$tmp"
|
||||
info "Extracted $found key(s) from $mpath"
|
||||
|
|
@ -1315,6 +1404,11 @@ Your key file has been left untouched."
|
|||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
|
||||
# EGB-283: born-multi — seed recipients.txt with this store's public key so
|
||||
# the store is multi-recipient-ready from day one. Committed (not gitignored),
|
||||
# staged by the first push like .secrets-format.
|
||||
printf '# self\n%s\n' "$pubkey" > "$RECIPIENTS_FILE"
|
||||
|
||||
info "Done! Your public key is:"
|
||||
echo " $pubkey"
|
||||
|
||||
|
|
@ -1477,7 +1571,6 @@ cmd_join() {
|
|||
push_dir_to_project() {
|
||||
local source_dir="$1"
|
||||
local project="$2"
|
||||
local pubkey="$3"
|
||||
|
||||
if ! collect_env_files "$source_dir"; then
|
||||
return 1
|
||||
|
|
@ -1492,7 +1585,7 @@ push_dir_to_project() {
|
|||
for f in "${COLLECTED_FILES[@]}"; do
|
||||
local name
|
||||
name=$(basename "$f")
|
||||
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
||||
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1554,8 +1647,7 @@ cmd_push() {
|
|||
info "Pushing secrets for project: $project"
|
||||
echo_store_if_non_default
|
||||
|
||||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
_load_recipients
|
||||
|
||||
# ── Manifest read (validated; absence = bootstrap) ──
|
||||
# jq is required only when a manifest exists (authoritative, can't be
|
||||
|
|
@ -1643,7 +1735,7 @@ cmd_push() {
|
|||
*/*) mkdir -p "$SECRETS_DIR/$project/$(dirname "$rel")" ;;
|
||||
*) mkdir -p "$SECRETS_DIR/$project" ;;
|
||||
esac
|
||||
age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel"
|
||||
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel"
|
||||
echo " $rel"
|
||||
count=$((count + 1))
|
||||
done <<< "$sync_list"
|
||||
|
|
@ -1651,7 +1743,7 @@ cmd_push() {
|
|||
|
||||
local did=0
|
||||
[ "$count" -gt 0 ] && did=1
|
||||
if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi
|
||||
if push_external_files "$PWD" "$project"; then did=1; fi
|
||||
if [ "$did" -eq 0 ]; then
|
||||
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
|
||||
fi
|
||||
|
|
@ -1727,12 +1819,11 @@ cmd_push_workspaces() {
|
|||
info "Pushing workspaces for monorepo: $monorepo_name"
|
||||
echo_store_if_non_default
|
||||
|
||||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
_load_recipients
|
||||
local total=0
|
||||
|
||||
# Push root env files (if any)
|
||||
if push_dir_to_project "$root" "$monorepo_name" "$pubkey"; then
|
||||
if push_dir_to_project "$root" "$monorepo_name"; then
|
||||
total=$((total + ${#COLLECTED_FILES[@]}))
|
||||
fi
|
||||
|
||||
|
|
@ -1743,14 +1834,14 @@ cmd_push_workspaces() {
|
|||
[ -n "$ws" ] || continue
|
||||
local ws_dir="$root/$ws"
|
||||
local ws_project="$monorepo_name/$ws"
|
||||
if push_dir_to_project "$ws_dir" "$ws_project" "$pubkey"; then
|
||||
if push_dir_to_project "$ws_dir" "$ws_project"; then
|
||||
total=$((total + ${#COLLECTED_FILES[@]}))
|
||||
fi
|
||||
done <<< "$workspaces"
|
||||
|
||||
# External files (.secrets-files) are monorepo-root-scoped, like
|
||||
# .secrets-store — handle once, not per-workspace.
|
||||
if push_external_files "$root" "$monorepo_name" "$pubkey"; then
|
||||
if push_external_files "$root" "$monorepo_name"; then
|
||||
total=$((total + 1))
|
||||
fi
|
||||
|
||||
|
|
@ -2105,6 +2196,81 @@ cmd_rm() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Decrypt every blob in the store with the local key and re-encrypt each to the
|
||||
# currently-loaded RECIPIENT_ARGS, then commit + push. The caller MUST have run
|
||||
# _load_recipients (or set RECIPIENT_ARGS) and check_key first. Aborts with the
|
||||
# store untouched on any decrypt failure (you must be a current recipient).
|
||||
# Shared by recipients add/rm, reencrypt, and multi-recipient rekey.
|
||||
_reencrypt_all() {
|
||||
local commit_msg="$1"
|
||||
local tmpdir
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM
|
||||
|
||||
info "Decrypting all blobs with your key..."
|
||||
local file_count=0 dir project f rel dest
|
||||
for dir in "$SECRETS_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
project=$(basename "$dir")
|
||||
case "$project" in .*) continue ;; esac
|
||||
mkdir -p "$tmpdir/$project"
|
||||
while IFS= read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
rel=${f#"$dir"}; rel=${rel%.age}
|
||||
dest="$tmpdir/$project/$rel"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then
|
||||
die "Decryption failed for $project/$rel (are you a current recipient?). Aborted; store unchanged."
|
||||
fi
|
||||
file_count=$((file_count + 1))
|
||||
done < <(find "$dir" -type f -name '*.age')
|
||||
done
|
||||
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
rm -rf "$tmpdir"; trap - EXIT INT TERM
|
||||
info "No encrypted blobs in the store — nothing to re-encrypt."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local rc=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
|
||||
info "Re-encrypting $file_count blob(s) to $rc recipient(s)..."
|
||||
for dir in "$tmpdir"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
project=$(basename "$dir")
|
||||
mkdir -p "$SECRETS_DIR/$project"
|
||||
while IFS= read -r f; do
|
||||
[ -f "$f" ] || continue
|
||||
rel=${f#"$dir"}
|
||||
mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")"
|
||||
age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$f"
|
||||
done < <(find "$dir" -type f)
|
||||
done
|
||||
|
||||
ensure_store_protections
|
||||
git -C "$SECRETS_DIR" add -A
|
||||
git -C "$SECRETS_DIR" commit -m "$commit_msg" >/dev/null
|
||||
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "$SECRETS_DIR" push >/dev/null 2>&1
|
||||
info "Pushed re-encrypted secrets to remote"
|
||||
else
|
||||
info "Committed re-encrypted secrets locally (no remote configured)"
|
||||
fi
|
||||
rm -rf "$tmpdir"; trap - EXIT INT TERM
|
||||
}
|
||||
|
||||
cmd_reencrypt() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
resolve_store
|
||||
check_initialized
|
||||
check_key
|
||||
if [ ! -e "$RECIPIENTS_FILE" ]; then
|
||||
info "No $RECIPIENTS_FILE_NAME — single-key store; re-encrypting to your own key only. Add teammates with 'secrets recipients add'."
|
||||
fi
|
||||
_load_recipients
|
||||
_reencrypt_all "reencrypt: re-encrypt all to current recipients"
|
||||
}
|
||||
|
||||
cmd_rekey() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
|
|
@ -2112,6 +2278,18 @@ cmd_rekey() {
|
|||
check_initialized
|
||||
check_key
|
||||
|
||||
# EGB-283: on a multi-recipient store, rekey means "re-encrypt every blob to
|
||||
# the current recipients.txt set" — NOT a new keypair (rotating an identity is
|
||||
# the member's own age-keygen + recipients rm/add). Legacy stores (no
|
||||
# recipients.txt) keep the original generate-new-keypair behavior below.
|
||||
if [ -e "$RECIPIENTS_FILE" ]; then
|
||||
_load_recipients
|
||||
info "Multi-recipient store — re-encrypting to $RECIPIENTS_FILE_NAME (no new key generated)."
|
||||
_reencrypt_all "rekey: re-encrypt all to current recipients"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Legacy single-key rotation (unchanged) ──
|
||||
# Create temp dir with cleanup trap
|
||||
local tmpdir
|
||||
tmpdir=$(mktemp -d)
|
||||
|
|
@ -2311,6 +2489,131 @@ cmd_run() {
|
|||
exit "$rc"
|
||||
}
|
||||
|
||||
cmd_recipients() {
|
||||
resolve_store
|
||||
local sub="${1:-list}"
|
||||
[ $# -gt 0 ] && shift
|
||||
case "$sub" in
|
||||
list) _recipients_list ;;
|
||||
add) _recipients_add "$@" ;;
|
||||
rm|remove) _recipients_rm "$@" ;;
|
||||
*) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add <age1...> [--name N]|rm <age1...|name> [--yes]]" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_recipients_list() {
|
||||
check_initialized
|
||||
if [ ! -e "$RECIPIENTS_FILE" ]; then
|
||||
check_key
|
||||
echo "recipients: single-key (no $RECIPIENTS_FILE_NAME)"
|
||||
echo " $(get_pubkey)"
|
||||
return 0
|
||||
fi
|
||||
_load_recipients # validates the file (dies on bad key / symlink)
|
||||
local count=0 k n
|
||||
while IFS=$'\t' read -r k n; do count=$((count + 1)); done < <(_recipients_dump)
|
||||
echo "recipients: $count (from $RECIPIENTS_FILE_NAME)"
|
||||
while IFS=$'\t' read -r k n; do
|
||||
if [ -n "$n" ]; then echo " $k ($n)"; else echo " $k"; fi
|
||||
done < <(_recipients_dump)
|
||||
}
|
||||
|
||||
_recipients_add() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
check_initialized
|
||||
check_key
|
||||
local key="" name=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name) [ $# -ge 2 ] || die "--name requires a value."; name="$2"; shift 2 ;;
|
||||
-*) die "Unknown flag: $1. Usage: secrets recipients add <age1...> [--name <label>]" ;;
|
||||
*) if [ -z "$key" ]; then key="$1"; else die "Unexpected argument: $1"; fi; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$key" ] || die "Usage: secrets recipients add <age1...> [--name <label>]"
|
||||
_validate_age_recipient "$key" || die "Not a valid age recipient: '$key' (expected age1..., 62 chars; SSH keys unsupported)."
|
||||
if [ -n "$name" ]; then
|
||||
# Reject a whitespace-only label (would write a blank "# " comment line).
|
||||
local _name_stripped="${name//[[:space:]]/}"
|
||||
[ -n "$_name_stripped" ] || die "Invalid --name: must contain a non-space character."
|
||||
_validate_recipient_name "$name" || die "Invalid --name '$name' (allowed: letters, digits, space, . _ -)."
|
||||
fi
|
||||
if [ -L "$RECIPIENTS_FILE" ]; then
|
||||
die "Refusing to write symlinked $RECIPIENTS_FILE_NAME."
|
||||
fi
|
||||
# Validate BEFORE any mutation: a hand-corrupted file dies here, leaving
|
||||
# recipients.txt untouched (no half-mutate / blobs-not-reencrypted skew).
|
||||
# On a legacy store (no recipients.txt) _load_recipients succeeds via the
|
||||
# derived-pubkey path and does NOT die — so the bootstrap path still works.
|
||||
_load_recipients
|
||||
# Bootstrap a legacy store: seed this machine's key first so the operator
|
||||
# stays a recipient (and can decrypt to re-encrypt).
|
||||
if [ ! -e "$RECIPIENTS_FILE" ]; then
|
||||
printf '# self\n%s\n' "$(get_pubkey)" > "$RECIPIENTS_FILE"
|
||||
fi
|
||||
local k _n
|
||||
while IFS=$'\t' read -r k _n; do
|
||||
[ "$k" = "$key" ] && die "Recipient already present: $key"
|
||||
done < <(_recipients_dump)
|
||||
{ [ -n "$name" ] && printf '# %s\n' "$name"; printf '%s\n' "$key"; } >> "$RECIPIENTS_FILE"
|
||||
info "Added recipient${name:+ ($name)}: $key"
|
||||
_load_recipients
|
||||
_reencrypt_all "recipients: add ${name:-$key}; re-encrypt all"
|
||||
}
|
||||
|
||||
# Rewrite recipients.txt canonically (one "# name"? + key per entry), dropping
|
||||
# the entry whose key == <drop>. Atomic-ish via temp file in the same dir.
|
||||
_recipients_write_without() {
|
||||
local drop="$1" tmp k n
|
||||
tmp=$(mktemp "$SECRETS_DIR/.recipients.XXXXXX")
|
||||
while IFS=$'\t' read -r k n; do
|
||||
[ "$k" = "$drop" ] && continue
|
||||
[ -n "$n" ] && printf '# %s\n' "$n" >> "$tmp"
|
||||
printf '%s\n' "$k" >> "$tmp"
|
||||
done < <(_recipients_dump)
|
||||
mv "$tmp" "$RECIPIENTS_FILE"
|
||||
}
|
||||
|
||||
_recipients_rm() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
check_initialized
|
||||
check_key
|
||||
local target="" assume_yes=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--yes|-y) assume_yes=true; shift ;;
|
||||
-*) die "Unknown flag: $1. Usage: secrets recipients rm <age1...|name> [--yes]" ;;
|
||||
*) if [ -z "$target" ]; then target="$1"; else die "Unexpected argument: $1"; fi; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$target" ] || die "Usage: secrets recipients rm <age1...|name> [--yes]"
|
||||
[ -e "$RECIPIENTS_FILE" ] || die "No $RECIPIENTS_FILE_NAME — store is single-key; nothing to remove."
|
||||
[ -L "$RECIPIENTS_FILE" ] && die "Refusing to write symlinked $RECIPIENTS_FILE_NAME."
|
||||
# Validate BEFORE any mutation: a hand-corrupted file dies here, leaving
|
||||
# recipients.txt untouched (no half-mutate / blobs-not-reencrypted skew).
|
||||
_load_recipients
|
||||
local k n match="" count=0 total=0
|
||||
while IFS=$'\t' read -r k n; do
|
||||
total=$((total + 1))
|
||||
if [ "$k" = "$target" ] || { [ -n "$n" ] && [ "$n" = "$target" ]; }; then
|
||||
match="$k"; count=$((count + 1))
|
||||
fi
|
||||
done < <(_recipients_dump)
|
||||
[ "$count" -eq 0 ] && die "No recipient matches '$target'."
|
||||
[ "$count" -gt 1 ] && die "'$target' matches $count recipients by name — remove by key (age1...) instead."
|
||||
[ "$total" -le 1 ] && die "Refusing to remove the last recipient — a store must have at least one."
|
||||
local self; self="$(get_pubkey)"
|
||||
if [ "$match" = "$self" ] && [ "$assume_yes" != true ]; then
|
||||
die "Refusing to remove your own key (you would lose access to future pushes). Re-run with --yes to confirm."
|
||||
fi
|
||||
_recipients_write_without "$match"
|
||||
info "Removed recipient: $match"
|
||||
_load_recipients
|
||||
_reencrypt_all "recipients: remove $match; re-encrypt all"
|
||||
}
|
||||
|
||||
cmd_which() {
|
||||
resolve_store
|
||||
echo "store: $SECRETS_DIR"
|
||||
|
|
@ -2328,6 +2631,22 @@ cmd_which() {
|
|||
fi
|
||||
fi
|
||||
|
||||
# EGB-283: surface the recipient set (store-scoped; one key per team member).
|
||||
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
|
||||
local rcount=0 rk rn rnames=""
|
||||
while IFS=$'\t' read -r rk rn; do
|
||||
rcount=$((rcount + 1))
|
||||
[ -n "$rn" ] && rnames="${rnames:+$rnames, }$rn"
|
||||
done < <(_recipients_dump)
|
||||
if [ -n "$rnames" ]; then
|
||||
echo "recipients: $rcount ($rnames)"
|
||||
else
|
||||
echo "recipients: $rcount"
|
||||
fi
|
||||
else
|
||||
echo "recipients: single-key (no $RECIPIENTS_FILE_NAME)"
|
||||
fi
|
||||
|
||||
# v2 manifest (.secrets.json): validate and summarize. Validation here
|
||||
# is deliberately fatal (symlink / malformed / unsupported version) so
|
||||
# `secrets which` doubles as the manifest linter.
|
||||
|
|
@ -2466,12 +2785,45 @@ _verify_blob_decrypts() {
|
|||
age -d -i "$KEY_FILE" "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Count age recipient stanzas ("-> ...") in a blob's header. The age v1 header
|
||||
# is ASCII and ends at the "--- <mac>" line, so reading line-by-line stops
|
||||
# before any binary body. Echoes the count.
|
||||
_blob_recipient_count() {
|
||||
local f="$1" line count=0
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
'--- '*) break ;;
|
||||
'-> '*) count=$((count + 1)) ;;
|
||||
esac
|
||||
done < "$f"
|
||||
echo "$count"
|
||||
}
|
||||
|
||||
# If the store is multi-recipient (expected non-empty), assert <blob> was
|
||||
# encrypted to exactly <expected> recipients. Echoes a FINDING and returns 1 on
|
||||
# mismatch; returns 0 otherwise (incl. legacy stores where expected is empty).
|
||||
_check_blob_recipient_count() {
|
||||
local blob="$1" rel="$2" expected="$3"
|
||||
[ -n "$expected" ] || return 0
|
||||
local actual; actual=$(_blob_recipient_count "$blob")
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "FINDING: $rel is encrypted to $actual recipient(s) but $RECIPIENTS_FILE_NAME has $expected — run 'secrets reencrypt'." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# `secrets verify --all` — store-wide decrypt sweep. Decrypt-tests every blob
|
||||
# in every project. No manifest consistency check: the store carries only
|
||||
# ciphertext (manifests live in each project's repo), so orphan/missing
|
||||
# detection is impossible store-wide. This is the migration integrity gate.
|
||||
_verify_all() {
|
||||
local checked=0 failed=0 dir project f rel
|
||||
local rexpected=""
|
||||
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
|
||||
_load_recipients # validates; dies on a bad recipients.txt
|
||||
rexpected=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
|
||||
fi
|
||||
for dir in "$SECRETS_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
project=$(basename "$dir")
|
||||
|
|
@ -2486,6 +2838,12 @@ _verify_all() {
|
|||
rel="${f#"$SECRETS_DIR"/}"
|
||||
echo "FAIL: $rel does not decrypt with the current key." >&2
|
||||
failed=$((failed + 1))
|
||||
else
|
||||
# Only check recipient count when the blob actually decrypted — an
|
||||
# undecryptable blob would report "0 recipients" which is spurious.
|
||||
if ! _check_blob_recipient_count "$f" "${f#"$SECRETS_DIR"/}" "$rexpected"; then
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
fi
|
||||
done < <(find "$dir" -type f -name '*.age')
|
||||
done
|
||||
|
|
@ -2494,7 +2852,7 @@ _verify_all() {
|
|||
return 0
|
||||
fi
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
echo "verify --all: $failed of $checked blob(s) failed to decrypt." >&2
|
||||
echo "verify --all: $failed of $checked blob(s) failed (decrypt or recipient-count)." >&2
|
||||
return 1
|
||||
fi
|
||||
info "verify --all: OK — all $checked blob(s) decrypt with the current key (integrity only; run 'secrets verify' in a project for manifest consistency)."
|
||||
|
|
@ -2519,6 +2877,11 @@ _verify_project() {
|
|||
local pdir="$SECRETS_DIR/$project"
|
||||
|
||||
local findings=0 checked=0
|
||||
local rexpected=""
|
||||
if [ -e "$RECIPIENTS_FILE" ] && [ ! -L "$RECIPIENTS_FILE" ]; then
|
||||
_load_recipients # validates; dies on a bad recipients.txt
|
||||
rexpected=$(( ${#RECIPIENT_ARGS[@]} / 2 ))
|
||||
fi
|
||||
# `expected` accumulates the store-relative blob paths the manifest implies,
|
||||
# newline-framed (leading + trailing \n per entry) so the orphan walk can
|
||||
# test membership. bash 3.2 has no associative arrays — this string-set +
|
||||
|
|
@ -2548,6 +2911,12 @@ _verify_project() {
|
|||
if ! _verify_blob_decrypts "$blob"; then
|
||||
echo "FINDING: blob for '$rel' ($project/$rel.age) does not decrypt with the current key." >&2
|
||||
findings=$((findings + 1))
|
||||
else
|
||||
# Only check recipient count when the blob actually decrypted — an
|
||||
# undecryptable blob would report "0 recipients" which is spurious.
|
||||
if ! _check_blob_recipient_count "$blob" "$project/$rel.age" "$rexpected"; then
|
||||
findings=$((findings + 1))
|
||||
fi
|
||||
fi
|
||||
done < <(jq -r '.dotenv // [] | .[]' "$manifest")
|
||||
|
||||
|
|
@ -2573,6 +2942,12 @@ _verify_project() {
|
|||
if ! _verify_blob_decrypts "$eblob"; then
|
||||
echo "FINDING: external blob for '$epath' ($project/$erel) does not decrypt with the current key." >&2
|
||||
findings=$((findings + 1))
|
||||
else
|
||||
# Only check recipient count when the blob actually decrypted — an
|
||||
# undecryptable blob would report "0 recipients" which is spurious.
|
||||
if ! _check_blob_recipient_count "$eblob" "$project/$erel" "$rexpected"; then
|
||||
findings=$((findings + 1))
|
||||
fi
|
||||
fi
|
||||
done < <(_json_external_entries "$manifest")
|
||||
|
||||
|
|
@ -2864,6 +3239,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 Optional GC: drop v1 blobs and mark the store pure v2
|
||||
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`
|
||||
|
|
@ -3034,9 +3413,11 @@ case "${1:-help}" in
|
|||
add) cmd_add "${2:-}" ;;
|
||||
list) shift; cmd_list "$@" ;;
|
||||
rm) cmd_rm "${2:-}" ;;
|
||||
rekey) cmd_rekey ;;
|
||||
rekey) cmd_rekey ;;
|
||||
reencrypt) cmd_reencrypt ;;
|
||||
verify) shift; cmd_verify "$@" ;;
|
||||
migrate) shift; cmd_migrate "$@" ;;
|
||||
recipients) shift; cmd_recipients "$@" ;;
|
||||
which|where|status) cmd_which ;;
|
||||
upgrade) shift; cmd_upgrade "$@" ;;
|
||||
help|--help|-h) cmd_help ;;
|
||||
|
|
|
|||
407
test/recipients.bats
Normal file
407
test/recipients.bats
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
#!/usr/bin/env bats
|
||||
load test_helper
|
||||
|
||||
# A throwaway second identity for "another teammate".
|
||||
make_second_identity() {
|
||||
age-keygen -o "$TEST_TMPDIR/bob.txt" 2>/dev/null
|
||||
BOB_PUB=$(age-keygen -y "$TEST_TMPDIR/bob.txt")
|
||||
}
|
||||
|
||||
@test "push with only-self recipients.txt encrypts to the store key (born-multi)" {
|
||||
init_with_remote
|
||||
# init now seeds recipients.txt with self — born-multi store.
|
||||
[ -e "$SECRETS_DIR/recipients.txt" ]
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
[ "$status" -eq 0 ]
|
||||
# Blob must still decrypt with the store's own key.
|
||||
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "push with a hand-written recipients.txt encrypts to every listed key" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '# self\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
[ "$status" -eq 0 ]
|
||||
# Bob (a recipient) can decrypt the pushed blob with HIS key.
|
||||
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -eq 0 ]
|
||||
# And the store key still can too.
|
||||
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "push refuses a recipients.txt with an invalid key" {
|
||||
init_with_remote
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '%s\nnot-an-age-key\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Invalid recipient"* ]] || false
|
||||
}
|
||||
|
||||
@test "push refuses a symlinked recipients.txt" {
|
||||
init_with_remote
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
|
||||
# Remove the born-multi recipients.txt so we can replace it with a symlink.
|
||||
rm -f "$SECRETS_DIR/recipients.txt"
|
||||
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"symlink"* ]] || false
|
||||
}
|
||||
|
||||
@test "recipients list on a legacy store shows the single derived key" {
|
||||
init_with_remote
|
||||
# Simulate a legacy store by removing the born-multi recipients.txt.
|
||||
rm -f "$SECRETS_DIR/recipients.txt"
|
||||
run "$SECRETS_BIN" recipients list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"single-key"* ]] || false
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
[[ "$output" == *"$STORE_PUB"* ]] || false
|
||||
}
|
||||
|
||||
@test "recipients list shows names and keys from recipients.txt" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '# alice\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
||||
run "$SECRETS_BIN" recipients list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recipients: 2"* ]] || false
|
||||
[[ "$output" == *"alice"* ]] || false
|
||||
[[ "$output" == *"bob"* ]] || false
|
||||
}
|
||||
|
||||
@test "reencrypt re-encrypts existing blobs to a newly added recipient line" {
|
||||
init_with_remote
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push # single-key blob (project name = "myproj"; blob at $SECRETS_DIR/myproj/.env.age)
|
||||
[ "$status" -eq 0 ]
|
||||
make_second_identity
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
||||
# Bob cannot read the old single-key blob yet.
|
||||
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -ne 0 ]
|
||||
run "$SECRETS_BIN" reencrypt
|
||||
[ "$status" -eq 0 ]
|
||||
# Now he can.
|
||||
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "rekey on a multi-recipient store keeps recipients and the same key" {
|
||||
init_with_remote
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
before=$(cat "$SECRETS_DIR/key.txt")
|
||||
make_second_identity
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
||||
run "$SECRETS_BIN" rekey
|
||||
[ "$status" -eq 0 ]
|
||||
# No new keypair was generated.
|
||||
[ "$(cat "$SECRETS_DIR/key.txt")" = "$before" ]
|
||||
# Both recipients can decrypt.
|
||||
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "rekey on a legacy store still rotates to a new key (unchanged)" {
|
||||
init_with_remote
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
before=$(cat "$SECRETS_DIR/key.txt")
|
||||
# Simulate a legacy store by removing the born-multi recipients.txt.
|
||||
rm -f "$SECRETS_DIR/recipients.txt"
|
||||
run "$SECRETS_BIN" rekey
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(cat "$SECRETS_DIR/key.txt")" != "$before" ]
|
||||
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "recipients add bootstraps a legacy store and re-encrypts" {
|
||||
init_with_remote
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
# Simulate a legacy store (no recipients.txt) so recipients add triggers the
|
||||
# bootstrap branch (if [ ! -e "$RECIPIENTS_FILE" ]) rather than the append path.
|
||||
rm -f "$SECRETS_DIR/recipients.txt"
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
||||
[ "$status" -eq 0 ]
|
||||
[ -e "$SECRETS_DIR/recipients.txt" ]
|
||||
# recipients.txt now has self + bob (2 keys).
|
||||
run "$SECRETS_BIN" recipients list
|
||||
[[ "$output" == *"recipients: 2"* ]] || false
|
||||
[[ "$output" == *"bob"* ]] || false
|
||||
# Existing blob re-encrypted: bob can read it.
|
||||
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "recipients add rejects a non-age key" {
|
||||
init_with_remote
|
||||
run "$SECRETS_BIN" recipients add "ssh-ed25519 AAAAfoo"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"valid age recipient"* ]] || false
|
||||
}
|
||||
|
||||
@test "recipients add rejects a duplicate" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
||||
[ "$status" -eq 0 ]
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"already present"* ]] || false
|
||||
}
|
||||
|
||||
@test "recipients add rejects an unsafe --name" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name 'bob; rm -rf ~'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Invalid --name"* ]] || false
|
||||
}
|
||||
|
||||
@test "recipients add rejects a whitespace-only --name" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name ' '
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Invalid --name"* ]] || false
|
||||
}
|
||||
|
||||
@test "recipients add --name with no value errors" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"--name requires a value"* ]] || false
|
||||
}
|
||||
|
||||
@test "recipients rm removes a recipient and re-encrypts to the rest" {
|
||||
init_with_remote
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
||||
run "$SECRETS_BIN" recipients rm bob
|
||||
[ "$status" -eq 0 ]
|
||||
run "$SECRETS_BIN" recipients list
|
||||
[[ "$output" == *"recipients: 1"* ]] || false
|
||||
# Store key still reads its own blobs.
|
||||
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "recipients rm refuses to remove the last recipient" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # store = self + bob
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
run "$SECRETS_BIN" recipients rm bob # back to self only
|
||||
[ "$status" -eq 0 ]
|
||||
run "$SECRETS_BIN" recipients rm "$STORE_PUB" # would be the last
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"last recipient"* ]] || false
|
||||
}
|
||||
|
||||
@test "recipients rm of your own key requires --yes" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
run "$SECRETS_BIN" recipients rm "$STORE_PUB"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"your own key"* ]] || false
|
||||
run "$SECRETS_BIN" recipients rm "$STORE_PUB" --yes
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "recipients rm of a non-existent target errors" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
||||
run "$SECRETS_BIN" recipients rm carol
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"No recipient matches"* ]] || false
|
||||
}
|
||||
|
||||
@test "init seeds recipients.txt with the new store key (born-multi)" {
|
||||
run "$SECRETS_BIN" init
|
||||
[ "$status" -eq 0 ]
|
||||
[ -e "$SECRETS_DIR/recipients.txt" ]
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
run cat "$SECRETS_DIR/recipients.txt"
|
||||
[[ "$output" == *"$STORE_PUB"* ]] || false
|
||||
}
|
||||
|
||||
@test "which reports the recipient count" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recipients: 2"* ]] || false
|
||||
[[ "$output" == *"bob"* ]] || false
|
||||
}
|
||||
|
||||
@test "which reports single-key for a legacy store" {
|
||||
init_with_remote
|
||||
rm -f "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"recipients: single-key"* ]] || false
|
||||
}
|
||||
|
||||
@test "verify --all passes on a healthy multi-recipient store" {
|
||||
init_with_remote
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # re-encrypts to 2
|
||||
run "$SECRETS_BIN" verify --all
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "verify flags a blob whose recipient count drifted" {
|
||||
init_with_remote
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push # single-key blob (1 stanza)
|
||||
make_second_identity
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
# Declare 2 recipients but do NOT re-encrypt — the on-disk blob still has 1.
|
||||
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
|
||||
run "$SECRETS_BIN" verify --all
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"recipient"* ]] || false
|
||||
}
|
||||
|
||||
@test "SECURITY: a dangling symlink recipients.txt is refused, not silently ignored" {
|
||||
init_with_remote
|
||||
rm -f "$SECRETS_DIR/recipients.txt"
|
||||
ln -s "$TEST_TMPDIR/does-not-exist.txt" "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"symlink"* ]] || false
|
||||
}
|
||||
|
||||
@test "SECURITY: recipients.txt with shell metacharacters is rejected, no execution" {
|
||||
init_with_remote
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '%s\nage1$(touch %s/pwned)\n' "$STORE_PUB" "$TEST_TMPDIR" > "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
[ "$status" -ne 0 ]
|
||||
[ ! -e "$TEST_TMPDIR/pwned" ]
|
||||
[[ "$output" == *"Invalid recipient"* ]] || false
|
||||
}
|
||||
|
||||
@test "SECURITY: recipients.txt line that looks like an extra age flag is rejected" {
|
||||
init_with_remote
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '%s\n-i /etc/passwd\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Invalid recipient"* ]] || false
|
||||
# The push must have aborted before encrypting — no blob should exist.
|
||||
[ ! -e "$SECRETS_DIR/myproj/.env.age" ]
|
||||
}
|
||||
|
||||
@test "SECURITY: control/ANSI characters in recipients.txt are rejected" {
|
||||
init_with_remote
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '%s\nage1%b\n' "$STORE_PUB" 'aaaa\033[31mevil' > "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Invalid recipient"* ]] || false
|
||||
}
|
||||
|
||||
@test "SECURITY: recipients add rejects a key with embedded whitespace" {
|
||||
init_with_remote
|
||||
run "$SECRETS_BIN" recipients add "age1aaaa bbbb"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"valid age recipient"* ]] || false
|
||||
}
|
||||
|
||||
# ── Fix 1: validate-before-mutate ────────────────────────────────────────────
|
||||
|
||||
@test "recipients rm dies without mutating a hand-corrupted recipients.txt" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # valid: self + bob
|
||||
# Corrupt the file by hand.
|
||||
printf 'age1-not-a-valid-key\n' >> "$SECRETS_DIR/recipients.txt"
|
||||
before=$(cat "$SECRETS_DIR/recipients.txt")
|
||||
run "$SECRETS_BIN" recipients rm bob
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Invalid recipient"* ]] || false
|
||||
# File unchanged (no half-mutation).
|
||||
[ "$(cat "$SECRETS_DIR/recipients.txt")" = "$before" ]
|
||||
}
|
||||
|
||||
@test "recipients add dies without mutating a hand-corrupted recipients.txt" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
printf 'age1-not-a-valid-key\n' >> "$SECRETS_DIR/recipients.txt" # init seeded self; now corrupt
|
||||
before=$(cat "$SECRETS_DIR/recipients.txt")
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Invalid recipient"* ]] || false
|
||||
[ "$(cat "$SECRETS_DIR/recipients.txt")" = "$before" ]
|
||||
}
|
||||
|
||||
# ── Fix 4: reencrypt advisory on a legacy store ───────────────────────────────
|
||||
|
||||
@test "reencrypt on a legacy store prints a single-key advisory" {
|
||||
init_with_remote
|
||||
rm -f "$SECRETS_DIR/recipients.txt"
|
||||
create_project_dir myproj
|
||||
run "$SECRETS_BIN" push
|
||||
run "$SECRETS_BIN" reencrypt
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"single-key"* ]] || false
|
||||
}
|
||||
|
||||
# ── Fix 5: ambiguous-name rm coverage ────────────────────────────────────────
|
||||
|
||||
@test "recipients rm by an ambiguous name is refused" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
age-keygen -o "$TEST_TMPDIR/carol.txt" 2>/dev/null
|
||||
CAROL_PUB=$(age-keygen -y "$TEST_TMPDIR/carol.txt")
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name dup
|
||||
run "$SECRETS_BIN" recipients add "$CAROL_PUB" --name dup
|
||||
run "$SECRETS_BIN" recipients rm dup
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"matches"* ]] || false
|
||||
}
|
||||
|
||||
@test "SECURITY: a symlinked recipients.txt is refused on add and rm too" {
|
||||
init_with_remote
|
||||
make_second_identity
|
||||
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||
printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
|
||||
rm -f "$SECRETS_DIR/recipients.txt"
|
||||
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
|
||||
run "$SECRETS_BIN" recipients add "$BOB_PUB"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"symlink"* ]] || false
|
||||
}
|
||||
|
|
@ -19,6 +19,14 @@ setup() {
|
|||
# not be a real ancestor of /tmp). EGB-281 F9.
|
||||
export HOME="$TEST_TMPDIR"
|
||||
|
||||
# Provide git author identity so `git commit` works with the fresh temp HOME
|
||||
# (no ~/.gitconfig is present in the isolated dir). GIT_* env vars override
|
||||
# any global config and survive the HOME redirect.
|
||||
export GIT_AUTHOR_NAME="Test User"
|
||||
export GIT_AUTHOR_EMAIL="test@example.com"
|
||||
export GIT_COMMITTER_NAME="Test User"
|
||||
export GIT_COMMITTER_EMAIL="test@example.com"
|
||||
|
||||
# Secrets repo lives in temp
|
||||
export SECRETS_DIR="$TEST_TMPDIR/secrets-repo"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue