234 lines
19 KiB
Markdown
234 lines
19 KiB
Markdown
# 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.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, 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
|