@openeudi/openid4vp 0.3.0 → 0.4.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 +98 -33
- package/dist/index.cjs +458 -264
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +73 -21
- package/dist/index.d.ts +73 -21
- package/dist/index.js +446 -265
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
|
-
import { splitSdJwt, decodeSdJwt, getClaims } from '@sd-jwt/decode';
|
|
2
|
-
import { decodeProtectedHeader, importX509, jwtVerify, importJWK } from 'jose';
|
|
3
|
-
import { Encoder, Tag } from 'cbor-x';
|
|
4
1
|
import { v4 } from 'uuid';
|
|
2
|
+
import { Encoder, Tag } from 'cbor-x';
|
|
3
|
+
import { importX509, decodeProtectedHeader, jwtVerify, importJWK } from 'jose';
|
|
4
|
+
import { splitSdJwt, decodeSdJwt, getClaims } from '@sd-jwt/decode';
|
|
5
|
+
import { matchQuery, buildSubmission } from '@openeudi/dcql';
|
|
6
|
+
export { DcqlMatchError, DcqlValidationError } from '@openeudi/dcql';
|
|
5
7
|
|
|
6
|
-
// src/
|
|
8
|
+
// src/authorization.ts
|
|
9
|
+
function createAuthorizationRequest(input, query) {
|
|
10
|
+
if (!input.clientId) throw new TypeError("clientId is required");
|
|
11
|
+
if (!input.nonce) throw new TypeError("nonce is required");
|
|
12
|
+
if (!input.responseUri) throw new TypeError("responseUri is required");
|
|
13
|
+
const state = input.state ?? v4();
|
|
14
|
+
const responseMode = input.responseMode ?? "direct_post";
|
|
15
|
+
const params = new URLSearchParams({
|
|
16
|
+
response_type: "vp_token",
|
|
17
|
+
response_mode: responseMode,
|
|
18
|
+
response_uri: input.responseUri,
|
|
19
|
+
client_id: input.clientId,
|
|
20
|
+
nonce: input.nonce,
|
|
21
|
+
state,
|
|
22
|
+
dcql_query: JSON.stringify(query)
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
uri: `openid4vp://authorize?${params.toString()}`,
|
|
26
|
+
dcqlQuery: query,
|
|
27
|
+
nonce: input.nonce,
|
|
28
|
+
state
|
|
29
|
+
};
|
|
30
|
+
}
|
|
7
31
|
|
|
8
32
|
// src/errors.ts
|
|
9
33
|
var InvalidSignatureError = class extends Error {
|
|
@@ -41,228 +65,131 @@ var NonceValidationError = class extends Error {
|
|
|
41
65
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
42
66
|
}
|
|
43
67
|
};
|
|
68
|
+
var HaipValidationError = class extends Error {
|
|
69
|
+
code;
|
|
70
|
+
credentialId;
|
|
71
|
+
constructor(code, message, credentialId) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "HaipValidationError";
|
|
74
|
+
this.code = code;
|
|
75
|
+
this.credentialId = credentialId;
|
|
76
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
44
79
|
|
|
45
|
-
// src/
|
|
46
|
-
var
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const input = typeof data === "string" ? encoder2.encode(data) : data;
|
|
50
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", input);
|
|
51
|
-
return new Uint8Array(hashBuffer);
|
|
80
|
+
// src/haip.ts
|
|
81
|
+
var HAIP_DOCTYPE_NAMESPACES = {
|
|
82
|
+
"org.iso.18013.5.1.mDL": "org.iso.18013.5.1",
|
|
83
|
+
"eu.europa.ec.eudi.pid.1": "eu.europa.ec.eudi.pid.1"
|
|
52
84
|
};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
function bytesEqual(a, b) {
|
|
72
|
-
if (a.length !== b.length) return false;
|
|
73
|
-
for (let i = 0; i < a.length; i++) {
|
|
74
|
-
if (a[i] !== b[i]) return false;
|
|
75
|
-
}
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
function derToPem(base64Der) {
|
|
79
|
-
const lines = base64Der.match(/.{1,64}/g) || [];
|
|
80
|
-
return `-----BEGIN CERTIFICATE-----
|
|
81
|
-
${lines.join("\n")}
|
|
82
|
-
-----END CERTIFICATE-----`;
|
|
83
|
-
}
|
|
84
|
-
function bytesToBase64url(bytes) {
|
|
85
|
-
let binary = "";
|
|
86
|
-
for (const byte of bytes) {
|
|
87
|
-
binary += String.fromCharCode(byte);
|
|
88
|
-
}
|
|
89
|
-
return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
90
|
-
}
|
|
91
|
-
function mapToCredentialClaims(raw) {
|
|
92
|
-
const claims = {};
|
|
93
|
-
if (typeof raw["age_over_18"] === "boolean") {
|
|
94
|
-
claims.age_over_18 = raw["age_over_18"];
|
|
95
|
-
}
|
|
96
|
-
if (typeof raw["age_over_21"] === "boolean") {
|
|
97
|
-
claims.age_over_21 = raw["age_over_21"];
|
|
98
|
-
}
|
|
99
|
-
if (typeof raw["resident_country"] === "string") {
|
|
100
|
-
claims.resident_country = raw["resident_country"];
|
|
101
|
-
}
|
|
102
|
-
if (typeof raw["nationality"] === "string") {
|
|
103
|
-
claims.nationality = raw["nationality"];
|
|
104
|
-
}
|
|
105
|
-
if (typeof raw["family_name_birth"] === "string") {
|
|
106
|
-
claims.family_name_birth = raw["family_name_birth"];
|
|
107
|
-
}
|
|
108
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
109
|
-
if (!(key in claims) && !key.startsWith("_sd") && key !== "iss" && key !== "exp" && key !== "iat" && key !== "cnf" && key !== "vct") {
|
|
110
|
-
claims[key] = value;
|
|
85
|
+
function buildHaipQuery(input) {
|
|
86
|
+
if (input.claims.length === 0) {
|
|
87
|
+
throw new HaipValidationError(
|
|
88
|
+
"NO_CLAIMS",
|
|
89
|
+
`credential '${input.credentialId}' must declare at least one claim`,
|
|
90
|
+
input.credentialId
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
let meta;
|
|
94
|
+
let claims;
|
|
95
|
+
if (input.format === "dc+sd-jwt") {
|
|
96
|
+
if (!input.vctValues || input.vctValues.length === 0) {
|
|
97
|
+
throw new HaipValidationError(
|
|
98
|
+
"MISSING_SDJWT_META",
|
|
99
|
+
`credential '${input.credentialId}' with format 'dc+sd-jwt' must provide non-empty vctValues`,
|
|
100
|
+
input.credentialId
|
|
101
|
+
);
|
|
111
102
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
103
|
+
meta = { vct_values: input.vctValues };
|
|
104
|
+
claims = input.claims.map((c) => ({ path: [c] }));
|
|
105
|
+
} else {
|
|
106
|
+
if (!input.doctypeValue) {
|
|
107
|
+
throw new HaipValidationError(
|
|
108
|
+
"MISSING_MDOC_META",
|
|
109
|
+
`credential '${input.credentialId}' with format 'mso_mdoc' must provide doctypeValue`,
|
|
110
|
+
input.credentialId
|
|
111
|
+
);
|
|
121
112
|
}
|
|
122
|
-
|
|
113
|
+
meta = { doctype_value: input.doctypeValue };
|
|
114
|
+
const namespace = HAIP_DOCTYPE_NAMESPACES[input.doctypeValue] ?? input.doctypeValue;
|
|
115
|
+
claims = input.claims.map((c) => ({ path: [namespace, c] }));
|
|
123
116
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
117
|
+
const credential = {
|
|
118
|
+
id: input.credentialId,
|
|
119
|
+
format: input.format,
|
|
120
|
+
meta,
|
|
121
|
+
claims
|
|
122
|
+
};
|
|
123
|
+
if (input.trustedAuthorities && input.trustedAuthorities.length > 0) {
|
|
124
|
+
credential.trusted_authorities = input.trustedAuthorities;
|
|
130
125
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
const alg = header.alg;
|
|
156
|
-
if (typeof alg !== "string" || !allowedAlgorithms.includes(alg)) {
|
|
157
|
-
return invalidResult(`Unsupported algorithm: ${alg}`);
|
|
158
|
-
}
|
|
159
|
-
const x5cArray = header.x5c;
|
|
160
|
-
if (!Array.isArray(x5cArray) || x5cArray.length === 0 || typeof x5cArray[0] !== "string") {
|
|
161
|
-
throw new MalformedCredentialError("Missing or invalid x5c in JWT header");
|
|
162
|
-
}
|
|
163
|
-
let issuerKey;
|
|
164
|
-
let issuerCertBytes;
|
|
165
|
-
try {
|
|
166
|
-
issuerKey = await importX509(derToPem(x5cArray[0]), alg);
|
|
167
|
-
issuerCertBytes = base64ToBytes(x5cArray[0]);
|
|
168
|
-
} catch (err) {
|
|
169
|
-
if (err instanceof MalformedCredentialError) throw err;
|
|
170
|
-
throw new MalformedCredentialError("Failed to import issuer certificate");
|
|
171
|
-
}
|
|
172
|
-
let payload;
|
|
173
|
-
try {
|
|
174
|
-
const result = await jwtVerify(parts.jwt, issuerKey, { algorithms: allowedAlgorithms });
|
|
175
|
-
payload = result.payload;
|
|
176
|
-
} catch {
|
|
177
|
-
return invalidResult("Issuer JWT signature verification failed");
|
|
126
|
+
return { credentials: [credential] };
|
|
127
|
+
}
|
|
128
|
+
var HAIP_FORMATS = /* @__PURE__ */ new Set(["dc+sd-jwt", "mso_mdoc"]);
|
|
129
|
+
function validateHaipQuery(query) {
|
|
130
|
+
if (!query.credentials || query.credentials.length === 0) {
|
|
131
|
+
throw new HaipValidationError(
|
|
132
|
+
"EMPTY_QUERY",
|
|
133
|
+
"query.credentials must have at least one entry"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (query.credential_sets !== void 0) {
|
|
137
|
+
throw new HaipValidationError(
|
|
138
|
+
"CREDENTIAL_SETS_DISALLOWED",
|
|
139
|
+
"credential_sets are not permitted in HAIP-minimal"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
for (const credential of query.credentials) {
|
|
143
|
+
if (!HAIP_FORMATS.has(credential.format)) {
|
|
144
|
+
throw new HaipValidationError(
|
|
145
|
+
"UNSUPPORTED_FORMAT",
|
|
146
|
+
`credential '${credential.id}' uses unsupported format '${credential.format}' (allowed: dc+sd-jwt, mso_mdoc)`,
|
|
147
|
+
credential.id
|
|
148
|
+
);
|
|
178
149
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
throw new MalformedCredentialError(
|
|
187
|
-
"trustedCertificates must not be empty unless skipTrustCheck is true"
|
|
150
|
+
if (credential.format === "dc+sd-jwt") {
|
|
151
|
+
const vctValues = credential.meta?.vct_values;
|
|
152
|
+
if (!vctValues || vctValues.length === 0) {
|
|
153
|
+
throw new HaipValidationError(
|
|
154
|
+
"MISSING_SDJWT_META",
|
|
155
|
+
`credential '${credential.id}' with format 'dc+sd-jwt' must set meta.vct_values`,
|
|
156
|
+
credential.id
|
|
188
157
|
);
|
|
189
158
|
}
|
|
190
|
-
} else {
|
|
191
|
-
const isTrusted = options.trustedCertificates.some((trusted) => bytesEqual(trusted, issuerCertBytes));
|
|
192
|
-
if (!isTrusted) {
|
|
193
|
-
return invalidResult("Issuer certificate is not trusted");
|
|
194
|
-
}
|
|
195
159
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const hashB64url = bytesToBase64url(hashBytes);
|
|
204
|
-
if (!sdArray.includes(hashB64url)) {
|
|
205
|
-
return invalidResult("Disclosure hash mismatch");
|
|
206
|
-
}
|
|
160
|
+
if (credential.format === "mso_mdoc") {
|
|
161
|
+
if (typeof credential.meta?.doctype_value !== "string") {
|
|
162
|
+
throw new HaipValidationError(
|
|
163
|
+
"MISSING_MDOC_META",
|
|
164
|
+
`credential '${credential.id}' with format 'mso_mdoc' must set meta.doctype_value`,
|
|
165
|
+
credential.id
|
|
166
|
+
);
|
|
207
167
|
}
|
|
208
168
|
}
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
const kbHeader = decodeProtectedHeader(parts.kbJwt);
|
|
217
|
-
const kbAlg = kbHeader.alg;
|
|
218
|
-
if (typeof kbAlg !== "string" || !allowedAlgorithms.includes(kbAlg)) {
|
|
219
|
-
return invalidResult(`Unsupported KB-JWT algorithm: ${kbAlg}`);
|
|
220
|
-
}
|
|
221
|
-
holderKey = await importJWK(cnf["jwk"], kbAlg);
|
|
222
|
-
} catch (err) {
|
|
223
|
-
if (err instanceof MalformedCredentialError) throw err;
|
|
224
|
-
throw new MalformedCredentialError("Failed to import holder key from cnf.jwk");
|
|
225
|
-
}
|
|
226
|
-
let kbPayload;
|
|
227
|
-
try {
|
|
228
|
-
const kbResult = await jwtVerify(parts.kbJwt, holderKey, {
|
|
229
|
-
algorithms: allowedAlgorithms,
|
|
230
|
-
...options.audience ? { audience: options.audience } : {}
|
|
231
|
-
});
|
|
232
|
-
kbPayload = kbResult.payload;
|
|
233
|
-
} catch {
|
|
234
|
-
return invalidResult("Key binding JWT signature verification failed");
|
|
235
|
-
}
|
|
236
|
-
if (kbPayload["nonce"] !== options.nonce) {
|
|
237
|
-
return invalidResult("Key binding JWT nonce does not match");
|
|
238
|
-
}
|
|
239
|
-
const disclosurePart = parts.disclosures.length > 0 ? parts.disclosures.join("~") + "~" : "";
|
|
240
|
-
const sdJwtForHash = parts.jwt + "~" + disclosurePart;
|
|
241
|
-
const sdHashBytes = await sha256Hasher(sdJwtForHash);
|
|
242
|
-
const expectedSdHash = bytesToBase64url(sdHashBytes);
|
|
243
|
-
if (kbPayload["sd_hash"] !== expectedSdHash) {
|
|
244
|
-
return invalidResult("Key binding JWT sd_hash does not match");
|
|
245
|
-
}
|
|
169
|
+
if (!credential.claims || credential.claims.length === 0) {
|
|
170
|
+
throw new HaipValidationError(
|
|
171
|
+
"NO_CLAIMS",
|
|
172
|
+
`credential '${credential.id}' must declare at least one claim`,
|
|
173
|
+
credential.id
|
|
174
|
+
);
|
|
246
175
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
decoded.disclosures,
|
|
253
|
-
sha256Hasher
|
|
176
|
+
if (credential.claim_sets !== void 0) {
|
|
177
|
+
throw new HaipValidationError(
|
|
178
|
+
"CLAIM_SETS_DISALLOWED",
|
|
179
|
+
`credential '${credential.id}' uses claim_sets, not permitted in HAIP-minimal`,
|
|
180
|
+
credential.id
|
|
254
181
|
);
|
|
255
|
-
} catch {
|
|
256
|
-
throw new MalformedCredentialError("Failed to decode SD-JWT disclosures");
|
|
257
182
|
}
|
|
258
|
-
return {
|
|
259
|
-
valid: true,
|
|
260
|
-
format: this.format,
|
|
261
|
-
claims: mapToCredentialClaims(resolvedClaims),
|
|
262
|
-
issuer: issuerInfo
|
|
263
|
-
};
|
|
264
183
|
}
|
|
265
|
-
}
|
|
184
|
+
}
|
|
185
|
+
function isHaipQuery(query) {
|
|
186
|
+
try {
|
|
187
|
+
validateHaipQuery(query);
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
266
193
|
var decoder = new Encoder({ mapsAsObjects: false, useRecords: false });
|
|
267
194
|
var HEADER_ALG = 1;
|
|
268
195
|
var HEADER_X5CHAIN = 33;
|
|
@@ -369,7 +296,7 @@ async function computeItemDigest(itemBytes, digestAlgorithm) {
|
|
|
369
296
|
const buf = await crypto.subtle.digest(digestAlgorithm, new Uint8Array(itemBytes));
|
|
370
297
|
return new Uint8Array(buf);
|
|
371
298
|
}
|
|
372
|
-
function
|
|
299
|
+
function bytesEqual(a, b) {
|
|
373
300
|
if (a.length !== b.length) return false;
|
|
374
301
|
for (let i = 0; i < a.length; i++) {
|
|
375
302
|
if (a[i] !== b[i]) return false;
|
|
@@ -415,7 +342,7 @@ async function verifyAllDigests(nameSpaces, mso) {
|
|
|
415
342
|
if (!expectedDigest) {
|
|
416
343
|
throw new MalformedCredentialError(`digestID ${digestID} missing from MSO valueDigests[${ns}]`);
|
|
417
344
|
}
|
|
418
|
-
if (!
|
|
345
|
+
if (!bytesEqual(hash, expectedDigest)) {
|
|
419
346
|
throw new MalformedCredentialError(`digest mismatch for ns=${ns} digestID=${digestID}`);
|
|
420
347
|
}
|
|
421
348
|
}
|
|
@@ -529,15 +456,15 @@ function validateMsoDocType(mso, expectedDocType) {
|
|
|
529
456
|
// src/parsers/mdoc.parser.ts
|
|
530
457
|
var decoder4 = new Encoder({ mapsAsObjects: false, useRecords: false });
|
|
531
458
|
var encoder = new Encoder({ mapsAsObjects: false, useRecords: false });
|
|
532
|
-
var
|
|
533
|
-
function
|
|
459
|
+
var DEFAULT_ALLOWED_ALGORITHMS = ["ES256", "ES384", "ES512"];
|
|
460
|
+
function bytesEqual2(a, b) {
|
|
534
461
|
if (a.length !== b.length) return false;
|
|
535
462
|
for (let i = 0; i < a.length; i++) {
|
|
536
463
|
if (a[i] !== b[i]) return false;
|
|
537
464
|
}
|
|
538
465
|
return true;
|
|
539
466
|
}
|
|
540
|
-
function
|
|
467
|
+
function derToPem(der) {
|
|
541
468
|
let binary = "";
|
|
542
469
|
for (const b of der) binary += String.fromCharCode(b);
|
|
543
470
|
const b64 = globalThis.btoa(binary);
|
|
@@ -562,8 +489,10 @@ function extractRawItemBytes(item) {
|
|
|
562
489
|
throw new MalformedCredentialError("IssuerSignedItem must be a tag-24 byte string");
|
|
563
490
|
}
|
|
564
491
|
function mapRawToClaims(nameSpaces) {
|
|
565
|
-
const
|
|
566
|
-
|
|
492
|
+
const flat = {};
|
|
493
|
+
const namespaced = {};
|
|
494
|
+
for (const [ns, items] of nameSpaces.entries()) {
|
|
495
|
+
const nsBucket = namespaced[ns] ?? {};
|
|
567
496
|
for (const itemBytes of items) {
|
|
568
497
|
let decoded;
|
|
569
498
|
try {
|
|
@@ -585,12 +514,14 @@ function mapRawToClaims(nameSpaces) {
|
|
|
585
514
|
const id = inner.get("elementIdentifier");
|
|
586
515
|
const val = inner.get("elementValue");
|
|
587
516
|
if (typeof id === "string") {
|
|
588
|
-
|
|
517
|
+
flat[id] = val;
|
|
518
|
+
nsBucket[id] = val;
|
|
589
519
|
}
|
|
590
520
|
}
|
|
591
521
|
}
|
|
522
|
+
namespaced[ns] = nsBucket;
|
|
592
523
|
}
|
|
593
|
-
return
|
|
524
|
+
return { flat, namespaced };
|
|
594
525
|
}
|
|
595
526
|
var MdocParser = class {
|
|
596
527
|
format = "mdoc";
|
|
@@ -612,7 +543,7 @@ var MdocParser = class {
|
|
|
612
543
|
if (!(vpToken instanceof Uint8Array)) {
|
|
613
544
|
throw new MalformedCredentialError("mDOC vpToken must be a Uint8Array");
|
|
614
545
|
}
|
|
615
|
-
const allowedAlgorithms = options.allowedAlgorithms ??
|
|
546
|
+
const allowedAlgorithms = options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS;
|
|
616
547
|
let deviceResponse;
|
|
617
548
|
try {
|
|
618
549
|
const decoded = decoder4.decode(vpToken);
|
|
@@ -659,7 +590,7 @@ var MdocParser = class {
|
|
|
659
590
|
);
|
|
660
591
|
}
|
|
661
592
|
} else {
|
|
662
|
-
const trusted = options.trustedCertificates.some((t) =>
|
|
593
|
+
const trusted = options.trustedCertificates.some((t) => bytesEqual2(t, issuerCertBytes));
|
|
663
594
|
if (!trusted) {
|
|
664
595
|
return {
|
|
665
596
|
valid: false,
|
|
@@ -670,7 +601,7 @@ var MdocParser = class {
|
|
|
670
601
|
};
|
|
671
602
|
}
|
|
672
603
|
}
|
|
673
|
-
const publicKey = await importX509(
|
|
604
|
+
const publicKey = await importX509(derToPem(issuerCertBytes), cose.alg);
|
|
674
605
|
try {
|
|
675
606
|
await verifyCoseSign1(cose, publicKey, allowedAlgorithms);
|
|
676
607
|
} catch (err) {
|
|
@@ -728,52 +659,243 @@ var MdocParser = class {
|
|
|
728
659
|
nameSpaces.set(ns, items.map(extractRawItemBytes));
|
|
729
660
|
}
|
|
730
661
|
await verifyAllDigests(nameSpaces, mso);
|
|
731
|
-
const claims = mapRawToClaims(nameSpaces);
|
|
662
|
+
const { flat: claims, namespaced: namespacedClaims } = mapRawToClaims(nameSpaces);
|
|
732
663
|
const issuer = {
|
|
733
664
|
certificate: issuerCertBytes,
|
|
734
665
|
country: extractCountryHintFromCert()
|
|
735
666
|
};
|
|
736
|
-
return {
|
|
667
|
+
return {
|
|
668
|
+
valid: true,
|
|
669
|
+
format: this.format,
|
|
670
|
+
claims,
|
|
671
|
+
issuer,
|
|
672
|
+
docType: mso.docType,
|
|
673
|
+
namespacedClaims
|
|
674
|
+
};
|
|
737
675
|
}
|
|
738
676
|
};
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
677
|
+
var DEFAULT_ALLOWED_ALGORITHMS2 = ["ES256", "ES384", "ES512"];
|
|
678
|
+
var sha256Hasher = async (data, _alg) => {
|
|
679
|
+
const encoder2 = new TextEncoder();
|
|
680
|
+
const input = typeof data === "string" ? encoder2.encode(data) : data;
|
|
681
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", input);
|
|
682
|
+
return new Uint8Array(hashBuffer);
|
|
683
|
+
};
|
|
684
|
+
var BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
685
|
+
function base64ToBytes(base64) {
|
|
686
|
+
const cleaned = base64.replace(/=+$/, "");
|
|
687
|
+
const byteLength = Math.floor(cleaned.length * 3 / 4);
|
|
688
|
+
const buffer = new ArrayBuffer(byteLength);
|
|
689
|
+
const bytes = new Uint8Array(buffer);
|
|
690
|
+
let byteIndex = 0;
|
|
691
|
+
for (let i = 0; i < cleaned.length; i += 4) {
|
|
692
|
+
const a = BASE64_CHARS.indexOf(cleaned[i]);
|
|
693
|
+
const b = i + 1 < cleaned.length ? BASE64_CHARS.indexOf(cleaned[i + 1]) : 0;
|
|
694
|
+
const c = i + 2 < cleaned.length ? BASE64_CHARS.indexOf(cleaned[i + 2]) : 0;
|
|
695
|
+
const d = i + 3 < cleaned.length ? BASE64_CHARS.indexOf(cleaned[i + 3]) : 0;
|
|
696
|
+
bytes[byteIndex++] = a << 2 | b >> 4;
|
|
697
|
+
if (byteIndex < byteLength) bytes[byteIndex++] = (b & 15) << 4 | c >> 2;
|
|
698
|
+
if (byteIndex < byteLength) bytes[byteIndex++] = (c & 3) << 6 | d;
|
|
699
|
+
}
|
|
700
|
+
return bytes;
|
|
701
|
+
}
|
|
702
|
+
function bytesEqual3(a, b) {
|
|
703
|
+
if (a.length !== b.length) return false;
|
|
704
|
+
for (let i = 0; i < a.length; i++) {
|
|
705
|
+
if (a[i] !== b[i]) return false;
|
|
706
|
+
}
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
function derToPem2(base64Der) {
|
|
710
|
+
const lines = base64Der.match(/.{1,64}/g) || [];
|
|
711
|
+
return `-----BEGIN CERTIFICATE-----
|
|
712
|
+
${lines.join("\n")}
|
|
713
|
+
-----END CERTIFICATE-----`;
|
|
714
|
+
}
|
|
715
|
+
function bytesToBase64url(bytes) {
|
|
716
|
+
let binary = "";
|
|
717
|
+
for (const byte of bytes) {
|
|
718
|
+
binary += String.fromCharCode(byte);
|
|
719
|
+
}
|
|
720
|
+
return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
776
721
|
}
|
|
722
|
+
function mapToCredentialClaims(raw) {
|
|
723
|
+
const claims = {};
|
|
724
|
+
if (typeof raw["age_over_18"] === "boolean") {
|
|
725
|
+
claims.age_over_18 = raw["age_over_18"];
|
|
726
|
+
}
|
|
727
|
+
if (typeof raw["age_over_21"] === "boolean") {
|
|
728
|
+
claims.age_over_21 = raw["age_over_21"];
|
|
729
|
+
}
|
|
730
|
+
if (typeof raw["resident_country"] === "string") {
|
|
731
|
+
claims.resident_country = raw["resident_country"];
|
|
732
|
+
}
|
|
733
|
+
if (typeof raw["nationality"] === "string") {
|
|
734
|
+
claims.nationality = raw["nationality"];
|
|
735
|
+
}
|
|
736
|
+
if (typeof raw["family_name_birth"] === "string") {
|
|
737
|
+
claims.family_name_birth = raw["family_name_birth"];
|
|
738
|
+
}
|
|
739
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
740
|
+
if (!(key in claims) && !key.startsWith("_sd") && key !== "iss" && key !== "exp" && key !== "iat" && key !== "cnf" && key !== "vct") {
|
|
741
|
+
claims[key] = value;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return claims;
|
|
745
|
+
}
|
|
746
|
+
function extractCountryHint(issuer) {
|
|
747
|
+
try {
|
|
748
|
+
const url = new URL(issuer);
|
|
749
|
+
const tld = url.hostname.split(".").pop()?.toUpperCase();
|
|
750
|
+
if (tld && tld.length === 2) {
|
|
751
|
+
return tld;
|
|
752
|
+
}
|
|
753
|
+
} catch {
|
|
754
|
+
}
|
|
755
|
+
return "";
|
|
756
|
+
}
|
|
757
|
+
var SdJwtParser = class {
|
|
758
|
+
format = "sd-jwt-vc";
|
|
759
|
+
canParse(vpToken) {
|
|
760
|
+
return typeof vpToken === "string" && vpToken.includes("~");
|
|
761
|
+
}
|
|
762
|
+
async parse(vpToken, options) {
|
|
763
|
+
if (typeof vpToken !== "string") {
|
|
764
|
+
throw new MalformedCredentialError("VP token must be a string");
|
|
765
|
+
}
|
|
766
|
+
const allowedAlgorithms = options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS2;
|
|
767
|
+
const invalidResult = (error) => ({
|
|
768
|
+
valid: false,
|
|
769
|
+
format: this.format,
|
|
770
|
+
claims: {},
|
|
771
|
+
issuer: { certificate: new Uint8Array(), country: "" },
|
|
772
|
+
error
|
|
773
|
+
});
|
|
774
|
+
let parts;
|
|
775
|
+
try {
|
|
776
|
+
parts = splitSdJwt(vpToken);
|
|
777
|
+
} catch {
|
|
778
|
+
throw new MalformedCredentialError("Failed to split SD-JWT structure");
|
|
779
|
+
}
|
|
780
|
+
let header;
|
|
781
|
+
try {
|
|
782
|
+
header = decodeProtectedHeader(parts.jwt);
|
|
783
|
+
} catch {
|
|
784
|
+
throw new MalformedCredentialError("Failed to decode SD-JWT issuer JWT header");
|
|
785
|
+
}
|
|
786
|
+
const alg = header.alg;
|
|
787
|
+
if (typeof alg !== "string" || !allowedAlgorithms.includes(alg)) {
|
|
788
|
+
return invalidResult(`Unsupported algorithm: ${alg}`);
|
|
789
|
+
}
|
|
790
|
+
const x5cArray = header.x5c;
|
|
791
|
+
if (!Array.isArray(x5cArray) || x5cArray.length === 0 || typeof x5cArray[0] !== "string") {
|
|
792
|
+
throw new MalformedCredentialError("Missing or invalid x5c in JWT header");
|
|
793
|
+
}
|
|
794
|
+
let issuerKey;
|
|
795
|
+
let issuerCertBytes;
|
|
796
|
+
try {
|
|
797
|
+
issuerKey = await importX509(derToPem2(x5cArray[0]), alg);
|
|
798
|
+
issuerCertBytes = base64ToBytes(x5cArray[0]);
|
|
799
|
+
} catch (err) {
|
|
800
|
+
if (err instanceof MalformedCredentialError) throw err;
|
|
801
|
+
throw new MalformedCredentialError("Failed to import issuer certificate");
|
|
802
|
+
}
|
|
803
|
+
let payload;
|
|
804
|
+
try {
|
|
805
|
+
const result = await jwtVerify(parts.jwt, issuerKey, { algorithms: allowedAlgorithms });
|
|
806
|
+
payload = result.payload;
|
|
807
|
+
} catch {
|
|
808
|
+
return invalidResult("Issuer JWT signature verification failed");
|
|
809
|
+
}
|
|
810
|
+
const issuerString = typeof payload["iss"] === "string" ? payload["iss"] : "";
|
|
811
|
+
const issuerInfo = {
|
|
812
|
+
certificate: issuerCertBytes,
|
|
813
|
+
country: extractCountryHint(issuerString)
|
|
814
|
+
};
|
|
815
|
+
if (options.trustedCertificates.length === 0) {
|
|
816
|
+
if (options.skipTrustCheck !== true) {
|
|
817
|
+
throw new MalformedCredentialError(
|
|
818
|
+
"trustedCertificates must not be empty unless skipTrustCheck is true"
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
} else {
|
|
822
|
+
const isTrusted = options.trustedCertificates.some((trusted) => bytesEqual3(trusted, issuerCertBytes));
|
|
823
|
+
if (!isTrusted) {
|
|
824
|
+
return invalidResult("Issuer certificate is not trusted");
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const sdArray = payload["_sd"];
|
|
828
|
+
if (parts.disclosures.length > 0) {
|
|
829
|
+
if (!Array.isArray(sdArray) || sdArray.length === 0) {
|
|
830
|
+
return invalidResult("Disclosures present but no _sd array in issuer JWT");
|
|
831
|
+
}
|
|
832
|
+
for (const disclosure of parts.disclosures) {
|
|
833
|
+
const hashBytes = await sha256Hasher(disclosure);
|
|
834
|
+
const hashB64url = bytesToBase64url(hashBytes);
|
|
835
|
+
if (!sdArray.includes(hashB64url)) {
|
|
836
|
+
return invalidResult("Disclosure hash mismatch");
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (parts.kbJwt) {
|
|
841
|
+
const cnf = payload["cnf"];
|
|
842
|
+
if (!cnf || typeof cnf !== "object" || !cnf["jwk"]) {
|
|
843
|
+
throw new MalformedCredentialError("Missing cnf.jwk in issuer JWT for key binding");
|
|
844
|
+
}
|
|
845
|
+
let holderKey;
|
|
846
|
+
try {
|
|
847
|
+
const kbHeader = decodeProtectedHeader(parts.kbJwt);
|
|
848
|
+
const kbAlg = kbHeader.alg;
|
|
849
|
+
if (typeof kbAlg !== "string" || !allowedAlgorithms.includes(kbAlg)) {
|
|
850
|
+
return invalidResult(`Unsupported KB-JWT algorithm: ${kbAlg}`);
|
|
851
|
+
}
|
|
852
|
+
holderKey = await importJWK(cnf["jwk"], kbAlg);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
if (err instanceof MalformedCredentialError) throw err;
|
|
855
|
+
throw new MalformedCredentialError("Failed to import holder key from cnf.jwk");
|
|
856
|
+
}
|
|
857
|
+
let kbPayload;
|
|
858
|
+
try {
|
|
859
|
+
const kbResult = await jwtVerify(parts.kbJwt, holderKey, {
|
|
860
|
+
algorithms: allowedAlgorithms,
|
|
861
|
+
...options.audience ? { audience: options.audience } : {}
|
|
862
|
+
});
|
|
863
|
+
kbPayload = kbResult.payload;
|
|
864
|
+
} catch {
|
|
865
|
+
return invalidResult("Key binding JWT signature verification failed");
|
|
866
|
+
}
|
|
867
|
+
if (kbPayload["nonce"] !== options.nonce) {
|
|
868
|
+
return invalidResult("Key binding JWT nonce does not match");
|
|
869
|
+
}
|
|
870
|
+
const disclosurePart = parts.disclosures.length > 0 ? parts.disclosures.join("~") + "~" : "";
|
|
871
|
+
const sdJwtForHash = parts.jwt + "~" + disclosurePart;
|
|
872
|
+
const sdHashBytes = await sha256Hasher(sdJwtForHash);
|
|
873
|
+
const expectedSdHash = bytesToBase64url(sdHashBytes);
|
|
874
|
+
if (kbPayload["sd_hash"] !== expectedSdHash) {
|
|
875
|
+
return invalidResult("Key binding JWT sd_hash does not match");
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
let resolvedClaims;
|
|
879
|
+
try {
|
|
880
|
+
const decoded = await decodeSdJwt(vpToken, sha256Hasher);
|
|
881
|
+
resolvedClaims = await getClaims(
|
|
882
|
+
decoded.jwt.payload,
|
|
883
|
+
decoded.disclosures,
|
|
884
|
+
sha256Hasher
|
|
885
|
+
);
|
|
886
|
+
} catch {
|
|
887
|
+
throw new MalformedCredentialError("Failed to decode SD-JWT disclosures");
|
|
888
|
+
}
|
|
889
|
+
const vct = typeof payload["vct"] === "string" ? payload["vct"] : void 0;
|
|
890
|
+
return {
|
|
891
|
+
valid: true,
|
|
892
|
+
format: this.format,
|
|
893
|
+
claims: mapToCredentialClaims(resolvedClaims),
|
|
894
|
+
issuer: issuerInfo,
|
|
895
|
+
...vct !== void 0 ? { vct } : {}
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
};
|
|
777
899
|
|
|
778
900
|
// src/presentation.ts
|
|
779
901
|
var parsers = [new SdJwtParser(), new MdocParser()];
|
|
@@ -791,10 +913,69 @@ async function parsePresentation(vpToken, options) {
|
|
|
791
913
|
error: "Unsupported credential format"
|
|
792
914
|
};
|
|
793
915
|
}
|
|
916
|
+
var INTERNAL_TO_DCQL_FORMAT = {
|
|
917
|
+
"sd-jwt-vc": "dc+sd-jwt",
|
|
918
|
+
mdoc: "mso_mdoc"
|
|
919
|
+
};
|
|
920
|
+
function classifyMismatch(query, decoded, hasDetail) {
|
|
921
|
+
if (decoded.format !== query.format) return "format_mismatch";
|
|
922
|
+
if (query.format === "dc+sd-jwt") {
|
|
923
|
+
const allowed = query.meta?.vct_values;
|
|
924
|
+
if (allowed && allowed.length > 0 && !allowed.includes(decoded.vct ?? "")) {
|
|
925
|
+
return "vct_mismatch";
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (query.format === "mso_mdoc") {
|
|
929
|
+
const expected = query.meta?.doctype_value;
|
|
930
|
+
if (expected && expected !== decoded.doctype) return "doctype_mismatch";
|
|
931
|
+
}
|
|
932
|
+
if (query.trusted_authorities && query.trusted_authorities.length > 0) {
|
|
933
|
+
const decodedAuthIds = decoded.trusted_authority_ids ?? [];
|
|
934
|
+
const anyMatch = query.trusted_authorities.some(
|
|
935
|
+
(ta) => ta.values.some((v) => decodedAuthIds.includes(v))
|
|
936
|
+
);
|
|
937
|
+
if (!anyMatch) return "trusted_authority_mismatch";
|
|
938
|
+
}
|
|
939
|
+
if (hasDetail) return "missing_claims";
|
|
940
|
+
return "no_credential_found";
|
|
941
|
+
}
|
|
942
|
+
function refineUnmatched(query, decoded, match) {
|
|
943
|
+
if (match.unmatched.length === 0) return match;
|
|
944
|
+
const refined = match.unmatched.map((entry) => {
|
|
945
|
+
const credentialQuery = query.credentials.find((c) => c.id === entry.queryId);
|
|
946
|
+
if (!credentialQuery) return entry;
|
|
947
|
+
const specific = classifyMismatch(credentialQuery, decoded, entry.detail !== void 0);
|
|
948
|
+
return { ...entry, reason: specific };
|
|
949
|
+
});
|
|
950
|
+
return { ...match, unmatched: refined };
|
|
951
|
+
}
|
|
952
|
+
async function verifyPresentation(vpToken, query, options) {
|
|
953
|
+
const parsed = await parsePresentation(vpToken, options);
|
|
954
|
+
const dcqlFormat = INTERNAL_TO_DCQL_FORMAT[parsed.format] ?? parsed.format;
|
|
955
|
+
const decodedClaims = parsed.format === "mdoc" && parsed.namespacedClaims !== void 0 ? parsed.namespacedClaims : parsed.claims;
|
|
956
|
+
const decoded = {
|
|
957
|
+
id: query.credentials[0]?.id ?? "presented",
|
|
958
|
+
format: dcqlFormat,
|
|
959
|
+
claims: decodedClaims
|
|
960
|
+
};
|
|
961
|
+
if (parsed.format === "sd-jwt-vc" && typeof parsed.vct === "string") {
|
|
962
|
+
decoded.vct = parsed.vct;
|
|
963
|
+
}
|
|
964
|
+
if (parsed.format === "mdoc" && typeof parsed.docType === "string") {
|
|
965
|
+
decoded.doctype = parsed.docType;
|
|
966
|
+
}
|
|
967
|
+
const rawMatch = matchQuery(query, [decoded]);
|
|
968
|
+
const match = refineUnmatched(query, decoded, rawMatch);
|
|
969
|
+
const submission = match.satisfied ? buildSubmission(query, match) : null;
|
|
970
|
+
return {
|
|
971
|
+
parsed,
|
|
972
|
+
match,
|
|
973
|
+
submission,
|
|
974
|
+
valid: parsed.valid && match.satisfied
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
var VERSION = "0.4.0";
|
|
794
978
|
|
|
795
|
-
|
|
796
|
-
var VERSION = "0.3.0";
|
|
797
|
-
|
|
798
|
-
export { ExpiredCredentialError, InvalidSignatureError, MalformedCredentialError, MdocParser, NonceValidationError, SdJwtParser, UnsupportedFormatError, VERSION, createAuthorizationRequest, parsePresentation };
|
|
979
|
+
export { ExpiredCredentialError, HAIP_DOCTYPE_NAMESPACES, HaipValidationError, InvalidSignatureError, MalformedCredentialError, MdocParser, NonceValidationError, SdJwtParser, UnsupportedFormatError, VERSION, buildHaipQuery, createAuthorizationRequest, isHaipQuery, parsePresentation, validateHaipQuery, verifyPresentation };
|
|
799
980
|
//# sourceMappingURL=index.js.map
|
|
800
981
|
//# sourceMappingURL=index.js.map
|