@dwk/vc 0.1.0-beta.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.
Files changed (58) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +143 -0
  3. package/dist/config.d.ts +97 -0
  4. package/dist/config.d.ts.map +1 -0
  5. package/dist/config.js +62 -0
  6. package/dist/config.js.map +1 -0
  7. package/dist/credential.d.ts +70 -0
  8. package/dist/credential.d.ts.map +1 -0
  9. package/dist/credential.js +139 -0
  10. package/dist/credential.js.map +1 -0
  11. package/dist/data-integrity.d.ts +102 -0
  12. package/dist/data-integrity.d.ts.map +1 -0
  13. package/dist/data-integrity.js +253 -0
  14. package/dist/data-integrity.js.map +1 -0
  15. package/dist/datetime.d.ts +26 -0
  16. package/dist/datetime.d.ts.map +1 -0
  17. package/dist/datetime.js +54 -0
  18. package/dist/datetime.js.map +1 -0
  19. package/dist/did-web.d.ts +93 -0
  20. package/dist/did-web.d.ts.map +1 -0
  21. package/dist/did-web.js +206 -0
  22. package/dist/did-web.js.map +1 -0
  23. package/dist/handler.d.ts +37 -0
  24. package/dist/handler.d.ts.map +1 -0
  25. package/dist/handler.js +362 -0
  26. package/dist/handler.js.map +1 -0
  27. package/dist/index.d.ts +42 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +46 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/jcs.d.ts +31 -0
  32. package/dist/jcs.d.ts.map +1 -0
  33. package/dist/jcs.js +67 -0
  34. package/dist/jcs.js.map +1 -0
  35. package/dist/log.d.ts +34 -0
  36. package/dist/log.d.ts.map +1 -0
  37. package/dist/log.js +32 -0
  38. package/dist/log.js.map +1 -0
  39. package/dist/multibase.d.ts +57 -0
  40. package/dist/multibase.d.ts.map +1 -0
  41. package/dist/multibase.js +165 -0
  42. package/dist/multibase.js.map +1 -0
  43. package/dist/status-list.d.ts +116 -0
  44. package/dist/status-list.d.ts.map +1 -0
  45. package/dist/status-list.js +241 -0
  46. package/dist/status-list.js.map +1 -0
  47. package/package.json +48 -0
  48. package/src/config.ts +158 -0
  49. package/src/credential.ts +188 -0
  50. package/src/data-integrity.ts +425 -0
  51. package/src/datetime.ts +57 -0
  52. package/src/did-web.ts +273 -0
  53. package/src/handler.ts +477 -0
  54. package/src/index.ts +133 -0
  55. package/src/jcs.ts +83 -0
  56. package/src/log.ts +35 -0
  57. package/src/multibase.ts +189 -0
  58. package/src/status-list.ts +356 -0
package/src/handler.ts ADDED
@@ -0,0 +1,477 @@
1
+ /**
2
+ * The `@dwk/vc` fetch handler: VC-API-style issuance and verification endpoints,
3
+ * a status-update endpoint, and the served (signed) Bitstring Status List
4
+ * credentials. Routing matches the request pathname against each configured
5
+ * endpoint's path, so the handler is mountable under any prefix simply by
6
+ * configuring absolute endpoint URLs.
7
+ *
8
+ * The DID document itself is a static artifact ({@link ./did-web.buildDidDocument});
9
+ * this handler covers only the dynamic parts: signing credentials with the
10
+ * domain's key (a secret binding), verifying them, and flipping status bits in a
11
+ * strongly-consistent D1 store.
12
+ */
13
+
14
+ import { hostFromUrl, type LogFields } from "@dwk/log";
15
+
16
+ import { resolveConfig, type ResolvedVcConfig, type VcConfig } from "./config";
17
+ import {
18
+ addProof,
19
+ importSigner,
20
+ verifyProof,
21
+ type JsonObject,
22
+ type Signer,
23
+ type VerificationMethodResolver,
24
+ } from "./data-integrity";
25
+ import { createDidWebResolver } from "./did-web";
26
+ import { checkValidityPeriod, validateCredential } from "./credential";
27
+ import { VcLogEvent } from "./log";
28
+ import {
29
+ buildEncodedList,
30
+ buildStatusEntry,
31
+ buildStatusListCredential,
32
+ createVcStatusStore,
33
+ decodeBitstring,
34
+ findStatusEntry,
35
+ getBit,
36
+ statusEntryIndex,
37
+ type StatusPurpose,
38
+ type VcStatusStore,
39
+ type VcStatusStoreEnv,
40
+ } from "./status-list";
41
+
42
+ /** Cloudflare bindings required by the `@dwk/vc` handler. */
43
+ export interface VcEnv extends Partial<VcStatusStoreEnv> {
44
+ /**
45
+ * The issuer's **private** signing key as a JWK JSON string (secret binding).
46
+ * Its public half is published in the DID document's verification method.
47
+ */
48
+ readonly VC_SIGNING_KEY: string;
49
+ }
50
+
51
+ /** A `fetch`-compatible Worker handler. */
52
+ export type VcHandler = (
53
+ request: Request,
54
+ env: VcEnv,
55
+ ctx: ExecutionContext,
56
+ ) => Promise<Response>;
57
+
58
+ const JSON_HEADERS = { "content-type": "application/json" } as const;
59
+
60
+ function json(body: unknown, status = 200): Response {
61
+ return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
62
+ }
63
+
64
+ function problem(
65
+ reason: string,
66
+ description: string,
67
+ status: number,
68
+ ): Response {
69
+ return json({ error: reason, error_description: description }, status);
70
+ }
71
+
72
+ function emit(
73
+ config: ResolvedVcConfig,
74
+ level: "info" | "warn",
75
+ event: string,
76
+ fields?: LogFields,
77
+ ): void {
78
+ config.logger[level](event, fields);
79
+ config.metrics.count(event, fields);
80
+ }
81
+
82
+ // Importing a key with crypto.subtle is comparatively expensive, and the secret
83
+ // binding is stable for the life of the isolate. Cache the resolved Signer by the
84
+ // raw JWK string so a hot issuance path imports the key once, not per request.
85
+ const signerCache = new Map<string, Signer>();
86
+
87
+ /** Parse and validate the signing-key secret binding (fail loudly). */
88
+ async function loadSigner(env: VcEnv): Promise<Signer> {
89
+ if (!env.VC_SIGNING_KEY || typeof env.VC_SIGNING_KEY !== "string") {
90
+ throw new Error(
91
+ "@dwk/vc: missing required secret binding `VC_SIGNING_KEY`",
92
+ );
93
+ }
94
+ const cached = signerCache.get(env.VC_SIGNING_KEY);
95
+ if (cached !== undefined) return cached;
96
+
97
+ let jwk: JsonWebKey;
98
+ try {
99
+ jwk = JSON.parse(env.VC_SIGNING_KEY) as JsonWebKey;
100
+ } catch {
101
+ throw new Error("@dwk/vc: `VC_SIGNING_KEY` is not valid JWK JSON");
102
+ }
103
+ const signer = await importSigner(jwk);
104
+ signerCache.set(env.VC_SIGNING_KEY, signer);
105
+ return signer;
106
+ }
107
+
108
+ function getStore(env: VcEnv): VcStatusStore | undefined {
109
+ return env.VC_STATUS_DB
110
+ ? createVcStatusStore({ VC_STATUS_DB: env.VC_STATUS_DB })
111
+ : undefined;
112
+ }
113
+
114
+ /** The status list credential URL for a purpose, under this issuer. */
115
+ function statusListUrl(
116
+ config: ResolvedVcConfig,
117
+ purpose: StatusPurpose,
118
+ ): string {
119
+ return `${config.statusListEndpoint}/${purpose}`;
120
+ }
121
+
122
+ async function isObjectBody(request: Request): Promise<JsonObject | null> {
123
+ try {
124
+ const body = (await request.json()) as unknown;
125
+ if (body !== null && typeof body === "object" && !Array.isArray(body)) {
126
+ return body as JsonObject;
127
+ }
128
+ } catch {
129
+ /* fall through */
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /** POST issue: sign a credential, optionally attaching a status entry. */
135
+ async function handleIssue(
136
+ request: Request,
137
+ env: VcEnv,
138
+ config: ResolvedVcConfig,
139
+ ): Promise<Response> {
140
+ if (!(await config.authorize("issue", request))) {
141
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "unauthorized" });
142
+ return problem("unauthorized", "not authorized to issue credentials", 401);
143
+ }
144
+ const body = await isObjectBody(request);
145
+ const credential = body?.credential;
146
+ if (
147
+ credential === null ||
148
+ typeof credential !== "object" ||
149
+ Array.isArray(credential)
150
+ ) {
151
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "malformed_request" });
152
+ return problem("malformed_request", "body must be { credential }", 400);
153
+ }
154
+ const doc = credential as JsonObject;
155
+
156
+ const errors = validateCredential(doc);
157
+ if (errors.length > 0) {
158
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "malformed_request" });
159
+ return problem("malformed_request", errors.join("; "), 400);
160
+ }
161
+
162
+ // Attach a status entry when status is enabled, the request does not opt out,
163
+ // and the credential does not already carry one.
164
+ if (
165
+ config.statusEnabled &&
166
+ body?.credentialStatus !== false &&
167
+ doc.credentialStatus === undefined
168
+ ) {
169
+ const store = getStore(env);
170
+ if (store !== undefined) {
171
+ await store.init();
172
+ const listUrl = statusListUrl(config, config.statusPurpose);
173
+ const index = await store.allocateIndex(listUrl, config.statusPurpose);
174
+ doc.credentialStatus = buildStatusEntry({
175
+ statusListCredential: listUrl,
176
+ statusListIndex: index,
177
+ statusPurpose: config.statusPurpose,
178
+ });
179
+ }
180
+ }
181
+
182
+ let signer: Signer;
183
+ try {
184
+ signer = await loadSigner(env);
185
+ } catch (error) {
186
+ return problem("server_error", (error as Error).message, 500);
187
+ }
188
+
189
+ const verifiableCredential = await addProof(doc, signer, {
190
+ verificationMethod: config.verificationMethod,
191
+ });
192
+ emit(config, "info", VcLogEvent.Issued, { cryptosuite: signer.cryptosuite });
193
+ return json({ verifiableCredential });
194
+ }
195
+
196
+ /** Resolve a credential's status bit, best-effort. */
197
+ async function checkStatus(
198
+ credential: JsonObject,
199
+ env: VcEnv,
200
+ config: ResolvedVcConfig,
201
+ ): Promise<{ checked: boolean; revoked: boolean; error?: string }> {
202
+ const entry = findStatusEntry(credential, config.statusPurpose);
203
+ if (entry === undefined) return { checked: false, revoked: false };
204
+ const index = statusEntryIndex(entry);
205
+ const listCredential = entry.statusListCredential;
206
+ if (index === undefined || typeof listCredential !== "string") {
207
+ return {
208
+ checked: false,
209
+ revoked: false,
210
+ error: "malformed credentialStatus",
211
+ };
212
+ }
213
+
214
+ // Our own list: read the authoritative D1 store directly.
215
+ const store = getStore(env);
216
+ if (
217
+ store !== undefined &&
218
+ listCredential.startsWith(config.statusListEndpoint)
219
+ ) {
220
+ const revoked = await store.getStatus(
221
+ listCredential,
222
+ config.statusPurpose,
223
+ index,
224
+ );
225
+ return { checked: true, revoked };
226
+ }
227
+
228
+ // Foreign list: fetch the published status list credential and decode it.
229
+ // Only https: is fetched — a foreign `statusListCredential` is attacker-
230
+ // controlled, so refusing other schemes blunts SSRF into internal services.
231
+ let listUrl: URL;
232
+ try {
233
+ listUrl = new URL(listCredential);
234
+ } catch {
235
+ return { checked: false, revoked: false, error: "status list malformed" };
236
+ }
237
+ if (listUrl.protocol !== "https:") {
238
+ return {
239
+ checked: false,
240
+ revoked: false,
241
+ error: "status list URL must use https",
242
+ };
243
+ }
244
+ try {
245
+ const response = await fetch(listUrl.toString(), {
246
+ headers: { accept: "application/json" },
247
+ });
248
+ if (!response.ok) {
249
+ return {
250
+ checked: false,
251
+ revoked: false,
252
+ error: "status list unreachable",
253
+ };
254
+ }
255
+ const listCred = (await response.json()) as JsonObject;
256
+ const subject = listCred.credentialSubject as JsonObject | undefined;
257
+ const encodedList = subject?.encodedList;
258
+ if (typeof encodedList !== "string") {
259
+ return { checked: false, revoked: false, error: "status list malformed" };
260
+ }
261
+ const bits = await decodeBitstring(encodedList);
262
+ return { checked: true, revoked: getBit(bits, index) };
263
+ } catch {
264
+ return { checked: false, revoked: false, error: "status list unreachable" };
265
+ }
266
+ }
267
+
268
+ /** POST verify: structural + validity + proof + status checks. */
269
+ async function handleVerify(
270
+ request: Request,
271
+ env: VcEnv,
272
+ config: ResolvedVcConfig,
273
+ resolver: VerificationMethodResolver,
274
+ ): Promise<Response> {
275
+ const body = await isObjectBody(request);
276
+ const vc = body?.verifiableCredential ?? body?.credential;
277
+ if (vc === null || typeof vc !== "object" || Array.isArray(vc)) {
278
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "malformed_request" });
279
+ return problem(
280
+ "malformed_request",
281
+ "body must be { verifiableCredential }",
282
+ 400,
283
+ );
284
+ }
285
+ const doc = vc as JsonObject;
286
+
287
+ const errors: string[] = [...validateCredential(doc)];
288
+ const warnings: string[] = [];
289
+
290
+ const proofResult = await verifyProof(doc, {
291
+ resolveVerificationMethod: resolver,
292
+ });
293
+ errors.push(...proofResult.errors);
294
+
295
+ const validity = checkValidityPeriod(doc);
296
+ if (validity !== null) errors.push(`credential is ${validity}`);
297
+
298
+ const status = await checkStatus(doc, env, config);
299
+ if (status.error !== undefined) warnings.push(status.error);
300
+ if (status.checked && status.revoked) {
301
+ errors.push(
302
+ config.statusPurpose === "revocation"
303
+ ? "credential is revoked"
304
+ : `credential status "${config.statusPurpose}" is set`,
305
+ );
306
+ }
307
+
308
+ const verified = errors.length === 0;
309
+ emit(config, verified ? "info" : "warn", VcLogEvent.Verified, { verified });
310
+ return json({ verified, errors, warnings });
311
+ }
312
+
313
+ /** POST status: flip a credential's status bit (e.g. revoke). */
314
+ async function handleStatusUpdate(
315
+ request: Request,
316
+ env: VcEnv,
317
+ config: ResolvedVcConfig,
318
+ ): Promise<Response> {
319
+ if (!config.statusEnabled) {
320
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "status_disabled" });
321
+ return problem("status_disabled", "status lists are not enabled", 404);
322
+ }
323
+ if (!(await config.authorize("status", request))) {
324
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "unauthorized" });
325
+ return problem("unauthorized", "not authorized to change status", 401);
326
+ }
327
+ const store = getStore(env);
328
+ if (store === undefined) {
329
+ return problem("server_error", "missing D1 binding `VC_STATUS_DB`", 500);
330
+ }
331
+
332
+ const body = await isObjectBody(request);
333
+ if (body === null) {
334
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "malformed_request" });
335
+ return problem("malformed_request", "body must be a JSON object", 400);
336
+ }
337
+
338
+ // Accept either a full credential (read its status entry) or an explicit index.
339
+ let index: number | undefined;
340
+ const purpose = (
341
+ typeof body.statusPurpose === "string"
342
+ ? body.statusPurpose
343
+ : config.statusPurpose
344
+ ) as StatusPurpose;
345
+ const cred = body.credential;
346
+ if (cred !== undefined && typeof cred === "object" && !Array.isArray(cred)) {
347
+ const entry = findStatusEntry(cred as JsonObject, purpose);
348
+ if (entry !== undefined) index = statusEntryIndex(entry);
349
+ }
350
+ if (index === undefined) {
351
+ const raw = body.statusListIndex ?? body.index;
352
+ const n = typeof raw === "string" ? Number(raw) : raw;
353
+ if (typeof n === "number" && Number.isInteger(n) && n >= 0) index = n;
354
+ }
355
+ if (index === undefined) {
356
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "malformed_request" });
357
+ return problem(
358
+ "malformed_request",
359
+ "provide a credential with a status entry or a statusListIndex",
360
+ 400,
361
+ );
362
+ }
363
+
364
+ const value = body.status === undefined ? true : body.status === true;
365
+ await store.init();
366
+ await store.setStatus(statusListUrl(config, purpose), purpose, index, value);
367
+ emit(config, "info", VcLogEvent.StatusChanged, {
368
+ statusPurpose: purpose,
369
+ value,
370
+ });
371
+ return json({ status: "ok", statusListIndex: index, value });
372
+ }
373
+
374
+ /** GET a signed status list credential for a purpose. */
375
+ async function handleStatusList(
376
+ env: VcEnv,
377
+ config: ResolvedVcConfig,
378
+ purposeSegment: string,
379
+ ): Promise<Response> {
380
+ if (!config.statusEnabled) {
381
+ return problem("status_disabled", "status lists are not enabled", 404);
382
+ }
383
+ const store = getStore(env);
384
+ if (store === undefined) {
385
+ return problem("server_error", "missing D1 binding `VC_STATUS_DB`", 500);
386
+ }
387
+ const purpose = purposeSegment as StatusPurpose;
388
+ const listUrl = statusListUrl(config, purpose);
389
+ await store.init();
390
+ const indices = await store.setIndices(listUrl, purpose);
391
+
392
+ const encodedList = await buildEncodedList(indices, config.statusListLength);
393
+ const unsigned = buildStatusListCredential({
394
+ id: listUrl,
395
+ statusPurpose: purpose,
396
+ encodedList,
397
+ issuer: config.did,
398
+ });
399
+
400
+ let signer: Signer;
401
+ try {
402
+ signer = await loadSigner(env);
403
+ } catch (error) {
404
+ return problem("server_error", (error as Error).message, 500);
405
+ }
406
+ const signed = await addProof(unsigned, signer, {
407
+ verificationMethod: config.verificationMethod,
408
+ });
409
+ return json(signed);
410
+ }
411
+
412
+ /**
413
+ * Build the `@dwk/vc` handler from configuration.
414
+ *
415
+ * The returned handler routes `POST {issueEndpoint}`, `POST {verifyEndpoint}`,
416
+ * `POST {statusEndpoint}`, and `GET {statusListEndpoint}/<purpose>`. Issuance and
417
+ * status changes are gated by the configured {@link AuthorizeOperation} hook (or
418
+ * the composing Worker's front door) and require the `VC_SIGNING_KEY` secret;
419
+ * status requires the `VC_STATUS_DB` D1 binding. Unmatched routes get `404`;
420
+ * wrong methods get `405`. The issuer credential / `issuerId` is recorded by host
421
+ * only; subjects, claims, keys, and proof values are never logged.
422
+ */
423
+ export function createVc(config: VcConfig): VcHandler {
424
+ const resolved = resolveConfig(config);
425
+ // Default verification resolver: did:web over the global fetch.
426
+ const resolver: VerificationMethodResolver =
427
+ resolved.resolveDid ?? createDidWebResolver();
428
+
429
+ return async (request, env, _ctx) => {
430
+ const url = new URL(request.url);
431
+ const path = url.pathname;
432
+ const method = request.method;
433
+
434
+ if (path === resolved.issuePath) {
435
+ if (method !== "POST") return methodNotAllowed(resolved, "POST");
436
+ return handleIssue(request, env, resolved);
437
+ }
438
+ if (path === resolved.verifyPath) {
439
+ if (method !== "POST") return methodNotAllowed(resolved, "POST");
440
+ return handleVerify(request, env, resolved, resolver);
441
+ }
442
+ if (path === resolved.statusPath) {
443
+ if (method !== "POST") return methodNotAllowed(resolved, "POST");
444
+ return handleStatusUpdate(request, env, resolved);
445
+ }
446
+ if (path.startsWith(`${resolved.statusListPath}/`)) {
447
+ if (method !== "GET" && method !== "HEAD") {
448
+ return methodNotAllowed(resolved, "GET");
449
+ }
450
+ const segment = path.slice(resolved.statusListPath.length + 1);
451
+ if (segment.length === 0 || segment.includes("/")) {
452
+ return problem("not_found", "no such status list", 404);
453
+ }
454
+ const response = await handleStatusList(env, resolved, segment);
455
+ return method === "HEAD"
456
+ ? new Response(null, {
457
+ status: response.status,
458
+ headers: response.headers,
459
+ })
460
+ : response;
461
+ }
462
+
463
+ emit(resolved, "warn", VcLogEvent.Rejected, {
464
+ reason: "not_found",
465
+ host: hostFromUrl(request.url),
466
+ });
467
+ return problem("not_found", "no such endpoint", 404);
468
+ };
469
+ }
470
+
471
+ function methodNotAllowed(config: ResolvedVcConfig, allow: string): Response {
472
+ emit(config, "warn", VcLogEvent.Rejected, { reason: "method_not_allowed" });
473
+ return new Response(JSON.stringify({ error: "method_not_allowed" }), {
474
+ status: 405,
475
+ headers: { ...JSON_HEADERS, allow },
476
+ });
477
+ }
package/src/index.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `@dwk/vc` — `did:web` identity plus Verifiable Credential issuance and
3
+ * verification.
4
+ *
5
+ * Endpoint package (+ lib): exports a factory returning a `fetch`-compatible
6
+ * handler, mountable under any prefix so it composes with the other `@dwk`
7
+ * packages in one Worker. Decentralized identity is rooted at the user's own
8
+ * domain — the same WebID / IndieAuth identity root, expressed as a DID — and
9
+ * credential proofs reuse the project's asymmetric, alg-allow-listed crypto
10
+ * posture (`@dwk/dpop`, `@dwk/http-signatures`).
11
+ *
12
+ * The split: the **`did:web` DID document is a static file** ({@link buildDidDocument}
13
+ * produces `/.well-known/did.json` — no Worker needed to resolve it), while the
14
+ * Worker covers the dynamic parts — VC **issuance** (signing with the domain's
15
+ * key), VC **verification**, and **status / revocation** whose bit-flips need a
16
+ * strongly-consistent store. Proofs use the **JCS** Data Integrity cryptosuites
17
+ * (`eddsa-jcs-2022`, `ecdsa-jcs-2019`), so the package canonicalizes with
18
+ * {@link canonicalize} (RFC 8785) rather than shipping a JSON-LD/RDF
19
+ * canonicalizer — staying within the Worker script-size budget.
20
+ *
21
+ * Credential construction and proof logic take **plain-data inputs** and
22
+ * unit-test without a Workers runtime; only the issuance/status endpoints and
23
+ * their D1-backed status store touch the runtime. Signing keys and issuer
24
+ * identity arrive via config and a secret binding — never read from the global
25
+ * environment (composition contract).
26
+ *
27
+ * @see spec/packages/vc.md
28
+ * @packageDocumentation
29
+ */
30
+
31
+ export { createVc } from "./handler";
32
+ export type { VcEnv, VcHandler } from "./handler";
33
+
34
+ export {
35
+ resolveConfig,
36
+ type VcConfig,
37
+ type ResolvedVcConfig,
38
+ type StatusConfig,
39
+ type VcOperation,
40
+ type AuthorizeOperation,
41
+ type DidResolver,
42
+ } from "./config";
43
+
44
+ // Data Integrity (cryptosuites + proof pipeline)
45
+ export {
46
+ importSigner,
47
+ addProof,
48
+ verifyProof,
49
+ isSupportedCryptosuite,
50
+ type Cryptosuite,
51
+ type Signer,
52
+ type JsonObject,
53
+ type SecuredDocument,
54
+ type AddProofOptions,
55
+ type VerifyProofOptions,
56
+ type VerifyProofResult,
57
+ type VerificationMethod,
58
+ type VerificationMethodResolver,
59
+ } from "./data-integrity";
60
+
61
+ // Credential data model (VCDM 2.0)
62
+ export {
63
+ buildCredential,
64
+ validateCredential,
65
+ checkValidityPeriod,
66
+ issuerId,
67
+ VC_CONTEXT_V2,
68
+ VERIFIABLE_CREDENTIAL_TYPE,
69
+ type UnsignedCredential,
70
+ type BuildCredentialOptions,
71
+ type Issuer,
72
+ } from "./credential";
73
+
74
+ // did:web
75
+ export {
76
+ didWebToUrl,
77
+ urlToDidWeb,
78
+ buildDidDocument,
79
+ findVerificationMethod,
80
+ createDidWebResolver,
81
+ DID_CONTEXT_V1,
82
+ MULTIKEY_CONTEXT_V1,
83
+ JWK_CONTEXT_V1,
84
+ type BuildDidDocumentOptions,
85
+ type VerificationMethodInput,
86
+ type VerificationRelationships,
87
+ type DidWebResolverOptions,
88
+ type FetchLike,
89
+ } from "./did-web";
90
+
91
+ // Bitstring Status List
92
+ export {
93
+ buildStatusListCredential,
94
+ buildStatusEntry,
95
+ buildEncodedList,
96
+ encodeBitstring,
97
+ decodeBitstring,
98
+ getBit,
99
+ setBit,
100
+ findStatusEntry,
101
+ statusEntryIndex,
102
+ createVcStatusStore,
103
+ DEFAULT_STATUS_LIST_LENGTH,
104
+ BITSTRING_STATUS_LIST_CREDENTIAL_TYPE,
105
+ BITSTRING_STATUS_LIST_ENTRY_TYPE,
106
+ BITSTRING_STATUS_LIST_SUBJECT_TYPE,
107
+ type StatusPurpose,
108
+ type StatusPurposeValue,
109
+ type StatusListCredentialOptions,
110
+ type VcStatusStore,
111
+ type VcStatusStoreEnv,
112
+ } from "./status-list";
113
+
114
+ // XSD dateTimeStamp validation for VC temporal fields
115
+ export { isValidXsdDateTimeStamp, toXsdDateTime } from "./datetime";
116
+
117
+ // JCS canonicalization and multibase/Multikey codecs
118
+ export { canonicalize, canonicalizeToBytes, type JcsValue } from "./jcs";
119
+ export {
120
+ base58btcEncode,
121
+ base58btcDecode,
122
+ base64urlEncode,
123
+ base64urlDecode,
124
+ encodeMultibaseBase58btc,
125
+ encodeMultibaseBase64url,
126
+ decodeMultibase,
127
+ encodeEd25519Multikey,
128
+ decodeMultikey,
129
+ type DecodedMultikey,
130
+ } from "./multibase";
131
+
132
+ export { VcLogEvent } from "./log";
133
+ export type { Logger, Metrics } from "@dwk/log";
package/src/jcs.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * JSON Canonicalization Scheme (RFC 8785).
3
+ *
4
+ * The `*-jcs-*` Data Integrity cryptosuites canonicalize a credential and its
5
+ * proof configuration with JCS — not RDF Dataset Canonicalization — so the proof
6
+ * pipeline needs no JSON-LD expansion or URDNA2015 implementation. That keeps
7
+ * the package within the Worker script-size budget (no `jsonld.js`/Comunica) and
8
+ * makes proof construction a pure, plain-data transform that unit-tests without a
9
+ * Workers runtime.
10
+ *
11
+ * JCS ordering and escaping line up with the ECMAScript primitives this uses:
12
+ * object members are sorted by the UTF-16 code units of their keys (the default
13
+ * `Array.prototype.sort` order), and string escaping matches `JSON.stringify`.
14
+ * Number serialization uses the ECMAScript `Number`-to-string algorithm
15
+ * (`String(n)`); credentials carry small integers and simple decimals, well
16
+ * within where that agrees with RFC 8785.
17
+ *
18
+ * @see https://www.rfc-editor.org/rfc/rfc8785
19
+ */
20
+
21
+ /** A JSON value accepted by {@link canonicalize}. */
22
+ export type JcsValue =
23
+ | null
24
+ | boolean
25
+ | number
26
+ | string
27
+ | JcsValue[]
28
+ | { [key: string]: JcsValue | undefined };
29
+
30
+ function serializeNumber(value: number): string {
31
+ if (!Number.isFinite(value)) {
32
+ throw new Error("@dwk/vc: cannot canonicalize a non-finite number");
33
+ }
34
+ // String(-0) === "0", matching JCS's requirement that -0 serialize as "0".
35
+ return String(value);
36
+ }
37
+
38
+ function serialize(value: JcsValue): string {
39
+ if (value === null) return "null";
40
+
41
+ const type = typeof value;
42
+ if (type === "boolean") return value ? "true" : "false";
43
+ if (type === "number") return serializeNumber(value as number);
44
+ if (type === "string") return JSON.stringify(value);
45
+
46
+ if (Array.isArray(value)) {
47
+ // Mirror JSON.stringify: an `undefined` element serializes as `null`.
48
+ const items = value.map((item) =>
49
+ item === undefined ? "null" : serialize(item),
50
+ );
51
+ return `[${items.join(",")}]`;
52
+ }
53
+
54
+ if (type === "object") {
55
+ const obj = value as { [key: string]: JcsValue | undefined };
56
+ // Drop members whose value is `undefined`, then sort by UTF-16 code unit.
57
+ const keys = Object.keys(obj)
58
+ .filter((key) => obj[key] !== undefined)
59
+ .sort();
60
+ const members = keys.map(
61
+ (key) => `${JSON.stringify(key)}:${serialize(obj[key]!)}`,
62
+ );
63
+ return `{${members.join(",")}}`;
64
+ }
65
+
66
+ throw new Error(`@dwk/vc: cannot canonicalize value of type ${type}`);
67
+ }
68
+
69
+ /**
70
+ * Canonicalize a JSON value to its RFC 8785 string form. Throws on values JSON
71
+ * cannot represent (non-finite numbers, `undefined` at the top level, functions).
72
+ */
73
+ export function canonicalize(value: JcsValue): string {
74
+ if (value === undefined) {
75
+ throw new Error("@dwk/vc: cannot canonicalize `undefined`");
76
+ }
77
+ return serialize(value);
78
+ }
79
+
80
+ /** Canonicalize a value and return its UTF-8 bytes (the hash input). */
81
+ export function canonicalizeToBytes(value: JcsValue): Uint8Array {
82
+ return new TextEncoder().encode(canonicalize(value));
83
+ }