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
gates / gates (push) Successful in 13m23s
This commit is contained in:
@@ -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
|
||||
|
||||
+22
-5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user