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

@@ -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)
}
}