diff --git a/documentation/architecture/05-hub-architecture.md b/documentation/architecture/05-hub-architecture.md index 8f771562..fe8f56f6 100644 --- a/documentation/architecture/05-hub-architecture.md +++ b/documentation/architecture/05-hub-architecture.md @@ -57,7 +57,8 @@ A customer's deployment is one **Host** (its agent) plus one-or-more **Guests** `retrieval_password`, status, config_json. Unchanged role. - **`hosts`** (new) — `host_id PK, customer_id, api_key` (the agent's hub key), `agent_version`, desired-state intent (storage manifest + policies + golden-image version, as JSON), a per-host - **`desired_generation`** counter, the slim DR record (§9), timestamps. + **`desired_generation`** counter, timestamps. (The "slim DR record" column `dr_record_json` exists but is + RETIRED — never written, never read; §9, R-105.) - **`guests`** (new) — `guest_id PK, customer_id, host_id, api_key` (the controller's hub key), `display_name, controller_version`, per-guest **`desired_spec_json`** (CPU/mem/disk, versions), timestamps. @@ -211,18 +212,22 @@ What remains: pruned by age); - the agent **escrows the recovery-code-wrapped PBS key** to the hub (the one artifact only the box can produce — zero-knowledge: the hub stores it, cannot open it); -- a **slim DR record** on the `hosts` row (PBS namespace + repo fingerprint + the wrapped escrow key). - These last two are *box-reported* columns on an otherwise operator-intent row — labelled as such so - the §1 two-driver split stays legible per column. +- ~~a **slim DR record** on the `hosts` row (PBS namespace + repo fingerprint + the wrapped escrow key)~~ — + **[FACT, RETIRED 2026-10-07, R-105, `09` §3 decision 169]: never built.** `hosts.dr_record_json` had no writer and + no reader; `host_escrow.directive_json` was written only by a by-hand selftest flag and read by nothing. Both are + retired in code (the hub and agent releases after 2026-10-07); the columns stay, unread. **Where each fact really lives:** the + PBS repo and namespace in the **DR recipe** (`dr_recipe`, reported every cycle — R-106 fixed it to match the + backup); the endpoint's PBS fingerprint in **tenantsync** (`hub/internal/tenantsync`); the wrapped key in + **`host_escrow.blob`** (and the identity blob beside it). Both existing infra-backup tables retire — `infra_backup_versions` (the current/live one, all readers -hit it) **and** `infra_backups` (the deprecated legacy mirror). The slim DR record folds onto `hosts` -instead. The **controller's infra-backup push is removed** (it's de-privileged). +hit it) **and** `infra_backups` (the deprecated legacy mirror). (The planned slim DR record on `hosts` was never +built — retired 2026-10-07, see above.) The **controller's infra-backup push is removed** (it's de-privileged). **Recovery (host loss):** the new agent re-enrolls in **restore mode**; the hub hands it the durable record — and DR reads from the **durable sources, not the prunable report mirror**: operator intent -(desired-state on `hosts`/`guests` — identity, tunnel token, storage manifest), the slim DR record -(PBS namespace + repo fingerprint), the **wrapped escrow key**, and **PBS's own snapshot enumeration** +(desired-state on `hosts`/`guests` — identity, tunnel token, storage manifest), the **DR recipe** (PBS +namespace + repo) with tenantsync's endpoint fingerprint, the **wrapped escrow key**, and **PBS's own snapshot enumeration** (the agent lists snapshots once it has the namespace + unwrapped key). Guest inventory + app data come from **inside the PBS guest snapshots**, not from a retained `host_report`, so recovery doesn't degrade when the last report has aged out. The **customer provides their recovery code at the agent**, which @@ -248,7 +253,7 @@ customer instead of a single controller. - **ADD** desired-state JSON + `desired_generation` to `hosts`; `desired_spec_json` to `guests`; the slim DR record (PBS namespace + repo fingerprint + wrapped escrow key) onto `hosts`. - **DROP both** `infra_backup_versions` (current/live) **and** `infra_backups` (legacy mirror) — the DR - record replaces them on `hosts`. + recipe, tenantsync and the escrow blob replace them (R-105). - **KEEP** `customer_configs`, `events`, `customer_notifications`, `notification_log`, `app_telemetry`, `app_log_issues`. - **Authz cleanup the cutover enables:** several endpoints today use global-or-any-customer-key auth diff --git a/documentation/architecture/06-offsite-connectivity.md b/documentation/architecture/06-offsite-connectivity.md index e4a2b1ff..5c7ba576 100644 --- a/documentation/architecture/06-offsite-connectivity.md +++ b/documentation/architecture/06-offsite-connectivity.md @@ -162,7 +162,11 @@ needed. offsite client key K + the auto-injected WG private key + the offsite PBS token under it, and uploaded the opaque blobs to the hub. Independently verified in `host_escrow` (host `demo-felhom-01`: `key_fingerprint` = the offsite key `b0:fe:2a…`, identity blob 499 B, directive with the non-secret -DR coords, `created_at` = now) — all with **zero knowledge of R**. `tunnel_token` intentionally empty +DR coords, `created_at` = now) — all with **zero knowledge of R**. **[FACT, 2026-10-07 — R-105, `09` §3 decision +169] The directive is RETIRED:** it was written only by this by-hand `-directive` flag (the customer's escrow wizard +never sent one, so every wizard escrow stored `{}` over it), and nothing read it. The flag is gone (the agent release after 2026-10-07) +and the hub no longer stores it; the re-enroll routes serve `{}`. The DR coordinates live in the DR +recipe and tenantsync. `tunnel_token` intentionally empty (the Cloudflare edge lives in the guest/controller, re-provisioned separately in DR). **S5 (DR consume) is now UNBLOCKED:** R + the hub-stored blobs reconstruct K + the WG key (+ the offsite PBS token). Note: `host_escrow` is one slot per host (last-write-wins) — the offsite escrow is the one diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index b1e9f32e..22355c03 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -9,6 +9,9 @@ shown only when the rule allows it. - Tests: `TestPVE_*` (4), `TestSystemPage_PVEButtonOnlyWhenReady`, the R-135 route list. Red-proofs: `documentation/audits/day-2026-10-07/B/red-hub-pve-mutations.txt`. +## Unreleased (2026-10-07) + +- hub (R-105 option A, `09` §3 decision 169): the two never-built "slim DR record" fields are retired. `hosts.dr_record_json` is no longer scanned (it had no writer and no reader); the escrow PUT no longer stores a `directive` (only a by-hand selftest flag ever sent one, and every wizard escrow overwrote it with `{}`); the re-enroll and restore-directive routes still serve the two opaque blobs and a `directive` of `{}`. The columns stay in the schema, unread. `TestR105_IdentitySaveLeavesDirectiveColumnAlone`, `TestR105_RetiredColumnsHaveNoReader` (red-proved, `documentation/audits/day-2026-10-07/F/`). `05` §9/§11 and `06` §3.5 corrected. ## v0.141.0 — a reinstall's new backup key no longer overwrites the old one in the escrow; a second off-site Save is refused, not raced; the SMART counters are modelled (R-366, R-31, R-330) (2026-10-07) diff --git a/hub/internal/api/dr.go b/hub/internal/api/dr.go index 8aa5c355..1daaf1c5 100644 --- a/hub/internal/api/dr.go +++ b/hub/internal/api/dr.go @@ -87,9 +87,9 @@ func (h *Handler) handleClearRecoveryMode(w http.ResponseWriter, r *http.Request type reEnrollResponse struct { HostID string `json:"host_id"` APIKeyRotated bool `json:"api_key_rotated"` - Directive json.RawMessage `json:"directive"` // non-secret DR directive - KEscrowB64 string `json:"k_escrow_b64"` // opaque PBS-key escrow blob - IdentityEscrowB64 string `json:"identity_escrow_b64"` // opaque identity escrow blob + Directive json.RawMessage `json:"directive"` // always {} — the directive is retired (R-105, decision 169); kept for the wire shape + KEscrowB64 string `json:"k_escrow_b64"` // opaque PBS-key escrow blob + IdentityEscrowB64 string `json:"identity_escrow_b64"` // opaque identity escrow blob } // handleReEnroll is the re-enroll handshake (slice 10D.2). Gated ONLY on RECOVERY MODE (the lost box @@ -136,9 +136,6 @@ func (h *Handler) handleReEnroll(w http.ResponseWriter, r *http.Request, hostID if bundle, err := h.store.GetHostDRBundle(hostID); err == nil && bundle != nil { resp.KEscrowB64 = base64.StdEncoding.EncodeToString(bundle.KEscrowBlob) resp.IdentityEscrowB64 = base64.StdEncoding.EncodeToString(bundle.IdentityBlob) - if bundle.DirectiveJSON != "" { - resp.Directive = json.RawMessage(bundle.DirectiveJSON) - } } h.logger.Printf("[INFO] DR: host %s RE-ENROLLED (hub credential rotated; old key revoked; directive served)", hostID) // The new key is returned so the box can use it; the operator sees the rotation in the response. @@ -175,9 +172,6 @@ func (h *Handler) handleGetRestoreDirective(w http.ResponseWriter, r *http.Reque if bundle, err := h.store.GetHostDRBundle(hostID); err == nil && bundle != nil { resp.KEscrowB64 = base64.StdEncoding.EncodeToString(bundle.KEscrowBlob) resp.IdentityEscrowB64 = base64.StdEncoding.EncodeToString(bundle.IdentityBlob) - if bundle.DirectiveJSON != "" { - resp.Directive = json.RawMessage(bundle.DirectiveJSON) - } } writeJSON(w, http.StatusOK, resp) } diff --git a/hub/internal/api/dr_test.go b/hub/internal/api/dr_test.go index 5bdcfde0..a68ecf1e 100644 --- a/hub/internal/api/dr_test.go +++ b/hub/internal/api/dr_test.go @@ -72,7 +72,7 @@ func TestRestoreDirective_GatedAndExpires(t *testing.T) { seedHost(t, st, "h1", "c1", "HKEY") // Seed a DR bundle: K-escrow row + identity blob + directive. st.SaveHostEscrow("h1", []byte("opaque-K-escrow"), "01:36:e9:…", "zero_knowledge", time.Now().UTC().Format(time.RFC3339), "") - st.SaveHostDRBundle("h1", []byte("opaque-identity"), `{"pbs_repo":"r","tunnel_id":"t","expected_key_fingerprint":"01:36:e9:…"}`) + st.SaveHostDRBundle("h1", []byte("opaque-identity")) // Not in recovery mode → 403. if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden { diff --git a/hub/internal/api/escrow_get_test.go b/hub/internal/api/escrow_get_test.go index 17e06a97..526750d8 100644 --- a/hub/internal/api/escrow_get_test.go +++ b/hub/internal/api/escrow_get_test.go @@ -25,7 +25,7 @@ func seedEscrowedHost(t *testing.T, st *store.Store, hostID, customerID, apiKey t.Fatal(err) } if len(identity) > 0 { - if err := st.SaveHostDRBundle(hostID, identity, `{}`); err != nil { + if err := st.SaveHostDRBundle(hostID, identity); err != nil { t.Fatal(err) } } diff --git a/hub/internal/api/escrow_test.go b/hub/internal/api/escrow_test.go index 9e5b6be4..cad9413b 100644 --- a/hub/internal/api/escrow_test.go +++ b/hub/internal/api/escrow_test.go @@ -158,7 +158,7 @@ func TestReportACK_EscrowStatus(t *testing.T) { if _, _, err := st.SaveHostEscrow("hv1", []byte("k-blob"), "fp", "zero_knowledge", "2026-07-09T20:00:00Z", "abc123"); err != nil { t.Fatal(err) } - if err := st.SaveHostDRBundle("hv1", []byte("identity-blob"), "{}"); err != nil { + if err := st.SaveHostDRBundle("hv1", []byte("identity-blob")); err != nil { t.Fatal(err) } rr2 := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"cust-e"}`) diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 6cbe642f..196223bb 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -1220,9 +1220,9 @@ type escrowUploadRequest struct { KeyFingerprint string `json:"key_fingerprint"` // for operator display only Posture string `json:"posture"` // e.g. "zero_knowledge" CreatedAt string `json:"created_at"` // RFC3339 - // Slice 10D.1 — optional DR bundle, stored alongside the K-escrow (both opaque/non-secret). - IdentityBlobB64 string `json:"identity_blob_b64,omitempty"` // age-wrapped {tunnel_token, pbs_token} - DirectiveJSON json.RawMessage `json:"directive,omitempty"` // non-secret directive (pbs repo/ns, expected fp, tunnel id) + // Slice 10D.1 — optional identity escrow, stored alongside the K-escrow (opaque). An old agent may still send a + // `directive`; it is ignored (R-105, decision 169 — retired, never read). + IdentityBlobB64 string `json:"identity_blob_b64,omitempty"` // age-wrapped {tunnel_token, pbs_token} // SLICE 3 — sha256 hex of the restic repo password sealed in the identity blob (non-reversible hash // of a 256-bit random secret — safe to store/serve; present only when a staged password was folded in). ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"` @@ -1290,25 +1290,20 @@ 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 + the non-secret DR directive alongside - // the K-escrow (both opaque / non-secret — no usable secret hub-side). Additive: a slice-7 - // upload without these is unchanged. + // 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 } - directive := req.DirectiveJSON - if len(directive) == 0 || !json.Valid(directive) { - directive = json.RawMessage("{}") - } - if err := h.store.SaveHostDRBundle(pathHostID, idBlob, string(directive)); err != nil { + 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 + directive)", pathHostID, len(idBlob)) + h.logger.Printf("[INFO] stored DR bundle for host %s (identity %d bytes)", 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) diff --git a/hub/internal/store/escrow_identity_retention_test.go b/hub/internal/store/escrow_identity_retention_test.go index 44e34b62..a397773c 100644 --- a/hub/internal/store/escrow_identity_retention_test.go +++ b/hub/internal/store/escrow_identity_retention_test.go @@ -32,7 +32,7 @@ func TestSaveHostEscrow_RetainsIdentityBlob(t *testing.T) { if _, _, err := st.SaveHostEscrow(h, []byte("k-blob-old"), "fp-old", "zk", "2026-07-09T00:00:00Z", "SHA_OLD"); err != nil { t.Fatal(err) } - if err := st.SaveHostDRBundle(h, oldIdentity, `{"gen":1}`); err != nil { + if err := st.SaveHostDRBundle(h, oldIdentity); err != nil { t.Fatal(err) } @@ -47,7 +47,7 @@ func TestSaveHostEscrow_RetainsIdentityBlob(t *testing.T) { if prev != "SHA_OLD" { t.Fatalf("prevResticPwSHA256 = %q, want SHA_OLD (R-197 needs the replaced hash)", prev) } - if err := st.SaveHostDRBundle(h, newIdentity, `{"gen":2}`); err != nil { + if err := st.SaveHostDRBundle(h, newIdentity); err != nil { t.Fatal(err) } @@ -95,7 +95,7 @@ func TestDeleteHost_DemotesIdentityBlob(t *testing.T) { if _, _, err := s.SaveHostEscrow(hostID, []byte("k-blob"), "fp", "zk", "2026-07-16T00:00:00Z", "SHA_A"); err != nil { t.Fatal(err) } - if err := s.SaveHostDRBundle(hostID, identity, `{}`); err != nil { + if err := s.SaveHostDRBundle(hostID, identity); err != nil { t.Fatal(err) } @@ -152,7 +152,7 @@ func TestCountCurrentEscrowWithIdentity(t *testing.T) { if _, _, err := st.SaveHostEscrow("with-id", []byte("k"), "fp", "zk", "2026-07-16T00:00:00Z", "SHA1"); err != nil { t.Fatal(err) } - if err := st.SaveHostDRBundle("with-id", []byte("age-blob"), `{}`); err != nil { + if err := st.SaveHostDRBundle("with-id", []byte("age-blob")); err != nil { t.Fatal(err) } if _, _, err := st.SaveHostEscrow("without-id", []byte("k"), "fp", "zk", "2026-07-16T00:00:00Z", "SHA2"); err != nil { diff --git a/hub/internal/store/r105_retired_fields_test.go b/hub/internal/store/r105_retired_fields_test.go new file mode 100644 index 00000000..bb75e44a --- /dev/null +++ b/hub/internal/store/r105_retired_fields_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "os" + "regexp" + "testing" +) + +// R-105 option A (`09` §3 decision 169): the two "slim DR record" fields are retired. Nothing writes +// `host_escrow.directive_json` any more (an escrow upload left a hand-made directive overwritten with `{}`), and +// nothing reads `hosts.dr_record_json` or the directive. The columns STAY (a column nobody reads is harmless, +// and dropping one is a schema change the hub's database backups would have to follow). + +// COMPANION RED-PROOF (observed): on the pre-R-105 code SaveHostDRBundle overwrote the column — this failed with +// "the identity-blob save must leave directive_json alone; got {}". Restored. +func TestR105_IdentitySaveLeavesDirectiveColumnAlone(t *testing.T) { + st := newResetStore(t) + if err := st.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil { + t.Fatal(err) + } + if _, _, err := st.SaveHostEscrow("h1", []byte("K"), "fp", "zero_knowledge", "2026-10-07T00:00:00Z", "sha"); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`UPDATE host_escrow SET directive_json = '{"hand":"made"}' WHERE host_id = 'h1'`); err != nil { + t.Fatal(err) + } + if err := st.SaveHostDRBundle("h1", []byte("ID")); err != nil { + t.Fatal(err) + } + var got string + if err := st.db.QueryRow(`SELECT directive_json FROM host_escrow WHERE host_id = 'h1'`).Scan(&got); err != nil { + t.Fatal(err) + } + if got != `{"hand":"made"}` { + t.Fatalf("the identity-blob save must leave directive_json alone; got %s", got) + } + b, err := st.GetHostDRBundle("h1") + if err != nil || b == nil || string(b.IdentityBlob) != "ID" || string(b.KEscrowBlob) != "K" { + t.Fatalf("the bundle must still carry both blobs; got %+v err=%v", b, err) + } +} + +// No reader: outside the schema statements, the column names do not appear in the store's source, so a new reader +// cannot appear without this design being revisited. +// COMPANION RED-PROOF (observed): with `DRRecordJSON` still scanned this failed naming `dr_record_json` in the +// hosts select list. Restored. +func TestR105_RetiredColumnsHaveNoReader(t *testing.T) { + src, err := os.ReadFile("store.go") + if err != nil { + t.Fatal(err) + } + schema := regexp.MustCompile(`(?m)^\s*dr_record_json\s+TEXT NOT NULL DEFAULT '\{\}',$|ALTER TABLE host_escrow ADD COLUMN directive_json`) + rest := schema.ReplaceAllString(string(src), "") + for _, name := range []string{"dr_record_json", "directive_json", "DRRecordJSON", "DirectiveJSON"} { + if regexp.MustCompile(`\b` + name + `\b`).MatchString(stripComments(rest)) { + t.Errorf("%s is still read or written in store.go outside its schema statement", name) + } + } +} + +func stripComments(s string) string { + return regexp.MustCompile(`(?m)//.*$`).ReplaceAllString(s, "") +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 92529c1f..4f4868e4 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -2792,7 +2792,6 @@ type Host struct { LastReportAt *time.Time DesiredJSON string DesiredGeneration int64 - DRRecordJSON string RecoveryModeUntil *time.Time // slice 10D: recovery mode active until this time (nil/past = off) CreatedAt time.Time UpdatedAt time.Time @@ -2864,7 +2863,7 @@ func scanHostRaw(scan func(dest ...any) error) (*Host, error) { var lastReport, recoveryUntil sql.NullString var createdAt, updatedAt string err := scan(&h.HostID, &h.CustomerID, &h.APIKey, &h.AgentVersion, &lastReport, - &h.DesiredJSON, &h.DesiredGeneration, &h.DRRecordJSON, &recoveryUntil, &createdAt, &updatedAt) + &h.DesiredJSON, &h.DesiredGeneration, &recoveryUntil, &createdAt, &updatedAt) if err != nil { return nil, err } @@ -2882,7 +2881,7 @@ func scanHostRaw(scan func(dest ...any) error) (*Host, error) { } const hostSelectCols = `host_id, customer_id, api_key, agent_version, last_report_at, - desired_json, desired_generation, dr_record_json, recovery_mode_until, created_at, updated_at` + desired_json, desired_generation, recovery_mode_until, created_at, updated_at` // GetHostByAPIKey looks up a host by its per-host hub key. Returns nil (no error) // if no match — parallels GetCustomerConfigByAPIKey. @@ -3170,7 +3169,7 @@ func (s *Store) LatestHostDeletion(customerID string) (*HostDeletion, error) { // UpsertHost creates or updates a host identity (used by the admin mint). On // conflict it updates only operator-settable identity fields + updated_at; it does // NOT touch the reality columns (agent_version/last_report_at) or the inert intent -// columns (desired_*/dr_record_json) — those are owned elsewhere. +// columns (desired_*) — those are owned elsewhere. func (s *Store) UpsertHost(h *Host) error { if h.SecretsUnreadable { return fmt.Errorf("store: host %s: refusing to save a host whose sealed api_key did not open", h.HostID) @@ -3617,15 +3616,15 @@ func (s *Store) RotateHostAPIKey(hostID, newAPIKey string) error { return nil } -// SaveHostDRBundle stores the IDENTITY escrow blob + the NON-secret DR directive alongside the -// existing K-escrow blob (slice 10D.1). The K-escrow row must already exist (slice-7 escrow upload); -// this updates the additive 10D columns. The hub holds only ciphertext + non-secret directive. -func (s *Store) SaveHostDRBundle(hostID string, identityBlob []byte, directiveJSON string) error { - if directiveJSON == "" { - directiveJSON = "{}" - } - res, err := s.db.Exec(`UPDATE host_escrow SET identity_blob = ?, directive_json = ?, updated_at = datetime('now') WHERE host_id = ?`, - identityBlob, directiveJSON, hostID) +// SaveHostDRBundle stores the IDENTITY escrow blob alongside the existing K-escrow blob (slice 10D.1). The K-escrow +// row must already exist (slice-7 escrow upload). The hub holds only ciphertext. +// +// R-105 (`09` §3 decision 169): the non-secret "DR directive" that used to ride here is RETIRED — nothing produced it +// but a by-hand selftest flag, and nothing read it; the recovery path reads the DR recipe, tenantsync and this blob. +// The column stays in the schema and is never written. Pinned by TestR105_IdentitySaveLeavesDirectiveColumnAlone. +func (s *Store) SaveHostDRBundle(hostID string, identityBlob []byte) error { + res, err := s.db.Exec(`UPDATE host_escrow SET identity_blob = ?, updated_at = datetime('now') WHERE host_id = ?`, + identityBlob, hostID) if err != nil { return err } @@ -3635,30 +3634,25 @@ func (s *Store) SaveHostDRBundle(hostID string, identityBlob []byte, directiveJS return nil } -// HostDRBundle is the full DR directive served to a re-enrolling box (slice 10D): the two OPAQUE -// escrow blobs (K + identity — useless without R) + the non-secret directive fields. +// HostDRBundle is what a re-enrolling box is served (slice 10D): the two OPAQUE escrow blobs (K + identity — useless +// without R). type HostDRBundle struct { - KEscrowBlob []byte - IdentityBlob []byte - DirectiveJSON string + KEscrowBlob []byte + IdentityBlob []byte } // GetHostDRBundle returns a host's DR bundle (nil if no escrow row). The blobs are opaque — the hub // cannot open them (it has no R). func (s *Store) GetHostDRBundle(hostID string) (*HostDRBundle, error) { var b HostDRBundle - var directive sql.NullString - err := s.db.QueryRow(`SELECT blob, identity_blob, directive_json FROM host_escrow WHERE host_id = ?`, hostID). - Scan(&b.KEscrowBlob, &b.IdentityBlob, &directive) + err := s.db.QueryRow(`SELECT blob, identity_blob FROM host_escrow WHERE host_id = ?`, hostID). + Scan(&b.KEscrowBlob, &b.IdentityBlob) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - if directive.Valid { - b.DirectiveJSON = directive.String - } return &b, nil } diff --git a/hub/internal/web/hosts_test.go b/hub/internal/web/hosts_test.go index 009e32dd..bc968248 100644 --- a/hub/internal/web/hosts_test.go +++ b/hub/internal/web/hosts_test.go @@ -133,7 +133,7 @@ func TestHandleHostDetail(t *testing.T) { if _, _, err := st.SaveHostEscrow("demo-felhom-01", []byte("opaque-escrow"), "fp", "posture", "2026-06-01T00:00:00Z", ""); err != nil { t.Fatal(err) } - if err := st.SaveHostDRBundle("demo-felhom-01", []byte("opaque-identity"), `{"v":1}`); err != nil { + if err := st.SaveHostDRBundle("demo-felhom-01", []byte("opaque-identity")); err != nil { t.Fatal(err) }