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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user