Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion ci/cert-gate-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
201 changes: 201 additions & 0 deletions scripts/ns20-canonical-bytes-probe.sh
Original file line number Diff line number Diff line change
@@ -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" <<PROJ
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net472</TargetFramework>
<LangVersion>12.0</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>Ns20CanonicalBytesProbe</AssemblyName>
<RootNamespace>Ns20CanonicalBytesProbe</RootNamespace>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="${STJ_VERSION}" />
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Reference Include="Ashlar.Certification.Contracts">
<HintPath>${WORK}/ns20/Ashlar.Certification.Contracts.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- The same shims the netstandard2.0 asset itself compiles against, so a consumer can see
required members and init-only setters instead of failing with CS0656. -->
<Compile Include="${ROOT}/src/Ashlar.Compat/Polyfills/IsExternalInit.cs" Link="Polyfills/IsExternalInit.cs" />
<Compile Include="${ROOT}/src/Ashlar.Compat/Polyfills/RequiredMemberAttribute.cs" Link="Polyfills/RequiredMemberAttribute.cs" />
<Compile Include="${ROOT}/src/Ashlar.Compat/Polyfills/CompilerFeatureRequiredAttribute.cs" Link="Polyfills/CompilerFeatureRequiredAttribute.cs" />
<Compile Include="${ROOT}/src/Ashlar.Compat/Polyfills/SetsRequiredMembersAttribute.cs" Link="Polyfills/SetsRequiredMembersAttribute.cs" />
</ItemGroup>
</Project>
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<CertificationRecordData>(
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
<PackageTags>ashlar;certification;bricks;trust</PackageTags>
</PropertyGroup>

<ItemGroup>
<!-- The canonical-payload shape guard is exercised directly against payloads this process
cannot make the serializer emit; the publish modes that produce them are not
reproducible inside a test host. -->
<InternalsVisibleTo Include="Ashlar.Tests.Infrastructure" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="System.Text.Json" Condition="'$(TargetFramework)' == 'netstandard2.0'" />
<!-- Ed25519 dual-write signing; NSec has no netstandard2.0 target, so those
Expand Down
Loading
Loading