diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a51e6a..4bee6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,201 @@ 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.7.0] - 2026-09-08 + +### Fixed + +- **pnpm monorepos discovered no workspace secrets (EGB-1232)** — both + workspace call sites resolved patterns from `package.json`'s `workspaces` + key only, which pnpm does not use (it declares `packages:` in + `pnpm-workspace.yaml`). `secrets push -w` refused outright; plain `secrets + push` failed *silently* — `_maybe_workspace_env_files` returned 0 the moment + the key was absent, so the auto-discovery that exists to cover push's + root-only scan was inert on every pnpm repo and printed "Nothing new to + add", indistinguishable from a repo that genuinely had nothing. Workspace + patterns now resolve through one shared source that falls back to + `pnpm-workspace.yaml`. +- **yarn's object `workspaces` form was never expanded (EGB-1232)** — the + filter `.workspaces // .workspaces.packages | .[]` short-circuits on yarn's + truthy object, so `.workspaces.packages` was never evaluated and `.[]` + iterated the object's values, yielding the pattern array itself as a single + token. Only npm's array form worked. Now type-aware, handling npm's array, + yarn's object, and absent/null alike. + +### Added + +- **`pnpm-workspace.yaml` support** — the `packages:` block sequence is read + without a YAML dependency: block form only, stopping at the next top-level + key so pnpm 10's `onlyBuiltDependencies:`/`catalog:` cannot leak in as glob + patterns, with quote and inline-comment handling and a symlink refusal. + `package.json` wins when it declares workspaces; `pnpm-workspace.yaml` is the + fallback. jq is now required only when `package.json` is the source, so a + pnpm-only repo resolves workspaces jq-free. +- **Workspace patterns are validated before glob expansion** — no absolute + paths, `..` traversal, shell metacharacters, or whitespace reach the + unquoted expansion; pnpm `!` negations are skipped. Same conservative rail + as `.secrets-store` / `.secrets-files`. +- **A monorepo-shaped root that resolves no workspaces now says so** — if a + `pnpm-workspace.yaml` or `packages/` directory is present but no workspace + packages can be read, `push` warns on stderr and points at `secrets add`, + instead of returning in silence. `secrets push -w`'s error now names + `pnpm-workspace.yaml` when that is the file present, rather than blaming a + `package.json` the repo may not use for workspaces. + +## [0.7.6.0] - 2026-09-08 + +### Added + +- **`secrets sync` — reconcile a diverged store (EGB-1231)** — the store is a + git repo, and once a clone was both ahead and behind its remote the CLI had + no way out: `push` demanded a fast-forward and pointed at `pull`, which + could not fast-forward either, so the advice looped and recovery meant + hand-running git next to a directory of encrypted blobs. `secrets sync` + fetches, stashes uncommitted blob edits, rebases local commits onto the + remote, restores the stash, and then asks before publishing local commits to + the shared store. `--yes` skips the prompt (scripts/CI); `--dry-run` reports + ahead/behind/dirty and what would happen, changing nothing. Deliberately + non-destructive: no merge, no force-push, no `reset --hard`, no `stash + drop`. A rebase conflict aborts, restores the stash, names the conflicting + files, and leaves the store exactly as found. +- **Store state in `secrets which` (EGB-1231)** — a new `remote:` line reports + the store's `ahead N, behind N, N modified` (or `up to date`) against its + upstream, with a `(run: secrets sync)` hint when there is anything to + reconcile. Offline-safe (reports against the last fetch, never reaches the + network) and silent for a local-only store or one with no upstream. + +### Fixed + +- **`secrets pull` no longer fails silently when the store can't sync + (EGB-1230)** — the store sync was `git pull >/dev/null 2>&1` under `set -euo + pipefail`, so a store that could not fast-forward killed the script at that + line with git's exit 128 and *nothing* on stdout or stderr. The user saw a + banner, no restored files, and no reason — indistinguishable from a project + with nothing to pull, and easy to lose entirely in a pipeline. The sync is + now guarded, git's output is captured and surfaced as the diagnosis, and the + error names the store path and points at `secrets sync`. + +### Changed + +- **`secrets pull`'s store sync is now fast-forward only**, matching the push + path. A plain `git pull` could quietly manufacture a merge commit in the + store; divergence is now resolved in exactly one place — `secrets sync`. +- **The push path's dead-end advice** ("Run 'secrets pull' first, then retry + push") now points at `secrets sync` and includes git's own output. + +## [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 [--name N]` adds a key and immediately + re-encrypts the whole store; `secrets recipients rm [--yes]` + removes one and re-encrypts; `secrets recipients list` shows the current + set (or a note that the store is still single-key). A new `secrets + reencrypt` command re-encrypts every blob to the current recipients without + changing the set (idempotent heal / backfill after a manual edit). Absence + of `recipients.txt` preserves exact legacy single-key behavior; the first + `recipients add` on a legacy store bootstraps the file seeded with the + local pubkey plus the new key. `init` now seeds `recipients.txt` born-multi + with the freshly generated pubkey. + +### Changed + +- **`secrets rekey` on a multi-recipient store** no longer generates a new + keypair — instead it re-encrypts all blobs to the current `recipients.txt` + set (the shared `_reencrypt_all` engine). On a legacy store (no + `recipients.txt`) `rekey` keeps today's generate-new-keypair behavior. +- **`secrets which`** now prints a `recipients: N (name, …)` line, or + `recipients: single-key (no recipients.txt)` for a legacy store. +- **`secrets verify` / `verify --all`** assert that each blob's age + recipient-stanza count equals the number of entries in `recipients.txt` + (skipped on legacy stores). Exits non-zero on any count mismatch so it can + gate CI or a migration. + +## [0.7.4.0] - 2026-06-18 + +### Added + +- **`secrets upgrade` verb (EGB-716)** — the fix path paired with the EGB-713 + version-skew *warning*. Until now the warning told you you were behind but not + how to catch up; `secrets upgrade` closes that loop. + - **`secrets upgrade`** — `git -C "$SCRIPT_DIR" pull --ff-only` on the tool's + own checkout (fast-forward only — never merges or rewrites local commits), + reports `vOLD -> vNEW`, then best-effort re-checks the store's recorded + writer-version against the new version so you see whether the EGB-713 nudge + is now cleared (the new code itself takes effect on your next command). + - **`secrets upgrade --check`** — reports whether an update is available + (`git fetch` + compare to upstream) and changes nothing. + - Deliberately thin: no auto-update, no background polling (this is a security + tool). Directed errors for not-a-git-checkout, no upstream, a diverged/dirty + branch, or being offline. + +## [0.7.3.1] - 2026-06-18 + +### Changed + +- **EGB-677 stage-1 structural cleanups (EGB-701)** — tech-debt dedup with one + new safety warning; no behavior change for the manifest-driven (v2) happy path. + - **`secrets which` now reuses the one external-entry extractor** the push/pull + path uses (`_json_external_entries`) instead of its own duplicated `jq` + projection. So `which` applies the same `properties`→`gradle-properties` + normalization and skips (with a warning) the same malformed external entries + the sync path drops — `which` shows exactly what will sync, not a stale raw + projection that could drift from the real behavior. + - **The two external-manifest read guards are factored into shared helpers** — + `_json_readable` (plain regular file, silent) and `_legacy_readable` (warns + and skips a symlinked `.secrets-files`) — so `_external_entries_for_push` and + `_external_entries_for_pull` can't drift apart. + +### Fixed + +- **Legacy (manifest-less) `pull` no longer silently under-restores (EGB-701)** — + the manifest-less pull path globs only top-level `*.age`/`.*.age`, so a nested + dotenv blob (`/.age`) written by a manifest-driven push on + another machine was invisible: restored nothing, counted nothing, said nothing. + It now **warns** and names each nested blob it can't reach (external blobs are + excluded — `pull_external_files` handles those), pointing at committing a + `.secrets.json` as the fix. The manifest-driven pull already restored nesting + correctly; this only closes the legacy path's blind spot. + +## [0.7.3.0] - 2026-06-08 + +### Added + +- **Real install / onboarding scripts (EGB-671)** — onboarding a machine is now + (close to) one command, and a mis-copied key fails loudly instead of silently. + - **`secrets join --remote --key `** — second-machine onboarding in + one verb: clones the vault, installs the key at mode 600, and **verifies the + key actually decrypts the store before declaring success**. An empty vault + reports "nothing to verify yet" (it never prints a false `VERIFIED`); a wrong + key fails loudly with the store left in place to fix. All security logic + (store resolution, URL handling, path rails) is reused from the audited core, + not re-implemented in a side script. + - **`secrets init --remote `** — wires the remote and pushes the initial + store so the upstream branch exists, so your first project `push` doesn't trip + the fast-forward-pull guard on a brand-new empty remote. Run interactively, + `init` also offers to add your first project's secrets (default No, skipped + under `--yes` / non-interactive, so it stays a clean primitive for CI). + - **`install.sh`** — thin bootstrap that ships in the repo: checks `age` + `jq` + + `git`, then prints the `PATH` line, the onboarding next-steps, the upgrade + one-liner, and a key-transfer hint. It never edits your shell config and never + runs `sudo` (it prints the command so you stay in control). + - **First-manifest `options.autoAdd` prompt (EGB-677 contract #2)** — the first + `push` that scaffolds a project's manifest now records an explicit, committed + `options.autoAdd` value (asked once when interactive; the default ON, written + explicitly, under automation). + +### Fixed + +- **Day-2 silent decrypt failure** — `secrets pull` now dies loudly when a blob + fails to decrypt with the current key (all three decrypt paths), instead of + emitting a warning and continuing with exit 0. A wrong key can no longer pass + unnoticed after onboarding. +- The `secrets init` second-machine trap now points at `secrets join` (the real + one-command path) instead of a manual `git clone`. + ## [0.7.2.0] - 2026-06-08 ### Added @@ -280,6 +475,6 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. - 37 → 66 tests. New coverage: store resolution rules and precedence, walk-up boundaries, command-injection prevention, key-file re-derivation across stores, teammate-onboarding error path, monorepo workspace binding, F1–F5 adversarial regressions. -[0.1.1.0]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.1.0 -[0.1.0.1]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.0.1 -[0.1.0.0]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.0.0 +[0.1.1.0]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.1.0 +[0.1.0.1]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.0.1 +[0.1.0.0]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 608040a..a0261b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ cd ~/my-project && ./secrets pull # Pull + decrypt .env* files ```bash brew install bats-core -bats test/ # runs secrets.bats + manifest.bats + migrate.bats +bats test/ # runs secrets.bats + manifest.bats + migrate.bats + upgrade.bats + recipients.bats ./test/run-security.sh # security regression subset + operator sign-off (see below) ``` @@ -56,17 +56,36 @@ skips security specialist + red team, and Step 11 skips adversarial review. ## Architecture -Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate. +Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, recipients, reencrypt, sync, upgrade. - Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable) - Storage: Private git repo at `~/.secrets/` - Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`) - Manifest (EGB-677 stage 1): committed `.secrets.json` is the source of truth for what syncs — `dotenv[]` (project-relative, nested ok, `@` allowed; rail rejects `..`/absolute/symlink) + `external[]` (`properties`/`file`). Push discovery auto-adds (gated by committed `options.autoAdd`, default ON; `--frozen`/`--dry-run` overrides), bootstraps the manifest on first push (written only after ≥1 blob encrypts), and absorbs a legacy `.secrets-files` (gradle-properties → `properties`; on pull the legacy file is superseded with a warning). Store layout: nested dotenv entries land at `/.age` (relpath preserved — the store self-describes where a file restores). jq is a hard dep only when a manifest exists/is written; manifest-less projects run jq-free (manifest features skipped with a notice). `check_cmd` prints platform-aware install hints. -- Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line `$SECRETS_DIR/.secrets-format` file (`2`). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the external `properties` blob suffix: `.gradle-properties.age` → `.properties.age` (matching the manifest `type`); dotenv and `file` blobs are unchanged. `_store_format()` reads the marker. **Additive v2 (EGB-712):** reads resolve a `properties` blob by trying `.properties.age` then falling back to `.gradle-properties.age` (`_resolve_external_blob_read`); writes dual-write a `properties` external only when a v1 twin already exists in the store (`_external_blob_write_targets`), so existing externals keep old clients fresh while brand-new externals are written v2-only (a gentle forcing function). Blob location no longer depends on the marker — the old `_external_blob_suffix` is gone. `init` stamps a fresh store v2 (born-v2). `secrets which` prints the store-format line `format: vN`, and (EGB-700) when a `.secrets.json` is present the manifest header line also carries its schema version (`manifest (.secrets.json at , version N):`). **Migration is copy-forward and non-destructive:** `secrets migrate --dry-run` (per project, reports old→new, writes nothing) → `secrets migrate` (per project, manifest-free: enumerates the store's `*.gradle-properties.age` blobs directly — same source of truth as `--finalize` — and writes their `.properties.age` twins, so a legacy `.secrets-files`-only project with no `.secrets.json` migrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) → `secrets migrate --finalize` (store-wide; the ONLY destructive step — gates on `verify --all` green + every v1 blob having a v2 twin, cuts a `pre-v2-migrate-` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation since a lagging v1 client against a finalized store stops seeing `properties` externals until it upgrades). `secrets migrate --status` is a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE, plus a `v2-only` count of externals old clients can't read), exiting non-zero while any v1 blob is un-twinned so it gates the path to `--finalize` (EGB-710/EGB-712). **Under additive v2 (EGB-712) `--finalize` is now OPTIONAL GC, not a required milestone:** because upgraded clients dual-write existing externals and read-fall-back, *not* finalizing never cuts anyone off — finalize only reclaims the duplicate v1 blobs and stays deferrable indefinitely (defusing the cross-machine coordination gate). dotenv and `file` blobs are identical across formats, so they always propagate to old clients; only a brand-new `properties` external is v2-only. **Version-skew nudge (EGB-713):** a committed `$SECRETS_DIR/.secrets-writer-version` records the highest client `VERSION` that has written to the store (monotonic; stamped via `_stamp_writer_version` right before each store-committing `git add -A` — push/rekey/migrate/finalize — never on read paths, so it always rides a commit and never dangles to break `pull --ff-only`). `check_initialized` calls `_check_store_version_skew`, which warns once per invocation (stderr, non-fatal, `set -e`-safe) when the store's stamp is numerically greater than `_client_version` (read from `$SCRIPT_DIR/VERSION`); `secrets which` prints the `written-by:` line. Stores with no stamp (pre-EGB-713) are silent. The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka. +- Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line `$SECRETS_DIR/.secrets-format` file (`2`). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the external `properties` blob suffix: `.gradle-properties.age` → `.properties.age` (matching the manifest `type`); dotenv and `file` blobs are unchanged. `_store_format()` reads the marker. **Additive v2 (EGB-712):** reads resolve a `properties` blob by trying `.properties.age` then falling back to `.gradle-properties.age` (`_resolve_external_blob_read`); writes dual-write a `properties` external only when a v1 twin already exists in the store (`_external_blob_write_targets`), so existing externals keep old clients fresh while brand-new externals are written v2-only (a gentle forcing function). Blob location no longer depends on the marker — the old `_external_blob_suffix` is gone. `init` stamps a fresh store v2 (born-v2). `secrets which` prints the store-format line `format: vN`, and (EGB-700) when a `.secrets.json` is present the manifest header line also carries its schema version (`manifest (.secrets.json at , version N):`). **Migration is copy-forward and non-destructive:** `secrets migrate --dry-run` (per project, reports old→new, writes nothing) → `secrets migrate` (per project, manifest-free: enumerates the store's `*.gradle-properties.age` blobs directly — same source of truth as `--finalize` — and writes their `.properties.age` twins, so a legacy `.secrets-files`-only project with no `.secrets.json` migrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) → `secrets migrate --finalize` (store-wide; the ONLY destructive step — gates on `verify --all` green + every v1 blob having a v2 twin, cuts a `pre-v2-migrate-` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation since a lagging v1 client against a finalized store stops seeing `properties` externals until it upgrades). `secrets migrate --status` is a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE, plus a `v2-only` count of externals old clients can't read), exiting non-zero while any v1 blob is un-twinned so it gates the path to `--finalize` (EGB-710/EGB-712). **Under additive v2 (EGB-712) `--finalize` is now OPTIONAL GC, not a required milestone:** because upgraded clients dual-write existing externals and read-fall-back, *not* finalizing never cuts anyone off — finalize only reclaims the duplicate v1 blobs and stays deferrable indefinitely (defusing the cross-machine coordination gate). dotenv and `file` blobs are identical across formats, so they always propagate to old clients; only a brand-new `properties` external is v2-only. **Version-skew nudge (EGB-713):** a committed `$SECRETS_DIR/.secrets-writer-version` records the highest client `VERSION` that has written to the store (monotonic; stamped via `_stamp_writer_version` right before each store-committing `git add -A` — push/rekey/migrate/finalize — never on read paths, so it always rides a commit and never dangles to break `pull --ff-only`). `check_initialized` calls `_check_store_version_skew`, which warns once per invocation (stderr, non-fatal, `set -e`-safe) when the store's stamp is numerically greater than `_client_version` (read from `$SCRIPT_DIR/VERSION`); `secrets which` prints the `written-by:` line. Stores with no stamp (pre-EGB-713) are silent. The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka. **Upgrade verb (EGB-716):** `secrets upgrade` is the fix path paired with the EGB-713 skew *warning* — it `git -C "$SCRIPT_DIR" pull --ff-only`s the tool's own checkout (fast-forward only, never merges/rewrites local commits), reports `vOLD -> vNEW`, then best-effort re-checks `_store_writer_version` against the new on-disk version so the operator sees whether the nudge is cleared (the new code takes effect next invocation). `secrets upgrade --check` does `git fetch` + `rev-list --count HEAD..@{u}` and reports availability without pulling. Deliberately thin: no auto-update, no background polling (security tool). Directed errors for not-a-git-checkout / no-upstream / diverged / offline. `cmd_upgrade` never calls `check_initialized` (it's about the tool, not the store); the skew re-check is silent unless a store with a writer-version resolves. +- Store sync + divergence (EGB-1230/EGB-1231): the store is a git repo, so a clone can end up ahead of and behind its remote at once. **EGB-1230:** `cmd_pull`'s sync used to be `git pull >/dev/null 2>&1` under `set -euo pipefail` — a store that couldn't fast-forward killed the script there with git's exit 128 and nothing on either stream (a banner, no files, no reason; invisible in a pipeline). It now routes through `_store_sync_pull`, which guards the pull, captures git's output as the diagnosis, and dies naming the store path and `secrets sync`. That sync is **`--ff-only`**, matching the push path — a plain `git pull` could quietly manufacture a merge commit in the store, and divergence is now resolved in exactly one place. **EGB-1231:** `_store_git_state` emits `ahead\tbehind\tdirty` (from `rev-list --left-right --count @{u}...HEAD` plus `status --porcelain`) and `_format_store_state` renders it; `cmd_which` prints a `remote:` line from them — offline-safe (reports against the last fetch), silent with no remote/upstream. `cmd_sync` is the reconcile verb the CLI was missing: fetch → report state → stash (`push -u`) → `rebase @{u}` → restore stash → `ensure_store_protections` (rebased-in history may lack `.gitignore`, and a store missing the `key.txt` line would stage the private key — same reasoning as push) → **confirmation-gated** `git push` of local commits. The gate (`_sync_confirm_push`) reads `/dev/tty` and requires a tty, so it stays CLOSED in scripts/CI rather than publishing to a shared store by default; `--yes` opens it, `--dry-run` reports and returns before any mutation. Non-destructive by construction: no merge, no `--force`, no `reset --hard`, no `stash drop`. A rebase conflict collects the conflicting paths BEFORE `rebase --abort` (the abort clears them), restores the stash, and dies — store byte-identical to how it was found. `_sync_restore_stash` never drops the stash on a failed pop; it tells the operator where their only copy lives. `cmd_sync` does not `_stamp_writer_version`: it replays existing commits rather than authoring content, and the stamp is specified to ride a store-committing `git add -A`. Test suite: `test/sync.bats` (25 tests), including a grep over the `cmd_sync` body asserting the destructive git verbs never appear in it. - Verify (EGB-698): `secrets verify` is a read-only integrity check. Default mode (current project) cross-checks `$PWD/.secrets.json` against `$SECRETS_DIR//` both ways (declared-but-missing blobs + orphaned blobs) and decrypt-tests every blob (dotenv + external) by streaming plaintext to `/dev/null` (never written to disk). `secrets verify --all` decrypt-tests every blob in every project (integrity only — the store carries no manifests, so consistency can't be checked store-wide). Both recurse the whole project tree (`find -type f`, same as rekey/list). Exits non-zero on any finding so it can gate the stage-2 `migrate --finalize` and CI. The store deliberately holds no manifest — `.secrets.json` is committed in each project's own repo and read from `$PWD`. - 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 (EGB-1232): `--workspaces` and the plain-push workspace re-scan both resolve patterns through ONE source — `_workspace_patterns()`. It reads `package.json` `.workspaces` via `$WORKSPACES_JQ` (type-aware: handles npm's array AND yarn's object `{packages:[...]}` form) and falls back to `pnpm-workspace.yaml`'s `packages:` block when package.json declares none. **Two defects fixed:** (1) both call sites were package.json-only, so no pnpm monorepo ever resolved a workspace — and `_maybe_workspace_env_files` failed *silently* (`jq -e '.workspaces' ... || return 0`), making auto-discovery inert and `push` print "Nothing new to add", indistinguishable from a repo with nothing new; it bit the same repo twice. (2) the old filter `.workspaces // .workspaces.packages | .[]` short-circuits on yarn's truthy object, iterating the object's values and yielding the pattern ARRAY as a single token. Note a naive reorder does NOT fix it — `.workspaces.packages` errors on an array; hence the `if type == "object"` form. `_pnpm_workspace_packages()` is a deliberate non-parser (block sequence only, stops at the next top-level key so pnpm 10's `onlyBuiltDependencies:`/`catalog:` can't leak in as globs, strips quotes/inline comments, refuses a symlinked file). Patterns are validated by `_valid_workspace_pattern` before they reach the unquoted `for pattern in $patterns` glob expansion (no absolute/`..`/metacharacters/whitespace; pnpm `!` negations skipped) — same posture as `.secrets-store`/`.secrets-files`. `_looks_like_monorepo` + `_workspace_source` turn the old silent return into a warning that names the real file, and `get_workspaces`'s error names `pnpm-workspace.yaml` when that's what's present instead of blaming package.json. jq is required only when package.json is the source. **Scope note:** the workspace re-scan still runs only for projects that already have a `.secrets.json` — push's root-scan-only behavior on a first push is by design (EGB-677 E13), and EGB-1232 is about the fallback that covers it never engaging. Tests: `test/workspaces.bats` (18). - Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`) +- Multi-recipient (EGB-283): a store-scoped, committed `recipients.txt` (age `-R` + format, `# name` comments) lets one store encrypt every blob to N age keys — + one per team member. Managed via `secrets recipients add/rm/list`; absence of + the file ⇒ legacy single-key behavior (recipients = the pubkey derived from + `key.txt`). The file is parsed by us (never `age -R `) into a validated + `RECIPIENT_ARGS` array (native age X25519 only, `age1[0-9a-z]{58}`; SSH + recipients rejected; symlinked file refused) — same conservative posture as + `.secrets-store`/`.secrets-files`. `_load_recipients` populates the array; + every encrypt site routes through it. Any recipient change re-encrypts the + WHOLE store in one commit via the shared `_reencrypt_all` engine (also used by + the new `secrets reencrypt` and by `rekey` on a multi-recipient store, where + rekey re-encrypts to the set with NO new keypair; legacy stores keep rekey's + generate-new-keypair behavior). `init` seeds `recipients.txt` born-multi. + `which` prints `recipients: N`; `verify`/`verify --all` assert each blob's + age recipient-stanza count equals `recipients.txt`'s length. Removal takes + effect going forward — git history stays readable by an old key, so rotate + genuinely-sensitive values. Decryption is unchanged (each member uses their + own `key.txt`). - Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features ## Project Structure @@ -76,8 +95,12 @@ secrets # CLI script (~2000 lines bash) hooks/pre-commit # Pre-commit hook template test/ secrets.bats # bats-core test suite (140 tests) - manifest.bats # EGB-677 .secrets.json manifest tests (78 tests) - migrate.bats # EGB-703 store-format-v2 migration tests (26 tests) + 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) + sync.bats # EGB-1230/1231 store sync + divergence reconcile tests (25 tests) + workspaces.bats # EGB-1232 npm/yarn/pnpm workspace discovery tests (18 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 @@ -107,7 +130,7 @@ The active store directory is picked by `resolve_store()` using these rules, hig Key design decisions (all driven by /autoplan review): - **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel). -- **Storage:** blobs live in `$SECRETS_DIR//external/.gradle-properties.age`. The `external/` subdir keeps them out of the legacy non-recursive `*.age` / `.*.age` globs the dotenv `pull` path uses, so a dotenv pull can never decrypt an external blob into cwd. `cmd_rekey` and `cmd_list` instead walk the **entire** project tree (`find -type f`), so they cover both `external/.age` and nested manifest dotenv blobs (`/.age`) — rekey MUST recurse, or any nested/external blob is orphaned under the old key after rotation = data loss (EGB-677 regression test: "rekey re-encrypts a nested manifest dotenv blob"). `` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path). `cmd_list --json` (EGB-699) emits the same recursive walk as a machine-readable object (`{store, projects[].entries[]}`, each entry `dotenv`→`path` or `external`→`subtype`+`path`) for tooling/CI (feeds EGB-671); jq assembles it so paths escape correctly and stdout stays pure JSON (the human store hint is suppressed; jq is a hard dep only in `--json` mode). +- **Storage:** blobs live in `$SECRETS_DIR//external/.gradle-properties.age`. The `external/` subdir keeps them out of the legacy non-recursive `*.age` / `.*.age` globs the dotenv `pull` path uses, so a dotenv pull can never decrypt an external blob into cwd. `cmd_rekey` and `cmd_list` instead walk the **entire** project tree (`find -type f`), so they cover both `external/.age` and nested manifest dotenv blobs (`/.age`) — rekey MUST recurse, or any nested/external blob is orphaned under the old key after rotation = data loss (EGB-677 regression test: "rekey re-encrypts a nested manifest dotenv blob"). **EGB-701 cleanups:** (1) the *legacy* (manifest-less) `pull` keeps its non-recursive globs but now **warns** when nested `/.age` blobs exist that those globs can't see (it excludes `external/`, which `pull_external_files` handles) — so a manifest-less pull never silently under-restores; the fix the warning points at is committing a `.secrets.json`. (2) `cmd_which`, push, and pull share one external extractor (`_json_external_entries`), so `which` applies the same `properties`→`gradle-properties` normalization and skip-with-warning rules the sync path does (it shows exactly what will sync, not a stale raw projection). (3) the two external-manifest read guards are factored into `_json_readable` (plain regular file, silent) / `_legacy_readable` (warn-and-skip on a symlinked legacy manifest). `` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path). `cmd_list --json` (EGB-699) emits the same recursive walk as a machine-readable object (`{store, projects[].entries[]}`, each entry `dotenv`→`path` or `external`→`subtype`+`path`) for tooling/CI (feeds EGB-671); jq assembles it so paths escape correctly and stdout stays pure JSON (the human store hint is suppressed; jq is a hard dep only in `--json` mode). - **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `.secrets-bak` before each merge. - **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins. - **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped). @@ -115,24 +138,43 @@ Key design decisions (all driven by /autoplan review): ## Deploy Configuration -- Platform: NONE (distributed via `git clone` from Codeberg) +- Platform: NONE (distributed via `git clone` from the private Forgejo at `git.dev.egbt.com`) - Production URL: N/A (no live service) - Release model: merge to `main` is the release. Optionally tagged with `v`. - Verification after merge: a fresh `git clone` should produce a working `secrets which` against an isolated `$HOME`. No canary URL. - Staging: none. - Rollback: revert the merge commit on `main` (and delete the tag) to roll back. -## Codeberg operations +## Forge operations (self-hosted Forgejo) -The remote is Codeberg (Forgejo) — `gh`/`glab` do NOT work here. Use `tea` -(login name: `codeberg`, user `egbt`) for forge operations when a skill's +The remote is a private Forgejo instance at `https://git.dev.egbt.com` +(migrated off Codeberg 2026-09-08). `gh`/`glab` do NOT work here. Use `tea` +(login name: `egbt`, user `brian`) for forge operations when a skill's platform detection comes up "unknown": -- PRs: `tea pr create --base main --title ... --description ...` / `tea pr merge ` -- Releases: `tea releases create --tag v --title "v" --note ...` +**Always pass `--login egbt --repo egbt/secrets` explicitly.** `tea`'s repo +autodetection fails here ("remote repository required"), and this machine also +has a leftover `codeberg` login pointing at the *old* forge +(`https://codeberg.org`) that `tea` will silently fall back to in +non-interactive mode — which would target the wrong server. Confirm with +`tea logins list` if a command errors. + +- PRs: `tea pr create --login egbt --repo egbt/secrets --base main --head --title ... --description ...` / `tea pr merge --login egbt --repo egbt/secrets` +- Releases: `tea releases create --login egbt --repo egbt/secrets --tag v --title "v" --note ...` (convention: one release per tag, title `v`) -- Issues/status: `tea issues`, `tea pr list` -- No CI on this repo: the bats suite run locally is the merge gate. +- Issues/status: `tea issues --login egbt --repo egbt/secrets`, `tea pr list --login egbt --repo egbt/secrets` +- **SSH is on port 2222**, not 22 (port 22 is the host's own sshd). Clone/remote + URLs must be `ssh://git@git.dev.egbt.com:2222/egbt/secrets.git`. A bare + `git@git.dev.egbt.com:egbt/secrets.git` will fail with "Permission denied + (publickey)" because it hits the wrong daemon. +- The host resolves to a Tailscale address — the forge is reachable only on the + VPN. Off-net, push/pull/`tea` all fail to connect; that is expected, not a + credentials problem. +- `FORGEJO_URL` and `FORGEJO_TOKEN` (API token for user `brian`) live in + `~/.zshenv` for direct API calls. +- CI: the instance has an Actions runner available, but no workflow is + configured for this repo yet. The bats suite run locally is still the merge + gate. ## Environment variable diff --git a/README.md b/README.md index e345d64..acfd2b4 100644 --- a/README.md +++ b/README.md @@ -58,83 +58,97 @@ Beyond project files, `secrets` can also sync files that live *outside* the proj ## Prerequisites -- **macOS** (uses Homebrew for installation) -- **git** (already installed on most Macs — type `git --version` to check) -- **age** (the encryption tool — installed in step 1 below) +- **macOS or Linux** +- **git** (`git --version` to check) +- **age** and **jq** — `install.sh` checks for these and prints the exact install command for your platform (Homebrew on macOS, `apt`/`dnf` on Linux) ## Setup -### First machine (one-time setup) +Clone the tool repo, then run `install.sh`. It checks dependencies and prints the +two commands to finish setup. It never edits your shell config and never runs +sudo — it prints the commands so you stay in control. ```bash -# 1. Install the encryption tool -brew install age - -# 2. Download the secrets tool (this repo — contains only the CLI, no secret files) -git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets - -# 3. Make the 'secrets' command available everywhere -# Add this line to your shell config file (~/.zshrc on Mac): -export PATH="$HOME/dev/secrets:$PATH" -# Then restart your terminal, or run: -source ~/.zshrc - -# 4. Initialize your encrypted secrets store -# This creates a folder at ~/.secrets/ with your encryption key -secrets init - -# 5. Create a PRIVATE repository on GitHub to store your encrypted secrets -# Go to github.com/new, name it something like 'my-secrets', and make sure -# "Private" is selected. Then connect it: -cd ~/.secrets -git remote add origin git@github.com:/my-secrets.git -git push -u origin main +git clone https://git.dev.egbt.com/egbt/secrets.git ~/dev/secrets +cd ~/dev/secrets +./install.sh ``` -> **Important:** Step 5 creates a *separate* private repo for your encrypted secrets. This is different from the `secrets` tool repo you cloned in step 2. The tool repo can be public — it contains no secrets. The `~/.secrets/` repo must be private. +`install.sh` prints a `export PATH="$HOME/dev/secrets:$PATH"` line — add it to your +shell config (`~/.zshrc` or `~/.bashrc`) and restart your terminal. Then onboard +this machine with one of the two flows below. -### Additional machines - -On each new machine (your desktop, a teammate's laptop, etc.): +### First machine (new vault) ```bash -# 1. Install prerequisites and the tool (same as steps 1-3 above) -brew install age -git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets -export PATH="$HOME/dev/secrets:$PATH" # add to ~/.zshrc +# 1. Create a PRIVATE repo for your encrypted secrets (github.com/new or a +# GitLab/Forgejo private repo). It holds only ciphertext — never your key. +# Then wire it up and push the store in one command: +secrets init --remote git@github.com:/my-secrets.git -# 2. Clone the encrypted secrets repo -git clone git@github.com:/my-secrets.git ~/.secrets - -# 3. Copy the encryption key from your first machine -# This is the only step that requires direct machine-to-machine transfer. -# Choose one method: -# -# Option A: AirDrop (Mac to Mac) -# On your first machine, right-click ~/.secrets/key.txt → Share → AirDrop -# Save it to ~/.secrets/key.txt on the new machine -# -# Option B: Secure copy over SSH -# scp first-machine:~/.secrets/key.txt ~/.secrets/key.txt -# -# Option C: USB drive -# Copy key.txt to a USB drive, transfer it, delete from USB after - -# 4. Pull your secrets into any project +# 2. (optional) Add a project's secrets. From a project directory: cd ~/myapp -secrets pull +secrets push +# The first push asks once whether to auto-track new env files and records +# your choice in the project's .secrets.json. ``` +`secrets init --remote` generates your key (`~/.secrets/key.txt`), wires the +remote, and pushes the initial store so the upstream branch exists. The private +secrets repo is separate from this tool repo — the tool repo is public and holds +no secrets; the `~/.secrets/` repo must be private. + +> Running `secrets init` interactively (in a terminal) also offers to add your +> first project's secrets right away. Run it with `--yes` (or in any non-tty +> context like CI) to skip that prompt and just create the vault. + +### Other machines (join an existing vault) + +On a second machine, a desktop, or a teammate's laptop: + +```bash +# 1. Clone the tool and run the bootstrap (as in Setup above) +git clone https://git.dev.egbt.com/egbt/secrets.git ~/dev/secrets +cd ~/dev/secrets && ./install.sh # add the printed PATH line to your shell config + +# 2. Get key.txt onto this machine (the one manual, out-of-band step): +# AirDrop (Mac→Mac), or +# scp first-machine:~/.secrets/key.txt ~/Downloads/key.txt, or +# a USB drive (delete from the drive afterward) + +# 3. Join the vault in one command: +secrets join --remote git@github.com:/my-secrets.git --key ~/Downloads/key.txt +``` + +`secrets join` clones the vault, installs the key at mode 600, and **verifies the +key actually decrypts the store before declaring success** — a mis-copied key +fails loudly here, not silently on a later `secrets pull`. On success it tells you +to run `secrets pull` in any project. + > **The key file (`~/.secrets/key.txt`) is the only thing that needs to be transferred manually.** It never leaves your machines — it's excluded from git, never uploaded, never transmitted over the internet. Anyone with this file can decrypt all your secrets, so treat it like a password. ### Sharing with teammates -To share secrets with a teammate, they need: +**Simple approach (shared key):** To share secrets with a teammate, they need: -1. Access to your private `my-secrets` GitHub repo (add them as a collaborator) -2. A copy of `key.txt` (send it to them directly — AirDrop, USB, or in-person) +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) -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. A teammate joins with +`secrets join --remote --key `. When anyone runs +`secrets push`, the encrypted files update and everyone else runs `secrets pull` +to get the latest. + +### Updating the tool + +```bash +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 @@ -169,12 +183,21 @@ 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 ` | 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 [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set | +| `secrets recipients rm [--yes]` | Remove a recipient and re-encrypt the store; `--yes` required when removing your own key | +| `secrets reencrypt` | Re-encrypt every blob to the current recipients (idempotent — useful after a manual edit or partial failure) | +| `secrets sync` | Reconcile a store that has diverged from its remote: stash local blob edits, rebase onto the remote, restore the stash, then offer to publish your local commits. Never merges, force-pushes, or hard-resets | +| `secrets sync --dry-run` | Report the store's ahead/behind/dirty state and what a reconcile would do; changes nothing | +| `secrets sync --yes` | Reconcile and publish local commits without the confirmation prompt (for scripts) | +| `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 | ### Upgrading: do teammates on an older `secrets` get new secrets? @@ -191,6 +214,32 @@ Store-format v2 is **additive** — an upgraded client reads either blob suffix And you'll be told when you're behind: if a store was last written by a newer `secrets` than the one you're running, any command prints a one-line nudge to stderr (non-fatal) — and `secrets which` shows the store's `written-by:` version. Stores written by older builds (no version stamp) stay silent. +### When the store diverges + +The store is a git repo, so two machines pushing at once can leave your clone +both ahead and behind its remote. `secrets push` needs a fast-forward and +`secrets pull` won't silently merge, so both stop and tell you to run: + +```bash +secrets sync +``` + +`sync` fetches, stashes any uncommitted blob edits, rebases your local commits +onto the remote, restores the stash, and then *asks* before publishing your +commits to the shared store (`--yes` skips the prompt; `--dry-run` just +reports). If the rebase conflicts, it aborts, restores your stash, names the +conflicting files, and leaves the store exactly as it found it — nothing in +the path force-pushes, hard-resets, or drops a stash. + +`secrets which` now reports the same state up front, so you can see it coming: + +``` +store: /Users/you/.secrets +source: default +format: v2 +remote: ahead 1, behind 11, 3 modified (run: secrets sync) +``` + ### Automatic project detection When you run `secrets push` or `secrets pull` without specifying a project name, the tool figures out which project you're in by: @@ -376,10 +425,10 @@ git commit -am "switch to personal secrets" ### Monorepo support -For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once: +For projects with multiple packages, add the `-w` flag to operate on all workspaces at once: ```bash -cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"] +cd ~/myapp # npm/yarn workspaces, or a pnpm-workspace.yaml secrets push -w # encrypts secrets from root + each workspace secrets pull -w # decrypts into root + each workspace directory secrets clear -w # clears secrets from root + each workspace @@ -396,7 +445,22 @@ Inside `~/.secrets/`, workspace secrets are organized by path: apps/api/.env.age # api workspace ``` -Requires `jq` (`brew install jq`). +**Where workspaces are declared.** All three package managers are supported: + +| Manager | Declaration | +|---|---| +| npm | `package.json` → `"workspaces": ["apps/*"]` | +| yarn | `package.json` → `"workspaces": {"packages": ["apps/*"]}` | +| pnpm | `pnpm-workspace.yaml` → `packages:` block | + +`package.json` wins when it declares any workspaces; `pnpm-workspace.yaml` is +the fallback. Reading `package.json` requires `jq` (`brew install jq`); a +pnpm-only repo needs no jq for workspace discovery. + +If a root *looks* like a monorepo (a `pnpm-workspace.yaml` or a `packages/` +directory) but no workspace packages can be read from it, `secrets push` says +so on stderr rather than silently discovering nothing — that silence +previously made a pnpm repo indistinguishable from one with nothing to sync. ### External files (Gradle properties) @@ -464,6 +528,64 @@ Some external secrets are whole binary files — an Android upload keystore, a c On `secrets push` the file is encrypted into `/external/`. On `secrets pull` it is restored to the same path with mode `600`; if a different version already exists there, it is backed up to `.secrets-bak` first. The same path rules apply (inside `$HOME`, no `..`, no symlinks). Like merged Gradle keys, restored files are permanent plaintext on disk — `secrets clear` does not remove them. +### Onboarding and offboarding teammates + +By default every team member uses the **same** `key.txt` (one shared private key). The multi-recipient feature lets each teammate have their **own** keypair while still sharing one store — so you never hand out a secret key to a new hire, and removing an ex-teammate's access is one command. + +#### Onboarding a teammate + +```bash +# 1. Teammate generates their own keypair on their machine (never shares the private key) +age-keygen -o ~/.secrets/key.txt # writes key.txt; prints the public key + +# 2. Teammate sends you their PUBLIC key (printed by age-keygen, starts with age1…) +# — over Slack, email, whatever. Public keys are not secret. + +# 3. An existing member adds the public key to the store +secrets recipients add age1theirpublickey --name alice +# => Adds alice to recipients.txt, re-encrypts every blob to the full set, pushes. + +# 4. Teammate clones the store repo and drops their key.txt in place +git clone git@github.com:/my-secrets.git ~/.secrets +# (key.txt already generated in step 1 — nothing to copy) + +# 5. Teammate pulls into any project +cd ~/myapp +secrets pull +# => Their key matches one recipient stanza in every blob — it just works. +``` + +Run `secrets recipients list` to confirm who has access: + +``` +alice age1theirpublickey… +you age1yourpublickey… +``` + +#### Offboarding a teammate + +```bash +# Remove the recipient by name (or public key) and re-encrypt the store +secrets recipients rm alice +# => Removes alice from recipients.txt, re-encrypts every blob, pushes. +# 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 @@ -517,7 +639,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` ## Development ```bash -# Run the test suite (237 tests across three files) +# Run the test suite (272 tests across four files) brew install bats-core bats test/ diff --git a/VERSION b/VERSION index 9872478..d5ba5b5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.2.0 +0.7.7.0 diff --git a/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md b/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md new file mode 100644 index 0000000..54a38d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md @@ -0,0 +1,1207 @@ +# Multi-recipient age encryption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a single `secrets` store encrypt every blob to N age recipient public keys (a per-store team-key set) instead of one shared key, managed via `secrets recipients add/rm/list`, with full backward compatibility for existing single-key stores. + +**Architecture:** A committed, store-scoped `recipients.txt` (age `-R` format) holds the recipient set. A new `_load_recipients` parses + validates it into a global `RECIPIENT_ARGS=(-r k1 -r k2 …)` array that every encrypt site uses; absence of the file means legacy single-key behavior (`-r $(get_pubkey)`). A shared `_reencrypt_all` engine (factored from today's `rekey`) decrypts the whole store with the local key and re-encrypts to the current set, and is called by `recipients add/rm`, the new `reencrypt`, and multi-recipient `rekey`. Decryption is unchanged — each member uses their own `key.txt`. + +**Tech Stack:** Single POSIX-ish bash script (`secrets`), system bash 3.2 compatible (indexed arrays OK, NO associative arrays). `age` / `age-keygen` for crypto. `git` for the store. `bats-core` for tests. `jq` is NOT introduced anywhere in this feature (recipients.txt is plain text, keeping store ops jq-free). + +## Global Constraints + +- **bash 3.2 only:** no associative arrays, no bash-4 features. Indexed arrays (`RECIPIENT_ARGS=()`, `arr+=(x)`, `"${arr[@]}"`) are fine. +- **bats `[[ ]]` gotcha:** every standalone `[[ … ]]` assertion in a test MUST end with `|| false`. Single-bracket `[ ]` is unaffected. +- **age recipient format accepted:** native age X25519 only — `age1` + exactly 58 chars of `[0-9a-z]`. SSH recipients are out of scope (reject them). This regex/charset is also the injection rail: it cannot contain shell metacharacters, whitespace, or extra flags. +- **`recipients.txt` is committed, NOT gitignored** (public keys are not secret). The store `.gitignore` only blocks `key.txt` and plaintext env files, so the file is tracked automatically — do not add it to `.gitignore`. +- **Security-review policy (`.ship-policy.json`, CLAUDE.md):** adversarial fixtures in this plan are ordinary bats regression tests, NOT AI red-team passes. Do NOT run `./test/run-security.sh` on the user's behalf. Before ship, the human operator runs it and signs off. +- **Re-encrypt invariant:** any change to the recipient set re-encrypts the WHOLE store in one commit. `RECIPIENT_ARGS` is always populated by `_load_recipients` before any `age "${RECIPIENT_ARGS[@]}"` call (never reference the array empty under `set -u`). +- **Commit cadence:** one commit per task (TDD: test → impl → green → commit). + +## File map + +- `secrets` — all code changes (helpers, `recipients`/`reencrypt` commands, encrypt-site refactor, `init`/`which`/`verify`/`rekey` edits, dispatch + help). +- `test/recipients.bats` — NEW suite for all multi-recipient behavior + security fixtures. +- `CLAUDE.md`, `README.md` — docs + test counts. + +## Conventions referenced + +- Constants like `SECRETS_FILES_NAME=".secrets-files"` live ~line 360; `KEY_FILE` is set both as a global default (~line 20) and re-set inside `resolve_store` (~line 301). Mirror this for `RECIPIENTS_FILE`. +- Existing encrypt sites (all `age -r "$pubkey" -o …`): `push_dir_to_project` (~1207), `cmd_push` inline (~1357), `push_external_files` (~655 and ~684), `cmd_rekey` (~1778). `get_pubkey` (~98) derives the pubkey from `key.txt`. +- Tests run via `run "$SECRETS_BIN" ` with isolated `$HOME` and `$SECRETS_DIR`; helpers `init_with_remote`, `create_project_dir` live in `test/test_helper.bash`. + +--- + +### Task 1: Recipient core (`RECIPIENTS_FILE`, validation, `_load_recipients`) wired into the push encrypt path + +**Files:** +- Modify: `secrets` (constants ~line 19-21; `resolve_store` ~301; new helpers after `get_pubkey` ~99; encrypt sites ~655, ~684, ~1207, ~1357; `cmd_push` ~1268; `cmd_push_workspaces` ~1428; `push_dir_to_project`/`push_external_files` signatures) +- Test: `test/recipients.bats` (new) + +**Interfaces:** +- Produces: global `RECIPIENT_ARGS` (indexed array of `-r ` pairs); `RECIPIENTS_FILE` / `RECIPIENTS_FILE_NAME`; `_validate_age_recipient ` (0 = valid age1 key); `_load_recipients` (populates `RECIPIENT_ARGS`, dies on bad/symlinked/empty file). +- Consumes: `get_pubkey`, `SECRETS_DIR`, `KEY_FILE`. + +- [ ] **Step 1: Write failing tests** in new `test/recipients.bats`: + +```bash +#!/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 without recipients.txt stays single-key (legacy behavior)" { + init_with_remote + create_project_dir myproj + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + # No recipients.txt was created by push. + [ ! -e "$SECRETS_DIR/recipients.txt" ] + # Blob decrypts 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" + ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt" + create_project_dir myproj + run "$SECRETS_BIN" push + [ "$status" -ne 0 ] + [[ "$output" == *"symlink"* ]] || false +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `bats test/recipients.bats` +Expected: FAIL (recipients.txt is ignored today; multi-recipient blob won't decrypt with bob's key; invalid/symlink cases don't error). + +- [ ] **Step 3: Add the constant + `RECIPIENTS_FILE` plumbing** + +Near `KEY_FILE="$SECRETS_DIR/key.txt"` (~line 20), add the name constant just above it and the path just below: + +```bash +RECIPIENTS_FILE_NAME="recipients.txt" +KEY_FILE="$SECRETS_DIR/key.txt" +RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME" +``` + +Inside `resolve_store`, right after the line that re-sets `KEY_FILE="$SECRETS_DIR/key.txt"` (~line 301), add: + +```bash + RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME" +``` + +- [ ] **Step 4: Add `_validate_age_recipient` and `_load_recipients`** immediately after `get_pubkey` (~line 99): + +```bash +# 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 +} + +# Populate the global RECIPIENT_ARGS array with one "-r " 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 `) 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 [ ! -e "$RECIPIENTS_FILE" ]; then + RECIPIENT_ARGS=(-r "$(get_pubkey)") + return 0 + fi + if [ -L "$RECIPIENTS_FILE" ]; then + die "Refusing to read symlinked $RECIPIENTS_FILE_NAME (security)." + 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 '." + fi +} +``` + +- [ ] **Step 5: Route every encrypt site through `RECIPIENT_ARGS`** + +In `push_dir_to_project`, change the encrypt line (~1207): +```bash + age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${name}.age" "$f" +``` +and delete its now-unused `local pubkey="$3"` line (~1192). + +In `cmd_push`, change the inline encrypt (~1357): +```bash + age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel" +``` + +In `push_external_files`, change both encrypt lines (~655 and ~684) to `age "${RECIPIENT_ARGS[@]}" -o …` (keep the rest of each line identical) and delete its `local pubkey="$3"` from the signature line `local root="$1" project="$2" pubkey="$3"` → `local root="$1" project="$2"`. + +- [ ] **Step 6: Load recipients in the push commands and drop the old `pubkey` threading** + +In `cmd_push` (~1268-1269) replace: +```bash + local pubkey + pubkey=$(get_pubkey) +``` +with: +```bash + _load_recipients +``` +and change the external call (~1365) `push_external_files "$PWD" "$project"` (drop `"$pubkey"`). + +In `cmd_push_workspaces` (~1428) replace the `pubkey=$(get_pubkey)` pair with `_load_recipients`, and drop the `"$pubkey"` argument from the `push_dir_to_project …` (~1432, ~1443) and `push_external_files …` (~1450) calls. + +- [ ] **Step 7: Run the tests** + +Run: `bats test/recipients.bats` +Expected: PASS (4 tests). + +- [ ] **Step 8: Run the full suite to confirm no regression** + +Run: `bats test/` +Expected: PASS (all existing tests still green — legacy push/pull unchanged). + +- [ ] **Step 9: Commit** + +```bash +git add secrets test/recipients.bats +git commit -m "feat: multi-recipient encrypt core + recipients.txt (EGB-283)" +``` + +--- + +### Task 2: `secrets recipients list` + +**Files:** +- Modify: `secrets` (new `_recipients_dump`, `cmd_recipients`, `_recipients_list`; dispatch + nothing in help yet) +- Test: `test/recipients.bats` + +**Interfaces:** +- Produces: `_recipients_dump` (emits `\t` per recipient, name = nearest preceding `# ` comment or empty); `cmd_recipients …` (routes `list`); `_recipients_list`. +- Consumes: `_load_recipients`, `RECIPIENTS_FILE`, `get_pubkey`. + +- [ ] **Step 1: Write failing tests** + +```bash +@test "recipients list on a legacy store shows the single derived key" { + init_with_remote + 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 +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `bats test/recipients.bats -f "recipients list"` +Expected: FAIL ("Unknown command: recipients"). + +- [ ] **Step 3: Add `_recipients_dump`** (place after `_load_recipients`): + +```bash +# Emit "\t" for each recipient in recipients.txt. is the most +# recent preceding "# " 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" +} +``` + +- [ ] **Step 4: Add `cmd_recipients` + `_recipients_list`** (place near `cmd_which`): + +```bash +cmd_recipients() { + resolve_store + local sub="${1:-list}" + [ $# -gt 0 ] && shift + case "$sub" in + list) _recipients_list ;; + *) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list]" ;; + 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) +} +``` + +- [ ] **Step 5: Wire dispatch.** In the `case "${1:-help}"` block, add above `which|where|status`: +```bash + recipients) shift; cmd_recipients "$@" ;; +``` + +- [ ] **Step 6: Run the tests** + +Run: `bats test/recipients.bats -f "recipients list"` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add secrets test/recipients.bats +git commit -m "feat: secrets recipients list (EGB-283)" +``` + +--- + +### Task 3: Shared `_reencrypt_all` engine + `secrets reencrypt` + dual `rekey` + +**Files:** +- Modify: `secrets` (new `_reencrypt_all`, `cmd_reencrypt`; rewrite `cmd_rekey` head to branch; dispatch) +- Test: `test/recipients.bats` + +**Interfaces:** +- Produces: `_reencrypt_all ` (decrypt whole store with `KEY_FILE`, re-encrypt to `RECIPIENT_ARGS`, commit + push; aborts with store intact on decrypt failure; no-op on empty store); `cmd_reencrypt`. +- Consumes: `_load_recipients`, `RECIPIENT_ARGS`, `KEY_FILE`, `ensure_store_protections`. + +- [ ] **Step 1: Write failing tests** + +```bash +@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") + 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 ] +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `bats test/recipients.bats -f "reencrypt|rekey on"` +Expected: FAIL ("Unknown command: reencrypt"; multi rekey generates a new key today). + +- [ ] **Step 3: Add `_reencrypt_all`** (place just before `cmd_rekey`): + +```bash +# 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 + _load_recipients + _reencrypt_all "reencrypt: re-encrypt all to current recipients" +} +``` + +- [ ] **Step 4: Branch `cmd_rekey`.** Replace the head of `cmd_rekey` — from its `check_cmd age` line down to and including the `info "Decrypting all files with current key..."` line — with the block below. **Leave the rest of the existing legacy body (temp dir, decrypt loop, keygen, re-encrypt loop, commit/push) exactly as-is** below this insertion: + +```bash +cmd_rekey() { + check_cmd age + check_cmd git + resolve_store + 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) ── + info "Decrypting all files with current key..." +``` + +- [ ] **Step 5: Wire dispatch.** Add near `rekey)`: +```bash + reencrypt) cmd_reencrypt ;; +``` + +- [ ] **Step 6: Run the tests** + +Run: `bats test/recipients.bats -f "reencrypt|rekey"` +Expected: PASS (3 tests). + +- [ ] **Step 7: Run the full suite** (the legacy rekey tests in `secrets.bats` must still pass) + +Run: `bats test/` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add secrets test/recipients.bats +git commit -m "feat: shared _reencrypt_all + reencrypt cmd + dual rekey (EGB-283)" +``` + +--- + +### Task 4: `secrets recipients add` + +**Files:** +- Modify: `secrets` (`_recipients_add`, `_validate_recipient_name`; extend `cmd_recipients` case) +- Test: `test/recipients.bats` + +**Interfaces:** +- Produces: `_recipients_add [--name