hub: opaque PBS recovery-code escrow storage (v0.8.0) + doc 03 §8a posture model

Slice-7 close-out (hub half). PUT /api/v1/hosts/{host_id}/escrow (per-host key)
stores the agent's OPAQUE R-wrapped blob verbatim against the host; the hub never
decrypts it (no recovery code, no decrypt path). host_escrow table + Save/GetHostEscrow.
Tests: verbatim store, rotation last-write-wins, 401/403/400 auth+body, wire contract.

doc 03 §8a rewritten into the key-custody posture model: separation principle,
topology matrix, default + anti-lockout ladder, SSH-vs-key, breach/legal, integrity
caveat. Corrected: hub opaque storage is slice 7 (this task); serving is slice 10.
Slice table + §13 updated.

No secrets committed (R/K never appear; spike findings + docs use placeholders).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 07:46:33 +02:00
parent fe7d0850a5
commit 7eb3772000
6 changed files with 372 additions and 72 deletions
+58
View File
@@ -269,6 +269,19 @@ func (s *Store) migrate() error {
);
CREATE INDEX IF NOT EXISTS idx_host_reports_host ON host_reports(host_id, received_at DESC);
CREATE INDEX IF NOT EXISTS idx_host_reports_customer ON host_reports(customer_id, received_at DESC);
-- host_escrow (slice 7, doc 03 §8a): the OPAQUE R-wrapped PBS-key escrow blob. The hub
-- stores the ciphertext bytes against the host and NEVER decrypts them (it has no recovery
-- code). One row per host; a re-upload (rotation) is last-write-wins. Restore-mode serving
-- (handing the blob back to a re-enrolling box) is slice 10.
CREATE TABLE IF NOT EXISTS host_escrow (
host_id TEXT PRIMARY KEY,
blob BLOB NOT NULL,
key_fingerprint TEXT NOT NULL DEFAULT '',
posture TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
`)
if err != nil {
return err
@@ -1381,6 +1394,51 @@ func (s *Store) UpsertHost(h *Host) error {
return err
}
// HostEscrow is the opaque R-wrapped escrow blob stored for a host (doc 03 §8a). Blob is
// ciphertext the hub cannot open.
type HostEscrow struct {
HostID string
Blob []byte
KeyFingerprint string
Posture string
CreatedAt string
UpdatedAt string
}
// SaveHostEscrow stores (last-write-wins) the OPAQUE escrow blob for a host. The hub keeps the
// bytes and NEVER decrypts them — there is no decrypt path. createdAt is the agent's timestamp.
func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt string) error {
_, err := s.db.Exec(`
INSERT INTO host_escrow (host_id, blob, key_fingerprint, posture, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(host_id) DO UPDATE SET
blob = excluded.blob,
key_fingerprint = excluded.key_fingerprint,
posture = excluded.posture,
created_at = excluded.created_at,
updated_at = datetime('now')`,
hostID, blob, keyFingerprint, posture, createdAt,
)
return err
}
// GetHostEscrow returns the stored opaque escrow for a host (nil if none). Used by tests and
// (future, slice 10) restore-mode serving. The hub returns bytes verbatim; it never decrypts.
func (s *Store) GetHostEscrow(hostID string) (*HostEscrow, error) {
var e HostEscrow
err := s.db.QueryRow(`
SELECT host_id, blob, key_fingerprint, posture, created_at, updated_at
FROM host_escrow WHERE host_id = ?`, hostID).
Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &e, nil
}
// SaveHostReport inserts a host_reports row and bumps the host's reality columns
// (agent_version/last_report_at/updated_at) — never the inert intent columns.
func (s *Store) SaveHostReport(hostID, customerID string, reportJSON []byte, d HostReportDenorm) error {