Skip to content

chore: retire Registry Notary and make Evidence the documented product - #644

Open
jeremi wants to merge 146 commits into
mainfrom
chore/notary-retirement-and-evidence-onboarding
Open

chore: retire Registry Notary and make Evidence the documented product#644
jeremi wants to merge 146 commits into
mainfrom
chore/notary-retirement-and-evidence-onboarding

Conversation

@jeremi

@jeremi jeremi commented Aug 4, 2026

Copy link
Copy Markdown
Member

Retires Registry Notary, keeps Registry Relay, and makes Evidence the
documented product. This is the accumulated work of the plan in
plans/notary-retirement-and-evidence-onboarding.md: 134 commits that have
never run against CI, because nothing had been pushed since the plan began.

Opened as a draft to get a CI signal, not to request review. 1252 files
changed is not a reviewable unit. The intent is to find out what the required
checks say about this state, fix what they surface, and then decide how to
split the change for actual review.

What is in scope

  • Registry Notary runtime, tooling lanes, release surface, and product-surface
    documentation removed. The retirement itself stays on the history pages
    (decision records and changelog), which the docs-site checker exempts by
    design.
  • Four platform crates deleted with no live references remaining
    (registry-platform-cache, -oid4vci, -replay, -sts).
  • Evidence promoted to the documented product: adopter tooling
    (registry-evidencectl), tutorials, reference deployment projects, and
    Evidence-specific verification gates.
  • Registry Relay retained unchanged as a product.

Known-red on arrival

  • DCO. 823c93e5 ("Revert "wip(evidence): ..."") has no Signed-off-by
    trailer. It is the only one of the 134. Fixing it rewrites the 78 commits
    after it, which also moves the base under an in-flight documentation branch,
    so the fix is being sequenced deliberately rather than rushed into this push.
  • Base-relative drift checks. STABLE_SURFACE_BASE_REF,
    OPENAPI_CONTRACT_BASE_REF, and ARCHIVE_LOCK_BASE_REF all resolve to
    pull_request.base.sha, which here is 134 commits stale. Intentional
    removals will read as stable-surface breaks. Each failure needs reading on
    its merits before it is treated as a defect.
  • Secret scan. A known gitleaks false positive is expected to fire.

Plan state

Global gates G1, G2, G3, and G5 are ticked in the plan file. G4 (frozen
Evidence Version 1 contracts byte-identical, or a recorded re-approval) is
reserved for maintainer decision and is provably not byte-identical: three
frozen files changed since the plan base. F3 is blocked on cutting v0.17.0.

Not in this branch

Evidence tutorial and UX documentation work in progress on
docs/evidence-ux-reset, which will land as its own reviewed change.

jeremi added 30 commits August 2, 2026 16:35
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Deployments with many callers and no identity provider had no safe way to
get a signed, expiring, audience-bound token to a resource server such as
Evidence. Handing every client a key from one pooled JWK set does not work:
key selection is by `kid`, which the signer chooses, so every key in the
pool is equally authoritative for every claim. Any client could name any
principal, any requester tags, and any evidence audience.

Mint separates the two questions. A server-side client registry binds a
client id to that client's own public keys and to the authority Mint will
assert for it. The token endpoint selects the key set by the asserted
client id before verifying the signature, then reads authority from the
registry and never from the assertion payload. A client therefore holds its
own key and signs for itself, but possession of a key no longer decides
what may be said.

Security-sensitive review notes:

- RFC 7523 `private_key_jwt` only. Assertions are bound to this endpoint by
  audience, bounded in lifetime, and single use by `jti`. The replay record
  outlives the assertion by the same clock skew the freshness check
  tolerates, so there is no window where an assertion is still accepted but
  no longer recorded as spent.
- Strict compact-JWS preflight with duplicate-JSON-member rejection runs
  before any cryptographic work, and every segment is size-bounded.
- The replay cache fails closed when saturated rather than evicting a live
  entry, since eviction is the move an attacker would use to make room for
  a replay.
- OAuth errors collapse to `invalid_client` so the endpoint cannot be used
  to probe which client ids are registered.
- Issuer identity, signing keys, listener, and token policy are
  startup-only. Only the client registry reloads, on SIGHUP, keeping the
  previous registry if the new one fails to load. Caller lifecycle changes
  therefore never restart a resource server.
- Registered client authority is redacted from Debug, and the signing key
  never appears in Debug output or in startup diagnostics.

`tests/evidence_compatibility.rs` drives the real router over a real
on-disk deployment and feeds the minted token to Evidence's own
authenticator. The dependency runs one way only: Evidence does not depend
on Mint.

The CI shard inventory gains a `mint` shard. Evidence changes now also
select it, because the compatibility test dev-depends on registry-evidence
and must run when Evidence's auth surface moves.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Accepted additions and must-close blockers landed so far: required
request nonce end to end, governed response formats with strict Accept
negotiation and the unsigned envelope, serialize-before-release-audit
ordering, format-aware audit, strict verifier expectations, subject
role order by declaration, Rhai containment/indexing/numeric/required
fixes, transport pinning, existence-channel collapse, JWKS parity,
operation exhaustion, acceptance traceability rows 1-62 with checker,
contract and documentation reconciliation.

Work in progress: bundle diagnostics, SIGTERM lifecycle and audit
rotation procedure, reserved-header aliases, and the evidence verify
subcommand are mid-implementation; independent reviews and the final
same-revision gates have not run. Includes the separately authored
evidence-definitions discovery endpoint that was staged in this
worktree. Not a completion claim.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A registered client may now ask Mint for a token that is valid only for
evidence about one named person. The delegation request rides inside the
client's own signed assertion, in an `on_behalf_of` member, so the actor and
the subject are covered by the client's signature. Mint validates both against
the client's registration and mints them as claims.

