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:
@@ -43,6 +43,14 @@ type (
|
||||
OffboxEnabler interface {
|
||||
ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error
|
||||
}
|
||||
// KeyAuthProber checks whether an ALREADY-INSTALLED key authenticates to the target (pinned to the
|
||||
// freshly-scanned knownHosts). ok=true returns that key's PEM so the descriptor change is applied by
|
||||
// re-pinning + reconfiguring WITHOUT consuming a one-time password (key-auth-first — kills the
|
||||
// stale-descriptor consume-404 loop and shrinks the re-issue blast radius to genuinely-fresh guests).
|
||||
// ok=false (no key / auth refused) → the caller falls through to the full consume+install path.
|
||||
KeyAuthProber interface {
|
||||
Probe(ctx context.Context, host, user string, port int, knownHosts string) (privPEM string, ok bool)
|
||||
}
|
||||
)
|
||||
|
||||
// Bridge reconciles the offsite descriptor into a configured offbox target.
|
||||
@@ -53,7 +61,8 @@ type Bridge struct {
|
||||
KeyGen KeyGenerator
|
||||
Installer KeyInstaller
|
||||
Enabler OffboxEnabler
|
||||
MarkerPath string // where the applied-descriptor-hash is persisted (e.g. <dataDir>/offbox/applied_marker)
|
||||
Prober KeyAuthProber // optional: key-auth-first (nil → always the full consume+install path)
|
||||
MarkerPath string // where the applied-descriptor-hash is persisted (e.g. <dataDir>/offbox/applied_marker)
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
@@ -121,6 +130,24 @@ func (b *Bridge) Reconcile(ctx context.Context) error {
|
||||
return fmt.Errorf("offsite-apply: host-key MISMATCH for %s (got %s, want %s) — refusing to pin/install (possible MITM)", o.Host, scannedFP, o.HostFingerprint)
|
||||
}
|
||||
|
||||
// 1b) Key-auth-first: if an already-installed key still authenticates (pinned to the key we JUST
|
||||
// verified — the probe never weakens the identity check), the descriptor change is applied by
|
||||
// re-pinning + reconfiguring alone. NO one-time password is consumed — a stale/re-scanned descriptor
|
||||
// on an already-provisioned guest no longer loops on consume-404.
|
||||
if b.Prober != nil {
|
||||
if privPEM, ok := b.Prober.Probe(ctx, o.Host, o.User, port, knownHostsLine); ok {
|
||||
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine); err != nil {
|
||||
return fmt.Errorf("offsite-apply: reconfigure (key-auth-first): %w", err)
|
||||
}
|
||||
if err := b.writeMarker(h); err != nil {
|
||||
b.logf("[WARN] [offsite-apply] key-auth-first applied for %s but failed to persist the marker: %v", o.Host, err)
|
||||
return err
|
||||
}
|
||||
b.logf("[INFO] [offsite-apply] existing key still authenticates to %s@%s — re-pinned + reconfigured without consuming a password", o.User, o.Host)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Generate the controller keypair.
|
||||
privPEM, pubAuthorized, err := b.KeyGen.Generate()
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -191,6 +191,45 @@ func (SSHCopyIDInstaller) Install(ctx context.Context, host, user string, port i
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- SFTPKeyAuthProber: does the ALREADY-INSTALLED key still authenticate? (key-auth-first) ---
|
||||
|
||||
// SFTPKeyAuthProber probes passwordless auth with the existing installed key (KeyPath), pinned to the
|
||||
// freshly-verified knownHosts line. No key file → ok=false (fresh guest). The probe never logs secrets.
|
||||
type SFTPKeyAuthProber struct {
|
||||
KeyPath string // the installed key, e.g. <dataDir>/offbox/ssh_key
|
||||
Timeout time.Duration // per-probe budget; 0 → 20s
|
||||
}
|
||||
|
||||
func (p SFTPKeyAuthProber) Probe(ctx context.Context, host, user string, port int, knownHosts string) (string, bool) {
|
||||
pem, err := os.ReadFile(p.KeyPath)
|
||||
if err != nil {
|
||||
return "", false // no existing key — a fresh guest; take the full path
|
||||
}
|
||||
timeout := p.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
pctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
work, err := os.MkdirTemp("", "felhom-keyprobe-")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
khPath := filepath.Join(work, "known_hosts")
|
||||
if err := os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600); err != nil {
|
||||
return "", false
|
||||
}
|
||||
probe := exec.CommandContext(pctx, "sftp", "-b", "-", "-P", strconv.Itoa(port),
|
||||
"-i", p.KeyPath, "-oBatchMode=yes", "-oConnectTimeout=10",
|
||||
"-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile="+khPath, user+"@"+host)
|
||||
probe.Stdin = strings.NewReader("pwd\n")
|
||||
if err := probe.Run(); err != nil {
|
||||
return "", false // auth refused / unreachable — fall through to the full path
|
||||
}
|
||||
return string(pem), true
|
||||
}
|
||||
|
||||
func truncate(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > 300 {
|
||||
|
||||
Reference in New Issue
Block a user