From 0c4e5993423c3e49950c3463534772f26aace09e Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sat, 18 Jul 2026 20:27:53 -0700 Subject: [PATCH] lab: rung-1 appsecret host dress rehearsal (M2 ladder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First end-to-end run of the real rotate --execute spine on the host toolchain — real gopass, mandatory backup gate, audit log, INCREDIGO_PASSPHRASE seal flow — against a THROWAWAY scratch app (dummy .env + config.yml, fake SECRET_KEY) in an isolated store. Walks dry-run -> blast -> execute, asserts the in-place cutover (old value gone from both files, one identical new value written, stored blob updated, sealed backup produced) and that the old value is recoverable from the backup. Confirms the safety-relevant asymmetry: only plan/dry-run/blast scan the host (read-only, noop); --execute sources ONLY gopass entries under --prefix, so it can rotate nothing but the scratch entry. appsecret stays LIVE-VM: the scratch secret is fake, so this proves the host flow, not a real credential. Promotion to LIVE-REAL is reserved for a real-cred rotation with explicit per-credential authorization. Co-Authored-By: Claude Opus 4.6 --- lab/README.md | 8 ++ lab/lab-rung1-appsecret-host.sh | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100755 lab/lab-rung1-appsecret-host.sh diff --git a/lab/README.md b/lab/README.md index 1d1278e..8088b89 100644 --- a/lab/README.md +++ b/lab/README.md @@ -61,6 +61,14 @@ multipass exec incredigo-sbx -- bash -lc ' ## Custody / smoke +- `lab-rung1-appsecret-host.sh` — **safe-candidate ladder rung 1**, the host dress + rehearsal: the first end-to-end run of the real `rotate --execute` spine on the host + toolchain (real gopass, backup gate, audit, seal flow) against a **throwaway scratch + app** (dummy `.env` + `config.yml`, fake secret). Walks dry-run → blast → execute → + asserts the in-place cutover (old gone, one new value in both files, stored blob + updated) → shows the old value is recoverable from the sealed backup. Isolated store, + zero real-cred risk. `appsecret` stays LIVE-VM — a real-cred rotation is what earns + LIVE-REAL. (ROADMAP M2 rung 1.) - `lab-restore-drill.sh` — proves the sealed `.age` backup is a **real recovery path** on the host against real gopass (isolated throwaway store, fake creds): seed → seal → destroy every entry → `import` → assert byte-equal restoration + no plaintext in the diff --git a/lab/lab-rung1-appsecret-host.sh b/lab/lab-rung1-appsecret-host.sh new file mode 100755 index 0000000..ca791a8 --- /dev/null +++ b/lab/lab-rung1-appsecret-host.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# lab-rung1-appsecret-host.sh — RUNG 1 of the safe-candidate ladder, on the HOST. +# +# The first end-to-end run of the REAL rotate --execute spine on the host toolchain +# (real gopass, real backup gate, real audit log, real INCREDIGO_PASSPHRASE seal flow) +# — but against a THROWAWAY "scratch app" (dummy .env + config.yml) so no credential +# that matters is at risk. This is the dress rehearsal before a real dev DB / Gitea PAT. +# +# What it walks (docs/ROTATION-REAL-CRED-RUNBOOK.md Part B): +# 1. stage a self-contained appsecret credential in an ISOLATED gopass store +# 2. --dry-run (noop spine, touches nothing) +# 3. --blast (map which files consume the secret, read-only) +# 4. --execute (backup gate → rotate files in place → verify → store → revoke-noop) +# 5. assert: old value gone from both files, one identical NEW value in both, +# the stored blob carries the new value, a sealed backup exists +# 6. recovery: import the sealed backup and show the OLD signing value is recoverable +# +# Safety: isolated GOPASS_HOMEDIR + GNUPGHOME (no-protection throwaway key), a scratch +# app dir under $HOME, FAKE secret. It CANNOT touch the real store or a real GPG key. +set -euo pipefail +export PATH=/usr/local/bin:$PATH +export GOPASS_HOMEDIR="${GOPASS_HOMEDIR:-$HOME/.rung1-gopass}" +export GNUPGHOME="${GNUPGHOME:-$HOME/.rung1-gnupg}" +export GOPASS_NO_NOTIFY=true +export INCREDIGO_PASSPHRASE="${INCREDIGO_PASSPHRASE:-rung1-seal-pass}" + +SCRATCH="${SCRATCH:-$HOME/incredigo-scratch-app}" +PREFIX="rotation-test/" +ENTRY="rotation-test/appsecret/scratchapp" + +# --- locate incredigo (prefer installed/staged, else build from the repo) --- +if [ -n "${INCREDIGO_BIN:-}" ] && [ -x "${INCREDIGO_BIN}" ]; then + INC="$INCREDIGO_BIN" +elif command -v incredigo >/dev/null 2>&1; then + INC="$(command -v incredigo)" +else + ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "$(dirname "$0")/..")" + INC="$(mktemp -d)/incredigo" + echo "== building incredigo from $ROOT ==" + ( cd "$ROOT" && CGO_ENABLED=0 go build -o "$INC" ./cmd/incredigo ) +fi +echo "== incredigo: $INC" +command -v gopass >/dev/null 2>&1 || { echo "FAIL: gopass not on PATH"; exit 1; } + +# --- clean isolated store + throwaway key --- +rm -rf "$GOPASS_HOMEDIR" "$GNUPGHOME" "$SCRATCH" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +mkdir -p "$GOPASS_HOMEDIR" "$SCRATCH" + +echo "== throwaway GPG key ==" +cat > "$GNUPGHOME/key.batch" <<'EOF' +%no-protection +Key-Type: eddsa +Key-Curve: ed25519 +Subkey-Type: ecdh +Subkey-Curve: cv25519 +Name-Real: Incredigo Rung1 +Name-Email: rung1@incredigo.local +Expire-Date: 0 +%commit +EOF +gpg --batch --generate-key "$GNUPGHOME/key.batch" 2>/dev/null +FPR=$(gpg --list-keys --with-colons rung1@incredigo.local | awk -F: '/^fpr:/{print $10; exit}') +gopass init --crypto gpg --storage fs "$FPR" &1 | tail -1 || gopass init "$FPR" &1 | tail -1 + +# --- scratch app: two files sharing one signing secret (FAKE) --- +OLD="$(openssl rand -hex 32)" # 64 hex chars — stands in for a Django SECRET_KEY etc. +cat > "$SCRATCH/.env" < "$SCRATCH/config.yml" </dev/null +RECOVERED_BLOB="$(gopass show -o "$ENTRY")" +case "$RECOVERED_BLOB" in + *"val=$OLD"*) echo " ✓ restored blob carries the OLD value — file rewrite is reversible from backup" ;; + *) echo " ✗ backup did not carry the old value"; fail=1 ;; +esac + +echo +[ "$fail" -eq 0 ] && echo "RUNG1_APPSECRET_OK — real host rotate --execute spine walked on a scratch app" \ + || { echo "RUNG1_APPSECRET_FAIL"; exit 1; }