hub v0.60.0: offsite continuity Part B — superseded-escrow retention (data-first)
- host_escrow_superseded table + SaveHostEscrow retains a different-sha old blob before overwrite (tx); same-sha idempotent (no supersede row); returns superseded bool. ACK/restore read the current row unchanged. CountSuperseded/ListSuperseded; DeleteHost drops retained rows. - escrow_superseded audit event + operator retained-count on host detail; register offbox_repo_orphaned/reset. Red-proof TestSaveHostEscrow_RetainsSuperseded.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
// Part B (v0.60.0) — the escrow retention red-proof (the 2026-07-17 incident class). A new escrow blob
|
||||
// with a DIFFERENT sealed-passphrase sha must RETAIN the old blob (recoverable) instead of destroying
|
||||
// it; a same-sha re-upload is idempotent (no supersede row). Pre-fix (destructive ON CONFLICT
|
||||
// overwrite) the old blob is gone → the retrieval assertion FAILS.
|
||||
func TestSaveHostEscrow_RetainsSuperseded(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
const h = "h1"
|
||||
|
||||
// 1st upload (P_old) — nothing to supersede.
|
||||
sup, err := st.SaveHostEscrow(h, []byte("blob-old"), "fp-old", "zk", "2026-07-09T00:00:00Z", "SHA_OLD")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sup {
|
||||
t.Fatal("first upload must not supersede")
|
||||
}
|
||||
|
||||
// 2nd upload (P_new, DIFFERENT sha) — must supersede + retain the old.
|
||||
sup, err = st.SaveHostEscrow(h, []byte("blob-new"), "fp-new", "zk", "2026-07-16T00:00:00Z", "SHA_NEW")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sup {
|
||||
t.Fatal("a different-passphrase upload must supersede (retain the old blob)")
|
||||
}
|
||||
// Current row is the NEW blob (ACK/restore-serving read this — unchanged behavior).
|
||||
cur, _ := st.GetHostEscrow(h)
|
||||
if cur == nil || string(cur.Blob) != "blob-new" || cur.ResticPwSHA256 != "SHA_NEW" {
|
||||
t.Fatalf("current row not the new blob: %+v", cur)
|
||||
}
|
||||
// RED-PROOF: the OLD blob is RETAINED and retrievable.
|
||||
old, err := st.ListSupersededEscrow(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(old) != 1 || string(old[0].Blob) != "blob-old" || old[0].ResticPwSHA256 != "SHA_OLD" {
|
||||
t.Fatalf("old blob NOT retained after overwrite (incident): %+v", old)
|
||||
}
|
||||
if n, _ := st.CountSupersededEscrow(h); n != 1 {
|
||||
t.Fatalf("superseded count = %d, want 1", n)
|
||||
}
|
||||
|
||||
// 3rd upload, SAME sha as current — idempotent (re-ceremony of the same password): NO supersede row.
|
||||
sup, err = st.SaveHostEscrow(h, []byte("blob-new-2"), "fp-new", "zk", "2026-07-16T01:00:00Z", "SHA_NEW")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sup {
|
||||
t.Fatal("a same-sha re-upload must NOT supersede (idempotent)")
|
||||
}
|
||||
if n, _ := st.CountSupersededEscrow(h); n != 1 {
|
||||
t.Fatalf("idempotent re-upload created a superseded row: count=%d", n)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func seedHostWithArtifacts(t *testing.T, s *Store, hostID, customerID string) {
|
||||
"PK-"+hostID, "ip-"+hostID, hostID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SaveHostEscrow(hostID, []byte("opaque-escrow"), "fp", "posture", "2026-07-01T00:00:00Z", ""); err != nil {
|
||||
if _, err := s.SaveHostEscrow(hostID, []byte("opaque-escrow"), "fp", "posture", "2026-07-01T00:00:00Z", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +330,25 @@ func (s *Store) migrate() error {
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- host_escrow_superseded (v0.60.0, offsite-continuity): RETAINED old escrow blobs. Viktor's
|
||||
-- ruling (data protection first): when a NEW escrow blob supersedes an old one whose sealed
|
||||
-- restic-password sha DIFFERS, the old row is COPIED here BEFORE host_escrow is overwritten —
|
||||
-- so the old passphrase stays customer-R-recoverable (turns the reinstall-orphan incident from
|
||||
-- "history destroyed" into "history recoverable with the recovery code"). Append-only; the hub
|
||||
-- never decrypts; NO pruning (the blobs are tiny + R-encrypted; custody unchanged). The ACK and
|
||||
-- restore-serving read host_escrow (the CURRENT row) — never this table.
|
||||
CREATE TABLE IF NOT EXISTS host_escrow_superseded (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id TEXT NOT NULL,
|
||||
blob BLOB NOT NULL,
|
||||
key_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
posture TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
restic_pw_sha256 TEXT NOT NULL DEFAULT '',
|
||||
superseded_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_escrow_superseded_host ON host_escrow_superseded(host_id);
|
||||
|
||||
-- signed_jobs (slice 10A): the per-host queue of OPAQUE operator-signed destructive-op
|
||||
-- blobs. The hub STORES + SERVES them; it never forges one (there is no signing key
|
||||
-- hub-side) and never executes them (execution + signature verification is slice 10B).
|
||||
@@ -1990,6 +2009,7 @@ func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error {
|
||||
}
|
||||
if deleteEscrow {
|
||||
stmts = append(stmts, `DELETE FROM host_escrow WHERE host_id = ?`)
|
||||
stmts = append(stmts, `DELETE FROM host_escrow_superseded WHERE host_id = ?`) // retained blobs go with the host
|
||||
}
|
||||
stmts = append(stmts, `DELETE FROM hosts WHERE host_id = ?`)
|
||||
for _, q := range stmts {
|
||||
@@ -2071,8 +2091,49 @@ type HostEscrow struct {
|
||||
// SaveHostEscrow stores (last-write-wins) the OPAQUE escrow blob for a host. The hub keeps the
|
||||
// bytes and NEVER decrypts them — there is no decrypt path. createdAt is the agent's timestamp.
|
||||
// resticPwSHA256 is "" when the ceremony sealed no staged password (stored as-is; never auto-confirms).
|
||||
func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt, resticPwSHA256 string) error {
|
||||
_, err := s.db.Exec(`
|
||||
// SaveHostEscrow returns superseded=true when it RETAINED a different-passphrase old blob into
|
||||
// host_escrow_superseded before overwriting the current row (Part B, v0.60.0). A same-sha re-upload
|
||||
// (idempotent re-ceremony of the same password) refreshes the current row and does NOT create a
|
||||
// superseded row.
|
||||
func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt, resticPwSHA256 string) (superseded bool, err error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// 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 (
|
||||
curBlob []byte
|
||||
curFp, curPosture, curCreated, curSHA string
|
||||
exists bool
|
||||
)
|
||||
row := tx.QueryRow(`SELECT blob, key_fingerprint, posture, created_at, COALESCE(restic_pw_sha256,'') FROM host_escrow WHERE host_id = ?`, hostID)
|
||||
switch scanErr := row.Scan(&curBlob, &curFp, &curPosture, &curCreated, &curSHA); scanErr {
|
||||
case nil:
|
||||
exists = true
|
||||
case sql.ErrNoRows:
|
||||
exists = false
|
||||
default:
|
||||
err = scanErr
|
||||
return false, err
|
||||
}
|
||||
if exists && curSHA != resticPwSHA256 {
|
||||
if _, err = tx.Exec(`
|
||||
INSERT INTO host_escrow_superseded (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, superseded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
hostID, curBlob, curFp, curPosture, curCreated, curSHA); err != nil {
|
||||
return false, err
|
||||
}
|
||||
superseded = true
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
INSERT INTO host_escrow (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(host_id) DO UPDATE SET
|
||||
@@ -2083,9 +2144,40 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu
|
||||
restic_pw_sha256 = excluded.restic_pw_sha256,
|
||||
stale_at = NULL,
|
||||
updated_at = datetime('now')`,
|
||||
hostID, blob, keyFingerprint, posture, createdAt, resticPwSHA256,
|
||||
)
|
||||
return err
|
||||
hostID, blob, keyFingerprint, posture, createdAt, resticPwSHA256); err != nil {
|
||||
return false, err
|
||||
}
|
||||
err = tx.Commit()
|
||||
return superseded, err
|
||||
}
|
||||
|
||||
// CountSupersededEscrow returns how many retained (superseded) escrow blobs the hub holds for a host
|
||||
// (the operator "N superseded escrow blobs retained" surface). 0 on no rows.
|
||||
func (s *Store) CountSupersededEscrow(hostID string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM host_escrow_superseded WHERE host_id = ?`, hostID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ListSupersededEscrow returns the retained (superseded) escrow blobs for a host, newest-superseded
|
||||
// first. Opaque bytes — the hub never decrypts. Seeds the future guided-recovery flow (R-26).
|
||||
func (s *Store) ListSupersededEscrow(hostID string) ([]HostEscrow, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, superseded_at
|
||||
FROM host_escrow_superseded WHERE host_id = ? ORDER BY id DESC`, hostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []HostEscrow
|
||||
for rows.Next() {
|
||||
var e HostEscrow
|
||||
if err := rows.Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.ResticPwSHA256, &e.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkEscrowStale flags a host's escrow blob as stale (v0.57.0, 2.3) — called when the offsite repo
|
||||
|
||||
Reference in New Issue
Block a user