Files
felhom.eu/hub/internal/store/escrow_carry.go
T

151 lines
6.7 KiB
Go

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
}