hub v0.93.0: the retention keeps the key it was built to keep (R-198) + three honesty fixes (R-197, R-192, R-196)
gates / gates (push) Successful in 7s
gates / gates (push) Successful in 7s
R-198 — host_escrow_superseded shipped with `blob` (the K-escrow / PBS datastore key) and
identity_blob was added to host_escrow LATER, never here. The offsite restic REPOSITORY
password lives in identity_blob. So demoteCurrentEscrowTx -- whose own comment calls it "THE
ONE escrow row-copy routine" -- retained the whole-guest key and silently dropped the off-site
data key, which is the secret the retention was built to preserve. And because the copy happens
as the new blob overwrites the old, the destroying act was the ESCROW CEREMONY: the exact thing
a rebuilt box tells its customer to run, on a card promising in Hungarian that the old backups
stay recoverable. Both demo boxes crossed that line on 2026-08-04.
- identity_blob added to the table (CREATE + additive ALTER) and carried in the shared copy
routine, so BOTH callers are fixed at once: re-escrow and host-delete demotion.
- ListSupersededEscrow reads it back; store.HostEscrow gains IdentityBlob.
- CountCurrentEscrowWithIdentity is the census of who the fix protects.
- Nothing is backfillable: pre-v0.93.0 retained rows have no blob and their sources are gone.
- Tests assert the CONSEQUENCE (a retained row can still yield a repo password), which is why
the pre-existing retention test stayed green for two months asserting the mechanism.
R-197 — SaveHostEscrow returns the hash it replaced; the escrow PUT raises
offsite_repo_key_changed (warning, operator-only, edge-triggered) when both hashes are known and
differ. No hash value travels. Severity chosen for the world v0.93.0 creates: with the identity
blob retained, a changed key is "this history now depends on an older recovery code", not a loss.
R-192 (half) — the stuck alert now reports the two shapes it actually covers, burned and
regressed, each stating its own measurement; the regressed text withdraws the Re-issue
recommendation. Every self-heal refusal leaves a notification_log row with its reason. The
guard's logic is unchanged; its 500-oldest-reports scoping stays OPEN and the window is named in
the alert text so the limitation travels with the number. offsite_delivery_stuck and
offsite_credential_restaged are added to operatorOnlyEvents -- neither was registered and neither
has a customerMessages entry, which is not a block.
R-196 — five comments (not the three the spec expected) claimed ReissueCredentials rotates the
restic repo password. It resets the PROVIDER password and cannot touch the repo password, which
is generated on the box. All five corrected; the staleness mark documented as precautionary. The
BEHAVIOUR stays open.
Not in this release: R-199, R-200, R-201 remain open -- the chain that hands the key back is
still unassembled. Part 5 hit its gate; the orphan card is untouched (R-202).
This commit is contained in:
@@ -195,13 +195,30 @@ func (p *Provisioner) ReissueCredentials(ctx context.Context, customerID, typ st
|
||||
return fmt.Errorf("offsite: store re-issued one-time password: %w", err)
|
||||
}
|
||||
|
||||
// v0.57.0 (2.3, the escrow-honesty fix): the restic repo password just changed, so any existing
|
||||
// key-escrow blob — which sealed the OLD password — is now STALE. A recovery code minted against
|
||||
// it would decrypt a password that no longer opens the repo. Mark the escrow stale so the hub
|
||||
// stops advertising "ceremony done" and the customer's escrow wizard is offered again; a fresh
|
||||
// ceremony seals the new password and clears the flag. Every credential change also emits a
|
||||
// visible customer event (offsite_reissued always; escrow_stale only when a blob was invalidated).
|
||||
// Best-effort: the password reset already succeeded — a bookkeeping failure here must not fail it.
|
||||
// v0.57.0 (2.3, the escrow-honesty fix). ⚠ ITS STATED REASON WAS FALSE AND IS CORRECTED HERE
|
||||
// (R-196, 2026-08-04). The comment used to read "the restic repo password just changed" — this
|
||||
// function does NOT change it and CANNOT: the repository password is generated on the box
|
||||
// (controller `WriteOffboxSecrets`) and never leaves it except sealed inside the R-wrapped escrow.
|
||||
// What this function changes is the PROVIDER account password (①), which is a different secret at
|
||||
// a different layer. The false premise survived because the EFFECT (a stale escrow) is real, so
|
||||
// nobody checked the CAUSE — and it sent two separate investigations the wrong way in one day
|
||||
// (audits/SPIKE-offsite-credential-recovery-2026-08-04.md Q4).
|
||||
//
|
||||
// WHY THE MARK IS STILL MADE, on honest grounds: a re-issue is the operator's response to a
|
||||
// broken delivery, and the box's re-apply may mint a fresh repository password (it does exactly
|
||||
// that whenever `<DataDir>/offbox/repo_password` is absent — the guest-rebuild shape). The mark is
|
||||
// therefore PRECAUTIONARY, not evidential. The evidential signal is R-197's
|
||||
// `offsite_repo_key_changed`, which fires on a measured hash difference at the next ceremony.
|
||||
// KNOWN CONSEQUENCE, left in place deliberately: on the ordinary re-issue shape — a box that still
|
||||
// holds its repo_password file — the password does NOT change, so this marks a healthy escrow
|
||||
// stale and asks the customer for an unnecessary ceremony. R-196 stays OPEN for that; it is a
|
||||
// behaviour change, not a comment fix, and it must not ride a comment-correction commit.
|
||||
//
|
||||
// Mark the escrow stale so the hub stops advertising "ceremony done" and the customer's escrow
|
||||
// wizard is offered again; a fresh ceremony seals the current password and clears the flag. Every
|
||||
// credential change also emits a visible customer event (offsite_reissued always; escrow_stale
|
||||
// only when a blob was invalidated). Best-effort: the password reset already succeeded — a
|
||||
// bookkeeping failure here must not fail it.
|
||||
escrowStaled := false
|
||||
if host, herr := p.Store.GetHostByCustomer(customerID); herr == nil && host != nil {
|
||||
if esc, eerr := p.Store.GetHostEscrow(host.HostID); eerr == nil && esc != nil {
|
||||
|
||||
@@ -226,7 +226,7 @@ func TestReissue_InvalidatesEscrow(t *testing.T) {
|
||||
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 {
|
||||
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.
|
||||
@@ -256,7 +256,7 @@ func TestReissue_InvalidatesEscrow(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user