R-105 (decision 169): retire the never-built slim DR record fields (no writer, no reader); 05 §9/§11, 06 §3.5 corrected

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-07 10:02:19 +02:00
parent e7fb10200e
commit aeca52ea54
12 changed files with 121 additions and 63 deletions
+3
View File
@@ -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)
+3 -9
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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"}`)
+7 -12
View File
@@ -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)
@@ -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 {
@@ -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, "")
}
+18 -24
View File
@@ -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
}
+1 -1
View File
@@ -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)
}