Skip to content

feat(certification): give an operator a way to turn Ed25519 pinning on - #661

Merged
IanFrelinger merged 3 commits into
masterfrom
feat/pinning-config-path
Sep 28, 2026
Merged

IanFrelinger merged 3 commits into
masterfrom
feat/pinning-config-path

Conversation

@IanFrelinger

@IanFrelinger IanFrelinger commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

An operator can now turn Ed25519 pinning on. Nothing could before.

CertificationVerifyOptions.TrustedEd25519PublicKeys has always been { get; init; }, so it was
always settable. The defect was that nothing in non-test code ever assigned it, so
PinningEnabled was false in every production path and verification ran against the key the record
itself carries — which the options file, in its own words, says is close to worthless. An attacker's
self-consistent record verified exactly as the operator's would.

CertificationTrustPolicy reads ASHLAR_CERT_TRUSTED_ED25519_KEYS (Base64 raw public keys, from
any configuration source, not only the environment) and hands the resulting
CertificationVerifyOptions to every production verifier. ASHLAR_CERT_PINNING_REQUIRED makes
"pinning is in effect" a checkable startup assertion rather than a hope.

Strict as a static preset is unchanged, and with nothing configured Ambient.Strict is
reference-identical to it — so an unconfigured host behaves exactly as today.

Three defects found while building it, each worse than the feature's absence

A fail-open in the one switch whose job is to make pinning checkable. ParsePinningRequired read a
present-but-empty value as "pinning not required", while the type's own doc comment promised the
opposite and the sibling keys variable threw on identical input. A deployment template rendering
ASHLAR_CERT_PINNING_REQUIRED= unsubstituted would start a host unpinned while the operator believed
they had demanded pinning. Blank now falls through to the refusal; configured is null still separates
absent from blank.

SameKeys compared counts and only one direction, so a basis of {A, A} against a configuration of
{A, B} reported "same set", raised no conflict, and Apply returned options accepting B — the
exact widening Apply's own summary promises never happens.

Validation by bytes, enforcement by string. Validate decoded a key only to check its length and
then stored the operator's raw text, while both verification tiers compare ordinally against
Convert.ToBase64String output. A non-canonical encoding of a trusted key therefore would not match,
and two encodings of one key counted as two keys. Keys are now canonicalised on store.

And two tests that could not fail

The operator-facing variable names were pinned by nothing. Every fact wrote and read through
TrustedKeysVariable itself, so arrange and act shared one symbol: renaming that constant's value
reddened zero of 26 cases, while every operator following docs/Configuration.md would be setting a
variable nothing reads — pinning silently off. The names are now pinned to their literals and to the
document that tells an operator to set them.

Apply's field copy was unfalsifiable in the dangerous direction. Hardcoding three of its fields so
it ignored its argument left the suite green, because the only fact applied Strict as the basis and
compared back to Strict. A configured policy's Default and Apply(Legacy) are now asserted.

How much of the wiring is itself verified — stated, not implied

Seven production verifiers apply the policy and they are not equally covered.

sites covered by
5 of 7 file record store, brick registry, hot-swap verify-at-load, composition constituent check, self-extend admission edge a behavioural fact that reddens when the call site reverts to the bare preset
2 of 7 StateLogVerifier's 5-arg overload, the adaptation DI factory nothing behavioural — both resolve process-wide Ambient directly, and with nothing configured that IS the preset, so reverting either is invisible to any test

That gap is not hypothetical: five of these seven were in that state when pinning was first wired in.
PinnedWiringSiteConventionTests now asserts on shipped IL (via Mono.Cecil, already a dependency —
this branch adds no package) that all seven still call CertificationTrustPolicy.Ambient, so deleting
any wiring reddens. It does not prove the pinned set is enforced at the two uncovered sites, and
both lines carry a comment saying exactly that, with the reason: Ambient is a Lazy resolved once per
process, so a test cannot vary it without pinning the whole run. Giving those two a policy parameter is
the fix and it is a public API change on a packable library — flagged rather than taken.

Verification

Linux devtest container, never the host:

  • cert-gate: 1248/1248 passed, 0 warnings.
  • Full Ashlar.sln build succeeded.
  • Every fact mutation-checked, and the mutations were chosen adversarially: two independent reviewers
    ran their own, deliberately including the ones the author had not, and re-confirmed 8 of 8
    blocking findings resolved in the committed code rather than from the author's report.