The containment is the resource server's: with the subject role's `valueOrigin`
declared as `authenticated-context`, Evidence reads the selector from the token
and refuses any request carrying selector values of its own. A client bug that
puts the wrong person in the request body therefore cannot reach that person.

Security review notes. This is authentication and authorization surface.

- Delegation is opt-in per registration. A client with no `delegation` block
  cannot obtain a delegated token, and Mint refuses an actor outside the
  registered set. Every delegation failure collapses to `invalid_client`, like
  the existing failures, so the endpoint stays unusable as a probe.
- The subject must carry the registration's subject fields exactly. A missing
  or extra field is refused rather than silently dropped.
- `on_behalf_of` is Mint's own member, not RFC 8693 `act`: token exchange
  presents a subject's own credential, which a deployment without an IdP does
  not have.
- Threat model. This defends against a buggy client, not a compromised one: a
  client holding its own signing key can ask for a token naming a different
  subject within the fields its registration permits. Closing that would mean
  resolving the subject from a server-side grant record.
- Evidence confines an actor-bearing token to `kind: delegated` authority
  profiles but does not conversely require an actor to reach one. An
  undelegated token therefore matches such a grant and is stopped at selector
  resolution. Asserted in the tests and stated in both READMEs.

`tests/delegated_subject_binding.rs` proves the property against Evidence's own
entitlement match and selector resolution, over the same bundle the
demonstration uses. `demo/` runs the whole thing against the real binaries with
every request printed; its keys, certificates, and subjects are generated per
run and synthetic.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Evidence's request model gained a required caller correlation nonce. The
delegation test uses the offline constant, since the nonce never reaches
authorization, which is what that test exercises. The demo generates a fresh
one per request.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`mint token` signs a client assertion with the caller's own key and
presents it to a running token endpoint. Getting an assertion right by
hand is fiddly and getting it wrong yields an opaque `invalid_client`.

Security review notes:

- The subcommand authenticates; it does not decide. There is deliberately
  no path that signs an access token with Mint's signing key, which would
  be a way to obtain authority without authenticating inside the binary
  whose purpose is to make authority depend on authentication. Anything
  it obtains, the same client could have obtained over the wire.
- The caller's key file gets the same guarantees as Mint's own signing
  key, through `secretfile::read_owner_only`.
- Delegation subjects come from `--subject-file`, never from flags: they
  are a person's identifying details, and command lines are visible to
  every process on the host and land in shell history.
- Logs move to stderr for this subcommand so stdout carries the access
  token alone.

Also closes a claim-shadowing gap in `ClaimNames::validate`. Minting
writes the configured claim names last, so a principal claim named `aud`
produced a token whose audience was the principal and which still
verified; the same held for any non-principal claim named `sub`. Both
are now refused at configuration load, with tests over every reserved
name.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Adds a sequence diagram to the demonstration README, and corrects the
walkthrough's summary, which counted four requests for a script that
sends nine across six steps.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Version 1 generated the public contract as a release artifact but never
published it from the running service. Add GET /openapi.json, returning the
same bytes the contract gate reproduces, so an adopter can point tooling at a
deployment instead of tracking the repository artifact.

Security review: the route is unauthenticated, like /health, /ready, and the
public JWKS. Its payload is a process-constant built by the same generator as
products/evidence/generated/registry-evidence.openapi.json, so it names no
definition, bundle revision, authority, principal, or source, and it reaches no
runtime state or dependency. It is therefore not a discovery oracle, and the
requester-scoped catalog stays authenticated at GET /v1/evidence-definitions.
Enforcement is the handler itself, which reads no token and holds no state, and
is pinned by
openapi_route_serves_the_generated_contract_without_authentication_or_source_access:
it asserts no source request, an empty audit, and no bundle revision in the
served bytes.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…file

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The only conflict was the workspace dependency block: the release train
rewrote every registry-* pin to 0.16.3 while this side added
registry-evidence and registry-mint at 0.16.2. Resolved to the 0.16.3
pins with both new members reinserted; `cargo update --workspace` moved
the same two packages in the lockfile and nothing else.

Neither crate appears in the beta-26 artifact list, which is correct:
neither ships as a released binary yet.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…inition

Rename the reference deployment project dhis2-adult-status ->
dhis2-tracker-evidence and add the professional-licence-status acceptance
definition beside adult-status, both served from the same DHIS2 tracker
source. Bring in the version one runtime: request nonce end-to-end,
response-format negotiation with unsigned envelope, strict offline verifier,
source transport pinning, and observability.

Security-sensitive: touches authentication (auth.rs), audit (audit.rs),
source transport pinning (source.rs), and verifier policy. Reviewed for data
minimization; reference deployment-project names stay test-only and the
production code path remains source-neutral.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Serialize the stateless Evidence assertion as an SD-JWT VC
(application/dc+sd-jwt, typ dc+sd-jwt) beside the JWS form, using the
registry-platform-sdjwt serialization primitive. Amend the version one
boundary to permit SD-JWT serialization solely for this response format.

Security-sensitive: touches signing (signing.rs) and verification
(verifier.rs). Response-format only; no credential issuance, OID4VCI, PDP,
holder binding, or federation.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Landing the Evidence SD-JWT VC response format added the optional
holder_key field to EvidenceRequest. Mint's tests drive Evidence's
authenticator and build the struct directly, so the delegated
subject-binding fixture stopped compiling.

Security review: no behavior change. holder_key is meaningful only to
the SD-JWT VC response format; it never reaches authorization,
selectors, Rhai, source requests, or audit, and never appears in the
signed-JWS payload. Setting it to None keeps this fixture exercising
authorization alone, which is what the test asserts.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The demo performed every request with curl but never showed the
invocation, so a reader could not reproduce a step outside the script.
Each step now prints the command in copy-pasteable flag form before
running it, with paths relative to the repository root.

