diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 018f7384..f6e64e05 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -1271,7 +1271,7 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa } if superseded { n, _ := h.store.CountSupersededEscrow(pathHostID) - h.logger.Printf("[INFO] escrow for host %s superseded a different-passphrase blob — RETAINED (now %d superseded blob(s) held)", pathHostID, n) + h.logger.Printf("[INFO] escrow for host %s superseded an escrow with a different passphrase or backup key (R-366) — RETAINED (now %d superseded blob(s) held)", pathHostID, n) // Hub-internal audit event (not gated by allowedEventTypes) — tied to the owning customer. if host, herr := h.store.GetHost(pathHostID); herr == nil && host != nil && host.CustomerID != "" { details, _ := json.Marshal(map[string]any{"host_id": pathHostID, "retained_count": n}) diff --git a/hub/internal/store/r366_escrow_key_change_test.go b/hub/internal/store/r366_escrow_key_change_test.go new file mode 100644 index 00000000..8b3f6c41 --- /dev/null +++ b/hub/internal/store/r366_escrow_key_change_test.go @@ -0,0 +1,65 @@ +package store + +import "testing" + +// R-366 — a reinstall mints a NEW whole-guest backup key K (`felhom-pbs-apply`: `--encryption-key +// autogen`) while, since R-241's mint guard, the box keeps its restic password. The escrow PUT then +// carries the SAME restic sha and a NEW key fingerprint. Until this fix the supersession rule looked +// at the restic sha only, so the row holding the OLD K (wrapped under the customer's recovery code) +// was OVERWRITTEN — every whole-guest archive written before the reinstall became unopenable for +// good, not only for the new box. The old row must be RETAINED when K changes. +// +// COMPANION RED-PROOF (observed, `audits/night-burndown-2026-10-06/r366/red-key-change.txt`): on the +// pre-fix rule (`curSHA != resticPwSHA256` alone) this fails with "a new backup key with the same +// restic password must supersede (retain the old K)". Restored. +func TestSaveHostEscrow_R366_NewBackupKeySameResticPasswordRetainsOldKey(t *testing.T) { + st := newTestStore(t) + const h = "h1" + if _, _, err := st.SaveHostEscrow(h, []byte("K-old-wrapped"), "3f:4f:65:c0", "zk", "2026-08-18T00:00:00Z", "SHA_SAME"); err != nil { + t.Fatal(err) + } + sup, _, err := st.SaveHostEscrow(h, []byte("K-new-wrapped"), "dd:d1:d8:53", "zk", "2026-08-21T00:00:00Z", "SHA_SAME") + if err != nil { + t.Fatal(err) + } + if !sup { + t.Fatal("a new backup key with the same restic password must supersede (retain the old K)") + } + old, err := st.ListSupersededEscrow(h) + if err != nil { + t.Fatal(err) + } + if len(old) != 1 || string(old[0].Blob) != "K-old-wrapped" || old[0].KeyFingerprint != "3f:4f:65:c0" { + t.Fatalf("the old K is not retained: %+v", old) + } + cur, _ := st.GetHostEscrow(h) + if cur == nil || string(cur.Blob) != "K-new-wrapped" || cur.KeyFingerprint != "dd:d1:d8:53" { + t.Fatalf("the current row must be the new K: %+v", cur) + } +} + +// The same key in another letter case, or a legacy row with no fingerprint, is NOT a key change: +// a re-upload of the same K must stay idempotent (no retained row per re-ceremony). +func TestSaveHostEscrow_R366_SameKeyOrUnknownFingerprintDoesNotSupersede(t *testing.T) { + st := newTestStore(t) + const h = "h1" + if _, _, err := st.SaveHostEscrow(h, []byte("a"), "AB:CD", "zk", "2026-08-18T00:00:00Z", "SHA"); err != nil { + t.Fatal(err) + } + for i, fp := range []string{"ab:cd", " AB:CD ", ""} { + sup, _, err := st.SaveHostEscrow(h, []byte("b"), fp, "zk", "2026-08-19T00:00:00Z", "SHA") + if err != nil { + t.Fatal(err) + } + if sup { + t.Fatalf("case %d (%q): the same or an unknown fingerprint must not supersede", i, fp) + } + // keep the stored fingerprint known for the next case + if _, _, err := st.SaveHostEscrow(h, []byte("a"), "AB:CD", "zk", "2026-08-18T00:00:00Z", "SHA"); err != nil { + t.Fatal(err) + } + } + if n, _ := st.CountSupersededEscrow(h); n != 0 { + t.Fatalf("no key change happened, yet %d rows were retained", n) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index af3d4beb..92529c1f 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -3257,6 +3257,13 @@ func demoteCurrentEscrowTx(tx *sql.Tx, hostID string) (int64, error) { return res.RowsAffected() } +// backupKeyChanged reports whether two key fingerprints name different keys. Case and surrounding +// space do not count; an empty side is unknown, never a change. +func backupKeyChanged(cur, next string) bool { + cur, next = strings.TrimSpace(cur), strings.TrimSpace(next) + return cur != "" && next != "" && !strings.EqualFold(cur, next) +} + func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt, resticPwSHA256 string) (superseded bool, prevResticPwSHA256 string, err error) { tx, err := s.db.Begin() if err != nil { @@ -3269,10 +3276,14 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu }() // Retain the current row iff it exists AND seals a DIFFERENT restic password (the incident: a - // recreated volume mints a new passphrase; the old must stay recoverable with its recovery code). - var curSHA string + // recreated volume mints a new passphrase; the old must stay recoverable with its recovery code) + // OR a DIFFERENT whole-guest backup key K (R-366: a reinstall mints a new K — `--encryption-key + // autogen` — while R-241 keeps the restic password, so the restic sha alone overwrote the only copy + // of the old K and every pre-reinstall archive became unopenable for good). An unknown fingerprint + // on either side ("" — a legacy row) is not a change. Pinned by TestSaveHostEscrow_R366_*. + var curSHA, curFP string var exists bool - switch scanErr := tx.QueryRow(`SELECT COALESCE(restic_pw_sha256,'') FROM host_escrow WHERE host_id = ?`, hostID).Scan(&curSHA); scanErr { + switch scanErr := tx.QueryRow(`SELECT COALESCE(restic_pw_sha256,''), COALESCE(key_fingerprint,'') FROM host_escrow WHERE host_id = ?`, hostID).Scan(&curSHA, &curFP); scanErr { case nil: exists = true case sql.ErrNoRows: @@ -3284,7 +3295,7 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu if exists { prevResticPwSHA256 = curSHA // R-197: the caller compares; "" = no row or a legacy hash-less blob } - if exists && curSHA != resticPwSHA256 { + if exists && (curSHA != resticPwSHA256 || backupKeyChanged(curFP, keyFingerprint)) { if _, err = demoteCurrentEscrowTx(tx, hostID); err != nil { return false, prevResticPwSHA256, err }