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
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:
1 parent
05595ecff6
commit
1ee6d5a507
6 files changed
+204
-10
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user