diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 4546bab8..198cc3f8 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,41 @@ +## v0.163.0 — a rebuilt box gets the household's sealed key package back (R-1176); one run per daily job on clock-change days (R-1183); abandoning one set-aside copy keeps the other copies' packages (R-1182); the „new link" page tells a household its old box is still linked (R-1203) (2026-10-21) + +Built for the operator-attended deploy of 2026-10-21 (rulings D24 and the R-1176 shape, same day). + +- **R-1176 — the re-attach (operator ruling 2026-10-21; 03 §8a, 07 §7.0).** After the operator deletes a dead box's + host (escrow acknowledged), the household's sealed package sat under the deleted host id, and every reader a rebuilt + box has is keyed by a live host: the box minted a fresh key, offered no recovery and set the history aside on night + one (proven live, rehearsal 2026-10-20 B7). Now `store.ReattachRetainedEscrow` MOVES the newest retained row of the + customer's newest escrow-acknowledged deletion into `host_escrow` for the customer's one live host, in one + transaction — the same opaque bytes, never opened, re-sealed or logged. Conditions: exactly one live host, no live + host holding a package, the deleted host not live again, the row carries an identity blob. Called on the agent's + host report (before the controller exists) and on the controller's report before the ACK's escrow status is built, + so the box's first ACK says „identity present" and its R-241 mint guard holds. Two live hosts → no carry, + `escrow_carry_refused` mailed to the operator once per deleted host; a carry → `escrow_carried` (host ids + the + non-reversible hash). Both operator-only. The legacy report's wire is unchanged (a server-side step only). + Tests `TestR1176_*` (store: 7; api: 3) — red-proved: no carry → `the rebuilt box's ACK carries no escrow status`; + each wiring call removed → its test fails. +- **R-1183 — DST (operator ruling D24).** `scheduleDaily` added 24 h to an absolute time: on 2026-10-25 every daily job + after 02:00 would run twice (04:00 → 03:00 CET and 04:00 CET; the 02:00 database snapshot at 02:00 CEST and 02:00 + CET), on 2027-03-28 an hour late. Now `nextDailyRun` takes the calendar day after (`time.Date`, day+1) and never + re-runs the slot that ran last. Tests `TestR1183_*` (both 2026-10-25 and 2027-03-28, fake clock) — red-proved + against the old arithmetic (`04:00 job ran 2 time(s) on 2026-10-25`, `ran at 2027-03-28 05:00 CEST`). No other + hub scheduler computes a wall-clock time (`Add(24` grep: thresholds only). +- **R-1182 — the abandonment purge is scoped.** It deleted every retained package of the customer, including packages + that open OTHER set-aside copies still stored. New column `host_escrow_superseded.repo_tag`: NULL for every existing + row (unknown → never purged by an abandonment), 'live' at each new retention; the hub's own move-aside re-tags the + 'live' rows whose key differs from the box's current one with the set-aside path; the purge + (`PurgeAbandonedEscrowForCustomer`) removes only rows tagged with a path the hub has DELETED. The ACK to a declaring + report carries `escrow.abandon_purge_done` so a box can finish while other packages are kept (controller support: + the next controller release; an older controller keeps declaring — harmless, the purge is idempotent). Tests + `TestR1182_*` — red-proved against the customer-wide purge (`purge removed 3, want exactly 1`). +- **R-1203 — the „new link" page.** A household that pressed „Új linket kérek" while its old host was still linked + was promised a mail that never came. Now the holder of the household's REAL used or expired link reads „A régi + dobozod még össze van kötve a fiókoddal. Ezért most nem küldünk új linket. Írj a Felhom ügyfélszolgálatának: …" + (EN twin in the bundle), no link is mailed, and the operator gets `bind_old_host_linked` (warning) with the host + name, once per hour per customer. An unknown token still gets the generic page (no oracle for strangers). Test + `TestR1203_AHouseholdWhoseOldBoxIsStillLinkedIsToldSo` — red-proved (branch removed → the old „Kész." page). + ## v0.162.0 — a hub restart no longer re-mails a known-down box (R-1001); the Day-0 agent list offers only real agent packages (R-1011); a box with no tunnel reads „none set up" (R-1070) (2026-10-19) — DEPLOYED 2026-10-20 06:03 UTC on the operator's yes (morning answers) Built, NOT deployed (the night brief of 2026-10-19: the hub is never deployed at night; the deploy is the operator's diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 61bd093a..e8f1375d 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -1054,12 +1054,9 @@ func scheduleDaily(ctx context.Context, name, timeStr string, fn func(), logger hour, min := parseHM(timeStr) + var last time.Time for { - now := time.Now().In(budapest) - next := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, budapest) - if !next.After(now) { - next = next.Add(24 * time.Hour) - } + next := nextDailyRun(time.Now(), last, hour, min, budapest) delay := time.Until(next) logger.Printf("[INFO] %s: next run at %s (in %s)", name, next.Format("2006-01-02 15:04 MST"), delay.Round(time.Second)) @@ -1067,11 +1064,31 @@ func scheduleDaily(ctx context.Context, name, timeStr string, fn func(), logger case <-ctx.Done(): return case <-time.After(delay): + last = next fn() } } } +// nextDailyRun returns the next HH:MM in loc that is after now and after the slot that ran last (the zero time +// when none has). R-1183: the day after is computed as a calendar day (time.Date with day+1), never as now+24h — +// on 2026-10-25 (25 hours) the old Add(24h) fired a 04:00 job at 03:00 CET and again at 04:00, and on 2027-03-28 +// (23 hours) it fired at 05:00 CEST. The controller's scheduler made the same fix (scheduler.go, day+1). +// The `last` guard keeps a timer that wakes a moment early from running the same slot twice. +// Pinned by TestR1183_* in r1183_dst_schedule_test.go. +func nextDailyRun(now, last time.Time, hour, min int, loc *time.Location) time.Time { + if last.After(now) { + now = last + } + now = now.In(loc) + for d := 0; ; d++ { + next := time.Date(now.Year(), now.Month(), now.Day()+d, hour, min, 0, 0, loc) + if next.After(now) { + return next + } + } +} + // parseHM parses "HH:MM" into hour and minute. Returns 0, 0 on invalid input. func parseHM(s string) (int, int) { var h, m int diff --git a/hub/cmd/hub/r1183_dst_schedule_test.go b/hub/cmd/hub/r1183_dst_schedule_test.go new file mode 100644 index 00000000..291b56fd --- /dev/null +++ b/hub/cmd/hub/r1183_dst_schedule_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "testing" + "time" +) + +// R-1183: a daily hub job runs ONCE on a 25-hour day (2026-10-25, the clocks go back) and once on a 23-hour day +// (2027-03-28, the clocks go forward), each time at the wall-clock hour it was set for. The old loop added 24 h to +// an absolute time, so on 2026-10-25 a 04:00 job fired at 03:00 CET and again at 04:00 CET, and on 2027-03-28 it +// fired at 05:00 CEST — the replay is in audits/dataloss-hunt-2026-10-20/C3-T9-hub-dst/. +// +// The test walks the scheduler's own sequence (nextDailyRun after each run) across both days with a fake clock. +func walkRuns(t *testing.T, start time.Time, until time.Time, hour, min int, loc *time.Location) []time.Time { + t.Helper() + var runs []time.Time + now := start + var last time.Time + for i := 0; i < 20; i++ { + next := nextDailyRun(now, last, hour, min, loc) + if next.After(until) { + break + } + runs = append(runs, next) + last = next + now = next.Add(2 * time.Second) // the job takes a moment; the loop asks again + } + return runs +} + +func TestR1183_DailyJobRunsOnceOnTheDayTheClocksGoBack(t *testing.T) { + loc, err := time.LoadLocation("Europe/Budapest") + if err != nil { + t.Skip("no tz database") + } + for _, hm := range [][2]int{{4, 0}, {5, 0}, {7, 10}, {2, 0}, {2, 30}} { + start := time.Date(2026, 10, 24, 12, 0, 0, 0, loc) + until := time.Date(2026, 10, 26, 23, 0, 0, 0, loc) + runs := walkRuns(t, start, until, hm[0], hm[1], loc) + perDay := map[string]int{} + for _, r := range runs { + l := r.In(loc) + perDay[l.Format("2006-01-02")]++ + if l.Hour() != hm[0] || l.Minute() != hm[1] { + t.Errorf("%02d:%02d job ran at %s — not at its wall-clock time", hm[0], hm[1], l.Format("2006-01-02 15:04 MST")) + } + } + for _, d := range []string{"2026-10-25", "2026-10-26"} { + if perDay[d] != 1 { + t.Errorf("%02d:%02d job ran %d time(s) on %s, want 1 (runs: %v)", hm[0], hm[1], perDay[d], d, runs) + } + } + } +} + +func TestR1183_DailyJobRunsOnceOnTheDayTheClocksGoForward(t *testing.T) { + loc, err := time.LoadLocation("Europe/Budapest") + if err != nil { + t.Skip("no tz database") + } + for _, hm := range [][2]int{{4, 0}, {5, 0}, {7, 10}} { + start := time.Date(2027, 3, 27, 12, 0, 0, 0, loc) + until := time.Date(2027, 3, 29, 23, 0, 0, 0, loc) + runs := walkRuns(t, start, until, hm[0], hm[1], loc) + perDay := map[string]int{} + for _, r := range runs { + l := r.In(loc) + perDay[l.Format("2006-01-02")]++ + if l.Hour() != hm[0] || l.Minute() != hm[1] { + t.Errorf("%02d:%02d job ran at %s — not at its wall-clock time", hm[0], hm[1], l.Format("2006-01-02 15:04 MST")) + } + } + for _, d := range []string{"2027-03-28", "2027-03-29"} { + if perDay[d] != 1 { + t.Errorf("%02d:%02d job ran %d time(s) on %s, want 1 (runs: %v)", hm[0], hm[1], perDay[d], d, runs) + } + } + } +} + +// A timer that fires a moment early (a wall-clock step) must not run the same slot twice. +func TestR1183_TheSameSlotNeverRunsTwice(t *testing.T) { + loc, err := time.LoadLocation("Europe/Budapest") + if err != nil { + t.Skip("no tz database") + } + slot := time.Date(2026, 11, 3, 4, 0, 0, 0, loc) + early := slot.Add(-300 * time.Millisecond) // the loop asks again while the wall clock still reads 03:59:59.7 + if next := nextDailyRun(early, slot, 4, 0, loc); !next.After(slot) { + t.Fatalf("after running the %s slot, the next run is %s — the same slot again", slot, next) + } +} diff --git a/hub/internal/api/escrow_carry.go b/hub/internal/api/escrow_carry.go new file mode 100644 index 00000000..20fec2ab --- /dev/null +++ b/hub/internal/api/escrow_carry.go @@ -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") + } + } +} diff --git a/hub/internal/api/escrow_carry_test.go b/hub/internal/api/escrow_carry_test.go new file mode 100644 index 00000000..68f9eb7e --- /dev/null +++ b/hub/internal/api/escrow_carry_test.go @@ -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") + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index e4c4e2bf..6d311eb0 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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) diff --git a/hub/internal/i18n/locales/en.json b/hub/internal/i18n/locales/en.json index af033266..d4fbae14 100644 --- a/hub/internal/i18n/locales/en.json +++ b/hub/internal/i18n/locales/en.json @@ -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." } diff --git a/hub/internal/i18n/locales/hu.json b/hub/internal/i18n/locales/hu.json index dc2a2f5f..c57fd1e5 100644 --- a/hub/internal/i18n/locales/hu.json +++ b/hub/internal/i18n/locales/hu.json @@ -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." } diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 85ba972d..bd718562 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -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 diff --git a/hub/internal/offsitekeys/service.go b/hub/internal/offsitekeys/service.go index ceeb830a..9abb1cb2 100644 --- a/hub/internal/offsitekeys/service.go +++ b/hub/internal/offsitekeys/service.go @@ -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}) diff --git a/hub/internal/store/escrow_abandon_scope_test.go b/hub/internal/store/escrow_abandon_scope_test.go new file mode 100644 index 00000000..1ee83c25 --- /dev/null +++ b/hub/internal/store/escrow_abandon_scope_test.go @@ -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) + } +} diff --git a/hub/internal/store/escrow_carry.go b/hub/internal/store/escrow_carry.go new file mode 100644 index 00000000..f1b8c5f7 --- /dev/null +++ b/hub/internal/store/escrow_carry.go @@ -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 +} diff --git a/hub/internal/store/escrow_carry_test.go b/hub/internal/store/escrow_carry_test.go new file mode 100644 index 00000000..d9b69328 --- /dev/null +++ b/hub/internal/store/escrow_carry_test.go @@ -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) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 45a4f66f..f6975ede 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -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() } diff --git a/hub/internal/web/selfbind.go b/hub/internal/web/selfbind.go index 8acdb21f..20194bd0 100644 --- a/hub/internal/web/selfbind.go +++ b/hub/internal/web/selfbind.go @@ -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 = `

{{T "bind.consumed.lead"}}

{{T "bind.consumed.body"}}

{{T "bind.resend.hint"}}

+ {{else if eq .State "oldbox"}} +

{{T "bind.oldbox.lead"}}

+

{{T "bind.oldbox.body"}}

{{else if eq .State "resent"}}

{{T "bind.resent.lead"}}

{{T "bind.resent.body"}}

@@ -368,7 +373,8 @@ const bindPageHTML = ` 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" diff --git a/hub/internal/web/selfbind_resend_test.go b/hub/internal/web/selfbind_resend_test.go index 5f529658..ed70448c 100644 --- a/hub/internal/web/selfbind_resend_test.go +++ b/hub/internal/web/selfbind_resend_test.go @@ -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") + } +}