Files
felhom-agent/REPORT.md
T
admin 588fed2aa9 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>
2026-06-10 20:14:16 +02:00

4.1 KiB

REPORT — slice 10B (agent half): operator-signed destructive completion (v0.16.0) (2026-06-10)

Overwrite-latest report. Cumulative history: CHANGELOG.md.

What was implemented

The security centerpiece: a destructive op runs ONLY on a verified, operator-signed authorization. Decision (a): offline operator key + signing CLI, hardware-key-ready. The signing key is NOT in the hub and NOT in the agent. Concrete consumer: this closes the 8C data-bearing-wipe pending_signature gap. Pairs with hub v0.10.0.

The flow (end-to-end)

8C format of a data-bearing device → agent refuses pending_signature + surfaces the bound op (durable id + host) → operator signs offline (felhom-opsign) → uploads to the hub jobs queue → agent's signed-jobs runner verifies + executes the wipe (re-resolve durable id + re-inspect 8C → mkfs) → clears the job.

cmd/felhom-opsign (new) — the offline signing CLI

  • Builds the canonical OpBlob by reusing authz.CanonicalBlob (the exact bytes the verifier authenticates over — signer/verifier can't drift) and signs with ssh-keygen -Y sign -n felhom-op-v1 (hardware-ready: sk-/YubiKey work unchanged). Output: {op_blob_b64, sig_armored}; optional --upload. Touches only the operator's key.

The verify-and-execute machinery

  • internal/signedjobs.Runner — fetches each opaque job → runs the gate (the LOCKED authz pipeline: pinned-key SSHSIG → namespace → allow-list by key MATERIAL → crypto over raw bytes → host target → time window → durable nonce-burn LAST) → on all-pass hands the verified op to an Executor. Order: verify → burn nonce (durable) → execute → clear. Rejects (forged/replayed/ expired/retargeted/non-pinned) never reach the executor.
  • WipeExecutor (the 8C consumer) — resolve the signed durable id → re-derive + match (anti-retarget) → re-inspect (8C classifier) still-data-bearing → mkfs. A path-only binding, a vanished/changed device, or a non-data-bearing target is refused even with a valid signature.
  • internal/storage durable identityDeviceDurableID / ResolveDurableDevice over the world-readable udev symlinks (byid: wwn/serial, byuuid: fallback) — no privilege, no subprocess.
  • authz.CanonicalBlob promoted to production. hub.Client.Jobs/CompleteJob + MultiObserver. The 8C 403 now carries a pending_op (op + durable id + host) + a felhom-opsign hint.

Pinning / rotation

Operator pubkeys pinned via authz.signers (config, trusted path — NEVER hub-alone), multi-key (KeyID selects, role-scoped) for backup/rotation without a flag-day. Unchanged verifier wiring; 10B activates the execute path (the runner is the second EnvelopeObserver, runs on HasSignedOps).

Tests (real crypto, non-hollow — assert the op did/did NOT run)

  • signedjobs over the real gate+verifier (in-Go minted SSHSIGs): valid → executor runs once + job cleared; replay / non-pinned / expired / retarget / forged / no-signer → rejected, executor never called; malformed cleared.
  • WipeExecutor: valid → mkfs; path-only / durable-mismatch / device-gone / re-inspect-non-data- bearing / not-probed → refused, Format not called.
  • storage durable: wwn-preference, uuid-fallback, path-only+traversal refusal, round-trip, missing (symlink tests gated to Linux). go test ./... green.

Versioning / docs

  • Version 0.15.0 → 0.16.0; CHANGELOG.md. Doc 03 §4 (signed path live) + §6 (8C wipe completes) + §9 (10B done) updated.

Out of scope (per the task)

  • Other destructive executors (guest_destroy, decommission, restore-overwrite → 10D) reuse the same gate+runner; their executors plug in per-slice. 10B ships the machinery + the storage-wipe.

Pending

  • Live validation on the demo: data-bearing wipe → pending_signature → sign offline with a real operator key → hub queue → agent verifies + wipes; confirm replay + a non-pinned-key signature are rejected. (Also validates the Linux-only durable-device tests + ssh-keygen interop.)