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