fix: init second-machine guard, store .gitignore self-heal, rekey dotfile loss
Three fixes, found from a real Ubuntu second-machine setup failure:
1. cmd_init: if key.txt exists but the store has no .git, die BEFORE
git init with directed guidance to clone the existing secrets repo
instead. Previously init ran git init, then age-keygen died on the
existing key, leaving a half-initialized store (.git but no
.gitignore or hook).
2. ensure_store_protections: push/pull/rekey now restore a missing
store .gitignore (and pre-commit hook) before any `git add -A`.
Without the .gitignore, add -A would commit key.txt to the remote.
3. cmd_rekey: the re-encrypt loop used a bare "$dir"* glob, which never
matches dotfiles — .env blobs were decrypted to the tmpdir but never
re-encrypted, leaving them on the OLD key (undecryptable) while the
new key overwrote key.txt. Glob now matches the decrypt loop
("$dir"* "$dir".*). Exposed by the self-heal test: with nothing
staged, the empty commit failed mid-rekey.
Tests: 5 new bats tests (118 total) — init guard, .gitignore self-heal
on push/pull/rekey, and a rekey round-trip that survives key rotation.
This commit is contained in:
parent
e947cfde19
commit
25cd1feb43
3 changed files with 144 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
100
secrets
100
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 <your-secrets-remote> $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
|
||||
|
|
|
|||
|
|
@ -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" ]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue