diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4af01f5..124296788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ At release time, move the `[Unreleased]` notes under a new `[X.Y.Z] - YYYY-MM-DD - **Docs** — product-split, operator env-var tables, protocol, licensing, and CI inventory distinguish `AirGapped` from `SecureWorkstation`, drop the `Ashlar.Client` product-consumption overclaim, and document `products-gate` / `DistributedContractTests` ownership. - **Published pin is 0.1.2.** `ci/published-version` matches nuget.org and GHCR `nexo-cli:0.1.2`. Consumer-template pins follow the pin. The v0.1.2 `release.yml` pack-and-publish job timed out on nuget.org index lag after a successful push; visibility/registration polls now default to 40×15s (and raise a shorter repo-var budget) so the next tag does not fail the same way. nuget.org restore samples pin `System.Text.Encodings.Web` **10.0.11** so they do not NU1605 against the 0.1.2 graph. - **`OllamaProvider` no longer loads the model manifest in its constructor**; call `InitializeAsync` (or rely on lazy refresh via `ValidateModel`/`CheckHealth`). `ProviderFactory` warm-up is asynchronous and bounded to 5 s (#567). +- **The canonical certification payload is now pinned byte-for-byte, and a degenerate one is an error rather than an input.** Those bytes are the message every certification signature — HMAC and Ed25519, v1 and v2 — is computed over, so they are the whole basis on which a signature written yesterday still verifies today. They were produced by reflection-based `System.Text.Json` and checked by nothing: `ci/cert-gate-assertions.md` listed "v1/v2 record bytes are frozen" as a merge-blocking assertion while only the v1 lane had a byte pin, and v2 — the shape every live minter emits — had none. **Reflection-based serialization does not survive trimming or ahead-of-time publishing**, and under those publish modes the payload can serialize to the empty object `{}` or lose properties with no exception, no warning, and nothing in the calling code able to tell: it hashes whatever it was handed. Measured across the publish matrix, the same source produced correct bytes, a throw, or `{}` depending only on the target framework and trim settings — which is not a property that can be reasoned about, only asserted. `canonical-payloads.golden.json` now pins both lanes fully populated, both lanes minimal, and an escaping case, and `BuildPayload` refuses to return a payload that is not its lane's exact property-name sequence. Degeneracy is defined as shape rather than size on purpose: no ignore policy is configured, 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 — which makes size useless as a signal and the name sequence a value-independent invariant of the payload type. The check re-parses with `JsonDocument`, which is reflection-free and therefore still works in exactly the configurations that break the serializer, and it catches a reordering as well as an emptying: a reorder loses no information but changes the bytes, which would silently invalidate every signature already written. **Signing propagates the refusal** — a crash at mint time is the correct outcome for bytes nobody can vouch for — while **verification refuses without throwing into its host**: `VerifySignature` returns false and `CertificationTrustVerifier` reports a new `payload-not-canonical` failure code (and `payload-unbuildable` when the record's own JSON cannot produce a payload at all), built before the signature comparison and unconditionally, because a comparison over bytes whose shape has not been established is not a result worth reporting. **Cross-target equality is now measured rather than assumed.** `Ashlar.Certification.Contracts` ships `netstandard2.0`, `net8.0` and `net10.0`, and the ns2.0 target exists for external consumers; a target that serialized these bytes even slightly differently would recompute a different message and refuse certificates the others accept. `scripts/ns20-canonical-bytes-probe.sh` builds the shipped ns2.0 asset, publishes a `net472` consumer against it and executes that under Mono, checking the **same** checked-in corpus the xunit golden test reads — one constant, two readers, because two independently typed copies would drift and the equality claim would evaporate with them. All five payloads are byte-identical on net8.0, net10.0 and the ns2.0 asset under Mono 6.12. The corpus restricts doubles to exactly representable values deliberately: decimal formatting of arbitrary doubles is **not** yet identical across those three targets, and a golden corpus should not claim otherwise. The probe is a script, not a CI leg: netstandard2.0 is not a runtime, so there is nothing to run tests "on", and until a workflow calls it this is a repeatable local measurement rather than merge-blocking coverage. **Not yet covered:** other canonical-bytes producers in this tree are not yet pinned or guarded. ### Fixed diff --git a/ci/cert-gate-assertions.md b/ci/cert-gate-assertions.md index ec60e213b..f0cc298f1 100644 --- a/ci/cert-gate-assertions.md +++ b/ci/cert-gate-assertions.md @@ -27,7 +27,7 @@ away, can see what they would be turning off. | **Composition order cannot decide durability.** `AddCertificationGate` and `AddCertificationInfrastructure` resolve to a durable store in either composition order; a host-supplied signer survives. | `CertificationStoreCompositionTests` | Admissions silently revert to in-memory, and for the CLI — a fresh process per invocation — nothing certified can ever be admitted again. | | **The schema floor refuses a downgraded record.** A forged record with a rewritten gate name verifies at floor 0 and is refused at floor 2. | `SchemaVersionFloorTests` | A record can claim to have passed gates it never ran: the legacy payload lane leaves `Gate`, `GatesPassed`, `Inputs`, `Proposer`, `Attempts` and `Ed25519PublicKey` outside the signed bytes. | | **The dev key is loud.** A signer falling back to the committed HMAC constant warns, and never logs the key itself. | `CertificationRecordSignerDevKeyTests` | Production admissions run on a key anyone with the source can forge, with nothing on the record saying so. | -| **v1/v2 record bytes are frozen.** The legacy payload is byte-pinned. | `TrustLoopRecordSchemaTests` | Every signature ever written becomes unverifiable, silently. | +| **v1/v2 record bytes are frozen.** Both lanes are byte-pinned against a checked-in golden corpus (`canonical-payloads.golden.json`), which `scripts/ns20-canonical-bytes-probe.sh` also checks the netstandard2.0 asset against, and a payload that does not carry its lane’s exact property-name sequence is refused rather than signed. | `TrustLoopRecordSchemaTests`, `CanonicalPayloadGoldenTests`, `CanonicalPayloadGuardTests` | Every signature ever written becomes unverifiable, silently. | | **No eighth unbounded appender.** Every production `File.AppendAllText` / `AppendAllLines` / `AppendText` sits in a frozen allowlist of the seven that exist; a stale allowlist row fails too, so the inventory can only shrink honestly. | `AppendOnlyWriterConventionTests` | An appender on a path that never rotates grows until the disk does not — weeks later, on an unattended node, long after the change that caused it. `CLOSING-PLAN.md` Phase 5 bounds these at the write path; this stops the count going up in the meantime. | | **The write floor holds under every spelling.** A mediated write (self-extend or imported package) that resolves onto a governance or build-integrity path — the project contract, the operator policy, anything under `.ashlar/` `.git/` `.github/` `.vscode/` `.devcontainer/` `scripts/`, any `Directory.Build.*` / `Directory.Packages.props` / `nuget.config` / `global.json` / `Makefile` / `*.csproj` / `*.sln` — is refused for the whole batch, whatever the spelling (`./`, `..`, backslash, case). | `ForgeApplierGovernanceTests` | Governance was checked on the raw target against a first-segment denylist, so `./ashlar.policy.yaml` and `a/../.ashlar/x` slipped through onto the operator policy and gate state, and the floor was three entries wide — an admitted `Directory.Build.targets` ran on the receiver's next `dotnet build`, outside the loader, the gate and the registry. | | **Every mediated writer shares one floor.** Forge apply, package import AND shared-adaptation adopt all route through `MediatedWritePath.Refuse`; a peer's adaptation can no longer escape the repo (`../../x`), write a governance/build path, or run through a symlink, and the whole entry is refused before any file is written. | `MediatedWritePathTests`, `SharedAdaptationGovernanceTests` | `FileBasedSharedAdaptationStore.ValidateAndAdoptAsync` used to `Path.Combine(repoRoot, path)` attacker-controlled files with no containment and no floor, then `dotnet build` them — an RCE the forge floor never covered because it was a second, divergent writer. | diff --git a/scripts/ns20-canonical-bytes-probe.sh b/scripts/ns20-canonical-bytes-probe.sh new file mode 100755 index 000000000..2060fecf7 --- /dev/null +++ b/scripts/ns20-canonical-bytes-probe.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# ns20-canonical-bytes-probe.sh — check that the netstandard2.0 asset of +# Ashlar.Certification.Contracts produces the SAME canonical signing payload bytes as the +# net8.0 and net10.0 assets. +# +# WHY THIS IS A SCRIPT AND NOT AN xunit LEG +# ----------------------------------------- +# netstandard2.0 is not a runtime, so there is nothing to "run the tests on". The only honest +# way to show the ns2.0 asset agrees is to EXECUTE it somewhere that asset is really loaded: +# a .NET Framework consumer. This drives one under Mono, which is the route that works without +# a Windows runner. The bytes matter because they are the message every HMAC and Ed25519 +# certification signature is computed over — a target that serialized them even slightly +# differently would recompute a different message and refuse certificates the other targets +# accept, and the package ships netstandard2.0 specifically for external consumers. +# +# Ed25519 is deliberately out of scope: NSec ships lib/net8.0 only, so the ns2.0 lane is +# HMAC-only by construction (see CertificationRecordEd25519). The payload bytes are the shared +# artifact and the only thing that has to match. +# +# THE CONSTANTS ARE NOT DUPLICATED. This probe reads the same +# src/Ashlar.Tests.Infrastructure/Tests/Certification/canonical-payloads.golden.json that +# CanonicalPayloadGoldenTests reads. Two independently typed copies would drift, and the +# cross-target equality claim would quietly evaporate with them. +# +# Usage: +# scripts/ns20-canonical-bytes-probe.sh # build + run (needs dotnet AND docker) +# scripts/ns20-canonical-bytes-probe.sh --build-only # produce the consumer, no docker +# scripts/ns20-canonical-bytes-probe.sh --run-only # run an already-built consumer +# +# NS20_PROBE_WORKDIR pins the scratch directory, which is what makes the split usable when the +# host that has dotnet and the host that has docker are not the same one. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GOLDEN="${ROOT}/src/Ashlar.Tests.Infrastructure/Tests/Certification/canonical-payloads.golden.json" +MONO_IMAGE="${NS20_PROBE_MONO_IMAGE:-mono:latest}" +STJ_VERSION="${NS20_PROBE_STJ_VERSION:-10.0.11}" + +DO_BUILD=1 +DO_RUN=1 +case "${1:-}" in + --build-only) DO_RUN=0 ;; + --run-only) DO_BUILD=0 ;; + "") ;; + *) echo "ns20-canonical-bytes-probe: unknown argument ${1}" >&2; exit 2 ;; +esac + +WORK="${NS20_PROBE_WORKDIR:-}" +if [[ -z "${WORK}" ]]; then + WORK="$(mktemp -d)" + trap 'rm -rf "${WORK}"' EXIT +fi +mkdir -p "${WORK}" + +if [[ ! -f "${GOLDEN}" ]]; then + echo "ns20-canonical-bytes-probe: golden corpus not found at ${GOLDEN}" >&2 + exit 1 +fi + +if [[ "${DO_BUILD}" == "1" ]]; then + echo "== ns2.0 probe: build the shipped netstandard2.0 asset ==" + rm -rf "${WORK}/ns20" "${WORK}/consumer" "${WORK}/net472" + dotnet build "${ROOT}/src/Ashlar.Certification.Contracts/Ashlar.Certification.Contracts.csproj" \ + -c Release -f netstandard2.0 -o "${WORK}/ns20" --nologo -v minimal + + echo "== ns2.0 probe: author the net472 consumer ==" + mkdir -p "${WORK}/consumer" + # The consumer lives OUTSIDE the repo on purpose: inside it, Directory.Build.props and + # central package management would rewrite its graph, and the point is to model what an + # external consumer of the published package resolves. + cat > "${WORK}/consumer/Ns20CanonicalBytesProbe.csproj" < + + Exe + net472 + 12.0 + disable + Ns20CanonicalBytesProbe + Ns20CanonicalBytesProbe + false + false + + + + + + + + ${WORK}/ns20/Ashlar.Certification.Contracts.dll + + + + + + + + + + +PROJ + + cat > "${WORK}/consumer/Program.cs" <<'PROG' +using System; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ashlar.Certification.Contracts; + +internal static class Ns20CanonicalBytesProbe +{ + private static int Main(string[] args) + { + var path = args.Length > 0 ? args[0] : "canonical-payloads.golden.json"; + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var failures = 0; + var checkedCases = 0; + + Console.WriteLine("runtime: " + RuntimeDescription()); + Console.WriteLine("contracts: " + typeof(CertificationRecordSigning).Assembly.Location); + + using (var document = JsonDocument.Parse(File.ReadAllText(path))) + { + foreach (var element in document.RootElement.GetProperty("cases").EnumerateArray()) + { + var name = element.GetProperty("name").GetString(); + var record = JsonSerializer.Deserialize( + element.GetProperty("record").GetRawText(), options); + var expectedPayload = element.GetProperty("payload").GetString(); + var expectedSha = element.GetProperty("payloadSha256").GetString(); + + var payload = CertificationRecordSigning.BuildPayload(record); + var bytes = Encoding.UTF8.GetBytes(payload); + string sha; + using (var hasher = SHA256.Create()) + { + sha = BitConverter.ToString(hasher.ComputeHash(bytes)).Replace("-", string.Empty); + } + + checkedCases++; + if (sha == expectedSha && payload == expectedPayload) + { + Console.WriteLine(" OK " + name + " " + sha + " " + bytes.Length + " bytes"); + continue; + } + + failures++; + Console.WriteLine(" FAIL " + name); + Console.WriteLine(" expected sha256 " + expectedSha); + Console.WriteLine(" actual sha256 " + sha); + Console.WriteLine(" expected payload " + expectedPayload); + Console.WriteLine(" actual payload " + payload); + } + } + + if (checkedCases == 0) + { + Console.WriteLine("FAIL: the golden corpus is empty; a probe that checks nothing is not a passing probe."); + return 1; + } + + Console.WriteLine(failures == 0 + ? "PASS: " + checkedCases + " canonical payloads are byte-identical on the netstandard2.0 asset." + : "FAIL: " + failures + " of " + checkedCases + " canonical payloads differ on the netstandard2.0 asset."); + return failures == 0 ? 0 : 1; + } + + private static string RuntimeDescription() + { + var monoRuntime = Type.GetType("Mono.Runtime"); + if (monoRuntime == null) + { + return "not Mono (" + Environment.Version + ")"; + } + + var displayName = monoRuntime.GetMethod("GetDisplayName", BindingFlags.NonPublic | BindingFlags.Static); + return "Mono " + (displayName == null ? "(unknown)" : (string)displayName.Invoke(null, null)); + } +} +PROG + + echo "== ns2.0 probe: publish the net472 consumer ==" + dotnet publish "${WORK}/consumer/Ns20CanonicalBytesProbe.csproj" -c Release -o "${WORK}/net472" --nologo -v minimal + cp "${GOLDEN}" "${WORK}/canonical-payloads.golden.json" +fi + +if [[ "${DO_RUN}" == "1" ]]; then + echo "== ns2.0 probe: execute the netstandard2.0 asset under ${MONO_IMAGE} ==" + # Docker on Windows wants a Windows-shaped host path and no MSYS path mangling. + MOUNT="${WORK}" + if command -v cygpath >/dev/null 2>&1; then + MOUNT="$(cygpath -w "${WORK}" | tr '\\' '/')" + fi + MSYS_NO_PATHCONV=1 docker run --rm \ + -v "${MOUNT}:/probe:ro" \ + -w /probe/net472 \ + "${MONO_IMAGE}" \ + mono Ns20CanonicalBytesProbe.exe /probe/canonical-payloads.golden.json +fi diff --git a/src/Ashlar.Certification.Contracts/Ashlar.Certification.Contracts.csproj b/src/Ashlar.Certification.Contracts/Ashlar.Certification.Contracts.csproj index bdcc569ed..de0cc04cf 100644 --- a/src/Ashlar.Certification.Contracts/Ashlar.Certification.Contracts.csproj +++ b/src/Ashlar.Certification.Contracts/Ashlar.Certification.Contracts.csproj @@ -15,6 +15,13 @@ ashlar;certification;bricks;trust + + + + + + + diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/CanonicalPayloadGoldenTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/CanonicalPayloadGoldenTests.cs new file mode 100644 index 000000000..8a026b046 --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/CanonicalPayloadGoldenTests.cs @@ -0,0 +1,132 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ashlar.Certification.Contracts; +using FluentAssertions; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// +/// Byte-level pin on the canonical signing payload for every reachable payload version. +/// +/// These bytes are the message every HMAC and Ed25519 certification signature is computed +/// over, so their stability is the whole basis on which a signature written yesterday still +/// verifies today. They are produced by reflection-based System.Text.Json, whose +/// output depends on the runtime, the serializer version and the publish mode rather than on +/// anything this repository declares — so "the bytes did not change" is an assertion, not a +/// property that can be reasoned about. This suite makes it one. +/// +/// +/// The corpus lives in canonical-payloads.golden.json rather than in this file because +/// scripts/ns20-canonical-bytes-probe.sh reads the same file to check the +/// netstandard2.0 asset against the same constants. Two independently typed copies would +/// drift, and the cross-target equality claim would quietly evaporate with them. +/// +/// +[Trait("Category", "Certification")] +public sealed class CanonicalPayloadGoldenTests +{ + private const string GoldenFileName = "canonical-payloads.golden.json"; + + private static readonly IReadOnlyDictionary Corpus = LoadCorpus(); + + public static TheoryData GoldenCaseNames + { + get + { + var data = new TheoryData(); + foreach (var name in Corpus.Keys.OrderBy(n => n, StringComparer.Ordinal)) + data.Add(name); + return data; + } + } + + [Theory] + [MemberData(nameof(GoldenCaseNames))] + public void CanonicalPayload_IsByteIdenticalToGolden(string caseName) + { + var golden = Corpus[caseName]; + + var payload = CertificationRecordSigning.BuildPayload(golden.Record); + + // The string comparison comes first on purpose: it is the assertion that prints a + // readable diff. The length and digest below turn "readable" into "byte-exact", so a + // change that a string comparison could normalise away still fails. + payload.Should().Be( + golden.Payload, + "the canonical payload for '{0}' backs every signature already written over it; " + + "changing these bytes invalidates them", + caseName); + + var bytes = Encoding.UTF8.GetBytes(payload); + bytes.Length.Should().Be(golden.PayloadByteLength, "the signed message is bytes, not characters"); + Sha256Hex(bytes).Should().Be( + golden.PayloadSha256, + "SHA-256 of the UTF-8 canonical payload for '{0}'; this is the same constant " + + "scripts/ns20-canonical-bytes-probe.sh checks the netstandard2.0 asset against. Actual payload was: {1}", + caseName, + payload); + } + + [Fact] + public void GoldenCorpus_CoversBothPayloadVersions() + { + // A theory over an empty or half-empty corpus passes without asserting anything, which + // is the same failure mode as having no golden test at all. Assert the corpus itself. + Corpus.Should().NotBeEmpty(); + Corpus.Values.Should().Contain(c => c.Record.SchemaVersion == null, "the v1 lane is reachable through the published package API"); + Corpus.Values.Should().Contain(c => c.Record.SchemaVersion != null, "v2 is the shape every live minter emits"); + Corpus.Values.Should().OnlyContain( + c => c.Payload.Length > 2, + "an empty object is never a legitimate canonical payload — every declared property is emitted whatever its value"); + } + + [Fact] + public void Sign_ComputesTheHmacOverExactlyTheGoldenBytes() + { + // Pins the binding between the golden bytes and the signature, without introducing a + // second constant: the expected signature is derived from the golden payload string, + // so this fails if Sign ever hashes anything other than what BuildPayload returned. + const string key = "canonical-payload-golden-test-hmac"; + var golden = Corpus["v2-populated"]; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key)); + var expected = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(golden.Payload))); + + CertificationRecordSigning.Sign(golden.Record, key).Should().Be(expected); + } + + private static string Sha256Hex(byte[] bytes) + { + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(bytes)); + } + + private static IReadOnlyDictionary LoadCorpus() + { + var path = Path.Combine(AppContext.BaseDirectory, "Tests", "Certification", GoldenFileName); + if (!File.Exists(path)) + throw new FileNotFoundException($"Golden canonical payload corpus not found at '{path}'.", path); + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var corpus = new Dictionary(StringComparer.Ordinal); + foreach (var element in document.RootElement.GetProperty("cases").EnumerateArray()) + { + var name = element.GetProperty("name").GetString()!; + corpus[name] = new GoldenCase( + JsonSerializer.Deserialize(element.GetProperty("record").GetRawText(), options)!, + element.GetProperty("payload").GetString()!, + element.GetProperty("payloadSha256").GetString()!, + element.GetProperty("payloadByteLength").GetInt32()); + } + + return corpus; + } + + private sealed record GoldenCase( + CertificationRecordData Record, + string Payload, + string PayloadSha256, + int PayloadByteLength); +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/CanonicalPayloadGuardTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/CanonicalPayloadGuardTests.cs new file mode 100644 index 000000000..dada7e9e6 --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/CanonicalPayloadGuardTests.cs @@ -0,0 +1,252 @@ +using System.Text.Json; +using Ashlar.Certification.Contracts; +using FluentAssertions; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// +/// The canonical payload must never be signed, or accepted, unless it is the shape its lane +/// declares. +/// +/// The payload is produced by reflection-based System.Text.Json. That is not a +/// guarantee: reflection-based serialization does not survive trimming or ahead-of-time +/// publishing, and under those publish modes the payload can come out as the empty object +/// {} — no exception, no warning. Those bytes are the message every signature is +/// computed over, so an empty or short payload does not weaken a signature, it moves the +/// signature onto something that is not the record. +/// +/// +/// The guard is exercised here directly rather than through BuildPayload 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. What the golden corpus in +/// proves alongside it is the other half: that the +/// declared shape is the shape the serializer really emits, so the guard rejects nothing +/// legitimate. +/// +/// +[Trait("Category", "Certification")] +public sealed class CanonicalPayloadGuardTests +{ + private const string MinimalV1Payload = + "{\"status\":\"FAIL\",\"stage\":\"S0\",\"admitted\":false,\"signed\":false," + + "\"timestamp\":\"2026-01-02T03:04:05.0000000Z\",\"brickId\":\"guard-brick\"," + + "\"contentHash\":null,\"escapeRate\":null,\"totalMutants\":null,\"survivingMutants\":null," + + "\"killedMutants\":[],\"survivingMutantIds\":[],\"reason\":null}"; + + private const string MinimalV2Payload = + "{\"schemaVersion\":2,\"status\":\"FAIL\",\"stage\":\"load\",\"admitted\":false,\"signed\":false," + + "\"timestamp\":\"2026-01-02T03:04:05.0000000Z\",\"brickId\":\"guard-brick\"," + + "\"contentHash\":null,\"escapeRate\":null,\"totalMutants\":null,\"survivingMutants\":null," + + "\"killedMutants\":[],\"survivingMutantIds\":[],\"reason\":null,\"gate\":null," + + "\"gatesPassed\":[],\"inputs\":[],\"proposer\":null,\"attempts\":[],\"ed25519PublicKey\":null}"; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EmptyObject_IsRefused(bool versioned) + { + Action act = () => _ = CertificationRecordSigning.EnsureCanonical("{}", versioned); + + act.Should().Throw( + "an empty payload signs nothing about the record it claims to cover") + .WithMessage("*not its declared shape*") + .Which.Message.Should().Contain(versioned ? "v2" : "v1", "the refusal has to say which lane it refused"); + } + + [Theory] + [InlineData("null")] + [InlineData("[]")] + [InlineData("\"\"")] + [InlineData("0")] + public void NonObjectPayload_IsRefused(string payload) + { + Action act = () => _ = CertificationRecordSigning.EnsureCanonical(payload, versioned: true); + + act.Should().Throw(); + } + + [Fact] + public void UnparseablePayload_IsRefused() + { + Action act = () => _ = CertificationRecordSigning.EnsureCanonical("{\"status\":", versioned: false); + + act.Should().Throw().Which.InnerException.Should().BeAssignableTo(); + } + + [Fact] + public void MinimalPayloads_AreAccepted() + { + // The negative signal is the property-NAME set, never size: no ignore policy is + // configured, so a record with nothing but its required members still emits every + // declared property with a null value. A guard that keyed on length or emptiness would + // refuse these, which are entirely legitimate certificates. + CertificationRecordSigning.EnsureCanonical(MinimalV1Payload, versioned: false).Should().Be(MinimalV1Payload); + CertificationRecordSigning.EnsureCanonical(MinimalV2Payload, versioned: true).Should().Be(MinimalV2Payload); + } + + [Fact] + public void DroppedProperty_IsRefused() + { + var truncated = MinimalV1Payload.Replace(",\"reason\":null", string.Empty); + + Action act = () => _ = CertificationRecordSigning.EnsureCanonical(truncated, versioned: false); + + act.Should().Throw("a partially serialized payload covers less of the record than the signature claims") + .Which.Message.Should().Contain("13").And.Contain("reason"); + } + + [Fact] + public void ReorderedProperties_AreRefused() + { + // Reordering keeps every name and every value, so nothing about the record is lost — + // but the bytes change, which silently invalidates every signature already written. + var reordered = MinimalV1Payload + .Replace("{\"status\":\"FAIL\",\"stage\":\"S0\"", "{\"stage\":\"S0\",\"status\":\"FAIL\""); + + Action act = () => _ = CertificationRecordSigning.EnsureCanonical(reordered, versioned: false); + + act.Should().Throw(); + } + + [Fact] + public void ExtraProperty_IsRefused() + { + var extended = MinimalV2Payload.Replace("\"ed25519PublicKey\":null}", "\"ed25519PublicKey\":null,\"extra\":1}"); + + Action act = () => _ = CertificationRecordSigning.EnsureCanonical(extended, versioned: true); + + act.Should().Throw(); + } + + [Fact] + public void WrongLane_IsRefused() + { + // The schema version selects the payload, so a v1 body checked as v2 (or the reverse) + // is exactly the shape mismatch a downgraded or upgraded record would produce. + Action actV1AsV2 = () => _ = CertificationRecordSigning.EnsureCanonical(MinimalV1Payload, versioned: true); + Action actV2AsV1 = () => _ = CertificationRecordSigning.EnsureCanonical(MinimalV2Payload, versioned: false); + + actV1AsV2.Should().Throw(); + actV2AsV1.Should().Throw(); + } + + public static TheoryData DegenerateNestedShapes => new() + { + { "gatesPassed", "\"gatesPassed\":[{}]" }, + { "inputs", "\"inputs\":[{}]" }, + { "proposer", "\"proposer\":{}" }, + { "attempts", "\"attempts\":[{}]" }, + }; + + [Theory] + [MemberData(nameof(DegenerateNestedShapes))] + public void DegenerateNestedShape_IsRefused(string member, string replacement) + { + // The trust-loop evidence lives in these nested shapes, so the top-level name check is + // not sufficient on its own: each nested element's shape has to be established too. + var payload = MinimalV2Payload.Replace(NestedOriginal(member), replacement); + payload.Should().NotBe(MinimalV2Payload, "the fixture must actually have been modified"); + + Action act = () => _ = CertificationRecordSigning.EnsureCanonical(payload, versioned: true); + + act.Should().Throw().Which.Message.Should().Contain(member); + } + + public static TheoryData NonArrayNestedMembers => new() + { + { "gatesPassed", "\"gatesPassed\":null" }, + { "gatesPassed", "\"gatesPassed\":{}" }, + { "inputs", "\"inputs\":\"x\"" }, + { "attempts", "\"attempts\":0" }, + }; + + [Theory] + [MemberData(nameof(NonArrayNestedMembers))] + public void NonArrayNestedMember_IsRefused(string member, string replacement) + { + // A collection member that is not a collection has to be refused in the same terms as one + // whose elements are the wrong shape. Enumerating it instead raises an exception no + // handler on the verification path catches, which would put the refusal contract — and + // the payload-not-canonical failure code — out of reach for exactly these inputs. + var payload = MinimalV2Payload.Replace(NestedOriginal(member), replacement); + payload.Should().NotBe(MinimalV2Payload, "the fixture must actually have been modified"); + + Action act = () => _ = CertificationRecordSigning.EnsureCanonical(payload, versioned: true); + + act.Should().Throw().Which.Message.Should().Contain(member); + } + + [Fact] + public void ProposerParameters_AreValuesRatherThanShape() + { + // Parameter keys are caller-supplied and emitted verbatim, so they are content, not + // structure. Treating them as structure would make the guard refuse legitimate records. + var withParameters = MinimalV2Payload.Replace( + "\"proposer\":null", + "\"proposer\":{\"identity\":\"agent://p\",\"parameters\":{\"Anything\":\"1\",\"at all\":\"2\"},\"seed\":null}"); + var withoutParameters = MinimalV2Payload.Replace( + "\"proposer\":null", + "\"proposer\":{\"identity\":\"agent://p\",\"parameters\":{},\"seed\":null}"); + + CertificationRecordSigning.EnsureCanonical(withParameters, versioned: true).Should().Be(withParameters); + CertificationRecordSigning.EnsureCanonical(withoutParameters, versioned: true).Should().Be(withoutParameters); + } + + [Fact] + public void GuardedPayload_IsReturnedUnchanged() + { + // Byte-neutrality on the legitimate path, asserted where the guard runs. The golden + // corpus asserts the same thing against every pinned record. + var record = new CertificationRecordData + { + Status = "PASS", + Stage = "S0-S2", + Admitted = true, + Signed = true, + Timestamp = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), + BrickId = "guard-brick", + SchemaVersion = CertificationRecordData.TrustLoopSchemaVersion, + }; + + var payload = CertificationRecordSigning.BuildPayload(record); + + CertificationRecordSigning.EnsureCanonical(payload, versioned: true).Should().BeSameAs(payload); + } + + [Fact] + public void RecordThatCannotProduceAPayload_IsRefusedRatherThanThrown() + { + // The shape guard is not the only way building the bytes can fail. A record's collections + // come from its own JSON and the deserializer does not enforce the non-null annotations on + // their elements, so BuildPayload can fail before the guard sees any bytes at all. That is + // still a record whose payload could not be established, and the verifier owes its caller + // that answer rather than an exception raised in the caller's host. + var record = new CertificationRecordData + { + Status = "PASS", + Stage = "S0-S2", + Admitted = true, + Signed = true, + Timestamp = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), + BrickId = "guard-brick", + ContentHash = "hash-abc", + SchemaVersion = CertificationRecordData.TrustLoopSchemaVersion, + GatesPassed = new CertificationGatePass[] { null! }, + }; + + var trust = CertificationTrustVerifier.Verify(record, "class GuardProbe { }"); + + trust.Trusted.Should().BeFalse(); + trust.FailureCode.Should().Be("payload-unbuildable"); + } + + private static string NestedOriginal(string member) => member switch + { + "gatesPassed" => "\"gatesPassed\":[]", + "inputs" => "\"inputs\":[]", + "proposer" => "\"proposer\":null", + "attempts" => "\"attempts\":[]", + _ => throw new ArgumentOutOfRangeException(nameof(member), member, "unknown nested member"), + }; +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/canonical-payloads.golden.json b/src/Ashlar.Tests.Infrastructure/Tests/Certification/canonical-payloads.golden.json new file mode 100644 index 000000000..30ad26723 --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/canonical-payloads.golden.json @@ -0,0 +1,183 @@ +{ + "_readme": "Golden canonical signing payloads. These bytes back every certification signature, so they are pinned here and read by BOTH the in-repo golden test and the netstandard2.0 parity probe (scripts/ns20-canonical-bytes-probe.sh) - one constant, two readers. To regenerate a case deliberately, copy the actual payload the failing test prints into this file. Regenerate ONLY when the canonical form is deliberately changed, and understand that changing it invalidates every signature already written. Doubles are deliberately restricted to exactly representable values: decimal formatting of arbitrary doubles is not yet identical across the targets this package ships for.", + "cases": [ + { + "name": "v1-legacy-populated", + "why": "Every v1 field carrying a non-default value, plus the fields the v1 lane must structurally exclude (signature, ed25519Signature, ed25519PublicKey, gate). Mutant id lists are supplied out of order to pin the ordinal sort, and the reason string pins the escaping of quote, angle brackets, ampersand, apostrophe, plus and non-ASCII.", + "record": { + "status": "PASS", + "stage": "S0-S2", + "admitted": true, + "signed": true, + "timestamp": "2026-01-02T03:04:05.1234567\u002B00:00", + "brickId": "golden-legacy-brick", + "contentHash": "3f786850e387550fdab836ed7e6dc881de23001b", + "escapeRate": 0.5, + "totalMutants": 7, + "survivingMutants": 2, + "killedMutants": [ + "m-2", + "m-10", + "m-1" + ], + "survivingMutantIds": [ + "s-2", + "s-1" + ], + "reason": "golden fixture \u2014 quote \u0022 angle \u003C \u0026 \u003E apostrophe \u0027 plus \u002B caf\u00E9", + "signature": "SIGNATURE-MUST-NOT-APPEAR", + "gate": "Ashlar.Infrastructure.Certification.CertificationGate", + "ed25519Signature": "ED25519-SIGNATURE-MUST-NOT-APPEAR", + "ed25519PublicKey": "3q2\u002B7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "payloadSha256": "E8ED6042BAAD811A5F76F3980809E0091F1C8A30FD613C2E7B300D197EFA4E57", + "payloadByteLength": 435, + "payload": "{\u0022status\u0022:\u0022PASS\u0022,\u0022stage\u0022:\u0022S0-S2\u0022,\u0022admitted\u0022:true,\u0022signed\u0022:true,\u0022timestamp\u0022:\u00222026-01-02T03:04:05.1234567Z\u0022,\u0022brickId\u0022:\u0022golden-legacy-brick\u0022,\u0022contentHash\u0022:\u00223f786850e387550fdab836ed7e6dc881de23001b\u0022,\u0022escapeRate\u0022:0.5,\u0022totalMutants\u0022:7,\u0022survivingMutants\u0022:2,\u0022killedMutants\u0022:[\u0022m-1\u0022,\u0022m-10\u0022,\u0022m-2\u0022],\u0022survivingMutantIds\u0022:[\u0022s-1\u0022,\u0022s-2\u0022],\u0022reason\u0022:\u0022golden fixture \\u2014 quote \\u0022 angle \\u003C \\u0026 \\u003E apostrophe \\u0027 plus \\u002B caf\\u00E9\u0022}" + }, + { + "name": "v1-legacy-minimal", + "why": "Only the required members. Pins that the v1 payload still carries all 13 names with null values - a small payload is legitimate, an empty one is not.", + "record": { + "status": "FAIL", + "stage": "S0", + "admitted": false, + "signed": false, + "timestamp": "2026-01-02T03:04:05\u002B00:00", + "brickId": "golden-minimal-brick" + }, + "payloadSha256": "BC038A904CA99F1CC289FA90173F010415FD34AC5B53CC817B5428F687A45CD6", + "payloadByteLength": 276, + "payload": "{\u0022status\u0022:\u0022FAIL\u0022,\u0022stage\u0022:\u0022S0\u0022,\u0022admitted\u0022:false,\u0022signed\u0022:false,\u0022timestamp\u0022:\u00222026-01-02T03:04:05.0000000Z\u0022,\u0022brickId\u0022:\u0022golden-minimal-brick\u0022,\u0022contentHash\u0022:null,\u0022escapeRate\u0022:null,\u0022totalMutants\u0022:null,\u0022survivingMutants\u0022:null,\u0022killedMutants\u0022:[],\u0022survivingMutantIds\u0022:[],\u0022reason\u0022:null}" + }, + { + "name": "v2-populated", + "why": "Every v2 field and every nested shape. gatesPassed and attempts are supplied in caller order (semantic, preserved); inputs and mutant ids are supplied out of order to pin the ordinal sort; proposer parameters are supplied out of order with mixed case to pin the ordinal key sort and the fact that dictionary keys are emitted verbatim rather than camel-cased.", + "record": { + "schemaVersion": 2, + "status": "PASS", + "stage": "S0-S2", + "admitted": true, + "signed": true, + "timestamp": "2026-08-12T10:00:00\u002B00:00", + "brickId": "golden-trust-loop-brick", + "contentHash": "3f786850e387550fdab836ed7e6dc881de23001b", + "escapeRate": 0, + "totalMutants": 4, + "survivingMutants": 0, + "killedMutants": [ + "m-2", + "m-10", + "m-1" + ], + "survivingMutantIds": [], + "reason": null, + "gate": "Ashlar.Infrastructure.Certification.CertificationGate", + "gatesPassed": [ + { + "name": "analyzer-gate", + "version": "1", + "configuration": "severityFloor=Warning" + }, + { + "name": "correctness-witness", + "version": null, + "configuration": null + }, + { + "name": "mutation-gate", + "version": "1", + "configuration": "escapeRateThreshold=0" + } + ], + "inputs": [ + { + "kind": "witness", + "id": "golden-trust-loop-brick", + "hash": "witness-hash" + }, + { + "kind": "context", + "id": "b-context", + "hash": "context-hash-b" + }, + { + "kind": "context", + "id": "a-context", + "hash": "context-hash-a" + } + ], + "proposer": { + "identity": "agent://proposer-1", + "parameters": { + "temperature": "0", + "IterationLimit": "3", + "beta": "x" + }, + "seed": "42" + }, + "attempts": [ + { + "index": 1, + "outcome": "fail", + "failureCategory": "mutation", + "durationSeconds": 0.5 + }, + { + "index": 2, + "outcome": "pass", + "failureCategory": null, + "durationSeconds": 0.25 + } + ], + "signature": "SIGNATURE-MUST-NOT-APPEAR", + "ed25519Signature": "ED25519-SIGNATURE-MUST-NOT-APPEAR", + "ed25519PublicKey": "3q2\u002B7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "payloadSha256": "638182E94741ED1F905ABD694A049387AACC5A3E7C8C6CDD915F795720CEB14A", + "payloadByteLength": 1213, + "payload": "{\u0022schemaVersion\u0022:2,\u0022status\u0022:\u0022PASS\u0022,\u0022stage\u0022:\u0022S0-S2\u0022,\u0022admitted\u0022:true,\u0022signed\u0022:true,\u0022timestamp\u0022:\u00222026-08-12T10:00:00.0000000Z\u0022,\u0022brickId\u0022:\u0022golden-trust-loop-brick\u0022,\u0022contentHash\u0022:\u00223f786850e387550fdab836ed7e6dc881de23001b\u0022,\u0022escapeRate\u0022:0,\u0022totalMutants\u0022:4,\u0022survivingMutants\u0022:0,\u0022killedMutants\u0022:[\u0022m-1\u0022,\u0022m-10\u0022,\u0022m-2\u0022],\u0022survivingMutantIds\u0022:[],\u0022reason\u0022:null,\u0022gate\u0022:\u0022Ashlar.Infrastructure.Certification.CertificationGate\u0022,\u0022gatesPassed\u0022:[{\u0022name\u0022:\u0022analyzer-gate\u0022,\u0022version\u0022:\u00221\u0022,\u0022configuration\u0022:\u0022severityFloor=Warning\u0022},{\u0022name\u0022:\u0022correctness-witness\u0022,\u0022version\u0022:null,\u0022configuration\u0022:null},{\u0022name\u0022:\u0022mutation-gate\u0022,\u0022version\u0022:\u00221\u0022,\u0022configuration\u0022:\u0022escapeRateThreshold=0\u0022}],\u0022inputs\u0022:[{\u0022kind\u0022:\u0022context\u0022,\u0022id\u0022:\u0022a-context\u0022,\u0022hash\u0022:\u0022context-hash-a\u0022},{\u0022kind\u0022:\u0022context\u0022,\u0022id\u0022:\u0022b-context\u0022,\u0022hash\u0022:\u0022context-hash-b\u0022},{\u0022kind\u0022:\u0022witness\u0022,\u0022id\u0022:\u0022golden-trust-loop-brick\u0022,\u0022hash\u0022:\u0022witness-hash\u0022}],\u0022proposer\u0022:{\u0022identity\u0022:\u0022agent://proposer-1\u0022,\u0022parameters\u0022:{\u0022IterationLimit\u0022:\u00223\u0022,\u0022beta\u0022:\u0022x\u0022,\u0022temperature\u0022:\u00220\u0022},\u0022seed\u0022:\u002242\u0022},\u0022attempts\u0022:[{\u0022index\u0022:1,\u0022outcome\u0022:\u0022fail\u0022,\u0022failureCategory\u0022:\u0022mutation\u0022,\u0022durationSeconds\u0022:0.5},{\u0022index\u0022:2,\u0022outcome\u0022:\u0022pass\u0022,\u0022failureCategory\u0022:null,\u0022durationSeconds\u0022:0.25}],\u0022ed25519PublicKey\u0022:\u00223q2\\u002B7wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\u0022}" + }, + { + "name": "v2-minimal", + "why": "The load-refusal shape: required members plus a schema version. Pins that the v2 payload still carries all 20 names.", + "record": { + "schemaVersion": 2, + "status": "FAIL", + "stage": "load", + "admitted": false, + "signed": false, + "timestamp": "2026-08-12T10:00:00\u002B00:00", + "brickId": "golden-refusal-brick" + }, + "payloadSha256": "92BEC029516E276311F0986BEB538BC4E90D48434CCA6860D8DC25ED8AB64723", + "payloadByteLength": 391, + "payload": "{\u0022schemaVersion\u0022:2,\u0022status\u0022:\u0022FAIL\u0022,\u0022stage\u0022:\u0022load\u0022,\u0022admitted\u0022:false,\u0022signed\u0022:false,\u0022timestamp\u0022:\u00222026-08-12T10:00:00.0000000Z\u0022,\u0022brickId\u0022:\u0022golden-refusal-brick\u0022,\u0022contentHash\u0022:null,\u0022escapeRate\u0022:null,\u0022totalMutants\u0022:null,\u0022survivingMutants\u0022:null,\u0022killedMutants\u0022:[],\u0022survivingMutantIds\u0022:[],\u0022reason\u0022:null,\u0022gate\u0022:null,\u0022gatesPassed\u0022:[],\u0022inputs\u0022:[],\u0022proposer\u0022:null,\u0022attempts\u0022:[],\u0022ed25519PublicKey\u0022:null}" + }, + { + "name": "v2-escaping", + "why": "Non-ASCII, a control character, a surrogate pair, a tab, and every character the default JavaScript encoder escapes - in brickId and reason. Pins the encoder and its uppercase hex.", + "record": { + "schemaVersion": 2, + "status": "PASS", + "stage": "S0-S2", + "admitted": true, + "signed": true, + "timestamp": "2026-08-12T10:00:00\u002B00:00", + "brickId": "caf\u00E9-brick", + "contentHash": "3f786850e387550fdab836ed7e6dc881de23001b", + "escapeRate": 0, + "totalMutants": 1, + "survivingMutants": 0, + "killedMutants": [ + "m-1" + ], + "survivingMutantIds": [], + "reason": "caf\u00E9 \u003Cscript\u003E \u0026 \u0027quote\u0022 \u002B \u2014 \u0001 \uD83D\uDE00 tab\there", + "gate": "Ashlar.Infrastructure.Certification.CertificationGate" + }, + "payloadSha256": "2E496D625B56E5B62353A6DCDE4A2BBFFEF902CA3F2BC7CE5C8243AA97C5B6C9", + "payloadByteLength": 565, + "payload": "{\u0022schemaVersion\u0022:2,\u0022status\u0022:\u0022PASS\u0022,\u0022stage\u0022:\u0022S0-S2\u0022,\u0022admitted\u0022:true,\u0022signed\u0022:true,\u0022timestamp\u0022:\u00222026-08-12T10:00:00.0000000Z\u0022,\u0022brickId\u0022:\u0022caf\\u00E9-brick\u0022,\u0022contentHash\u0022:\u00223f786850e387550fdab836ed7e6dc881de23001b\u0022,\u0022escapeRate\u0022:0,\u0022totalMutants\u0022:1,\u0022survivingMutants\u0022:0,\u0022killedMutants\u0022:[\u0022m-1\u0022],\u0022survivingMutantIds\u0022:[],\u0022reason\u0022:\u0022caf\\u00E9 \\u003Cscript\\u003E \\u0026 \\u0027quote\\u0022 \\u002B \\u2014 \\u0001 \\uD83D\\uDE00 tab\\there\u0022,\u0022gate\u0022:\u0022Ashlar.Infrastructure.Certification.CertificationGate\u0022,\u0022gatesPassed\u0022:[],\u0022inputs\u0022:[],\u0022proposer\u0022:null,\u0022attempts\u0022:[],\u0022ed25519PublicKey\u0022:null}" + } + ] +}