hub R-1254 (P2): the escrow key, its hash and the identity blob in ONE transaction; a bad identity blob is refused before anything is stored. Committed, not deployed.
gates / gates (push) Successful in 13m27s

This commit is contained in:
2026-10-24 20:37:59 +02:00
parent 80f1ab3167
commit dcf4f84cb7
6 changed files with 165 additions and 16 deletions
+1 -1
View File
@@ -96,7 +96,7 @@
| `(*Server).configFormData` (v0.49.0) | hub/internal/web/configs.go (~L430) | `(r, isNew, cfg, overrides, errMsg) configFormView` | The ONE view-model builder for the customer config form (standalone chrome + the customer page Edit tab) | `overrides=nil` → parses the STORED cfg.ConfigJSON; pass the SUBMITTED map on the update validation-error re-render or typed values reset (red-proofed). |
| `config_form_body` sub-template (v0.49.0) | hub/internal/web/templates/config_form_body.html | `{{template "config_form_body" <configFormView>}}` | Rendering the config form on ANY surface (config_form.html chrome + customer Edit tab) | The floor/geo/danger cards on the Edit tab are SIBLINGS after `</form>` — never nest a form inside it (breaks the offsite/PBS formaction sub-buttons). Includes the F5 in-flight `<script>`. |
| `handleHostEscrowGet` + `escrowSelfServiceRetrieval` | hub/internal/api/handler.go | `GET /hosts/{id}/escrow` | The box-authenticated MIRROR of the escrow PUT — self-scoped by the per-host key. **`escrowSelfServiceRetrieval` is THE single decision point** for whether a box may read its own blob without operator-armed recovery mode (§8.2 vs §8.3, R-199): flip it, do not scatter the condition. **Never merge this with `dr.go`'s `handleReEnroll`/`handleGetRestoreDirective`** — those rotate the host API key and serve the K-escrow and directive too, and keep their recovery-mode gate (pinned by `TestEscrowGet_OperatorDRPathUnchanged`). Every successful retrieval MUST raise `escrow_blob_served` before the bytes leave; that audit row is the mitigation the trade rests on. |
| `demoteCurrentEscrowTx` (+ `(*Store).SaveHostEscrow`) | hub/internal/store/store.go (~L2597/~L2612) | `(tx, hostID) (int64, error)` / `(hostID, blob, fp, posture, createdAt, resticPwSHA) (superseded bool, prevResticPwSHA string, err error)` | **THE ONE escrow row-copy routine** — used by the re-escrow retention AND by `DeleteHost`'s custody demotion; never write a second one | **It must copy BOTH sealed artifacts** — `blob` (K-escrow / PBS key) and `identity_blob` (the age bundle carrying the offsite restic repo password). Omitting the second is R-198: two months of retaining the wrong key, with the ceremony as the destroying act. **Ordering it depends on:** `SaveHostDRBundle` writes `identity_blob` AFTER `SaveHostEscrow` returns, so the demote sees the PREVIOUS generation — invert that and the retained bytes are the new blob under the old hash. Pinned by `TestSaveHostEscrow_RetainsIdentityBlob` + `TestDeleteHost_DemotesIdentityBlob` (both callers). `prevResticPwSHA` feeds R-197's changed-key signal; it is a hash and never leaves the store. |
| `demoteCurrentEscrowTx` (+ `(*Store).SaveHostEscrow` / `SaveHostEscrowWithIdentity`) | hub/internal/store/store.go (~L2597/~L2612) | `(tx, hostID) (int64, error)` / `(hostID, blob, fp, posture, createdAt, resticPwSHA) (superseded bool, prevResticPwSHA string, err error)` | **THE ONE escrow row-copy routine** — used by the re-escrow retention AND by `DeleteHost`'s custody demotion; never write a second one | **It must copy BOTH sealed artifacts** — `blob` (K-escrow / PBS key) and `identity_blob` (the age bundle carrying the offsite restic repo password). Omitting the second is R-198: two months of retaining the wrong key, with the ceremony as the destroying act. **Ordering it depends on:** since R-1254 (unreleased, next hub v0.170.0) `SaveHostEscrowWithIdentity` writes the key, its hash and `identity_blob` in ONE transaction, with the demote FIRST inside it, so the demote sees the PREVIOUS generation — invert that and the retained bytes are the new blob under the old hash. `SaveHostEscrow` is that function with no identity blob; `SaveHostDRBundle` (a second statement) is no longer called by the escrow PUT. Pinned by `TestR1254_*`. Pinned by `TestSaveHostEscrow_RetainsIdentityBlob` + `TestDeleteHost_DemotesIdentityBlob` (both callers). `prevResticPwSHA` feeds R-197's changed-key signal; it is a hash and never leaves the store. |
| `(*Store).CountHostArtifacts` / `DeleteHost` | hub/internal/store/store.go (~L1640/~L1690) | `(hostID) (HostArtifacts, error)` / `(hostID, deleteEscrow bool) error` | Host-delete impact preview + the ONE-transaction cascade | ONLINE gate lives in the handler, escrow gate in the store (`ErrHostEscrowPresent`, tx never starts). log_bundles die by `scope_id == host_id` ONLY (customer-scoped bundles survive). The wg_peers delete is INSIDE the tx — never split it out. |
| `(*Server).commitCustomerReset` (v0.69.0) | hub/internal/web/customer_reset.go (~L165) | `(ctx, cfg, resetID int64, purgeEscrow bool) *resetLegError` | THE committed RESET sequence — external teardown FIRST (Hetzner, PBS), then claim → descriptor → DB purge, each leg stamped into the `customer_resets` journal | Owns NO gate, NO audit event, NO journal open/close, NO redirect — those are the caller's (the two callers differ there). `purgeEscrow` governs ONLY whether `PurgeCustomerResetDBState` destroys retained custody: standalone RESET passes the operator's `escrow_ack`; the DELETE cascade passes **false** so custody dies exactly once, in its leg 3. Returns a `resetLegError` carrying the leg name + the exact status/message the standalone handler has always returned — do not re-word them. |
| `(*Store).CustomerResidue` / `PurgeCustomerResidue` (v0.70.0) | hub/internal/store/customer_delete.go | `(customerID) (*CustomerResidue, error)` / `(customerID) error` | Counting + purging the report-derived state and the credential-bearing bindings a deleted customer leaves behind | **`GetCustomers()` is REPORT-derived** — until the reports are gone the customer stays on the Customers list AND stays in the staleness/offsite checkers’ work list, so a deleted customer keeps emailing the operator. Both funcs walk ONE shared `residueQueries` list so a table can never be counted-but-not-purged. Includes `appliance_registrations` + `selfbind_tokens` (credential-bearing, not telemetry). NEVER touches `events`, `notification_log`, `host_deletions`, `customer_resets`. |
+15
View File
@@ -1,3 +1,18 @@
## Unreleased (next: v0.170.0) — the escrow package in one transaction (R-1254) (2026-10-24 night) — committed, NOT built for release, NOT deployed
- **The new escrow key, its sealed-password hash and the identity blob are stored in ONE transaction**
(`Store.SaveHostEscrowWithIdentity`; `SaveHostEscrow` is it with no identity blob). Until now the escrow PUT committed
the key + hash, then wrote the identity blob in a second statement (`SaveHostDRBundle`): a failure between them left
the NEW hash beside the OLD identity package, the box read the hash and marked itself escrowed, and on a total loss
the written recovery code opened nothing. A failed write now leaves all of the old package or all of the new; the
superseded copy (R-366) is made inside the same transaction, so a rolled-back save leaves none behind.
- **A malformed `identity_blob_b64` is refused BEFORE anything is stored** (it answered 400 after the key was saved).
- Tests (each seen failing): `TestR1254_AFailedIdentityWriteStoresNothingNew` (store; the `identityWriteFault` seam;
red-proved by committing the key before the identity write), `TestR1254_AnUploadWithoutIdentityLeavesItAlone`,
`TestR1254_ABadIdentityBlobStoresNothing` (api, the real endpoint; red-proved with the old handler).
- Not in this entry: the agent ceremony's „the previous code still works" after a lost answer (R-1254's H2 half) stays
open — it is the agent's, and the brief scoped this run to the hub's store.
## v0.169.0 (DEPLOYED 2026-10-24 on the operator's yes — built from `1a788f33`, sha256:fb9b2489…, CI 2279) — the upload watch's four events
- **`app_unexplained_upload`, `app_unexplained_cpu` (warning) and their `_cleared` (info)** from controller v0.342.0's
+16 -15
View File
@@ -1409,10 +1409,23 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa
if createdAt == "" {
createdAt = time.Now().UTC().Format(time.RFC3339)
}
// Slice 10D.1: optionally the IDENTITY escrow blob alongside the K-escrow (opaque — no usable secret hub-side).
// R-1254: decoded BEFORE any write (a bad one used to 400 after the new key was already stored) and saved in the
// SAME transaction as the key and its hash — all of the new package or all of the old. The directive is retired
// (R-105).
var idBlob []byte
if req.IdentityBlobB64 != "" {
b, derr := base64.StdEncoding.DecodeString(req.IdentityBlobB64)
if derr != nil || len(b) == 0 {
http.Error(w, "Invalid payload: identity_blob_b64 not valid base64", http.StatusBadRequest)
return
}
idBlob = b
}
// Store the OPAQUE bytes. No decrypt path exists — the hub cannot open this. Part B (v0.60.0):
// 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.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt, req.ResticPwSHA256)
superseded, prevPwSHA, serr := h.store.SaveHostEscrowWithIdentity(pathHostID, blob, idBlob, req.KeyFingerprint, req.Posture, createdAt, req.ResticPwSHA256)
if serr != nil {
h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, serr)
http.Error(w, "Internal error", http.StatusInternalServerError)
@@ -1434,20 +1447,8 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa
h.maybeEmitRepoKeyChanged(host.CustomerID, pathHostID, prevPwSHA, req.ResticPwSHA256, n)
}
}
// Slice 10D.1: optionally store the IDENTITY escrow blob alongside the K-escrow (opaque — no usable secret
// hub-side). Additive: a slice-7 upload without it is unchanged. The directive is retired (R-105).
if req.IdentityBlobB64 != "" {
idBlob, derr := base64.StdEncoding.DecodeString(req.IdentityBlobB64)
if derr != nil || len(idBlob) == 0 {
http.Error(w, "Invalid payload: identity_blob_b64 not valid base64", http.StatusBadRequest)
return
}
if err := h.store.SaveHostDRBundle(pathHostID, idBlob); err != nil {
h.logger.Printf("[ERROR] Failed to store DR bundle for host %s: %v", pathHostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.logger.Printf("[INFO] stored DR bundle for host %s (identity %d bytes)", pathHostID, len(idBlob))
if idBlob != nil {
h.logger.Printf("[INFO] stored DR bundle for host %s (identity %d bytes, same transaction as the key — R-1254)", pathHostID, len(idBlob))
}
h.logger.Printf("[INFO] stored opaque escrow blob for host %s (%d bytes, posture=%s, fp=%s)",
pathHostID, len(blob), req.Posture, req.KeyFingerprint)
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1254: an escrow upload with a malformed identity blob is refused BEFORE anything is stored — until this fix the new
// key and its hash were committed first and the bad identity answered 400 after, leaving the new hash beside the old
// identity package. Through the real endpoint. RED-PROOF (2026-10-24): with the identity decoded after the key save
// (the old order), this fails on „the key was stored before the bad identity was refused".
func TestR1254_ABadIdentityBlobStoresNothing(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("k1"), "SHA_GEN1", "age-gen1")); rr.Code != http.StatusOK {
t.Fatalf("first PUT = %d (%s)", rr.Code, rr.Body.String())
}
m := map[string]string{
"blob_b64": base64.StdEncoding.EncodeToString([]byte("k2")), "key_fingerprint": "ab:cd:ef",
"posture": "zero_knowledge", "created_at": "2026-10-24T21:00:00Z", "restic_pw_sha256": "SHA_GEN2",
"identity_blob_b64": "%%% not base64 %%%",
}
body, _ := json.Marshal(m)
if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", string(body)); rr.Code != http.StatusBadRequest {
t.Fatalf("a bad identity blob must be a 400, got %d", rr.Code)
}
e, _ := st.GetHostEscrow("h1")
if e == nil || string(e.Blob) != "k1" || e.ResticPwSHA256 != "SHA_GEN1" {
t.Fatalf("the key was stored before the bad identity was refused: %+v", e)
}
if b, _ := st.GetHostDRBundle("h1"); b == nil || string(b.IdentityBlob) != "age-gen1" {
t.Fatalf("the old identity must stay: %+v", b)
}
}
@@ -0,0 +1,70 @@
package store
import (
"errors"
"testing"
)
// R-1254 (review round 2, HUNT-3 H1): the hub saved a household's new escrow package in TWO writes — the key and its
// sealed-password hash committed, then the identity blob in a second statement. A failure between them left the new
// hash beside the OLD identity package; the box read the hash, marked itself escrowed, and on a total loss the written
// recovery code opened nothing. Now one transaction. RED-PROOF (2026-10-24): with SaveHostEscrowWithIdentity committing
// the key before the identity write (the old two-step shape), this fails on „the NEW key is stored beside the OLD
// identity package".
func TestR1254_AFailedIdentityWriteStoresNothingNew(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "K"}); err != nil {
t.Fatal(err)
}
if _, _, err := s.SaveHostEscrowWithIdentity("h1", []byte("key-gen1"), []byte("identity-gen1"), "fp1", "zero_knowledge", "2026-10-24T20:00:00Z", "SHA_GEN1"); err != nil {
t.Fatal(err)
}
identityWriteFault = func() error { return errors.New("disk full") }
t.Cleanup(func() { identityWriteFault = nil })
_, _, err := s.SaveHostEscrowWithIdentity("h1", []byte("key-gen2"), []byte("identity-gen2"), "fp2", "zero_knowledge", "2026-10-24T21:00:00Z", "SHA_GEN2")
if err == nil {
t.Fatal("the failed identity write must fail the save")
}
e, _ := s.GetHostEscrow("h1")
b, _ := s.GetHostDRBundle("h1")
if e == nil || b == nil {
t.Fatalf("the old package must still be there: escrow=%v bundle=%v", e, b)
}
if string(e.Blob) != "key-gen1" || e.ResticPwSHA256 != "SHA_GEN1" || string(b.IdentityBlob) != "identity-gen1" {
t.Fatalf("the NEW key is stored beside the OLD identity package (or a mix): blob=%q sha=%q identity=%q",
e.Blob, e.ResticPwSHA256, b.IdentityBlob)
}
if n, _ := s.CountSupersededEscrow("h1"); n != 0 {
t.Fatalf("a rolled-back save must not leave a superseded copy behind, got %d", n)
}
// and the success path: all new, the old one retained as superseded
identityWriteFault = nil
if _, _, err := s.SaveHostEscrowWithIdentity("h1", []byte("key-gen2"), []byte("identity-gen2"), "fp2", "zero_knowledge", "2026-10-24T21:00:00Z", "SHA_GEN2"); err != nil {
t.Fatal(err)
}
e, _ = s.GetHostEscrow("h1")
b, _ = s.GetHostDRBundle("h1")
if string(e.Blob) != "key-gen2" || e.ResticPwSHA256 != "SHA_GEN2" || string(b.IdentityBlob) != "identity-gen2" {
t.Fatalf("all new after a good save: blob=%q sha=%q identity=%q", e.Blob, e.ResticPwSHA256, b.IdentityBlob)
}
if n, _ := s.CountSupersededEscrow("h1"); n != 1 {
t.Fatalf("the gen-1 package must be retained (R-366), got %d", n)
}
}
// An upload without an identity blob (slice 7) leaves the row's identity as it was — the old behaviour, unchanged.
func TestR1254_AnUploadWithoutIdentityLeavesItAlone(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "K"}); err != nil {
t.Fatal(err)
}
if _, _, err := s.SaveHostEscrowWithIdentity("h1", []byte("k"), []byte("id"), "fp", "zero_knowledge", "2026-10-24T20:00:00Z", "SHA"); err != nil {
t.Fatal(err)
}
if _, _, err := s.SaveHostEscrow("h1", []byte("k"), "fp", "zero_knowledge", "2026-10-24T20:05:00Z", "SHA"); err != nil {
t.Fatal(err)
}
if b, _ := s.GetHostDRBundle("h1"); b == nil || string(b.IdentityBlob) != "id" {
t.Fatalf("the identity blob must stay, got %+v", b)
}
}
+25
View File
@@ -3463,6 +3463,20 @@ func backupKeyChanged(cur, next string) bool {
}
func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt, resticPwSHA256 string) (superseded bool, prevResticPwSHA256 string, err error) {
return s.SaveHostEscrowWithIdentity(hostID, blob, nil, keyFingerprint, posture, createdAt, resticPwSHA256)
}
// 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
// SaveHostEscrowWithIdentity (R-1254) stores the new escrow blob, its sealed-password hash AND — when the upload
// carries one — the identity blob in ONE transaction. Until hub 0.169.0 the identity blob was a second statement
// after the first had committed (SaveHostDRBundle): a failure between them left the new hash beside the OLD identity
// package, the box then marked itself escrowed, and on a total loss the written recovery code opened nothing. Now a
// failed write leaves all of the old package or all of the new. identityBlob nil = an upload without one (slice 7):
// the row's identity blob is left as it is, exactly as before. Pinned by TestR1254_*.
func (s *Store) SaveHostEscrowWithIdentity(hostID string, blob, identityBlob []byte, keyFingerprint, posture, createdAt, resticPwSHA256 string) (superseded bool, prevResticPwSHA256 string, err error) {
tx, err := s.db.Begin()
if err != nil {
return false, "", err
@@ -3514,6 +3528,17 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu
hostID, blob, keyFingerprint, posture, createdAt, resticPwSHA256); err != nil {
return false, prevResticPwSHA256, err
}
if identityBlob != nil {
if identityWriteFault != nil {
if err = identityWriteFault(); err != nil {
return false, prevResticPwSHA256, err
}
}
if _, err = tx.Exec(`UPDATE host_escrow SET identity_blob = ?, updated_at = datetime('now') WHERE host_id = ?`,
identityBlob, hostID); err != nil {
return false, prevResticPwSHA256, err
}
}
err = tx.Commit()
return superseded, prevResticPwSHA256, err
}