The two verify steps build one array that is both printed and executed,
so the shown command cannot drift from the one that runs.

Security review: the bearer token is still never rendered. curl keeps
receiving its configuration on standard input, so the token reaches no
command line and no process listing; the printed form substitutes the
literal text $EVIDENCE_ACCESS_TOKEN for any Authorization header value.
Verified by running the demo and confirming the 458-character token
appears in neither the transcript nor harness.log.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
RFC 6749 section 5.1 makes expires_in recommended rather than required, so a
compliant provider may answer with only access_token and token_type. The token
parser rejected that response, which made such a provider unreachable. Bundles
may now state assumedLifetimeSeconds: a governed positive value bounded by one
day, applied only when the provider omits expires_in, and still clamped by
maximumCacheSeconds. A present but zero or non-numeric expires_in stays a
credential failure that no assumed lifetime rescues.

Version 1 also drops query-string credential placement. RFC 6749 section 2.3.1
requires the client identifier and secret to travel in the Authorization header
or the request body and never in the request URI. The OpenCRVS reference, its
deployment project, and its frozen shape fixture move to form-body placement,
which the provider accepts, so no shipped artifact needs the removed option.

Security review notes:

- No placement can now put a client identifier or secret in a token URL, which
  removes the documented exposure through authorization-server, proxy, and
  ingress URL logs. Narrowing the enum is a breaking bundle change; restoring
  the value later would be additive.
- The assumed lifetime is a governed bundle value and is never inferred from
  the token itself, so no unverified token claim influences cache duration.
- The redaction surface is unchanged in strength and narrower in scope: the
  token URL now carries nothing to redact, and the request body, response, and
  debug output remain fully redacted.
- The opt-in live check now parses token responses exactly as the product
  does, so it can no longer report a passing profile for a provider the runtime
  would reject.

Acceptance row 19 loses its query-string clause because the placement no longer
exists.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The operator contract described the telemetry policy but never named the
series, labels, or histogram bounds, so an operator writing a scrape config
or a dashboard had to read the Rust source. Record the published contract
alongside the limits that make it safe to expose.

Also states two things the policy paragraph left implicit: the listener
performs no authentication of its own, so the private binding is the only
access control, and the series cover the HTTP boundary only, with source,
signing, credential, and audit health reported by /ready instead.

No behavior change. Reference only; the metric names, label sets, bucket
bounds, route templates, and problem codes are the ones the runtime already
emits and already asserts in tests.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…oundary

The startup check rejects the mistake that actually exposes telemetry, a
public or unspecified bindHost, and an operator can reasonably read the
accepted range as meaning only they can reach the endpoint. On a flat pod
network or a shared VPC every workload holds an RFC 1918 or unique-local
address, so binding one there leaves the unauthenticated endpoint scrapable
by every neighbour.

Name loopback with a same-pod or same-host collector as the shape that keeps
the intended boundary, and place the network policy for any wider binding
with the operator.

Security-sensitive documentation only: no behavior, validation, or default
changes. The accepted address range and the absence of authentication on the
telemetry listener are unchanged.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Add the registry-evidencectl crate and evidencectl binary: adopter
tooling beside the frozen Version 1 runtime, like registryctl for the
rest of the stack. It generates signing, holder, and HMAC key material,
assembles public JWKS documents, scaffolds a neutral deployment project
that passes `evidence check` and `evidence evaluate` with zero edits
after one keygen pass (optionally paired with a Mint configuration),
and drives bundle fixtures through the evidence binary. Every Evidence
semantic decision is shelled out to `evidence`; nothing is
re-implemented. The only workspace dependency edge is
registry-platform-crypto.

The source-neutrality gate now also sweeps evidencectl production Rust,
templates, and Cargo metadata, and the CI evidence shard includes the
new crate so evidencectl-only changes run the Evidence contract jobs.

Security review notes (key generation and key handling):

- Entropy: getrandom OS randomness into Zeroizing buffers; the private
  scalar's only non-zeroized copy is the short-lived serde_json string
  inside the rendered JWK, documented in code. Negative test: jwks
  rejects a private JWK input without echoing its contents.
- File modes: private files 0600 and secret directories 0700, applied
  at O_CREAT so no window exists with looser permissions; public
  outputs 0644. Negative tests pin the modes and the 0700
  normalization of a pre-created --out-dir.
- Overwrite safety: --force removes the existing path first and then
  creates with O_EXCL, so a symlink at the target is replaced, never
  followed or written through, including in the remove-to-open race
  window. Negative tests in keygen and jwks pin this.
- Redaction: private key material never reaches stdout, stderr, or
  process arguments; scaffolds ship no secrets and the scaffolded
  README instructs operators to place source bearer tokens as 0600
  files, never on a command line.
- Batch semantics: all target paths are collision-checked before any
  write, so a refused batch leaves nothing behind.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Add the registry-evidencectl row to the repository map, state its
boundary beside the runtime (outside the frozen Version 1 contract,
shells out to the evidence binary for every semantic decision, no
registry-notary* dependency, covered by the same neutrality checks),
and extend the Evidence reproducible gate commands to build and test
both crates.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Convert flow-style mappings in the reference deployment projects to
block style so an adopter copying these files can edit one field per
line: selector profile fields, fixture case documents, and schema
property maps. Short scalar lists and single-key const properties stay
inline. Every file is proven byte-for-byte semantically identical to
its previous parse (yq JSON round-trip diff), and the registry-evidence
suite that loads these bundles passes unchanged.

The opencrvs-family-evidence bundle/evidence.yaml carries the same
reformat but is left uncommitted here because the working tree also
holds unrelated in-progress edits to it.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The runtime refuses a file-provided secret containing a NUL byte, which a
uniform 32-byte draw carries about 11.8% of the time. A scaffolded project
therefore had roughly a one in eight chance of failing at `evidence serve`,
long after `evidence check` and the fixtures had passed, for a reason that
pointed at the secret file rather than at the tool that wrote it.

