Skip to content

feat: audience-scoped access — verification, per-audience proofs and access audit - #63

Merged
LKSNDRTMLKV merged 6 commits into
mainfrom
feat/credential-signature-verification
Jul 29, 2026
Merged

feat: audience-scoped access — verification, per-audience proofs and access audit#63
LKSNDRTMLKV merged 6 commits into
mainfrom
feat/credential-signature-verification

Conversation

@LKSNDRTMLKV

@LKSNDRTMLKV LKSNDRTMLKV commented Jul 28, 2026

Copy link
Copy Markdown
Member

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.

⚠️ Blocked on dpp-core #70, which is not published. This branch builds against it via the local [patch.crates-io] override (just core-local). .cargo/config.toml is git-ignored, so CI will fail to compile until core is released — Passport::disclosure_signatures does not exist in the published 0.11.0. That release is a breaking change wanting 0.12.0 (lockstep).

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.

X-DPP-Credential: <compact JWS>
  → decode payload            (attacker-controlled until proven otherwise)
  → resolve issuer did:web    → SSRF-guarded, or in-process for our own DID
  → verify signature          EdDSA pinned, alg:none rejected, kid-selected
  → fetch revocation list     only now — an unsigned status URL is attacker chosen
  → claims + trust + revocation via core's composed verifier
  ── database ──
  → scope to the passport's sector → Audience
  → append the access to the audit trail
  → disclosure classes → filtered passport + the proof covering it

Wire format: VC-JWT

A compact JWS whose payload is the JCS-canonical credential. Chosen over an embedded Data Integrity proof because it reuses the verification path this workspace already has (extract_kid_from_jws → DID document → extract_key_by_fingerprintverify_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

  • A dedicated header, not a third Authorization scheme. Bearer is API keys, Basic is 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 of publicJwsSignature. And /api/v1 is API-key territory: a repairer holds a credential and no key, so requiring one would gate lawful access behind a commercial relationship with us.
  • No credential is not an error. It serves the signed public view. ESPR Art. 11(b) requires access free of charge, and the toy and detergent regulations forbid requiring consumers to register or supply a password — anonymous read is the baseline, not a degraded case.
  • Out of scope is a downgrade, not a denial. A battery credential on a textile passport is valid; it simply unlocks nothing there, so it resolves to Audience::Public rather than 401. Denying would also make the route a probe for which sector a passport is in.
  • Authentication before the database, scope after it. Everything that can make a credential invalid is settled pre-DB, so a bad credential gets an identical 401 whether or not the passport exists. Scope is not validity — it decides how much an already-valid credential unlocks — so it is the only check that may wait for the row.
  • Unconfigured node serves public rather than denying. The trust report says 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:

Audience Body served Proof it had
Public public view publicJwsSignature
LegitimateInterest public + restricted + individual publicJwsSignature — covers less
Authority public + restricted + conformity jwsSignature — covers more

publicJwsSignature has no disclosure-table entry, so it defaulted to Public and reached everyone. Now: a redacted view is a payload with every proof stripped, and the serving layer attaches the one that covers it. disclosure_signatures is frozen at publish beside its two siblings, and the response carries disclosureSet naming 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 → sectorData stripped 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.issuer is 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 through url_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_private opt-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_drain into dpp_common::url_guard so 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=true previously registered http://host:port where credentials carry did:web:host%3Aport, so self-trust could never match — and the non-empty list still reported the port Live, advertising a capability that denied everything. Non-DID entries are now dropped with a warning.

Migrations

  • 0026 — admits the credentialed_read audit action.
  • 0027 — adds disclosureSignatures to the retention guard's mutable_keys, alongside jwsSignature and publicJwsSignature. All three are re-signed on re-publish; without this, re-publishing a retention-locked passport is refused. MUTABLE_FIELDS and the resolver parity test are updated in lockstep.

Tests

just check green 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 leak Debug output 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_subject carries free-string product_categories with no defined mapping to the typed ProductCategory — whose Other(_) 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#69DppAccessCredential has no proof member, which is why the envelope is external.

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.
@codacy-production

codacy-production Bot commented Jul 28, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 6 high · 1 medium

Alerts:
⚠ 7 issues (≤ 0 issues of at least minor severity)

Results:
7 new issues

Category Results
Compatibility 1 medium
Security 6 high

View in Codacy

🟢 Metrics 197 complexity · 41 duplication

Metric Results
Complexity 197
Duplication 41

View in Codacy

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.

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).
@LKSNDRTMLKV LKSNDRTMLKV changed the title feat(vault): verify access credentials as VC-JWT feat: audience-scoped access — credential verification and reads Jul 28, 2026
@LKSNDRTMLKV LKSNDRTMLKV changed the title feat: audience-scoped access — credential verification and reads feat: audience-scoped access — verification, per-audience proofs and access audit Jul 29, 2026
@LKSNDRTMLKV
LKSNDRTMLKV merged commit 074baab into main Jul 29, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant