feat(hub): v0.57.0 reinstall-of-existing-customer arc — claim/offsite/escrow continuity

F2 claim re-issue on clean-slate re-enroll (ReissueForReenroll, host-enroll mint path,
single-bump, reset code; hub never stores the password so fork B). F3 offsite re-issue on
re-enroll (ReissueOffsiteForCustomer, same machinery as the manual button). 2.3 escrow honesty
(red-proofed): re-issuing offsite marks the escrow stale (MarkEscrowStale), withholds the
mismatched restic hash from auto-confirm, DR checklist shows stale not done. Events:
claim_reissued_reenroll / offsite_reissued / escrow_stale.

Controller + scripts unchanged (source contradicted both premises): the controller reads escrow
prereqs live from the agent; the installer can't know the descriptor-provisioned storage id. F4
root fix is agent-side -> ROADMAP R-22; demo unblocked live (Part 0 ACL grant). VALIDATION doc
F2 erratum + F3/F4 dispositions. Green gate + Scenario-C red-proof pass.
This commit is contained in:
2026-07-16 18:00:13 +02:00
parent dd961a66bb
commit 7747a16ff1
15 changed files with 395 additions and 37 deletions
+27
View File
@@ -159,6 +159,33 @@ func (e *Engine) RequestReset(cc *store.CustomerConfig) error {
return err
}
// ReissueForReenroll handles the clean-slate reinstall of a CLAIMED customer (F2, v0.57.0): the box
// (host + in-guest controller) was wiped and re-enrolls, so the fresh controller has NO password
// while the hub-side claim is set. This rotates + emails a RESET code (rides Resend's rotation
// semantics — a single generation bump, single active code) so the customer gets a fresh code
// automatically instead of hunting for the manual "request new code" button. The new hash reaches
// the fresh controller through the existing report ACK.
//
// No-op for an UNCLAIMED customer — that is the first-provision path where EnsureIssued already
// owns the first code; re-enrolling before the first claim must NOT rotate. The CALLER guarantees
// single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path,
// which fires exactly once per reinstall). Returns (generation, reissued, error).
func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) {
cs, err := e.Store.GetClaim(cc.CustomerID)
if err != nil {
return 0, false, fmt.Errorf("claim: reading state: %w", err)
}
if cs == nil || !cs.Claimed() {
return 0, false, nil // unclaimed → first-provision path; nothing to re-issue
}
gen, err = e.rotateAndSend(cc, EmailReset)
if err != nil {
return gen, true, err // reissued=true so the caller records the attempt even on email failure
}
e.logf("[INFO] [claim] reset code re-issued (gen %d) for %s on box re-enrollment (clean-slate reinstall)", gen, cc.CustomerID)
return gen, true, nil
}
// MarkClaimed records a controller-reported successful claim and sends the one-time confirmation
// email on the unclaimed→claimed transition (idempotent — repeated reports are no-ops).
func (e *Engine) MarkClaimed(cc *store.CustomerConfig) error {
+50
View File
@@ -139,6 +139,56 @@ func TestResend_ClaimedGetsResetTemplateAndStaysClaimed(t *testing.T) {
}
}
// v0.57.0 (F2) — ReissueForReenroll rotates + emails a RESET code for a CLAIMED customer whose box
// was clean-slate reinstalled (fresh box has no password), and is a NO-OP for an unclaimed customer
// (the first-provision path, where EnsureIssued owns the first code — re-enrolling must not rotate).
func TestReissueForReenroll(t *testing.T) {
t.Run("claimed rotates and sends the reset template", func(t *testing.T) {
e, st, m := newTestEngine(t)
if _, err := e.EnsureIssued(cust()); err != nil {
t.Fatalf("EnsureIssued: %v", err)
}
if err := e.MarkClaimed(cust()); err != nil {
t.Fatalf("MarkClaimed: %v", err)
}
sendsBefore := len(m.sends)
gen, reissued, err := e.ReissueForReenroll(cust())
if err != nil {
t.Fatalf("ReissueForReenroll: %v", err)
}
if !reissued {
t.Fatal("a CLAIMED customer must re-issue a code on box re-enrollment")
}
cs, _ := st.GetClaim("c1")
if gen < 2 || cs.Generation != gen {
t.Fatalf("re-enroll must bump the generation once: gen=%d stored=%d", gen, cs.Generation)
}
if !cs.Claimed() {
t.Fatal("re-issue must NEVER un-claim (reset rides rotation)")
}
if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reset:") {
t.Fatalf("claimed re-enroll must send exactly one RESET email, got %v", m.sends)
}
})
t.Run("unclaimed is a no-op (first-provision path)", func(t *testing.T) {
e, _, m := newTestEngine(t)
if _, err := e.EnsureIssued(cust()); err != nil { // issued but NOT claimed
t.Fatalf("EnsureIssued: %v", err)
}
sendsBefore := len(m.sends)
_, reissued, err := e.ReissueForReenroll(cust())
if err != nil {
t.Fatalf("ReissueForReenroll: %v", err)
}
if reissued {
t.Fatal("an UNCLAIMED customer must NOT re-issue on re-enroll (first provision owns the code)")
}
if len(m.sends) != sendsBefore {
t.Fatalf("no email may be sent on an unclaimed re-enroll, got %v", m.sends)
}
})
}
// RequestReset caps at 3/day per customer, hub-side.
func TestRequestReset_DailyCap(t *testing.T) {
e, _, m := newTestEngine(t)