Draw by rejection sampling instead. The value stays uniform over the accepted
set, 255^32 or about 255.8 bits, which is not a meaningful reduction in
strength for an HMAC key.

Also state in the scaffold report that the source bearer token has to be
obtained from the source system and written by hand: check, fixtures, and
startup all pass without it, so a missing token is otherwise discovered by the
first live request.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Finishes the reformatting the reference deployment bundle already started:
the last flow-style mappings become block mappings, so selector fields, fixed
headers, and selector alternatives are edited and diffed a line at a time.

Verified value-identical: the parsed document is unchanged.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Measured audit throughput was about 270 appends/second and flat from 1 to 128
concurrent appenders: every append took one fsync under a held mutex, at
3.70 ms each. At two audit records per request that capped the service near
135 requests/second regardless of hardware or concurrency.

Security review note, required for a change to audit integrity.

What changed

Chain-head ownership moves from registry_platform_audit::ChainState into
Evidence's DurableJsonlSink. ChainState::append held its mutex across
sink.write(), which serialized every append whatever the sink did internally.
The on-disk chain is byte-identical: same keyed hasher, same envelope.

A two-lock split replaces the single mutex. A short state lock claims a chain
position and advances the head, performing no I/O; a separate flush lock is
held across the blocking fsync. Appends arriving during a write form the next
batch. Group commit is leader-follower with no timer and no configured window:
the first caller to arrive while no write is in flight writes everything queued
so far, and the others wait on the flush lock to find their records already
durable. Batch size is 1 at idle and grows by itself under load.

Segments rotate at auditStorage.maximumFileBytes with chain continuity across
the seam, and startup verification is now bounded to the active segment, so
restart time no longer grows with retained history.

Properties preserved

Chain positions are handed out in enqueue order under one lock, so concurrent
appends extend one chain rather than forking it. Nothing is reported durable
before its own bytes are synced. A failed durable write leaves the in-memory
head ahead of the disk, so the sink poisons itself and refuses every later
append for the life of the process rather than chaining onto a record the disk
never received; concurrent waiters on a poisoned batch each receive the
failure instead of hanging. A batch crossing a segment bound flushes the
buffered run into the outgoing segment before sealing. Pinned-writer identity
is still validated before the append and again after the sync, for both the
segment and the lock file.

Defect found and fixed during review

The first version of the split read the recorded fingerprint under the state
lock but compared it after releasing the lock, and acquired that lock with a
non-blocking try_lock. Both were wrong under load: the service's own appends
changed the file between snapshot and comparison, so readiness read its own
traffic as external mutation, and try_lock lost to writer contention, which is
busyness rather than ill health. Measured 195 of 200 probes unready while the
service wrote its own audit records, which would have taken a healthy service
out of a load balancer rotation under sustained load. The fingerprint is now
compared under the same lock the writer advances it under, and only while the
sink is quiescent. Tamper detection is unchanged.

Accepted risk

Startup verification is bounded to the active segment, so tampering inside an
already sealed segment is not caught at boot. The new `evidence verify-audit`
command is the out-of-band counterpart that does catch it. It reads the audit
path and hash secret from the deployment's own runtime document and takes no
path or secret flags, so it can neither be aimed at a foreign chain nor take a
secret on a command line. It reports a missing sealed segment as archived
history rather than as corruption, so deliberate archival stays
distinguishable from tampering.

Cross-crate change

AuditEnvelope::new_with_hasher in registry-platform-audit becomes public.
Visibility only, no behavior change. The alternative was a second
implementation of the chain hash inside Evidence, and two implementations of a
security-critical hash chain diverge eventually. Notary shares the crate and is
unaffected; its tests pass unchanged.

Result

Audit appends, same host, before and after: 270/s to 322/s at 1 concurrent
appender, ~270/s to 1,253/s at 8, to 4,245/s at 32, and to 12,864/s at 128.

End to end over real sockets through the whole request path, including token
verification, rate limiting, Rhai preparation, one source call, Rhai
extraction, evidence construction, Ed25519 signing, and both audit appends:
6,976 to 7,057 requests/second across two runs at 128 in flight, p50 17.8 ms,
zero non-2xx. The in-process source's own ceiling is measured in the same run
and the check reports the run inconclusive below a 5x margin; it measured 20.8x.

The request path also gains structured request logging, a correlation header,
and an opt-in metrics listener, and the audit and rate-limiter capacity gauges
that make the ceilings above observable. They land here rather than separately
because the gauges read storage state this change introduces.

The traceability checker now accepts #[tokio::test(flavor = "multi_thread")] as
a test item. A concurrency invariant cannot be proven by a single-threaded
test, so the stricter form meant leaving these invariants untraced.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
evidence check accepted a deployment whose mounted secrets the server
refuses at startup, so a signing key whose kid does not match the
bundle's signing.activeKeyId passed check and then failed every start.
Check now resolves and validates the audit, subject-binding, and
signing material exactly as startup does, without opening the audit
chain; source credentials stay unresolved because readiness owns them.

Security review notes: startup validation is extracted into
validate_secret_material, not altered; check gains read-only secret
resolution with the same fixed value-free operator messages; no secret
bytes reach output; startup error-class precedence now reports
subject-binding and signing faults before audit chain faults when both
are broken.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A static reference docker-compose for the evidence service and the
optional mint pairing: static private addresses on a user-defined
network because the listener refuses wildcard binds, the deployment
project mounted read-only with a container-shaped runtime file
overlaid on the project's host-shaped one, and the audit chain in a
named volume. The files are adopter-owned after copying; there is no
generation contract. Verified end to end against the locally built
images: check, both health endpoints, and audit persistence across a
restart.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ence

