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:
@@ -1,61 +1,63 @@
|
||||
# REPORT — slice 10A (agent half): hub desired-state serving — the "Down" channel (v0.15.0) (2026-06-10)
|
||||
# REPORT — slice 10B (agent half): operator-signed destructive completion (v0.16.0) (2026-06-10)
|
||||
|
||||
> Overwrite-latest report. Cumulative history: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## What was implemented
|
||||
|
||||
The agent half of **slice 10A**: activate the control envelope as the live **Down channel** and feed
|
||||
a hub-backed desired-state into the reconcile engine. Pairs with hub v0.9.0.
|
||||
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 control loop (now live)
|
||||
report (heartbeat) → control envelope → (DesiredGeneration advanced past cache? fetch desired-state)
|
||||
→ reconcile benign / gate destructive → report. The heartbeat stays light; the heavy desired-state is
|
||||
fetched **only on a generation advance**.
|
||||
### 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.
|
||||
|
||||
### `internal/reconcile`
|
||||
- **`DesiredGuest.Decommission`** — the canonical **destructive** desired-state delta (an EXPLICIT
|
||||
flag, never "absent from the list", so a partial hub list can't mass-destroy). Planner emits
|
||||
`ActionDecommission` → `ClassDecommission` → Destructive → the gate refuses `pending_signature`.
|
||||
- **`Reconcile`** now counts a `pending_signature` gate refusal as **`Result.Pending`** (expected,
|
||||
INFO-logged), not a failure; any other refusal stays a real failure. `ActionDecommission` has **no
|
||||
executor** (10B) — a defensive guard refuses to run it.
|
||||
- **`CachingProvider`** — thread-safe DesiredState + generation cache (`Desired`/`Update`/
|
||||
`Generation`); the production provider, replacing `EmptyProvider` in the daemon engine. Empty until
|
||||
the hub serves intent → cold-start is a live no-op (unchanged behaviour).
|
||||
### `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.
|
||||
|
||||
### `internal/hub`
|
||||
- `ControlEnvelope` fields are now active. New wire types **`DesiredStateResponse`** +
|
||||
**`WireDesiredState`** (guests + forward-compat `restore_directive` (10D) / `pbs_namespace` / opaque
|
||||
`storage_manifest`+`backup_policy`) + **`WireDesiredGuest`**. New **`Client.FetchDesiredState`**
|
||||
(GET `/api/v1/hosts/{host_id}/desired-state`, self-scoped to the client's own host). New
|
||||
**`EnvelopeObserver`** loop seam + `SetEnvelopeObserver` (hub does not import reconcile/desired).
|
||||
### 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 identity** — `DeviceDurableID` / `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.
|
||||
|
||||
### `internal/desired` (new) + wiring
|
||||
- **`Syncer`** — implements `hub.EnvelopeObserver`; fetches on a generation advance, maps wire→domain,
|
||||
updates the `CachingProvider`. Caches the **fetched** generation (race-robust); a fetch failure keeps
|
||||
the last-known state. `restore_directive` carried + logged, not acted on (10D). Wired in
|
||||
`cmd/felhom-agent`: provider → engine, syncer → loop.
|
||||
### 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 (all green)
|
||||
- reconcile: **benign applied + destructive decommission gated pending (not executed)**; Plan
|
||||
decommission-only + classifies Destructive; CachingProvider update/isolation.
|
||||
- desired: **fetch-once-on-advance** / no-refetch-on-unchanged / fetch-failure-keeps-cache /
|
||||
caches-the-fetched-generation.
|
||||
- hub: `FetchDesiredState` path+auth+decode (incl. `restore_directive`) + typed 403; loop notifies the
|
||||
observer + adopts `PollIntervalSeconds`, skips the observer on a report error.
|
||||
- cross-repo golden (`desired-state` + `control-envelope`) decode + key-set guard, byte-identical with
|
||||
felhom.eu/hub. `go test ./...` green.
|
||||
## 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.14.0 → 0.15.0`; `CHANGELOG.md` updated. Doc 03 §4 (control loop live) + §9 (slice table:
|
||||
10A done, 10B/10C/10D pending) updated.
|
||||
- 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)
|
||||
- Signed-op **execution** (verify + run the gated destructive op) → 10B (10A marks it pending only).
|
||||
- **Restore-mode / re-enroll** consumption (a new box's first directive) → 10D; 10A serves
|
||||
already-authenticated hosts only.
|
||||
- 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: build+deploy agent v0.15.0 + hub v0.9.0; admin-set a desired-state
|
||||
with a benign + a decommission delta → generation bumps → agent fetches → reconciles benign + gates
|
||||
the decommission; change `poll_interval_seconds`; confirm a host can't fetch another host's state.
|
||||
- **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.)
|
||||
|
||||
Reference in New Issue
Block a user