13 KiB
13 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
[0.2.1.0] - 2026-06-05
Fixed
secrets rekeyno longer bricks dotenv stores. The re-encrypt loop used a bare"$dir"*glob, which never matches dotfiles — so.envblobs were decrypted to the temp dir but never re-encrypted, leaving them on the old key while the new key overwrotekey.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 ranrekeyon an earlier version andpullnow fails withno identity matched any of the recipients, your blobs are on a pre-rotation key — recover with an oldkey.txtfrom another machine.secrets initon a second machine now fails helpfully instead of half-initializing. Copyingkey.txtinto~/.secretsand then runninginit(instead of cloning your secrets repo) used to rungit init, crash on the existing key, and leave a store with no.gitignore— a state where a laterpushwould commit the private key. The guard now fires beforegit init, leaves the key untouched, and prints the exactgit clonecommand to run — using the real remote URL when your.secrets-storefile declares one.
Security
- The private key can no longer be committed by a store missing its
.gitignore.push,pull, andrekeynow self-heal store protections immediately before anygit add -A: a missing or corrupted.gitignore(one without thekey.txtline) 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.gitignorecould strip protection mid-push. - An already-tracked
key.txtis now untracked automatically..gitignorecan'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.mdgained 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-filesmanifest letssecretstrack 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-bakbefore each merge. secrets whichreads back the parsed manifest;secrets listshows[external]entries;secrets rekeyre-encrypts external blobs alongside dotenv ones.- Backward compatible: no
.secrets-files→ identical behavior to before.
- push extracts only the named keys and encrypts them under
Security
- The merge is pure bash with exact-string key matching — no
sed/regex. This is deliberate: ased-based substitution would corrupt values containing&,\, or/(common in API keys) and would false-match substring keys (beaconClerkPkvsbeaconClerkPkTest). 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-storeposture. Writes are atomic (temp-in-same-dir + rename), mode-preserving, and default to600on create. - Storage isolation. External blobs live in a
<project>/external/subdir so the existing broad*.ageglobs (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 cleardoes not remove them) — appropriate for publishable/low-secrecy values like Clerk publishable keys, by design.
Fixed
secrets rekeywas broken and never completed. Two latent bugs, exposed by the new rekey test: (1)age-keygen -o key.txtaborts 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) theEXITtrap referenced the function-local$tmpdirafter the function returned, erroring underset -uand 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 mode600, manifest injection/symlink/unsafe-key rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd),listsurfacing, pre-commit blocking plaintextgradle.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:
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 actualwork git@github.com:acme/work-secrets.gitgit clone <url> <path>line — they no longer have to ask the original setter for the URL. The URL is optional; existing single-token.secrets-storefiles continue to work and show the<their-store-remote>placeholder as before. (EGB-282)
Security
- Hardened
.secrets-storeURL parser against copy-paste shell injection. The URL is rendered into agit cloneline that a teammate is likely to copy-paste from the directed error. Without sanitization, a malicious.secrets-storecontainingwork evil.git;rm -rf ~would render verbatim and executerm -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 -- $linetoread -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 whichnow prints the full path of the.secrets-storefile that won resolution. Before this fix,_find_secrets_store_fileset_LAST_FOUND_ATinside 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 thatresolve_storesplits in the parent shell. Caught by the/land-and-deploypost-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 pushandsecrets pullagainst 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-storefile 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 myappworks without touching files. Bare names like--store workexpand to$HOME/.secrets-work.--store defaultis sugar for~/.secrets.secrets whichprints the active store path and which rule chose it (flag,.secrets-storefile, env var, or default). Aliases:secrets where,secrets status.- Directed errors for teammate onboarding. When
.secrets-storeresolves to an uninitialized store or one missingkey.txt, the error message names both recovery paths:git clone <remote>to join an existing store, orsecrets --store <name> initto start fresh. - Active-store echo.
secrets pushandsecrets pullprint==> Store: <path> (from <source>)whenever a non-default store is active, so wrong-store mistakes surface immediately.
Changed
secrets listnow hints atsecrets whichwhen a non-default store is active.cmd_helpdocuments the four-rule resolution order (--store>.secrets-storefile >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-storeis literal-only. Noeval, no$VARinterpolation, no$(...)execution. A committed.secrets-storecontaining$(rm -rf ~)reads as plain text, not as a command. - Walk-up bounded by
$HOME.secretsnever reads$HOME/.secrets-store, never walks past$HOMEto/, and never follows symlinked.secrets-storefiles. Symlinks (potential supply-chain attack via committed link to~/.aws/credentialsor similar) are ignored. KEY_FILEre-derives after--storeswitches stores. Previously, callingsecrets pull --store otherwould have decrypted ciphertext from the new store using the default store's key. Nowsecretsupdates bothSECRETS_DIRandKEY_FILEtogether insideresolve_store().secrets runcleanup 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/codestill get plaintext cleared after the wrapped command exits.- Test isolation: the bats suite now sets
HOME=$TEST_TMPDIRso.secrets-storewalk-up cannot wander into the developer's real home directory. --storeflag value validation. Empty values (--store=) and flag-shaped values (--store --workspaces) are rejected with directed errors instead of silently mapping to~/.secrets--something.- Unset
HOMEis 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.