@vidos-id/openid4vc-issuer 0.0.0-test1
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 +148 -0
- package/dist/index.d.mts +593 -0
- package/dist/index.mjs +670 -0
- package/package.json +46 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { SignJWT, calculateJwkThumbprint, decodeProtectedHeader, exportJWK, exportPKCS8, exportSPKI, generateKeyPair, importJWK, jwtVerify } from "jose";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { SDJwt } from "@sd-jwt/core";
|
|
8
|
+
import { hasher } from "@sd-jwt/hash";
|
|
9
|
+
import { SDJwtVcInstance } from "@sd-jwt/sd-jwt-vc";
|
|
10
|
+
import { deflateSync } from "node:zlib";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
//#region src/schemas.ts
|
|
13
|
+
const jwkSchema = z.object({
|
|
14
|
+
kty: z.string().min(1),
|
|
15
|
+
kid: z.string().min(1).optional(),
|
|
16
|
+
alg: z.string().min(1).optional(),
|
|
17
|
+
crv: z.string().min(1).optional(),
|
|
18
|
+
x: z.string().min(1).optional(),
|
|
19
|
+
y: z.string().min(1).optional(),
|
|
20
|
+
d: z.string().min(1).optional(),
|
|
21
|
+
n: z.string().min(1).optional(),
|
|
22
|
+
e: z.string().min(1).optional(),
|
|
23
|
+
use: z.string().min(1).optional(),
|
|
24
|
+
key_ops: z.array(z.string().min(1)).optional(),
|
|
25
|
+
x5c: z.array(z.string().min(1)).optional()
|
|
26
|
+
}).catchall(z.unknown());
|
|
27
|
+
const claimSetSchema = z.record(z.string(), z.unknown());
|
|
28
|
+
const credentialConfigurationSchema = z.object({
|
|
29
|
+
format: z.literal("dc+sd-jwt"),
|
|
30
|
+
vct: z.string().min(1),
|
|
31
|
+
scope: z.string().min(1).optional(),
|
|
32
|
+
claims: z.record(z.string(), z.unknown()).optional(),
|
|
33
|
+
proof_signing_alg_values_supported: z.array(z.string().min(1)).default([
|
|
34
|
+
"ES256",
|
|
35
|
+
"ES384",
|
|
36
|
+
"EdDSA"
|
|
37
|
+
])
|
|
38
|
+
});
|
|
39
|
+
const signingAlgSchema = z.enum([
|
|
40
|
+
"ES256",
|
|
41
|
+
"ES384",
|
|
42
|
+
"EdDSA"
|
|
43
|
+
]);
|
|
44
|
+
const statusListBitsSchema = z.union([
|
|
45
|
+
z.literal(1),
|
|
46
|
+
z.literal(2),
|
|
47
|
+
z.literal(4),
|
|
48
|
+
z.literal(8)
|
|
49
|
+
]);
|
|
50
|
+
const tokenStatusValueSchema = z.number().int().min(0).max(255);
|
|
51
|
+
const credentialStatusListReferenceSchema = z.object({
|
|
52
|
+
idx: z.number().int().nonnegative(),
|
|
53
|
+
uri: z.string().min(1)
|
|
54
|
+
});
|
|
55
|
+
const credentialStatusSchema = z.object({ status_list: credentialStatusListReferenceSchema });
|
|
56
|
+
const statusListRecordSchema = z.object({
|
|
57
|
+
uri: z.string().min(1),
|
|
58
|
+
bits: statusListBitsSchema.default(1),
|
|
59
|
+
statuses: z.array(tokenStatusValueSchema).default([]),
|
|
60
|
+
ttl: z.number().int().positive().optional(),
|
|
61
|
+
expiresAt: z.number().int().positive().optional(),
|
|
62
|
+
aggregation_uri: z.string().min(1).optional()
|
|
63
|
+
});
|
|
64
|
+
const createStatusListInputSchema = statusListRecordSchema.omit({ statuses: true });
|
|
65
|
+
const allocateCredentialStatusInputSchema = z.object({
|
|
66
|
+
statusList: statusListRecordSchema,
|
|
67
|
+
status: tokenStatusValueSchema.default(0)
|
|
68
|
+
});
|
|
69
|
+
const updateCredentialStatusInputSchema = z.object({
|
|
70
|
+
statusList: statusListRecordSchema,
|
|
71
|
+
idx: z.number().int().nonnegative(),
|
|
72
|
+
status: tokenStatusValueSchema
|
|
73
|
+
});
|
|
74
|
+
const issuerConfigSchema = z.object({
|
|
75
|
+
issuer: z.string().url(),
|
|
76
|
+
credentialConfigurationsSupported: z.record(z.string(), credentialConfigurationSchema).refine((value) => Object.keys(value).length > 0, "At least one credential configuration is required"),
|
|
77
|
+
signingKey: z.object({
|
|
78
|
+
alg: signingAlgSchema.default("EdDSA"),
|
|
79
|
+
privateJwk: jwkSchema,
|
|
80
|
+
publicJwk: jwkSchema
|
|
81
|
+
}),
|
|
82
|
+
endpoints: z.object({
|
|
83
|
+
token: z.string().url().optional(),
|
|
84
|
+
credential: z.string().url().optional(),
|
|
85
|
+
nonce: z.string().url().optional()
|
|
86
|
+
}).optional(),
|
|
87
|
+
nonceTtlSeconds: z.number().int().positive().default(300),
|
|
88
|
+
grantTtlSeconds: z.number().int().positive().default(600),
|
|
89
|
+
tokenTtlSeconds: z.number().int().positive().default(600)
|
|
90
|
+
});
|
|
91
|
+
const createPreAuthorizedGrantInputSchema = z.object({
|
|
92
|
+
credential_configuration_id: z.string().min(1),
|
|
93
|
+
claims: claimSetSchema,
|
|
94
|
+
expires_in: z.number().int().positive().optional()
|
|
95
|
+
});
|
|
96
|
+
const createCredentialOfferInputSchema = createPreAuthorizedGrantInputSchema;
|
|
97
|
+
const preAuthorizedGrantTypeSchema = z.literal("urn:ietf:params:oauth:grant-type:pre-authorized_code");
|
|
98
|
+
const credentialOfferGrantSchema = z.object({
|
|
99
|
+
"pre-authorized_code": z.string().min(1),
|
|
100
|
+
tx_code: z.never().optional()
|
|
101
|
+
});
|
|
102
|
+
const credentialOfferSchema = z.object({
|
|
103
|
+
credential_issuer: z.string().url(),
|
|
104
|
+
credential_configuration_ids: z.array(z.string().min(1)).min(1).max(1),
|
|
105
|
+
grants: z.object({ "urn:ietf:params:oauth:grant-type:pre-authorized_code": credentialOfferGrantSchema })
|
|
106
|
+
});
|
|
107
|
+
const credentialOfferUriSchema = z.string().min(1).refine((value) => {
|
|
108
|
+
try {
|
|
109
|
+
return new URL(value).protocol === "openid-credential-offer:";
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}, "Credential offer URI must use openid-credential-offer://");
|
|
114
|
+
const issuerMetadataCredentialConfigurationSchema = z.object({
|
|
115
|
+
format: z.literal("dc+sd-jwt"),
|
|
116
|
+
vct: z.string().min(1),
|
|
117
|
+
scope: z.string().min(1),
|
|
118
|
+
proof_types_supported: z.object({ jwt: z.object({ proof_signing_alg_values_supported: z.array(z.string().min(1)).min(1) }) }),
|
|
119
|
+
cryptographic_binding_methods_supported: z.array(z.literal("jwk")).min(1),
|
|
120
|
+
credential_signing_alg_values_supported: z.array(z.string().min(1)).min(1)
|
|
121
|
+
});
|
|
122
|
+
const issuerMetadataSchema = z.object({
|
|
123
|
+
credential_issuer: z.string().url(),
|
|
124
|
+
token_endpoint: z.string().url(),
|
|
125
|
+
credential_endpoint: z.string().url(),
|
|
126
|
+
nonce_endpoint: z.string().url().optional(),
|
|
127
|
+
jwks: z.object({ keys: z.array(jwkSchema).min(1) }),
|
|
128
|
+
credential_configurations_supported: z.record(z.string(), issuerMetadataCredentialConfigurationSchema)
|
|
129
|
+
});
|
|
130
|
+
const preAuthorizedGrantRecordSchema = z.object({
|
|
131
|
+
preAuthorizedCode: z.string().min(1),
|
|
132
|
+
credentialConfigurationId: z.string().min(1),
|
|
133
|
+
claims: claimSetSchema,
|
|
134
|
+
expiresAt: z.number().int(),
|
|
135
|
+
used: z.boolean()
|
|
136
|
+
});
|
|
137
|
+
const tokenRequestSchema = z.object({
|
|
138
|
+
grant_type: preAuthorizedGrantTypeSchema,
|
|
139
|
+
"pre-authorized_code": z.string().min(1),
|
|
140
|
+
tx_code: z.string().min(1).optional()
|
|
141
|
+
});
|
|
142
|
+
const tokenResponseSchema = z.object({
|
|
143
|
+
access_token: z.string().min(1),
|
|
144
|
+
token_type: z.literal("Bearer"),
|
|
145
|
+
expires_in: z.number().int().positive(),
|
|
146
|
+
credential_configuration_id: z.string().min(1),
|
|
147
|
+
c_nonce: z.string().min(1).optional(),
|
|
148
|
+
c_nonce_expires_in: z.number().int().positive().optional()
|
|
149
|
+
});
|
|
150
|
+
const exchangePreAuthorizedCodeInputSchema = z.object({
|
|
151
|
+
tokenRequest: tokenRequestSchema,
|
|
152
|
+
preAuthorizedGrant: preAuthorizedGrantRecordSchema
|
|
153
|
+
});
|
|
154
|
+
const accessTokenRecordSchema = z.object({
|
|
155
|
+
accessToken: z.string().min(1),
|
|
156
|
+
credentialConfigurationId: z.string().min(1),
|
|
157
|
+
claims: claimSetSchema,
|
|
158
|
+
expiresAt: z.number().int(),
|
|
159
|
+
used: z.boolean()
|
|
160
|
+
});
|
|
161
|
+
const issueCredentialInputSchema = z.object({
|
|
162
|
+
accessToken: accessTokenRecordSchema,
|
|
163
|
+
credential_configuration_id: z.string().min(1),
|
|
164
|
+
holderPublicJwk: jwkSchema.optional(),
|
|
165
|
+
status: credentialStatusSchema.optional()
|
|
166
|
+
});
|
|
167
|
+
const nonceRecordSchema = z.object({
|
|
168
|
+
c_nonce: z.string().min(1),
|
|
169
|
+
expiresAt: z.number().int(),
|
|
170
|
+
used: z.boolean()
|
|
171
|
+
});
|
|
172
|
+
const nonceResponseSchema = z.object({
|
|
173
|
+
c_nonce: z.string().min(1),
|
|
174
|
+
c_nonce_expires_in: z.number().int().positive()
|
|
175
|
+
});
|
|
176
|
+
const credentialRequestProofSchema = z.object({
|
|
177
|
+
proof_type: z.literal("jwt"),
|
|
178
|
+
jwt: z.string().min(1)
|
|
179
|
+
});
|
|
180
|
+
const credentialRequestSchema = z.object({
|
|
181
|
+
format: z.literal("dc+sd-jwt").optional(),
|
|
182
|
+
credential_configuration_id: z.string().min(1),
|
|
183
|
+
proofs: z.object({ jwt: z.array(credentialRequestProofSchema).min(1) })
|
|
184
|
+
});
|
|
185
|
+
const credentialResponseSchema = z.object({
|
|
186
|
+
format: z.literal("dc+sd-jwt"),
|
|
187
|
+
credential: z.string().min(1),
|
|
188
|
+
c_nonce: z.string().min(1).optional(),
|
|
189
|
+
c_nonce_expires_in: z.number().int().positive().optional()
|
|
190
|
+
});
|
|
191
|
+
const validateProofJwtInputSchema = z.object({
|
|
192
|
+
jwt: z.string().min(1),
|
|
193
|
+
nonce: nonceRecordSchema
|
|
194
|
+
});
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/crypto.ts
|
|
197
|
+
const cleanupFingerprint = (value) => value.replace(/^sha256 fingerprint=/i, "").replaceAll(":", "").trim();
|
|
198
|
+
const certificatePemToX5c = (certificatePem) => [certificatePem.replace("-----BEGIN CERTIFICATE-----", "").replace("-----END CERTIFICATE-----", "").replace(/\s+/g, "")];
|
|
199
|
+
const generateIssuerTrustMaterial = async (input) => {
|
|
200
|
+
const kid = input?.kid ?? "issuer-key-1";
|
|
201
|
+
const subject = input?.subject ?? "/CN=Demo Issuer/O=openid4vc-tools";
|
|
202
|
+
const daysValid = input?.daysValid ?? 365;
|
|
203
|
+
const alg = input?.alg ?? "EdDSA";
|
|
204
|
+
const { privateKey, publicKey } = alg === "EdDSA" ? await generateKeyPair("EdDSA", {
|
|
205
|
+
crv: "Ed25519",
|
|
206
|
+
extractable: true
|
|
207
|
+
}) : await generateKeyPair(alg, { extractable: true });
|
|
208
|
+
const privateJwk = jwkSchema.parse({
|
|
209
|
+
...await exportJWK(privateKey),
|
|
210
|
+
kid,
|
|
211
|
+
alg,
|
|
212
|
+
use: "sig"
|
|
213
|
+
});
|
|
214
|
+
const publicJwk = jwkSchema.parse({
|
|
215
|
+
...await exportJWK(publicKey),
|
|
216
|
+
kid,
|
|
217
|
+
alg,
|
|
218
|
+
use: "sig"
|
|
219
|
+
});
|
|
220
|
+
const privateKeyPem = await exportPKCS8(privateKey);
|
|
221
|
+
const publicKeyPem = await exportSPKI(publicKey);
|
|
222
|
+
const dir = await mkdtemp(join(tmpdir(), "issuer-trust-"));
|
|
223
|
+
const keyPath = join(dir, "issuer-key.pem");
|
|
224
|
+
const certPath = join(dir, "issuer-cert.pem");
|
|
225
|
+
try {
|
|
226
|
+
await writeFile(keyPath, privateKeyPem, "utf8");
|
|
227
|
+
execFileSync("openssl", [
|
|
228
|
+
"req",
|
|
229
|
+
"-x509",
|
|
230
|
+
"-new",
|
|
231
|
+
"-key",
|
|
232
|
+
keyPath,
|
|
233
|
+
"-out",
|
|
234
|
+
certPath,
|
|
235
|
+
"-subj",
|
|
236
|
+
subject,
|
|
237
|
+
"-days",
|
|
238
|
+
String(daysValid)
|
|
239
|
+
], { stdio: "ignore" });
|
|
240
|
+
const certificatePem = await readFile(certPath, "utf8");
|
|
241
|
+
const certificateFingerprintSha256 = cleanupFingerprint(execFileSync("openssl", [
|
|
242
|
+
"x509",
|
|
243
|
+
"-noout",
|
|
244
|
+
"-fingerprint",
|
|
245
|
+
"-sha256",
|
|
246
|
+
"-in",
|
|
247
|
+
certPath
|
|
248
|
+
], { encoding: "utf8" }));
|
|
249
|
+
const x5c = certificatePemToX5c(certificatePem);
|
|
250
|
+
const publicJwkWithCertificate = jwkSchema.parse({
|
|
251
|
+
...publicJwk,
|
|
252
|
+
x5c
|
|
253
|
+
});
|
|
254
|
+
const jwks = { keys: [publicJwkWithCertificate] };
|
|
255
|
+
return {
|
|
256
|
+
alg,
|
|
257
|
+
kid,
|
|
258
|
+
privateJwk,
|
|
259
|
+
publicJwk: publicJwkWithCertificate,
|
|
260
|
+
privateKeyPem,
|
|
261
|
+
publicKeyPem,
|
|
262
|
+
certificatePem,
|
|
263
|
+
certificateFingerprintSha256,
|
|
264
|
+
jwks,
|
|
265
|
+
trustArtifact: {
|
|
266
|
+
kid,
|
|
267
|
+
alg,
|
|
268
|
+
jwks,
|
|
269
|
+
publicKeyPem,
|
|
270
|
+
certificatePem,
|
|
271
|
+
certificateFingerprintSha256
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
} finally {
|
|
275
|
+
await rm(dir, {
|
|
276
|
+
recursive: true,
|
|
277
|
+
force: true
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/errors.ts
|
|
283
|
+
var IssuerError = class extends Error {
|
|
284
|
+
constructor(code, message) {
|
|
285
|
+
super(message);
|
|
286
|
+
this.code = code;
|
|
287
|
+
this.name = "IssuerError";
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/openid4vci.ts
|
|
292
|
+
const OPENID_CREDENTIAL_OFFER_WELL_KNOWN = "/.well-known/openid-credential-issuer";
|
|
293
|
+
function createIssuerMetadata(config) {
|
|
294
|
+
const parsed = issuerConfigSchema.parse(config);
|
|
295
|
+
const tokenEndpoint = parsed.endpoints?.token ?? new URL("/token", parsed.issuer).toString();
|
|
296
|
+
const credentialEndpoint = parsed.endpoints?.credential ?? new URL("/credential", parsed.issuer).toString();
|
|
297
|
+
const nonceEndpoint = parsed.endpoints?.nonce ?? new URL("/nonce", parsed.issuer).toString();
|
|
298
|
+
return issuerMetadataSchema.parse({
|
|
299
|
+
credential_issuer: parsed.issuer,
|
|
300
|
+
token_endpoint: tokenEndpoint,
|
|
301
|
+
credential_endpoint: credentialEndpoint,
|
|
302
|
+
nonce_endpoint: nonceEndpoint,
|
|
303
|
+
jwks: { keys: [parsed.signingKey.publicJwk] },
|
|
304
|
+
credential_configurations_supported: Object.fromEntries(Object.entries(parsed.credentialConfigurationsSupported).map(([id, entry]) => [id, {
|
|
305
|
+
format: entry.format,
|
|
306
|
+
vct: entry.vct,
|
|
307
|
+
scope: entry.scope ?? id,
|
|
308
|
+
proof_types_supported: { jwt: { proof_signing_alg_values_supported: entry.proof_signing_alg_values_supported } },
|
|
309
|
+
cryptographic_binding_methods_supported: ["jwk"],
|
|
310
|
+
credential_signing_alg_values_supported: [parsed.signingKey.alg]
|
|
311
|
+
}]))
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function serializeCredentialOfferUri(offer) {
|
|
315
|
+
const parsed = credentialOfferSchema.parse(offer);
|
|
316
|
+
const url = new URL("openid-credential-offer://");
|
|
317
|
+
url.searchParams.set("credential_offer", JSON.stringify(parsed));
|
|
318
|
+
return credentialOfferUriSchema.parse(url.toString());
|
|
319
|
+
}
|
|
320
|
+
function serializeCredentialOfferReferenceUri(credentialOfferUrl) {
|
|
321
|
+
const parsed = new URL(credentialOfferUrl).toString();
|
|
322
|
+
const url = new URL("openid-credential-offer://");
|
|
323
|
+
url.searchParams.set("credential_offer_uri", parsed);
|
|
324
|
+
return credentialOfferUriSchema.parse(url.toString());
|
|
325
|
+
}
|
|
326
|
+
function getCredentialIssuerMetadataUrl(credentialIssuer) {
|
|
327
|
+
const issuerUrl = new URL(credentialIssuer);
|
|
328
|
+
const issuerPath = issuerUrl.pathname === "/" ? "" : issuerUrl.pathname;
|
|
329
|
+
return new URL(`${OPENID_CREDENTIAL_OFFER_WELL_KNOWN}${issuerPath}`, issuerUrl.origin).toString();
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/status-list.ts
|
|
333
|
+
const maxStatusValue = (bits) => (1 << bits) - 1;
|
|
334
|
+
function assertStatusValueFits(bits, value) {
|
|
335
|
+
if (value > maxStatusValue(bits)) throw new Error(`Status value ${value} does not fit in a ${bits}-bit status list`);
|
|
336
|
+
}
|
|
337
|
+
function createStatusList(input) {
|
|
338
|
+
const parsed = createStatusListInputSchema.parse(input);
|
|
339
|
+
return statusListRecordSchema.parse({
|
|
340
|
+
...parsed,
|
|
341
|
+
statuses: []
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
function allocateCredentialStatus(input) {
|
|
345
|
+
const parsed = allocateCredentialStatusInputSchema.parse(input);
|
|
346
|
+
assertStatusValueFits(parsed.statusList.bits, parsed.status);
|
|
347
|
+
const idx = parsed.statusList.statuses.length;
|
|
348
|
+
const updatedStatusList = statusListRecordSchema.parse({
|
|
349
|
+
...parsed.statusList,
|
|
350
|
+
statuses: [...parsed.statusList.statuses, parsed.status]
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
credentialStatus: { status_list: {
|
|
354
|
+
idx,
|
|
355
|
+
uri: parsed.statusList.uri
|
|
356
|
+
} },
|
|
357
|
+
updatedStatusList
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function updateCredentialStatus(input) {
|
|
361
|
+
const parsed = updateCredentialStatusInputSchema.parse(input);
|
|
362
|
+
assertStatusValueFits(parsed.statusList.bits, parsed.status);
|
|
363
|
+
if (parsed.idx >= parsed.statusList.statuses.length) throw new Error(`Status list index ${parsed.idx} is out of bounds`);
|
|
364
|
+
const statuses = [...parsed.statusList.statuses];
|
|
365
|
+
statuses[parsed.idx] = parsed.status;
|
|
366
|
+
return statusListRecordSchema.parse({
|
|
367
|
+
...parsed.statusList,
|
|
368
|
+
statuses
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function encodeStatusList(statusList) {
|
|
372
|
+
const parsed = statusListRecordSchema.parse(statusList);
|
|
373
|
+
const packed = packStatuses(parsed.statuses, parsed.bits);
|
|
374
|
+
return {
|
|
375
|
+
bits: parsed.bits,
|
|
376
|
+
lst: deflateSync(packed).toString("base64url"),
|
|
377
|
+
aggregation_uri: parsed.aggregation_uri
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
async function createStatusListJwt(input) {
|
|
381
|
+
const payload = encodeStatusList(input.statusList);
|
|
382
|
+
const claims = {
|
|
383
|
+
iss: input.issuer,
|
|
384
|
+
status_list: payload
|
|
385
|
+
};
|
|
386
|
+
if (input.statusList.ttl) claims.ttl = input.statusList.ttl;
|
|
387
|
+
const jwt = new SignJWT(claims).setProtectedHeader({
|
|
388
|
+
alg: input.signingKey.alg,
|
|
389
|
+
typ: "statuslist+jwt",
|
|
390
|
+
kid: input.signingKey.publicJwk.kid,
|
|
391
|
+
x5c: input.signingKey.publicJwk.x5c
|
|
392
|
+
}).setSubject(input.statusList.uri).setIssuedAt(input.now());
|
|
393
|
+
if (input.statusList.expiresAt) jwt.setExpirationTime(input.statusList.expiresAt);
|
|
394
|
+
return jwt.sign(input.signingKey.privateKey);
|
|
395
|
+
}
|
|
396
|
+
function packStatuses(statuses, bits) {
|
|
397
|
+
const byteLength = Math.ceil(statuses.length * bits / 8);
|
|
398
|
+
const output = new Uint8Array(byteLength);
|
|
399
|
+
for (const [idx, status] of statuses.entries()) {
|
|
400
|
+
assertStatusValueFits(bits, status);
|
|
401
|
+
const bitOffset = idx * bits;
|
|
402
|
+
const byteIndex = Math.floor(bitOffset / 8);
|
|
403
|
+
const intraByteOffset = bitOffset % 8;
|
|
404
|
+
output[byteIndex] = (output[byteIndex] ?? 0) | status << intraByteOffset;
|
|
405
|
+
}
|
|
406
|
+
return output;
|
|
407
|
+
}
|
|
408
|
+
//#endregion
|
|
409
|
+
//#region src/utils.ts
|
|
410
|
+
const nowInSeconds = () => Math.floor(Date.now() / 1e3);
|
|
411
|
+
const randomToken = () => randomBytes(24).toString("base64url");
|
|
412
|
+
const cloneJson = (value) => structuredClone(value);
|
|
413
|
+
const toBase64Url = (input) => Buffer.from(input).toString("base64url");
|
|
414
|
+
const fromBase64Url = (input) => new Uint8Array(Buffer.from(input, "base64url"));
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/issuer.ts
|
|
417
|
+
const RESERVED_CREDENTIAL_CLAIMS = new Set([
|
|
418
|
+
"iss",
|
|
419
|
+
"nbf",
|
|
420
|
+
"exp",
|
|
421
|
+
"cnf",
|
|
422
|
+
"vct",
|
|
423
|
+
"status",
|
|
424
|
+
"iat"
|
|
425
|
+
]);
|
|
426
|
+
const deriveDisclosureFrame = (claims) => {
|
|
427
|
+
const topLevelClaims = Object.keys(claims).filter((claim) => !RESERVED_CREDENTIAL_CLAIMS.has(claim));
|
|
428
|
+
if (topLevelClaims.length === 0) return;
|
|
429
|
+
return { _sd: topLevelClaims };
|
|
430
|
+
};
|
|
431
|
+
const sanitizeCredentialClaims = (claims) => Object.fromEntries(Object.entries(claims).filter(([claim]) => !RESERVED_CREDENTIAL_CLAIMS.has(claim)));
|
|
432
|
+
const subtleAlgorithm = (alg) => {
|
|
433
|
+
if (alg === "EdDSA") return "Ed25519";
|
|
434
|
+
if (alg === "ES384") return {
|
|
435
|
+
name: "ECDSA",
|
|
436
|
+
hash: "SHA-384"
|
|
437
|
+
};
|
|
438
|
+
return {
|
|
439
|
+
name: "ECDSA",
|
|
440
|
+
hash: "SHA-256"
|
|
441
|
+
};
|
|
442
|
+
};
|
|
443
|
+
const createSdJwtSigner = (privateKey, alg) => async (input) => {
|
|
444
|
+
const signature = await crypto.subtle.sign(subtleAlgorithm(alg), privateKey, new TextEncoder().encode(input));
|
|
445
|
+
return toBase64Url(new Uint8Array(signature));
|
|
446
|
+
};
|
|
447
|
+
const createSdJwtVerifier = (publicKey, alg) => async (input, signature) => {
|
|
448
|
+
return crypto.subtle.verify(subtleAlgorithm(alg), publicKey, fromBase64Url(signature), new TextEncoder().encode(input));
|
|
449
|
+
};
|
|
450
|
+
const holderJwkSchema = jwkSchema.refine((value) => Boolean(value.kty && (value.x || value.n)), "Holder proof must contain an embedded public JWK");
|
|
451
|
+
const proofPayloadSchema = z.object({
|
|
452
|
+
aud: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]),
|
|
453
|
+
nonce: z.string().min(1),
|
|
454
|
+
cnf: z.object({ jwk: holderJwkSchema }).optional()
|
|
455
|
+
});
|
|
456
|
+
var DemoIssuer = class {
|
|
457
|
+
config;
|
|
458
|
+
now;
|
|
459
|
+
idGenerator;
|
|
460
|
+
issuerPrivateKeyPromise;
|
|
461
|
+
issuerPublicKeyPromise;
|
|
462
|
+
sdJwtVc;
|
|
463
|
+
constructor(config, options) {
|
|
464
|
+
this.config = issuerConfigSchema.parse(config);
|
|
465
|
+
this.now = options?.now ?? nowInSeconds;
|
|
466
|
+
this.idGenerator = options?.idGenerator ?? randomToken;
|
|
467
|
+
this.issuerPrivateKeyPromise = importJWK(this.config.signingKey.privateJwk, this.config.signingKey.alg, { extractable: false });
|
|
468
|
+
this.issuerPublicKeyPromise = importJWK(this.config.signingKey.publicJwk, this.config.signingKey.alg, { extractable: true });
|
|
469
|
+
this.sdJwtVc = Promise.all([this.issuerPrivateKeyPromise, this.issuerPublicKeyPromise]).then(([privateKey, publicKey]) => new SDJwtVcInstance({
|
|
470
|
+
signer: createSdJwtSigner(privateKey, this.config.signingKey.alg),
|
|
471
|
+
signAlg: this.config.signingKey.alg,
|
|
472
|
+
verifier: createSdJwtVerifier(publicKey, this.config.signingKey.alg),
|
|
473
|
+
hasher,
|
|
474
|
+
hashAlg: "sha-256",
|
|
475
|
+
saltGenerator: async (length = 16) => toBase64Url(crypto.getRandomValues(new Uint8Array(length)))
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
getJwks() {
|
|
479
|
+
return { keys: [cloneJson(this.config.signingKey.publicJwk)] };
|
|
480
|
+
}
|
|
481
|
+
getMetadata() {
|
|
482
|
+
return issuerMetadataSchema.parse(createIssuerMetadata(this.config));
|
|
483
|
+
}
|
|
484
|
+
createStatusList(input) {
|
|
485
|
+
return createStatusList(input);
|
|
486
|
+
}
|
|
487
|
+
allocateCredentialStatus(input) {
|
|
488
|
+
return allocateCredentialStatus(input);
|
|
489
|
+
}
|
|
490
|
+
updateCredentialStatus(input) {
|
|
491
|
+
return updateCredentialStatus(input);
|
|
492
|
+
}
|
|
493
|
+
async createStatusListToken(statusList) {
|
|
494
|
+
return createStatusListJwt({
|
|
495
|
+
issuer: this.config.issuer,
|
|
496
|
+
signingKey: {
|
|
497
|
+
alg: this.config.signingKey.alg,
|
|
498
|
+
privateKey: await this.issuerPrivateKeyPromise,
|
|
499
|
+
publicJwk: this.config.signingKey.publicJwk
|
|
500
|
+
},
|
|
501
|
+
statusList,
|
|
502
|
+
now: this.now
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
createPreAuthorizedGrant(input) {
|
|
506
|
+
const parsed = createPreAuthorizedGrantInputSchema.parse(input);
|
|
507
|
+
if (!this.config.credentialConfigurationsSupported[parsed.credential_configuration_id]) throw new IssuerError("unsupported_credential_configuration", "Unsupported credential_configuration_id");
|
|
508
|
+
const preAuthorizedCode = this.idGenerator();
|
|
509
|
+
const expiresAt = this.now() + (parsed.expires_in ?? this.config.grantTtlSeconds);
|
|
510
|
+
const preAuthorizedGrant = {
|
|
511
|
+
preAuthorizedCode,
|
|
512
|
+
credentialConfigurationId: parsed.credential_configuration_id,
|
|
513
|
+
claims: cloneJson(parsed.claims),
|
|
514
|
+
expiresAt,
|
|
515
|
+
used: false
|
|
516
|
+
};
|
|
517
|
+
return {
|
|
518
|
+
preAuthorizedCode,
|
|
519
|
+
expiresAt,
|
|
520
|
+
credential_configuration_id: parsed.credential_configuration_id,
|
|
521
|
+
preAuthorizedGrant
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
createCredentialOffer(input) {
|
|
525
|
+
const parsed = createCredentialOfferInputSchema.parse(input);
|
|
526
|
+
const grant = this.createPreAuthorizedGrant(parsed);
|
|
527
|
+
return {
|
|
528
|
+
...credentialOfferSchema.parse({
|
|
529
|
+
credential_issuer: this.config.issuer,
|
|
530
|
+
credential_configuration_ids: [grant.credential_configuration_id],
|
|
531
|
+
grants: { "urn:ietf:params:oauth:grant-type:pre-authorized_code": { "pre-authorized_code": grant.preAuthorizedCode } }
|
|
532
|
+
}),
|
|
533
|
+
preAuthorizedGrant: grant.preAuthorizedGrant
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
createCredentialOfferUri(input) {
|
|
537
|
+
return serializeCredentialOfferUri(this.createCredentialOffer(input));
|
|
538
|
+
}
|
|
539
|
+
createCredentialOfferReferenceUri(credentialOfferUrl) {
|
|
540
|
+
return serializeCredentialOfferReferenceUri(credentialOfferUrl);
|
|
541
|
+
}
|
|
542
|
+
exchangePreAuthorizedCode(input) {
|
|
543
|
+
const parsed = exchangePreAuthorizedCodeInputSchema.parse(input);
|
|
544
|
+
if (parsed.tokenRequest.tx_code) throw new IssuerError("unsupported_tx_code", "tx_code is not supported in this demo issuer");
|
|
545
|
+
if (parsed.preAuthorizedGrant.preAuthorizedCode !== parsed.tokenRequest["pre-authorized_code"]) throw new IssuerError("invalid_grant", "Invalid or expired pre-authorized code");
|
|
546
|
+
if (parsed.preAuthorizedGrant.used || parsed.preAuthorizedGrant.expiresAt <= this.now()) throw new IssuerError("invalid_grant", "Invalid or expired pre-authorized code");
|
|
547
|
+
const accessToken = this.idGenerator();
|
|
548
|
+
const expiresIn = this.config.tokenTtlSeconds;
|
|
549
|
+
const updatedPreAuthorizedGrant = {
|
|
550
|
+
...parsed.preAuthorizedGrant,
|
|
551
|
+
used: true
|
|
552
|
+
};
|
|
553
|
+
const accessTokenRecord = {
|
|
554
|
+
accessToken,
|
|
555
|
+
credentialConfigurationId: parsed.preAuthorizedGrant.credentialConfigurationId,
|
|
556
|
+
claims: cloneJson(parsed.preAuthorizedGrant.claims),
|
|
557
|
+
expiresAt: this.now() + expiresIn,
|
|
558
|
+
used: false
|
|
559
|
+
};
|
|
560
|
+
return {
|
|
561
|
+
access_token: accessToken,
|
|
562
|
+
token_type: "Bearer",
|
|
563
|
+
expires_in: expiresIn,
|
|
564
|
+
credential_configuration_id: parsed.preAuthorizedGrant.credentialConfigurationId,
|
|
565
|
+
accessTokenRecord,
|
|
566
|
+
updatedPreAuthorizedGrant
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
createNonce() {
|
|
570
|
+
const c_nonce = this.idGenerator();
|
|
571
|
+
const expiresIn = this.config.nonceTtlSeconds;
|
|
572
|
+
return {
|
|
573
|
+
c_nonce,
|
|
574
|
+
c_nonce_expires_in: expiresIn,
|
|
575
|
+
nonce: {
|
|
576
|
+
c_nonce,
|
|
577
|
+
expiresAt: this.now() + expiresIn,
|
|
578
|
+
used: false
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
async validateProofJwt(input) {
|
|
583
|
+
const parsed = validateProofJwtInputSchema.parse(input);
|
|
584
|
+
const protectedHeader = decodeProtectedHeader(parsed.jwt);
|
|
585
|
+
if (protectedHeader.typ !== "openid4vci-proof+jwt") throw new IssuerError("invalid_proof", "Proof JWT typ must be openid4vci-proof+jwt");
|
|
586
|
+
if (typeof protectedHeader.alg !== "string" || protectedHeader.alg.length === 0) throw new IssuerError("invalid_proof", "Proof JWT alg is required");
|
|
587
|
+
const embeddedJwk = holderJwkSchema.safeParse(protectedHeader.jwk);
|
|
588
|
+
if (!embeddedJwk.success) throw new IssuerError("invalid_proof", "Proof JWT must contain an embedded public JWK in the protected header");
|
|
589
|
+
const importedFromHeader = await importJWK(embeddedJwk.data, protectedHeader.alg, { extractable: true });
|
|
590
|
+
let verified;
|
|
591
|
+
try {
|
|
592
|
+
verified = await jwtVerify(parsed.jwt, importedFromHeader, { audience: this.config.issuer });
|
|
593
|
+
} catch (error) {
|
|
594
|
+
throw new IssuerError("invalid_proof", error instanceof Error ? error.message : "Proof JWT verification failed");
|
|
595
|
+
}
|
|
596
|
+
const payload = proofPayloadSchema.parse(verified.payload);
|
|
597
|
+
if (parsed.nonce.c_nonce !== payload.nonce) throw new IssuerError("invalid_proof", "Proof JWT nonce is invalid or expired");
|
|
598
|
+
if (parsed.nonce.used || parsed.nonce.expiresAt <= this.now()) throw new IssuerError("invalid_proof", "Proof JWT nonce is invalid or expired");
|
|
599
|
+
const holderPublicJwk = embeddedJwk.data;
|
|
600
|
+
const updatedNonce = {
|
|
601
|
+
...parsed.nonce,
|
|
602
|
+
used: true
|
|
603
|
+
};
|
|
604
|
+
return {
|
|
605
|
+
nonce: payload.nonce,
|
|
606
|
+
holderPublicJwk,
|
|
607
|
+
holderKeyThumbprint: await calculateJwkThumbprint(holderPublicJwk),
|
|
608
|
+
payload: cloneJson(verified?.payload),
|
|
609
|
+
protectedHeader: cloneJson(protectedHeader),
|
|
610
|
+
updatedNonce
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
async issueCredential(input) {
|
|
614
|
+
const parsed = issueCredentialInputSchema.parse(input);
|
|
615
|
+
if (parsed.accessToken.used || parsed.accessToken.expiresAt <= this.now()) throw new IssuerError("invalid_token", "Invalid or expired access token");
|
|
616
|
+
if (parsed.accessToken.credentialConfigurationId !== parsed.credential_configuration_id) throw new IssuerError("invalid_request", "Access token is not valid for credential_configuration_id");
|
|
617
|
+
const configuration = this.config.credentialConfigurationsSupported[parsed.credential_configuration_id];
|
|
618
|
+
if (!configuration) throw new IssuerError("unsupported_credential_configuration", "Unsupported credential_configuration_id");
|
|
619
|
+
const binding = input.proof ? {
|
|
620
|
+
holderPublicJwk: input.proof.holderPublicJwk,
|
|
621
|
+
holderKeyThumbprint: input.proof.holderKeyThumbprint
|
|
622
|
+
} : parsed.holderPublicJwk ? {
|
|
623
|
+
holderPublicJwk: parsed.holderPublicJwk,
|
|
624
|
+
holderKeyThumbprint: await calculateJwkThumbprint(parsed.holderPublicJwk)
|
|
625
|
+
} : {};
|
|
626
|
+
const sdJwtVc = await this.sdJwtVc;
|
|
627
|
+
const issuedAt = this.now();
|
|
628
|
+
const credentialClaims = sanitizeCredentialClaims(parsed.accessToken.claims);
|
|
629
|
+
const payload = {
|
|
630
|
+
iss: this.config.issuer,
|
|
631
|
+
iat: issuedAt,
|
|
632
|
+
vct: configuration.vct,
|
|
633
|
+
status: parsed.status ? cloneJson(parsed.status) : void 0,
|
|
634
|
+
cnf: binding.holderPublicJwk ? { jwk: binding.holderPublicJwk } : void 0,
|
|
635
|
+
...cloneJson(credentialClaims)
|
|
636
|
+
};
|
|
637
|
+
const credential = await sdJwtVc.issue(payload, deriveDisclosureFrame(credentialClaims), { header: {
|
|
638
|
+
kid: this.config.signingKey.publicJwk.kid,
|
|
639
|
+
x5c: this.config.signingKey.publicJwk.x5c
|
|
640
|
+
} });
|
|
641
|
+
const nonce = this.createNonce();
|
|
642
|
+
const updatedAccessToken = {
|
|
643
|
+
...parsed.accessToken,
|
|
644
|
+
used: true
|
|
645
|
+
};
|
|
646
|
+
return {
|
|
647
|
+
...credentialResponseSchema.parse({
|
|
648
|
+
format: "dc+sd-jwt",
|
|
649
|
+
credential,
|
|
650
|
+
c_nonce: nonce.c_nonce
|
|
651
|
+
}),
|
|
652
|
+
format: "dc+sd-jwt",
|
|
653
|
+
nonce: nonce.nonce,
|
|
654
|
+
updatedAccessToken
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
async parseIssuedCredential(encoded) {
|
|
658
|
+
const sdJwt = await SDJwt.fromEncode(encoded, hasher);
|
|
659
|
+
const jwt = await SDJwt.extractJwt(encoded);
|
|
660
|
+
return {
|
|
661
|
+
jwt: jwt.encodeJwt(),
|
|
662
|
+
header: jwt.header,
|
|
663
|
+
payload: jwt.payload,
|
|
664
|
+
claims: await sdJwt.getClaims(hasher)
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
const createIssuer = (config, options) => new DemoIssuer(config, options);
|
|
669
|
+
//#endregion
|
|
670
|
+
export { DemoIssuer, IssuerError, accessTokenRecordSchema, allocateCredentialStatus, claimSetSchema, createCredentialOfferInputSchema, createIssuer, createIssuerMetadata, createPreAuthorizedGrantInputSchema, createStatusList, createStatusListInputSchema, createStatusListJwt, credentialConfigurationSchema, credentialOfferSchema, credentialOfferUriSchema, credentialRequestProofSchema, credentialRequestSchema, credentialResponseSchema, credentialStatusListReferenceSchema, credentialStatusSchema, encodeStatusList, exchangePreAuthorizedCodeInputSchema, generateIssuerTrustMaterial, getCredentialIssuerMetadataUrl, issueCredentialInputSchema, issuerConfigSchema, issuerMetadataCredentialConfigurationSchema, issuerMetadataSchema, jwkSchema, nonceRecordSchema, nonceResponseSchema, preAuthorizedGrantRecordSchema, preAuthorizedGrantTypeSchema, serializeCredentialOfferReferenceUri, serializeCredentialOfferUri, signingAlgSchema, statusListBitsSchema, statusListRecordSchema, tokenRequestSchema, tokenResponseSchema, tokenStatusValueSchema, updateCredentialStatus, validateProofJwtInputSchema };
|