Decisions this leaves open — deliberately

  1. This is a third location for trust material, and the type ships in a IsPackable=true library.
    SPEC-006 §3 designates ~/.ashlar/keys/trusted/ for trust material, and the enumeration hazard
    documented there (rotation re-authorizes a stolen key) is why configuration was chosen instead.

    An earlier draft of this section claimed an external consumer of Ashlar.Certification.Contracts
    "has its verification decisions influenced by ambient process environment it never opted into."
    That was measured and is not true, so it is corrected here rather than left to be acted on:
    nothing inside the packable assembly consults Ambient implicitly — grep over the whole project
    finds only its own definition (CertificationTrustPolicy.cs:87,107) — and
    CertificationTrustVerifier falls back to CertificationVerifyOptions.Default, the static preset,
    when options is null. Reaching ambient behaviour requires naming CertificationTrustPolicy.Ambient.
    Every ambient read is in Ashlar.Infrastructure's wiring, i.e. Ashlar's own hosts, which is exactly
    where an operator's configuration should apply. No breaking API change is needed.

    What IS worth guaranteeing is that this stays true, because it is the kind of property that
    regresses silently: a later convenience fallback of options ?? Ambient.Strict inside the packable
    library would make every consumer environment-dependent without anyone deciding to. A follow-up adds
    an IL convention test asserting no type in the packable assembly calls get_Ambient, in the same
    shape as PinnedWiringSiteConventionTests.

  2. Nothing in this repository or CI sets either variable, so this ships a capability, not a
    posture
    , and the limitation row is downgraded from an open hole to a configurable one — not closed.
    Public keys are safe to publish. Recommendation: set them in a CI lane.

  3. Whether CertificationTrustPolicy should read operator.pub by default, which would make pinning
    on-by-default for any host that has run ashlar keys init. A posture change, not taken.

  4. Whether StateLogVerifier's 5-arg overload should refuse rather than throw on unusable trust
    configuration. A taste call on an overload with no production callers (verified by grep over
    src/ application/ tools/ products/ samples/). Neither convention was changed.

🤖 Generated with Claude Code

t and others added 3 commits September 28, 2026 11:47
TrustedEd25519PublicKeys was always settable and PinningEnabled always read
it, but nothing outside tests ever assigned it. Every production verifier
therefore ran unpinned and checked each signature against the public key the
RECORD carries -- which a record signed with an attacker's own keypair
satisfies. The options file says so in its own words.

CertificationTrustPolicy is the missing assignment: it reads a set of trusted
PUBLIC keys (ASHLAR_CERT_TRUSTED_ED25519_KEYS, or any configuration reader)
and hands verifiers Strict with that set applied. The file-backed record
store, the certified brick registry, the composition constituent checker, the
hot-swap host, the self-extend admission edge, the adaptation wiring and the
state-log verifier all take it; with nothing configured each of them resolves
to the Strict preset instance itself, so an unconfigured host is unchanged.

No private key is configurable here. The minting key stays with the certifier
(SPEC-006); publishing the public half is what pinning is for.

Configuration fails closed and loud. Absent is not malformed: absent leaves
the preset alone, but a value that is present and unusable -- naming no key,
not Base64, not 32 bytes -- throws at resolution rather than resolving to an
EMPTY set, because an empty set reads as pinning OFF at every call site that
tests PinningEnabled. ASHLAR_CERT_PINNING_REQUIRED lets an operator assert
that pinning is in effect, and an unrecognized value there is refused rather
than read as false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…npinned

ParsePinningRequired read a present-but-EMPTY ASHLAR_CERT_PINNING_REQUIRED as
"pinning not required", which is the one failure that variable exists to catch.
A deployment template rendering `ASHLAR_CERT_PINNING_REQUIRED=` with nothing
substituted into it — a k8s configMap empty value, `export VAR=$UNSET`, a YAML
empty string, an IConfiguration key present with no value — therefore started a
host UNPINNED while the operator believed they had demanded pinning, with no
error anywhere. The type's own XML doc and the exception message both promised
the opposite, and the sibling keys variable throws for the identical input. Blank
now falls through to the throw; `configured is null` already distinguishes absent
("no assertion made") from blank ("an assertion that arrived empty").

Three further things that were documented and not true:

- Apply's summary promises configuration will not WIDEN a pinning set chosen in
  code. SameKeys compared counts and then checked one direction only, so basis
  {A, A} against configuration {A, B} reported "same set", raised no conflict, and
  returned options accepting B. It is set equality on de-duplicated sides now.
