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.
This commit is contained in:
Brian Majewski 2026-05-26 12:42:04 -07:00
parent ac2195d830
commit 110ac514cc
7 changed files with 816 additions and 13 deletions

View file

@ -5,6 +5,31 @@ 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.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