One multi-stage Dockerfile built from the repository root, with a
cargo-chef recipe per binary so dependency compilation is a cacheable
layer and an unrelated workspace manifest change invalidates neither
image. Both images run as the distroless nonroot user with no shell.
These are not release evidence; released images keep following the
release/docker reproducible-build path.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
jeremi added 10 commits August 4, 2026 10:11
The retired external integration runner took the only release-owned proof of
an end-to-end source read with it. The `relay-oidc` harness looked like it
covered this already: it mounts `records.csv`, materializes `people_table`,
and declares `read_scope: smoke_registry:rows` on the `person` entity. But
every assertion targeted `/v1/datasets`, so the read scope was never granted
to the Zitadel role and no row was ever read.

Map the role through a parameterized `$mapped_scope` and add three required
checks. A metadata-scoped token is refused at the entity records route with
`403 auth.scope_denied`; the same token remapped to the read scope returns
`200`; and the record it returns is exactly the authored projection, so the
source column `person_id` is absent. Relay restarts between the two scope
stages deliberately, because one token carrying both scopes could not show
that the read scope is independently enforced.

`source_read_result` is pure so the projection rule is unit-tested offline,
and it reports field names only, never the row, keeping a failing report
inside the same evidence boundary as a passing one. A new test derives the
expected record from the pinned fixture so the CSV, the template projection,
and the expectation cannot drift apart.

Review note (data minimization): these assertions are the release's only
end-to-end check that a published Relay image discloses the authored
projection rather than the source row. They are source-ready and unrun; a
live run needs a published digest-pinned candidate. READINESS records them
as unrun so no release cites them yet.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ence

Remove the `federation` block and the `registry-notary` access kind from the
portable manifest schema, and validate `registry-evidence` access in their
place. Relay runtime binding accepts only that kind, so before this change a
Relay deployment could advertise offerings that only ever pointed at a retired
product.

The `access.kind` vocabulary is now open. Registry Manifest checks endpoint
shape for `registry-evidence` alone, because that kind names a service whose
shape this repository defines: an assertion endpoint and the discovery document
a relying party reads to verify the response. Any other kind falls through to
generic optional-URI validation and is left to the consuming runtime.

`registry-evidence-gateway-pdp/v1` is deliberately unchanged. It is live
surface, consumed by the governed evidence pack policy path and required at
Relay startup, and it does not name Notary.

The schema version stays `registry-manifest/v1`. No adopter has published a
manifest, `federation` was optional, and serde's unknown-field rejection fails
closed with a readable error that names the removed key. Bumping would churn
the published JSON-LD namespace IRI and the DCAT/SHACL goldens for nobody.

Review note (security-sensitive: published schema and deployment contract):
this narrows what a Relay deployment may advertise and changes a published
discovery vocabulary. It does not touch authentication, authorization,
assertion evaluation, signing, or audit. `conforms_to` is checked for presence
only, not pinned to an Evidence contract version, so the portable layer stays
independent of any one product's contract. Both `endpoint_url` and
`discovery_url` are now required and must be HTTPS for `registry-evidence`
access; validation checks shape, never reachability.

The OpenAPI document is regenerated by its documented generator, not
hand-edited, and `just openapi-contract` passes.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Follows the schema change that removed the `federation` block and the
`registry-notary` access kind. The reference's federation section becomes an
evidence-offering access section stating what the validator now enforces for
`registry-evidence`, and that every other `access.kind` is left to the
consuming runtime.

Changelog and release-notes entries keep their historical wording.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
RS-DM-MANIFEST 0.4.0. Section 7 becomes evidence-offering access discovery:
the `access.kind` vocabulary is open, and Registry Manifest checks endpoint
shape for `registry-evidence` alone. REQ-DM-MANIFEST-010 now requires a
non-blank `conforms_to` naming the response profile plus HTTPS `endpoint_url`
and `discovery_url`, and a `ruleset` that names a declared evaluation profile
when present.

The Relay API reference stops telling readers a new deployment should declare
no evidence offering; runtime binding accepts `registry-evidence`, so an
offering now points somewhere maintained.

Version-history rows for the retirement keep their original wording.

Closes C10 in the Notary retirement plan.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The two C10 endpoint tests were committed with hand-wrapped comparisons
that `cargo fmt --check` rejects. C10 was verified with check, clippy,
tests and the docs gates but not with fmt, so the drift reached main.
The change is whitespace only.

Ticks G1: the full verification suite is green, with every gate's exit
status recorded rather than inferred from a pipeline.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The mirrored crate docs are published at /products/registry-relay/ and are
as current as any authored page, but the surface gate never read them. They
still sent adopters to Registry Notary for the consultation caller,
credential issuance, and signing-key operations.

The consultation profile is now documented by what it does, an authenticated
workload sending a purpose and a pinned contract, rather than by a caller
product; the offering handoff is the access.kind registry-evidence that
registry-manifest-core actually binds. The shipped wire identifiers stay:
the Registry-Notary-Evaluation-Id header and the notary_evaluation_id
envelope field are unchanged.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Evidence's README no longer defines itself against Registry Notary, and the
two dependency clauses it carried are vacuous now that no registry-notary
crate exists. The manifest reference loses its federation row outright,
because FederationManifest is gone from registry-manifest-core.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The gate skipped everything under src/content/docs/products/, which is
exactly where sync-repo-docs mirrors the crate docs, so the pages an adopter
reaches from the product menus were never read. Removing the skip found 55
blocks across 13 mirrored pages.

Two exemptions come with it. A non-mermaid fenced block is code for the same
reason an inline span is, so an http example may spell a shipped header,
while a mermaid fence stays subject to the rule because a diagram's
participant labels are prose. And a page whose editUrl names a frozen
Evidence Version 1 contract is skipped, since editing one needs a recorded
re-approval rather than a docs pass.

