588fed2aa9
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>
112 lines
4.7 KiB
Go
112 lines
4.7 KiB
Go
package signedjobs
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
|
)
|
|
|
|
// opStorageWipe is the op class this executor serves (mirrors reconcile.ClassStorageWipe; the
|
|
// literal avoids importing reconcile here just for the string).
|
|
const opStorageWipe = "storage_wipe"
|
|
|
|
// WipeOps is the privileged storage surface the wipe executor needs (a subset of storage.HostOps).
|
|
type WipeOps interface {
|
|
InspectDevice(ctx context.Context, device string) (storage.DeviceProbe, error)
|
|
Format(ctx context.Context, device, fstype string) error
|
|
}
|
|
|
|
// wipeParams is the verified params of a storage_wipe op. The device is named by a DURABLE id
|
|
// (never a mutable /dev path), so execution re-resolves it to the exact physical device.
|
|
type wipeParams struct {
|
|
DurableID string `json:"durable_id"`
|
|
FSType string `json:"fstype"`
|
|
}
|
|
|
|
// WipeExecutor is the slice-10B storage-wipe consumer — it CLOSES the 8C data-bearing-format
|
|
// `pending_signature` gap. Given a gate-VERIFIED+bound storage_wipe op, it:
|
|
// 1. resolves the op's DURABLE device id → the current /dev path (vanished/replaced → refuse);
|
|
// 2. re-derives that device's durable id and requires it to MATCH the signed id (resource-level
|
|
// anti-retarget — "wipe device X" wipes exactly X, not whatever is at /dev/sdb now);
|
|
// 3. re-inspects (8C classifier) to confirm it is STILL the data-bearing target;
|
|
// 4. mkfs.
|
|
//
|
|
// A path-only binding, a device that vanished/changed, or a target that is no longer data-bearing
|
|
// is refused EVEN WITH a valid signature. The gate has already burned the nonce durably before we
|
|
// run, so an interrupted wipe can't be re-authorized by replaying the blob.
|
|
type WipeExecutor struct {
|
|
ops WipeOps
|
|
resolve func(durableID string) (string, error)
|
|
derive func(device string) (string, error)
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewWipeExecutor wires the production durable resolver/deriver (storage package).
|
|
func NewWipeExecutor(ops WipeOps, logger *slog.Logger) *WipeExecutor {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &WipeExecutor{
|
|
ops: ops,
|
|
resolve: storage.ResolveDurableDevice,
|
|
derive: storage.DeviceDurableID,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Execute implements signedjobs.Executor for the storage_wipe op class.
|
|
func (w *WipeExecutor) Execute(ctx context.Context, op string, params json.RawMessage) error {
|
|
if op != opStorageWipe {
|
|
return ErrNoExecutor // not ours — the runner leaves it queued for the owning slice
|
|
}
|
|
var p wipeParams
|
|
if err := json.Unmarshal(params, &p); err != nil {
|
|
return fmt.Errorf("wipe: bad params: %w", err)
|
|
}
|
|
if p.DurableID == "" {
|
|
// The whole point of the durable binding: a path-only op is refused (anti-retarget).
|
|
return fmt.Errorf("wipe: op has no durable_id — refusing a path-only wipe binding")
|
|
}
|
|
|
|
// 1. Resolve the durable id → current device (gone/replaced → refuse).
|
|
device, err := w.resolve(p.DurableID)
|
|
if err != nil {
|
|
return fmt.Errorf("wipe: durable id %q no longer resolves (device removed/replaced?) — refusing: %w", p.DurableID, err)
|
|
}
|
|
// 2. Anti-retarget: re-derive the resolved device's durable id; it MUST equal the signed id.
|
|
got, err := w.derive(device)
|
|
if err != nil {
|
|
return fmt.Errorf("wipe: cannot re-derive durable id for %s — refusing: %w", device, err)
|
|
}
|
|
if got != p.DurableID {
|
|
return fmt.Errorf("wipe: durable-id mismatch — %s now has id %q, signed id was %q — refusing", device, got, p.DurableID)
|
|
}
|
|
// 3. Re-inspect (8C classifier) — confirm the resolved device is STILL the data-bearing target.
|
|
probe, err := w.ops.InspectDevice(ctx, device)
|
|
if err != nil {
|
|
return fmt.Errorf("wipe: re-inspect %s failed — refusing: %w", device, err)
|
|
}
|
|
if !probe.Probed {
|
|
return fmt.Errorf("wipe: %s did not probe cleanly at execution — refusing", device)
|
|
}
|
|
if !probe.DataBearing() {
|
|
// The signed op authorized wiping a DATA-BEARING device; if it is now blank, the target
|
|
// changed since signing — refuse rather than wipe the wrong (or an unexpected) device.
|
|
return fmt.Errorf("wipe: %s is no longer data-bearing (target changed since signing) — refusing", device)
|
|
}
|
|
|
|
// 4. Execute. The nonce was durably burned by the gate's Verify BEFORE this point (crash-safety:
|
|
// a replay after an interrupted wipe is rejected). This log line is the audit trail of a
|
|
// destructive, operator-authorized wipe actually running.
|
|
w.logger.Warn("wipe: executing operator-signed data-bearing wipe",
|
|
"device", device, "durable_id", p.DurableID, "fstype", p.FSType, "data_reason", probe.Reason())
|
|
if err := w.ops.Format(ctx, device, p.FSType); err != nil {
|
|
return fmt.Errorf("wipe: mkfs %s %s: %w", p.FSType, device, err)
|
|
}
|
|
w.logger.Warn("wipe: operator-signed wipe complete", "device", device, "durable_id", p.DurableID)
|
|
return nil
|
|
}
|