Skip to content

feat: add W3C Digital Credentials API support via @sirosfoundation/dc-api - #16

Closed
leifj wants to merge 67 commits into
mainfrom
feat/dc-api-polyfill
Closed

feat: add W3C Digital Credentials API support via @sirosfoundation/dc-api#16
leifj wants to merge 67 commits into
mainfrom
feat/dc-api-polyfill

Conversation

@leifj

@leifj leifj commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Integrates the W3C Digital Credentials API (DC API) into the vc verifier via the @sirosfoundation/dc-api polyfill package.

What changed

  • internal/verifier/staticembed/dc-api-polyfill.js / dc-api.js: Vendor the DC API browser polyfill for environments without native support
  • internal/verifier/staticembed/presentation-definition.html/js: Wire up DC API credential request path in the verifier presentation flow
  • internal/verifier/apiv1/: Add handler support for DC API credential presentation
  • Makefile: Add target to update the vendored DC API bundle

Why

The W3C Digital Credentials API is now supported in Chrome and is being rolled out across browsers. This change allows the vc verifier to request credentials via the browser-native API instead of QR-code-only flows, enabling a direct tap-to-present UX.

Update (vendored bundle now v0.5.0)

Live end-to-end testing against a real Android device surfaced a real bug: the native DC API path was passing the redirect-flow authorization_request URI (openid4vp://...?client_id=...&request_uri=...) directly as the DC API request value. That URI is neither a valid JWT (openid4vp-v1-signed needs one) nor a valid raw-params object (openid4vp-v1-unsigned needs that instead) - no protocol handler could parse it, so every native DC API attempt failed silently ("Your info wasn't found").

Fixed in @sirosfoundation/dc-api itself (v0.5.0): a new requestCredentialFromAuthorizationRequestURI() helper resolves a standard authorization request URI into whatever data shape the detected protocol actually needs (fetching request_uri for the JWT when required), so any verifier producing a standard authorization request gets a working native DC API path for free.

Two more bugs found and fixed in the same pass (both required for the above to actually reach a working state):

  • supported_wallets can arrive as JSON null (an unconfigured Go map marshals that way) - the schema had no fallback for it, so the whole /ui/metadata response failed to parse and the page hung on its loading spinner before a user could select a credential.
  • The claim-selection form always built DCQL meta.vct_values, even for mso_mdoc credentials, which have no vct - the correct constraint there is meta.doctype_value (OpenID4VP 1.0 6.4.1).

Testing

Sonarcloud quality gate issues resolved (commit a323ed65). Vendored bundle updated to v0.5.0 (was v0.2.0), verified end-to-end against a real Android device (Chrome DC API + native mdoc credential presentation) after the above fixes.

Leif Johansson and others added 30 commits June 11, 2026 13:41
… file

Adds a -priv-in flag that allows specifying an existing private JWK file
instead of generating a new key pair on every run. This is useful when
you need a stable signing key across multiple invocations.

When -priv-in is not set, behavior is unchanged (new key generated).
Use the Docker buildx automatic platform ARG TARGETARCH instead of
hardcoding GOARCH=amd64. When building with plain 'docker build', the
default (amd64) preserves current behavior. When building with
'docker buildx build --platform linux/amd64,linux/arm64', each
platform variant compiles with the correct GOARCH automatically.

This enables CI pipelines to produce multi-architecture container
images without modifying the Makefile or build scripts.
Add a CI workflow that builds and pushes multi-arch container images
for all services (verifier, registry, apigw, issuer) to GHCR.

Features:
- Multi-arch builds (linux/amd64, linux/arm64) via QEMU + buildx
- Matrix strategy: 4 parallel jobs, one per service
- Trivy CVE scanning with SARIF upload to Security tab
- Standard semver tag policy via docker/metadata-action
- GHA build cache per service
- Manual dispatch (workflow_dispatch) for ad-hoc builds

Triggers on semver tags (v*.*.*) and manual dispatch.

Depends on:
- SUNET#473 (TARGETARCH support for multi-arch)
- SUNET#474 (inline gobuild for self-contained builds)
Pass inputs.image_tag through an environment variable instead of
interpolating directly in the run block. This prevents potential
script injection via crafted dispatch inputs.

Addresses SonarQube security hotspot.
Pin action dependencies to immutable commit SHAs for supply-chain
security. Resolves 6 SonarCloud security hotspots.
- Fix tag glob: use [0-9]* (glob) not [0-9]+ (regex)
- Fix uses: indentation (align with name:/with: at step level)
- Guard inputs context: use github.event_name checks so inputs.*
  is never evaluated on push events
- Fix Trivy image-ref: use sanitized tag from custom-tag step output
  instead of raw inputs.image_tag
- Export SAFE_TAG as 'version' output for consistent downstream use
- Use step outputs for BUILDTAG instead of direct inputs reference
- Set context: git on docker/metadata-action so workflow_dispatch
  builds with inputs.git_ref derive metadata from the checked-out
  ref, not the triggering workflow ref.
- Use steps.meta.outputs.version (instead of github.ref_name) for
  BUILDTAG so the version embedded in binaries matches the image tag.
When no presentation request templates are configured, the verifier
falls back to buildDCQLQueryFromConfig() which iterated all claims
from the VCTM and added them to the DCQL query. This caused the
verifier to request every possible claim from the wallet, ignoring
any claim filtering configured in presentation request templates.

Remove the VCTM claim enumeration from the fallback path. When no
templates are configured, the DCQL query now omits the Claims field
entirely, letting the wallet decide what to disclose.

To request specific claims, configure presentation request templates
with explicit DCQL claim paths.

Fixes SUNET#480
Previously, when presentation_requests_dir was configured but template
loading failed (e.g. duplicate scopes across templates), the verifier
silently fell back to the buildDCQLQueryFromConfig() path, which
requests all VCTM claims instead of the configured subset.

This made configuration errors invisible — the verifier appeared to
work but requested far more claims than intended.

Now template loading errors propagate from loadPresentationTemplates()
through New(), preventing the verifier from starting with a broken
configuration.

Fixes SUNET#480
- Validate that -priv-in contains an EC P-256 private key immediately
  after parsing, failing fast with a clear error for wrong key type,
  curve, or public-only keys.
- Guard against output files (-jwt-out, -jwk-out, -priv-out) matching
  -priv-in, preventing accidental overwrite of the private key.
The redirect_uri validator resolved hostnames via net.LookupIP() and
rejected URIs whose hostnames didn't resolve (for http/https). This
caused ccTLD domains like example.se to fail registration while
example.org (which resolves to an IANA-reserved IP) succeeded.

Redirect URIs are never fetched server-side — they're URLs the browser
is redirected to. The SSRF concern that motivated the DNS check doesn't
apply here. Remove the DNS resolution and private-IP blocking from the
redirect_uri validator, keeping only the syntactic checks required by
RFC 6749 (scheme present, no fragment).

The safe_uri validator (used for server-side fetches like logo_uri)
retains its DNS/SSRF checks.

Add test cases for ccTLD and non-resolving hostnames.
Add a new endpoint that generates a pre-authorized credential offer for a
specific document in the datastore. This restores the capability that was
lost when PR SUNET#375 removed the old /api/v1/notification endpoint and the QR
field from the CompleteDocument model.

The endpoint:
1. Looks up the document by (authentic_source, scope, document_id)
2. Generates a credential offer with a pre-authorized code
3. Creates and persists an authorization context in the cache
4. Caches the document data for the credential endpoint
5. Returns the credential offer, credential_offer_url, and a QR code

This follows the same pre-authorized code flow pattern used by the OIDC
and SAML standalone authentication flows, but sources the document data
from the datastore instead of from authentication claims.

Closes SUNET#492
Wire OTel Metrics SDK alongside existing tracing infrastructure:
- pkg/metric: MeterProvider with OTLP push + Prometheus scrape exporter
- pkg/metric/vci.go: VCI counters (offers, tokens, credentials, notifications)
  and histograms (token latency, issuance latency)
- pkg/metric/vp.go: VP counters (requests, presentations, failures)
  and histogram (verification latency)

Instrument key handlers:
- apigw: OAuthToken, VCICredential, VCIDeferredCredential, VCINotification,
  OIDC RP credential offer creation
- verifier: CreateRequestObject, HandleDirectPost

All metric labels use protocol-level metadata only (format, grant_type,
credential_config_id, source, error_class) — no PII is ever recorded.

Adds /metrics Prometheus scrape endpoint to apigw HTTP server.
Adds common.metrics config section (mirrors common.tracing).
…ate swagger docs

- Add missing DatastorePreAuthOffer stub to unimplementedApiv1 struct to
  fix build error in admin status tests
- Add pkg/openid4vp to swagger-apigw parse directories so
  openid4vp.QRReply type is resolved
- Regenerate apigw swagger docs
Per reviewer feedback (issue SUNET#492 comment), QR code generation is
unnecessary server-side complexity that clients can handle more flexibly
(custom size, branding, etc).

The endpoint now returns only the credential_offer and credential_offer_url.
Clients can generate QR codes from the URL as needed.
When AuthorizationDetails is set on the AuthorizationContext, the token
endpoint returns authorization_details with credential_identifiers in
the token response. Per OID4VCI spec, this forces the wallet to use
credential_identifier (not credential_configuration_id) in the
credential request. Most wallets use credential_configuration_id for
pre-auth flows, causing a 400 error.

Remove AuthorizationDetails from the pre-auth context. The scope is
already conveyed via the Scopes field, and the credential offer itself
contains the credential_configuration_id.
The credential endpoint dispatches on AuthProvider to retrieve cached
documents. The datastore pre-auth handler was not setting AuthProvider,
causing 'unsupported or missing auth provider' errors when the wallet
redeemed the offer.

Add AuthProviderDatastore constant and set it on the authorization
context. The credential handler now recognizes it alongside the
existing OIDC/SAML/OpenID4VP providers.
The credential endpoint's requireIdentifier check rejected datastore
pre-auth sessions because they don't have an authenticated identifier.
Like assertion-based issuance, datastore issuance sources its data from
pre-uploaded documents, not identity-mapped lookups, so an empty
identifier is valid. Downstream code already guards identifier usage
with != "" checks.
…-api

Integrates the @sirosfoundation/dc-api library for DC API protocol
constants, feature detection, and native invocation. The verifier
polyfill adds transport fallbacks (redirect, QR, SSE/poll).

New files:
- dc-api.js: vendored bundle of @sirosfoundation/dc-api (3.2 kB)
- dc-api-polyfill.js: verifier integration importing the library,
  adding same-device redirect + cross-device SSE/poll fallbacks

Updated:
- presentation-definition.html/js: tries DC API first, falls back to QR
- authorize_enhanced.html: uses polyfill for credential requests

Detection uses W3C-specified feature checks:
- typeof DigitalCredential !== 'undefined'
- DigitalCredential.userAgentAllowsProtocol(protocol)

Ref: W3C Digital Credentials API spec, CS-07
Dep: https://github.com/sirosfoundation/dc-api
- dc-api.js: rebuild bundle with es2022 target, var→const
- dc-api-polyfill.js: use export...from, globalThis, RegExp.exec(),
  optional chaining, move SSE error handler to outer scope
- presentation-definition.html: add id/label for select (a11y),
  add aria-label for group toggle checkbox
- presentation-definition.js: remove unused imports, extract
  _tryNativeDCAPI() and _setupFallbackFlow() to reduce cognitive
  complexity, use optional chaining + globalThis
Adds newly exported: OID4VP_ALL_PROTOCOLS, OID4VP_SPEC_PROTOCOLS, isOID4VPProtocol
… §6.4.1

The DCQL spec (Section 6.4.1) defines claim_sets as an array of arrays
of claim identifiers, but the implementation used []string (flat array).
Additionally, ClaimQuery was missing the ID field which is required when
claim_sets is present on a CredentialQuery.

- Add ID field to ClaimQuery struct
- Update MarshalJSON/UnmarshalJSON/UnmarshalYAML to handle id field
- Change ClaimSet type from []string to [][]string
- Update copyDCQL to deep-copy both the new ID field and nested slices
- Add TestClaimSetsRoundTrip regression test
Native-app redirect URIs (e.g. com.example.app:/oauth2redirect) use
custom schemes without an authority component. Only require a hostname
for http/https schemes; for custom schemes, require at least a path
or opaque component beyond the scheme.
…n test

- Distinguish ErrNoDocumentFound from internal store failures instead
  of wrapping all errors as 'document not found'
- Use assert.ErrorIs in test for robustness
…vendor

- Call meter.Shutdown() in cmd/apigw and cmd/verifier for clean
  metric flush on graceful shutdown
- Record IssuanceLatency on the failure path too so latency stats
  include failed requests
- Run go mod tidy + go mod vendor to fix indirect annotations
The comment incorrectly referenced 'openid4vp-v1-unsigned' as the example
protocol check. The library actually prefers signed requests via
getBestProtocol() (signed > multisigned > unsigned).
Previously only org.iso.18013.5.1.mDL was routed to the mdl-issuer
policy. Other mDoc doctypes (e.g. eu.europa.ec.eudi.pid.1) fell through
to the generic credential-issuer action, which typically has no mdociaca
registry configured — causing 'no registry returned positive match'
denials in go-trust.

Since DocType is exclusively an ISO 18013-5 (mDoc) concept, any non-empty
DocType with an issuer/verifier role now routes to mdoc-issuer/mdoc-verifier
policies that include the IACA registry.
extractMDocIssuerID now checks (in order):
1. URI SANs (e.g. https://issuer.example.com)
2. DNS SANs → converted to https://<hostname>
3. Configured IssuerURL on the Verifier (new field)
4. Certificate Organization (fallback for allowlist registries)

This enables the mdociaca registry to discover IACA certificates via
.well-known/openid-credential-issuer metadata when the DS certificate
contains a SAN identifying the issuer.

Also adds WithMDocIssuerURL option to MDocHandler for cases where the
certificate doesn't contain a discoverable URL.
Adds the mdoc_iacas_uri field to .well-known/openid-credential-issuer
metadata, enabling go-trust's mdociaca registry to dynamically discover
IACA certificates for mDOC verification.

Configuration:
  issuer_metadata:
    mdoc_iacas_uri: "https://issuer.example.com/iacas"

The operator must serve an endpoint at that URL returning:
  {"iacas": [{"certificate": "<base64-DER>"}]}
masv3971 and others added 27 commits July 28, 2026 11:39
Addresses s-jairl's two comments on the PR:

- The DCQL playground UI (presentation-definition.js) always attempted
  the native W3C Digital Credentials API regardless of server config.
  Thread it through the existing verifier.digital_credentials.enable
  flag (already respected by authorize_enhanced.html) via a new
  dc_api_enabled field on /ui/metadata, instead of adding a duplicate
  toggle.

- When the user cancelled/declined the native DC API picker, the
  native call rejects with NotAllowedError. dc-api-polyfill.js's
  requestCredential() silently swallowed that and fell through to its
  own cross-device wait (SSE/poll, up to timeoutMs = 5min by default),
  even though both callers (presentation-definition.js,
  authorize_enhanced.html) already have their own immediate QR/wallet-
  link fallback UI. This left the page stuck on a loading spinner
  after cancelling, on both desktop Chromium and Android Chromium/
  Vanadium. Let native failures propagate directly to callers instead;
  the polyfill fallback path is now only reached when native DC API
  isn't available/usable at all, not as a retry after a completed
  native attempt.
Update pipeline version and dockerfile for dev-container default gola…
fix: add ClaimQuery.ID and fix ClaimSet type to [][]string per OID4VP §6.4.1
…uth-offer

feat: add POST /api/v1/datastore/preauth_offer endpoint
…-mdoc-format

fix(verifier): use credential format from metadata for custom presentation requests
…on-routing

fix: route all mDoc doctypes to mdoc-issuer policy for IACA validation
…s-validation

fix: don't DNS-resolve redirect_uris during client registration
…mit-claims

fix: do not enumerate all VCTM claims in fallback DCQL query
Consistency fix per Copilot review comment on PR SUNET#524 - the sibling
helper createValidJWTProof already formats this map one key per line.
# Conflicts:
#	go.mod
#	go.sum
#	vendor/modules.txt
- Move metric.NewVCI/NewVP construction out of cmd/apigw/main.go and
  cmd/verifier/main.go and into apiv1.New() itself, in both services.
  main.go now just constructs the shared *metric.Meter and passes it
  through; each apiv1.New() builds its own domain-specific metrics
  from it. Keeps metrics wiring next to the code that uses it instead
  of scattered across main().
- Rename the ambiguous `vci`/`vp` Client fields to `vciMetrics`/
  `vpMetrics` in both apigw and verifier for clarity, and update all
  call sites.
feat: add OpenTelemetry metrics for credential issuance and verification
# Conflicts:
#	internal/verifier/staticembed/presentation-definition.js
…-docker-ci

ci: add GitHub Actions workflow for Docker image builds
… precision

Per Copilot's low-confidence suggestions on PR SUNET#524:

- Express the tolerated/rejected iat offsets in createJWTProofWithIat
  tests relative to proofIatClockSkew instead of hard-coded durations,
  so the tests keep exercising the intended boundary if the constant
  changes.
- Set iat via jwtv5.NewNumericDate(iat) instead of iat.Unix(), which
  truncated sub-second precision and made these tests less faithful
  to how the verifier actually parses claims (claims.GetIssuedAt()).
…lock-skew

fix(openid4vci): allow clock skew when validating proof JWT iat
feat: W3C Digital Credentials API support via @sirosfoundation/dc-api
Bumps the vendored dc-api.js bundle to v0.5.0 and wires the verifier's
native DC API path through the new requestCredentialFromAuthorizationRequestURI
helper instead of passing the redirect-flow authorization_request URI
directly as the DC API request value - that URI is neither a valid JWT
(openid4vp-v1-signed) nor a valid raw-params object (openid4vp-v1-unsigned),
so no protocol handler could ever parse it and every native DC API attempt
failed. The new helper resolves the authorization request into whatever
shape the detected protocol actually needs.

Also fixes two bugs found in the same testing pass, both required for the
above to actually reach a working state:
- supported_wallets can arrive as JSON null (an unconfigured Go map
  marshals that way) - the schema had no fallback for it, so the whole
  /ui/metadata response failed to parse and the page hung on its loading
  spinner before a user could ever select a credential.
- The claim-selection form always built DCQL meta.vct_values, even for
  mso_mdoc credentials, which have no vct - the correct constraint there
  is meta.doctype_value (OpenID4VP 1.0 6.4.1). Sending vct_values for an
  mdoc credential matches nothing on the wallet side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhWrEC7D4b3vxZ4wm3gDML
@leifj
leifj force-pushed the feat/dc-api-polyfill branch from 77f46df to 30c3e69 Compare July 30, 2026 08:52
@leifj

leifj commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Superseding this PR - the DC API polyfill feature it represents was already merged upstream via SUNET#504, and this fork branch has since diverged (119 commits behind upstream/main, base main here hasn't been synced in ~7 weeks) making its current diff misleading (836 files) rather than reflective of actual unreviewed work.

The one commit here that was genuinely never upstreamed - the authorization-request-bridge fix (30c3e697) plus two related bugs found in the same testing pass - has been cherry-picked onto a clean branch off current upstream/main and opened as SUNET#534.

Closing this without merging; no content is lost, it's now tracked cleanly in SUNET#534.

@leifj leifj closed this Jul 31, 2026
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