R-366: the hub retains the escrow when the whole-guest backup key changes

A reinstall mints a new PBS key K while R-241 keeps the restic password, so
the supersession rule (restic sha only) overwrote the only copy of the old K
and every pre-reinstall whole-guest archive became unopenable for good. The
current row is now also retained when the key fingerprint changes (case and
space ignored; an empty fingerprint is unknown, not a change).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-06 20:43:29 +02:00
parent ed8b10330c
commit 74009014aa
3 changed files with 81 additions and 5 deletions
+1 -1
View File
@@ -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})
@@ -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)
}
}
+15 -4
View File
@@ -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
}