151 lines
6.7 KiB
Go
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
|
|
}
|