hub v0.163.0: a rebuilt box gets the household's sealed package back (R-1176); one run per daily job on clock-change days (R-1183); a scoped abandonment purge (R-1182); the new-link page says the old box is still linked (R-1203)
gates / gates (push) Successful in 13m23s

This commit is contained in:
admin committed 2026-10-21 13:36:45 +02:00
1 parent e07b077ac2
commit 9688e76efd
16 files changed
+1018 -29

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
package api
import (
"encoding/json"
"fmt"
"strings"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1176 (operator ruling 2026-10-21): the hub re-attaches a household's sealed package to its rebuilt box. The
// store does the move (store.ReattachRetainedEscrow — conditions and reasons there); this half records it.
//
// Called on BOTH report paths, before anything reads the escrow: the agent's host report (the first thing a new
// box sends after enrolling, long before its controller exists) and the controller's report (before the ACK's
// escrow status is built, so the very first ACK already says „identity present" and the box never mints a fresh
// repository password). Idempotent and cheap: one query once the box holds a package.
//
// The audit names host ids and the non-reversible hash only — never the blob (03 §8a: the hub never opens, re-seals
// or logs the package).
const (
eventEscrowCarried = "escrow_carried"
eventEscrowCarryRefused = "escrow_carry_refused"
escrowCarryHashLogPrefix = 12
)
func (h *Handler) carryEscrow(customerID string) {
res, err := h.store.ReattachRetainedEscrow(customerID)
if err != nil {
h.logger.Printf("[ERROR] escrow carry for %s failed (the rebuilt box sees no package until this succeeds; retried on the next report): %v", customerID, err)
return
}
switch res.Outcome {
case store.CarryDone:
short := res.ResticPwSHA256
if len(short) > escrowCarryHashLogPrefix {
short = short[:escrowCarryHashLogPrefix]
}
h.logger.Printf("[WARN] escrow CARRIED for %s: the sealed package of deleted host %s is now the current package of %s (repo-key hash %s…); the box will ask for the recovery code",
customerID, res.FromHost, res.ToHost, short)
msg := fmt.Sprintf("Recovery package carried: the sealed package of the deleted host %s now belongs to the household's new host %s. "+
"The box will offer the household its earlier off-site backups and ask for the recovery code. The hub did not open the package.",
res.FromHost, res.ToHost)
details, _ := json.Marshal(map[string]any{"from_host": res.FromHost, "to_host": res.ToHost, "restic_pw_sha256": res.ResticPwSHA256})
if _, eerr := h.store.SaveEvent(customerID, eventEscrowCarried, "info", msg, string(details), "hub"); eerr != nil {
h.logger.Printf("[WARN] %s event save failed for %s: %v", eventEscrowCarried, customerID, eerr)
}
case store.CarryRefusedOtherLiveHost:
// Once per deleted host: the report repeats every few minutes, the operator needs one mail.
if prev, perr := h.store.GetLatestEventByType(customerID, eventEscrowCarryRefused); perr == nil && prev != nil &&
strings.Contains(prev.DetailsJSON, `"from_host":"`+res.FromHost+`"`) {
return
}
live := strings.Join(res.LiveHosts, ", ")
h.logger.Printf("[WARN] escrow carry REFUSED for %s: the package of deleted host %s waits, but the customer has %d live hosts (%s) — the operator must delete the one that is not the household's",
customerID, res.FromHost, len(res.LiveHosts), live)
msg := fmt.Sprintf("Recovery package NOT carried: customer %s has %d live hosts (%s), so the hub cannot tell which box is the household's. "+
"The sealed package of the deleted host %s is kept. Delete the host that is not in use on the Hosts page; the package then goes to the remaining box on its next report.",
customerID, len(res.LiveHosts), live, res.FromHost)
details, _ := json.Marshal(map[string]any{"from_host": res.FromHost, "live_hosts": res.LiveHosts})
if _, eerr := h.store.SaveEvent(customerID, eventEscrowCarryRefused, "warning", msg, string(details), "hub"); eerr != nil {
h.logger.Printf("[WARN] %s event save failed for %s: %v", eventEscrowCarryRefused, customerID, eerr)
return // audit row first: a mail without its event row lies
}
if h.dispatcher != nil {
go h.dispatcher.ProcessEvent(customerID, eventEscrowCarryRefused, "warning", msg, string(details), "hub")
}
}
}
+164
View File
@@ -0,0 +1,164 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1176 scenario A through the hub's own front doors: the reports a rebuilt box sends, the ACK it reads, and the
// escrow GET its agent calls during the recovery.
func carryRebuiltBox(t *testing.T, st *store.Store, identity []byte) {
t.Helper()
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "Tester4", APIKey: "ckey", RetrievalPassword: "p"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "Tester4-old", CustomerID: "Tester4", APIKey: "OLDKEY"}); err != nil {
t.Fatal(err)
}
if _, _, err := st.SaveHostEscrow("Tester4-old", []byte("k-blob"), "fp", "zero_knowledge", "2026-10-18T00:00:00Z", "SHA_HOUSEHOLD"); err != nil {
t.Fatal(err)
}
if err := st.SaveHostDRBundle("Tester4-old", identity); err != nil {
t.Fatal(err)
}
if err := st.DeleteHost("Tester4-old", true); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "Tester4-new", CustomerID: "Tester4", APIKey: "NEWKEY"}); err != nil {
t.Fatal(err)
}
}
func TestR1176_TheRebuiltBoxsFirstACKCarriesTheHouseholdsPackage(t *testing.T) {
h, st, _ := newTestHandler(t)
identity := []byte("sealed-under-the-household-recovery-code")
carryRebuiltBox(t, st, identity)
rr := do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"Tester4"}`)
if rr.Code != http.StatusOK {
t.Fatalf("report = %d %s", rr.Code, rr.Body.String())
}
var ack struct {
Escrow *struct {
IdentityBlobPresent bool `json:"identity_blob_present"`
ResticPwSHA256 string `json:"restic_pw_sha256"`
} `json:"escrow"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
t.Fatal(err)
}
if ack.Escrow == nil || !ack.Escrow.IdentityBlobPresent || ack.Escrow.ResticPwSHA256 != "SHA_HOUSEHOLD" {
t.Fatalf("first ACK escrow = %+v — the rebuilt box would mint a fresh key (R-1176)", ack.Escrow)
}
// The new host's agent fetches its OWN current package (self-scoped) — the household's bytes.
g := do(h, http.MethodGet, "/hosts/Tester4-new/escrow", "NEWKEY", "")
var got struct {
Present bool `json:"present"`
B64 string `json:"identity_escrow_b64"`
}
if err := json.Unmarshal(g.Body.Bytes(), &got); err != nil || g.Code != http.StatusOK || !got.Present {
t.Fatalf("escrow GET = %d %s", g.Code, g.Body.String())
}
if b, _ := base64.StdEncoding.DecodeString(got.B64); string(b) != string(identity) {
t.Fatal("the served package is not byte-identical to the household's")
}
// The deleted host's key is dead; nothing else of the old host is reachable by the new one.
if r := do(h, http.MethodGet, "/hosts/Tester4-old/escrow/retained", "NEWKEY", ""); r.Code != http.StatusForbidden {
t.Fatalf("new host reading the old host's retained packages = %d, want 403 (self-scope)", r.Code)
}
evs, _ := st.GetEventsByType("Tester4", eventEscrowCarried, time.Time{})
if len(evs) != 1 {
t.Fatalf("escrow_carried events = %d, want 1", len(evs))
}
if strings.Contains(evs[0].DetailsJSON, string(identity)) || strings.Contains(evs[0].Message, string(identity)) {
t.Fatal("the audit event carries the package bytes")
}
}
func TestR1176_TheAgentsHostReportCarriesFirst(t *testing.T) {
h, st, _ := newTestHandler(t)
carryRebuiltBox(t, st, []byte("id"))
if rr := do(h, http.MethodPost, "/host-report", "NEWKEY", validReportBody("Tester4-new")); rr.Code != http.StatusOK {
t.Fatalf("host-report = %d %s", rr.Code, rr.Body.String())
}
if e, _ := st.GetHostEscrow("Tester4-new"); e == nil || e.ResticPwSHA256 != "SHA_HOUSEHOLD" {
t.Fatalf("after the agent's first report the new host holds %+v, want the household's package", e)
}
}
func TestR1176_RefusalMailsTheOperatorOnce(t *testing.T) {
h, st, _ := newTestHandler(t)
carryRebuiltBox(t, st, []byte("id"))
if err := st.UpsertHost(&store.Host{HostID: "Tester4-second", CustomerID: "Tester4", APIKey: "K2"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"Tester4"}`)
}
evs, _ := st.GetEventsByType("Tester4", eventEscrowCarryRefused, time.Time{})
if len(evs) != 1 || evs[0].Severity != "warning" {
t.Fatalf("refusal events = %+v, want exactly one warning", evs)
}
for _, hid := range []string{"Tester4-new", "Tester4-second"} {
if e, _ := st.GetHostEscrow(hid); e != nil {
t.Fatalf("%s got the package while two hosts are live", hid)
}
}
}
// R-1182 at the ACK: a box that declared its abandonment is told the purge is done, although another set-aside
// copy's package is (rightly) still kept and `superseded_present` stays true.
func TestR1182_TheDeclaringBoxIsToldThePurgeIsDone(t *testing.T) {
h, st, _ := newTestHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "C", APIKey: "ckey", RetrievalPassword: "p"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "C-h", CustomerID: "C", APIKey: "HK"}); err != nil {
t.Fatal(err)
}
for _, sha := range []string{"SHA_1", "SHA_2", "SHA_3"} {
if _, _, err := st.SaveHostEscrow("C-h", []byte("k-"+sha), "fp", "zk", "2026-10-01T00:00:00Z", sha); err != nil {
t.Fatal(err)
}
if err := st.SaveHostDRBundle("C-h", []byte("id-"+sha)); err != nil {
t.Fatal(err)
}
if sha != "SHA_3" {
if _, err := st.TagRetainedEscrowSetAside("C", "/r.orphaned-"+sha); err != nil {
t.Fatal(err)
}
}
}
// SHA_1's package now points at /r.orphaned-SHA_2 (the copy set aside after SHA_2 replaced it); SHA_2's package is
// retained 'live'. The household abandons that one copy and the hub deletes it:
id, _ := st.CreateOffsiteAbandon("C", "/r.orphaned-SHA_2", time.Now())
if err := st.MarkOffsiteAbandonDeleted(id); err != nil {
t.Fatal(err)
}
rr := do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"C","offsite":{"abandon_purge_requested":true}}`)
var ack struct {
Escrow struct {
SupersededPresent bool `json:"superseded_present"`
AbandonPurgeDone bool `json:"abandon_purge_done"`
} `json:"escrow"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
t.Fatal(err)
}
if !ack.Escrow.AbandonPurgeDone || !ack.Escrow.SupersededPresent {
t.Fatalf("ACK escrow = %+v, want done=true while another copy's package is kept", ack.Escrow)
}
// A report that did not declare never carries the flag.
rr = do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"C"}`)
if strings.Contains(rr.Body.String(), "abandon_purge_done") {
t.Fatal("a non-declaring report's ACK carries abandon_purge_done")
}
}
+18 -1
View File
@@ -622,9 +622,12 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
AbandonPurgeRequested bool `json:"abandon_purge_requested"`
} `json:"offsite"`
}
abandonDeclared := false
if err := json.Unmarshal(body, &abandonPayload); err == nil &&
abandonPayload.Offsite != nil && abandonPayload.Offsite.AbandonPurgeRequested {
n, perr := h.store.PurgeSupersededEscrowForCustomer(payload.CustomerID)
abandonDeclared = true
// R-1182: only the packages that open a set-aside copy the hub DELETED — never every retained package.
n, perr := h.store.PurgeAbandonedEscrowForCustomer(payload.CustomerID)
switch {
case perr != nil:
// Never swallowed: the box keeps declaring, so this retries next cycle — but an operator
@@ -649,7 +652,16 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
// EscrowState pending→escrowed ONLY when sha256(its local repo password) matches restic_pw_sha256
// (blob-presence alone must never confirm — a stale blob may not cover the current key). The hash is
// non-reversible (256-bit random secret) — safe to serve; omitted entirely when no escrow row exists.
// R-1176: a rebuilt box's FIRST ACK must already carry the household's package, or the box mints a fresh key.
h.carryEscrow(payload.CustomerID)
if es, err := h.store.GetEscrowStatusForCustomer(payload.CustomerID); err == nil && es != nil {
// R-1182: other retained packages are now rightly KEPT after an abandonment, so `superseded_present` can stay
// true for good; this field tells the box its own declaration is finished. Only on a declaring report.
if abandonDeclared {
if left, lerr := h.store.AbandonedEscrowLeft(payload.CustomerID); lerr == nil && left == 0 {
es.AbandonPurgeDone = true
}
}
resp["escrow"] = es
}
@@ -969,6 +981,11 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
return
}
// R-1176: the agent's report is the first a new box sends — carry the household's package before its
// controller exists, so the controller's first ACK finds it.
if custID != "" {
h.carryEscrow(custID)
}
h.checkTunnel(hostID, custID, rep.Cloudflared.Status, rep.Cloudflared.Detail)
h.checkCrash(hostID, custID, body)
h.checkDeliberateOff(hostID, custID, &rep)
+2
View File
@@ -91,6 +91,8 @@
"bind.resend.button": "Send me a new link",
"bind.resent.lead": "Done.",
"bind.resent.body": "If this was a real link and your box is not linked yet, a new e-mail reaches your registered address within a few minutes. If it does not, contact support.",
"bind.oldbox.lead": "Your old box is still linked to your account.",
"bind.oldbox.body": "So we are not sending a new link now. Write to Felhom support: we unlink the old box, and then we send you a new link. Your new box waits until then; you do not need to install it again.",
"mail.event.host_restarted_after_crash": "Your box restarted by itself after an unexpected stop. You do not need to do anything.",
"mail.event.os_update_applied": "System security fixes were installed on your box. You do not need to do anything."
}
+2
View File
@@ -91,6 +91,8 @@
"bind.resend.button": "Új linket kérek",
"bind.resent.lead": "Kész.",
"bind.resent.body": "Ha ez egy valódi hivatkozás volt, és a dobozod még nincs összekötve, néhány percen belül új e-mailt kapsz a regisztrált címedre. Ha nem jön, szólj az ügyfélszolgálatnak.",
"bind.oldbox.lead": "A régi dobozod még össze van kötve a fiókoddal.",
"bind.oldbox.body": "Ezért most nem küldünk új linket. Írj a Felhom ügyfélszolgálatának: leválasztjuk a régi dobozt, és utána küldünk új linket. Az új dobozod addig vár; nem kell újra telepítened.",
"mail.event.host_restarted_after_crash": "A dobozod egy váratlan leállás után magától újraindult. Ehhez nem kell semmit tenned.",
"mail.event.os_update_applied": "Rendszerbiztonsági javítások telepítve a dobozodra. Ehhez nem kell semmit tenned."
}
+8
View File
@@ -849,6 +849,14 @@ var operatorOnlyEvents = map[string]bool{
// mints the type — an operator-tier type absent from this register reaches customers as raw
// English (the v0.78.0 defect recorded above).
"escrow_blob_served": true,
// R-1176 (2026-10-21): a household's sealed package was carried to its rebuilt box, or the carry was refused
// because the customer has two live hosts. Host ids and a hash; the operator acts, the household cannot.
// Registered in the same commit that mints the types.
"escrow_carried": true,
"escrow_carry_refused": true,
// R-1203 (2026-10-21): a household asked for a new box link while its old host is still linked — the operator
// gets the host name to delete. Registered in the same commit that mints the type.
"bind_old_host_linked": true,
// R-523 (v0.114.0). The agent restarted a dead in-guest controller, or gave up after repeated
// restarts. Operator-grade (host ids, vmids, raw docker states) and not actionable by a household
// — the customer's side of this is the dashboard coming back. Registered in the same commit that
+7
View File
@@ -145,6 +145,13 @@ func (s *Service) MoveAside(ctx context.Context, customerID string) (string, err
s.logf("[WARN] offsitekeys: clear the snapshot baseline of %s after the move-aside: %v", customerID, err)
}
s.logf("[WARN] offsitekeys: moved %s's repository aside: %s -> %s (nothing deleted)", customerID, t.RepoPath, name)
// R-1182: record which retained recovery packages open the copy just set aside, so a later abandonment of THIS
// copy removes only them. A failure keeps the packages untagged — kept for ever, never purged by a guess.
if n, terr := s.Store.TagRetainedEscrowSetAside(customerID, name); terr != nil {
s.logf("[WARN] offsitekeys: could not record which recovery packages open %s for %s (they are kept, never purged): %v", name, customerID, terr)
} else {
s.logf("[INFO] offsitekeys: %d retained recovery package(s) of %s now point at the set-aside copy %s", n, customerID, name)
}
s.event(customerID, EventMovedAside, "info",
fmt.Sprintf("Off-site: the box asked to set its orphaned repository aside; %s was moved to %s. Nothing was deleted.", t.RepoPath, name),
map[string]any{"from": t.RepoPath, "to": name})
@@ -0,0 +1,98 @@
package store
import (
"testing"
"time"
)
// R-1182: abandoning ONE set-aside off-site copy removes only the package that opens THAT copy. Before 2026-10-21 the
// purge was customer-wide (audits/dataloss-hunt-2026-10-20, C1 reviewer 3, K2): the package of a second set-aside copy
// still on the Storage Box went with it.
func TestR1182_AbandoningOneCopyKeepsTheOtherCopysPackage(t *testing.T) {
s := newTestStore(t)
const cust = "C"
const repo = "/home/felhom/C/repo"
if err := s.UpsertHost(&Host{HostID: "C-h", CustomerID: cust, APIKey: "k"}); err != nil {
t.Fatal(err)
}
// A row retained before the tag existed (legacy): unknown copy — must be kept.
if _, err := s.db.Exec(`INSERT INTO host_escrow_superseded (host_id, blob, created_at, restic_pw_sha256, identity_blob) VALUES ('C-h', x'01', '2026-01-01', 'SHA_LEGACY', x'02')`); err != nil {
t.Fatal(err)
}
save := func(sha string) {
t.Helper()
if _, _, err := s.SaveHostEscrow("C-h", []byte("k-"+sha), "fp", "zk", "2026-10-01T00:00:00Z", sha); err != nil {
t.Fatal(err)
}
if err := s.SaveHostDRBundle("C-h", []byte("id-"+sha)); err != nil {
t.Fatal(err)
}
}
save("SHA_1") // key 1 writes the repository
save("SHA_2") // key 1 retained ('live'); the box set the old copy aside:
if n, err := s.TagRetainedEscrowSetAside(cust, repo+".orphaned-20261001"); err != nil || n != 1 {
t.Fatalf("first set-aside tagged %d err=%v, want 1 (key 1)", n, err)
}
save("SHA_3") // key 2 retained; a second copy set aside:
if n, err := s.TagRetainedEscrowSetAside(cust, repo+".orphaned-20261015"); err != nil || n != 1 {
t.Fatalf("second set-aside tagged %d err=%v, want 1 (key 2)", n, err)
}
// The household abandons the FIRST copy; the hub deletes it.
id, err := s.CreateOffsiteAbandon(cust, repo+".orphaned-20261001", time.Now())
if err != nil {
t.Fatal(err)
}
if err := s.MarkOffsiteAbandonDeleted(id); err != nil {
t.Fatal(err)
}
if left, _ := s.AbandonedEscrowLeft(cust); left != 1 {
t.Fatalf("before the purge, packages of deleted copies = %d, want 1", left)
}
n, err := s.PurgeAbandonedEscrowForCustomer(cust)
if err != nil || n != 1 {
t.Fatalf("purge removed %d err=%v, want exactly 1 (key 1)", n, err)
}
kept := map[string]bool{}
rows, _ := s.ListSupersededEscrow("C-h")
for _, r := range rows {
kept[r.ResticPwSHA256] = true
}
if !kept["SHA_2"] {
t.Fatal("the package that opens the SECOND set-aside copy was purged — R-1182")
}
if !kept["SHA_LEGACY"] {
t.Fatal("a legacy package (unknown copy) was purged by a guess")
}
if kept["SHA_1"] {
t.Fatal("the abandoned copy's package survived the purge")
}
if cur, _ := s.GetHostEscrow("C-h"); cur == nil || cur.ResticPwSHA256 != "SHA_3" {
t.Fatalf("the CURRENT package was touched: %+v", cur)
}
if left, _ := s.AbandonedEscrowLeft(cust); left != 0 {
t.Fatalf("after the purge, packages of deleted copies = %d, want 0", left)
}
if n2, _ := s.PurgeAbandonedEscrowForCustomer(cust); n2 != 0 {
t.Fatalf("second purge removed %d, want 0 (idempotent)", n2)
}
}
// A package whose key is the box's CURRENT key is not tagged by a move-aside: it opens the new repository (or holds
// an older whole-guest key, R-366), not the copy set aside.
func TestR1182_TheCurrentKeysPackageIsNotTaggedToASetAsideCopy(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertHost(&Host{HostID: "C-h", CustomerID: "C", APIKey: "k"}); err != nil {
t.Fatal(err)
}
if _, _, err := s.SaveHostEscrow("C-h", []byte("k1"), "FP1", "zk", "2026-10-01T00:00:00Z", "SHA_X"); err != nil {
t.Fatal(err)
}
// same repository password, new whole-guest key → retained (R-366) with the SAME restic hash
if _, _, err := s.SaveHostEscrow("C-h", []byte("k2"), "FP2", "zk", "2026-10-02T00:00:00Z", "SHA_X"); err != nil {
t.Fatal(err)
}
if n, err := s.TagRetainedEscrowSetAside("C", "/r.orphaned-20261003"); err != nil || n != 0 {
t.Fatalf("tagged %d err=%v, want 0", n, err)
}
}
+150
View File
@@ -0,0 +1,150 @@
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
}
+199
View File
@@ -0,0 +1,199 @@
package store
import (
"bytes"
"testing"
)
// R-1176 scenario A at the store. The first test is the C3 red-proof from the data-loss hunt
// (audits/dataloss-hunt-2026-10-20/C3-K1-escrow-after-host-delete/), now asserting the household's package
// reaches the rebuilt box — byte-identical, still sealed, with its hash.
func carrySetupOldBox(t *testing.T, s *Store, cust, host, sha string, identity []byte) {
t.Helper()
if err := s.UpsertHost(&Host{HostID: host, CustomerID: cust, APIKey: "k-" + host}); err != nil {
t.Fatal(err)
}
if _, _, err := s.SaveHostEscrow(host, []byte("k-blob-"+host), "fp-"+host, "zero_knowledge", "2026-10-18T00:00:00Z", sha); err != nil {
t.Fatal(err)
}
if identity != nil {
if err := s.SaveHostDRBundle(host, identity); err != nil {
t.Fatal(err)
}
}
}
func TestR1176_RebuiltBoxGetsTheHouseholdsPackage(t *testing.T) {
s := newTestStore(t)
const cust = "Tester4"
identity := []byte("sealed-identity-opening-the-old-repo")
carrySetupOldBox(t, s, cust, "Tester4-old", "SHA_OLD", identity)
if err := s.DeleteHost("Tester4-old", true); err != nil {
t.Fatal(err)
}
if err := s.UpsertHost(&Host{HostID: "Tester4-new", CustomerID: cust, APIKey: "k2"}); err != nil {
t.Fatal(err)
}
res, err := s.ReattachRetainedEscrow(cust)
if err != nil {
t.Fatal(err)
}
st, err := s.GetEscrowStatusForCustomer(cust)
if err != nil || st == nil {
t.Fatalf("the rebuilt box's ACK carries no escrow status (st=%v err=%v) — the R-1176 defect", st, err)
}
if !st.IdentityBlobPresent || st.ResticPwSHA256 != "SHA_OLD" {
t.Fatalf("status = %+v, want identity present with the household's hash", st)
}
if res.Outcome != CarryDone || res.FromHost != "Tester4-old" || res.ToHost != "Tester4-new" || res.ResticPwSHA256 != "SHA_OLD" {
t.Fatalf("carry result = %+v, want done old→new with SHA_OLD", res)
}
b, err := s.GetHostDRBundle("Tester4-new")
if err != nil || b == nil || !bytes.Equal(b.IdentityBlob, identity) || !bytes.Equal(b.KEscrowBlob, []byte("k-blob-Tester4-old")) {
t.Fatalf("the new host's package is not the household's bytes: %+v err=%v", b, err)
}
// One sealed package exists once: the retained copy is gone, the current row holds it.
if ret, _ := s.ListSupersededEscrow("Tester4-old"); len(ret) != 0 {
t.Fatalf("retained rows left under the deleted host: %d, want 0 (moved, not copied)", len(ret))
}
// Idempotent: the second report changes nothing.
if res2, err := s.ReattachRetainedEscrow(cust); err != nil || res2.Outcome != CarryNothing {
t.Fatalf("second call = %+v err=%v, want nothing", res2, err)
}
// And the carried package is never lost: deleting the new host (escrow acknowledged) demotes it again.
if err := s.DeleteHost("Tester4-new", true); err != nil {
t.Fatal(err)
}
if ret, _ := s.ListSupersededEscrow("Tester4-new"); len(ret) != 1 || !bytes.Equal(ret[0].IdentityBlob, identity) {
t.Fatalf("after deleting the rebuilt host, its retained rows = %d, want the household's package", len(ret))
}
}
// The rehearsal's own shape: the old box (with a package) deleted acknowledged, then a second box (never made a
// package) deleted un-acknowledged, then a third box. The un-acked deletion has nothing and must not hide the older one.
func TestR1176_AnUnackedLaterDeletionDoesNotHideThePackage(t *testing.T) {
s := newTestStore(t)
const cust = "Tester4"
carrySetupOldBox(t, s, cust, "Tester4-816e21", "SHA_OLD", []byte("id"))
if err := s.DeleteHost("Tester4-816e21", true); err != nil {
t.Fatal(err)
}
if err := s.UpsertHost(&Host{HostID: "Tester4-648b80", CustomerID: cust, APIKey: "k2"}); err != nil {
t.Fatal(err)
}
// (the hub of the rehearsal did not carry — simulate by deleting before any report)
if err := s.DeleteHost("Tester4-648b80", true); err != nil {
t.Fatal(err)
}
if d, _ := s.LatestHostDeletion(cust); d == nil || d.EscrowAcked {
t.Fatalf("precondition: the latest deletion must be un-acked, got %+v", d)
}
if err := s.UpsertHost(&Host{HostID: "Tester4-third", CustomerID: cust, APIKey: "k3"}); err != nil {
t.Fatal(err)
}
res, err := s.ReattachRetainedEscrow(cust)
if err != nil || res.Outcome != CarryDone || res.FromHost != "Tester4-816e21" {
t.Fatalf("carry = %+v err=%v, want the 816e21 package carried", res, err)
}
}
func TestR1176_RefusedWhileTheCustomerHasAnotherLiveHost(t *testing.T) {
s := newTestStore(t)
const cust = "C"
carrySetupOldBox(t, s, cust, "C-old", "SHA_OLD", []byte("id"))
if err := s.DeleteHost("C-old", true); err != nil {
t.Fatal(err)
}
for _, h := range []string{"C-a", "C-b"} {
if err := s.UpsertHost(&Host{HostID: h, CustomerID: cust, APIKey: "k" + h}); err != nil {
t.Fatal(err)
}
}
res, err := s.ReattachRetainedEscrow(cust)
if err != nil || res.Outcome != CarryRefusedOtherLiveHost || len(res.LiveHosts) != 2 || res.FromHost != "C-old" {
t.Fatalf("carry = %+v err=%v, want refused with both live hosts named", res, err)
}
for _, h := range []string{"C-a", "C-b"} {
if e, _ := s.GetHostEscrow(h); e != nil {
t.Fatalf("%s got a package while the carry was refused", h)
}
}
if ret, _ := s.ListSupersededEscrow("C-old"); len(ret) != 1 {
t.Fatalf("the retained package moved although refused: %d rows", len(ret))
}
}
func TestR1176_NeverAnotherCustomersPackage(t *testing.T) {
s := newTestStore(t)
carrySetupOldBox(t, s, "A", "A-old", "SHA_A", []byte("id-A"))
if err := s.DeleteHost("A-old", true); err != nil {
t.Fatal(err)
}
if err := s.UpsertHost(&Host{HostID: "B-new", CustomerID: "B", APIKey: "kb"}); err != nil {
t.Fatal(err)
}
if res, err := s.ReattachRetainedEscrow("B"); err != nil || res.Outcome != CarryNothing {
t.Fatalf("customer B got %+v err=%v — another customer's package", res, err)
}
if e, _ := s.GetHostEscrow("B-new"); e != nil {
t.Fatal("B-new holds a package it must never see")
}
}
func TestR1176_NotFromAHostDeletedWithoutAcknowledgement(t *testing.T) {
s := newTestStore(t)
const cust = "C"
// A host with no escrow row deletes un-acked; a stray retained row under its id (a supersession earlier in its
// life) must not be carried — only an acknowledged deletion's package is.
if err := s.UpsertHost(&Host{HostID: "C-old", CustomerID: cust, APIKey: "k"}); err != nil {
t.Fatal(err)
}
carrySetupOldBox(t, s, cust, "C-old", "SHA1", []byte("id1"))
if _, _, err := s.SaveHostEscrow("C-old", []byte("k2"), "fp2", "zk", "2026-10-19T00:00:00Z", "SHA2"); err != nil {
t.Fatal(err) // SHA1's package retained under C-old
}
if _, err := s.db.Exec(`DELETE FROM host_escrow WHERE host_id = 'C-old'`); err != nil {
t.Fatal(err)
}
if err := s.DeleteHost("C-old", false); err != nil {
t.Fatal(err)
}
if err := s.UpsertHost(&Host{HostID: "C-new", CustomerID: cust, APIKey: "k3"}); err != nil {
t.Fatal(err)
}
if res, err := s.ReattachRetainedEscrow(cust); err != nil || res.Outcome != CarryNothing {
t.Fatalf("carry = %+v err=%v, want nothing from an un-acknowledged deletion", res, err)
}
}
func TestR1176_ABoxWithItsOwnPackageIsNeverOverwritten(t *testing.T) {
s := newTestStore(t)
const cust = "C"
carrySetupOldBox(t, s, cust, "C-old", "SHA_OLD", []byte("id-old"))
if err := s.DeleteHost("C-old", true); err != nil {
t.Fatal(err)
}
carrySetupOldBox(t, s, cust, "C-new", "SHA_NEW", []byte("id-new"))
if res, err := s.ReattachRetainedEscrow(cust); err != nil || res.Outcome != CarryNothing {
t.Fatalf("carry = %+v err=%v, want nothing", res, err)
}
if e, _ := s.GetHostEscrow("C-new"); e == nil || e.ResticPwSHA256 != "SHA_NEW" {
t.Fatalf("the new box's own package was replaced: %+v", e)
}
}
func TestR1176_APackageWithNoIdentityIsNotCarried(t *testing.T) {
s := newTestStore(t)
const cust = "C"
carrySetupOldBox(t, s, cust, "C-old", "", nil) // a pre-v0.93.0 shape: K only, no repository password
if err := s.DeleteHost("C-old", true); err != nil {
t.Fatal(err)
}
if err := s.UpsertHost(&Host{HostID: "C-new", CustomerID: cust, APIKey: "k"}); err != nil {
t.Fatal(err)
}
if res, err := s.ReattachRetainedEscrow(cust); err != nil || res.Outcome != CarryNothing {
t.Fatalf("carry = %+v err=%v, want nothing", res, err)
}
}
+71 -19
View File
@@ -485,6 +485,14 @@ func (s *Store) migrate() error {
// v0.93.0 report records that as a looked-at fact rather than a deduction.
s.db.Exec(`ALTER TABLE host_escrow_superseded ADD COLUMN identity_blob BLOB`)
// R-1182 (2026-10-21) — WHICH STORED COPY a retained package opens. NULL = unknown (every row retained before
// this column existed): such a row is never purged by a household's abandonment, because the hub cannot tell
// which copy it opens. 'live' = it was the key of the live repository when it was retained. At a move-aside
// the 'live' rows whose repository key differs from the box's current one are re-tagged with the set-aside
// 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`)
// 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
// store and serve; it lets the controller VERIFY "the escrow covers the CURRENT repo password"
@@ -3342,8 +3350,8 @@ type HostEscrow struct {
// TestSaveHostEscrow_RetainsIdentityBlob, which asserts the retained bytes are the previous ones.
func demoteCurrentEscrowTx(tx *sql.Tx, hostID string) (int64, error) {
res, err := tx.Exec(`
INSERT INTO host_escrow_superseded (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, superseded_at, identity_blob)
SELECT host_id, blob, key_fingerprint, posture, created_at, COALESCE(restic_pw_sha256, ''), datetime('now'), identity_blob
INSERT INTO host_escrow_superseded (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, superseded_at, identity_blob, repo_tag)
SELECT host_id, blob, key_fingerprint, posture, created_at, COALESCE(restic_pw_sha256, ''), datetime('now'), identity_blob, 'live'
FROM host_escrow WHERE host_id = ?`, hostID)
if err != nil {
return 0, err
@@ -3492,6 +3500,12 @@ type EscrowStatus struct {
// SupersededAt (hub v0.97.0, R-222) — when the most recent supersession happened (RFC3339-ish, as
// stored). Empty when none. Non-secret: a timestamp.
SupersededAt string `json:"superseded_at,omitempty"`
// AbandonPurgeDone (R-1182, 2026-10-21) — set only in the ACK to a report that declared
// `offsite.abandon_purge_requested`, when no retained package that opens a deleted set-aside copy is left. The box
// closes its declaration on it. Before R-1182 the box waited for SupersededPresent=false, which the scoped purge
// no longer guarantees (packages of OTHER stored copies are kept); a box that predates the field keeps the old
// rule and keeps declaring — harmless, the purge is idempotent.
AbandonPurgeDone bool `json:"abandon_purge_done,omitempty"`
}
// GetEscrowStatusForCustomer returns the escrow status of the customer's host (nil if the customer has no
@@ -4165,31 +4179,69 @@ func (s *Store) GetHostLeafFingerprints() ([]HostLeafRow, error) {
return out, rows.Err()
}
// PurgeSupersededEscrowForCustomer deletes the RETAINED (superseded) escrow rows for every host of a
// customer, and reports how many went. R-241 (v0.98.0), and it is the ONE place the retention added
// by R-198 is ever undone.
// PurgeAbandonedEscrowForCustomer deletes the RETAINED (superseded) escrow rows that open a set-aside off-site copy
// the hub has DELETED for this customer, and reports how many went. R-241 (v0.98.0) made this the one place the
// retention added by R-198 is undone; R-1182 (2026-10-21) narrowed it.
//
// ⚠ WHY THIS EXISTS AT ALL, given R-198 was written to STOP a supersession destroying the key that
// opens an earlier history. Because the customer has now asked for that history to go. The controller
// runs a 14-day countdown after an explicit, twice-confirmed abandonment, deletes the set-aside
// restic store at the end of it, and then DECLARES `offsite.abandon_purge_requested` in its report
// until this runs. Removing the store while keeping its sealed package leaves a package that opens
// nothing; removing the package while keeping the store leaves ciphertext nobody can ever decrypt.
// Both are states that ask a question with no answer, which is exactly what R-241 was.
// ⚠ WHY THIS EXISTS AT ALL, given R-198 was written to STOP a supersession destroying the key that opens an earlier
// history. Because the customer has asked for that history to go: the household's countdown ran out, the hub deleted
// the set-aside copy (decision 74), and the box DECLARES `offsite.abandon_purge_requested` until this runs. A package
// that opens nothing and ciphertext nobody can decrypt are both questions with no answer (R-241).
//
// IT NEVER TOUCHES `host_escrow` — the CURRENT package, which covers the key the box is using now,
// is what makes the box's live off-site backups recoverable. Only the retained rows go.
// R-1182: until 2026-10-21 this deleted EVERY retained row of the customer — including packages that open OTHER
// set-aside copies still stored (a rebuilt box makes a second one likely). Now only rows whose repo_tag names a path
// with a `deleted_at` in offsite_abandon_requests go. Rows with no tag (retained before the tag existed) and 'live'
// rows are KEPT: the hub cannot tell which copy they open, and a guess is how R-1182 happened.
//
// The caller is the report handler, acting on the box's own declaration: the hub cannot see that a
// remote store was deleted and must not infer it.
func (s *Store) PurgeSupersededEscrowForCustomer(customerID string) (int64, error) {
// IT NEVER TOUCHES `host_escrow` — the CURRENT package, which covers the key the box is using now.
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 = ?
)`, customerID, customerID)
) AND repo_tag IN (
SELECT path FROM offsite_abandon_requests WHERE customer_id = ? AND deleted_at IS NOT NULL
)`, customerID, customerID, customerID)
if err != nil {
return 0, fmt.Errorf("purge superseded escrow for %s: %w", customerID, err)
return 0, fmt.Errorf("purge abandoned escrow for %s: %w", customerID, err)
}
return res.RowsAffected()
}
// AbandonedEscrowLeft reports how many retained rows of the customer still name a deleted set-aside path. 0 after a
// successful PurgeAbandonedEscrowForCustomer — the ACK's `abandon_purge_done` (R-1182), which lets the box close its
// declaration while OTHER retained packages are rightly kept.
func (s *Store) AbandonedEscrowLeft(customerID string) (int, error) {
var n int
err := s.db.QueryRow(`
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)
return n, err
}
// TagRetainedEscrowSetAside records, at a move-aside, which retained packages open the copy just set aside: every
// 'live' row of the customer whose repository-key hash is known and differs from the box's CURRENT package. Between
// two move-asides the repository at the live path was created under ONE key; a 'live' row with a different key
// either opens it or opens nothing still stored, so the tag is exact for the first and harmless for the second. A
// row with the current key's hash stays 'live' (it opens the new repository, or an older whole-guest key — R-366).
// Returns how many rows were tagged.
func (s *Store) TagRetainedEscrowSetAside(customerID, setAsidePath string) (int64, error) {
if customerID == "" || setAsidePath == "" {
return 0, fmt.Errorf("tag set-aside escrow: empty customer or path")
}
res, err := s.db.Exec(`
UPDATE host_escrow_superseded SET repo_tag = ?
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 (
SELECT COALESCE(e.restic_pw_sha256, '') FROM host_escrow e JOIN hosts h ON h.host_id = e.host_id WHERE h.customer_id = ?
)`, setAsidePath, customerID, customerID, customerID)
if err != nil {
return 0, fmt.Errorf("tag set-aside escrow for %s: %w", customerID, err)
}
return res.RowsAffected()
}
+40 -2
View File
@@ -2,6 +2,8 @@ package web
import (
"crypto/subtle"
"encoding/json"
"fmt"
"html/template"
"net/http"
"regexp"
@@ -97,7 +99,7 @@ func bindClientIP(r *http.Request) string {
// --- the page ---
type bindPageData struct {
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent"
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent" | "oldbox" (R-1203)
Token string // echoed into the form action (the capability itself; already in the URL)
Failed bool // generic factor-check failure (form state only)
// Lang is the language to render in. It is the CUSTOMER'S CREATION-TIME language and nothing
@@ -334,6 +336,9 @@ const bindPageHTML = `<!DOCTYPE html>
<p class="lead">{{T "bind.consumed.lead"}}</p>
<p>{{T "bind.consumed.body"}}</p>
<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>
{{else if eq .State "oldbox"}}
<p class="lead">{{T "bind.oldbox.lead"}}</p>
<p>{{T "bind.oldbox.body"}}</p>
{{else if eq .State "resent"}}
<p class="lead">{{T "bind.resent.lead"}}</p>
<p>{{T "bind.resent.body"}}</p>
@@ -368,7 +373,8 @@ const bindPageHTML = `<!DOCTYPE html>
const bindResendEvery = time.Hour
func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
defer s.renderBind(w, http.StatusOK, bindPageData{State: "resent", Lang: i18n.Default})
page := bindPageData{State: "resent", Lang: i18n.Default}
defer func() { s.renderBind(w, http.StatusOK, page) }()
tok, err := s.store.SelfBindTokenByHash(selfBindHash(token))
if err != nil || tok == nil {
return
@@ -377,6 +383,35 @@ func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
if !tok.Expired(now) && !tok.Consumed() {
return // a live link needs no replacement
}
// R-1203 (2026-10-21): a household whose old box is still linked waited for a mail that never came (rehearsal
// 2026-10-20 B7, 17 minutes, then a phone call). The link holder is told so in plain words, and the operator gets
// the host name to delete. This answers only a REAL used or expired link — an unknown token still gets the same
// „resent" page, so a stranger learns nothing; the holder of a real link is the household (it came to their mailbox).
if h, herr := s.store.GetHostByCustomer(tok.CustomerID); herr == nil && h != nil {
page = bindPageData{State: "oldbox", Lang: s.store.CustomerLanguage(tok.CustomerID)}
s.bindResendMu.Lock()
key := "oldbox:" + tok.CustomerID
last := s.bindResendAt[key]
if now.Sub(last) < bindResendEvery {
s.bindResendMu.Unlock()
return
}
if s.bindResendAt == nil {
s.bindResendAt = map[string]time.Time{}
}
s.bindResendAt[key] = now
s.bindResendMu.Unlock()
s.logger.Printf("[WARN] self-bind: %s asked for a new link but host %s is still linked — no link sent; the operator is told", tok.CustomerID, h.HostID)
if s.emit != nil {
details, _ := json.Marshal(map[string]any{"host_id": h.HostID})
s.emit(tok.CustomerID, eventBindOldHostLinked, "warning",
fmt.Sprintf("Customer %s asked for a new box link, but host %s is still linked to it, so no link was sent. "+
"If the household's old box is gone (a rebuild), delete host %s on the Hosts page, then send a new link from the customer page. "+
"The household was told the old box is still linked and to write to Felhom.", tok.CustomerID, h.HostID, h.HostID),
string(details), "hub")
}
return
}
s.bindResendMu.Lock()
last := s.bindResendAt[tok.CustomerID]
if now.Sub(last) < bindResendEvery {
@@ -391,3 +426,6 @@ func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
s.bindResendMu.Unlock()
s.autoMintSelfBindIfWaiting(tok.CustomerID, "fresh link asked on an expired or used link")
}
// eventBindOldHostLinked (R-1203): operator-only, registered in notify.operatorOnlyEvents in the same commit.
const eventBindOldHostLinked = "bind_old_host_linked"
+38 -2
View File
@@ -41,8 +41,8 @@ func TestR719_AnExpiredLinkOffersAndSendsAFreshOne(t *testing.T) {
}
}
// No oracle, and no spam: an unknown token, a customer that already has a box, and a second press within
// the hour all get the SAME page and NO mail.
// No oracle, and no spam: an unknown token and a second press within the hour get the SAME page and NO mail; a
// customer that already has a box gets no link (R-1203 below: its REAL link's holder is told why).
func TestR719_ResendIsNoOracleAndRateLimited(t *testing.T) {
s, st := newTestServer(t)
m := &stubMailer{}
@@ -76,3 +76,39 @@ func TestR719_ResendIsNoOracleAndRateLimited(t *testing.T) {
t.Fatal("a customer with a box was mailed a bind link")
}
}
// R-1203 (rehearsal 2026-10-20 B7): the household's old box is still linked. The holder of the household's REAL used
// link is told so in plain words (not „a mail comes in minutes"), the operator is told the host name once per hour,
// and no link is mailed. An unknown token still gets the generic page.
// COMPANION RED-PROOF: drop the GetHostByCustomer branch in handleBindResend → the page says „Kész." and no event.
func TestR1203_AHouseholdWhoseOldBoxIsStillLinkedIsToldSo(t *testing.T) {
s, st := newTestServer(t)
m := &stubMailer{}
s.SetSelfBindMailer(m)
type ev struct{ cust, typ, sev, msg, details string }
var evs []ev
s.emit = func(c, typ, sev, msg, details, src string) { evs = append(evs, ev{c, typ, sev, msg, details}) }
seedForMint(t, st, "Tester4", "tester4@felhom.example")
if err := st.UpsertHost(&store.Host{HostID: "Tester4-816e21", CustomerID: "Tester4", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
used := mintLink(t, st, "Tester4", -time.Hour)
body := resendPOST(s, used).Body.String()
if !strings.Contains(body, "A régi dobozod még össze van kötve") || strings.Contains(body, "percen belül új e-mailt") {
t.Fatalf("the page does not say the old box is still linked:\n%s", body)
}
if m.link != "" {
t.Fatal("a link was mailed while the old box is linked")
}
if len(evs) != 1 || evs[0].typ != "bind_old_host_linked" || evs[0].sev != "warning" || !strings.Contains(evs[0].msg, "Tester4-816e21") {
t.Fatalf("operator events = %+v, want one warning naming the host", evs)
}
_ = resendPOST(s, used) // a second press within the hour: same page, no second mail
if len(evs) != 1 {
t.Fatalf("operator events after a second press = %d, want 1", len(evs))
}
if unk := resendPOST(s, strings.Repeat("b", 64)).Body.String(); strings.Contains(unk, "A régi dobozod") {
t.Fatal("an unknown token learns that a box is linked")
}
}