secrets/CHANGELOG.md

270 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Changelog
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.1.0] - 2026-06-08
### Added
- **Version-skew nudge (EGB-713)** — the store now records the highest `secrets`
version that has written to it (`.secrets-writer-version`, committed,
monotonic). When you run a command against a store last written by a *newer*
`secrets` than your own, you get a one-line non-fatal stderr nudge to update
your tool; `secrets which` shows the store's `written-by:` version (and flags
when you're behind). Stores written by older builds carry no stamp and stay
silent — no false alarms. The loud counterpart to EGB-712's quiet
forcing function.
## [0.7.0.0] - 2026-06-08
### Changed
- **Additive store-format v2 (EGB-712)** — upgraded `secrets` clients now read
either external blob suffix (`.properties.age` or the legacy
`.gradle-properties.age`) and **dual-write** a `properties` external whenever a
v1 twin already exists in the store. Existing externals keep working for
teammates on an older `secrets`; only a brand-new `properties` external is
written v2-only (a gentle "upgrade to see it" forcing function). dotenv and
whole-`file` externals are unchanged across formats and always propagate.
- **`secrets migrate --finalize` is now optional GC**, not a required milestone.
Because clients dual-write and read-fall-back, no teammate is ever cut off by
*not* finalizing; finalize only reclaims the duplicate v1 blobs, and stays
deferrable indefinitely. Its safety gates are unchanged. This defuses the
cross-machine "all clients must be v2 before finalize" coordination gate.
### Added
- **`secrets migrate --status`** now reports `v2-only` externals per project
(the ones an un-upgraded client cannot read), so you can see the forcing
function's footprint at a glance.
## [0.6.1.0] - 2026-06-08
### Changed
- **`secrets migrate` copy-forward is now manifest-free (EGB-710)** — the
per-project step enumerates the store's `*.gradle-properties.age` blobs
directly (the same source of truth `--finalize` uses) instead of reading
`.secrets.json`. A legacy `.secrets-files`-only project now migrates cleanly
instead of dead-ending with "No .secrets.json", and a store blob the manifest
no longer declares still gets a v2 twin (so `--finalize` won't refuse it).
Running migrate in a project with no v1 properties blobs is a clean no-op.
### Added
- **`secrets migrate --status`** — a read-only survey that walks every project
in the store and reports its v2 readiness (v2-ready / migrated / NEEDS
MIGRATE), then whether the store as a whole is finalize-ready. Exits non-zero
while any v1 blob is un-twinned, so it can gate the path to `--finalize`.
## [0.6.0.1] - 2026-06-08
### Added
- **`secrets which` now prints the manifest version (EGB-700)** — the manifest
header line shows `version N` alongside the store format, so a single
`secrets which` tells you both the on-disk store format and the `.secrets.json`
schema version at a glance.
## [0.6.0.0] - 2026-06-07
### Added
- **Self-describing store format (v2) + `secrets migrate` (EGB-703)** — the
store now records its format in a committed `.secrets-format` file, and
`secrets which` prints it (`format: v2`). A fresh `secrets init` creates a
v2 store; existing stores read as v1 until migrated.
- **`secrets migrate`** — copy-forward a project's encrypted blobs to the v2
layout. It is non-destructive: the old blobs are kept until you finalize, so
a half-migrated store stays fully readable and recoverable. `secrets migrate
--dry-run` previews exactly what would change without writing anything.
- **`secrets migrate --finalize`** — the one destructive step, run once
store-wide. It refuses unless `secrets verify` passes and every blob has its
new-format twin, cuts a `pre-v2-migrate-*` recovery tag first, then drops the
old blobs. It asks for confirmation (or `--yes`) because a machine still on
an older `secrets` will stop seeing migrated external files until it updates.
### Changed
- The external `properties` blob is stored as `<name>.properties.age` in a v2
store (was `<name>.gradle-properties.age`), matching the manifest `type`.
`push`, `pull`, and `verify` pick the right name automatically from the store
format, so v1 and v2 stores both keep working during a migration.
## [0.5.0.0] - 2026-06-07
### Added
- **`secrets verify` (EGB-698)** — a read-only integrity check. Run it in a
project to cross-check the committed `.secrets.json` against the store both
ways (entries declared but missing from the store, and stored blobs with no
manifest entry) and decrypt-test every blob with your current key. Catches a
partially-synced store, a stale key, or a manifest that has drifted from the
store. Plaintext is streamed to `/dev/null` and never written to disk.
- **`secrets verify --all`** — decrypt-tests every blob in every project in the
store: a fast store-wide integrity sweep. (The store carries no manifests, so
`--all` checks decryptability only, not manifest consistency.)
- Both modes recurse the whole project tree, so nested entries and external
files are covered. `secrets verify` exits non-zero on any problem, so it can
gate CI or a future store migration.
## [0.4.0.0] - 2026-06-07
### Added
- **`.secrets.json` manifest (EGB-677 stage 1)** — a committed, project-root
manifest is now the source of truth for what syncs. List the env files you
want under `dotenv[]` (project-relative, nested paths and `@`-scoped
workspaces allowed; `..`, absolute, and symlink paths are rejected) and
out-of-project files under `external[]` (`properties` or `file`). The
manifest is shared across machines, so a teammate who clones the project
sees exactly what to pull.
- **`secrets add <path>`** — declare an env file in the manifest without
pushing. Bootstraps `.secrets.json` on first use, dedupes, and writes a
stable canonical form.
- **Auto-add on push** — `secrets push` discovers new `.env*` / `.dev.vars`
files and adds them to the manifest (prints what it added and reminds you to
commit). Gated by `options.autoAdd` in the manifest (default on);
`push --frozen` syncs only declared files, and `push --dry-run` previews
what would change without writing anything.
- **Manifest-driven pull** — restores every declared file, recreating nested
directories as needed, with the same path-safety rail applied at restore
time so a malicious committed manifest can't write outside the project. An
empty manifest is a safe no-op.
- **Legacy `.secrets-files` absorb** — an existing `.secrets-files` is folded
into `.secrets.json` on first push (gradle-properties entries become
`properties`); on pull the legacy file is superseded with a warning.
- **Platform-aware install hints** — missing-dependency errors now print the
right install command for your platform (brew / apt-get / dnf).
### Changed
- `jq` is required only when a manifest is present or being written;
manifest-less projects keep working without `jq` (manifest features are
skipped with a notice).
### Fixed
- **Key rotation no longer orphans nested or external blobs.** `secrets rekey`
and `secrets list` now walk the entire project tree, so nested manifest
entries (`<project>/<relpath>.age`) and `external/` blobs are re-encrypted
and listed correctly. Previously a rekey could leave nested blobs encrypted
under the discarded old key, making them permanently undecryptable.
- Test assertions now fail correctly under system bash 3.2 (standalone
`[[ ]]` checks no longer pass silently).
## [0.3.0.0] - 2026-06-07
### Added
- **`file` external type (EGB-652)** — `.secrets-files` can now sync whole
files outside the project root (binary-safe; built for the Beacon Android
upload keystore): `file ~/keystores/beacon-upload.keystore`. Push encrypts
the file verbatim into `<project>/external/`; pull restores it with mode
600, backing up a divergent existing target to `<name>.secrets-bak`. Same
path safety rails as `gradle-properties` (inside `$HOME`, no `..`, no
symlinks) minus the basename restriction. 7 new bats tests.
## [0.2.1.0] - 2026-06-05
### Fixed
- **`secrets rekey` no longer bricks dotenv stores.** The re-encrypt loop used a bare `"$dir"*` glob, which never matches dotfiles — so `.env` blobs were decrypted to the temp dir but never re-encrypted, leaving them on the **old** key while the new key overwrote `key.txt`. After a rotation, every dotenv file in the store was undecryptable. The glob now mirrors the decrypt loop (`"$dir"* "$dir".*`), and a round-trip test (push → rekey → pull) pins it. If you ran `rekey` on an earlier version and `pull` now fails with `no identity matched any of the recipients`, your blobs are on a pre-rotation key — recover with an old `key.txt` from another machine.
- **`secrets init` on a second machine now fails helpfully instead of half-initializing.** Copying `key.txt` into `~/.secrets` and then running `init` (instead of cloning your secrets repo) used to run `git init`, crash on the existing key, and leave a store with no `.gitignore` — a state where a later `push` would commit the private key. The guard now fires *before* `git init`, leaves the key untouched, and prints the exact `git clone` command to run — using the real remote URL when your `.secrets-store` file declares one.
### Security
- **The private key can no longer be committed by a store missing its `.gitignore`.** `push`, `pull`, and `rekey` now self-heal store protections immediately before any `git add -A`: a missing *or corrupted* `.gitignore` (one without the `key.txt` line) is rewritten, and the pre-commit hook is reinstalled if absent. The heal runs *after* the fast-forward pull, closing a window where remote history without a `.gitignore` could strip protection mid-push.
- **An already-tracked `key.txt` is now untracked automatically.** `.gitignore` can't untrack a file that was committed in the past; the heal now removes a tracked key from the index with a warning that history may need scrubbing and the key may warrant rotation.
### Changed
- Project `CLAUDE.md` gained agent skill-routing guidance and an updated test-suite count (126 bats tests, up from 113).
## [0.2.0.0] - 2026-05-26
### Added
- **Sync designated keys from external files (Gradle properties).** A new committed `.secrets-files` manifest lets `secrets` track specific keys from files that live *outside* the project root — the motivating case being `~/.gradle/gradle.properties`, where Android builds read Clerk publishable keys (`beaconClerkPkTest`, `beaconClerkPkLive`) that Android Studio's GUI builds can only get from that persistent global file, not from terminal env vars. One entry per line: `gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive`. (EGB-531)
- **push** extracts only the named keys and encrypts them under `<project>/external/` in the store.
- **pull** *merges* those keys into the target file, preserving every unrelated key, comment, and line order. An existing managed key is updated in place; the target is backed up to `gradle.properties.secrets-bak` before each merge.
- `secrets which` reads back the parsed manifest; `secrets list` shows `[external]` entries; `secrets rekey` re-encrypts external blobs alongside dotenv ones.
- Backward compatible: no `.secrets-files` → identical behavior to before.
### Security
- **The merge is pure bash with exact-string key matching — no `sed`/regex.** This is deliberate: a `sed`-based substitution would corrupt values containing `&`, `\`, or `/` (common in API keys) and would false-match substring keys (`beaconClerkPk` vs `beaconClerkPkTest`). Values are treated as opaque literals and round-trip byte-exact.
- **External write targets are validated against an attacker-controlled path.** Because the target path comes from a *committed* manifest, the writable target is locked down: basename must be `gradle.properties`, the path must resolve inside `$HOME`, `..` traversal is rejected, and symlinked targets (file or parent dir) are refused — blocking a malicious manifest from appending decrypted keys to `~/.gitconfig`, `~/.bashrc`, etc. Manifest parsing rejects shell metacharacters and control characters in paths and keys, mirroring the `.secrets-store` posture. Writes are atomic (temp-in-same-dir + rename), mode-preserving, and default to `600` on create.
- **Storage isolation.** External blobs live in a `<project>/external/` subdir so the existing broad `*.age` globs (pull, list, rekey) structurally never decrypt them into the working directory or orphan them.
- **Note on plaintext.** Merged Gradle keys are written as permanent plaintext into the target file (`secrets clear` does not remove them) — appropriate for publishable/low-secrecy values like Clerk publishable keys, by design.
### Fixed
- **`secrets rekey` was broken and never completed.** Two latent bugs, exposed by the new rekey test: (1) `age-keygen -o key.txt` aborts because age-keygen refuses to overwrite an existing file — the new key is now generated into a temp dir and moved into place only on success, so the old key survives a failed rotation; (2) the `EXIT` trap referenced the function-local `$tmpdir` after the function returned, erroring under `set -u` and leaking the plaintext temp dir — the temp dir is now removed explicitly and the trap cleared on normal completion.
### Tests
- 80 → 113 (+33). New coverage: manifest parse/read-back, key extraction across `=`/`:`/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside `$HOME`, symlinked target, symlinked parent dir), first-create mode `600`, manifest injection/symlink/unsafe-key rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, workspace (`push -w`/`pull -w`) external sync, multi-entry manifests, partial-key push warnings, missing-blob pull warnings, source-side comment/continuation skipping, and backward compatibility.
## [0.1.1.0] - 2026-05-09
### Added
- **Optional git remote URL in `.secrets-store`.** Add a second whitespace-separated token after the store name to give teammates a copy-paste-ready clone command:
```
work git@github.com:acme/work-secrets.git
```
When a teammate clones a project bound to a store they don't have on their machine yet, the directed error now fills in the actual `git clone <url> <path>` line — they no longer have to ask the original setter for the URL. The URL is optional; existing single-token `.secrets-store` files continue to work and show the `<their-store-remote>` placeholder as before. (EGB-282)
### Security
- **Hardened `.secrets-store` URL parser against copy-paste shell injection.** The URL is rendered into a `git clone` line that a teammate is likely to copy-paste from the directed error. Without sanitization, a malicious `.secrets-store` containing `work evil.git;rm -rf ~` would render verbatim and execute `rm -rf ~` on paste. The parser now rejects URLs containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (including ANSI escape sequences that could spoof terminal output), and embedded whitespace. Rejected URLs are dropped with a stderr warning; the directed error falls back to the safe placeholder. Found by adversarial review during /ship; verified with regression tests for every named attack vector.
- **Switched URL parsing from `set -- $line` to `read -r spec rest`.** The previous form word-split *and* glob-expanded — `work *` from a populated directory would have leaked filenames into the URL field. The new form preserves the rest of the line verbatim into a single variable, so glob characters and internal whitespace are noticed by the sanitizer instead of silently expanded.
### Tests
- 72 → 80 (+8). New coverage: backward-compat single-token form, two-token URL form (SSH, HTTPS, `~/`-prefixed), comment-and-URL form, copy-paste injection (rm -rf payload), backtick injection, `$()` injection, ANSI escape injection, multi-token URL, glob-character URL, and a positive test asserting standard git URL chars (`-`, `+`, `_`, `:`, `/`, `@`, `.`) round-trip unchanged.
## [0.1.0.1] - 2026-05-09
### Fixed
- `secrets which` now prints the full path of the `.secrets-store` file that won resolution. Before this fix, `_find_secrets_store_file` set `_LAST_FOUND_AT` inside a `$(...)` subshell, so the parent shell never saw it; the source line read `.secrets-store file ()` with empty parens. The function now returns a tab-separated `<dir>\t<source-file-path>` tuple that `resolve_store` splits in the parent shell. Caught by the `/land-and-deploy` post-merge fresh-clone check; the existing test was too lenient and matched the truncated form. Test tightened to assert the full path appears in the parenthetical.
## [0.1.0.0] - 2026-05-09
### Added
- **Multiple stores per user.** Run `secrets push` and `secrets pull` against any encrypted store directory you choose, not just `~/.secrets/`. Use cases: keep work secrets isolated from personal, run a separate store per client, or onboard a teammate to one project without giving them every other project's keys.
- **`.secrets-store` file** for per-project bindings. Drop a one-line file at the project root (e.g. `echo work > .secrets-store && git add .secrets-store && git commit`) and every machine that clones the project automatically uses `~/.secrets-work/` for that repo. No env var to remember, no per-machine setup.
- **`--store <dir>` flag** for one-shot overrides on any subcommand. `secrets --store ~/.secrets-clientA pull myapp` works without touching files. Bare names like `--store work` expand to `$HOME/.secrets-work`. `--store default` is sugar for `~/.secrets`.
- **`secrets which`** prints the active store path and which rule chose it (flag, `.secrets-store` file, env var, or default). Aliases: `secrets where`, `secrets status`.
- **Directed errors for teammate onboarding.** When `.secrets-store` resolves to an uninitialized store or one missing `key.txt`, the error message names both recovery paths: `git clone <remote>` to join an existing store, or `secrets --store <name> init` to start fresh.
- **Active-store echo.** `secrets push` and `secrets pull` print `==> Store: <path> (from <source>)` whenever a non-default store is active, so wrong-store mistakes surface immediately.
### Changed
- `secrets list` now hints at `secrets which` when a non-default store is active.
- `cmd_help` documents the four-rule resolution order (`--store` > `.secrets-store` file > `SECRETS_DIR` > default).
- Error messages for missing init / missing key file are now context-aware: they distinguish between "default store on a fresh machine" and "non-default store referenced by `.secrets-store`."
### Security
- **Path expansion in `.secrets-store` is literal-only.** No `eval`, no `$VAR` interpolation, no `$(...)` execution. A committed `.secrets-store` containing `$(rm -rf ~)` reads as plain text, not as a command.
- **Walk-up bounded by `$HOME`.** `secrets` never reads `$HOME/.secrets-store`, never walks past `$HOME` to `/`, and never follows symlinked `.secrets-store` files. Symlinks (potential supply-chain attack via committed link to `~/.aws/credentials` or similar) are ignored.
- **`KEY_FILE` re-derives after `--store` switches stores.** Previously, calling `secrets pull --store other` would have decrypted ciphertext from the new store using the default store's key. Now `secrets` updates both `SECRETS_DIR` and `KEY_FILE` together inside `resolve_store()`.
- **`secrets run` cleanup survives paths with apostrophes.** The EXIT trap is now a named function rather than a string-interpolated command, so projects at e.g. `/Users/you/Mom's Mac/code` still get plaintext cleared after the wrapped command exits.
- **Test isolation:** the bats suite now sets `HOME=$TEST_TMPDIR` so `.secrets-store` walk-up cannot wander into the developer's real home directory.
- **`--store` flag value validation.** Empty values (`--store=`) and flag-shaped values (`--store --workspaces`) are rejected with directed errors instead of silently mapping to `~/.secrets-`-something.
- **Unset `HOME` is detected** with a directed error before the script tries to expand it. Helps cron, sudo without `-H`, and minimal CI runners.
### Tests
- 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, F1F5 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