v0.107.0: key-auth-first bridge + staged-secret wipe on escrow confirm

Key-auth-first: a KeyAuthProber seam lets the bridge skip consume+install
when the already-installed key still authenticates (pinned to the freshly
verified host key) — descriptor changes on provisioned guests no longer
loop on consume-404. Fingerprint verify still precedes everything.

Wipe-on-escrowed: confirm-escrow now calls the agent's new
DELETE /escrow/stage-secret (v0.78.0) best-effort, closing the hygiene gap
where a ceremony-less confirm left the staged password file behind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 22:26:53 +02:00
parent 42af088308
commit a38c743926
9 changed files with 241 additions and 1 deletions
@@ -58,6 +58,23 @@ func (f *fakeInstaller) Install(_ context.Context, _, _ string, _ int, password,
return f.err
}
type fakeProber struct {
pem string
ok bool
panics bool
calls int
gotKH string
}
func (f *fakeProber) Probe(_ context.Context, _, _ string, _ int, kh string) (string, bool) {
if f.panics {
panic("prober must NOT be called (verify must precede the probe)")
}
f.calls++
f.gotKH = kh
return f.pem, f.ok
}
type fakeEnabler struct {
err error
calls int
@@ -123,10 +140,54 @@ func TestBridge_AppliesEndToEnd(t *testing.T) {
}
}
// Key-auth-first (Scenario B) — the existing key still works: NO consume, NO install; re-verify + re-pin +
// reconfigure with the EXISTING key, marker updated.
func TestBridge_KeyAuthFirstSkipsConsume(t *testing.T) {
b, cons, inst, en, _ := newBridge(t, goodOffsite())
cons.panics = true // the whole point: a working key must NEVER consume the one-time password
prober := &fakeProber{pem: "EXISTINGPEM", ok: true}
b.Prober = prober
if err := b.Reconcile(context.Background()); err != nil {
t.Fatalf("key-auth-first reconcile: %v", err)
}
if prober.calls != 1 || prober.gotKH != "[h]:23 ssh-ed25519 AAAAKEY" {
t.Fatalf("probe must run once with the freshly-scanned pinned known_hosts: %+v", prober)
}
if inst.calls != 0 {
t.Fatal("installer must NOT run when the existing key authenticates")
}
if en.calls != 1 || en.gotPriv != "EXISTINGPEM" || en.gotKnownHost != "[h]:23 ssh-ed25519 AAAAKEY" {
t.Fatalf("enabler must reconfigure with the EXISTING key + fresh pin: %+v", en)
}
if b.readMarker() != descriptorHash(b.Cfg.Offsite) {
t.Fatal("marker must be updated after a key-auth-first apply")
}
}
// Scenario C — key-auth-first must NOT weaken the fresh path: probe fails → the full
// verify→consume→install path runs unchanged (with the freshly GENERATED key).
func TestBridge_FreshGuestFallsThroughToFullPath(t *testing.T) {
b, cons, inst, en, _ := newBridge(t, goodOffsite())
b.Prober = &fakeProber{ok: false} // fresh guest: no key / auth refused
if err := b.Reconcile(context.Background()); err != nil {
t.Fatalf("fresh-guest reconcile: %v", err)
}
if cons.calls != 1 || inst.calls != 1 {
t.Fatalf("fresh guest must consume+install exactly once: cons=%d inst=%d", cons.calls, inst.calls)
}
if en.calls != 1 || en.gotPriv != "PRIVPEM" {
t.Fatalf("fresh guest must configure with the GENERATED key: %+v", en)
}
if b.readMarker() != descriptorHash(b.Cfg.Offsite) {
t.Fatal("marker must be persisted after a full-path apply")
}
}
// Scenario B — host-key mismatch → refuse: no consume, no install, no configure, no marker.
func TestBridge_HostKeyMismatchRefuses(t *testing.T) {
b, cons, inst, en, _ := newBridge(t, goodOffsite())
b.Scanner = &fakeScanner{fp: "SHA256:ATTACKER", line: "[h]:23 ssh-ed25519 EVIL"}
b.Prober = &fakeProber{panics: true} // the probe must NEVER run when the identity check failed
err := b.Reconcile(context.Background())
if err == nil || !strings.Contains(err.Error(), "MISMATCH") {
t.Fatalf("mismatch must refuse, got %v", err)