# secrets
Encrypted env file sync between machines using `age` key-file encryption + a private git repo.
## Quick Start
```bash
brew install age
./secrets init # Create ~/.secrets repo + generate age key
cd ~/my-project && ./secrets push # Encrypt .env* files, commit, push
# On other machine:
cd ~/my-project && ./secrets pull # Pull + decrypt .env* files
```
## Testing
```bash
brew install bats-core
bats test/secrets.bats
```
## Architecture
Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey.
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
- Storage: Private git repo at `~/.secrets/`
- Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`)
- External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`) — merged, not overwritten (EGB-531, see below)
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
- Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`)
- Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features
## Project Structure
```
secrets # CLI script (~600 lines bash)
hooks/pre-commit # Pre-commit hook template
test/
secrets.bats # bats-core test suite (118 tests)
test_helper.bash # Shared setup/teardown
README.md # User-facing documentation
CLAUDE.md # This file
```
## Key file
`~/.secrets/key.txt` is the age identity (private key). It is gitignored and must be copied manually to each machine once.
## Multi-store resolution
The active store directory is picked by `resolve_store()` using these rules, highest precedence first:
1. `--store
` flag (parsed in the main pre-pass into `STORE_OVERRIDE`).
2. `.secrets-store` file in cwd or any ancestor, walk-up bounded by `$HOME` (never reads `$HOME/.secrets-store` itself or anything above).
3. `SECRETS_DIR` env var (legacy escape hatch).
4. `~/.secrets` default.
`resolve_store` mutates BOTH `SECRETS_DIR` and `KEY_FILE` so the existing single-store code paths just work. `STORE_SOURCE` reports which rule won. `_LAST_FOUND_AT` (when rule 2 fires) holds the path of the file that was read.
`.secrets-store` parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no `$VAR`, `$()`, backticks). Bare names map via `_expand_store_path`: `work` → `$HOME/.secrets-work`, `default` → `$HOME/.secrets`. An optional remote URL after the spec on the same line is captured as `_REMOTE_URL` and passed through to `check_initialized`, which uses it to fill in a runnable `git clone ` in the missing-store error (EGB-282). The URL is parsed via `read -r spec rest` (no `set -- $line`, no glob expansion) and then **sanitized**: any URL containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (incl. ANSI escapes), or whitespace is dropped with a stderr warning. The directed error then falls back to the `` placeholder. This matters because the rendered `git clone` line is meant to be copy-pasted by a teammate — without sanitization, `work evil.git;rm -rf ~` would render verbatim and execute the payload on paste. Internal flow: `_parse_secrets_store_file` returns `\t`; `_find_secrets_store_file` returns `\t\t`; `resolve_store` splits the 3-tuple via `IFS=$'\t' read -r ...`.
## External files (.secrets-files) — EGB-531
`.secrets-files` is a committed, project-root manifest declaring keys to sync from files **outside** the project (motivating case: `~/.gradle/gradle.properties`, which Android Studio GUI builds read but terminal env vars can't reach). One entry per line: ` ...`. Only type `gradle-properties` is supported; the type token leaves room for future types **without** a plugin-dispatch framework (build the concrete case — a deliberate scope cut).
Key design decisions (all driven by /autoplan review):
- **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel).
- **Storage:** blobs live in `$SECRETS_DIR//external/.gradle-properties.age`. The `external/` subdir keeps them out of the existing non-recursive `*.age` / `.*.age` globs (pull, list, rekey), so the old dotenv path can never decrypt a blob into cwd. `cmd_rekey` and `cmd_list` recurse into `external/` explicitly (rekey MUST, or the blob is orphaned after rotation = data loss). `` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path).
- **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `.secrets-bak` before each merge.
- **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins.
- **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped).
- **Plaintext tradeoff (accepted, documented):** merged keys are permanent plaintext in the target; `secrets clear` does not remove them. Fine for the Clerk *publishable* keys this was built for; not for high-value secrets (use `secrets run` + `.env`).
## Deploy Configuration
- Platform: NONE (distributed via `git clone` from GitHub)
- Production URL: N/A (no live service)
- Release model: merge to `main` is the release. Optionally tagged with `v`.
- Verification after merge: a fresh `git clone` should produce a working `secrets which` against an isolated `$HOME`. No canary URL.
- Staging: none.
- Rollback: revert the merge commit on `main` (and delete the tag) to roll back.
## Environment variable
`SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing). Per-project bindings via `.secrets-store` file beat this env var; use `--store ` for one-shot overrides that beat everything.