From 1ee6d5a507688e11bc2c354400da4948c45233c6 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 28 Oct 2026 22:40:01 +0100 Subject: [PATCH] =?UTF-8?q?hub:=20R-1255=20escrow=20edges=20(HUNT-3=20H3/H?= =?UTF-8?q?4)=20=E2=80=94=20the=20carry=20takes=20the=20package=20with=20a?= =?UTF-8?q?=20hash;=20set-aside=20names=20unique=20per=20run;=20the=20purg?= =?UTF-8?q?e=20never=20takes=20a=20reused=20path's=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests red before the fix: TestR1255_TheCarryTakesTheNewestPackageThatHasAHash, TestR1255_APurgeNeverDeletesAPackageAReusedPathStillNeeds, TestR1255_TwoSetAsidesOnOneDayGetTwoNames. Commit only; rides the next hub release. --- hub/CHANGELOG.md | 17 +++ .../offsitekeys/r1255_setaside_name_test.go | 35 +++++ hub/internal/offsitekeys/service.go | 4 +- hub/internal/store/escrow_carry.go | 10 +- hub/internal/store/r1255_escrow_edges_test.go | 125 ++++++++++++++++++ hub/internal/store/store.go | 23 +++- 6 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 hub/internal/offsitekeys/r1255_setaside_name_test.go create mode 100644 hub/internal/store/r1255_escrow_edges_test.go diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 68be97eb..d9c0c704 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,20 @@ +## Unreleased (2026-10-28 night) — R-1255: two escrow edges (review round 2, HUNT-3 H3/H4) — commit only; rides the next hub release (the morning list) + +- **H3 — the carry takes the package that has a hash (`store/escrow_carry.go`).** The R-1176 carry moved the deleted + host's NEWEST retained row and checked only its identity blob: a newer package without a repository-password hash + was carried while the household's real one stayed behind. It now takes the newest row with an identity AND a hash; + only when no row has a hash, the newest row as before (a legacy package — never fewer ways back). Tests + `TestR1255_TheCarryTakesTheNewestPackageThatHasAHash` (red before the fix: the hash-less row was carried), + `TestR1255_WithNoHashedPackageTheNewestIdentityIsStillCarried`. +- **H4 — a set-aside name is unique per run, and the purge never deletes a package a stored copy still needs.** + `offsitekeys.Service.MoveAside` stamps `.orphaned-` (was the date alone: a copy abandoned and + deleted the same day freed the name, and the next set-aside took it). The store records WHEN a row was tagged with a + set-aside path (`host_escrow_superseded.repo_tagged_at`, new column); `PurgeAbandonedEscrowForCustomer` and + `AbandonedEscrowLeft` take a row only when it was tagged no later than its path's deletion (a NULL tag time — a row + tagged before this column — uses its `superseded_at`). Tests `TestR1255_TwoSetAsidesOnOneDayGetTwoNames`, + `TestR1255_APurgeNeverDeletesAPackageAReusedPathStillNeeds` (both red before the fix); the R-1182 tests unchanged + and green. + ## v0.174.1 (2026-10-27) — R-1299: the window event is really recorded; the block moves to the Backup & DR tab — DEPLOYED on the operator's yes (image sha256:bb43f88a…, from `de57c090`, CI 2437) - **Defect in v0.174.0, found by the operator's live click on tester-1 (2026-10-27 20:07 CET):** a grant logged its line diff --git a/hub/internal/offsitekeys/r1255_setaside_name_test.go b/hub/internal/offsitekeys/r1255_setaside_name_test.go new file mode 100644 index 00000000..262fa243 --- /dev/null +++ b/hub/internal/offsitekeys/r1255_setaside_name_test.go @@ -0,0 +1,35 @@ +package offsitekeys + +import ( + "context" + "testing" + "time" +) + +// R-1255 H4: a set-aside name is unique per run. The name was `.orphaned-`: a copy set aside, abandoned +// and deleted the same day freed the name, and the next set-aside took it again — two copies, one name, and the +// packages tagged with it could no longer be told apart (the purge took both). The stamp now carries the time. +// RED-PROOF: go back to Format("20060102") in Service.MoveAside → both runs give the same name → FAILS. +func TestR1255_TwoSetAsidesOnOneDayGetTwoNames(t *testing.T) { + s, fs, _ := svcFixture(t) + clock := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC) + s.Now = func() time.Time { return clock } + fs.dirs["/home/felhom-repo"] = true + first, err := s.MoveAside(context.Background(), "c1") + if err != nil { + t.Fatal(err) + } + delete(fs.dirs, first) // abandoned and deleted the same day + fs.dirs["/home/felhom-repo"] = true + clock = clock.Add(3 * time.Hour) + second, err := s.MoveAside(context.Background(), "c1") + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatalf("two set-asides on one day share the name %q", first) + } + if !IsSetAsidePath("/home/felhom-repo", first) || !IsSetAsidePath("/home/felhom-repo", second) { + t.Fatalf("names %q / %q are not set-aside paths", first, second) + } +} diff --git a/hub/internal/offsitekeys/service.go b/hub/internal/offsitekeys/service.go index 9abb1cb2..709afabf 100644 --- a/hub/internal/offsitekeys/service.go +++ b/hub/internal/offsitekeys/service.go @@ -136,7 +136,9 @@ func (s *Service) MoveAside(ctx context.Context, customerID string) (string, err if err != nil { return "", err } - name, err := s.Reg.MoveAside(ctx, t, pw, s.now().UTC().Format("20060102")) + // R-1255 H4: the stamp carries the time, so a name is unique per run — a date alone was freed when that day's copy + // was abandoned and deleted, and the next set-aside took the same name (its packages then purged with the old ones). + name, err := s.Reg.MoveAside(ctx, t, pw, s.now().UTC().Format("20060102T150405Z")) if err != nil { return "", err } diff --git a/hub/internal/store/escrow_carry.go b/hub/internal/store/escrow_carry.go index f1b8c5f7..2b6eaf49 100644 --- a/hub/internal/store/escrow_carry.go +++ b/hub/internal/store/escrow_carry.go @@ -30,7 +30,8 @@ import ( // (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). +// row seals no repository password and would make the box wait for a key that cannot come). R-1255 H3: the +// newest row WITH a repository-password hash is taken first; the plain newest only when no row has one. // // 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 @@ -94,11 +95,16 @@ func (s *Store) ReattachRetainedEscrow(customerID string) (CarryResult, error) { return res, nil // the deleted host id enrolled again — its own rows are its own } + // R-1255 H3: the newest retained row that seals the off-site repository password (a hash is recorded) comes + // first; only when no row has a hash, the newest row (a legacy package: the hash was never recorded) as before. + // The newest row alone could be a package without the password while the household's real one stayed behind. 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 { + FROM host_escrow_superseded WHERE host_id = ? + ORDER BY (identity_blob IS NOT NULL AND length(identity_blob) > 0 AND COALESCE(restic_pw_sha256, '') != '') DESC, 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 diff --git a/hub/internal/store/r1255_escrow_edges_test.go b/hub/internal/store/r1255_escrow_edges_test.go new file mode 100644 index 00000000..e89b6d5a --- /dev/null +++ b/hub/internal/store/r1255_escrow_edges_test.go @@ -0,0 +1,125 @@ +package store + +import ( + "bytes" + "testing" + "time" +) + +// R-1255 (review round 2, HUNT-3 H3/H4): two escrow edges at the store. + +// H3: the R-1176 carry took the deleted host's NEWEST retained row and checked only its identity blob. A newer +// row with an identity but NO repository-password hash (a package that seals no off-site key) was carried, and +// the household's real package stayed behind under the deleted host. The carry takes the newest row that has +// BOTH. +// RED-PROOF: drop `AND restic_pw_sha256 != ”` from the carry's retained lookup → the hash-less row is carried → FAILS. +func TestR1255_TheCarryTakesTheNewestPackageThatHasAHash(t *testing.T) { + s := newTestStore(t) + const cust = "T" + identity := []byte("sealed-identity-opening-the-repo") + carrySetupOldBox(t, s, cust, "T-old", "SHA_REAL", identity) + if err := s.DeleteHost("T-old", true); err != nil { + t.Fatal(err) + } + // A newer retained row of the same deleted host: an identity, no repository-password hash. + if _, err := s.db.Exec(`INSERT INTO host_escrow_superseded (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, identity_blob, repo_tag) + VALUES ('T-old', x'0101', 'fp-x', 'zero_knowledge', '2026-10-19T00:00:00Z', '', x'0202', 'live')`); err != nil { + t.Fatal(err) + } + if err := s.UpsertHost(&Host{HostID: "T-new", CustomerID: cust, APIKey: "k2"}); err != nil { + t.Fatal(err) + } + res, err := s.ReattachRetainedEscrow(cust) + if err != nil { + t.Fatal(err) + } + if res.Outcome != CarryDone || res.ResticPwSHA256 != "SHA_REAL" { + t.Fatalf("carry = %+v — want the package with the household's hash (SHA_REAL)", res) + } + b, err := s.GetHostDRBundle("T-new") + if err != nil || b == nil || !bytes.Equal(b.IdentityBlob, identity) { + t.Fatalf("the new host holds %+v (err %v) — want the household's identity package", b, err) + } + // The hash-less row stays retained under the deleted host (nothing is thrown away). + ret, _ := s.ListSupersededEscrow("T-old") + if len(ret) != 1 || ret[0].ResticPwSHA256 != "" { + t.Fatalf("retained under the deleted host: %+v — want only the hash-less row", ret) + } +} + +// Only hash-less rows (a legacy package: its identity may still seal the password, the hash was never recorded): the +// newest with an identity is still carried, as before — never fewer ways back than before the fix. +func TestR1255_WithNoHashedPackageTheNewestIdentityIsStillCarried(t *testing.T) { + s := newTestStore(t) + const cust = "U" + carrySetupOldBox(t, s, cust, "U-old", "", []byte("id")) + if err := s.DeleteHost("U-old", true); err != nil { + t.Fatal(err) + } + if err := s.UpsertHost(&Host{HostID: "U-new", CustomerID: cust, APIKey: "k2"}); err != nil { + t.Fatal(err) + } + if res, err := s.ReattachRetainedEscrow(cust); err != nil || res.Outcome != CarryDone || res.FromHost != "U-old" { + t.Fatalf("carry = %+v err=%v — want the legacy package carried, as before", res, err) + } +} + +// H4: two set-asides of one customer can carry the SAME path (the first was abandoned and deleted, then a second +// copy was set aside under the free name). The purge deleted every row tagged with a deleted path — so the SECOND +// copy's package went too, while that copy is still stored. A row is purged only when it was tagged no later than +// the deletion of its path. +// RED-PROOF: drop the tagged-before-the-deletion condition from PurgeAbandonedEscrowForCustomer → the second copy's +// package is purged → FAILS. +func TestR1255_APurgeNeverDeletesAPackageAReusedPathStillNeeds(t *testing.T) { + s := newTestStore(t) + const cust = "R" + const repo = "/home/felhom/R/repo" + path := repo + ".orphaned-20261028" + if err := s.UpsertHost(&Host{HostID: "R-h", CustomerID: cust, APIKey: "k"}); err != nil { + t.Fatal(err) + } + save := func(sha string) { + t.Helper() + if _, _, err := s.SaveHostEscrow("R-h", []byte("k-"+sha), "fp", "zk", "2026-10-01T00:00:00Z", sha); err != nil { + t.Fatal(err) + } + if err := s.SaveHostDRBundle("R-h", []byte("id-"+sha)); err != nil { + t.Fatal(err) + } + } + save("SHA_1") + save("SHA_2") // key 1 retained; its copy set aside: + if n, err := s.TagRetainedEscrowSetAside(cust, path); err != nil || n != 1 { + t.Fatalf("first set-aside tagged %d err=%v", n, err) + } + id, err := s.CreateOffsiteAbandon(cust, path, time.Now()) + if err != nil { + t.Fatal(err) + } + if err := s.MarkOffsiteAbandonDeleted(id); err != nil { + t.Fatal(err) + } + time.Sleep(1100 * time.Millisecond) // the hub's clock has second precision; a real reuse is hours later + save("SHA_3") // key 2 retained; a second copy is set aside under the same (now free) name: + if n, err := s.TagRetainedEscrowSetAside(cust, path); err != nil || n != 1 { + t.Fatalf("second set-aside tagged %d err=%v", n, err) + } + n, err := s.PurgeAbandonedEscrowForCustomer(cust) + if err != nil || n != 1 { + t.Fatalf("purge removed %d err=%v — want exactly the deleted copy's package (key 1)", n, err) + } + kept := map[string]bool{} + rows, _ := s.ListSupersededEscrow("R-h") + for _, r := range rows { + kept[r.ResticPwSHA256] = true + } + if !kept["SHA_2"] { + t.Fatal("the package of the copy STILL STORED under the reused name was purged — R-1255 H4") + } + if kept["SHA_1"] { + t.Fatal("the deleted copy's package survived the purge") + } + if left, _ := s.AbandonedEscrowLeft(cust); left != 0 { + t.Fatalf("after the purge, packages of deleted copies = %d, want 0 (the reused path's package is not one)", left) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 6377a309..9d4bc0b9 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -492,6 +492,10 @@ func (s *Store) migrate() error { // path (TagRetainedEscrowSetAside); an abandonment purges only rows tagged with a path the hub deleted // (PurgeAbandonedEscrowForCustomer). Pinned by TestR1182_*. s.db.Exec(`ALTER TABLE host_escrow_superseded ADD COLUMN repo_tag TEXT`) + // R-1255 H4: WHEN a row was tagged with a set-aside path. A purge takes a row only when it was tagged no later than + // its path's deletion — a path reused by a later set-aside keeps the later copy's packages. NULL = tagged before + // this column existed: the row's superseded_at stands in (a row retained after the deletion cannot open that copy). + s.db.Exec(`ALTER TABLE host_escrow_superseded ADD COLUMN repo_tagged_at DATETIME`) // SLICE 3 (escrow auto-confirm) — sha256 hex of the offsite restic repo password sealed in the // identity blob. The hash of a 256-bit random secret is non-reversible/non-brute-forceable — safe to @@ -4331,14 +4335,21 @@ func (s *Store) GetHostLeafFingerprints() ([]HostLeafRow, error) { // rows are KEPT: the hub cannot tell which copy they open, and a guess is how R-1182 happened. // // IT NEVER TOUCHES `host_escrow` — the CURRENT package, which covers the key the box is using now. +// abandonedTagMatch (R-1255 H4) is a retained row whose set-aside path the hub DELETED, and which was tagged no later than +// that deletion. A path reused by a later set-aside (the same name, freed by the deletion) tags its own packages AFTER +// the deletion — those open a copy that is still stored and are never purged. One customer id parameter. +const abandonedTagMatch = `EXISTS ( + SELECT 1 FROM offsite_abandon_requests a + WHERE a.customer_id = ? AND a.deleted_at IS NOT NULL AND a.path = host_escrow_superseded.repo_tag + AND COALESCE(host_escrow_superseded.repo_tagged_at, host_escrow_superseded.superseded_at) <= a.deleted_at + )` + func (s *Store) PurgeAbandonedEscrowForCustomer(customerID string) (int64, error) { res, err := s.db.Exec(` DELETE FROM host_escrow_superseded WHERE host_id IN ( SELECT host_id FROM hosts WHERE customer_id = ? UNION SELECT host_id FROM host_deletions WHERE customer_id = ? - ) AND repo_tag IN ( - SELECT path FROM offsite_abandon_requests WHERE customer_id = ? AND deleted_at IS NOT NULL - )`, customerID, customerID, customerID) + ) AND `+abandonedTagMatch, customerID, customerID, customerID) if err != nil { return 0, fmt.Errorf("purge abandoned escrow for %s: %w", customerID, err) } @@ -4354,9 +4365,7 @@ func (s *Store) AbandonedEscrowLeft(customerID string) (int, error) { SELECT COUNT(*) FROM host_escrow_superseded WHERE host_id IN ( SELECT host_id FROM hosts WHERE customer_id = ? UNION SELECT host_id FROM host_deletions WHERE customer_id = ? - ) AND repo_tag IN ( - SELECT path FROM offsite_abandon_requests WHERE customer_id = ? AND deleted_at IS NOT NULL - )`, customerID, customerID, customerID).Scan(&n) + ) AND `+abandonedTagMatch, customerID, customerID, customerID).Scan(&n) return n, err } @@ -4371,7 +4380,7 @@ func (s *Store) TagRetainedEscrowSetAside(customerID, setAsidePath string) (int6 return 0, fmt.Errorf("tag set-aside escrow: empty customer or path") } res, err := s.db.Exec(` - UPDATE host_escrow_superseded SET repo_tag = ? + UPDATE host_escrow_superseded SET repo_tag = ?, repo_tagged_at = datetime('now') WHERE repo_tag = 'live' AND restic_pw_sha256 != '' AND host_id IN (SELECT host_id FROM hosts WHERE customer_id = ? UNION SELECT host_id FROM host_deletions WHERE customer_id = ?) AND restic_pw_sha256 NOT IN (