Closes G2.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Both executing tutorial gates pass from this worktree: six registryctl reader
journeys and five Evidence tutorials, exit status 0 each. Six Evidence tutorial
pages have no executing gate here; that extension is owned outside this plan and
is recorded as a coverage caveat rather than closed.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Audited the 100 commits since the plan's starting revision. Thirty are
security-sensitive; fifteen already carried a note in their own commit message,
and the fifteen that did not now have one, keyed by what changed rather than by
revision. Two are removals of enforcement and are argued rather than waved
through.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Comment thread crates/registry-mint/demo/support/provision.py Fixed
Comment thread crates/registry-mint/demo/support/tls_front.py Fixed
Comment thread crates/registry-mint/demo/support/tls_front.py Fixed
Comment thread crates/registry-mint/demo/support/tls_front.py Fixed
Comment thread crates/registry-mint/demo/support/tls_front.py Fixed
Comment thread release/scripts/test_registry_release.py Fixed
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

jeremi added 8 commits August 4, 2026 15:05
The local was named `secret` and its value carries enough entropy to match
the repository secret scan's generic-api-key rule, so a fixture the test
asserts never reaches a rejection message was reported as a leak. The value,
the behaviour and the assertion are unchanged.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The gate emitted a `node` call into every reader journey that edits a file,
so those journeys stopped at `node: command not found` in CI's clean Debian
userland. Mounting an interpreter would clear the red without fixing
anything: the container exists to prove a reader needs only the documented
toolset. The edit step is ported to a shell and awk helper with the same
fence semantics, and a test asserts the gate reaches for no interpreter.

All 22 fences named by the 11 edit steps are byte-identical between the Node
helper and the shell helper, on macOS awk and on Debian trixie's mawk. G3 is
unticked until the pull request's tutorial job is green.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The pull request's Evidence tutorial job passed with every step green,
including the clean-container replay that failed before the edit step was
ported off an interpreter.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Retargeting an evidence offering from Notary to Evidence made
`access.conforms_to` name a response profile, `registry.assertion-evidence/v1`,
rather than a document location, so that the portable manifest layer stays
independent of any one product's contract version. Dropping `format: uri` was
part of that change, not collateral damage: the manifest core validates the
member as a URI for every other access kind, Relay refuses at startup to serve
an offering of any other kind, and so every value these two routes can return
would fail the annotation the baseline still carries.

Recorded as a dated accepted diff beside the issue #362 entry, and pinned with
a test that a profile identifier validates. The guard was checked against a
deliberately reinstated URI requirement and failed as it should.

Security review note. Threat: a future change reinstates the URI requirement
and the manifest layer starts rejecting what the Evidence runtime returns.
Enforcement point: `validate_registry_evidence_access` in
registry-manifest-core, plus Relay's startup refusal of any offering whose
`access.kind` is not `registry-evidence`. Negative test:
`validation_rejects_blank_registry_evidence_conforms_to` keeps the member
mandatory, so relaxing the URI rule did not relax presence.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The front forwarded whatever request line it was handed and relayed whatever
headers came back. In a private repository that would be plumbing; in a public
one it is an example, and it was modelling an ingress that publishes every
upstream route and passes headers it cannot write intact. Code scanning read it
the same way.

It now serves only the two routes this deployment declares, Mint's key set and
its token endpoint, forwarding a path literal from this file rather than the
caller's; refuses a request or upstream header carrying CR, LF or NUL rather
than splitting a message around it; sets its own TLS 1.2 floor instead of
inheriting the runtime's; and closes the upstream connection on the error path.

Provisioning creates secret files at their final mode. The old order wrote the
file and then narrowed it, leaving a freshly generated signing key world
readable for the length of a write.

Tests are stdlib only, so they run under the same bare python3 the rest of the
repository's checks use. `openssl` supplies the throwaway certificate, since
nothing in CI installs `cryptography`; provision.py needs it and its three
tests skip where it is absent, which is why they are not the gate for the route
and header properties. The full demonstration was run end to end and all six
steps behaved as described.

Security review note. Threat: an ingress that forwards an attacker-chosen path
reaches administrative routes the deployment never meant to publish, and one
that relays an unvalidated header lets an upstream response split into two.
Enforcement point: `route_for`, which can only return one of this file's own
literals, and `well_formed`, applied to both directions before anything is
written. Negative tests:
`test_an_undeclared_route_never_reaches_the_upstream` asserts the stub upstream
saw nothing, and `test_an_upstream_header_that_cannot_be_written_safely_is_not_relayed`
asserts the injected header does not appear in the response.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`load_baseline` carried the whole v3 shape check inline beside a one-line v2
branch and returned from three places, so the two versions read as if they were
different kinds of thing. Lifting v3 out puts it beside `validate_v2_baseline`
and leaves one return.

No behaviour change: the same checks run in the same order and fail with the
same messages. `crates/registry-relay/tests/advisory_baseline_check_test.py`
passes unchanged, 17 tests.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ertions

`check-debian13-images.py` built a `binary_recipe` handle and never used it, so
the release workflow's builder pin was checked while the recipe's own default
was not. Both ends now have to carry the same image.

Two assertions were saying less than they meant. `assertTrue(a <= b)` on two
sets reports only `False` when it fails; `assertLessEqual` names the artifacts
that were missing. And a helper whose every other branch returns a string
ended on `self.fail`, which reads as falling off the end and returning `None`
into the comprehension that calls it; `raise AssertionError` says plainly that
the branch does not return.

Verified: `check-debian13-images.py` passes, `test_registry_release.py` 71
tests, `test_first_country_release_form.py` 54 tests.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…tion

