@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 +94 -0
- package/dist/index.cjs +396 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -2
- package/dist/index.d.ts +124 -2
- package/dist/index.js +394 -38
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
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
|
|
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 (!
|
|
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
|
|
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:
|
|
8576
|
-
const leaf = new
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
8796
|
-
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
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
|
-
|
|
8804
|
-
|
|
8805
|
-
|
|
8806
|
-
|
|
8807
|
-
|
|
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:
|
|
8825
|
-
const leaf = new
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
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.
|
|
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
|