@openeudi/openid4vp 0.9.3 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,7 +124,7 @@ Mismatches return `valid: false` — they do not throw. Only crypto/structural f
124
124
 
125
125
  > **Privacy — diagnostics are verifier-internal.** `match.unmatched[].reason` and `detail` (including `value_mismatch`) are intended for verifier-side logging, debugging, and admin UIs. OpenID4VP §11 warns that per-claim verification outcomes can reveal wallet contents to observers. Do NOT echo these diagnostics into the OpenID4VP wire response sent back to the wallet, into end-user-visible error messages that another party could correlate, or into public analytics/third-party logs. The protocol's own error codes are the public interface; these fields are your internal instrumentation.
126
126
 
127
- ## Signed authorization requests (x509_san_dns)
127
+ ## Signed authorization requests (x509_san_dns, x509_hash)
128
128
 
129
129
  For flows that require a signed request object (JAR) per OpenID4VP 1.0 §5.10, use `createSignedAuthorizationRequest`:
130
130
 
@@ -132,12 +132,13 @@ For flows that require a signed request object (JAR) per OpenID4VP 1.0 §5.10, u
132
132
  import { createSignedAuthorizationRequest } from "@openeudi/openid4vp";
133
133
 
134
134
  const req = await createSignedAuthorizationRequest({
135
- hostname: "verifier.example.com",
135
+ clientIdPrefix: "x509_san_dns", // or "x509_hash"; defaults to "x509_san_dns"
136
+ hostname: "verifier.example.com", // required for x509_san_dns; optional for x509_hash
136
137
  requestUri: "https://verifier.example.com/request.jwt",
137
138
  responseUri: "https://verifier.example.com/response",
138
139
  nonce,
139
140
  signer: verifierKeyPair, // CryptoKeyPair with public+private
140
- certificateChain: [leafCertDer], // DER-encoded, leaf SAN DNSName must equal hostname
141
+ certificateChain: [leafCertDer], // DER-encoded, leaf SAN DNSName must include hostname when one is given
141
142
  encryptionKey: {
142
143
  publicJwk: encryptionPublicJwk, // must include alg, e.g. "ECDH-ES"
143
144
  },
@@ -151,14 +152,11 @@ const req = await createSignedAuthorizationRequest({
151
152
  // (Content-Type: application/oauth-authz-req+jwt)
152
153
  ```
153
154
 
154
- The caller hosts `req.requestObject` at `requestUri` (the library does not host HTTP). The library verifies that the signing key's public SPKI matches the leaf certificate's public key — an attempt to sign with a mismatched key fails with `SignedRequestBuildError: signing_key_cert_mismatch`.
155
-
156
- The emitted `client_metadata` carries both shapes for compatibility:
155
+ `clientIdPrefix: "x509_hash"` sets `client_id` to `x509_hash:` followed by the base64url-encoded SHA-256 hash of the DER-encoded leaf certificate, per OpenID4VP 1.0 §5.9.3 (referenced by HAIP 1.0 Final for its mandated Client Identifier Prefix). `hostname` is not required in this mode, since the `client_id` is derived from the certificate rather than the host. If you do supply one it is still checked against the leaf SAN DNSName values — a supplied hostname that the certificate does not cover is treated as a misconfiguration (`hostname_cert_mismatch`) rather than silently ignored.
157
156
 
158
- - 1.0 Final plural — `encrypted_response_enc_values_supported: ["A128GCM", ...]`
159
- - ID3 singular — `authorization_encrypted_response_alg: "ECDH-ES"`, `authorization_encrypted_response_enc: "A128GCM"`
157
+ The caller hosts `req.requestObject` at `requestUri` (the library does not host HTTP). The library verifies that the signing key's public SPKI matches the leaf certificate's public key — an attempt to sign with a mismatched key fails with `SignedRequestBuildError: signing_key_cert_mismatch`.
160
158
 
161
- Verifiers reading either shape (e.g. the OIDF conformance suite reads ID3 directly) work without bespoke configuration.
159
+ The emitted `client_metadata` carries the 1.0 Final shape: `encrypted_response_enc_values_supported: ["A128GCM", ...]`.
162
160
 
163
161
  ## Authorization responses (direct_post and direct_post.jwt)
164
162
 
@@ -395,7 +393,7 @@ This library implements the **verifier side** of OpenID4VP for SD-JWT VC and mDO
395
393
  - **mDOC / ISO 18013-5** `mso_mdoc` format — CBOR decoding, claim extraction, COSE_Sign1 signature verification, MobileSecurityObject validity enforcement, IssuerSignedItem digest verification. Device authentication (ISO 18013-5 §9.1.3) is **mandatory and fails closed**: the `DeviceSignature` (COSE_Sign1) over `DeviceAuthentication`/`SessionTranscript` is verified against the MSO-committed device key. `DeviceMac` (COSE_Mac0) is **not supported and is rejected**.
396
394
  - **DCQL** — authorization request builder with DCQL query, matching via [@openeudi/dcql](https://www.npmjs.com/package/@openeudi/dcql), `verifyPresentation` for combined crypto + match.
397
395
  - **HAIP** — `buildHaipQuery` / `validateHaipQuery` helpers for the High Assurance Interoperability Profile.
398
- - **Signed authorization requests (JAR)** — `createSignedAuthorizationRequest` per RFC 9101 / OpenID4VP 1.0 §5.10 with `x509_san_dns` client-id binding. Emits both 1.0 Final and ID3 `client_metadata` shapes for verifier interop.
396
+ - **Signed authorization requests (JAR)** — `createSignedAuthorizationRequest` per RFC 9101 / OpenID4VP 1.0 §5.10 with `x509_san_dns` or `x509_hash` client-id binding. Emits the OpenID4VP 1.0 Final `client_metadata` shape.
399
397
  - **Encrypted responses** — `decryptAuthorizationResponse` for `direct_post.jwt` (ECDH-ES + A128GCM/A256GCM), `verifyAuthorizationResponse` for the 1.0 §8.1 object-keyed `vp_token` envelope.
400
398
  - **X.509 chain validation** — RFC 5280 chain building including `nameConstraints`, `StaticTrustStore`, `CompositeTrustStore`.
401
399
  - **Revocation checking** — OCSP-first with CRL fallback (`revocationPolicy: 'skip' | 'prefer' | 'require'`).
@@ -406,7 +404,6 @@ This library implements the **verifier side** of OpenID4VP for SD-JWT VC and mDO
406
404
  **What is NOT yet implemented** (planned for follow-up releases):
407
405
 
408
406
  - Multi-credential DCQL queries (multiple query ids) and multi-presentation arrays per query id — currently rejected with `MultipleCredentialsNotSupportedError`.
409
- - `client_id_scheme: x509_hash` (HAIP 1.0 final's mandated scheme) — only `x509_san_dns` is supported today.
410
407
  - Self-signed-leaf rejection per HAIP 1.0 final's strict constraint (current behaviour accepts self-signed leaves for the verifier's own identity).
411
408
  - SIOPv2 (Self-Issued OpenID Provider) identity flows.
412
409
 
@@ -439,6 +436,8 @@ See [CHANGELOG.md](./CHANGELOG.md) for per-release changes. Key migration moment
439
436
  - **0.6.0** — DCQL surfaces specific `UnmatchedReason` values via `@openeudi/dcql@0.2.0` (BREAKING for callers reading `match.unmatched[].reason`).
440
437
  - **0.7.0** — `createSignedAuthorizationRequest`, `decryptAuthorizationResponse`, `verifyAuthorizationResponse` for HAIP / 1.0 §8.1 envelopes.
441
438
  - **0.8.0** — additive: ID3 `client_metadata` bridge, `trustedIssuerJwks` opt-in, transitive SD-JWT disclosure check.
439
+ - **0.9.3** — `reflect-metadata` is loaded by the package itself in both ESM and CJS builds. If you added a manual `import "reflect-metadata"` before importing this library to work around the 0.9.2 bug, you can drop it.
440
+ - **0.10.0** — `createSignedAuthorizationRequest` gains `clientIdPrefix` (`x509_san_dns` default, or `x509_hash`), and `hostname` becomes optional — required only for `x509_san_dns`, but still validated against the leaf SAN whenever supplied. **Wire change for existing callers:** the emitted `client_metadata` no longer carries the singular ID3 `authorization_encrypted_response_alg` / `authorization_encrypted_response_enc` fields added in 0.8.0. Verifiers that read the 1.0 Final `encrypted_response_enc_values_supported` array (and `alg` from `client_metadata.jwks`) are unaffected; anything still reading the singular fields needs updating.
442
441
 
443
442
  ## License
444
443
 
package/dist/index.cjs CHANGED
@@ -8268,14 +8268,23 @@ async function createSignedAuthorizationRequest(input, query) {
8268
8268
  "vpFormatsSupported is required and must be non-empty"
8269
8269
  );
8270
8270
  }
8271
+ const clientIdPrefix = input.clientIdPrefix ?? "x509_san_dns";
8271
8272
  const leafCert = new x509.X509Certificate(toArrayBuffer(input.certificateChain[0]));
8272
- const sanDnsNames = extractDnsNames(leafCert);
8273
- if (!sanDnsNames.includes(input.hostname)) {
8273
+ if (clientIdPrefix === "x509_san_dns" && !input.hostname) {
8274
8274
  throw new exports.SignedRequestBuildError(
8275
- "hostname_cert_mismatch",
8276
- `leaf cert SAN DNSName values [${sanDnsNames.join(", ")}] do not include hostname "${input.hostname}"`
8275
+ "missing_hostname",
8276
+ 'hostname is required for clientIdPrefix "x509_san_dns"'
8277
8277
  );
8278
8278
  }
8279
+ if (input.hostname) {
8280
+ const sanDnsNames = extractDnsNames(leafCert);
8281
+ if (!sanDnsNames.includes(input.hostname)) {
8282
+ throw new exports.SignedRequestBuildError(
8283
+ "hostname_cert_mismatch",
8284
+ `leaf cert SAN DNSName values [${sanDnsNames.join(", ")}] do not include hostname "${input.hostname}"`
8285
+ );
8286
+ }
8287
+ }
8279
8288
  const signerPublicSpki = new Uint8Array(
8280
8289
  await crypto.subtle.exportKey("spki", input.signer.publicKey)
8281
8290
  );
@@ -8302,7 +8311,7 @@ async function createSignedAuthorizationRequest(input, query) {
8302
8311
  }
8303
8312
  }
8304
8313
  const state = input.state ?? uuid.v4();
8305
- const clientId = `x509_san_dns:${input.hostname}`;
8314
+ const clientId = clientIdPrefix === "x509_hash" ? `x509_hash:${await sha256Base64Url(input.certificateChain[0])}` : `x509_san_dns:${input.hostname}`;
8306
8315
  const now = Math.floor(Date.now() / 1e3);
8307
8316
  const clientMetadata = {
8308
8317
  vp_formats_supported: input.vpFormatsSupported
@@ -8319,8 +8328,6 @@ async function createSignedAuthorizationRequest(input, query) {
8319
8328
  clientMetadata.jwks = { keys: [jwk] };
8320
8329
  const encValues = enc3 ?? [...DEFAULT_SUPPORTED_ENC_VALUES];
8321
8330
  clientMetadata.encrypted_response_enc_values_supported = encValues;
8322
- clientMetadata.authorization_encrypted_response_alg = "ECDH-ES";
8323
- clientMetadata.authorization_encrypted_response_enc = encValues[0];
8324
8331
  }
8325
8332
  const payload = {
8326
8333
  iss: clientId,
@@ -8365,6 +8372,10 @@ function bytesEqual(a, b) {
8365
8372
  }
8366
8373
  return true;
8367
8374
  }
8375
+ async function sha256Base64Url(derCert) {
8376
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", toArrayBuffer(derCert)));
8377
+ return bytesToBase64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8378
+ }
8368
8379
  function bytesToBase64(bytes) {
8369
8380
  let s = "";
8370
8381
  for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
@@ -9684,8 +9695,9 @@ async function verifyPresentation(vpToken, query, options) {
9684
9695
  const parsed = await parsePresentation(vpToken, options);
9685
9696
  const dcqlFormat = INTERNAL_TO_DCQL_FORMAT[parsed.format] ?? parsed.format;
9686
9697
  const decodedClaims = parsed.format === "mdoc" && parsed.namespacedClaims !== void 0 ? parsed.namespacedClaims : parsed.claims;
9698
+ const presentedCredentialQuery = query.credentials.find((c) => c.format === dcqlFormat) ?? query.credentials[0];
9687
9699
  const decoded = {
9688
- id: query.credentials[0]?.id ?? "presented",
9700
+ id: presentedCredentialQuery?.id ?? "presented",
9689
9701
  format: dcqlFormat,
9690
9702
  claims: decodedClaims
9691
9703
  };
@@ -9806,8 +9818,12 @@ async function verifyAuthorizationResponse(envelope, query, options) {
9806
9818
  if (queryIds.length > 1 || presentationCount > 1) {
9807
9819
  throw new exports.MultipleCredentialsNotSupportedError(queryIds.length, presentationCount);
9808
9820
  }
9809
- const presentation = vpToken[queryIds[0]][0];
9810
- const decodedPresentation = query.credentials[0]?.format === "mso_mdoc" && typeof presentation === "string" ? base64UrlToBytes(presentation) : presentation;
9821
+ const presentedQueryId = queryIds[0];
9822
+ const presentation = vpToken[presentedQueryId][0];
9823
+ const presentedCredentialQuery = query.credentials.find(
9824
+ (c) => c.id === presentedQueryId
9825
+ );
9826
+ const decodedPresentation = presentedCredentialQuery?.format === "mso_mdoc" && typeof presentation === "string" ? base64UrlToBytes(presentation) : presentation;
9811
9827
  return verifyPresentation(decodedPresentation, query, options);
9812
9828
  }
9813
9829
 
@@ -10440,7 +10456,7 @@ function equalBytes(a, b) {
10440
10456
 
10441
10457
  // src/index.ts
10442
10458
  init_errors();
10443
- var VERSION = "0.9.3";
10459
+ var VERSION = "0.10.1";
10444
10460
  /*! Bundled license information:
10445
10461
 
10446
10462
  pvtsutils/build/index.es.js: