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:
@@ -3,6 +3,53 @@
|
||||
All notable changes to **felhom-agent** are recorded here. Update on every code
|
||||
change that gets pushed.
|
||||
|
||||
## v0.16.0 — slice 10B: operator-signed destructive completion (offline key + signing CLI) (2026-06-10)
|
||||
|
||||
The security centerpiece: a destructive op runs ONLY on a verified, operator-signed authorization
|
||||
— signature valid against a **pinned** operator pubkey (never the hub's or the blob's), nonce
|
||||
unseen + durably burned, in-window, host-bound, and **resource-bound to a DURABLE device id** that
|
||||
execution re-resolves + re-inspects. Decision (a): **offline operator key + signing CLI**,
|
||||
hardware-key-ready (`sk-`/YubiKey via ssh-keygen). The key floor holds: 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.
|
||||
|
||||
### Added
|
||||
- **`cmd/felhom-opsign`** — the operator's offline signing CLI. Builds the canonical `OpBlob` by
|
||||
**reusing `authz.CanonicalBlob`** (the exact production path the verifier authenticates over — so
|
||||
signer + verifier can never drift) and signs it with **`ssh-keygen -Y sign -n felhom-op-v1`**
|
||||
(hardware-ready). Output: a `{op_blob_b64, sig_armored}` envelope to hand to the hub jobs queue
|
||||
(optional `--upload`). Touches ONLY the operator's signing key.
|
||||
- **`authz.CanonicalBlob`** — promoted to production (was test-only) so the CLI + verifier share one
|
||||
canonical-bytes source; params canonicalized (sorted keys, compact).
|
||||
- **`internal/storage` durable device identity** (`durable_device.go`): `DeviceDurableID` (derive a
|
||||
stable `byid:`(wwn/serial)/`byuuid:` id from the world-readable udev symlinks — no privilege, no
|
||||
subprocess) + `ResolveDurableDevice` (re-resolve to the current `/dev` path; a path-only/unknown
|
||||
scheme is REFUSED). The resource-level anti-retarget.
|
||||
- **`internal/signedjobs`** (new): the queue consumer. `Runner` fetches each opaque job → runs it
|
||||
through the **gate** (the LOCKED authz pipeline) → on all-pass hands the verified op to an
|
||||
`Executor`; the order is **verify → nonce-burn (durable, in Verify) → execute → clear job**. The
|
||||
**`WipeExecutor`** is the 8C consumer: resolve the signed durable id → **re-derive + match**
|
||||
(anti-retarget) → **re-inspect (8C classifier)** the device is still the data-bearing target →
|
||||
`mkfs`. A vanished/changed/non-data-bearing device or a path-only binding is refused **even with a
|
||||
valid signature**. Wired as a second `EnvelopeObserver` (runs on `HasSignedOps`).
|
||||
- **`hub.Client.Jobs` / `CompleteJob`** + `hub.MultiObserver`; the 8C format refusal now **surfaces
|
||||
the bound op** (op + durable id + host) in its 403 `pending_op` + a `felhom-opsign …` hint.
|
||||
|
||||
### Pinning / rotation
|
||||
- Operator pubkeys are pinned via `authz.signers` (config, trusted path — provision/agent config,
|
||||
NEVER hub-alone), **multiple** keys (KeyID selects; role-scoped), so a backup/rotation key exists
|
||||
without a flag-day. Unchanged from the slice-4 verifier wiring; 10B activates the execute path.
|
||||
|
||||
### Tests (real crypto, non-hollow)
|
||||
- `signedjobs` runner over the **real** gate+verifier (in-Go minted SSHSIGs): valid → executor runs
|
||||
once + job cleared; **replay** (nonce burned) / **non-pinned signer** / **expired** / **retarget**
|
||||
(other host) / **forged sig** / **no pinned signer** → all rejected, **executor never called**;
|
||||
malformed envelope cleared.
|
||||
- `WipeExecutor`: valid → `mkfs` runs; **path-only**, **durable-id mismatch**, **device gone**,
|
||||
**re-inspect non-data-bearing**, **not-probed** → all refused, `Format` not called.
|
||||
- `storage` durable: wwn-preference, uuid-fallback, path-only/traversal refusal, round-trip,
|
||||
missing-device error (symlink tests gated to Linux — the agent's OS).
|
||||
|
||||
## v0.15.0 — slice 10A: hub desired-state serving — the "Down" channel (2026-06-10)
|
||||
|
||||
The agent half of slice 10A. The control envelope (`hub.ControlEnvelope`) stops being "reserved — ignored" and becomes the live **Down channel**: a cheap change-notification on every heartbeat. The agent caches the hub's desired-state + its generation; only when **`DesiredGeneration` advances** does it fetch the full state (the heartbeat stays light, the heavy state moves on change). The engine then reconciles **benign** deltas and the gate marks an explicit **destructive** delta `pending_signature` (no signer in 10A → never executed; signed execution is 10B). Pairs with hub v0.9.0.
|
||||
|
||||
Reference in New Issue
Block a user