feat: secrets join + init --remote + verified onboarding (EGB-671)
Add second-machine onboarding as a first-class verb rather than a manual clone + key-copy sequence: - secrets join --remote <url> --key <path>: 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 <url>: 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.
This commit is contained in:
parent
ec538d7ef0
commit
6319313ee4
4 changed files with 380 additions and 28 deletions
145
test/join.bats
Normal file
145
test/join.bats
Normal file
|
|
@ -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 <file> <cmd...>`. 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) ──
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue