hub(R-1218): never replace a recovery package that seals the off-site key with one that seals none (409)
gates / gates (push) Successful in 1m54s

SaveHostEscrowWithIdentity refuses (ErrEscrowWouldDropRepoKey); the PUT answers 409. Red-proved (store: superseded=true;
API: 500). Two older tests pinned the old demotion and are updated. Commit only.
This commit is contained in:
admin committed 2026-10-26 20:21:08 +01:00
1 parent 99c0f5e310
commit 09b5f02fc3
7 files changed
+102 -13

No files matched your search

+8 -1
View File
@@ -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.
@@ -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)
}
}
+5 -3
View File
@@ -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)
}
+8
View File
@@ -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)
@@ -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)
}
}
@@ -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)
}
}
+10
View File
@@ -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