Files
felhom-agent/REPORT.md
T
admin f0fee7e193 feat(authz): operator signed-op verifier + durable nonce store (slice 2, v0.2.0)
internal/authz: production form of the Phase-4 SSHSIG signing primitive.

- Verifier.New/Verify with the LOCKED pipeline (namespace → allow-list by key
  material → crypto over RAW bytes → target → time → nonce LAST); each post-crypto
  stage rejects even with a valid sig; an invalid sig never burns a nonce.
- SSHSIG framing via x/crypto/ssh (no hand-rolled crypto); key-type-agnostic
  (ed25519 / sk-ssh-ed25519 / rsa / ecdsa via pub.Verify). Fixed namespace
  felhom-op-v1. Typed errors. OpBlob (fixed host_id/guest_id tags) + VerifiedOp.
- NonceStore: MemoryNonceStore + durable crash-safe FileNonceStore (fsync'd append
  log, replay-on-open, compaction, expiry-only pruning; survives restart).
- config.AuthzConfig (nonce path + pinned operational/recovery signer keys).
- Tests (14): real ssh-keygen fixture, per-stage rejection, nonce-not-burned,
  replay, persistence-across-restart, synthetic sk, byte-exactness.

Dep: golang.org/x/crypto v0.52.0 (declares go 1.25 — the Phase-4 doc's "Go 1.24.4 /
x/crypto v0.52.0" pairing doesn't build; build server upgraded to go1.26.0,
backward-compatible). Version 0.1.0 -> 0.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:23:02 +02:00

4.9 KiB

felhom-agent — latest task report

This file holds the report for the most recent change, fully overwritten each task. Cumulative history lives in CHANGELOG.md.

Task: authz signed-op verifier (slice 2) — v0.2.0

Turned the Phase-4 reference VerifySignedOp into a production package (internal/authz): a key-type-agnostic SSHSIG verifier for operator-signed destructive ops, the full anti-replay/authorization pipeline, and a durable, crash-safe nonce store. This is what slice 4 (reconcile) calls to gate destructive desired-state deltas. Pushed to main. Build/vet/test green locally (Go 1.26) and on the build server.

Public surface (internal/authz)

  • VerifierNew(signers []AllowedSigner, store NonceStore, hostID string) *Verifier; Verify(blob, sigArmored []byte) (*VerifiedOp, error). Optional ClockSkew (default 2m, not-yet-valid only) and Logger (advisory key_id-mismatch warning).
  • OpBlob — canonical signed object; Target{HostID,GuestID} with corrected host_id/guest_id json tags; Params json.RawMessage, Nonce, IssuedAt, ExpiresAt, KeyID.
  • VerifiedOpOp, HostID, GuestID, Params, Nonce, IssuedAt, ExpiresAt, KeyID (advisory), Signer (matched), KeyIDMatchesSigner.
  • AllowedSigner + NewAllowedSigner(keyID, role, authorizedKeyLine); roles RoleOperational / RoleRecovery (doc 04 two-key model; role-scoping enforced by the caller).
  • NonceStore interface + MemoryNonceStore (tests) and FileNonceStore (durable).
  • Typed errors: ErrMalformed, ErrNamespace, ErrUnknownSigner, ErrBadSignature, ErrTarget, ErrExpired, ErrNotYetValid, ErrReplay (errors.Is-friendly).
  • Config: config.AuthzConfig (nonce-store path + pinned Signers).

Locked pipeline (order load-bearing)

parse armor → namespace (fixed felhom-op-v1) → parse pubkey → allow-list by key MATERIAL (not key_id) → crypto verify over RAW received bytes → parse blob → target (host strict, guest surfaced) → time window → nonce recorded LAST. Each post-crypto stage rejects even with a valid signature; an invalid signature can never consume a nonce.

Durable nonce store — mechanism & guarantee

fsync'd append-only JSONL log + in-memory index (replayed on open) + periodic compaction.

  • Crash-safe: a nonce is written and fsync'd before SeenOrRecord returns false, so the caller acts only after the durable record. A crash between verify and execute drops the op (fail-safe) and never enables a replay. I/O failure → returns seen=true (op not executed).
  • Survives restart: the log is replayed into the index on OpenFileNonceStore.
  • Pruning: expired nonces dropped only at compaction (never before exp) — and an expired op is rejected by the time check before the nonce check, so pruning is housekeeping, not a hole.
  • Concurrency-safe: single mutex over file handle + index.

OPEN choices

  • Clock skew: 2-minute tolerance on not-yet-valid only; expiry not extended (window stays an honest bound).
  • Durable mechanism: fsync'd append log + compaction (simple, honest, no embedded-KV dep).
  • Fixtures: committed real ssh-keygen -Y sign vector (hermetic + proves OpenSSH interop) + in-Go minting for rejection cases; the sk case is synthetic (spec-faithful, no hardware).
  • Package name: authz (control-plane-authorization layer, matches doc 04).

Test matrix (all pass — 14 tests)

Real ssh-keygen fixture · happy path · per-stage rejection {namespace, unknown-signer, tampered, retargeted-host, expired, not-yet-valid, replay} · invalid-sig-does-NOT-burn-nonce (then the valid op with that nonce still succeeds) · replay-rejected-across-restart (durable store) · key-type-agnostic synthetic sk-ssh-ed25519 · byte-exactness (re-serialized blob fails crypto).

Corrections to the Phase-4 §7 reference (for production)

  • Target needed host_id/guest_id json tags — fixed.
  • The doc's "Go 1.24.4 / x/crypto v0.52.0" does not hold: x/crypto v0.52.0 declares go 1.25.0 and won't build on Go 1.24. Resolved by upgrading the build server to go1.26.0 (backward-compatible — felhom-controller/hub build unchanged; distro Go package left intact, upstream Go fronted on PATH).
  • Free function → constructed Verifier; returns full VerifiedOp; typed errors; clock-skew; durable nonce store (the net-new engineering).
  • Shared-contract flag (not built): the hub and felhom-sign CLI must produce byte-identical canonical JSON or signatures won't verify; a shared canonicalizer both import is the right home.

Verification

  • go build/vet/test green locally (go1.26.0) and on the build server (upgraded to go1.26.0).
  • Real OpenSSH ssh-keygen (OpenSSH 10.0p2) minted the committed fixture and self-verified it before commit.

Repo state

  • Branch: main only. Dep: golang.org/x/crypto v0.52.0 (+ x/sys indirect); go 1.25.0.