@openeudi/openid4vp 0.6.0 → 0.8.0

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,6 +124,96 @@ 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)
128
+
129
+ For flows that require a signed request object (JAR) per OpenID4VP 1.0 §5.10, use `createSignedAuthorizationRequest`:
130
+
131
+ ```ts
132
+ import { createSignedAuthorizationRequest } from "@openeudi/openid4vp";
133
+
134
+ const req = await createSignedAuthorizationRequest({
135
+ hostname: "verifier.example.com",
136
+ requestUri: "https://verifier.example.com/request.jwt",
137
+ responseUri: "https://verifier.example.com/response",
138
+ nonce,
139
+ signer: verifierKeyPair, // CryptoKeyPair with public+private
140
+ certificateChain: [leafCertDer], // DER-encoded, leaf SAN DNSName must equal hostname
141
+ encryptionKey: {
142
+ publicJwk: encryptionPublicJwk, // must include alg, e.g. "ECDH-ES"
143
+ },
144
+ vpFormatsSupported: {
145
+ "dc+sd-jwt": { "sd-jwt_alg_values": ["ES256"] },
146
+ },
147
+ }, dcqlQuery);
148
+
149
+ // req.uri — the short URI to hand to the wallet
150
+ // req.requestObject — the JWS the verifier must host at requestUri
151
+ // (Content-Type: application/oauth-authz-req+jwt)
152
+ ```
153
+
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
+ ## Authorization responses (direct_post and direct_post.jwt)
157
+
158
+ Wallets POST the Authorization Response to your `responseUri`. The library is stateless — you MUST compare the envelope's `state` against the value you issued before treating the response as trustworthy. The recommended pattern differs slightly between the unencrypted and encrypted modes.
159
+
160
+ ### Unencrypted (`direct_post`)
161
+
162
+ The envelope arrives as form-encoded JSON; parse it, check `state`, then verify:
163
+
164
+ ```ts
165
+ import { verifyAuthorizationResponse } from "@openeudi/openid4vp";
166
+
167
+ const envelope = parsedVpTokenObject; // { vp_token, state, ... }
168
+
169
+ if (envelope.state !== submittedState) {
170
+ throw new Error("state mismatch — possible CSRF / replay");
171
+ }
172
+
173
+ const result = await verifyAuthorizationResponse(envelope, dcqlQuery, {
174
+ trustedCertificates: [issuerCertDer],
175
+ nonce,
176
+ });
177
+ ```
178
+
179
+ ### Encrypted (`direct_post.jwt`)
180
+
181
+ The wallet wraps the envelope in a JWE. Decrypt explicitly so you can check `state` against the decrypted envelope **before** verification runs:
182
+
183
+ ```ts
184
+ import {
185
+ decryptAuthorizationResponse,
186
+ verifyAuthorizationResponse,
187
+ } from "@openeudi/openid4vp";
188
+
189
+ const decrypted = await decryptAuthorizationResponse(
190
+ form.get("response"), // the JWE string
191
+ verifierEncryptionPrivateKey,
192
+ );
193
+
194
+ if (decrypted.state !== submittedState) {
195
+ throw new Error("state mismatch — possible CSRF / replay");
196
+ }
197
+
198
+ const result = await verifyAuthorizationResponse(decrypted, dcqlQuery, {
199
+ trustedCertificates: [issuerCertDer],
200
+ nonce,
201
+ });
202
+ ```
203
+
204
+ `verifyAuthorizationResponse` also accepts the JWE directly via `{ response: jwe }` together with `options.decryptionKey` — but that path makes the `state` check easy to skip, since the caller never holds the decrypted envelope. Prefer the explicit two-step pattern above.
205
+
206
+ `verifyAuthorizationResponse` accepts the OpenID4VP 1.0 §8.1 envelope shape: `vp_token` is always an object keyed by DCQL credential query id, with arrays of presentations. This release supports **single-credential single-presentation only** — multi-credential queries or multi-presentation arrays throw `MultipleCredentialsNotSupportedError`.
207
+
208
+ ### Supported JWE algorithms
209
+
210
+ `direct_post.jwt` decryption supports:
211
+
212
+ - `alg`: `ECDH-ES` (driven by the encryption JWK's `alg` parameter)
213
+ - `enc`: `A128GCM`, `A256GCM` (HAIP requires both)
214
+
215
+ Other algorithms throw `UnsupportedJweError`.
216
+
127
217
  ### ParseOptions / VerifyOptions
128
218
 
129
219
  Both `parsePresentation` and `verifyPresentation` accept:
@@ -248,6 +338,10 @@ This library implements the **verifier side** of OpenID4VP for SD-JWT VC and mDO
248
338
 
249
339
  EUDI Architecture Reference Framework (ARF) alignment: tracks OpenID4VP 1.0 final. Full ARF 1.4+ profile compliance will be added before a stable 1.0.
250
340
 
341
+ ## OIDF Conformance Testing
342
+
343
+ Verifier-side conformance is automated against a self-hosted OpenID Foundation conformance suite. See `docs/manual-testing/oidf-interop.md` for both the CI orchestrator (`npm run oidf:ci -- --profile=happy-flow|full`) and the manual hosted-demo escape hatch.
344
+
251
345
  ## Related packages
252
346
 
253
347
  - **[@openeudi/core](https://www.npmjs.com/package/@openeudi/core)** -- Framework-agnostic EUDI Wallet verification protocol engine with session management and QR code generation.
package/dist/index.cjs CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  var x509 = require('@peculiar/x509');
4
4
  var uuid = require('uuid');
5
- var cborX = require('cbor-x');
6
5
  var jose = require('jose');
6
+ var cborX = require('cbor-x');
7
7
  var decode = require('@sd-jwt/decode');
8
8
  var dcql = require('@openeudi/dcql');
9
9
  var xmldom = require('@xmldom/xmldom');
@@ -40,7 +40,7 @@ var __export = (target, all) => {
40
40
  };
41
41
 
42
42
  // src/errors.ts
43
- exports.InvalidSignatureError = void 0; exports.ExpiredCredentialError = void 0; exports.UnsupportedFormatError = void 0; exports.MalformedCredentialError = void 0; exports.NonceValidationError = void 0; exports.HaipValidationError = void 0; exports.OpenID4VPError = void 0; exports.TrustAnchorNotFoundError = void 0; exports.CertificateChainError = void 0; exports.RevokedCertificateError = void 0; exports.RevocationCheckFailedError = void 0; exports.LotlFetchError = void 0; exports.LotlSignatureError = void 0;
43
+ exports.InvalidSignatureError = void 0; exports.ExpiredCredentialError = void 0; exports.UnsupportedFormatError = void 0; exports.MalformedCredentialError = void 0; exports.NonceValidationError = void 0; exports.HaipValidationError = void 0; exports.OpenID4VPError = void 0; exports.TrustAnchorNotFoundError = void 0; exports.CertificateChainError = void 0; exports.RevokedCertificateError = void 0; exports.RevocationCheckFailedError = void 0; exports.LotlFetchError = void 0; exports.LotlSignatureError = void 0; exports.SignedRequestBuildError = void 0; exports.UnsupportedJweError = void 0; exports.DecryptionFailedError = void 0; exports.MissingDecryptionKeyError = void 0; exports.MultipleCredentialsNotSupportedError = void 0;
44
44
  var init_errors = __esm({
45
45
  "src/errors.ts"() {
46
46
  exports.InvalidSignatureError = class extends Error {
@@ -136,6 +136,47 @@ var init_errors = __esm({
136
136
  exports.LotlSignatureError = class extends exports.OpenID4VPError {
137
137
  code = "lotl_signature_invalid";
138
138
  };
139
+ exports.SignedRequestBuildError = class extends exports.OpenID4VPError {
140
+ code;
141
+ constructor(code, message) {
142
+ super(message);
143
+ this.code = code;
144
+ }
145
+ };
146
+ exports.UnsupportedJweError = class extends exports.OpenID4VPError {
147
+ code = "unsupported_jwe";
148
+ alg;
149
+ enc;
150
+ constructor(alg, enc) {
151
+ super(`Unsupported JWE algorithms: alg=${alg}, enc=${enc}`);
152
+ this.alg = alg;
153
+ this.enc = enc;
154
+ }
155
+ };
156
+ exports.DecryptionFailedError = class extends exports.OpenID4VPError {
157
+ code = "decryption_failed";
158
+ constructor(message = "Failed to decrypt authorization response", options) {
159
+ super(message, options);
160
+ }
161
+ };
162
+ exports.MissingDecryptionKeyError = class extends exports.OpenID4VPError {
163
+ code = "missing_decryption_key";
164
+ constructor(message = "decryptionKey is required to verify a direct_post.jwt response") {
165
+ super(message);
166
+ }
167
+ };
168
+ exports.MultipleCredentialsNotSupportedError = class extends exports.OpenID4VPError {
169
+ code = "multi_credential_unsupported";
170
+ entryCount;
171
+ presentationCount;
172
+ constructor(entryCount, presentationCount) {
173
+ super(
174
+ `Multi-credential presentations are not yet supported: ${entryCount} queryId entries, ${presentationCount} presentations`
175
+ );
176
+ this.entryCount = entryCount;
177
+ this.presentationCount = presentationCount;
178
+ }
179
+ };
139
180
  }
140
181
  });
141
182
 
@@ -8045,6 +8086,138 @@ function createAuthorizationRequest(input, query) {
8045
8086
  };
8046
8087
  }
8047
8088
 
8089
+ // src/signed-authorization.ts
8090
+ init_errors();
8091
+ var DEFAULT_SUPPORTED_ENC_VALUES = ["A128GCM", "A256GCM"];
8092
+ var SUPPORTED_SIGNING_ALGS = /* @__PURE__ */ new Set(["ES256", "ES384", "RS256"]);
8093
+ async function createSignedAuthorizationRequest(input, query) {
8094
+ if (input.certificateChain.length === 0) {
8095
+ throw new exports.SignedRequestBuildError(
8096
+ "empty_cert_chain",
8097
+ "certificateChain must contain at least the leaf certificate"
8098
+ );
8099
+ }
8100
+ const signingAlg = input.signingAlgorithm ?? "ES256";
8101
+ if (!SUPPORTED_SIGNING_ALGS.has(signingAlg)) {
8102
+ throw new exports.SignedRequestBuildError(
8103
+ "unsupported_signing_alg",
8104
+ `signingAlgorithm ${signingAlg} is not supported (expected ES256/ES384/RS256)`
8105
+ );
8106
+ }
8107
+ if (!input.vpFormatsSupported || Object.keys(input.vpFormatsSupported).length === 0) {
8108
+ throw new exports.SignedRequestBuildError(
8109
+ "missing_vp_formats",
8110
+ "vpFormatsSupported is required and must be non-empty"
8111
+ );
8112
+ }
8113
+ const leafCert = new x509.X509Certificate(toArrayBuffer(input.certificateChain[0]));
8114
+ const sanDnsNames = extractDnsNames(leafCert);
8115
+ if (!sanDnsNames.includes(input.hostname)) {
8116
+ throw new exports.SignedRequestBuildError(
8117
+ "hostname_cert_mismatch",
8118
+ `leaf cert SAN DNSName values [${sanDnsNames.join(", ")}] do not include hostname "${input.hostname}"`
8119
+ );
8120
+ }
8121
+ const signerPublicSpki = new Uint8Array(
8122
+ await crypto.subtle.exportKey("spki", input.signer.publicKey)
8123
+ );
8124
+ const leafSpki = new Uint8Array(leafCert.publicKey.rawData);
8125
+ if (!bytesEqual(signerPublicSpki, leafSpki)) {
8126
+ throw new exports.SignedRequestBuildError(
8127
+ "signing_key_cert_mismatch",
8128
+ "signer.publicKey SPKI does not match leaf certificate SPKI"
8129
+ );
8130
+ }
8131
+ const responseMode = input.responseMode ?? "direct_post.jwt";
8132
+ if (responseMode === "direct_post.jwt") {
8133
+ if (!input.encryptionKey?.publicJwk) {
8134
+ throw new exports.SignedRequestBuildError(
8135
+ "missing_encryption_jwk",
8136
+ "direct_post.jwt response mode requires encryptionKey.publicJwk"
8137
+ );
8138
+ }
8139
+ if (!input.encryptionKey.publicJwk.alg) {
8140
+ throw new exports.SignedRequestBuildError(
8141
+ "missing_encryption_alg",
8142
+ "encryptionKey.publicJwk must carry alg (OpenID4VP 1.0 \xA78.3)"
8143
+ );
8144
+ }
8145
+ }
8146
+ const state = input.state ?? uuid.v4();
8147
+ const clientId = `x509_san_dns:${input.hostname}`;
8148
+ const now = Math.floor(Date.now() / 1e3);
8149
+ const clientMetadata = {
8150
+ vp_formats_supported: input.vpFormatsSupported
8151
+ };
8152
+ if (responseMode === "direct_post.jwt" && input.encryptionKey) {
8153
+ const enc = input.encryptionKey.supportedEncValues;
8154
+ if (enc !== void 0 && enc.length === 0) {
8155
+ throw new exports.SignedRequestBuildError(
8156
+ "empty_supported_enc_values",
8157
+ "encryptionKey.supportedEncValues must not be empty (omit to use defaults [A128GCM, A256GCM])"
8158
+ );
8159
+ }
8160
+ const jwk = { ...input.encryptionKey.publicJwk, use: "enc" };
8161
+ clientMetadata.jwks = { keys: [jwk] };
8162
+ const encValues = enc ?? [...DEFAULT_SUPPORTED_ENC_VALUES];
8163
+ clientMetadata.encrypted_response_enc_values_supported = encValues;
8164
+ clientMetadata.authorization_encrypted_response_alg = "ECDH-ES";
8165
+ clientMetadata.authorization_encrypted_response_enc = encValues[0];
8166
+ }
8167
+ const payload = {
8168
+ iss: clientId,
8169
+ aud: "https://self-issued.me/v2",
8170
+ response_type: "vp_token",
8171
+ response_mode: responseMode,
8172
+ client_id: clientId,
8173
+ response_uri: input.responseUri,
8174
+ nonce: input.nonce,
8175
+ state,
8176
+ dcql_query: query,
8177
+ client_metadata: clientMetadata,
8178
+ iat: now,
8179
+ exp: now + 120
8180
+ };
8181
+ const x5c = input.certificateChain.map((der) => bytesToBase64(der));
8182
+ const jws = await new jose.SignJWT(payload).setProtectedHeader({ typ: "oauth-authz-req+jwt", alg: signingAlg, x5c }).sign(input.signer.privateKey);
8183
+ const uriParams = new URLSearchParams({
8184
+ client_id: clientId,
8185
+ request_uri: input.requestUri
8186
+ });
8187
+ return {
8188
+ uri: `openid4vp://authorize?${uriParams.toString()}`,
8189
+ requestObject: jws,
8190
+ dcqlQuery: query,
8191
+ nonce: input.nonce,
8192
+ state
8193
+ };
8194
+ }
8195
+ function extractDnsNames(cert) {
8196
+ const SAN_OID = "2.5.29.17";
8197
+ const ext = cert.extensions.find((e) => e.type === SAN_OID);
8198
+ if (!ext) return [];
8199
+ const names = ext.names;
8200
+ const items = names?.items ?? [];
8201
+ return items.filter((e) => e.type === "dns").map((e) => e.value);
8202
+ }
8203
+ function bytesEqual(a, b) {
8204
+ if (a.length !== b.length) return false;
8205
+ for (let i = 0; i < a.length; i++) {
8206
+ if (a[i] !== b[i]) return false;
8207
+ }
8208
+ return true;
8209
+ }
8210
+ function bytesToBase64(bytes) {
8211
+ let s = "";
8212
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
8213
+ return btoa(s);
8214
+ }
8215
+ function toArrayBuffer(bytes) {
8216
+ const out = new ArrayBuffer(bytes.byteLength);
8217
+ new Uint8Array(out).set(bytes);
8218
+ return out;
8219
+ }
8220
+
8048
8221
  // src/haip.ts
8049
8222
  init_errors();
8050
8223
  var HAIP_DOCTYPE_NAMESPACES = {
@@ -8248,7 +8421,7 @@ async function verifyCoseSign1(coseSign1, publicKey, allowedAlgorithms) {
8248
8421
  throw new exports.MalformedCredentialError(`no hash mapping for alg ${coseSign1.alg}`);
8249
8422
  }
8250
8423
  const sigStructure = ["Signature1", coseSign1.protectedHeaderBytes, new Uint8Array(0), coseSign1.payload];
8251
- const sigInput = decoder.encode(sigStructure);
8424
+ const sigInput = new Uint8Array(decoder.encode(sigStructure));
8252
8425
  const ok = await crypto.subtle.verify(
8253
8426
  { name: "ECDSA", hash: hashAlg },
8254
8427
  publicKey,
@@ -8271,7 +8444,7 @@ async function computeItemDigest(itemBytes, digestAlgorithm) {
8271
8444
  const buf = await crypto.subtle.digest(digestAlgorithm, new Uint8Array(itemBytes));
8272
8445
  return new Uint8Array(buf);
8273
8446
  }
8274
- function bytesEqual(a, b) {
8447
+ function bytesEqual2(a, b) {
8275
8448
  if (a.length !== b.length) return false;
8276
8449
  for (let i = 0; i < a.length; i++) {
8277
8450
  if (a[i] !== b[i]) return false;
@@ -8317,7 +8490,7 @@ async function verifyAllDigests(nameSpaces, mso) {
8317
8490
  if (!expectedDigest) {
8318
8491
  throw new exports.MalformedCredentialError(`digestID ${digestID} missing from MSO valueDigests[${ns}]`);
8319
8492
  }
8320
- if (!bytesEqual(hash, expectedDigest)) {
8493
+ if (!bytesEqual2(hash, expectedDigest)) {
8321
8494
  throw new exports.MalformedCredentialError(`digest mismatch for ns=${ns} digestID=${digestID}`);
8322
8495
  }
8323
8496
  }
@@ -8436,7 +8609,7 @@ init_errors();
8436
8609
  var decoder4 = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
8437
8610
  var encoder = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
8438
8611
  var DEFAULT_ALLOWED_ALGORITHMS = ["ES256", "ES384", "ES512"];
8439
- function bytesEqual2(a, b) {
8612
+ function bytesEqual3(a, b) {
8440
8613
  if (a.length !== b.length) return false;
8441
8614
  for (let i = 0; i < a.length; i++) {
8442
8615
  if (a[i] !== b[i]) return false;
@@ -8572,8 +8745,8 @@ var MdocParser = class {
8572
8745
  cache: options.cache,
8573
8746
  clockSkewTolerance: options.clockSkewTolerance
8574
8747
  });
8575
- const { X509Certificate: X509Certificate8 } = await import('@peculiar/x509');
8576
- const leaf = new X509Certificate8(issuerCertBytes);
8748
+ const { X509Certificate: X509Certificate9 } = await import('@peculiar/x509');
8749
+ const leaf = new X509Certificate9(issuerCertBytes);
8577
8750
  trustResult = await evaluator.evaluate(leaf);
8578
8751
  } else {
8579
8752
  if (options.trustedCertificates.length === 0) {
@@ -8583,7 +8756,7 @@ var MdocParser = class {
8583
8756
  );
8584
8757
  }
8585
8758
  } else {
8586
- const trusted = options.trustedCertificates.some((t) => bytesEqual2(t, issuerCertBytes));
8759
+ const trusted = options.trustedCertificates.some((t) => bytesEqual3(t, issuerCertBytes));
8587
8760
  if (!trusted) {
8588
8761
  return {
8589
8762
  valid: false,
@@ -8698,7 +8871,7 @@ function base64ToBytes(base64) {
8698
8871
  }
8699
8872
  return bytes;
8700
8873
  }
8701
- function bytesEqual3(a, b) {
8874
+ function bytesEqual4(a, b) {
8702
8875
  if (a.length !== b.length) return false;
8703
8876
  for (let i = 0; i < a.length; i++) {
8704
8877
  if (a[i] !== b[i]) return false;
@@ -8787,32 +8960,74 @@ var SdJwtParser = class {
8787
8960
  return invalidResult(`Unsupported algorithm: ${alg}`);
8788
8961
  }
8789
8962
  const x5cArray = header.x5c;
8790
- if (!Array.isArray(x5cArray) || x5cArray.length === 0 || typeof x5cArray[0] !== "string") {
8791
- throw new exports.MalformedCredentialError("Missing or invalid x5c in JWT header");
8792
- }
8963
+ const hasX5c = Array.isArray(x5cArray) && x5cArray.length > 0 && typeof x5cArray[0] === "string";
8793
8964
  let issuerKey;
8794
8965
  let issuerCertBytes;
8795
- try {
8796
- issuerKey = await jose.importX509(derToPem2(x5cArray[0]), alg);
8797
- issuerCertBytes = base64ToBytes(x5cArray[0]);
8798
- } catch (err) {
8799
- if (err instanceof exports.MalformedCredentialError) throw err;
8800
- throw new exports.MalformedCredentialError("Failed to import issuer certificate");
8966
+ if (hasX5c) {
8967
+ try {
8968
+ issuerKey = await jose.importX509(derToPem2(x5cArray[0]), alg);
8969
+ issuerCertBytes = base64ToBytes(x5cArray[0]);
8970
+ } catch (err) {
8971
+ if (err instanceof exports.MalformedCredentialError) throw err;
8972
+ throw new exports.MalformedCredentialError("Failed to import issuer certificate");
8973
+ }
8974
+ } else if (options.trustedIssuerJwks && options.trustedIssuerJwks.length > 0) {
8975
+ issuerKey = void 0;
8976
+ } else {
8977
+ throw new exports.MalformedCredentialError("Missing or invalid x5c in JWT header");
8801
8978
  }
8802
8979
  let payload;
8803
- try {
8804
- const result2 = await jose.jwtVerify(parts.jwt, issuerKey, { algorithms: allowedAlgorithms });
8805
- payload = result2.payload;
8806
- } catch {
8807
- return invalidResult("Issuer JWT signature verification failed");
8980
+ if (issuerKey) {
8981
+ try {
8982
+ const result2 = await jose.jwtVerify(parts.jwt, issuerKey, { algorithms: allowedAlgorithms });
8983
+ payload = result2.payload;
8984
+ } catch {
8985
+ return invalidResult("Issuer JWT signature verification failed");
8986
+ }
8987
+ } else {
8988
+ const jwks = options.trustedIssuerJwks;
8989
+ const headerKid = header["kid"];
8990
+ let candidates;
8991
+ if (typeof headerKid === "string") {
8992
+ const matched = jwks.find((k) => k["kid"] === headerKid);
8993
+ if (!matched) {
8994
+ return invalidResult(
8995
+ `No matching JWK found in trustedIssuerJwks for kid=${headerKid}`
8996
+ );
8997
+ }
8998
+ candidates = [matched];
8999
+ } else {
9000
+ candidates = jwks;
9001
+ }
9002
+ let verifiedPayload;
9003
+ for (const jwk of candidates) {
9004
+ let candidateKey;
9005
+ try {
9006
+ candidateKey = await jose.importJWK(jwk, alg);
9007
+ } catch {
9008
+ continue;
9009
+ }
9010
+ try {
9011
+ const result2 = await jose.jwtVerify(parts.jwt, candidateKey, {
9012
+ algorithms: allowedAlgorithms
9013
+ });
9014
+ verifiedPayload = result2.payload;
9015
+ break;
9016
+ } catch {
9017
+ }
9018
+ }
9019
+ if (!verifiedPayload) {
9020
+ return invalidResult("Issuer JWT signature verification failed");
9021
+ }
9022
+ payload = verifiedPayload;
8808
9023
  }
8809
9024
  const issuerString = typeof payload["iss"] === "string" ? payload["iss"] : "";
8810
9025
  const issuerInfo = {
8811
- certificate: issuerCertBytes,
9026
+ certificate: issuerCertBytes ?? new Uint8Array(),
8812
9027
  country: extractCountryHint(issuerString)
8813
9028
  };
8814
9029
  let trustResult;
8815
- if (options.trustStore) {
9030
+ if (issuerCertBytes === void 0) ; else if (options.trustStore) {
8816
9031
  const { TrustEvaluator: TrustEvaluator2 } = await Promise.resolve().then(() => (init_TrustEvaluator(), TrustEvaluator_exports));
8817
9032
  const evaluator = new TrustEvaluator2({
8818
9033
  trustStore: options.trustStore,
@@ -8821,8 +9036,8 @@ var SdJwtParser = class {
8821
9036
  cache: options.cache,
8822
9037
  clockSkewTolerance: options.clockSkewTolerance
8823
9038
  });
8824
- const { X509Certificate: X509Certificate8 } = await import('@peculiar/x509');
8825
- const leaf = new X509Certificate8(issuerCertBytes);
9039
+ const { X509Certificate: X509Certificate9 } = await import('@peculiar/x509');
9040
+ const leaf = new X509Certificate9(issuerCertBytes);
8826
9041
  trustResult = await evaluator.evaluate(leaf);
8827
9042
  } else {
8828
9043
  if (options.trustedCertificates.length === 0) {
@@ -8832,23 +9047,55 @@ var SdJwtParser = class {
8832
9047
  );
8833
9048
  }
8834
9049
  } else {
8835
- const isTrusted = options.trustedCertificates.some((trusted) => bytesEqual3(trusted, issuerCertBytes));
9050
+ const isTrusted = options.trustedCertificates.some(
9051
+ (trusted) => bytesEqual4(trusted, issuerCertBytes)
9052
+ );
8836
9053
  if (!isTrusted) {
8837
9054
  return invalidResult("Issuer certificate is not trusted");
8838
9055
  }
8839
9056
  }
8840
9057
  }
8841
- const sdArray = payload["_sd"];
8842
9058
  if (parts.disclosures.length > 0) {
8843
- if (!Array.isArray(sdArray) || sdArray.length === 0) {
8844
- return invalidResult("Disclosures present but no _sd array in issuer JWT");
8845
- }
9059
+ const decodedDisclosures = [];
8846
9060
  for (const disclosure of parts.disclosures) {
9061
+ let parsed;
9062
+ try {
9063
+ parsed = JSON.parse(new TextDecoder().decode(base64ToBytes(disclosure)));
9064
+ } catch {
9065
+ return invalidResult("Disclosure is not valid base64url-encoded JSON");
9066
+ }
9067
+ if (!Array.isArray(parsed) || parsed.length !== 2 && parsed.length !== 3) {
9068
+ return invalidResult("Disclosure must be a 2- or 3-element array");
9069
+ }
8847
9070
  const hashBytes = await sha256Hasher(disclosure);
8848
- const hashB64url = bytesToBase64url(hashBytes);
8849
- if (!sdArray.includes(hashB64url)) {
8850
- return invalidResult("Disclosure hash mismatch");
9071
+ decodedDisclosures.push({
9072
+ raw: disclosure,
9073
+ hash: bytesToBase64url(hashBytes),
9074
+ length: parsed.length,
9075
+ value: parsed.length === 3 ? parsed[2] : parsed[1]
9076
+ });
9077
+ }
9078
+ const seed = collectAnchorHashes(payload);
9079
+ const kvHashes = seed.kvHashes;
9080
+ const arrayElemHashes = seed.arrayElemHashes;
9081
+ const remaining = [...decodedDisclosures];
9082
+ for (; ; ) {
9083
+ const anchored = [];
9084
+ for (const d of remaining) {
9085
+ const valid = d.length === 3 ? kvHashes.has(d.hash) : arrayElemHashes.has(d.hash);
9086
+ if (valid) anchored.push(d);
8851
9087
  }
9088
+ if (anchored.length === 0) break;
9089
+ for (const a of anchored) {
9090
+ const sub = collectAnchorHashes(a.value);
9091
+ for (const h of sub.kvHashes) kvHashes.add(h);
9092
+ for (const h of sub.arrayElemHashes) arrayElemHashes.add(h);
9093
+ const idx = remaining.indexOf(a);
9094
+ if (idx >= 0) remaining.splice(idx, 1);
9095
+ }
9096
+ }
9097
+ if (remaining.length > 0) {
9098
+ return invalidResult("Disclosure hash mismatch");
8852
9099
  }
8853
9100
  }
8854
9101
  if (parts.kbJwt) {
@@ -8912,6 +9159,35 @@ var SdJwtParser = class {
8912
9159
  return result;
8913
9160
  }
8914
9161
  };
9162
+ function collectAnchorHashes(node) {
9163
+ const kvHashes = /* @__PURE__ */ new Set();
9164
+ const arrayElemHashes = /* @__PURE__ */ new Set();
9165
+ const visit = (value) => {
9166
+ if (Array.isArray(value)) {
9167
+ for (const item of value) {
9168
+ if (item && typeof item === "object" && !Array.isArray(item)) {
9169
+ const obj = item;
9170
+ const keys = Object.keys(obj);
9171
+ if (keys.length === 1 && keys[0] === "..." && typeof obj["..."] === "string") {
9172
+ arrayElemHashes.add(obj["..."]);
9173
+ continue;
9174
+ }
9175
+ }
9176
+ visit(item);
9177
+ }
9178
+ } else if (value && typeof value === "object") {
9179
+ for (const [k, v] of Object.entries(value)) {
9180
+ if (k === "_sd" && Array.isArray(v)) {
9181
+ for (const h of v) if (typeof h === "string") kvHashes.add(h);
9182
+ continue;
9183
+ }
9184
+ visit(v);
9185
+ }
9186
+ }
9187
+ };
9188
+ visit(node);
9189
+ return { kvHashes, arrayElemHashes };
9190
+ }
8915
9191
 
8916
9192
  // src/presentation.ts
8917
9193
  var parsers = [new SdJwtParser(), new MdocParser()];
@@ -8929,6 +9205,44 @@ async function parsePresentation(vpToken, options) {
8929
9205
  error: "Unsupported credential format"
8930
9206
  };
8931
9207
  }
9208
+
9209
+ // src/decrypt-response.ts
9210
+ init_errors();
9211
+ var SUPPORTED_ALG = "ECDH-ES";
9212
+ var SUPPORTED_ENC = /* @__PURE__ */ new Set(["A128GCM", "A256GCM"]);
9213
+ async function decryptAuthorizationResponse(jwe, privateKey) {
9214
+ let header;
9215
+ try {
9216
+ header = jose.decodeProtectedHeader(jwe);
9217
+ } catch (err) {
9218
+ throw new exports.DecryptionFailedError("Malformed JWE header", {
9219
+ cause: err
9220
+ });
9221
+ }
9222
+ const alg = typeof header.alg === "string" ? header.alg : "";
9223
+ const enc = typeof header.enc === "string" ? header.enc : "";
9224
+ if (alg !== SUPPORTED_ALG || !SUPPORTED_ENC.has(enc)) {
9225
+ throw new exports.UnsupportedJweError(alg, enc);
9226
+ }
9227
+ let plaintext;
9228
+ try {
9229
+ const result = await jose.compactDecrypt(jwe, privateKey);
9230
+ plaintext = result.plaintext;
9231
+ } catch (err) {
9232
+ throw new exports.DecryptionFailedError(void 0, { cause: err });
9233
+ }
9234
+ try {
9235
+ const text = new TextDecoder().decode(plaintext);
9236
+ return JSON.parse(text);
9237
+ } catch (err) {
9238
+ throw new exports.DecryptionFailedError("Decrypted plaintext is not valid JSON", {
9239
+ cause: err
9240
+ });
9241
+ }
9242
+ }
9243
+
9244
+ // src/verify.ts
9245
+ init_errors();
8932
9246
  var INTERNAL_TO_DCQL_FORMAT = {
8933
9247
  "sd-jwt-vc": "dc+sd-jwt",
8934
9248
  mdoc: "mso_mdoc"
@@ -8960,6 +9274,48 @@ async function verifyPresentation(vpToken, query, options) {
8960
9274
  valid: parsed.valid && match.satisfied
8961
9275
  };
8962
9276
  }
9277
+ function isEncryptedResponse(x) {
9278
+ return typeof x === "object" && x !== null && "response" in x && typeof x.response === "string" && Object.keys(x).length === 1;
9279
+ }
9280
+ function isAuthorizationResponse(x) {
9281
+ return typeof x === "object" && x !== null && "vp_token" in x && typeof x.vp_token === "object" && x.vp_token !== null;
9282
+ }
9283
+ async function verifyAuthorizationResponse(envelope, query, options) {
9284
+ if (isEncryptedResponse(envelope)) {
9285
+ if (!options.decryptionKey) {
9286
+ throw new exports.MissingDecryptionKeyError();
9287
+ }
9288
+ const decrypted = await decryptAuthorizationResponse(
9289
+ envelope.response,
9290
+ options.decryptionKey
9291
+ );
9292
+ return verifyAuthorizationResponse(decrypted, query, options);
9293
+ }
9294
+ if (!isAuthorizationResponse(envelope)) {
9295
+ throw new TypeError("envelope must contain vp_token as a JSON object per OpenID4VP 1.0 \xA78.1");
9296
+ }
9297
+ const vpToken = envelope.vp_token;
9298
+ const queryIds = Object.keys(vpToken);
9299
+ if (queryIds.length === 0) {
9300
+ throw new TypeError("vp_token cannot be empty per OpenID4VP 1.0 \xA78.1");
9301
+ }
9302
+ for (const id of queryIds) {
9303
+ if (!Array.isArray(vpToken[id])) {
9304
+ throw new TypeError(
9305
+ `vp_token entry "${id}" must be an array of presentations per OpenID4VP 1.0 \xA78.1`
9306
+ );
9307
+ }
9308
+ }
9309
+ const presentationCount = queryIds.reduce(
9310
+ (sum, id) => sum + vpToken[id].length,
9311
+ 0
9312
+ );
9313
+ if (queryIds.length > 1 || presentationCount > 1) {
9314
+ throw new exports.MultipleCredentialsNotSupportedError(queryIds.length, presentationCount);
9315
+ }
9316
+ const presentation = vpToken[queryIds[0]][0];
9317
+ return verifyPresentation(presentation, query, options);
9318
+ }
8963
9319
 
8964
9320
  // src/index.ts
8965
9321
  init_errors();
@@ -9571,7 +9927,7 @@ function equalBytes(a, b) {
9571
9927
 
9572
9928
  // src/index.ts
9573
9929
  init_errors();
9574
- var VERSION = "0.5.0";
9930
+ var VERSION = "0.8.0";
9575
9931
  /*! Bundled license information:
9576
9932
 
9577
9933
  pvtsutils/build/index.es.js:
@@ -9659,9 +10015,12 @@ exports.StaticTrustStore = StaticTrustStore;
9659
10015
  exports.VERSION = VERSION;
9660
10016
  exports.buildHaipQuery = buildHaipQuery;
9661
10017
  exports.createAuthorizationRequest = createAuthorizationRequest;
10018
+ exports.createSignedAuthorizationRequest = createSignedAuthorizationRequest;
10019
+ exports.decryptAuthorizationResponse = decryptAuthorizationResponse;
9662
10020
  exports.isHaipQuery = isHaipQuery;
9663
10021
  exports.parsePresentation = parsePresentation;
9664
10022
  exports.validateHaipQuery = validateHaipQuery;
10023
+ exports.verifyAuthorizationResponse = verifyAuthorizationResponse;
9665
10024
  exports.verifyPresentation = verifyPresentation;
9666
10025
  //# sourceMappingURL=index.cjs.map
9667
10026
  //# sourceMappingURL=index.cjs.map