feat: audience-scoped access — verification, per-audience proofs and access audit - #63
Merged
Merged
Conversation
Binds core's StaticTrustedIssuers to env configuration and reports the tier through the existing ghost-honesty port list; an unconfigured node reports the capability absent rather than denying every credential, and the operator's own DID is trusted only when explicitly opted in.
Adopts a compact JWS over the JCS-canonical credential as the wire format, reusing the existing kid-to-DID-to-verify path with EdDSA pinned, and composes signature, claims and issuer-trust checks so a VerifiedCredential means signed by a trusted issuer for the audience it claims; revocation remains unwired and is named as the one gap.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Compatibility | 1 medium |
| Security | 6 high |
🟢 Metrics 197 complexity · 41 duplication
Metric Results Complexity 197 Duplication 41
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
…ature-verification
Completes the credential path: an HTTP directory resolving issuer DID documents and revocation status lists, revocation folded into core's composed verifier so an unfetchable status list denies, and a /credential/dpp/{id} route serving each audience the disclosure classes it may see - deliberately off /public (a caller-varying body breaks caching and the public signature) and off /api/v1 (a repairer holds a credential, not an API key).
This was referenced Jul 28, 2026
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.
The audience-access campaign as one feature: transport, trusted issuers, signature verification, revocation, the read endpoint, per-audience proofs and an access audit. Plan:
dpp-docs/work/AUDIENCE-ACCESS-PLAN.md.Supersedes #60 and #62 — both are folded in here. Close them.
The shape
A reader presents a credential in
X-DPP-Credential. The node authenticates it, authorises it against this passport, records the access, and serves the passport filtered to what that audience may see — with a proof over that view.Wire format: VC-JWT
A compact JWS whose payload is the JCS-canonical credential. Chosen over an embedded Data Integrity
proofbecause it reuses the verification path this workspace already has (extract_kid_from_jws→ DID document →extract_key_by_fingerprint→verify_jws), keeps one canonicalisation scheme, is header-safe, and is the cheaper thing to change from — EN 18246 is unread and the ESPR Art. 11 credential implementing acts are unadopted. Rationale is in the module docs so it travels with the code.Decisions worth reviewing
Authorizationscheme.Beareris API keys,Basicis local admin. A credential is a different axis — who the reader is — and a machine integration can hold both./credential/dpp/{id}, not/public/*and not/api/v1/*. A public URL whose body varies by caller breaks caching and the meaning ofpublicJwsSignature. And/api/v1is API-key territory: a repairer holds a credential and no key, so requiring one would gate lawful access behind a commercial relationship with us.Audience::Publicrather than 401. Denying would also make the route a probe for which sector a passport is in.credential_issuers: ghost; a caller gets public data instead of a misleading success.Every view is verifiable
The original cut of this PR served non-public views with a proof that did not cover them, which is worse than serving none — to anyone checking, a mismatch is indistinguishable from tampering:
publicJwsSignature✅publicJwsSignature— covers less ❌jwsSignature— covers more ❌publicJwsSignaturehas no disclosure-table entry, so it defaulted toPublicand reached everyone. Now: a redacted view is a payload with every proof stripped, and the serving layer attaches the one that covers it.disclosure_signaturesis frozen at publish beside its two siblings, and the response carriesdisclosureSetnaming what it contains.Keyed by disclosure set, never by audience name (
public+restricted+individual). ESPR's actor vocabulary is ~14 classes, not battery Art. 77(2)'s three, and the Art. 9(2)(f) delegated act mapping actors to data does not exist for any of our sectors. An artefact named for the data it covers survives that mapping landing; one named"legitimateInterest"would need every passport re-signed.Access audit
Credentialed reads append to the passport's hash-chained trail: issuer DID, holder DID,
disclosureClasses, sector. Recorded before serving — serving elevated data without recording it is the one outcome that must not happen, so an audit failure fails the request. Out-of-scope reads are recorded too, showing the public classes they were reduced to.Anonymous reads are not audited. They are the free, unregistered baseline, and logging them would turn a public right into a tracked event.
Fail-closed, in four places
Unreachable issuer DID → no key → deny. Unfetchable status list on a credential that declares one → core treats it as revoked → deny. Unmodelled sector → no field policy →
sectorDatastripped for every audience. Missing proof for the requested disclosure set → error, never an unsigned fallback.The issuer DID is chosen by an unauthenticated caller
credential.issueris read from an unverified payload on a route needing no API key — the only outbound fetch in this workspace whose target a stranger picks. So it goes throughurl_guard::assert_public_target(https + DNS re-resolution, refusing loopback/private/link-local/metadata), redirects are not followed, bodies are capped, and the DID cache is bounded.There is deliberately no
allow_privateopt-out: the webhook guard has one because the operator chooses that target. The one legitimate loopback case — the operator's own DID — is not a fetch at all: it resolves from the in-process identity port, which also removes a production round-trip and the node's dependence on its own public hostname being internally reachable.The DNS-resolving guard moved from
webhook_drainintodpp_common::url_guardso there is one implementation of the rule rather than two.Related fix: trusted issuers are now derived from the node's published DID document, not its base URL.
CREDENTIAL_ISSUERS_SELF=truepreviously registeredhttp://host:portwhere credentials carrydid:web:host%3Aport, so self-trust could never match — and the non-empty list still reported the portLive, advertising a capability that denied everything. Non-DID entries are now dropped with a warning.Migrations
0026— admits thecredentialed_readaudit action.0027— addsdisclosureSignaturesto the retention guard'smutable_keys, alongsidejwsSignatureandpublicJwsSignature. All three are re-signed on re-publish; without this, re-publishing a retention-locked passport is refused.MUTABLE_FIELDSand the resolver parity test are updated in lockstep.Tests
just checkgreen in both repos, 737 passing across the workspace including Docker integration.New: 10 HTTP-level tests for the route itself (
audience_read_route.rs) — anonymous reads byte-identical to/public, per-audience field grants, out-of-scope downgrade, 401 shape, suspended → 410, unconfigured node, and the audit assertions. Plus scope-downgrade unit tests, SSRF guard tests at both layers, disclosure-proof round-trip tests, the operator-DID regression, a CORS preflight test, and a check that rejection details never leakDebugoutput or caller-supplied strings.Two properties found the hard way and now pinned: the shared key extractor only accepts keys referenced from
assertionMethod(my first fixture omitted it — the happy path failed while every negative test passed), and a rejected credential must produce a byte-identical body whether or not the passport exists.Known gap
Product-category scope is not enforced.
credential_subjectcarries free-stringproduct_categorieswith no defined mapping to the typedProductCategory— whoseOther(_)variant does not even serialise as a plain string — so enforcing it would mean inventing a correspondence and then relying on it as a security control. Sector scope is enforced. Named in the module docs.Related:
dpp-core#69—DppAccessCredentialhas noproofmember, which is why the envelope is external.