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

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