hub v0.40.0: store escrow restic_pw_sha256 + serve escrow status in the report ACK (SLICE 3)

Additive host_escrow migration; SaveHostEscrow/HostEscrow gain the hash
(NULL-safe for legacy rows); GetEscrowStatusForCustomer joins hosts;
the report ACK gains escrow:{identity_blob_present,restic_pw_sha256,
created_at} (omitted without a row). Contract test mirrors the agent's
v0.79.0 emit struct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 23:10:29 +02:00
parent e0d1733b85
commit 49d1233391
6 changed files with 147 additions and 11 deletions
+12 -1
View File
@@ -334,6 +334,14 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
resp["config_version"] = custCfg.ConfigVersion
}
// SLICE 3 — escrow status for the hub-verified auto-confirm: the controller flips its offbox
// EscrowState pending→escrowed ONLY when sha256(its local repo password) matches restic_pw_sha256
// (blob-presence alone must never confirm — a stale blob may not cover the current key). The hash is
// non-reversible (256-bit random secret) — safe to serve; omitted entirely when no escrow row exists.
if es, err := h.store.GetEscrowStatusForCustomer(payload.CustomerID); err == nil && es != nil {
resp["escrow"] = es
}
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
// else global default) and the latest available version. The controller compares its current
// version against the floor and auto-updates when below it (latest stays the customer's opt-in
@@ -828,6 +836,9 @@ type escrowUploadRequest struct {
// 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 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"`
}
// handleHostEscrowPut stores a host's opaque escrow blob (doc 03 §8a). Authed with the PER-HOST key
@@ -868,7 +879,7 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa
createdAt = time.Now().UTC().Format(time.RFC3339)
}
// Store the OPAQUE bytes. No decrypt path exists — the hub cannot open this.
if err := h.store.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt); err != nil {
if err := h.store.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt, req.ResticPwSHA256); err != nil {
h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return