- Validation decoded to check the length and then stored the operator's STRING,
  while enforcement compares ordinally against Convert.ToBase64String of the raw
  key. A 32-byte key has four valid 44-character encodings, so an operator could
  configure a value that decodes to exactly the right key and match no record at
  all — fail-closed, but a host refusing every honest record while reporting
  itself correctly pinned. The stored set is canonical now.
- Validate SKIPPED a blank entry, two lines above a message refusing "a pinning
  set that dropped an entry". FromTrustedKeys passes a caller's bundle straight
  in, so a bundle with a blank line silently yielded a narrower set. It refuses.

And the tests that were not testing the thing. The operator-facing contract was
tautological: every fact reached FromConfiguration through a helper keyed on
TrustedKeysVariable itself, so arrange and act read the same symbol and neither
Ambient nor FromEnvironment — the only paths any production consumer uses — was
executed at all. One fact now sets the REAL variables and calls FromEnvironment
(not Ambient, which caches per process; it resolves Ambient first so no other
collection can resolve it inside the write window). FromTrustedKeys had no test.
Apply's hand-copy was unfalsifiable in the dangerous direction: every fact applied
Strict and compared back to Strict, so hardcoding Strict's three values inside
Apply stayed green while every pinned host asking for Default or Legacy would
silently have been handed Strict; a configured policy's Default and Apply(Legacy)
are now checked against their own presets. Five of the seven wirings were
unfalsifiable at all, because with nothing configured Ambient.Strict IS the Strict
instance; CertifiedBrickHotSwapHost (verify-at-load, the path a hot-swap attacker
reaches) and CertifiedBrickRegistry now have facts that redden if either reverts
to the bare preset. CompositionConstituentChecker, StateLogVerifier and the
adaptation DI factory are still unverified, and still invisible to any mutation.

Docs: ASHLAR_CERT_TRUSTED_ED25519_KEYS and ASHLAR_CERT_PINNING_REQUIRED get rows
in Configuration.md, and certification-evidence.md stops saying pinning "needs the
trust root that SPEC-006 §3 defers". It also now says what remains true: nothing
in this repository or in CI sets either variable, so every deployment that has not
configured one still verifies unpinned, and the packable contracts library takes
trust input from ambient process environment its consumers did not opt into.

VERIFIED in the devtest container. Filtered to the two touched classes
(net8.0, Ashlar.Tests.Infrastructure): baseline before the change Failed 0,
Passed 15, Total 15; after Failed 0, Passed 26, Total 26. Full cert-gate filter
on the committed tree: Failed 0, Passed 1234, Skipped 0, Total 1234. The packable
library builds clean on all three TFMs (netstandard2.0, net8.0, net10.0) under
TreatWarningsAsErrors. Mutations, in two disjoint groups:
  - restore `value.Length == 0` + FromEnvironment -> FromConfiguration(_ => null)
    + FromTrustedKeys -> Unpinned: Failed 5, Passed 21. The three new blank
    switch cases reddened and "treu" did not; the environment fact and the bundle
    fact reddened; nothing else moved.
  - Apply ignores its basis for all three copied values + hot-swap host reverted
    to the bare preset + old SameKeys + store the raw candidate: Failed 4,
    Passed 22 — one failure per fact, and Pinning_adds_to_strictness_and_relaxes
    _nothing stayed green under the Apply mutation, which is the whole point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…leaving wirings silently unverified

Four things the tail review asked for.

1. A regression the SameKeys rewrite introduced. `new HashSet<string>(left,
   StringComparer.Ordinal)` is not something a security type should be resting on
   when `left` — a caller-supplied basis — can contain a null entry; the loop it
   replaced tolerated one. `Apply` now refuses a null or whitespace basis entry
   with this type's own named refusal, before the set is built, for the same
   reason `Validate` refuses a blank configured entry.

2. The operator-facing variable NAMES were pinned by nothing. Every fact wrote
   and read through `TrustedKeysVariable` itself, so changing that constant's
   VALUE reddened zero of 26 cases while every operator following
   docs/Configuration.md would have set a variable nothing reads — pinning
   silently off. The names are now pinned to their literals AND to the document
   that tells an operator to set them.

