@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.
- package/LICENSE +15 -0
- package/README.md +143 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +62 -0
- package/dist/config.js.map +1 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.d.ts.map +1 -0
- package/dist/credential.js +139 -0
- package/dist/credential.js.map +1 -0
- package/dist/data-integrity.d.ts +102 -0
- package/dist/data-integrity.d.ts.map +1 -0
- package/dist/data-integrity.js +253 -0
- package/dist/data-integrity.js.map +1 -0
- package/dist/datetime.d.ts +26 -0
- package/dist/datetime.d.ts.map +1 -0
- package/dist/datetime.js +54 -0
- package/dist/datetime.js.map +1 -0
- package/dist/did-web.d.ts +93 -0
- package/dist/did-web.d.ts.map +1 -0
- package/dist/did-web.js +206 -0
- package/dist/did-web.js.map +1 -0
- package/dist/handler.d.ts +37 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +362 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/jcs.d.ts +31 -0
- package/dist/jcs.d.ts.map +1 -0
- package/dist/jcs.js +67 -0
- package/dist/jcs.js.map +1 -0
- package/dist/log.d.ts +34 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +32 -0
- package/dist/log.js.map +1 -0
- package/dist/multibase.d.ts +57 -0
- package/dist/multibase.d.ts.map +1 -0
- package/dist/multibase.js +165 -0
- package/dist/multibase.js.map +1 -0
- package/dist/status-list.d.ts +116 -0
- package/dist/status-list.d.ts.map +1 -0
- package/dist/status-list.js +241 -0
- package/dist/status-list.js.map +1 -0
- package/package.json +48 -0
- package/src/config.ts +158 -0
- package/src/credential.ts +188 -0
- package/src/data-integrity.ts +425 -0
- package/src/datetime.ts +57 -0
- package/src/did-web.ts +273 -0
- package/src/handler.ts +477 -0
- package/src/index.ts +133 -0
- package/src/jcs.ts +83 -0
- package/src/log.ts +35 -0
- package/src/multibase.ts +189 -0
- 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
|
+
}
|