hub: R-1255 escrow edges (HUNT-3 H3/H4) — the carry takes the package with a hash; set-aside names unique per run; the purge never takes a reused path's package
gates / gates (push) Successful in 1m52s

Tests red before the fix: TestR1255_TheCarryTakesTheNewestPackageThatHasAHash,
TestR1255_APurgeNeverDeletesAPackageAReusedPathStillNeeds, TestR1255_TwoSetAsidesOnOneDayGetTwoNames.
Commit only; rides the next hub release.
This commit is contained in:
admin committed 2026-10-28 22:40:01 +01:00
1 parent 05595ecff6
commit 1ee6d5a507
6 files changed
+204 -10

No files matched your search

+17
View File
@@ -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 `<repo>.orphaned-<YYYYMMDDTHHMMSSZ>` (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
@@ -0,0 +1,35 @@
package offsitekeys
import (
"context"
"testing"
"time"
)
// R-1255 H4: a set-aside name is unique per run. The name was `<repo>.orphaned-<date>`: 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)
}
}
+3 -1
View File
@@ -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
}
+8 -2
View File
@@ -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
@@ -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)
}
}
+16 -7
View File
@@ -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 (