Retiring Notary took `GeneratorRecipeId::AuthorizationBeforeSource` with it,
and with the recipe went the one code it emitted, `authorization.denied` under
`registryctl_relay_offline_harness`. The harness is maintained and thirteen of
its codes still stand, so the check was right to complain and wrong only about
the remedy available to it.

`RETIRED_DIAGNOSTIC_CODES` records the exemption against the exact
(family, product, code) and carries the reason beside it. Widening
`HISTORICAL_DIAGNOSTIC_PRODUCTS` to the product would have blinded the check to
those thirteen live codes; skipping unreleased entries would have disabled the
removal check altogether, since all seventy-four entries across the three
catalogs are unreleased.

Generated catalogs untouched.

Security review note, release provenance. Threat: a broad exemption stops the
check noticing that a maintained product dropped a code its released catalog
promised. Enforcement point: the membership test in
`compare_diagnostic_contracts` is on the full three-part key and cannot match a
code the map does not name. Negative test:
`test_a_retired_code_is_skipped_and_its_siblings_stay_protected` removes the
retired code and a sibling together and asserts only the sibling is reported.

Verified: `check-stable-surface-compatibility.py` exits 0, and its unit tests
pass 14.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Comment thread crates/registry-mint/demo/support/provision.py Fixed
Comment thread crates/registry-mint/demo/support/test_provision.py Fixed
Comment thread crates/registry-mint/demo/support/test_provision.py Fixed
Comment thread crates/registry-mint/demo/support/tls_front.py Fixed
The two accepted lines were written against whatever oasdiff was on my PATH,
which renders the same error differently from the 1.23.0 CI downloads, so they
matched nothing and the check stayed red for a diff already reasoned about. The
lines now reproduce 1.23.0's rendering, backticks and all, and the file says
where the wording has to come from.

Reproduced by installing the pinned 1.23.0 and running the CI command against
the same base: 4 changes 2 error before, 2 changes 0 error 2 warning after, the
two remaining warnings being the intended `registry-evidence` enum addition.

Two follow-ups on the demonstration, from what code scanning made of the
previous commit. `write` kept `os.open` with a mode argument that its ordinary
callers set to 0644 and 0444, which reads as opening a file permissively no
matter what a given call passes; the narrow creation now lives in
`write_secret` with a literal 0600 and nothing else, and `write` is back to a
plain write plus chmod. Refusal in the TLS front returned the value of a
function that returns nothing, so `forward` mixed bare and valued returns.

Verified: the demonstration runs end to end and all six steps behave as
described, `.run` shows tls.key and signing.jwk at 0600 with ca.pem at 0644 and
the Evidence runtime file at 0444, the support tests pass 13 under `uv` with
`cryptography` and 10 with 3 skipped under bare python3.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Comment thread crates/registry-mint/demo/support/provision.py Fixed
@jeremi
jeremi force-pushed the chore/notary-retirement-and-evidence-onboarding branch from bcab9a3 to 0b9f4a2 Compare August 4, 2026 08:56
…s a path to

Code scanning read the local `secret_root` as sensitive data and followed it
into the world-readable `runtime.yaml`, which is a true flow over a false
premise: the value is the path `evidence/secrets`, not anything under it. The
directory is created 0700 and every secret inside it goes through
`write_secret` at 0600. Naming the local `provider_root`, after the
`secretProviders.file.root` member it renders, leaves the query no
sensitive-looking source and keeps the check green by construction rather than
by a dismissal a later reader would never see. The rest of the stack keeps
saying `secret_root`, so the reason for the divergence sits at the declaration.

Verified: the support suite passes 13 tests with `cryptography` and 10 with 3
skipped under bare python3; provisioning a throwaway deployment renders the
same `secretProviders.file.root` and the same modes, runtime.yaml 0444,
secrets/ 0700, signing-key, source-token and tls.key 0600, ca.pem 0644; and
`evidence check` accepts the result.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi marked this pull request as ready for review August 4, 2026 11:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4b7b7dd71

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -0,0 +1,29 @@
stack:
release: beta-27
version: 0.17.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align beta-27 with the workspace package version

When the release-candidate workflow is dispatched for this new 0.17.0 manifest, build-release-binaries.sh still compiles workspace version 0.16.3 from Cargo.toml, so the explicit verify-registryctl-binary-version --version 0.17.0 step in .github/workflows/release-candidate.yml rejects the resulting registryctl 0.16.3 binary. Either defer this manifest or update the workspace dependencies and lockfile to 0.17.0 before making beta-27 selectable.

Useful? React with 👍 / 👎.

