@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/log.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/vc` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* Credential issuance and status changes are the security-relevant moments here:
|
|
5
|
+
* an unauthorized issue attempt, a verification that fails its proof, or a
|
|
6
|
+
* revocation are exactly what an operator wants a signal for. Logging and metrics
|
|
7
|
+
* are opt-in via an injected {@link Logger} and {@link Metrics} (see `@dwk/log`)
|
|
8
|
+
* and **share this one vocabulary**: the same dotted event name is passed to the
|
|
9
|
+
* logger and the metrics sink so a log line and its counter line up.
|
|
10
|
+
*
|
|
11
|
+
* Fields follow the redaction policy: never the credential subject, claims, the
|
|
12
|
+
* signing key, or a `proofValue` — only stable reason codes, a credential `type`
|
|
13
|
+
* count or list, the issuer host, and the status purpose/outcome.
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Stable event names emitted by `@dwk/vc`. */
|
|
19
|
+
export const VcLogEvent = {
|
|
20
|
+
/** A credential was issued (signed). Fields: `cryptosuite`. */
|
|
21
|
+
Issued: "vc.issued",
|
|
22
|
+
/** A credential was verified. Fields: `verified` (boolean). */
|
|
23
|
+
Verified: "vc.verified",
|
|
24
|
+
/** A credential's status was changed. Fields: `statusPurpose`, `value`. */
|
|
25
|
+
StatusChanged: "vc.status.changed",
|
|
26
|
+
/**
|
|
27
|
+
* A request was rejected before completing. Field: `reason`
|
|
28
|
+
* (`method_not_allowed`, `unauthorized`, `malformed_request`,
|
|
29
|
+
* `unsupported`, `status_disabled`, `not_found`).
|
|
30
|
+
*/
|
|
31
|
+
Rejected: "vc.rejected",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/** Union of the event-name string literals in {@link VcLogEvent}. */
|
|
35
|
+
export type VcLogEvent = (typeof VcLogEvent)[keyof typeof VcLogEvent];
|
package/src/multibase.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multibase / Multikey encoding primitives used by Verifiable Credential proofs
|
|
3
|
+
* and DID documents.
|
|
4
|
+
*
|
|
5
|
+
* Data Integrity proof values are **multibase base58-btc** strings (a leading
|
|
6
|
+
* `z`), and a `Multikey` verification method publishes its public key as a
|
|
7
|
+
* multibase base58-btc string over a **multicodec**-prefixed raw key. Status
|
|
8
|
+
* lists are **multibase base64url** (a leading `u`). These helpers are pure
|
|
9
|
+
* byte/string transforms with no Web Crypto or runtime dependency, so they
|
|
10
|
+
* unit-test in isolation.
|
|
11
|
+
*
|
|
12
|
+
* @see https://www.w3.org/TR/controller-document/#multikey
|
|
13
|
+
* @see https://github.com/multiformats/multibase
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BASE58_ALPHABET =
|
|
17
|
+
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
18
|
+
|
|
19
|
+
// Reverse lookup: code point → value, built once.
|
|
20
|
+
const BASE58_LOOKUP: Readonly<Record<string, number>> = (() => {
|
|
21
|
+
const map: Record<string, number> = {};
|
|
22
|
+
for (let i = 0; i < BASE58_ALPHABET.length; i++) {
|
|
23
|
+
map[BASE58_ALPHABET[i]!] = i;
|
|
24
|
+
}
|
|
25
|
+
return map;
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
/** Multibase prefix characters this module understands. */
|
|
29
|
+
export const MULTIBASE_BASE58BTC = "z";
|
|
30
|
+
export const MULTIBASE_BASE64URL = "u";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Multicodec varint prefixes for the key types published as a `Multikey`.
|
|
34
|
+
* Encoded as unsigned LEB128 varints (the on-disk multicodec form).
|
|
35
|
+
*/
|
|
36
|
+
export const MULTICODEC_ED25519_PUB = Uint8Array.of(0xed, 0x01);
|
|
37
|
+
|
|
38
|
+
/** Encode raw bytes as base58-btc (no multibase prefix). */
|
|
39
|
+
export function base58btcEncode(bytes: Uint8Array): string {
|
|
40
|
+
if (bytes.length === 0) return "";
|
|
41
|
+
|
|
42
|
+
// Count and preserve leading zero bytes as leading '1's.
|
|
43
|
+
let zeros = 0;
|
|
44
|
+
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
|
45
|
+
|
|
46
|
+
// Convert the base-256 big-endian integer to base-58 via repeated division.
|
|
47
|
+
const digits: number[] = [];
|
|
48
|
+
for (let i = zeros; i < bytes.length; i++) {
|
|
49
|
+
let carry = bytes[i]!;
|
|
50
|
+
for (let j = 0; j < digits.length; j++) {
|
|
51
|
+
carry += digits[j]! << 8;
|
|
52
|
+
digits[j] = carry % 58;
|
|
53
|
+
carry = (carry / 58) | 0;
|
|
54
|
+
}
|
|
55
|
+
while (carry > 0) {
|
|
56
|
+
digits.push(carry % 58);
|
|
57
|
+
carry = (carry / 58) | 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let out = "1".repeat(zeros);
|
|
62
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
63
|
+
out += BASE58_ALPHABET[digits[i]!];
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Decode a base58-btc string (no multibase prefix) to raw bytes. */
|
|
69
|
+
export function base58btcDecode(input: string): Uint8Array {
|
|
70
|
+
if (input.length === 0) return new Uint8Array(0);
|
|
71
|
+
|
|
72
|
+
let zeros = 0;
|
|
73
|
+
while (zeros < input.length && input[zeros] === "1") zeros++;
|
|
74
|
+
|
|
75
|
+
const bytes: number[] = [];
|
|
76
|
+
for (let i = zeros; i < input.length; i++) {
|
|
77
|
+
const value = BASE58_LOOKUP[input[i]!];
|
|
78
|
+
if (value === undefined) {
|
|
79
|
+
throw new Error(`@dwk/vc: invalid base58 character "${input[i]}"`);
|
|
80
|
+
}
|
|
81
|
+
let carry = value;
|
|
82
|
+
for (let j = 0; j < bytes.length; j++) {
|
|
83
|
+
carry += bytes[j]! * 58;
|
|
84
|
+
bytes[j] = carry & 0xff;
|
|
85
|
+
carry >>= 8;
|
|
86
|
+
}
|
|
87
|
+
while (carry > 0) {
|
|
88
|
+
bytes.push(carry & 0xff);
|
|
89
|
+
carry >>= 8;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const out = new Uint8Array(zeros + bytes.length);
|
|
94
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
95
|
+
out[zeros + bytes.length - 1 - i] = bytes[i]!;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Base64url-encode bytes without padding. */
|
|
101
|
+
export function base64urlEncode(bytes: Uint8Array): string {
|
|
102
|
+
let binary = "";
|
|
103
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
104
|
+
return btoa(binary)
|
|
105
|
+
.replace(/\+/g, "-")
|
|
106
|
+
.replace(/\//g, "_")
|
|
107
|
+
.replace(/=+$/, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Decode a base64url string (padded or not) to bytes. */
|
|
111
|
+
export function base64urlDecode(input: string): Uint8Array {
|
|
112
|
+
const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
113
|
+
const padded =
|
|
114
|
+
b64.length % 4 === 0 ? b64 : b64 + "=".repeat(4 - (b64.length % 4));
|
|
115
|
+
const binary = atob(padded);
|
|
116
|
+
const bytes = new Uint8Array(binary.length);
|
|
117
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
118
|
+
return bytes;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Encode bytes as a multibase base58-btc string (`z…`). */
|
|
122
|
+
export function encodeMultibaseBase58btc(bytes: Uint8Array): string {
|
|
123
|
+
return MULTIBASE_BASE58BTC + base58btcEncode(bytes);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Encode bytes as a multibase base64url string (`u…`). */
|
|
127
|
+
export function encodeMultibaseBase64url(bytes: Uint8Array): string {
|
|
128
|
+
return MULTIBASE_BASE64URL + base64urlEncode(bytes);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decode a multibase string, dispatching on its prefix. Supports base58-btc
|
|
133
|
+
* (`z`) and base64url (`u`) — the two encodings the VC suite emits.
|
|
134
|
+
*/
|
|
135
|
+
export function decodeMultibase(input: string): Uint8Array {
|
|
136
|
+
if (input.length === 0) {
|
|
137
|
+
throw new Error("@dwk/vc: empty multibase value");
|
|
138
|
+
}
|
|
139
|
+
const prefix = input[0];
|
|
140
|
+
const rest = input.slice(1);
|
|
141
|
+
if (prefix === MULTIBASE_BASE58BTC) return base58btcDecode(rest);
|
|
142
|
+
if (prefix === MULTIBASE_BASE64URL) return base64urlDecode(rest);
|
|
143
|
+
throw new Error(`@dwk/vc: unsupported multibase prefix "${prefix ?? ""}"`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Encode a raw Ed25519 public key (32 bytes) as a `Multikey`
|
|
148
|
+
* `publicKeyMultibase` value: multibase base58-btc over the
|
|
149
|
+
* multicodec-prefixed key.
|
|
150
|
+
*/
|
|
151
|
+
export function encodeEd25519Multikey(rawPublicKey: Uint8Array): string {
|
|
152
|
+
if (rawPublicKey.length !== 32) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`@dwk/vc: Ed25519 public key must be 32 bytes, got ${rawPublicKey.length}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const prefixed = new Uint8Array(
|
|
158
|
+
MULTICODEC_ED25519_PUB.length + rawPublicKey.length,
|
|
159
|
+
);
|
|
160
|
+
prefixed.set(MULTICODEC_ED25519_PUB, 0);
|
|
161
|
+
prefixed.set(rawPublicKey, MULTICODEC_ED25519_PUB.length);
|
|
162
|
+
return encodeMultibaseBase58btc(prefixed);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The raw key bytes and key type recovered from a `publicKeyMultibase`. */
|
|
166
|
+
export interface DecodedMultikey {
|
|
167
|
+
readonly keyType: "Ed25519";
|
|
168
|
+
readonly rawPublicKey: Uint8Array;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Decode a `Multikey` `publicKeyMultibase` value into its raw key bytes,
|
|
173
|
+
* validating the multicodec prefix. Only Ed25519 is supported as a Multikey;
|
|
174
|
+
* other key types are published as `publicKeyJwk` instead.
|
|
175
|
+
*/
|
|
176
|
+
export function decodeMultikey(publicKeyMultibase: string): DecodedMultikey {
|
|
177
|
+
const bytes = decodeMultibase(publicKeyMultibase);
|
|
178
|
+
if (
|
|
179
|
+
bytes.length === MULTICODEC_ED25519_PUB.length + 32 &&
|
|
180
|
+
bytes[0] === MULTICODEC_ED25519_PUB[0] &&
|
|
181
|
+
bytes[1] === MULTICODEC_ED25519_PUB[1]
|
|
182
|
+
) {
|
|
183
|
+
return {
|
|
184
|
+
keyType: "Ed25519",
|
|
185
|
+
rawPublicKey: bytes.slice(MULTICODEC_ED25519_PUB.length),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
throw new Error("@dwk/vc: unsupported or malformed Multikey prefix");
|
|
189
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bitstring Status List support: the encoded-list codec, credential/entry
|
|
3
|
+
* builders, and a D1-backed authority for flipping a credential's status.
|
|
4
|
+
*
|
|
5
|
+
* A status list is a GZIP-compressed bitstring, multibase base64url-encoded,
|
|
6
|
+
* published inside a `BitstringStatusListCredential`. A credential opts in by
|
|
7
|
+
* carrying a `BitstringStatusListEntry` that points at a list and an index;
|
|
8
|
+
* verifiers read the bit at that index. Flipping a bit (revoking, suspending) is
|
|
9
|
+
* stateful and security-sensitive, so the authoritative bits live in **D1** — a
|
|
10
|
+
* strongly-consistent store — never KV (see `spec/non-functional-requirements.md`).
|
|
11
|
+
*
|
|
12
|
+
* The codec and builders are pure (GZIP via `CompressionStream`, available under
|
|
13
|
+
* both Node and workerd); only {@link createVcStatusStore} touches a binding.
|
|
14
|
+
*
|
|
15
|
+
* @see https://www.w3.org/TR/vc-bitstring-status-list/
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { toXsdDateTime } from "./datetime";
|
|
19
|
+
import type { JcsValue } from "./jcs";
|
|
20
|
+
import type { JsonObject } from "./data-integrity";
|
|
21
|
+
import { decodeMultibase, encodeMultibaseBase64url } from "./multibase";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The minimum bitstring length the spec mandates (131,072 bits / 16 KB), chosen
|
|
25
|
+
* so an individual entry's index does not leak which credential it refers to.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_STATUS_LIST_LENGTH = 131072;
|
|
28
|
+
|
|
29
|
+
export const BITSTRING_STATUS_LIST_CREDENTIAL_TYPE =
|
|
30
|
+
"BitstringStatusListCredential";
|
|
31
|
+
export const BITSTRING_STATUS_LIST_ENTRY_TYPE = "BitstringStatusListEntry";
|
|
32
|
+
export const BITSTRING_STATUS_LIST_SUBJECT_TYPE = "BitstringStatusList";
|
|
33
|
+
|
|
34
|
+
/** A status purpose. `revocation` is permanent; `suspension` is reversible. */
|
|
35
|
+
export type StatusPurpose = "revocation" | "suspension" | (string & {});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A `statusPurpose` value as it appears on a credential or entry. The Bitstring
|
|
39
|
+
* Status List spec allows "one or more" purposes, so a single purpose or an
|
|
40
|
+
* array of them are both valid.
|
|
41
|
+
*/
|
|
42
|
+
export type StatusPurposeValue = StatusPurpose | readonly StatusPurpose[];
|
|
43
|
+
|
|
44
|
+
/** Emit a `statusPurpose` as plain data (a mutable array when one was given). */
|
|
45
|
+
function statusPurposeValue(purpose: StatusPurposeValue): JcsValue {
|
|
46
|
+
return Array.isArray(purpose) ? [...purpose] : (purpose as StatusPurpose);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether `purpose` (a `statusPurpose` field) covers `wanted`. */
|
|
50
|
+
function statusPurposeMatches(
|
|
51
|
+
purpose: JcsValue | undefined,
|
|
52
|
+
wanted: StatusPurpose,
|
|
53
|
+
): boolean {
|
|
54
|
+
return Array.isArray(purpose) ? purpose.includes(wanted) : purpose === wanted;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Get the bit at `index` in a most-significant-bit-first bitstring. */
|
|
58
|
+
export function getBit(bits: Uint8Array, index: number): boolean {
|
|
59
|
+
if (index < 0) return false;
|
|
60
|
+
const byteIndex = index >> 3;
|
|
61
|
+
if (byteIndex >= bits.length) return false;
|
|
62
|
+
const bit = 7 - (index & 7);
|
|
63
|
+
return ((bits[byteIndex]! >> bit) & 1) === 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Set the bit at `index` in a most-significant-bit-first bitstring. */
|
|
67
|
+
export function setBit(bits: Uint8Array, index: number, value: boolean): void {
|
|
68
|
+
// A negative index shifts to a negative byteIndex, which would slip past the
|
|
69
|
+
// upper-bound check, so guard both ends explicitly.
|
|
70
|
+
const byteIndex = index >> 3;
|
|
71
|
+
if (index < 0 || byteIndex >= bits.length) {
|
|
72
|
+
throw new Error(`@dwk/vc: status index ${index} is out of range`);
|
|
73
|
+
}
|
|
74
|
+
const bit = 7 - (index & 7);
|
|
75
|
+
if (value) {
|
|
76
|
+
bits[byteIndex]! |= 1 << bit;
|
|
77
|
+
} else {
|
|
78
|
+
bits[byteIndex]! &= ~(1 << bit);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function gzip(input: Uint8Array): Promise<Uint8Array> {
|
|
83
|
+
const stream = new Response(input).body!.pipeThrough(
|
|
84
|
+
new CompressionStream("gzip"),
|
|
85
|
+
);
|
|
86
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function gunzip(input: Uint8Array): Promise<Uint8Array> {
|
|
90
|
+
const stream = new Response(input).body!.pipeThrough(
|
|
91
|
+
new DecompressionStream("gzip"),
|
|
92
|
+
);
|
|
93
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** GZIP-compress a bitstring and multibase base64url-encode it (`encodedList`). */
|
|
97
|
+
export async function encodeBitstring(bits: Uint8Array): Promise<string> {
|
|
98
|
+
return encodeMultibaseBase64url(await gzip(bits));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Decode an `encodedList` (multibase base64url + GZIP) back to its bitstring. */
|
|
102
|
+
export async function decodeBitstring(
|
|
103
|
+
encodedList: string,
|
|
104
|
+
): Promise<Uint8Array> {
|
|
105
|
+
return gunzip(decodeMultibase(encodedList));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build an `encodedList` of `length` bits with the given indices set to 1.
|
|
110
|
+
* `length` is the bit count (rounded up to whole bytes).
|
|
111
|
+
*/
|
|
112
|
+
export async function buildEncodedList(
|
|
113
|
+
setIndices: Iterable<number>,
|
|
114
|
+
length: number = DEFAULT_STATUS_LIST_LENGTH,
|
|
115
|
+
): Promise<string> {
|
|
116
|
+
const bits = new Uint8Array(Math.ceil(length / 8));
|
|
117
|
+
for (const index of setIndices) setBit(bits, index, true);
|
|
118
|
+
return encodeBitstring(bits);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Options for {@link buildStatusListCredential}. */
|
|
122
|
+
export interface StatusListCredentialOptions {
|
|
123
|
+
/** The status list credential's id (a URL). */
|
|
124
|
+
readonly id: string;
|
|
125
|
+
/** The status purpose(s) this list tracks (one or more). */
|
|
126
|
+
readonly statusPurpose: StatusPurposeValue;
|
|
127
|
+
/** The pre-built `encodedList` value. */
|
|
128
|
+
readonly encodedList: string;
|
|
129
|
+
/** The issuer (string or object with id). */
|
|
130
|
+
readonly issuer: string | (JsonObject & { id: string });
|
|
131
|
+
/** `validFrom` (XSD dateTime). Defaults to now when omitted. */
|
|
132
|
+
readonly validFrom?: Date | string;
|
|
133
|
+
/**
|
|
134
|
+
* `validUntil` (XSD dateTime). Bounds how long a verifier may treat the
|
|
135
|
+
* cached status as authoritative.
|
|
136
|
+
*/
|
|
137
|
+
readonly validUntil?: Date | string;
|
|
138
|
+
/**
|
|
139
|
+
* Cache time-to-live in milliseconds, advertised on both the credential and
|
|
140
|
+
* its status-list subject so verifiers can bound status caching.
|
|
141
|
+
*/
|
|
142
|
+
readonly ttl?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Assemble an **unsigned** `BitstringStatusListCredential`. The caller signs it
|
|
147
|
+
* with {@link ./data-integrity.addProof} before publishing.
|
|
148
|
+
*
|
|
149
|
+
* Per the Bitstring Status List spec, `validFrom`/`validUntil`/`ttl` bound how
|
|
150
|
+
* long the published status may be cached; `validUntil` and `ttl` are emitted on
|
|
151
|
+
* the credential (and `ttl` also on the subject) when supplied.
|
|
152
|
+
*/
|
|
153
|
+
export function buildStatusListCredential(
|
|
154
|
+
options: StatusListCredentialOptions,
|
|
155
|
+
): JsonObject {
|
|
156
|
+
const subject: JsonObject = {
|
|
157
|
+
id: `${options.id}#list`,
|
|
158
|
+
type: BITSTRING_STATUS_LIST_SUBJECT_TYPE,
|
|
159
|
+
statusPurpose: statusPurposeValue(options.statusPurpose),
|
|
160
|
+
encodedList: options.encodedList,
|
|
161
|
+
};
|
|
162
|
+
if (options.ttl !== undefined) subject.ttl = options.ttl;
|
|
163
|
+
const credential: JsonObject = {
|
|
164
|
+
"@context": ["https://www.w3.org/ns/credentials/v2"],
|
|
165
|
+
id: options.id,
|
|
166
|
+
type: ["VerifiableCredential", BITSTRING_STATUS_LIST_CREDENTIAL_TYPE],
|
|
167
|
+
issuer: options.issuer,
|
|
168
|
+
validFrom: toXsdDateTime(options.validFrom ?? new Date()),
|
|
169
|
+
credentialSubject: subject,
|
|
170
|
+
};
|
|
171
|
+
if (options.validUntil !== undefined) {
|
|
172
|
+
credential.validUntil = toXsdDateTime(options.validUntil);
|
|
173
|
+
}
|
|
174
|
+
if (options.ttl !== undefined) credential.ttl = options.ttl;
|
|
175
|
+
return credential;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Build a `credentialStatus` entry referencing a list index. */
|
|
179
|
+
export function buildStatusEntry(options: {
|
|
180
|
+
readonly statusListCredential: string;
|
|
181
|
+
readonly statusListIndex: number;
|
|
182
|
+
readonly statusPurpose: StatusPurposeValue;
|
|
183
|
+
readonly id?: string;
|
|
184
|
+
}): JsonObject {
|
|
185
|
+
return {
|
|
186
|
+
id:
|
|
187
|
+
options.id ??
|
|
188
|
+
`${options.statusListCredential}#${options.statusListIndex}`,
|
|
189
|
+
type: BITSTRING_STATUS_LIST_ENTRY_TYPE,
|
|
190
|
+
statusPurpose: statusPurposeValue(options.statusPurpose),
|
|
191
|
+
statusListIndex: String(options.statusListIndex),
|
|
192
|
+
statusListCredential: options.statusListCredential,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Read a `statusListIndex` from a credential's `credentialStatus` entry. */
|
|
197
|
+
export function statusEntryIndex(entry: JsonObject): number | undefined {
|
|
198
|
+
const raw = entry.statusListIndex;
|
|
199
|
+
const value = typeof raw === "string" ? Number(raw) : raw;
|
|
200
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
201
|
+
? value
|
|
202
|
+
: undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Locate a `credentialStatus` entry for a purpose on a credential. */
|
|
206
|
+
export function findStatusEntry(
|
|
207
|
+
credential: JsonObject,
|
|
208
|
+
statusPurpose: StatusPurpose,
|
|
209
|
+
): JsonObject | undefined {
|
|
210
|
+
const status = credential.credentialStatus;
|
|
211
|
+
const entries: JcsValue[] = Array.isArray(status)
|
|
212
|
+
? status
|
|
213
|
+
: status === undefined
|
|
214
|
+
? []
|
|
215
|
+
: [status];
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (
|
|
218
|
+
entry !== null &&
|
|
219
|
+
typeof entry === "object" &&
|
|
220
|
+
!Array.isArray(entry) &&
|
|
221
|
+
statusPurposeMatches((entry as JsonObject).statusPurpose, statusPurpose)
|
|
222
|
+
) {
|
|
223
|
+
return entry as JsonObject;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Cloudflare bindings required by the D1-backed status store. */
|
|
230
|
+
export interface VcStatusStoreEnv {
|
|
231
|
+
/** D1 database holding per-list status bits and index allocations. */
|
|
232
|
+
readonly VC_STATUS_DB: D1Database;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Authoritative store for credential status bits, backed by D1. */
|
|
236
|
+
export interface VcStatusStore {
|
|
237
|
+
/** Create the schema if absent. Idempotent. */
|
|
238
|
+
init(): Promise<void>;
|
|
239
|
+
/** Allocate and return the next unused index for a list + purpose. */
|
|
240
|
+
allocateIndex(listId: string, statusPurpose: StatusPurpose): Promise<number>;
|
|
241
|
+
/** Set the status bit at an index (e.g. revoke). */
|
|
242
|
+
setStatus(
|
|
243
|
+
listId: string,
|
|
244
|
+
statusPurpose: StatusPurpose,
|
|
245
|
+
index: number,
|
|
246
|
+
value: boolean,
|
|
247
|
+
): Promise<void>;
|
|
248
|
+
/** Read the status bit at an index (defaults to `false`/unset). */
|
|
249
|
+
getStatus(
|
|
250
|
+
listId: string,
|
|
251
|
+
statusPurpose: StatusPurpose,
|
|
252
|
+
index: number,
|
|
253
|
+
): Promise<boolean>;
|
|
254
|
+
/** The set (value-1) indices for a list + purpose, ascending. */
|
|
255
|
+
setIndices(listId: string, statusPurpose: StatusPurpose): Promise<number[]>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const SCHEMA = [
|
|
259
|
+
`CREATE TABLE IF NOT EXISTS status_entries (
|
|
260
|
+
list_id TEXT NOT NULL,
|
|
261
|
+
status_purpose TEXT NOT NULL,
|
|
262
|
+
idx INTEGER NOT NULL,
|
|
263
|
+
value INTEGER NOT NULL DEFAULT 0,
|
|
264
|
+
PRIMARY KEY (list_id, status_purpose, idx)
|
|
265
|
+
)`,
|
|
266
|
+
`CREATE TABLE IF NOT EXISTS status_allocations (
|
|
267
|
+
list_id TEXT NOT NULL,
|
|
268
|
+
status_purpose TEXT NOT NULL,
|
|
269
|
+
next_index INTEGER NOT NULL,
|
|
270
|
+
PRIMARY KEY (list_id, status_purpose)
|
|
271
|
+
)`,
|
|
272
|
+
] as const;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Create the D1-backed {@link VcStatusStore}. Fails loudly if the required
|
|
276
|
+
* `VC_STATUS_DB` binding is missing — no silent degradation (composition
|
|
277
|
+
* contract).
|
|
278
|
+
*/
|
|
279
|
+
export function createVcStatusStore(env: VcStatusStoreEnv): VcStatusStore {
|
|
280
|
+
if (!env.VC_STATUS_DB) {
|
|
281
|
+
throw new Error("@dwk/vc: missing required D1 binding `VC_STATUS_DB`");
|
|
282
|
+
}
|
|
283
|
+
const db = env.VC_STATUS_DB;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
async init() {
|
|
287
|
+
for (const ddl of SCHEMA) await db.prepare(ddl).run();
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async allocateIndex(listId, statusPurpose) {
|
|
291
|
+
// Atomically bump the per-list counter and read it back, so concurrent
|
|
292
|
+
// allocations never hand out the same index.
|
|
293
|
+
const row = await db
|
|
294
|
+
.prepare(
|
|
295
|
+
`INSERT INTO status_allocations (list_id, status_purpose, next_index)
|
|
296
|
+
VALUES (?, ?, 1)
|
|
297
|
+
ON CONFLICT (list_id, status_purpose)
|
|
298
|
+
DO UPDATE SET next_index = next_index + 1
|
|
299
|
+
RETURNING next_index`,
|
|
300
|
+
)
|
|
301
|
+
.bind(listId, statusPurpose)
|
|
302
|
+
.first<{ next_index: number }>();
|
|
303
|
+
// next_index now points past the allocated slot; the slot is one below.
|
|
304
|
+
return (row?.next_index ?? 1) - 1;
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async setStatus(listId, statusPurpose, index, value) {
|
|
308
|
+
// Only set (value-1) bits are stored; clearing a bit deletes the row so the
|
|
309
|
+
// table stays sparse (one row per set bit) rather than accumulating
|
|
310
|
+
// value-0 tombstones. `getStatus`/`setIndices` already treat a missing row
|
|
311
|
+
// as unset.
|
|
312
|
+
if (value) {
|
|
313
|
+
await db
|
|
314
|
+
.prepare(
|
|
315
|
+
`INSERT INTO status_entries (list_id, status_purpose, idx, value)
|
|
316
|
+
VALUES (?, ?, ?, 1)
|
|
317
|
+
ON CONFLICT (list_id, status_purpose, idx)
|
|
318
|
+
DO UPDATE SET value = 1`,
|
|
319
|
+
)
|
|
320
|
+
.bind(listId, statusPurpose, index)
|
|
321
|
+
.run();
|
|
322
|
+
} else {
|
|
323
|
+
await db
|
|
324
|
+
.prepare(
|
|
325
|
+
`DELETE FROM status_entries
|
|
326
|
+
WHERE list_id = ? AND status_purpose = ? AND idx = ?`,
|
|
327
|
+
)
|
|
328
|
+
.bind(listId, statusPurpose, index)
|
|
329
|
+
.run();
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
async getStatus(listId, statusPurpose, index) {
|
|
334
|
+
const row = await db
|
|
335
|
+
.prepare(
|
|
336
|
+
`SELECT value FROM status_entries
|
|
337
|
+
WHERE list_id = ? AND status_purpose = ? AND idx = ?`,
|
|
338
|
+
)
|
|
339
|
+
.bind(listId, statusPurpose, index)
|
|
340
|
+
.first<{ value: number }>();
|
|
341
|
+
return row?.value === 1;
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async setIndices(listId, statusPurpose) {
|
|
345
|
+
const result = await db
|
|
346
|
+
.prepare(
|
|
347
|
+
`SELECT idx FROM status_entries
|
|
348
|
+
WHERE list_id = ? AND status_purpose = ? AND value = 1
|
|
349
|
+
ORDER BY idx ASC`,
|
|
350
|
+
)
|
|
351
|
+
.bind(listId, statusPurpose)
|
|
352
|
+
.all<{ idx: number }>();
|
|
353
|
+
return (result.results ?? []).map((r) => r.idx);
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|