diff --git a/CLAUDE.md b/CLAUDE.md index e31f76a..59473fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek secrets # CLI script (~600 lines bash) hooks/pre-commit # Pre-commit hook template test/ - secrets.bats # bats-core test suite (113 tests) + secrets.bats # bats-core test suite (118 tests) test_helper.bash # Shared setup/teardown README.md # User-facing documentation CLAUDE.md # This file diff --git a/secrets b/secrets index 76eb7c4..b50122f 100755 --- a/secrets +++ b/secrets @@ -701,26 +701,9 @@ HOOKEOF fi } -# ─── Subcommands ─────────────────────────────────────────────────────── - -cmd_init() { - check_cmd age - check_cmd git - resolve_store - - if [ -d "$SECRETS_DIR/.git" ]; then - die "Already initialized at $SECRETS_DIR. Key file preserved." - fi - - info "Initializing secrets repo at $SECRETS_DIR" - mkdir -p "$SECRETS_DIR" - git init "$SECRETS_DIR" >/dev/null - - # Generate age key pair - info "Generating age key pair" - age-keygen -o "$KEY_FILE" 2>&1 - - # Write .gitignore +# Write the store-level .gitignore. Critical: the `key.txt` line is what +# keeps the private key out of `git add -A` during push/rekey. +write_store_gitignore() { cat > "$SECRETS_DIR/.gitignore" << 'EOF' # Never commit the private key key.txt @@ -735,6 +718,56 @@ key.txt !**/.env.*.age !**/.dev.vars.age EOF +} + +# Restore store-level protections if missing. A cloned store has no +# pre-commit hook (hooks aren't cloned), and a half-initialized store may +# lack .gitignore — without it, `git add -A` would commit key.txt. +ensure_store_protections() { + if [ ! -f "$SECRETS_DIR/.gitignore" ]; then + write_store_gitignore + info "Restored store .gitignore" + fi + if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then + mkdir -p "$SECRETS_DIR/.git/hooks" + install_hook + info "Reinstalled pre-commit hook" + fi +} + +# ─── Subcommands ─────────────────────────────────────────────────────── + +cmd_init() { + check_cmd age + check_cmd git + resolve_store + + if [ -d "$SECRETS_DIR/.git" ]; then + die "Already initialized at $SECRETS_DIR. Key file preserved." + fi + + # Second-machine trap: a copied key.txt without a repo means the user + # should clone their existing secrets repo, not init a fresh one. + # Catch it BEFORE git init so we don't leave a half-initialized store. + if [ -f "$KEY_FILE" ]; then + die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR. +If this is a second machine, don't run 'secrets init' — clone your existing secrets repo instead: + + git clone $SECRETS_DIR + +Your key file has been left untouched." + fi + + info "Initializing secrets repo at $SECRETS_DIR" + mkdir -p "$SECRETS_DIR" + git init "$SECRETS_DIR" >/dev/null + + # Generate age key pair + info "Generating age key pair" + age-keygen -o "$KEY_FILE" 2>&1 + + # Write .gitignore + write_store_gitignore # Install pre-commit hook mkdir -p "$SECRETS_DIR/.git/hooks" @@ -781,6 +814,10 @@ push_dir_to_project() { commit_and_push_secrets() { local message="$1" + # Must run before `git add -A`: a store missing its .gitignore would + # otherwise stage and push key.txt. + ensure_store_protections + if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then die "Fast-forward pull failed. Run 'secrets pull' first, then retry push." @@ -917,11 +954,8 @@ cmd_pull() { # Merge any external files (.secrets-files) declared in this project. pull_external_files "$PWD" "$project" - # Reinstall hook if missing - if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then - install_hook - info "Reinstalled pre-commit hook" - fi + # Reinstall hook / store .gitignore if missing + ensure_store_protections } # Pull and decrypt .age files from a project path into a target directory. @@ -1004,11 +1038,8 @@ cmd_pull_workspaces() { info "Decrypted $total file(s) total" - # Reinstall hook if missing - if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then - install_hook - info "Reinstalled pre-commit hook" - fi + # Reinstall hook / store .gitignore if missing + ensure_store_protections } cmd_list() { @@ -1139,13 +1170,15 @@ cmd_rekey() { info "Re-encrypting all files with new key..." - # Re-encrypt all files + # Re-encrypt all files. The ".*" glob is required: dotenv files decrypt + # to dotfiles ("$tmpdir/p/.env") that a bare "*" would silently skip, + # leaving their blobs on the old key (undecryptable after rotation). for dir in "$tmpdir"/*/; do [ -d "$dir" ] || continue local project project=$(basename "$dir") mkdir -p "$SECRETS_DIR/$project" - for f in "$dir"*; do + for f in "$dir"* "$dir".*; do [ -f "$f" ] || continue local name name=$(basename "$f") @@ -1162,7 +1195,8 @@ cmd_rekey() { fi done - # Commit and push + # Commit and push (heal .gitignore first so add -A can't stage key.txt) + ensure_store_protections git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then diff --git a/test/secrets.bats b/test/secrets.bats index 2119c40..ad77ac2 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -1476,3 +1476,79 @@ gradle_project() { # definition must, and the '!' comment must be ignored. grep -q '^beaconClerkPkTest=realkey$' "$HOME/.gradle/gradle.properties" } + +# ─── init second-machine guard + store .gitignore self-heal ──────────── + +@test "init with existing key but no repo dies with clone guidance" { + # Second-machine trap: user copies key.txt into ~/.secrets, then runs + # `secrets init` instead of cloning their secrets repo. + mkdir -p "$SECRETS_DIR" + age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null + local key_before + key_before=$(cat "$SECRETS_DIR/key.txt") + + run "$SECRETS_BIN" init + [ "$status" -eq 1 ] + [[ "$output" == *"git clone"* ]] + # Must not leave a half-initialized store behind + [ ! -d "$SECRETS_DIR/.git" ] + # Key untouched + [ "$(cat "$SECRETS_DIR/key.txt")" = "$key_before" ] +} + +@test "push restores missing store .gitignore and never commits key.txt" { + init_with_remote + rm "$SECRETS_DIR/.gitignore" + create_project_dir + + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/.gitignore" ] + grep -q "key.txt" "$SECRETS_DIR/.gitignore" + # key.txt must never be tracked (push does `git add -A` in the store) + run git -C "$SECRETS_DIR" ls-files + [[ "$output" != *"key.txt"* ]] +} + +@test "pull restores missing store .gitignore" { + init_with_remote + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + rm "$SECRETS_DIR/.gitignore" + rm .env .env.staging + + run "$SECRETS_BIN" pull + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/.gitignore" ] +} + +@test "rekey restores missing store .gitignore and never commits key.txt" { + init_with_remote + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + rm "$SECRETS_DIR/.gitignore" + + run "$SECRETS_BIN" rekey + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/.gitignore" ] + run git -C "$SECRETS_DIR" ls-files + [[ "$output" != *"key.txt"* ]] +} + +@test "rekey re-encrypts dotenv blobs (round-trip survives key rotation)" { + # The decrypt loop matches dotfiles (".*.age") but a re-encrypt glob of + # "$dir"* would silently skip them — leaving .env.age on the OLD key + # after rotation, i.e. undecryptable. Guard the full round-trip. + init_with_remote + create_project_dir + "$SECRETS_BIN" push >/dev/null 2>&1 + + run "$SECRETS_BIN" rekey + [ "$status" -eq 0 ] + + rm .env .env.staging + run "$SECRETS_BIN" pull + [ "$status" -eq 0 ] + [ "$(cat .env)" = "SECRET_KEY=abc123" ] + [ "$(cat .env.staging)" = "DB_HOST=staging.db.example.com" ] +}