Comment thread AGENTS.md
Comment on lines 141 to 142
- Keep a change scoped to one owning area (`crates/`, `products/`,
`docs/site/`, `release/`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split the change along owning-area boundaries

This commit changes all four independently owned areas—crates/, products/, docs/site/, and release/—including new security-sensitive runtimes and release machinery, even though the repository requires one owning area per change. At this size, ownership review, gate attribution, and safe rollback cannot be isolated; split the retirement, Evidence runtime/tooling, documentation, and release changes into separately reviewable commits.

AGENTS.md reference: AGENTS.md:L140-L145

Useful? React with 👍 / 👎.

Comment on lines +948 to +950
let expires_at = Instant::now()
.checked_add(cache_lifetime)
.ok_or(SourceError::Credential)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expire cached OAuth tokens before the issuer does

When a source uses OAuth client credentials, expires_in is measured from issuance, but the cache deadline is calculated from a fresh Instant::now() only after the token response has arrived and been read. The cached token therefore remains eligible for at least the response latency beyond its real expiry, and without any safety margin a request near that boundary can send an already-expired bearer token and intermittently fail with 401; anchor the deadline before the token request and subtract a bounded refresh margin.

Useful? React with 👍 / 👎.

Comment on lines +267 to +269
if !self.signing.jwks_path.starts_with('/') {
return Err(ConfigError::Invalid("jwks path must be absolute"));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject JWKS paths that collide with Mint routes

A configuration such as jwksPath: /ready, /health, or the metadata path passes this absolute-path-only check, but build_app then registers two GET handlers for the same route and Axum rejects the overlap during router construction, turning an accepted configuration into a startup panic. Validate the path as a closed route path and reject collisions with every built-in Mint endpoint.

Useful? React with 👍 / 👎.

Comment on lines +261 to +262
validate_https_issuer(&self.issuer)?;
self.listener.bind_address()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unusable Mint listener limits at startup

The listener validation checks only that the address parses, so maximumRequestBytes: 0 and requestTimeoutMilliseconds: 0 are accepted. With registered clients /ready still returns 200, but every nonempty token form is rejected by the zero body limit or immediately times out, leaving a deployment that advertises readiness while it cannot issue any token; enforce nonzero bounded listener values during configuration loading.

Useful? React with 👍 / 👎.

Comment thread products/evidence/README.md Outdated
asset directly:

```sh
curl -fsSL https://github.com/registrystack/registry-stack/releases/download/<tag>/evidencectl-<tag>-install.sh | bash

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Download the versioned installer before executing it

This documented curl | bash path cannot infer the release tag: when Bash reads the script from stdin, BASH_SOURCE[0] does not contain the downloaded evidencectl-<tag>-install.sh filename, so default_version remains empty and the installer exits with “No release is pinned.” Download and execute the named asset as the tutorial does, or pass EVIDENCECTL_VERSION explicitly in the pipeline.

Useful? React with 👍 / 👎.

Comment thread crates/registry-evidencectl/install.sh Outdated
stage_dir="$(mktemp -d "$install_dir/.evidencectl-install.XXXXXX")"
install_started=0
install_complete=0
declare -A had_previous=()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid Bash 4 associative arrays in the macOS installer

On the advertised macOS arm64 platform, /usr/bin/env bash normally resolves to the stock Bash 3.2, which does not support declare -A; the installer therefore aborts at this line after downloading and verifying the assets, before installing anything. Use Bash 3-compatible rollback state or explicitly provision and require a newer Bash before claiming macOS support.

Useful? React with 👍 / 👎.

jeremi added 2 commits August 4, 2026 21:50
The rollback bookkeeping used an associative array, so `declare -A` aborted
the installer under the bash 3.2 that macOS ships. It reached that line only
after downloading all three binaries, verifying them against SHA256SUMS, and
creating a staging directory inside the install directory, and it aborted one
line before the EXIT trap that would have removed the staging directory. A Mac
without Homebrew bash therefore got a failed install and a stray
.evidencectl-install.XXXXXX directory, on a platform the README advertises.

The saved copy under the temporary directory is already the record that a
binary was previously installed, so rollback reads that instead and the
parallel bookkeeping goes away.

Two tests cover this. One asserts the source text carries no bash 4 construct,
since the runners that execute this suite all have bash 5 and would never
notice a regression. The other runs the full install, and the rollback path,
under /bin/bash when that is a 3.2, which is the case on macOS.

Verified: 14 tests pass, /bin/bash 3.2.57 parses and runs the installer end to
end, shellcheck and shfmt -d are clean.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The advertised one-liner pipes the installer into bash, which is the one way
of running it that cannot work. The installer derives the release it belongs
to from its own asset filename, and a piped script has no filename:
BASH_SOURCE[0] is unset, so the pin is empty and the installer exits 1 with
"No release is pinned for this installer copy." Anyone following the README
got that error and nothing else.

Document the download-then-run form the registryctl README and the first
assertion tutorial already use, say why the file has to reach the shell as a
file, and give the piped form its working shape for anyone who does not want
to keep the script.

Both behaviors verified against the installer: piped with no
EVIDENCECTL_VERSION refuses to guess a release, and piped with one gets past
the pin gate to asset download.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 43a1449526

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +301 to +302
for path in retired {
keys.push(load_retired_public_key(path)?);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject duplicate key IDs in the Mint JWKS

When retiredPublicJwkFiles repeats the active key ID—such as when an operator accidentally retains the active public key during rotation—this loop publishes both entries without validation. registry-platform-oidc::JwksFetcher::refresh collects keys into a map by kid, so the later retired entry replaces the active key; Mint continues reporting ready and issuing tokens, but Evidence rejects every newly minted token because it verifies against the wrong public key. Enforce unique IDs across the active and retired keys before startup succeeds.

AGENTS.md reference: AGENTS.md:L20-L23

Useful? React with 👍 / 👎.

Comment on lines +1205 to +1207
if !object.is_empty() {
return Err(SourceError::Credential);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore extensions in OAuth token responses

When an otherwise valid OAuth token response includes a provider-specific extension, this closed-object check rejects the credential and consequently makes readiness and every request using that source fail. RFC 6749 section 5.1 requires clients to ignore unrecognized response value names, so interoperable providers are allowed to add such members; consume the recognized fields while ignoring bounded unknown members instead of rejecting the entire response.

AGENTS.md reference: AGENTS.md:L80-L84

Useful? React with 👍 / 👎.

Comment on lines +198 to +200
let id = string_of(claims, "id")?;
if string_of(claims, "jti")? != id {
return Err(SdJwtVcClaimError::ClaimShape);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind the SD-JWT issuer to the provider claim

When an SD-JWT signed by a trusted key omits iss or sets it to a value different from providedBy, this reconstruction never reads iss, so verify_sd_jwt_vc_report can still return an authentic, policy-conformant Evidence payload built from providedBy. The frozen SD-JWT VC profile defines iss as the exact service.providerId, and this encoding is supposed to represent the same assertion as the JWS form; require iss and reject it unless it equals providedBy.

AGENTS.md reference: AGENTS.md:L43-L48

Useful? React with 👍 / 👎.

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.

2 participants