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
+54
View File
@@ -211,6 +211,60 @@ func TestReissue_RefusesAmbiguousLookup(t *testing.T) {
}
}
// v0.57.0 (2.3, escrow honesty) — re-issuing offsite credentials INVALIDATES the key-escrow blob:
// the blob sealed the OLD repo password, so a recovery code minted against it would decrypt a
// password that no longer opens the repo. RED-PROOF (Scenario C): on pre-fix code (no MarkEscrowStale
// in ReissueCredentials + no stale plumbing) the hub keeps advertising the escrow as current after a
// re-issue and keeps serving its restic-hash for auto-confirm — this test asserts it does NEITHER.
func TestReissue_InvalidatesEscrow(t *testing.T) {
p, _, st := newTestProvisioner(t)
const cust = "cust-esc"
if _, err := p.ProvisionOffsite(context.Background(), cust, Input{Enabled: true, Type: "shared", QuotaGB: 10}); err != nil {
t.Fatal(err)
}
// A host + a key-escrow blob whose sealed repo-password hash the hub serves for auto-confirm.
if err := st.UpsertHost(&store.Host{HostID: cust + "-01", CustomerID: cust, APIKey: "k"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostEscrow(cust+"-01", []byte("opaque-blob"), "SHA256:fp", "zero_knowledge", "2026-07-16T00:00:00Z", "OLDHASH"); err != nil {
t.Fatal(err)
}
// Before re-issue: current escrow — the hub serves the sealed hash and is NOT stale.
es, err := st.GetEscrowStatusForCustomer(cust)
if err != nil || es == nil {
t.Fatalf("escrow status (before): %v", err)
}
if es.Stale || es.ResticPwSHA256 != "OLDHASH" {
t.Fatalf("pre-reissue escrow must be current: stale=%v hash=%q", es.Stale, es.ResticPwSHA256)
}
// Re-issue the offsite credential — the repo password just changed under the sealed blob.
if err := p.ReissueCredentials(context.Background(), cust, "shared"); err != nil {
t.Fatalf("reissue: %v", err)
}
// After: the escrow is STALE and the restic-hash is WITHHELD (no auto-confirm against a dead key).
es, err = st.GetEscrowStatusForCustomer(cust)
if err != nil || es == nil {
t.Fatalf("escrow status (after): %v", err)
}
if !es.Stale {
t.Fatal("RED-PROOF: escrow must be STALE after an offsite re-issue (the hub was advertising ceremony-done against a key the repo no longer accepts)")
}
if es.ResticPwSHA256 != "" {
t.Fatalf("a stale escrow must WITHHOLD the restic hash to inhibit auto-confirm, got %q", es.ResticPwSHA256)
}
// A fresh ceremony (new blob sealing the new password) clears stale + serves the new hash.
if err := st.SaveHostEscrow(cust+"-01", []byte("opaque-blob-2"), "SHA256:fp", "zero_knowledge", "2026-07-16T01:00:00Z", "NEWHASH"); err != nil {
t.Fatal(err)
}
es, _ = st.GetEscrowStatusForCustomer(cust)
if es == nil || es.Stale || es.ResticPwSHA256 != "NEWHASH" {
t.Fatalf("a fresh ceremony must clear stale + serve the new hash: %+v", es)
}
}
// Scenario E (SLICE 4) — the freeze lever flips ONLY readonly on the exactly-1 labelled sub-account
// (SSH stays on — a freeze must not cut access, just writes); ambiguity refuses; unfreeze reverses.
func TestFreeze_SharedTogglesReadonlyOnly(t *testing.T) {