Signing kernel (SPEC-006 v1): canonical JSON + operator key, gate records signed fail-closed - #386
Merged
Merged
Conversation
…osed gate records The kernel half of SPEC-006 v1 — a single local operator Ed25519 keypair, presence- activated signing, and gate records that verify fail-closed on read. No CLI verb yet; this is the Ashlar.Manifest.Signing machinery plus its wiring into the admission store. - CanonicalJson: UTF-8 JSON with object keys sorted ordinally at every depth, arrays in order, null properties omitted, no insignificant whitespace. Signer and verifier derive bytes through this one method so they agree byte-for-byte regardless of in-memory order. - OperatorKey: Ed25519 keygen/load/fingerprint/sign/verify over NSec. Seed owner-only on POSIX and never emitted into any record or output; rotation retains the old public key under trusted/ so old records still verify. TryLoad returns null when no key exists — absence degrades to honest unsigned behaviour (S-2). - GateStore: GateRecord gains Sig+Signer. A store with a signer signs every record; one without writes unsigned (never half-signed). ReadRecordAsync refuses a record whose signature does not cover its contents, loudly (S-1) — a forged verdict is worse than a missing one. Verification is intrinsic, so a keyless reader still detects tampering. Two defects an adversarial review caught, fixed with regression tests: - WriteAsync was asymmetric: the keyless path kept an inherited signature over pre-mutation content (DecideAsync: Held -> Admitted), so the next fail-closed read would reject a legitimate verdict as forged and take the whole store enumeration down. The write path is now symmetric — strip to unsigned, then sign iff a key is present. - Generate wrote the keypair as two non-atomic files; a crash mid-rotation could strand a new seed beside an old pub. Each file is now written temp-then-rename, and SigningIdentity derives the public key from the seed and refuses a mismatched pair loudly. Kernel suite 346/346 (23 signing tests incl. 3 regression); cert gate 178/178; Manifest builds clean on net8.0 + net10.0 under TreatWarningsAsErrors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
IanFrelinger added a commit
that referenced
this pull request
Aug 24, 2026
…#387) The application surface that activates the signing kernel already on master (#386). `ashlar keys init` creates the operator's local Ed25519 identity; once it exists the gate signs every verdict it records — presence-activated, so a project with no key keeps working unsigned and honest. - keys init: generates the operator keypair under $ASHLAR_KEY_DIR (else ~/.ashlar/keys); --rotate replaces it and retains the old public key under trusted/; refuses to overwrite without --rotate. - keys show: prints the operator fingerprint, or says plainly there is none (S-3, never a fingerprint for an absent key). Only the public fingerprint is ever printed; the seed never leaves the key directory. - gates signs: propose/admit/refuse open the store with the operator identity when present. Held verdicts are signed, an admit re-signs over the new content, no key means unsigned. - reads never need the key: gates list/show verify each record against its OWN embedded key, so they open a signer-less store — a mangled or missing operator key can no longer block an operator from seeing the queue; only write paths load the key and fail closed when corrupt. - corrupt-key hardening (kernel): OperatorKey.TryLoad normalises every corrupt-key shape (garbled base64, wrong-length seed) to the same loud InvalidOperationException the mismatch case already raised, so no caller sees a raw FormatException escape as a stack trace. A present-but-unreadable key fails loud; it never degrades to "no key". (From this slice's adversarial review.) - registration guard: RootCommand_RegistersKeysCommand, so `keys` cannot silently vanish. e2e-loop 65/65 (real CLI binary, fresh process each): the integration is proven end to end — signed records carry Sig+Signer, admit re-signs and reads back, signed records survive process death, a corrupt key fails writes cleanly yet reads still work. Kernel signing 27/27; CLI registration 7/7. Public-API snapshots unaffected. Co-authored-by: PlzTouchGrass <130718671+PlzTouchGrass@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Signing kernel (SPEC-006 v1): canonical JSON + operator key, gate records signed fail-closed
What this is
The kernel half of SPEC-006 v1: a single local operator Ed25519 keypair, presence-activated
signing, and gate records that verify fail-closed on read. No CLI verb yet — this is the
Ashlar.Manifest.Signingmachinery plus its wiring into the admission store, so the next slice(
ashlar keys initand signing on gate admission) has a tested foundation to stand on.What landed
Signing/CanonicalJson.cs— the canonical byte form every signature covers: UTF-8 JSON,object keys sorted ordinally at every depth, arrays left in order, null-valued properties
omitted, no insignificant whitespace. Signer and verifier derive bytes through this one method,
so they agree byte-for-byte regardless of in-memory key order.
Signing/OperatorKey.cs— Ed25519 keygen / load / fingerprint / sign / verify over NSec.Key dir is
ASHLAR_KEY_DIRor~/.ashlar/keys; the seed is owner-only on POSIX; the privatekey never appears in a record, bundle, or output. Rotation retains the old public key under
trusted/so old records still verify (revocation is v2).TryLoadreturns null when there isno key — absence degrades to today's honest unsigned behaviour (S-2).
Admission/GateStore.cs—GateRecordgainsSig+Signer. A store constructed with asigner signs every record it writes; one without a signer writes unsigned (presence-activated,
never half-signed).
ReadRecordAsyncverifies fail-closed: a record whose signature does notcover its contents is treated as corrupt and refused loudly (S-1) — a forged verdict is
worse than a missing one. Verification is intrinsic to the record, so a fresh checkout with no
key still detects tampering.
Rules honoured
renderer never prints a fingerprint for an absent signature (
Sig/Signerare nullable andindependent). Org trust roots, revocation, and HSM are explicitly v2 and are not pretended
here.
Two bugs an adversarial review caught (fixed, with regression tests)
A four-lens adversarial review (canonicalization / forgery / key-handling / store-integration),
each finding independently refuted before being trusted, confirmed two real defects — the
canonicalization lens was fully refuted (single code path, no nullable fields in the record graph):
WriteAsyncwas asymmetric (HIGH). The keyless write path had noelse, so a record read,mutated, and rewritten by a store without a key (
DecideAsync: Held → Admitted) kept the oldsignature covering the pre-mutation content. The next fail-closed read would then reject a
legitimate verdict as forged — and because
ListAsyncthrows on it, the whole storeenumeration (and the budget check that rides on it) would go down. Fixed: the write path is now
symmetric — always strip to the unsigned form, then sign iff a key is present. Never persist a
signature not just computed over exactly these bytes.
Generatewroteoperator.keythenoperator.pubas twoindependent writes; a crash mid-rotation (old seed already gone) could strand a new seed beside
an old pub, and
TryLoadtrusted the pair blindly — so every record it signed would fail itsown signature. Fixed two ways: each file is now written crash-safely (temp then rename, owner-
only perms set on the temp before the move), and
SigningIdentityenforces an invariant — itderives the public key from the seed and refuses a mismatched pair loudly, turning silent
store-poisoning into a clear, recoverable error.
Verification
fixes above; zero regressions in the existing race / crash-safety / fuzz / admission tests that
all exercise
GateStore).Ashlar.Manifestbuilds clean on net8.0 + net10.0, 0 warnings underTreatWarningsAsErrors;NSec resolves on both TFMs.
All builds and tests run container-side (
scripts/handoff/devbox.sh) per the standing setup.