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
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:
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user