package store import ( "database/sql" "fmt" ) // ── R-1176 — a rebuilt box gets the household's sealed package back (operator ruling 2026-10-21) ──────────────── // // D19 makes a reinstall wait until the operator deletes the old host; DeleteHost then DEMOTES the old host's current // escrow row into host_escrow_superseded under the OLD host_id. Every reader the new box has (the report ACK's escrow // status, the escrow GET, the retained GET) is keyed by a LIVE host, so the new box saw no package at all: it minted // a fresh repository password, offered no recovery, and on its first night set the household's history aside. // Proven at the store (audits/dataloss-hunt-2026-10-20/C3-K1-escrow-after-host-delete/) and live (rehearsal // 2026-10-20 B7). // // The fix RE-ATTACHES the package: the newest retained row of the customer's newest escrow-acknowledged host // deletion becomes the new host's CURRENT escrow row — the same opaque bytes, still sealed under the household's // recovery code. Everything downstream already exists: the ACK reports „identity present" with the package's hash, // the box's mint guard (R-241) refuses a fresh key, and the box offers the recovery screen. // // ZERO-KNOWLEDGE IS UNCHANGED (03 §8a): the hub moves ciphertext from one row to another. It does not open, re-seal // or log the blob; the audit event carries host ids and the non-reversible hash only. // // WHEN IT CARRIES — every condition is required, and each refusal is a test (escrow_carry_test.go): // - the customer has exactly ONE live host (the new box). With two, the hub cannot tell which box is the // household's — CarryRefusedOtherLiveHost, and the caller mails the operator; // - no live host of the customer holds a current escrow row (a box with its own package is never overwritten); // - the newest host deletion WITH escrow_acked=1. escrow_acked=0 means the deleted host held no escrow row at all // (DeleteHost refuses a host with a row unless the operator acknowledges), so an un-acked deletion has nothing // to give and does not hide an older acknowledged one; // - that deleted host is not live again, and its newest retained row carries an identity blob (a pre-v0.93.0 // row seals no repository password and would make the box wait for a key that cannot come). // // It MOVES the row (insert current + delete retained, one transaction), so one sealed package exists once. If the // new host is later deleted with the escrow acknowledged, DeleteHost demotes it again under the new host id — no // path loses it. // CarryOutcome says what ReattachRetainedEscrow did. type CarryOutcome int const ( CarryNothing CarryOutcome = iota // nothing to carry, or the box already holds a package CarryDone // the package was re-attached to the new host CarryRefusedOtherLiveHost // a package is waiting, but the customer has more than one live host ) // CarryResult describes one carry decision. ResticPwSHA256 is the non-reversible hash the package covers ("" when // none); the blob itself never leaves the store. type CarryResult struct { Outcome CarryOutcome FromHost string ToHost string ResticPwSHA256 string LiveHosts []string } // ReattachRetainedEscrow carries the customer's retained package to its one live host when the conditions above // hold. Idempotent: once the live host holds a current row, every later call is CarryNothing after one query. func (s *Store) ReattachRetainedEscrow(customerID string) (CarryResult, error) { var res CarryResult if customerID == "" { return res, nil } tx, err := s.db.Begin() if err != nil { return res, fmt.Errorf("escrow carry %s: begin: %w", customerID, err) } defer tx.Rollback() var hasCurrent int if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM host_escrow e JOIN hosts h ON h.host_id = e.host_id WHERE h.customer_id = ?)`, customerID).Scan(&hasCurrent); err != nil { return res, fmt.Errorf("escrow carry %s: current check: %w", customerID, err) } if hasCurrent != 0 { return res, nil } var fromHost string switch err := tx.QueryRow(`SELECT host_id FROM host_deletions WHERE customer_id = ? AND escrow_acked = 1 ORDER BY id DESC LIMIT 1`, customerID).Scan(&fromHost); err { case nil: case sql.ErrNoRows: return res, nil default: return res, fmt.Errorf("escrow carry %s: deletion lookup: %w", customerID, err) } var fromLive int if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM hosts WHERE host_id = ?)`, fromHost).Scan(&fromLive); err != nil { return res, fmt.Errorf("escrow carry %s: deleted-host check: %w", customerID, err) } if fromLive != 0 { return res, nil // the deleted host id enrolled again — its own rows are its own } var retainedID int64 var hasIdentity int var sha string switch err := tx.QueryRow(`SELECT id, (identity_blob IS NOT NULL AND length(identity_blob) > 0), COALESCE(restic_pw_sha256, '') FROM host_escrow_superseded WHERE host_id = ? ORDER BY id DESC LIMIT 1`, fromHost).Scan(&retainedID, &hasIdentity, &sha); err { case nil: case sql.ErrNoRows: return res, nil // purged already (an abandonment), or never demoted default: return res, fmt.Errorf("escrow carry %s: retained lookup: %w", customerID, err) } if hasIdentity == 0 { return res, nil } rows, err := tx.Query(`SELECT host_id FROM hosts WHERE customer_id = ? ORDER BY host_id`, customerID) if err != nil { return res, fmt.Errorf("escrow carry %s: live hosts: %w", customerID, err) } var live []string for rows.Next() { var h string if err := rows.Scan(&h); err != nil { rows.Close() return res, err } live = append(live, h) } rows.Close() res.FromHost, res.ResticPwSHA256, res.LiveHosts = fromHost, sha, live switch { case len(live) == 0: return CarryResult{}, nil // nobody to carry to yet case len(live) > 1: res.Outcome = CarryRefusedOtherLiveHost return res, nil } res.ToHost = live[0] if _, err := tx.Exec(` INSERT INTO host_escrow (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, identity_blob, updated_at) SELECT ?, blob, key_fingerprint, posture, created_at, restic_pw_sha256, identity_blob, datetime('now') FROM host_escrow_superseded WHERE id = ?`, res.ToHost, retainedID); err != nil { return CarryResult{}, fmt.Errorf("escrow carry %s: attach to %s: %w", customerID, res.ToHost, err) } if _, err := tx.Exec(`DELETE FROM host_escrow_superseded WHERE id = ?`, retainedID); err != nil { return CarryResult{}, fmt.Errorf("escrow carry %s: remove the retained copy: %w", customerID, err) } if err := tx.Commit(); err != nil { return CarryResult{}, fmt.Errorf("escrow carry %s: commit: %w", customerID, err) } res.Outcome = CarryDone return res, nil }