18 KiB
18 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.6.0.1] - 2026-06-08
Added
secrets whichnow prints the manifest version (EGB-700) — the manifest header line showsversion Nalongside the store format, so a singlesecrets whichtells you both the on-disk store format and the.secrets.jsonschema 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-formatfile, andsecrets whichprints it (format: v2). A freshsecrets initcreates 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-runpreviews exactly what would change without writing anything.secrets migrate --finalize— the one destructive step, run once store-wide. It refuses unlesssecrets verifypasses and every blob has its new-format twin, cuts apre-v2-migrate-*recovery tag first, then drops the old blobs. It asks for confirmation (or--yes) because a machine still on an oldersecretswill stop seeing migrated external files until it updates.
Changed
- The external
propertiesblob is stored as<name>.properties.agein a v2 store (was<name>.gradle-properties.age), matching the manifesttype.push,pull, andverifypick 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.jsonagainst 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/nulland 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--allchecks decryptability only, not manifest consistency.)- Both modes recurse the whole project tree, so nested entries and external
files are covered.
secrets verifyexits non-zero on any problem, so it can gate CI or a future store migration.
[0.4.0.0] - 2026-06-07
Added
.secrets.jsonmanifest (EGB-677 stage 1) — a committed, project-root manifest is now the source of truth for what syncs. List the env files you want underdotenv[](project-relative, nested paths and@-scoped workspaces allowed;.., absolute, and symlink paths are rejected) and out-of-project files underexternal[](propertiesorfile). 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.jsonon first use, dedupes, and writes a stable canonical form.- Auto-add on push —
secrets pushdiscovers new.env*/.dev.varsfiles and adds them to the manifest (prints what it added and reminds you to commit). Gated byoptions.autoAddin the manifest (default on);push --frozensyncs only declared files, andpush --dry-runpreviews 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-filesabsorb — an existing.secrets-filesis folded into.secrets.jsonon first push (gradle-properties entries becomeproperties); 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
jqis required only when a manifest is present or being written; manifest-less projects keep working withoutjq(manifest features are skipped with a notice).
Fixed
- Key rotation no longer orphans nested or external blobs.
secrets rekeyandsecrets listnow walk the entire project tree, so nested manifest entries (<project>/<relpath>.age) andexternal/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
fileexternal type (EGB-652) —.secrets-filescan 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 asgradle-properties(inside$HOME, no.., no symlinks) minus the basename restriction. 7 new bats tests.
[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.