Repository navigation
feat(certification): give an operator a way to turn Ed25519 pinning on - #661
Merged
Merged
Conversation
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
force-pushed
the
feat/pinning-config-path
branch
from
September 28, 2026 15:47
3a819b3 to
65fdeab
Compare
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>
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.
An operator can now turn Ed25519 pinning on. Nothing could before.
CertificationVerifyOptions.TrustedEd25519PublicKeyshas always been{ get; init; }, so it wasalways settable. The defect was that nothing in non-test code ever assigned it, so
PinningEnabledwas false in every production path and verification ran against the key the recorditself 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.
CertificationTrustPolicyreadsASHLAR_CERT_TRUSTED_ED25519_KEYS(Base64 raw public keys, fromany configuration source, not only the environment) and hands the resulting
CertificationVerifyOptionsto every production verifier.ASHLAR_CERT_PINNING_REQUIREDmakes"pinning is in effect" a checkable startup assertion rather than a hope.
Strictas a static preset is unchanged, and with nothing configuredAmbient.Strictisreference-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.
ParsePinningRequiredread apresent-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 believedthey had demanded pinning. Blank now falls through to the refusal;
configured is nullstill separatesabsent from blank.
SameKeyscompared counts and only one direction, so a basis of{A, A}against a configuration of{A, B}reported "same set", raised no conflict, andApplyreturned options accepting B — theexact widening
Apply's own summary promises never happens.Validation by bytes, enforcement by string.
Validatedecoded a key only to check its length andthen stored the operator's raw text, while both verification tiers compare ordinally against
Convert.ToBase64Stringoutput. 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
TrustedKeysVariableitself, so arrange and act shared one symbol: renaming that constant's valuereddened zero of 26 cases, while every operator following
docs/Configuration.mdwould be setting avariable 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 soit ignored its argument left the suite green, because the only fact applied
Strictas the basis andcompared back to
Strict. A configured policy'sDefaultandApply(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.
StateLogVerifier's 5-arg overload, the adaptation DI factoryAmbientdirectly, and with nothing configured that IS the preset, so reverting either is invisible to any testThat gap is not hypothetical: five of these seven were in that state when pinning was first wired in.
PinnedWiringSiteConventionTestsnow asserts on shipped IL (via Mono.Cecil, already a dependency —this branch adds no package) that all seven still call
CertificationTrustPolicy.Ambient, so deletingany 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:
Ambientis aLazyresolved once perprocess, 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:
Ashlar.slnbuild succeeded.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
This is a third location for trust material, and the type ships in a
IsPackable=truelibrary.SPEC-006 §3 designates
~/.ashlar/keys/trusted/for trust material, and the enumeration hazarddocumented 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
Ambientimplicitly — grep over the whole projectfinds only its own definition (
CertificationTrustPolicy.cs:87,107) — andCertificationTrustVerifierfalls back toCertificationVerifyOptions.Default, the static preset,when
optionsis null. Reaching ambient behaviour requires namingCertificationTrustPolicy.Ambient.Every ambient read is in
Ashlar.Infrastructure's wiring, i.e. Ashlar's own hosts, which is exactlywhere 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.Strictinside the packablelibrary 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 sameshape as
PinnedWiringSiteConventionTests.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.
Whether
CertificationTrustPolicyshould readoperator.pubby default, which would make pinningon-by-default for any host that has run
ashlar keys init. A posture change, not taken.Whether
StateLogVerifier's 5-arg overload should refuse rather than throw on unusable trustconfiguration. 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