Repository navigation
fix(certification): pin the canonical signing payload and refuse a degenerate one - #584
Merged
IanFrelinger merged 4 commits intoSep 10, 2026
Merged
Conversation
The canonical payload is the message every certification signature — HMAC and Ed25519, v1 and v2 — is computed over. Its exact bytes are therefore the whole basis on which a signature written yesterday still verifies today, and they are produced by reflection-based System.Text.Json: output that depends on the runtime, the serializer version and the publish mode rather than on anything this repository declares. "The bytes did not change" is an assertion, not a property that can be reasoned about, so make it one. Only the v1 lane had byte coverage (TrustLoopRecordSchemaTests pins one legacy string). v2 — the shape every live minter emits — had none, while ci/cert-gate-assertions.md already listed "v1/v2 record bytes are frozen" as a merge-blocking assertion. That row now describes what is actually checked. canonical-payloads.golden.json holds five records: both lanes fully populated, both lanes minimal (which pins that a legitimate payload still carries every declared property name with null values — small is legitimate, empty is not), and an escaping case covering non-ASCII, a control character, a surrogate pair and every character the default encoder rewrites. Lists are supplied out of order so the ordinal sorts are pinned too, and proposer parameters are supplied with mixed case so the ordinal key sort and the verbatim (not camel-cased) dictionary keys are pinned with them. Doubles are restricted to exactly representable values on purpose: decimal formatting of arbitrary doubles is not yet identical across the three targets this package ships for, and a golden corpus should not claim otherwise. The corpus is a file rather than a constant in the test because scripts/ns20-canonical-bytes-probe.sh reads the same file. netstandard2.0 is not a runtime, so the only honest way to show that asset agrees is to execute it: the script builds the shipped ns2.0 asset, publishes a net472 consumer against it, and runs that under Mono. Measured today, all five payloads are byte-identical on net8.0, net10.0 and the ns2.0 asset under Mono 6.12. One checked-in constant, two readers — two independently typed copies would drift, and the cross-target equality claim would evaporate with them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BuildPayload is the one choke point every certification signature passes
through — HMAC sign and verify, Ed25519 sign and verify, both payload lanes —
and it produces those bytes with reflection-based System.Text.Json. Reflection
does not survive trimming or ahead-of-time publishing. Under those publish
modes the serializer can return the empty object {} or a short payload, with no
exception and no warning, and the calling code cannot tell: it hashes whatever
it was handed.
The bytes ARE the message. A payload that is not the record's declared shape
therefore does not describe the record, and both sides of a verification
recompute it from the same serializer, so a comparison over it establishes
nothing either. Refusing is the only answer that stays true whatever the
publish mode.
Degenerate is defined as shape, not size. PayloadOptions configures no
JsonIgnoreCondition, so every declared property is emitted for every record
whatever its values: a legitimate minimal certificate is 276 bytes carrying all
13 v1 names as nulls, and a load refusal carries all 20 v2 names. Size and
emptiness are therefore useless as signals, but the property-NAME sequence is a
value-independent invariant of the payload type. The payload is re-parsed with
JsonDocument — reflection-free, so it still works in exactly the configurations
that break the serializer — and each top-level and nested name sequence is
compared against a hard-coded constant. This subsumes the cheap "is it {}" test
and also catches a reordering, which would otherwise silently invalidate every
signature ever written. proposer.parameters is left alone: those keys are
caller-supplied and emitted verbatim, so they are content, not structure.
The two directions want different failure modes. Signing propagates:
minting a signature over bytes you cannot vouch for is the thing being
prevented, and a crash at mint time is loud and correct. Verification refuses
instead of throwing into its host — VerifySignature returns false, and
CertificationTrustVerifier reports a new payload-not-canonical failure code so
the fault stays distinguishable from an honestly mismatched signature. The
verifier builds the payload before comparing signatures and unconditionally,
because a comparison over bytes whose shape has not been established is not a
result worth reporting.
Byte-neutral on every legitimate path, and that is asserted rather than
asserted-by-hand: the golden corpus pinned in the previous commit passes
unchanged on net8.0, net10.0 and the netstandard2.0 asset under Mono.
EnsureCanonical is internal (InternalsVisibleTo, the pattern this repo already
uses) because the publish configurations that make the serializer degrade
cannot be reproduced inside a test host — the assembly under test is loaded
untrimmed by definition. The guard tests therefore drive it directly, and the
golden tests supply the other half of the proof: that the declared shape is the
shape the serializer really emits, so there is no legitimate record the guard
can reject.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…payload refusal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mber The shape guard checked the top-level property-name sequence and then enumerated gatesPassed, inputs and attempts. The name sequence establishes that a member is present; it does not establish what kind it is. A member arriving as null, an object, a string or a number therefore reached JsonElement.EnumerateArray, which throws InvalidOperationException — and neither VerifySignature lane nor CertificationTrustVerifier.Verify catches that. RequireArray now establishes the kind first, in the same terms and with the same message shape as the existing non-object branch, so those inputs are refused instead of thrown and reach the payload-not-canonical failure code they were always meant to reach. Verify had the mirror of the same gap one level out. Its unconditional BuildPayload runs before VerifySignature's own missing-signature short-circuit and caught only CanonicalPayloadException — but a record's collections come from its own JSON, and the deserializer does not enforce the non-null annotations on their elements, so building the payload can fail on a member before the guard sees any bytes at all. That turned a refusal into an exception raised in the caller's host, which is the one outcome this change exists to rule out. Every way of failing to establish the bytes is now the same answer: a new payload-unbuildable failure code, kept distinct from payload-not-canonical so the two faults stay separable in a log. The guard suite gains the four nested-member kinds that reached the raw throw, and a verifier-level case that pins the refusal rather than the exception. Wording: three comments and one XML doc reached past what they needed to say. Both sides of a signature comparison recompute the payload from the same serializer, so a comparison cannot establish anything about bytes whose shape has not been established first — which is the whole reason the shape is established before anything is compared, and is what they now say. The CHANGELOG entry moves from Security to Changed to match that robustness-and-determinism framing, names the new failure code, and no longer names the call sites left uncovered; a residual-risk map belongs in the tracker rather than in release notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IanFrelinger
deleted the
cursor/claude-pin-canonical-signing-payload
branch
September 10, 2026 15:15
IanFrelinger
added a commit
that referenced
this pull request
Sep 10, 2026
… and refuse what cannot be evaluated (#585) * fix(certification): refuse an Ed25519 signature the netstandard2.0 verifier cannot evaluate Ashlar.Certification.Contracts ships netstandard2.0 for external consumers, and that asset has no Ed25519 implementation. Its CertificationTrustVerifier never read Ed25519Signature: under options that did not require a signature (Legacy, or any options with RequireEd25519Signature false and no pinning) a record carrying an Ed25519 signature that does not verify fell through to the content-hash check and was trusted, while net8.0 and net10.0 refuse the same bytes with ed25519-signature-invalid. Every target must reach the same verdict for the same record, and a check a target cannot run must be refused, not skipped. The presence test and the key-missing test now sit outside the target-framework fence, so "present" means the same bytes everywhere and ed25519-key-missing is reported identically. Only the evaluation differs: where it cannot run, the record is refused with a new ed25519-signature-unverifiable code at the same position in the check sequence (after the HMAC, before the content hash), so a multi-fault record reports the same code on every target. The set of records the netstandard2.0 asset trusts is now a subset of what net8.0 trusts (HMAC-only records), never a contradiction. ed25519-verification-unavailable keeps its meaning (strictness this build cannot deliver) and now fires only for an absent signature. Records with no Ed25519 signature keep today's behaviour on every target. Measured on the shipped netstandard2.0 asset under Mono 6.12 with the extended scripts/ns20-canonical-bytes-probe.sh, against the golden corpus under Legacy: before, the two admitted records carrying an Ed25519 placeholder are TRUSTED (4 of 7 verdicts contradict net8.0); after, they are REFUSED ed25519-signature-unverifiable and all 7 verdicts agree. The HMAC-only shape of every admitted record is TRUSTED before and after. VerifierParityTests pins the net8.0 side against the same corpus, so the subset property is asserted from both sides against one set of bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(certification): let StateLogVerifier be handed options it can evaluate StateLogVerifier.Verify hardcoded CertificationVerifyOptions.Strict, and Strict requires an Ed25519 signature, which the netstandard2.0 asset of Ashlar.Certification.Contracts cannot evaluate. On that asset the state-log verifier could therefore never return a trusted verdict (every transition was behavior-cert-untrusted with ed25519-verification-unavailable inside), with no configuration escape. Add an overload taking CertificationVerifyOptions, placed last and non-optional: the existing overload takes an optional string? hmacKey in the slot a nullable options parameter would otherwise occupy, and a literal null argument must keep meaning "no key". The existing signature delegates to it with Strict, so no caller changes and no default changes. A null options instance is refused (verify-options-missing) in the verifier's own refusal convention rather than defaulted: a verifier that was not told how strict to be must not guess. The overload does not widen what the netstandard2.0 asset can trust. A certificate that carries an Ed25519 signature is refused there under every options instance, so only HMAC-only certificates ever verify on it and the set it trusts stays a subset of what net8.0 trusts. Measured under Mono 6.12 on the shipped netstandard2.0 assets with scripts/ns20-canonical-bytes-probe.sh, which now builds and drives Ashlar.Certification.State as well: a one-transition log bound to an HMAC-only certificate is REFUSED through the existing signature (behavior-cert-untrusted: ed25519-verification-unavailable) and TRUSTED through the overload with Legacy; the same log bound to a certificate carrying an Ed25519 signature is REFUSED through the overload with Legacy (ed25519-signature-unverifiable). AttestedStateLogBindingTests pins the net8.0 side: both signatures yield identical results under Strict, Legacy trusts an HMAC-only log that Strict refuses, and null options are refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(certification): make an unknown schema version an error on both paths, not a guess CertificationRecordSigning.BuildPayload selected the v2 payload lane on `SchemaVersion is not null`, so a record stamped 3, 7 or int.MaxValue was serialized in the v2 shape with that number emitted verbatim - bytes whose meaning no code has defined - and, because MinimumSchemaVersion only says "at least this new", cleared the floor of 2 that Default and Strict apply. Only null (v1) and TrustLoopSchemaVersion (2) select a lane now, through one IsKnownSchemaVersion used by the signing and the verification paths so they cannot disagree. Signing propagates the refusal as a CanonicalPayloadException, the same vehicle a degenerate payload uses (#584): loud at mint time. Every verification path refuses without throwing: VerifySignature and CertificationRecordEd25519.VerifySignature return false, the second tier (CertificationRecordSigner.Verify, which gates the file store's re-verify on load) returns false, and CertificationTrustVerifier reports a dedicated schema-version-unknown code rather than payload-not-canonical, because that code is documented as the serializer degrading under trimming or AOT (a deployment fault) and an unknown version is a record fault. Precedence is floor first, so an explicit 0 or 1 under Default still reports schema-version-below-floor. Byte-neutral for every valid record: the null and 2 branches are untouched, canonical-payloads.golden.json is not modified, and CanonicalPayloadGoldenTests and TrustLoopRecordSchemaTests pass against it as-is. Measured on the shipped netstandard2.0 asset under Mono 6.12: all five payloads are still byte-identical, BuildPayload throws for 3 and int.MaxValue, and the verifier reports schema-version-unknown for both under Legacy and under Default. No in-repo writer mints any version other than null or 2 (CertificationGate, LoadRefusalRecord, and every test fixture were checked before this change). UnknownSchemaVersionTests pins every path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
IanFrelinger
added a commit
that referenced
this pull request
Sep 11, 2026
…of serializing it (#589) * refactor(certification): write the canonical signing payload instead of serializing it The canonical payload is the message every certification signature -- HMAC and Ed25519, v1 and v2 -- is computed over, so the bytes must be produced the same way in every publish mode a consumer can choose. They were produced by reflection-based `JsonSerializer.Serialize`: the v1 lane over an anonymous type, the v2 lane over a private positional record. Reflection-based serialization does not survive trimming or ahead-of-time publishing. Measured in the devtest container across the publish matrix on a self-contained linux-x64 publish, the same source produced correct bytes, a throw, or an empty object depending only on the target framework and the trim settings: under `PublishAot` with defaults and under `PublishTrimmed` with either `TrimMode` both lanes throw `InvalidOperationException` (reflection-based serialization disabled); on net10.0 with `TrimMode=full` and `JsonSerializerIsReflectionEnabledByDefault=true` the v1 anonymous type serialized to `{}` while the named v2 record came out correct; on net8.0 with `TrimMode=full` and reflection re-enabled both lanes throw `NotSupportedException` about constructor parameter names ILLink had trimmed. Under a trimmed publish the payload could serialize to an empty object, and bytes that back a signature must never be silently empty. `BuildPayload` now writes the payload field by field with `Utf8JsonWriter`. The byte order is the statement order in one method and the names are constants read by both the writer and the shape guard, so the canonical form is a property of this file rather than of whichever `System.Text.Json` build a consumer resolves -- the package ships netstandard2.0, net8.0 and net10.0, which are three different serializer builds behind one "canonical bytes" claim. `Utf8JsonWriter` still does the encoding, deliberately: its defaults are compact output and the default JavaScript encoder, and `WriteNumber(double)` keeps whatever decimal form the target produces, so each target's existing behaviour is reproduced rather than replaced. Choosing one decimal form would be a different change and would move signatures already written. BYTE-NEUTRAL. `canonical-payloads.golden.json` is unchanged and is the acceptance test: all five cases -- both lanes populated, both lanes minimal, and the escaping case -- are byte-identical, byte-length-identical and SHA-256-identical to the corpus. #584 pinned those bytes and #585 fixed the lane selection, which is what makes this change verifiable by construction rather than by inspection. `EnsureCanonical` is unchanged and still called on the writer's output. It is no longer detecting a serializer degrading under a publish mode -- no input to the writer can produce another name sequence -- so it is now a post-condition on the writer, which is exactly what a mistyped name or a swapped pair of write statements would trip. Two names are worth the guard on their own: camel casing lowercases a leading run, so the correct spellings are `brickId` and `ed25519PublicKey`, and a one-character slip in either changes every signature in that lane. Two behaviour notes. Nulls, sort orders, structural exclusions, the `"O"`-format UTC timestamp string and the verbatim ordinal-sorted proposer parameter keys are all preserved exactly. A proposer that supplies the same parameter key twice now raises `CanonicalPayloadException` rather than the `ArgumentException` the removed intermediate dictionary raised: `Utf8JsonWriter` does not reject a duplicate property name, and a payload carrying one describes no single record. The new vehicle is the one `VerifySignature` already catches, so verification refuses instead of throwing into its host -- which is the documented contract everywhere else on that path. Reaching it needs a custom `IReadOnlyDictionary`; a real `Dictionary` cannot produce it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(certification): pin the composition certification payload bytes `CompositionCertificationRecordSigner` produces the message every composition admission signature is computed over -- `CompositionCertificationGate` mints one for every admitted composition, and `CertifiedCompositionRegistry` verifies it -- and nothing pinned those bytes. The brick lane has had a byte-for-byte corpus since #584; this lane could change shape, order or spelling and the only thing that would notice is a stored certificate failing to verify some time after the change shipped. `composition-payloads.golden.json` sits beside the brick corpus in the same shape and with the same regeneration ritual: a populated record, a minimal one, and an escaping case, each pinning the payload string, its UTF-8 byte length and its SHA-256 separately, so a change a string comparison could normalise away still fails. Doubles are restricted to exactly representable values (0.5 and 0.25) for the reason the brick corpus states. The populated fixture supplies `signature` and `gate` as sentinels, so the structural exclusion is asserted rather than assumed. `BuildPayload` becomes internal so the pin can observe the bytes themselves. Going through a signature instead would report "the signature moved" rather than which byte did, and `Ashlar.Infrastructure` already grants `InternalsVisibleTo` to this test assembly. Pinned against the current implementation, unchanged: all three cases pass as-is. That is the point of landing it first -- the corpus records what this signer emits today, so a later change to how it emits has an oracle rather than an opinion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(certification): write the composition payload instead of serializing it Same change as the brick lane, on the signer `CompositionCertificationGate` uses for every admitted composition. `BuildPayload` serialized a twelve-member anonymous type through a freshly allocated `JsonSerializerOptions` on every call. Reflection-based serialization does not survive trimming or ahead-of-time publishing, and an anonymous type is the shape that fares worst: measured on net10.0 with `TrimMode=full` and reflection left enabled, an anonymous type serialized to the empty object `{}` where a named record came out correct. Under a trimmed publish the payload could serialize to an empty object, and bytes that back a signature must never be silently empty. The payload is now written field by field with `Utf8JsonWriter`, from constants read by both the writer and the shape check. `Utf8JsonWriter` still does the encoding, so compact output, the default JavaScript encoder, the `"O"`-format UTC timestamp string, the ordinal-sorted mutant id lists, the literal nulls and each target's own decimal form for `CompositionEscapeRate` are all reproduced rather than replaced. BYTE-NEUTRAL against the corpus landed in the previous commit: all three cases -- populated, minimal and escaping -- are byte-identical, byte-length-identical and SHA-256-identical, and `Sign` still computes the HMAC over exactly those bytes. The payload now carries a post-condition of its own, matching the brick lane: the emitted bytes are re-parsed with `JsonDocument` -- reflection-free, so it holds wherever the writer holds -- and must carry the exact twelve-name sequence in order. No record can make the writer produce another sequence, so what it catches is a mistyped name or a swapped pair of write statements, which would re-shape the signed payload silently. `Verify` gains a `catch (CanonicalPayloadException)` beside its existing `catch (FormatException)`, so this lane refuses the way the brick lane already does instead of throwing into the host; signing still propagates, because a loud failure at mint time is the right outcome for bytes nobody can vouch for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(certification): pin the certified transition entry-hash bytes `StateLogVerifier` recomputes `CertifiedTransitionBuilder.ComputeEntryHash` for every entry in an attested state log and refuses the log when it does not match the stored hash, so those bytes decide whether a log written yesterday still verifies today -- the same role the canonical signing payload plays for a certificate. Nothing pinned them. The only other in-repo computation of them is a re-typed copy inside `AttestedStateLogBindingTests`, which would drift with the production code rather than catch it drifting, and `scripts/ns20-canonical-bytes-probe.sh` exercises the builder end to end but pins a verdict, not bytes: it would still pass if the builder and the verifier changed together. `transition-entry-hashes.golden.json` pins three cases -- the genesis shape (an empty `prevEntryHash`, which must be written as an empty string rather than omitted), a fully linked entry, and an escaping case -- each with the payload string, its UTF-8 byte length, its SHA-256 and the resulting Base64 `entryHash`. The fixtures use real Base64 hashes on purpose: the default JavaScript encoder escapes `+` as a six-character sequence while leaving `/` and `=` alone, which is a property of the real inputs this hash is computed over and was previously pinned nowhere. `BuildCanonicalPayload` is extracted from `ComputeEntryHash` and made internal so the pin can observe the bytes; the public API exposes only their hash, and a pin that could only see the hash would report "the hash moved" rather than which byte moved it. `Ashlar.Certification.State` gains `InternalsVisibleTo` for the test assembly, matching `Ashlar.Certification.Contracts`. Pinned against the current implementation, unchanged: the extraction is a move, and all three cases pass as-is on both halves -- payload bytes and public entry hash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(certification): write the transition entry-hash payload instead of serializing it Same change as the two certification signers, on the last of the three canonical-bytes producers named in this work. `BuildCanonicalPayload` serialized a five-member anonymous type through a freshly allocated `JsonSerializerOptions` on every call, and `StateLogVerifier` recomputes it for every entry in every attested state log it checks. Reflection-based serialization does not survive trimming or ahead-of-time publishing, and an anonymous type is the shape that fares worst under it: under a trimmed publish the payload could serialize to an empty object, and every entry hash in every log would then be the hash of the same empty object. The payload is now written field by field with `Utf8JsonWriter`. `Ashlar.Certification.State` ships netstandard2.0, net8.0 and net10.0, so the same source runs against three different `System.Text.Json` builds; written this way the byte order is the statement order in one method. `Utf8JsonWriter` still does the encoding, so the default JavaScript encoder still escapes the `+` in a Base64 hash and still leaves `/` and `=` alone. BYTE-NEUTRAL against the corpus landed in the previous commit: all three cases are byte-identical, byte-length-identical and SHA-256-identical, and the public `ComputeEntryHash` returns the same Base64 hash for each. `AttestedStateLogBindingTests` passes unchanged. No shape post-condition here, deliberately, unlike the two signers. Their callers have a refusal contract that `CanonicalPayloadException` fits -- signing propagates, verification returns false -- while `StateLogVerifier` answers with a refusal code and must not throw into its host, and it compares the hash rather than the payload, so there is no place for the exception to land. The byte pin is the check for this site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(certification): cover what the payload writer decides that a corpus cannot state The golden corpora pin the bytes for eleven records across three canonical-bytes producers, which is what makes their shape, order and escaping a fact rather than an intention. A corpus entry cannot hold the inputs where a writer and a serializer could quietly disagree, so those are asserted directly. `CanonicalPayloadEmitterTests` covers: the timestamp is the round-trip UTC string and not `Utf8JsonWriter`'s own `DateTimeOffset` form (that overload looks like the obvious call and emits `+00:00` with trailing fractional zeros trimmed, so the fixture uses a `+02:00` offset to pin the conversion beside the format); a null inside a mutant id list is written as the literal null in ordinal sort position rather than dropped; a proposer with no parameters is an empty object where no proposer at all is the literal null, because those are different records and must not share a signature; parameter keys are emitted verbatim in ordinal order, uppercase before lowercase; a repeated parameter key is refused rather than written twice, and refused as a verification failure (`payload-not-canonical`) rather than an exception in the host; and both lanes emit all thirteen and all twenty names for the emptiest record either accepts. `scripts/ns20-canonical-bytes-probe.sh` gains a fourth check: the certified transition entry hash of every case in `transition-entry-hashes.golden.json`, computed on the shipped netstandard2.0 asset under Mono. `StateLogVerifier` recomputes that hash for every entry in an attested state log, so a target that computed it differently would refuse logs the other targets accept. The payload behind it is internal; its Base64 SHA-256 is what an external consumer can observe, and a hash of the bytes is a byte check. The probe stays the reader of checked-in corpora rather than a typist of its own constants. `WitnessFixture.ComputeEntryHash` in `AttestedStateLogBindingTests` keeps serializing an anonymous type, and now says why: the production builder writes the payload field by field, so the two reach the same bytes by different routes and the fixture is a witness rather than a copy. It was a re-typing of the production code before this change and would have drifted with it. The class docs on the golden and guard suites no longer describe the payload as reflection-serialized. The guard is now a post-condition on the writer -- no input can make it emit another name sequence -- which is the reason to keep it, not a reason to delete it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(portability): publish the certification contracts trimmed and AOT, and run them The canonical bytes are produced by code a host may publish however it likes, and `dotnet build` exercises exactly one of those ways. This probe exercises four more: `PublishTrimmed` with `TrimMode=partial`, with `TrimMode=full`, with `TrimMode=full` and `JsonSerializerIsReflectionEnabledByDefault=true`, and `PublishAot` -- each published self-contained `linux-x64` from a consumer authored **outside** the repo, and each one **run**, with the emitted payloads compared against the checked-in corpora. The third configuration is the one that used to emit the empty object rather than throw, which is why it is named rather than folded into the other two. The consumer reads the corpora with `JsonDocument` and hand-written property access. A reader that used the reflection-based deserializer would fail for its own reasons in exactly the configurations under test and would say nothing about the writer. Measured on this branch, in the devtest container (SDK 10.0.400, runtime 10.0.11): all four configurations publish with **zero** `IL`/`AOT`/`TRIM` analysis warnings and all four emit the five golden canonical payloads and the three golden transition entry hashes byte-identically. The publish settings are written into the consumer project rather than passed as `-p:` switches. A `-p:` switch is an MSBuild global property and flows into the referenced projects, whose netstandard2.0 leg then refuses to build at all (NETSDK1124 / NETSDK1207) -- the configuration under test would never be reached. Like `scripts/ns20-canonical-bytes-probe.sh`, this is a repeatable local measurement rather than merge-blocking coverage; no workflow calls it. `CompositionCertificationRecordSigner` is not covered here: it lives in `Ashlar.Infrastructure`, whose dependency graph is not published trimmed or AOT anywhere. Its bytes are pinned by its own corpus and it is written the same way, but this lane does not execute it. Also spells a generic type plainly in a line comment that had it HTML-escaped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): record the de-reflected canonical bytes and the new byte pins Seven entries under Unreleased/Changed: what the three canonical-bytes producers did before and what was measured in each publish mode; how they write their payload now and what Utf8JsonWriter is still deciding on purpose; the byte-neutrality evidence across net8.0, net10.0, the netstandard2.0 asset under Mono, the .NET 9 runtime and four trimmed/AOT publishes; the two new corpora and the two probes that read them; what EnsureCanonical means now that no input can trip it; the two narrow behaviour changes; and the canonical-bytes producers this work does not reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(certification): say how the canonical payload is produced now Two places described the payload as a serialized DTO, which it no longer is. SPEC-006's canonical-form list now says the certification payload is written field by field in a fixed order, and why that matters: the order is the order of the write statements and the names are constants the shape guard reads too, so the canonical form is a property of that file rather than of the serializer a consumer resolves. `ci/cert-gate-assertions.md` gains a row for the two canonical-bytes producers that had no byte pin before this work. The file exists so a returning owner facing a red required check can see what the toggle would turn off, and cert-gate now carries the composition payload pin, the transition entry-hash pin and the writer's own decisions alongside the v1/v2 corpus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(certification): pin which comparer orders the sorted canonical sequences The golden corpora pin that the mutant-id lists and the input list come out sorted; they do not pin what sorts them. Ids that read like real ones - "m-1", "m-10", "m-2" - collate identically under ordinal and culture rules, so replacing StringComparer.Ordinal with StringComparer.InvariantCulture at any of the three sorting sites left every certification test green. The comparer decides the order of bytes that back a signature, so it has to be a fact rather than an intention, and the composition corpus was new in this branch: extending it costs nothing. Adds a composition corpus case whose structural mutant ids collate differently under the two rules, and the two cases the brick lane needs in CanonicalPayloadEmitterTests - the mutant-id lists, and the input order, whose entries also pin that kind outranks id rather than the other way round. Each of the three sorting sites now fails exactly one of the three new pins when its comparer is changed, measured rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(certification): name only the probe that reads the transition corpus, and the third input-hash producer Two statements this branch introduced do not hold. scripts/portability/net9-probe.sh does not read transition-entry-hashes.golden.json. It restores the packed certification contracts package, which does not carry CertifiedTransitionBuilder, and this branch does not touch the script. The corpus _readme added in the same commit already says so, so the XML doc on CertifiedTransitionEntryHashGoldenTests and the Content comment in the csproj contradicted a file beside them. Both now name scripts/ns20-canonical-bytes-probe.sh alone. The "Still not covered" entry named SessionEnvironmentInputs and BrickOutputSerializer and read as exhaustive. CertificationGate.BuildInputs is a third producer on the same footing: it serializes request.Witness reflectively and hashes the result into the "witness" CertificationInput, on the live minting path, and CertificationInput entries are written inside the signed v2 payload - so a trimmed publish still moves the signed bytes for that reason as well. WitnessRunner.CanonicalizeObservation joins BrickOutputSerializer in the not-signed half of the list. A disclosure that reads as complete while it is not is worse than no list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
IanFrelinger
added a commit
that referenced
this pull request
Sep 12, 2026
…udged (#603) Two more places where reflection-based serialization was fail-OPEN on the certification path. #584 and #589 fixed the signing payload; these are the input-hash and determinism producers beside it, and they degrade the same way. 1. CertificationGate.BuildInputs caught NotSupportedException around JsonSerializer.Serialize(request.Witness, ...), logged a warning, and returned request.AdditionalInputs -- minting the certificate with NO WITNESS INPUT AT ALL. The witness input is the record of WHAT WAS JUDGED. Neither Default nor Strict requires it -- they require gate-emitted-artifact and certifier-identity -- so a witness-less record verifies cleanly downstream and no consumer can tell it binds nothing about the specification. The trigger is not exotic: that exception is what the serializer raises when reflection-based serialization is disabled, which is every trimmed and ahead-of-time publish. So the fail-open was reachable by publish mode alone, with one warning in a log nobody reads at admission time. It now refuses at mint time, carrying the serializer failure as the inner exception so an operator can tell a publish-mode problem from a malformed witness. Refusing at mint is the precedent #584 set: signing propagates, verification refuses without throwing into its host. 2. BrickOutputSerializer.ToCanonicalJson feeds an EQUALITY comparison. The determinism leg runs a brick twice and compares the two strings, so two DIFFERENT outputs that both degrade to {} compare equal -- the leg reports deterministic and the certificate records a check that never ran. An equality comparison is the worst possible consumer of a silently degrading serializer. BrickOutput holds arbitrary object values set by brick code, so there is no closed type set a source generator could cover and reflection cannot simply be removed. Instead the shape is established before the bytes are returned. The invariant is the top-level property count: the values are open-world so nothing about them can be asserted, but the NUMBER of keys is fixed by the dictionary the serializer was handed. Re-read with JsonDocument, which is reflection-free and therefore still works in exactly the configurations where the serializer does not. The count comes from the composed payload, not from the output's dictionary, because a brick whose own output key is "summary" collapses onto the summary slot -- taking it from the dictionary would refuse a legitimate output. That is pinned. Measured in the devtest container: * CanonicalOutputShapeGuardTests 9/9, including a positive control and a case proving two different outputs do not serialize alike; * with EnsureCanonicalShape neutered to return its input, 4 of the 9 fail and the 4 controls still pass; * with the gate's catch restored to the old return, the end-to-end test fails -- it drives CertifyAsync with a witness carrying a delegate, which is what reproduces NotSupportedException in a process where reflection still works; * solution build clean, cert-gate 649/649. Still open, deliberately: SessionEnvironmentInputs serializes SandboxSpec and SessionAttestation by reflection for its sandbox-spec and attestation input hashes. Those are fixed types, so a source-generated context would make them trim-safe without changing the bytes -- a separate change, and one that needs its own golden pin. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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 free
to 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.
The bytes that every certification signature is computed over were produced by reflection-based JSON serialization and pinned by a test for only one of the two payload lanes. This pins both, and makes a payload that is not its declared shape a refusal rather than something to sign.
Why this matters
CertificationRecordSigning.BuildPayloademits two shapes, selected byrecord.SchemaVersion is null: a 13-property v1 (an anonymous type) and a 20-property v2 (a private positional record). Both go through reflection-basedJsonSerializer.Serialize.Reflection-based serialization does not survive trimming or Native AOT. Measured across a full publish matrix (SDK 10.0.400,
TrimMode∈ {partial, full} ×JsonSerializerIsReflectionEnabledByDefault∈ {true, false} × TFM ∈ {net8.0, net10.0}, plusPublishAot):net8.0: every trimmed/AOT configuration throws — loud, and therefore safe.net10.0, partial trim with reflection enabled: correct bytes.net10.0,TrimMode=fullwith reflection enabled: v2 is correct but v1 silently serializes to{}— no exception, no warning, no log.Bytes that back a signature must never be silently empty, and must be identical on every runtime and in every publish mode. Nothing in the repo asserted either property for v2.
What changed
1. A checked-in golden corpus pins both lanes.
canonical-payloads.golden.jsonholds the exact expected bytes;CanonicalPayloadGoldenTestsasserts them onnet8.0andnet10.0, including that the HMAC is computed over exactly those bytes. PreviouslyTrustLoopRecordSchemaTestspinned v1 only — v2, the shape every live minter actually emits, was unpinned.2. A payload that is not its declared shape is refused. The guard sits at the single choke point every consumer already funnels through (HMAC sign and verify, Ed25519 sign and verify). "Degenerate" is defined by shape, not size: the produced payload is re-parsed with
JsonDocument— which is reflection-free, so it still works in exactly the publish configurations that break the serializer that produced it — and its top-level property-name sequence is compared against a per-lane constant (13 for v1, 20 for v2), plus each nested element's sequence.Shape rather than size is deliberate: a legitimate minimal record is not small. With no
JsonIgnoreConditionconfigured, every declared property is always emitted, so a minimal v1 payload measures 256 characters with all 13 keys present. Emptiness would be a safe signal only for{}itself; the property-name set is the value-independent invariant.Signing throws; verification refuses with a failure code rather than throwing.
3.
ci/cert-gate-assertions.mdcorrected. Its merge-blocking table claimed "v1/v2 record bytes are frozen" while its own body said only the legacy payload was pinned. That claim is now true.netstandard2.0 parity — measured, and one divergence found
scripts/ns20-canonical-bytes-probe.shbuilds the shipped netstandard2.0 asset, publishes anet472consumer against it, and runs it undermono:latest.Strings, integers, timestamps, escaping and property order are byte-identical across
net8.0,net10.0and netstandard2.0-on-Mono.Doubles are not. The ns2.0 asset emits legacy 17-digit round-tripping where modern .NET emits shortest-round-trip:
0.3333333333333333vs0.33333333333333331,0.007vs0.0070000000000000001,-0vs0. End-to-end, a v2 record signed on .NET 10 withescapeRate = 1.0/3.0is rejected by the ns2.0 asset on Mono withsignature-invalid.This is latent, not live: the only production minters are
CertificationGateandLoadRefusalRecord, the gate refuses admission wheneverEscapeRate > 0, so every signed PASS record carriesescapeRate0 and every refusal carries null — andAttempts(the only other double) is populated by no production code path. It is fixed here only in the sense that it is now pinned and documented; the formatting fix itself is deliberately not in this PR, because changing it changes bytes and that deserves its own change with a migration story.The probe is a repeatable script, not a CI lane — wiring it in would change required-check topology.
Verification
All in the repo's devtest container; no host
dotnet.net8.0andnet10.0: 25/25 every run.net8.0: 480 passed, 0 failed.BuildPayloadreturn{}each fail the golden and/or guard tests.Adversarial review also caught, and this PR fixes, a guard bug of its own: the shape check enumerated
gatesPassed/inputs/attemptswithout first confirmingValueKind == Array.Deliberately not in this PR
CompositionCertificationRecordSigner.BuildPayloadandCertifiedTransitionBuilder.BuildPayloadroutes onSchemaVersion is not null, soschemaVersion3 orint.MaxValuestill selects the v2 shape and clears aMinimumSchemaVersion = 2floor.proposer.Parametersstill depends onDictionary<string,string>enumeration order after an ordinal sort — an implementation detail, now at least pinned by the golden corpus.🤖 Generated with Claude Code