diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 84c5882b..387c9414 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,4 +1,11 @@ -## Unreleased (next: v0.173.0) — D53: a held app reaches the operator (`08` §10) +## Unreleased (next: v0.173.0) — D53: a held app reaches the operator (`08` §10); R-1218: a keyed recovery package is never replaced by a key-less one + +- **R-1218 (P2, key code), the hub half:** `store.SaveHostEscrowWithIdentity` REFUSES (`ErrEscrowWouldDropRepoKey`) an upload that + seals no off-site repository password (`restic_pw_sha256` "") over a package that seals one; the escrow PUT answers 409 + with the reason and a WARN. Before: the good package was demoted to operator-only custody and the household's code no + longer opened its off-site copy. A key-less upload over a key-less (or no) package is still accepted. Tests + `TestR1218_*` (store + API), red-proved (superseded=true; 500); `TestEscrowPut_HashlessSupersession_NoSignal` and the + legacy case of `TestHandleHostEscrow_StoresResticPwHash` updated to the new contract (they pinned the old demotion). Operator ruling D53 (2026-10-26, `09` §3 decision 265): operator-only reminders first. Pairs with controller `5bd03ad` (`internal/holdreport`); a box on an older controller sends nothing new, and its list stays empty. diff --git a/hub/internal/api/escrow_repo_key_changed_test.go b/hub/internal/api/escrow_repo_key_changed_test.go index 1070b1cb..66733ba0 100644 --- a/hub/internal/api/escrow_repo_key_changed_test.go +++ b/hub/internal/api/escrow_repo_key_changed_test.go @@ -108,24 +108,22 @@ func TestEscrowPut_UnchangedRepoKey_Silent(t *testing.T) { } } -// The in-between shapes must not be silent-and-indistinguishable either: a hash-less NEW blob cannot -// show whether the key changed, and saying nothing at all would look identical to "unchanged". -// Asserted at the level the code offers — no event (correct: nothing was measured) and no crash. +// The in-between shape: a hash-less NEW blob over a hashed one. Until R-1218 it was accepted and SUPERSEDED the good +// package (the household's code then no longer opened its off-site copy). Since R-1218 (2026-10-26 night) it is REFUSED +// with 409 and nothing changes: no event (nothing was measured), no retained row, the hashed package stays current. func TestEscrowPut_HashlessSupersession_NoSignal(t *testing.T) { h, st, _ := newTestHandler(t) st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k1"), "SHA_GEN1", "age-1")) - if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k2"), "", "age-2")); rr.Code != http.StatusOK { - t.Fatalf("hash-less supersession PUT = %d", rr.Code) + if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k2"), "", "age-2")); rr.Code != http.StatusConflict { + t.Fatalf("hash-less PUT over a hashed package = %d, want 409 (R-1218)", rr.Code) } if ev, _ := st.GetLatestEventByType("c1", eventRepoKeyChanged); ev != nil { t.Fatalf("a hash-less new blob cannot EVIDENCE a change; it must not claim one: %+v", ev) } - // The retention still ran (the hashes differ, so this IS a supersession) — the key is kept even - // though the change cannot be evidenced. - if n, _ := st.CountSupersededEscrow("h1"); n != 1 { - t.Fatalf("superseded rows = %d, want 1", n) + if n, _ := st.CountSupersededEscrow("h1"); n != 0 { + t.Fatalf("superseded rows = %d, want 0 — the refused upload must not demote the good package", n) } } diff --git a/hub/internal/api/escrow_test.go b/hub/internal/api/escrow_test.go index cad9413b..1aade62f 100644 --- a/hub/internal/api/escrow_test.go +++ b/hub/internal/api/escrow_test.go @@ -128,11 +128,13 @@ func TestHandleHostEscrow_StoresResticPwHash(t *testing.T) { if got.ResticPwSHA256 != "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4" { t.Fatalf("hash not stored: %q", got.ResticPwSHA256) } - // legacy upload without the field → stored empty, read back NULL-safe as "" - if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("legacy"))); rr.Code != http.StatusOK { + // legacy upload without the field → stored empty, read back NULL-safe as "" (on a host whose package has no hash; + // over a hashed package a hash-less upload is refused since R-1218 — TestR1218_TheEscrowPutRefusesToDropTheRepoKey) + st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"}) + if rr := do(h, http.MethodPut, "/hosts/h2/escrow", "HKEY2", escrowBody([]byte("legacy"))); rr.Code != http.StatusOK { t.Fatalf("legacy PUT = %d", rr.Code) } - got2, err := st.GetHostEscrow("h1") + got2, err := st.GetHostEscrow("h2") if err != nil || got2.ResticPwSHA256 != "" { t.Fatalf("legacy upload must read back with an empty hash: %q err=%v", got2.ResticPwSHA256, err) } diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 307bc145..7e414f38 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -7,6 +7,7 @@ import ( "database/sql" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "log" @@ -1426,6 +1427,13 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa // when this upload supersedes a DIFFERENT-passphrase old blob, the old one is RETAINED (not // overwritten) so its recovery-code-recoverable history survives (Viktor's data-first ruling). superseded, prevPwSHA, serr := h.store.SaveHostEscrowWithIdentity(pathHostID, blob, idBlob, req.KeyFingerprint, req.Posture, createdAt, req.ResticPwSHA256) + if errors.Is(serr, store.ErrEscrowWouldDropRepoKey) { + // R-1218: the current package seals the off-site repository password and this one does not — keeping the good + // one current is the whole point; the box shows the ceremony as failed instead of silently weakening the code. + h.logger.Printf("[WARN] escrow for host %s REFUSED: the upload seals no off-site repository password while the current package does — the current package stays (R-1218)", pathHostID) + http.Error(w, "Refused: the new recovery package covers no off-site key while the stored one does; the stored package stays (R-1218)", http.StatusConflict) + return + } if serr != nil { h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, serr) http.Error(w, "Internal error", http.StatusInternalServerError) diff --git a/hub/internal/api/r1218_escrow_put_refusal_test.go b/hub/internal/api/r1218_escrow_put_refusal_test.go new file mode 100644 index 00000000..4824d149 --- /dev/null +++ b/hub/internal/api/r1218_escrow_put_refusal_test.go @@ -0,0 +1,27 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-1218 (hub half, the upload): an escrow PUT sealing no off-site password over a package that seals one is answered +// 409 with a sentence naming why — never „Internal error" — and the good package stays current. +// COMPANION RED-PROOF: without the errors.Is branch the handler answers 500. +func TestR1218_TheEscrowPutRefusesToDropTheRepoKey(t *testing.T) { + h, st, _ := newTestHandler(t) + st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) + if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("good"), "SHA_GOOD", "")); rr.Code != http.StatusOK { + t.Fatalf("first PUT = %d", rr.Code) + } + rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("keyless"), "", "")) + if rr.Code != http.StatusConflict || !strings.Contains(rr.Body.String(), "off-site") { + t.Fatalf("a key-less PUT over a keyed package = %d %q, want 409 naming the off-site key", rr.Code, rr.Body.String()) + } + if cur, _ := st.GetHostEscrow("h1"); cur == nil || string(cur.Blob) != "good" { + t.Fatalf("the good package is not current: %+v", cur) + } +} diff --git a/hub/internal/store/r1218_escrow_no_key_demotion_test.go b/hub/internal/store/r1218_escrow_no_key_demotion_test.go new file mode 100644 index 00000000..8b0fbc3e --- /dev/null +++ b/hub/internal/store/r1218_escrow_no_key_demotion_test.go @@ -0,0 +1,37 @@ +package store + +import ( + "errors" + "testing" +) + +// R-1218 — the hub half. A package that seals the off-site repository password (sha "abc") must never be replaced by +// one that seals none (sha ""): before this the hub DEMOTED the good package to operator-only custody (superseded), and +// the household's new recovery code no longer opened its off-site copy. Now the save is REFUSED and the good package +// stays current. The first upload of a box with no off-site key ("" over nothing) and "" over "" stay allowed. +// COMPANION RED-PROOF: drop the refusal → the second save succeeds with superseded=true and this fails. +func TestR1218_APackageWithAKeyIsNeverReplacedByOneWithout(t *testing.T) { + st := newTestStore(t) + const h = "h1" + if _, _, err := st.SaveHostEscrow(h, []byte("pkg-with-key"), "3f:4f", "zk", "2026-10-20T00:00:00Z", "abc"); err != nil { + t.Fatal(err) + } + sup, _, err := st.SaveHostEscrow(h, []byte("pkg-without-key"), "3f:4f", "zk", "2026-10-22T00:00:00Z", "") + if !errors.Is(err, ErrEscrowWouldDropRepoKey) { + t.Fatalf("a key-less package replaced one that covers the off-site key: superseded=%v err=%v", sup, err) + } + cur, _ := st.GetHostEscrow(h) + if cur == nil || string(cur.Blob) != "pkg-with-key" || cur.ResticPwSHA256 != "abc" { + t.Fatalf("the good package is no longer current: %+v", cur) + } + if old, _ := st.ListSupersededEscrow(h); len(old) != 0 { + t.Fatalf("the refused save still demoted something: %+v", old) + } + // Allowed: a box that never had an off-site key — "" over nothing, then "" over "". + if _, _, err := st.SaveHostEscrow("h2", []byte("first"), "aa", "zk", "2026-10-20T00:00:00Z", ""); err != nil { + t.Fatal(err) + } + if _, _, err := st.SaveHostEscrow("h2", []byte("second"), "aa", "zk", "2026-10-21T00:00:00Z", ""); err != nil { + t.Fatalf("a key-less re-ceremony on a key-less box was refused: %v", err) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 7185902f..6377a309 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -3472,6 +3472,10 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu return s.SaveHostEscrowWithIdentity(hostID, blob, nil, keyFingerprint, posture, createdAt, resticPwSHA256) } +// ErrEscrowWouldDropRepoKey (R-1218): the upload seals NO off-site repository password while the current package seals +// one. Refused — replacing it would demote the household's working package to operator-only custody. +var ErrEscrowWouldDropRepoKey = errors.New("escrow: the new package seals no off-site repository password, the current one does — refused (R-1218)") + // identityWriteFault is a TEST seam (nil in production): it makes the identity write inside the escrow transaction // fail, so a test can prove that nothing of the new package is then stored (R-1254). var identityWriteFault func() error @@ -3513,6 +3517,12 @@ func (s *Store) SaveHostEscrowWithIdentity(hostID string, blob, identityBlob []b if exists { prevResticPwSHA256 = curSHA // R-197: the caller compares; "" = no row or a legacy hash-less blob } + // R-1218: a package that seals the off-site key is never replaced by one that seals none. Pinned by + // TestR1218_APackageWithAKeyIsNeverReplacedByOneWithout. + if exists && curSHA != "" && strings.TrimSpace(resticPwSHA256) == "" { + err = ErrEscrowWouldDropRepoKey + return false, prevResticPwSHA256, err + } if exists && (curSHA != resticPwSHA256 || backupKeyChanged(curFP, keyFingerprint)) { if _, err = demoteCurrentEscrowTx(tx, hostID); err != nil { return false, prevResticPwSHA256, err