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>
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)
Verifier—New(signers []AllowedSigner, store NonceStore, hostID string) *Verifier;Verify(blob, sigArmored []byte) (*VerifiedOp, error). OptionalClockSkew(default 2m, not-yet-valid only) andLogger(advisory key_id-mismatch warning).OpBlob— canonical signed object;Target{HostID,GuestID}with correctedhost_id/guest_idjson tags;Params json.RawMessage,Nonce,IssuedAt,ExpiresAt,KeyID.VerifiedOp—Op, HostID, GuestID, Params, Nonce, IssuedAt, ExpiresAt, KeyID (advisory), Signer (matched), KeyIDMatchesSigner.AllowedSigner+NewAllowedSigner(keyID, role, authorizedKeyLine); rolesRoleOperational/RoleRecovery(doc 04 two-key model; role-scoping enforced by the caller).NonceStoreinterface +MemoryNonceStore(tests) andFileNonceStore(durable).- Typed errors:
ErrMalformed, ErrNamespace, ErrUnknownSigner, ErrBadSignature, ErrTarget, ErrExpired, ErrNotYetValid, ErrReplay(errors.Is-friendly). - Config:
config.AuthzConfig(nonce-store path + pinnedSigners).
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 beforeSeenOrRecordreturnsfalse, 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 signvector (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)
Targetneededhost_id/guest_idjson 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.0and 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 fullVerifiedOp; typed errors; clock-skew; durable nonce store (the net-new engineering). - Shared-contract flag (not built): the hub and
felhom-signCLI must produce byte-identical canonical JSON or signatures won't verify; a shared canonicalizer both import is the right home.
Verification
go build/vet/testgreen 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:
mainonly. Dep:golang.org/x/crypto v0.52.0(+x/sysindirect);go 1.25.0.