3. Three of seven wiring sites were unfalsifiable, because with nothing
   configured `Ambient.Strict` is reference-identical to the `Strict` preset.
   `CompositionConstituentChecker` has the same policy seam the registry and the
   hot-swap host have, so it takes a behavioural fact. `StateLogVerifier`'s
   five-argument overload and the adaptation DI factory have no seam, and
   creating one is a public API change on a packable library — so each carries a
   comment at the line saying it is unverified and why, rather than letting a
   green gate imply coverage. `PinnedWiringSiteConventionTests` asserts on
   shipped IL that all seven still call `CertificationTrustPolicy.Ambient`, so
   deleting any of the wiring reddens even where enforcement cannot be measured.

4. certification-evidence.md records which of the seven are behaviourally
   covered and which are carried by the IL guard alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@IanFrelinger
IanFrelinger force-pushed the feat/pinning-config-path branch from 3a819b3 to 65fdeab Compare September 28, 2026 15:47
@IanFrelinger
IanFrelinger merged commit 615728b into master Sep 28, 2026
30 checks passed
@IanFrelinger
IanFrelinger deleted the feat/pinning-config-path branch September 28, 2026 16:19
IanFrelinger pushed a commit that referenced this pull request Sep 30, 2026
…and gate it

The native readiness lanes run `ashlar ci verify`, which runs `ashlar validate`, which builds
and runs every test project its recursive sweep discovers (22 on this tree). Those lanes are
diff-conditional: they run only when a PR touches a glob in READINESS_PATHS. The transitive
ProjectReference closure of the 22 suites is 70 projects, and 37 of their directories matched
no glob. So editing src/Ashlar.Mcp.Server (built into six suites) skipped every lane that runs
them, while editing their tests re-ran them. #684 closed this for the kernel suite alone. The
same hole existed one level out: src/Ashlar.Compat is compiled into most of the closure through
src/Directory.Build.props and Directory.Build.targets, three nested Directory.Build.props and the
root .editorconfig are read implicitly, and the CLI embeds samples/templates/brick while
Tests.Infrastructure copies samples/physical-atom-cert. None of those re-ran readiness either.

What this does:
- Adds those 44 entries to BOTH readiness lists (on.push.paths and READINESS_PATHS), at the same
  positions and in the same order: the 37 project directories as dir/** after the kernel test
  entry, and the 7 implicit and cross-directory inputs after the shared root build inputs.
- Adds ReadinessClosureConventionTests (cert-gate). It calls validate's own discovery predicate
  (under a virtual root, because the predicate also reads the absolute path for "test"), walks
  ProjectReference with an XML parser so attribute order cannot hide an edge, follows
  analyzer-only (OutputItemType=Analyzer, ReferenceOutputAssembly=false) and conditional
  references on purpose, and fails naming the uncovered path, what names it, the list and a
  suite that needs it.
- Reads references from every shared MSBuild file a closure project is evaluated from, not only
  from the project file: the implicit Directory.Build.props/.targets and Directory.Packages.props
  above it, and any file it reaches by an explicit <Import>, transitively. A ProjectReference
  declared in src/Directory.Build.props is a real edge of every src/ project, and a first version
  of this test dropped it silently: a verifier added a Condition'd reference to src/Ashlar.Lite
  there for the kernel suite and the gate stayed green. Such a reference is now an edge of every
  closure project that reads the file (conditions are not evaluated, the same over-approximation
  as for conditional references). A reference, or an item path with a directory, in a shared file
  that is not anchored by $(MSBuildThisFileDirectory) is refused, because MSBuild resolves it
  against each importing project and it names no single file; so is a computed or wildcard
  Import.
- Also checks every implicit Directory.Build.*/Directory.Packages.props/.editorconfig/
  .globalconfig/nuget.config/global.json above a closure project, and every path outside the
  project's own directory that a closure project or one of those shared MSBuild files names: in
  the Include of an item of ANY type except the identity types (PackageReference, PackageVersion,
  FrameworkReference, InternalsVisibleTo, Using and the like, and ProjectReference, which the
  walk covers) and a None item that is not copied, in any item's HintPath, or in an Import.
  The item types are a denylist on purpose. A second version scanned an allowlist of six types
  (Compile, EmbeddedResource, Content, AdditionalFiles, Analyzer, None), and a verifier showed
  the item type alone decided the verdict: a cross-directory <Protobuf> item, a Reference
  HintPath and an EditorConfigFiles item in src/Ashlar.Transport.Grpc (which already compiles a
  Protobuf item) left it green, while a Compile item at the same spot turned it red. Moving that
  project's .proto to a shared protos/ directory would have kept cert-gate green while later
  edits to the contract skipped the lanes that build its suites. Now a type nobody listed is read
  as naming files, and a computed path on one fails instead of being skipped. A bare file name in
  a shared file names a file in each importer's own directory, which that project's glob covers;
  a path under $(NuGetPackageRoot) is in the package cache, outside the repository.
  $(Property) paths and wildcard/absolute/escaping/missing references fail rather than being
  approximated. Floors are stated constants (20 suites, 60 projects, 6 implicit, 3
  cross-directory; measured 22/70/10/6, unchanged by the denylist).
