slice 10B: operator-signed destructive completion (offline key + signing CLI) (v0.16.0)
A destructive op runs ONLY on a pinned-key-verified, nonce-fresh, in-window, host-bound, durable-id-bound operator signature. New cmd/felhom-opsign signs canonical OpBlobs offline via ssh-keygen -Y sign (hardware-ready); the signing key is never in the hub or agent. New internal/signedjobs runner verifies each queued blob through the gate and only on all-pass runs the WipeExecutor, which re-resolves the DURABLE device id + re-inspects (8C) before mkfs — closing the 8C data-bearing-wipe pending_signature gap. New storage durable-device resolution; authz.CanonicalBlob promoted to production. Real-crypto tests assert valid executes and forged/replay/expired/retarget/non-pinned are rejected (executor never called). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,13 +158,28 @@ type formatRequest struct {
|
||||
// inspects the device itself (8C invariant).
|
||||
}
|
||||
|
||||
// FormatResponse is POST /disks/format.
|
||||
// FormatResponse is POST /disks/format. On a data-bearing refusal (slice 10B) it SURFACES the
|
||||
// bound op the operator must sign: the op class + the DURABLE device id (not the mutable path) +
|
||||
// the fstype — so the operator can `felhom-opsign -op storage_wipe -durable-id <…>` offline, the
|
||||
// hub queues it, and the agent's signed-jobs runner verifies + executes the wipe (re-resolving the
|
||||
// durable id). This is the "records/reports the bound op intent so the operator sees what to sign".
|
||||
type FormatResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Device string `json:"device"`
|
||||
Formatted bool `json:"formatted"`
|
||||
DataBearing bool `json:"data_bearing"`
|
||||
Reason string `json:"reason"`
|
||||
// PendingOp is set on a data-bearing refusal — the exact op to sign (slice 10B).
|
||||
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
||||
}
|
||||
|
||||
// PendingOp is the bound destructive intent the operator must sign offline (slice 10B). Params bind
|
||||
// to the DURABLE device id so the signed authorization can't be retargeted to another disk.
|
||||
type PendingOp struct {
|
||||
Op string `json:"op"` // e.g. "storage_wipe"
|
||||
HostScope string `json:"host_scope"` // the agent's host id (anti-retarget target)
|
||||
DurableID string `json:"durable_id"` // byid:…|byuuid:… — the device's stable identity
|
||||
FSType string `json:"fstype"` // the filesystem to mkfs after the wipe
|
||||
}
|
||||
|
||||
// handleDiskFormat is the security centerpiece. The agent INSPECTS the device; if it is
|
||||
@@ -198,18 +213,33 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
|
||||
// inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed)
|
||||
}
|
||||
if probe.DataBearing() {
|
||||
// Destructive: route through the gate. With no operator signature (8C) → pending_signature.
|
||||
// Destructive: route through the gate. With no operator signature → pending_signature.
|
||||
allowed, reason := s.diskGate.AuthorizeWipe(req.Device)
|
||||
s.logger.Warn("local-api: refusing format of a data-bearing device",
|
||||
"vmid", vmid, "device", req.Device, "why", probe.Reason(), "gate", reason)
|
||||
if !allowed {
|
||||
// Surface the bound op the operator must sign (slice 10B): derive the DURABLE device id
|
||||
// so the signed wipe binds to this exact physical disk (not the mutable path), and the
|
||||
// runner can re-resolve it at execution. A durable-id derivation failure is non-fatal —
|
||||
// the refusal still stands; we just can't pre-fill the durable id.
|
||||
var pending *PendingOp
|
||||
if durableID, derr := storage.DeviceDurableID(req.Device); derr == nil {
|
||||
pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: durableID, FSType: req.FSType}
|
||||
s.logger.Warn("local-api: data-bearing format refused — PENDING OPERATOR SIGNATURE",
|
||||
"vmid", vmid, "device", req.Device, "durable_id", durableID, "fstype", req.FSType,
|
||||
"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+durableID)
|
||||
} else {
|
||||
s.logger.Warn("local-api: data-bearing format refused (no durable id)",
|
||||
"vmid", vmid, "device", req.Device, "why", probe.Reason(), "derive_err", derr)
|
||||
}
|
||||
writeStatus(w, http.StatusForbidden, false,
|
||||
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Reason: probe.Reason()},
|
||||
"device is data-bearing — format requires operator authorization ("+reason+")")
|
||||
FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Reason: probe.Reason(), PendingOp: pending},
|
||||
"device is data-bearing — format requires an operator signature ("+reason+")")
|
||||
return
|
||||
}
|
||||
// A signed completion would land here in slice 10; 8C never reaches it (gate refuses unsigned).
|
||||
writeErr(w, http.StatusForbidden, "data-bearing format is not supported in this slice")
|
||||
// A signed wipe is executed by the signed-jobs runner (queue → verify gate → durable
|
||||
// re-resolve + re-inspect → mkfs), NOT this synchronous path. This branch (gate ALLOWED a
|
||||
// data-bearing format inline) is unreachable: the inline path passes signed=nil → always
|
||||
// pending. Fail safe.
|
||||
writeErr(w, http.StatusForbidden, "data-bearing format must be completed via a signed job (felhom-opsign → hub queue)")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -129,8 +129,8 @@ func TestFormat_DataBearingDevice_RefusedNoMkfs(t *testing.T) {
|
||||
if len(g.calls) != 1 || g.calls[0] != "/dev/sdb" {
|
||||
t.Fatalf("gate not consulted for the destructive format: %v", g.calls)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "operator authorization") {
|
||||
t.Fatalf("response did not signal operator-authorization needed: %s", w.Body.String())
|
||||
if !strings.Contains(w.Body.String(), "operator signature") {
|
||||
t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@ type Options struct {
|
||||
// cpu-temp) + per-storage capacity, host-wide and token-authed (one-customer-per-host). When
|
||||
// nil the endpoint reports "not configured" (host still reports/reconciles).
|
||||
HostMetrics HostMetricsProvider
|
||||
Logger *slog.Logger
|
||||
// HostID is this agent's host id — surfaced in a data-bearing-format pending-op so the operator
|
||||
// signs an op bound to THIS host (slice 10B anti-retarget). Optional (only used for the hint).
|
||||
HostID string
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// defaultBackupCadence is the fallback /backup/due window when none is configured.
|
||||
@@ -129,6 +132,7 @@ type Server struct {
|
||||
guestList GuestLister // slice 8C (optional)
|
||||
|
||||
hostMetrics HostMetricsProvider // slice 9 (optional)
|
||||
hostID string // slice 10B: for the data-bearing-format pending-op hint
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobs map[int]*backupJob // per-guest backup job state (slice 8B)
|
||||
@@ -166,6 +170,7 @@ func NewServer(o Options) (*Server, error) {
|
||||
diskGate: o.DiskGate,
|
||||
guestList: o.Guests2,
|
||||
hostMetrics: o.HostMetrics,
|
||||
hostID: o.HostID,
|
||||
jobs: map[int]*backupJob{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user