hub v0.60.0: offsite continuity Part B — superseded-escrow retention (data-first)

- host_escrow_superseded table + SaveHostEscrow retains a different-sha old blob before overwrite (tx); same-sha idempotent (no supersede row); returns superseded bool. ACK/restore read the current row unchanged. CountSuperseded/ListSuperseded; DeleteHost drops retained rows.
- escrow_superseded audit event + operator retained-count on host detail; register offbox_repo_orphaned/reset. Red-proof TestSaveHostEscrow_RetainsSuperseded.
This commit is contained in:
2026-07-17 10:47:50 +02:00
parent 1c737db4f4
commit e247dbc1be
13 changed files with 217 additions and 18 deletions
+22 -3
View File
@@ -1095,12 +1095,27 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa
if createdAt == "" {
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, req.ResticPwSHA256); err != nil {
h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, err)
// 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, serr := h.store.SaveHostEscrow(pathHostID, blob, 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)
return
}
if superseded {
n, _ := h.store.CountSupersededEscrow(pathHostID)
h.logger.Printf("[INFO] escrow for host %s superseded a different-passphrase blob — RETAINED (now %d superseded blob(s) held)", pathHostID, n)
// Hub-internal audit event (not gated by allowedEventTypes) — tied to the owning customer.
if host, herr := h.store.GetHost(pathHostID); herr == nil && host != nil && host.CustomerID != "" {
details, _ := json.Marshal(map[string]any{"host_id": pathHostID, "retained_count": n})
if _, eerr := h.store.SaveEvent(host.CustomerID, "escrow_superseded", "info",
"A korábbi helyreállítási csomag megőrizve (új kulcs érkezett).", string(details), "hub"); eerr != nil {
h.logger.Printf("[WARN] escrow_superseded event save failed for %s: %v", pathHostID, eerr)
}
}
}
// 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.
@@ -1503,6 +1518,10 @@ var allowedEventTypes = map[string]bool{
// dynamic Hungarian message is customer-grade — deliberately NO customerMessages entry, which would
// discard the numbers (templates.go:129 priority)).
"offbox_enlarge_blocked": true,
// controller v0.142.0 — offsite-repo continuity: the remote repo is orphaned (reinstall shape) /
// was reset (move-aside + re-init). Customer-grade messages below.
"offbox_repo_orphaned": true,
"offbox_repo_reset": true,
"storage_disconnected": true,
"storage_reconnected": true,
"disk_warning": true,