secrets/CHANGELOG.md
Brian Majewski 110ac514cc v0.2.0.0 feat: sync gradle.properties keys via .secrets-files (EGB-531)
Add a committed .secrets-files manifest that lets secrets track designated
keys from files outside the project root (motivating case:
~/.gradle/gradle.properties for Android Clerk publishable keys, which
Android Studio GUI builds read but terminal env vars can't reach).

- push extracts only the named keys, encrypts under <project>/external/
- pull MERGES them into the target, preserving unrelated keys/comments/order
- pure-bash merge (no sed/regex): exact-string key match, opaque values
- path validator: basename gradle.properties, within $HOME, no symlink/..
- external/ subdir keeps blobs out of the dotenv *.age globs; rekey + list
  recurse explicitly
- which reads back the manifest; list shows [external]; pre-commit blocks
  plaintext gradle.properties

Also fixes two latent bugs in 'secrets rekey' (never completed before, no
prior test): age-keygen refusing to overwrite key.txt, and an EXIT trap
referencing an out-of-scope local under set -u.

Tests: 80 -> 104.

Reviewed via /autoplan (CEO/Eng/DX). EGB-531.
2026-05-26 12:42:04 -07:00

11 KiB
Raw Blame History

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.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 the first 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 → 104 (+24). 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, symlink), first-create mode 600, manifest injection/symlink rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), list surfacing, pre-commit blocking plaintext gradle.properties, 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.