From 6319313ee40af73328b67b69f8f16df673a86367 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Mon, 8 Jun 2026 16:23:50 -0700 Subject: [PATCH 1/3] feat: secrets join + init --remote + verified onboarding (EGB-671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add second-machine onboarding as a first-class verb rather than a manual clone + key-copy sequence: - secrets join --remote --key : clone the vault, install the key at mode 600, then decrypt-test it before declaring success. An empty vault reports "nothing to verify yet" (never a false VERIFIED); a wrong key fails loudly. Reuses the audited core (resolve_store, get_pubkey, _verify_all) — no security logic re-implemented. - secrets init --remote : wire the remote and push the initial store so the upstream branch exists (fixes the commit_and_push_secrets pull --ff-only die against a brand-new empty remote). init also offers an interactive first-add of a project (default No; skipped under --yes / non-interactive). - cmd_push first-manifest scaffold writes an explicit committed options.autoAdd value, asked once when interactive (EGB-677 contract #2). - secrets pull now dies loudly when a blob fails to decrypt (all three decrypt paths) instead of warning and exiting 0 — a wrong key can't pass silently. - Interactive prompts gate on stdin AND stdout being ttys, so bats/CI never hang. - Dispatcher routes init/join args correctly; second-machine trap points at join. Tests: 20 new (join, autoAdd, pty-no-hang regression); 2 trap tests updated. --- secrets | 239 ++++++++++++++++++++++++++++++++++++++++----- test/join.bats | 145 +++++++++++++++++++++++++++ test/manifest.bats | 11 +++ test/secrets.bats | 13 +-- 4 files changed, 380 insertions(+), 28 deletions(-) create mode 100644 test/join.bats diff --git a/secrets b/secrets index 3ad6c40..219cfbf 100755 --- a/secrets +++ b/secrets @@ -1235,6 +1235,25 @@ ensure_store_protections() { cmd_init() { check_cmd age check_cmd git + + # EGB-671: flag parsing. --remote wires the encrypted-vault git remote and + # establishes an upstream branch (so the first project push won't hit the + # commit_and_push_secrets `pull --ff-only` die on a brand-new empty remote). + # --yes / non-interactive means init-only: skip the interactive first-add. + local remote="" assume_yes=false + while [ $# -gt 0 ]; do + case "$1" in + --remote) [ $# -ge 2 ] || die "--remote requires a URL" + case "$2" in --|-*) die "--remote value looks like a flag: $2" ;; esac + remote="$2"; shift 2 ;; + --remote=*) remote="${1#--remote=}" + [ -n "$remote" ] || die "--remote= requires a value"; shift ;; + --yes|-y) assume_yes=true; shift ;; + -*) die "Unknown init flag: $1. Usage: secrets init [--remote ] [--yes]" ;; + *) die "Unexpected argument to init: $1" ;; + esac + done + resolve_store if [ -d "$SECRETS_DIR/.git" ]; then @@ -1242,17 +1261,15 @@ cmd_init() { 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. + # should join their existing vault, not init a fresh one. Catch it BEFORE + # git init so we don't leave a half-initialized store. if [ -f "$KEY_FILE" ]; then - # Render a runnable clone command when .secrets-store carried a remote - # URL (already sanitized by resolve_store), mirroring check_initialized. local clone_src="" [ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL" 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: +If this is a second machine, don't run 'secrets init' — join your existing vault: - git clone $clone_src $SECRETS_DIR + secrets join --remote $clone_src --key $KEY_FILE Your key file has been left untouched." fi @@ -1268,9 +1285,7 @@ Your key file has been left untouched." # Write .gitignore write_store_gitignore - # Stamp the store format (EGB-703): a fresh store is born v2 — it has no - # v1 blobs, so it is already in v2 shape. The marker is a committed, - # non-secret metadata file (NOT gitignored); the first push stages it. + # Stamp the store format (EGB-703): a fresh store is born v2. printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" # Install pre-commit hook @@ -1282,11 +1297,159 @@ Your key file has been left untouched." info "Done! Your public key is:" echo " $pubkey" + + if [ -n "$remote" ]; then + _init_wire_remote "$remote" + else + echo "" + echo "Next steps:" + echo " 1. Add a remote: secrets init --remote (or: git -C $SECRETS_DIR remote add origin )" + echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" + echo " 3. On the other machine: secrets join --remote --key " + fi + + # Interactive first-add (EGB-671): only when truly interactive AND not --yes. + # Require BOTH stdin and stdout to be ttys — bats/CI capture a command's + # stdout (so `[ -t 1 ]` is false under automation even when stdin is still + # the terminal), which is the reliable "don't prompt" signal. Default is No. + if [ "$assume_yes" != true ] && [ -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then + _init_first_add + fi +} + +# EGB-671: wire the encrypted-vault remote and establish an upstream branch. +# Commits the born-v2 store (so .secrets-format etc. exist on the remote) and +# push -u, so a later `secrets push` pulls --ff-only against a real upstream +# instead of dying on a non-existent branch. +_init_wire_remote() { + local remote="$1" + git -C "$SECRETS_DIR" remote add origin "$remote" + ensure_store_protections + _stamp_writer_version + git -C "$SECRETS_DIR" add -A + git -C "$SECRETS_DIR" commit -m "Initialize secrets store (format v2)" >/dev/null 2>&1 || true + local br + br=$(git -C "$SECRETS_DIR" symbolic-ref --short HEAD 2>/dev/null || echo main) + if git -C "$SECRETS_DIR" push -u origin "$br" >/dev/null 2>&1; then + info "Wired remote origin=$remote and pushed the initial store (upstream: origin/$br)." + else + info "Added remote origin=$remote, but the initial push failed." + echo " Create the PRIVATE repo first, then: git -C $SECRETS_DIR push -u origin $br" >&2 + fi echo "" - echo "Next steps:" - echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin " - echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" - echo " 3. Run 'secrets push ' from a project directory" + echo "Next: copy $KEY_FILE to your other machine, then run there:" + echo " secrets join --remote $remote --key " +} + +# EGB-671: interactive "add your first project" post-step. Reads from /dev/tty +# so it never collides with a piped stdin. Default No. Drives the existing +# push path (cmd_push is $PWD-bound) by cd'ing into the chosen project dir. +_init_first_add() { + printf "Add a project's secrets to the vault now? [y/N] " > /dev/tty 2>/dev/null || return 0 + local ans="" + read -r ans < /dev/tty 2>/dev/null || return 0 + case "$ans" in [Yy]*) ;; *) return 0 ;; esac + printf "Path to the project directory: " > /dev/tty 2>/dev/null || return 0 + local dir="" + read -r dir < /dev/tty 2>/dev/null || return 0 + [ -n "$dir" ] || return 0 + case "$dir" in + "~") dir="$HOME" ;; + "~/"*) dir="$HOME/${dir#\~/}" ;; + esac + if [ ! -d "$dir" ]; then + echo "Not a directory: $dir — skipping first-add. Run 'secrets push' from a project later." > /dev/tty 2>/dev/null || true + return 0 + fi + ( cd "$dir" && cmd_push ) || true + return 0 +} + +# EGB-671: second-machine onboarding. Clone the vault, install the key, and +# decrypt-test it BEFORE declaring success — the one thing a hand-copied +# README sequence never did (a mis-copied key fails silently at first pull). +# Security logic (path rails on --key/--store, URL sanitization) lives in the +# audited core, reused — never re-implemented in a standalone install script. +cmd_join() { + check_cmd age + check_cmd git + + local remote="" keyfile="" + while [ $# -gt 0 ]; do + case "$1" in + --remote) [ $# -ge 2 ] || die "--remote requires a URL" + case "$2" in --|-*) die "--remote value looks like a flag: $2" ;; esac + remote="$2"; shift 2 ;; + --remote=*) remote="${1#--remote=}" + [ -n "$remote" ] || die "--remote= requires a value"; shift ;; + --key) [ $# -ge 2 ] || die "--key requires a path" + case "$2" in --|-*) die "--key value looks like a flag: $2" ;; esac + keyfile="$2"; shift 2 ;; + --key=*) keyfile="${1#--key=}" + [ -n "$keyfile" ] || die "--key= requires a value"; shift ;; + -*) die "Unknown join flag: $1. Usage: secrets join --remote --key " ;; + *) die "Unexpected argument to join: $1" ;; + esac + done + + resolve_store + + [ -n "$remote" ] || die "secrets join requires --remote + Usage: secrets join --remote --key " + [ -n "$keyfile" ] || die "secrets join requires --key + This is the age key (key.txt) from your first machine. + Usage: secrets join --remote --key " + if [ -d "$keyfile" ]; then + die "--key must point to the key FILE, not a directory: $keyfile + Did you mean: --key $keyfile/key.txt ?" + fi + [ -e "$keyfile" ] || die "Key file not found: $keyfile + Copy key.txt from your first machine (AirDrop/scp/USB) and pass its path." + [ -r "$keyfile" ] || die "Key file not readable: $keyfile" + + if [ -e "$SECRETS_DIR" ]; then + die "A store already exists at $SECRETS_DIR. + 'secrets join' clones a fresh vault — it won't clobber an existing one. + If you meant to refresh it, run 'secrets pull' instead, or remove $SECRETS_DIR first." + fi + + info "Joining vault: cloning $remote → $SECRETS_DIR" + if ! git clone "$remote" "$SECRETS_DIR" >/dev/null 2>&1; then + rm -rf "$SECRETS_DIR" + die "Failed to clone $remote + Check the URL and that you have access to the repo." + fi + + # Install the key BEFORE anything that decrypts, at mode 600. + cp "$keyfile" "$SECRETS_DIR/key.txt" + chmod 600 "$SECRETS_DIR/key.txt" + KEY_FILE="$SECRETS_DIR/key.txt" + ensure_store_protections + + if ! get_pubkey >/dev/null 2>&1; then + die "The file you passed to --key is not a valid age identity: $keyfile + Your store was cloned to $SECRETS_DIR; replace key.txt with a valid key and run 'secrets pull'." + fi + + # Verify gate: decrypt-test every blob. An EMPTY store returns 0 from + # _verify_all ("nothing to check") — that proves nothing about the key, so + # join must NOT report VERIFIED in that case (EGB-671 / E-S1b). + local blob_count + blob_count=$(find "$SECRETS_DIR" -type f -name '*.age' 2>/dev/null | wc -l | tr -d ' ') + if [ "$blob_count" -eq 0 ]; then + info "Joined $SECRETS_DIR — the vault is empty, so there's nothing to verify yet." + echo "Next: run 'secrets pull' in a project once secrets have been pushed from another machine." + return 0 + fi + if _verify_all >/dev/null 2>&1; then + info "VERIFIED — your key decrypts all $blob_count blob(s). You've joined the vault." + echo "Next: run 'secrets pull' in any project to restore its secrets." + return 0 + fi + die "Your key does NOT decrypt this vault ($blob_count blob(s) failed). + This is almost always the wrong key.txt. The store is at $SECRETS_DIR; + replace key.txt with the correct key and run 'secrets pull', or remove + $SECRETS_DIR and re-run 'secrets join' with the right --key." } # Encrypt env files from a source dir into a project path in the secrets repo. @@ -1498,8 +1661,22 @@ cmd_push() { '.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \ | _write_manifest_canonical "$manifest" || die "Failed to update $manifest" else - jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" \ - '{version: '"$MANIFEST_VERSION"', dotenv: $add} | if ($ext | length) > 0 then .external = $ext else . end' \ + # EGB-671 / EGB-677 contract #2: scaffolding the project's FIRST manifest. + # Record an explicit, committed options.autoAdd value. Ask once when + # interactive (read from /dev/tty so a piped stdin never collides); + # otherwise write the tool default (ON) explicitly so the value is + # committed and team-shared rather than left implicit. + local autoadd_commit="true" + # Require BOTH stdin and stdout to be ttys (bats/CI capture stdout, so + # `[ -t 1 ]` is false under automation — never block a scripted push). + if [ -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then + printf "Auto-track new env files in this project as you add them? [Y/n] " > /dev/tty 2>/dev/null || true + local _aa="" + read -r _aa < /dev/tty 2>/dev/null || _aa="" + case "$_aa" in [Nn]*) autoadd_commit="false" ;; *) autoadd_commit="true" ;; esac + fi + jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" --argjson autoadd "$autoadd_commit" \ + '{version: '"$MANIFEST_VERSION"', dotenv: $add, options: {autoAdd: $autoadd}} | if ($ext | length) > 0 then .external = $ext else . end' \ | _write_manifest_canonical "$manifest" || die "Failed to write $manifest" fi if [ "$write_adds" = true ]; then @@ -1616,9 +1793,14 @@ cmd_pull() { continue fi case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac - age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob" + # EGB-671 (DX-3): a wrong-but-structurally-valid key must NOT fail + # silently. Die loudly on decrypt failure instead of leaving a partial. + if ! age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"; then + die "Failed to decrypt '$rel' with the current key ($KEY_FILE). + Wrong key for this vault? Run 'secrets verify --all' to check the key." + fi if [ ! -s "$target_dir/$rel" ]; then - echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)" + echo "WARNING: Decrypted file '$rel' is empty (an empty source file, or a truncated .age blob)" fi count=$((count + 1)) done <<< "$declared" @@ -1642,10 +1824,14 @@ cmd_pull() { local name name=$(basename "$f" .age) local outfile="$target_dir/$name" - age -d -i "$KEY_FILE" -o "$outfile" "$f" + # EGB-671 (DX-3): die loudly on decrypt failure (wrong key) — never silent. + if ! age -d -i "$KEY_FILE" -o "$outfile" "$f"; then + die "Failed to decrypt '$name' with the current key ($KEY_FILE). + Wrong key for this vault? Run 'secrets verify --all' to check the key." + fi # Integrity check: verify non-empty if [ ! -s "$outfile" ]; then - echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" + echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)" fi count=$((count + 1)) done @@ -1674,9 +1860,13 @@ pull_project_to_dir() { local name name=$(basename "$f" .age) local outfile="$target_dir/$name" - age -d -i "$KEY_FILE" -o "$outfile" "$f" + # EGB-671 (DX-3): die loudly on decrypt failure (wrong key) — never silent. + if ! age -d -i "$KEY_FILE" -o "$outfile" "$f"; then + die "Failed to decrypt '$name' with the current key ($KEY_FILE). + Wrong key for this vault? Run 'secrets verify --all' to check the key." + fi if [ ! -s "$outfile" ]; then - echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" + echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)" fi count=$((count + 1)) done @@ -2523,6 +2713,10 @@ secrets — encrypted secret file sync between machines Usage: secrets init Initialize the secrets repo and generate an age key + secrets init --remote Init, wire the remote, and push the initial store + secrets join --remote --key + Join an existing vault on a new machine: clone, + install the key, and verify it decrypts the store secrets push [project] Encrypt secret files and push to the secrets repo secrets push --frozen Sync only manifest-declared files (skip auto-add) secrets push --dry-run Show what would be added/synced; change nothing @@ -2679,7 +2873,8 @@ else fi case "${1:-help}" in - init) cmd_init ;; + init) shift; cmd_init "$@" ;; + join) shift; cmd_join "$@" ;; push) if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then cmd_push_workspaces diff --git a/test/join.bats b/test/join.bats new file mode 100644 index 0000000..205570a --- /dev/null +++ b/test/join.bats @@ -0,0 +1,145 @@ +#!/usr/bin/env bats +# EGB-671: `secrets join` (second-machine onboarding) + `secrets init --remote` +# + day-2 silent-decrypt fix. Functional paths only — security-rail tests +# (path traversal on --key/--store, URL injection) are operator-local per +# .ship-policy.json and live in test/run-security.sh. + +load test_helper + +# Push a project to REMOTE_DIR and save the key, then remove the local store +# to simulate a fresh second machine. Leaves: REMOTE_DIR has blobs, +# $TEST_TMPDIR/saved-key.txt is the decrypting key, $SECRETS_DIR is gone. +_machine1_push_then_wipe() { + init_with_remote + cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt" + create_project_dir "joinproj" + "$SECRETS_BIN" push >/dev/null 2>&1 + cd "$HOME" + rm -rf "$SECRETS_DIR" +} + +# Like above but never pushes a project — remote has a store with zero blobs. +_machine1_empty_then_wipe() { + init_with_remote + cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt" + cd "$HOME" + rm -rf "$SECRETS_DIR" +} + +# ─── secrets join ──────────────────────────────────────────────────────── + +@test "join without --remote fails with usage" { + run "$SECRETS_BIN" join + [ "$status" -ne 0 ] + [[ "$output" == *"--remote"* ]] || false +} + +@test "join clones the store, installs the key at 600, verifies, and succeeds" { + _machine1_push_then_wipe + run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt" + [ "$status" -eq 0 ] + [[ "$output" == *"VERIFIED"* ]] || false + [ -d "$SECRETS_DIR/.git" ] + [ -f "$SECRETS_DIR/key.txt" ] + # key installed at mode 600 + local perms + perms=$(stat -f '%Lp' "$SECRETS_DIR/key.txt" 2>/dev/null || stat -c '%a' "$SECRETS_DIR/key.txt") + [ "$perms" = "600" ] +} + +@test "join with the wrong key fails loudly and does not report VERIFIED" { + _machine1_push_then_wipe + age-keygen -o "$TEST_TMPDIR/wrong-key.txt" 2>/dev/null + run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/wrong-key.txt" + [ "$status" -ne 0 ] + [[ "$output" != *"VERIFIED"* ]] || false +} + +@test "join against an empty store reports nothing-to-verify, NOT VERIFIED" { + _machine1_empty_then_wipe + run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt" + [ "$status" -eq 0 ] + [[ "$output" == *"nothing to verify"* ]] || false + [[ "$output" != *"VERIFIED"* ]] || false +} + +@test "join refuses when a store already exists at the target" { + "$SECRETS_BIN" init >/dev/null 2>&1 + cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt" + run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt" + [ "$status" -ne 0 ] + [[ "$output" == *"already"* ]] || false +} + +@test "join fails clearly when the key file is missing" { + run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/nope.txt" + [ "$status" -ne 0 ] + [[ "$output" == *"key"* ]] || false +} + +@test "join detects a directory passed as --key" { + _machine1_push_then_wipe + run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR" + [ "$status" -ne 0 ] + [[ "$output" == *"key"* ]] || false +} + +# ─── secrets init --remote ──────────────────────────────────────────────── + +@test "init --remote sets origin and establishes an upstream branch" { + run "$SECRETS_BIN" init --remote "$REMOTE_DIR" + [ "$status" -eq 0 ] + run git -C "$SECRETS_DIR" remote get-url origin + [ "$status" -eq 0 ] + [ "$output" = "$REMOTE_DIR" ] + # upstream branch exists on the remote (so a later push won't ff-only die) + run git -C "$SECRETS_DIR" rev-parse --abbrev-ref '@{u}' + [ "$status" -eq 0 ] +} + +@test "init --remote then push does not die on the brand-new remote" { + "$SECRETS_BIN" init --remote "$REMOTE_DIR" >/dev/null 2>&1 + create_project_dir "freshproj" + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + [[ "$output" != *"Fast-forward pull failed"* ]] || false +} + +@test "init with no flags still works (clean primitive)" { + run "$SECRETS_BIN" init + [ "$status" -eq 0 ] + [ -f "$SECRETS_DIR/key.txt" ] +} + +@test "init does not hang on the first-add prompt when stdin is a tty but stdout is captured" { + # Regression: run-security.sh runs bats in a real terminal, so the command's + # stdin stays a tty while bats captures its stdout. The interactive first-add + # prompt must NOT fire in that shape (it gates on stdout being a tty too), + # or the whole suite hangs. Reproduce with a pty via `script`. + command -v script >/dev/null 2>&1 || skip "script (pty) not available" + # macOS/BSD syntax: `script -q `. Skip on other syntaxes. + script -q /dev/null true >/dev/null 2>&1 || skip "unsupported script syntax" + local out="$TEST_TMPDIR/pty-initout" + run timeout 10 script -q /dev/null bash -c "'$SECRETS_BIN' init > '$out' 2>&1" + [ "$status" -ne 124 ] # 124 == timeout == it hung on a prompt + run grep -c "Add a project's secrets" "$out" + [ "$output" = "0" ] +} + +# ─── day-2 silent-decrypt fix ───────────────────────────────────────────── + +@test "pull dies loudly when a blob cannot be decrypted with the current key" { + init_with_remote + create_project_dir "decryptproj" + "$SECRETS_BIN" push >/dev/null 2>&1 + # Swap in a different key so the stored blob no longer decrypts. + # (age-keygen refuses to overwrite, so generate elsewhere then copy.) + age-keygen -o "$TEST_TMPDIR/other-key.txt" 2>/dev/null + cp "$TEST_TMPDIR/other-key.txt" "$SECRETS_DIR/key.txt" + chmod 600 "$SECRETS_DIR/key.txt" + cd "$WORK_DIR/decryptproj" + rm -f .env .env.staging + run "$SECRETS_BIN" pull + [ "$status" -ne 0 ] + [[ "$output" == *"decrypt"* ]] || false +} diff --git a/test/manifest.bats b/test/manifest.bats index eb5c42a..ce16125 100644 --- a/test/manifest.bats +++ b/test/manifest.bats @@ -167,6 +167,17 @@ load test_helper [ "$output" = "2" ] } +@test "bootstrap: first push writes an explicit options.autoAdd value (EGB-677 contract #2)" { + init_with_remote + create_project_dir autoaddproj + # Non-interactive (bats has no tty): the prompt is skipped and the tool + # default (ON) is written explicitly so the value is committed + team-shared. + run "$SECRETS_BIN" push + [ "$status" -eq 0 ] + run jq -r '.options.autoAdd' .secrets.json + [ "$output" = "true" ] +} + @test "failed push leaves no bootstrap manifest behind" { init_with_remote mkdir -p "$WORK_DIR/emptyproj" diff --git a/test/secrets.bats b/test/secrets.bats index 3a72910..ee2928f 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -1484,9 +1484,10 @@ gradle_project() { # ─── 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. +@test "init with existing key but no repo dies with join guidance" { + # Second-machine trap (EGB-671): user copies key.txt into ~/.secrets, then + # runs `secrets init` instead of joining their existing vault. The trap now + # points at `secrets join` (the real one-command path), not a manual clone. mkdir -p "$SECRETS_DIR" age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null # Guard against a vacuous '' = '' comparison if age-keygen failed @@ -1496,7 +1497,7 @@ gradle_project() { run "$SECRETS_BIN" init [ "$status" -eq 1 ] - [[ "$output" == *"git clone"* ]] || false + [[ "$output" == *"secrets join"* ]] || false # Must not leave a half-initialized store behind [ ! -d "$SECRETS_DIR/.git" ] # Key untouched @@ -1651,7 +1652,7 @@ gradle_project() { [[ "$output" != *"key.txt"* ]] || false } -@test "init guard renders the real clone URL when .secrets-store carries a remote" { +@test "init guard renders the real remote URL in join guidance when .secrets-store carries a remote" { mkdir -p "$HOME/.secrets-work" age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null [ -s "$HOME/.secrets-work/key.txt" ] @@ -1660,7 +1661,7 @@ gradle_project() { run "$SECRETS_BIN" init [ "$status" -eq 1 ] - [[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]] || false + [[ "$output" == *"secrets join --remote git@example.com:me/secrets-work.git"* ]] || false } # ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ── From 7b041af68b2e13ee4c1281a6be3a4463def14297 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Mon, 8 Jun 2026 16:23:50 -0700 Subject: [PATCH 2/3] feat: thin install.sh onboarding bootstrap (EGB-671) Ships in the repo (clone already done). Checks age + jq + git, then PRINTS the PATH line, onboarding next-steps, upgrade one-liner, and key-transfer hint. Never edits shell rc, never runs sudo (prints the command). Exits non-zero with an install hint when a dependency is missing. --- install.sh | 113 ++++++++++++++++++++++++++++++++++++++++++++++ test/install.bats | 72 +++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100755 install.sh create mode 100644 test/install.bats diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..695fc59 --- /dev/null +++ b/install.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# secrets — thin onboarding bootstrap (EGB-671). +# +# This script ships INSIDE the repo: you already cloned the repo to get it, so +# its only jobs are (1) verify the dependencies the tool needs and (2) print the +# exact commands to finish setup. It deliberately does NOT: +# - edit your shell rc files (it prints the PATH line for you to paste) +# - invoke sudo or install packages behind your back (it prints the command) +# - re-implement any of the tool's security logic +# +# This is a security tool whose whole pitch is "verify, don't trust" — so the +# installer holds itself to a higher bar than convenience, not a lower one. +# +# Usage: +# ./install.sh # check deps, print setup + next steps +# ./install.sh --help + +set -euo pipefail + +# Resolve the directory this script lives in (the cloned tool repo). Uses bash +# builtins only so it works under a minimal PATH. +_src="${BASH_SOURCE[0]}" +TOOL_DIR="$(cd "${_src%/*}" 2>/dev/null && pwd)" + +usage() { + cat < + Other machine: secrets join --remote --key +EOF +} + +# Print the install command for a package, using whatever package manager is +# present. For sudo-requiring managers we PRINT the line for you to run — the +# installer never escalates on its own. +install_hint() { + local pkg="$1" + if command -v brew >/dev/null 2>&1; then + echo "brew install $pkg" + elif command -v apt-get >/dev/null 2>&1; then + echo "sudo apt-get install -y $pkg" + elif command -v dnf >/dev/null 2>&1; then + echo "sudo dnf install -y $pkg" + else + echo "install '$pkg' with your system package manager" + fi +} + +case "${1:-}" in + --help|-h) usage; exit 0 ;; + "") ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; +esac + +echo "secrets — bootstrap check (tool dir: $TOOL_DIR)" +echo "" + +# Dependency check. age + jq + git are all load-bearing on the cold-start path: +# jq became required once .secrets.json (manifest) is JSON, so it must be present +# BEFORE the first manifest read. +missing=0 +for dep in git age jq; do + if command -v "$dep" >/dev/null 2>&1; then + echo " ok $dep" + else + echo " MISSING $dep — install it with:" + echo " $(install_hint "$dep")" + missing=1 + fi +done +echo "" + +if [ "$missing" -ne 0 ]; then + echo "Install the missing dependencies above, then re-run ./install.sh." >&2 + exit 1 +fi + +cat < + # then transfer key.txt to your other machines (AirDrop / scp / USB): + # scp :$HOME/.secrets/key.txt ~/.secrets/key.txt + + Other machine (join an existing vault): + secrets join --remote --key + # 'join' clones the vault, installs the key, and VERIFIES it decrypts + # before declaring success — a mis-copied key fails loudly, not silently. + +To update the tool later: + git -C "$TOOL_DIR" pull +EOF diff --git a/test/install.bats b/test/install.bats new file mode 100644 index 0000000..0ce7593 --- /dev/null +++ b/test/install.bats @@ -0,0 +1,72 @@ +#!/usr/bin/env bats +# EGB-671: install.sh thin bootstrap. It ships IN the repo (you clone the repo +# to get it), so its job is: verify deps (age + jq + git), PRINT the PATH line +# and next-step commands — never edit dotfiles, never invoke sudo. Security-rail +# concerns are operator-local (.ship-policy.json); these are functional checks. + +load test_helper + +INSTALL_SH="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)/install.sh" + +@test "install.sh exists and is executable" { + [ -f "$INSTALL_SH" ] + [ -x "$INSTALL_SH" ] +} + +@test "install.sh --help prints usage and exits 0" { + run "$INSTALL_SH" --help + [ "$status" -eq 0 ] + [[ "$output" == *"install.sh"* ]] || false + [[ "$output" == *"join"* ]] || false +} + +@test "install.sh prints the PATH export line for the tool dir (does not edit rc)" { + local tool_dir + tool_dir="$(cd "$(dirname "$INSTALL_SH")" && pwd)" + run "$INSTALL_SH" + [ "$status" -eq 0 ] + [[ "$output" == *"export PATH="* ]] || false + [[ "$output" == *"$tool_dir"* ]] || false + # It must NOT have written to any shell rc in the isolated HOME. + [ ! -f "$HOME/.zshrc" ] + [ ! -f "$HOME/.bashrc" ] +} + +@test "install.sh prints both onboarding next-steps (init --remote and join)" { + run "$INSTALL_SH" + [ "$status" -eq 0 ] + [[ "$output" == *"secrets init --remote"* ]] || false + [[ "$output" == *"secrets join --remote"* ]] || false +} + +@test "install.sh prints the upgrade one-liner" { + run "$INSTALL_SH" + [ "$status" -eq 0 ] + [[ "$output" == *"git -C"* ]] || false + [[ "$output" == *"pull"* ]] || false +} + +@test "install.sh prints a key-transfer hint" { + run "$INSTALL_SH" + [ "$status" -eq 0 ] + [[ "$output" == *"key.txt"* ]] || false +} + +@test "install.sh never invokes sudo (prints it for the user instead)" { + # No executed 'sudo' — any sudo reference must be quoted guidance text. + run grep -nE '^[[:space:]]*sudo ' "$INSTALL_SH" + [ "$status" -ne 0 ] +} + +@test "install.sh reports a missing dependency with an install hint and non-zero exit" { + # Build a minimal PATH that has the tools install.sh needs but NOT jq. + local fake="$TEST_TMPDIR/fakebin" + mkdir -p "$fake" + for t in bash uname env cat grep sed tr dirname command age git printf; do + src="$(command -v "$t" 2>/dev/null || true)" + [ -n "$src" ] && ln -sf "$src" "$fake/$t" 2>/dev/null || true + done + run env PATH="$fake" "$INSTALL_SH" + [ "$status" -ne 0 ] + [[ "$output" == *"jq"* ]] || false +} From 4d975d447df5913a6869a2567c561cfa8db1dbe1 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Mon, 8 Jun 2026 16:23:50 -0700 Subject: [PATCH 3/3] chore: rewrite onboarding docs + bump version (v0.7.3.0) README rewritten to the install.sh + init --remote + join flow; dropped the macOS-only prerequisite (age+jq install hints now cover apt/dnf too). CHANGELOG entry for EGB-671. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 36 +++++++++++++++ README.md | 122 ++++++++++++++++++++++++++++----------------------- VERSION | 2 +- 3 files changed, 104 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a51e6a..23e6122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ 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.7.3.0] - 2026-06-08 + +### Added + +- **Real install / onboarding scripts (EGB-671)** — onboarding a machine is now + (close to) one command, and a mis-copied key fails loudly instead of silently. + - **`secrets join --remote --key `** — second-machine onboarding in + one verb: clones the vault, installs the key at mode 600, and **verifies the + key actually decrypts the store before declaring success**. An empty vault + reports "nothing to verify yet" (it never prints a false `VERIFIED`); a wrong + key fails loudly with the store left in place to fix. All security logic + (store resolution, URL handling, path rails) is reused from the audited core, + not re-implemented in a side script. + - **`secrets init --remote `** — wires the remote and pushes the initial + store so the upstream branch exists, so your first project `push` doesn't trip + the fast-forward-pull guard on a brand-new empty remote. Run interactively, + `init` also offers to add your first project's secrets (default No, skipped + under `--yes` / non-interactive, so it stays a clean primitive for CI). + - **`install.sh`** — thin bootstrap that ships in the repo: checks `age` + `jq` + + `git`, then prints the `PATH` line, the onboarding next-steps, the upgrade + one-liner, and a key-transfer hint. It never edits your shell config and never + runs `sudo` (it prints the command so you stay in control). + - **First-manifest `options.autoAdd` prompt (EGB-677 contract #2)** — the first + `push` that scaffolds a project's manifest now records an explicit, committed + `options.autoAdd` value (asked once when interactive; the default ON, written + explicitly, under automation). + +### Fixed + +- **Day-2 silent decrypt failure** — `secrets pull` now dies loudly when a blob + fails to decrypt with the current key (all three decrypt paths), instead of + emitting a warning and continuing with exit 0. A wrong key can no longer pass + unnoticed after onboarding. +- The `secrets init` second-machine trap now points at `secrets join` (the real + one-command path) instead of a manual `git clone`. + ## [0.7.2.0] - 2026-06-08 ### Added diff --git a/README.md b/README.md index e345d64..4fa3459 100644 --- a/README.md +++ b/README.md @@ -58,83 +58,95 @@ Beyond project files, `secrets` can also sync files that live *outside* the proj ## Prerequisites -- **macOS** (uses Homebrew for installation) -- **git** (already installed on most Macs — type `git --version` to check) -- **age** (the encryption tool — installed in step 1 below) +- **macOS or Linux** +- **git** (`git --version` to check) +- **age** and **jq** — `install.sh` checks for these and prints the exact install command for your platform (Homebrew on macOS, `apt`/`dnf` on Linux) ## Setup -### First machine (one-time setup) +Clone the tool repo, then run `install.sh`. It checks dependencies and prints the +two commands to finish setup. It never edits your shell config and never runs +sudo — it prints the commands so you stay in control. ```bash -# 1. Install the encryption tool -brew install age - -# 2. Download the secrets tool (this repo — contains only the CLI, no secret files) git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets - -# 3. Make the 'secrets' command available everywhere -# Add this line to your shell config file (~/.zshrc on Mac): -export PATH="$HOME/dev/secrets:$PATH" -# Then restart your terminal, or run: -source ~/.zshrc - -# 4. Initialize your encrypted secrets store -# This creates a folder at ~/.secrets/ with your encryption key -secrets init - -# 5. Create a PRIVATE repository on GitHub to store your encrypted secrets -# Go to github.com/new, name it something like 'my-secrets', and make sure -# "Private" is selected. Then connect it: -cd ~/.secrets -git remote add origin git@github.com:/my-secrets.git -git push -u origin main +cd ~/dev/secrets +./install.sh ``` -> **Important:** Step 5 creates a *separate* private repo for your encrypted secrets. This is different from the `secrets` tool repo you cloned in step 2. The tool repo can be public — it contains no secrets. The `~/.secrets/` repo must be private. +`install.sh` prints a `export PATH="$HOME/dev/secrets:$PATH"` line — add it to your +shell config (`~/.zshrc` or `~/.bashrc`) and restart your terminal. Then onboard +this machine with one of the two flows below. -### Additional machines - -On each new machine (your desktop, a teammate's laptop, etc.): +### First machine (new vault) ```bash -# 1. Install prerequisites and the tool (same as steps 1-3 above) -brew install age -git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets -export PATH="$HOME/dev/secrets:$PATH" # add to ~/.zshrc +# 1. Create a PRIVATE repo for your encrypted secrets (github.com/new or a +# Codeberg/GitLab private repo). It holds only ciphertext — never your key. +# Then wire it up and push the store in one command: +secrets init --remote git@github.com:/my-secrets.git -# 2. Clone the encrypted secrets repo -git clone git@github.com:/my-secrets.git ~/.secrets - -# 3. Copy the encryption key from your first machine -# This is the only step that requires direct machine-to-machine transfer. -# Choose one method: -# -# Option A: AirDrop (Mac to Mac) -# On your first machine, right-click ~/.secrets/key.txt → Share → AirDrop -# Save it to ~/.secrets/key.txt on the new machine -# -# Option B: Secure copy over SSH -# scp first-machine:~/.secrets/key.txt ~/.secrets/key.txt -# -# Option C: USB drive -# Copy key.txt to a USB drive, transfer it, delete from USB after - -# 4. Pull your secrets into any project +# 2. (optional) Add a project's secrets. From a project directory: cd ~/myapp -secrets pull +secrets push +# The first push asks once whether to auto-track new env files and records +# your choice in the project's .secrets.json. ``` +`secrets init --remote` generates your key (`~/.secrets/key.txt`), wires the +remote, and pushes the initial store so the upstream branch exists. The private +secrets repo is separate from this tool repo — the tool repo is public and holds +no secrets; the `~/.secrets/` repo must be private. + +> Running `secrets init` interactively (in a terminal) also offers to add your +> first project's secrets right away. Run it with `--yes` (or in any non-tty +> context like CI) to skip that prompt and just create the vault. + +### Other machines (join an existing vault) + +On a second machine, a desktop, or a teammate's laptop: + +```bash +# 1. Clone the tool and run the bootstrap (as in Setup above) +git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets +cd ~/dev/secrets && ./install.sh # add the printed PATH line to your shell config + +# 2. Get key.txt onto this machine (the one manual, out-of-band step): +# AirDrop (Mac→Mac), or +# scp first-machine:~/.secrets/key.txt ~/Downloads/key.txt, or +# a USB drive (delete from the drive afterward) + +# 3. Join the vault in one command: +secrets join --remote git@github.com:/my-secrets.git --key ~/Downloads/key.txt +``` + +`secrets join` clones the vault, installs the key at mode 600, and **verifies the +key actually decrypts the store before declaring success** — a mis-copied key +fails loudly here, not silently on a later `secrets pull`. On success it tells you +to run `secrets pull` in any project. + > **The key file (`~/.secrets/key.txt`) is the only thing that needs to be transferred manually.** It never leaves your machines — it's excluded from git, never uploaded, never transmitted over the internet. Anyone with this file can decrypt all your secrets, so treat it like a password. ### Sharing with teammates To share secrets with a teammate, they need: -1. Access to your private `my-secrets` GitHub repo (add them as a collaborator) -2. A copy of `key.txt` (send it to them directly — AirDrop, USB, or in-person) +1. Access to your private secrets repo (add them as a collaborator) +2. A copy of `key.txt` (send it directly — AirDrop, USB, or in-person) -Everyone on the team uses the same key. When anyone runs `secrets push`, the encrypted files are updated and everyone else can `secrets pull` to get the latest version. +Everyone on the team uses the same key. A teammate joins with +`secrets join --remote --key `. When anyone runs +`secrets push`, the encrypted files update and everyone else runs `secrets pull` +to get the latest. + +### Updating the tool + +```bash +git -C ~/dev/secrets pull +``` + +If your store was last written by a newer client than yours, `secrets` prints a +one-line version-skew nudge — that's your cue to run the command above. ## Usage diff --git a/VERSION b/VERSION index 9872478..934346d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.2.0 +0.7.3.0