- Adds its row to ci/cert-gate-assertions.md (after the runner-column row, so the "line 34"
  citation in TestOwnershipRunnerConventionTests stays true), updates the readiness core-path
  prose in docs/CiGateInventory.md, and regenerates the knowledge graph (+1 file, +11 facts).

Cost, measured: of the last 40 merged PRs (#633-#685) none would newly run the heavy lanes. 21
ran them already, 19 did not, and a replication of the in-job bash matcher agreed with the
observed Linux-lane conclusion on all 40. The only two that touched a newly covered path (#640,
#661) were already heavy through another entry.

Mutation-checked in the devtest container (net8.0):
- Removing src/Ashlar.Mcp.Server/** and src/Ashlar.Compat/** from both lists reddens the two tree
  facts naming exactly those paths, while AiPipelineCiRoutingConventionTests stays green.
- A walker that sees only Include-first references reddens the fixture fact (misses lib/B and
  lib/C). Reordering the attributes of Core.Application's analyzer reference leaves the logged
  closure identical. Breaking the discovery port fires the suite floor at 0.
- The verifier's shared-file edge (src/Directory.Build.props referencing src/Ashlar.Lite for the
  kernel suite) plus a direct control edge (src/Ashlar.Hosting.Bundle) now reddens the tree fact
  naming both directories in both lists; on the previous walker only the control was named.
- Reading only the project file, not following explicit imports, accepting an unanchored shared
  reference, and letting a wildcard Import through each redden their own fixture facts.
- The verifier's allowlist probe, re-applied to src/Ashlar.Transport.Grpc.csproj (a never-true
  ItemGroup with a cross-directory Protobuf item, a Reference HintPath and an EditorConfigFiles
  item, plus a property-function read of ver-verifier/v.txt), now reddens the tree input fact with
  6 problems: protos-verifier/probe.proto (Protobuf), lib-verifier/VerifierProbe.dll (Reference
  HintPath) and cfg-verifier/probe.editorconfig (EditorConfigFiles), in both lists. It went green
  on the allowlist. ver-verifier/v.txt is not named; that is the documented property-function
  limit below.
- Restoring the six-type allowlist reddens the new fixture fact (it misses 6 of its 7 paths) and
  the computed-Protobuf row. Not reading HintPath reddens the fixture fact (misses lib/Probe.dll)
  and the computed-HintPath row. Dropping the $(NuGetPackageRoot) rule makes CopyAssemblies.targets'
  AssembliesToCopy list throw in the tree fact. Dropping the bare-name rule, or InternalsVisibleTo
  from the identity list, reddens only the fixture fact. Each mutation turned red only those facts
  (24 or 25 of 26 still pass). The clean tree passes 26/26 here, 70/70 together with
  AiPipelineCiRouting and CertGateFilterCoverage, and 1314/1314 over the whole cert-gate filter.

What this does NOT fix: readiness-summary still counts a skipped lane as a pass; validate still
passes a project that selects zero tests; the container lanes do not run the sweep at all; files
tests read at runtime (ci/*.json, workflows, docs) are not build inputs and are not traced;
files read through an MSBuild property function are not traced - $([System.IO.File]::ReadAllText)
is how the root Directory.Build.props and Directory.Build.targets read VERSION, which is in both
lists by hand and is not proven by this test; paths in item metadata other than HintPath (a
Protobuf ProtoRoot or AdditionalImportDirs) and files a compiled input reaches on its own (a
.proto import) are not traced; projects an <MSBuild> task builds inside a target are not traced;
and projects `ci verify` builds that are not discovered suites are out of scope.
ci/test-ownership.tsv rows 47-49, 55 and 58, docs/HowGatesGoQuiet.md, CertifierReferenceSetTests'
summary and the readiness CLOSING-PLAN still describe the old routing gap; they belong to other
owners and are listed for them. This proves routing, not that anything passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant