Skip to content

Signing kernel (SPEC-006 v1): canonical JSON + operator key, gate records signed fail-closed - #386

Merged
IanFrelinger merged 1 commit into
masterfrom
claude/signing-kernel
Aug 24, 2026
Merged

Signing kernel (SPEC-006 v1): canonical JSON + operator key, gate records signed fail-closed#386
IanFrelinger merged 1 commit into
masterfrom
claude/signing-kernel

Conversation

@IanFrelinger

Copy link
Copy Markdown
Owner

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.Signing machinery plus its wiring into the admission store, so the next slice
(ashlar keys init and 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_DIR or ~/.ashlar/keys; the seed is owner-only on POSIX; the private
    key never appears in a record, bundle, or output. Rotation retains the old public key under
    trusted/ so old records still verify (revocation is v2). TryLoad returns null when there is
    no key — absence degrades to today's honest unsigned behaviour (S-2).
  • Admission/GateStore.csGateRecord gains Sig + Signer. A store constructed with a
    signer signs every record it writes; one without a signer writes unsigned (presence-activated,
    never half-signed). ReadRecordAsync verifies fail-closed: a record whose signature does not
    cover 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

  • S-1 bad signature = corrupt, throw. S-2 no key = unsigned, never half-on. S-3 the
    renderer never prints a fingerprint for an absent signature (Sig/Signer are nullable and
    independent). 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):

  1. WriteAsync was asymmetric (HIGH). The keyless write path had no else, so a record read,
    mutated, and rewritten by a store without a key (DecideAsync: Held → Admitted) kept the old
    signature covering the pre-mutation content. The next fail-closed read would then reject a
    legitimate verdict as forged — and because ListAsync throws on it, the whole store
    enumeration (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.
  2. Non-atomic keypair write (HIGH).Generate wrote operator.key then operator.pub as two
    independent writes; a crash mid-rotation (old seed already gone) could strand a new seed beside
    an old pub, and TryLoad trusted the pair blindly — so every record it signed would fail its
    own 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 SigningIdentity enforces an invariant — it
    derives the public key from the seed and refuses a mismatched pair loudly, turning silent
    store-poisoning into a clear, recoverable error.

Verification

  • Kernel suite: 346 / 346 (323 prior + 23 signing, including 3 regression tests pinning the two
    fixes above; zero regressions in the existing race / crash-safety / fuzz / admission tests that
    all exercise GateStore).
  • Cert gate: 178 / 178.
  • Ashlar.Manifest builds clean on net8.0 + net10.0, 0 warnings under TreatWarningsAsErrors;
    NSec resolves on both TFMs.

All builds and tests run container-side (scripts/handoff/devbox.sh) per the standing setup.

…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>
@IanFrelinger
IanFrelinger merged commit 462bb11 into masterAug 24, 2026
9 checks passed
@IanFrelinger
IanFrelinger deleted the claude/signing-kernel branch August 24, 2026 02:16
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@IanFrelinger@PlzTouchGrass