diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bee6fb..ce00bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,119 +5,6 @@ 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 @@ -475,6 +362,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://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 +[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 diff --git a/CLAUDE.md b/CLAUDE.md index a0261b8..9693fde 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 + upgrade.bats + recipients.bats +bats test/ # runs secrets.bats + manifest.bats + migrate.bats ./test/run-security.sh # security regression subset + operator sign-off (see below) ``` @@ -56,36 +56,17 @@ 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, recipients, reencrypt, sync, upgrade. +Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, 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. **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 (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). +- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq` - Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`) -- Multi-recipient (EGB-283): a store-scoped, committed `recipients.txt` (age `-R` - format, `# name` comments) lets one store encrypt every blob to N age keys — - one per team member. Managed via `secrets recipients add/rm/list`; absence of - the file ⇒ legacy single-key behavior (recipients = the pubkey derived from - `key.txt`). The file is parsed by us (never `age -R `) into a validated - `RECIPIENT_ARGS` array (native age X25519 only, `age1[0-9a-z]{58}`; SSH - recipients rejected; symlinked file refused) — same conservative posture as - `.secrets-store`/`.secrets-files`. `_load_recipients` populates the array; - every encrypt site routes through it. Any recipient change re-encrypts the - WHOLE store in one commit via the shared `_reencrypt_all` engine (also used by - the new `secrets reencrypt` and by `rekey` on a multi-recipient store, where - rekey re-encrypts to the set with NO new keypair; legacy stores keep rekey's - generate-new-keypair behavior). `init` seeds `recipients.txt` born-multi. - `which` prints `recipients: N`; `verify`/`verify --all` assert each blob's - age recipient-stanza count equals `recipients.txt`'s length. Removal takes - effect going forward — git history stays readable by an old key, so rotate - genuinely-sensitive values. Decryption is unchanged (each member uses their - own `key.txt`). - Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features ## Project Structure @@ -98,9 +79,6 @@ 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) - 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 @@ -138,43 +116,24 @@ Key design decisions (all driven by /autoplan review): ## Deploy Configuration -- Platform: NONE (distributed via `git clone` from the private Forgejo at `git.dev.egbt.com`) +- Platform: NONE (distributed via `git clone` from Codeberg) - 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. -## Forge operations (self-hosted Forgejo) +## Codeberg operations -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 +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 platform detection comes up "unknown": -**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 ...` +- PRs: `tea pr create --base main --title ... --description ...` / `tea pr merge ` +- Releases: `tea releases create --tag v --title "v" --note ...` (convention: one release per tag, title `v`) -- 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. +- Issues/status: `tea issues`, `tea pr list` +- No CI on this repo: the bats suite run locally is the merge gate. ## Environment variable diff --git a/README.md b/README.md index acfd2b4..aa4662c 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ 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 -git clone https://git.dev.egbt.com/egbt/secrets.git ~/dev/secrets +git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets cd ~/dev/secrets ./install.sh ``` @@ -82,7 +82,7 @@ this machine with one of the two flows below. ```bash # 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. +# Codeberg/GitLab 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 @@ -108,7 +108,7 @@ 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 +git clone https://codeberg.org/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): @@ -129,7 +129,7 @@ to run `secrets pull` in any project. ### Sharing with teammates -**Simple approach (shared key):** To share secrets with a teammate, they need: +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,8 +148,6 @@ 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 @@ -183,19 +181,12 @@ 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 (single-key store) or re-encrypt to the current recipients without changing keys (multi-recipient store) | +| `secrets rekey` | Generate a new encryption key and re-encrypt everything | | `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 | @@ -214,32 +205,6 @@ 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: @@ -425,10 +390,10 @@ git commit -am "switch to personal secrets" ### Monorepo support -For projects with multiple packages, add the `-w` flag to operate on all workspaces at once: +For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once: ```bash -cd ~/myapp # npm/yarn workspaces, or a pnpm-workspace.yaml +cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"] 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 @@ -445,22 +410,7 @@ Inside `~/.secrets/`, workspace secrets are organized by path: apps/api/.env.age # api workspace ``` -**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. +Requires `jq` (`brew install jq`). ### External files (Gradle properties) @@ -528,64 +478,6 @@ 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 @@ -639,7 +531,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` ## Development ```bash -# Run the test suite (272 tests across four files) +# Run the test suite (237 tests across three files) brew install bats-core bats test/ diff --git a/VERSION b/VERSION index d5ba5b5..584db57 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.7.0 +0.7.4.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 deleted file mode 100644 index 54a38d8..0000000 --- a/docs/superpowers/plans/2026-06-24-multi-recipient-age-encryption.md +++ /dev/null @@ -1,1207 +0,0 @@ -# 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