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:
Brian Majewski 2026-06-05 09:38:16 -07:00
parent e947cfde19
commit 25cd1feb43
3 changed files with 144 additions and 34 deletions

100
secrets
View file

@ -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