sello 0.1.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 (47) hide show
  1. package/LICENSE +200 -0
  2. package/README.md +195 -0
  3. package/SPEC.md +738 -0
  4. package/docs/assets/sello-banner.png +0 -0
  5. package/docs/assets/sello-social-preview.png +0 -0
  6. package/docs/decisions.md +79 -0
  7. package/docs/paper/notarized-agents.md +523 -0
  8. package/docs/paper/notarized-agents.pdf +0 -0
  9. package/docs/paper/notarized-agents.tex +1387 -0
  10. package/docs/paper/refs.bib +245 -0
  11. package/docs/performance.md +24 -0
  12. package/docs/release-checklist.md +56 -0
  13. package/docs/sdk-build-plan.md +214 -0
  14. package/docs/sdk-quickstart.md +115 -0
  15. package/docs/sdk-security-audit.md +53 -0
  16. package/docs/security-review.md +54 -0
  17. package/examples/mcp-tool-server.ts +250 -0
  18. package/examples/quickstart-tool.ts +178 -0
  19. package/fixtures/vectors/.gitkeep +1 -0
  20. package/fixtures/vectors/sello-v0.1.json +101 -0
  21. package/package.json +52 -0
  22. package/src/cbor.ts +337 -0
  23. package/src/cli/bench.ts +390 -0
  24. package/src/cli/demo.ts +114 -0
  25. package/src/cli/sello.ts +514 -0
  26. package/src/cose/protected-header.ts +210 -0
  27. package/src/cose/sign1.ts +124 -0
  28. package/src/crypto/ed25519.ts +117 -0
  29. package/src/crypto/identifiers.ts +64 -0
  30. package/src/hpke/base.ts +349 -0
  31. package/src/hpke/receipt.ts +79 -0
  32. package/src/index.ts +15 -0
  33. package/src/log/canonical-url.ts +168 -0
  34. package/src/log/mock-log.ts +170 -0
  35. package/src/log/rekor.ts +147 -0
  36. package/src/log/types.ts +27 -0
  37. package/src/mcp/middleware.ts +198 -0
  38. package/src/owner/verify.ts +276 -0
  39. package/src/receipt/body.ts +210 -0
  40. package/src/registry/json-registry.ts +233 -0
  41. package/src/sdk/index.ts +22 -0
  42. package/src/sdk/keys.ts +191 -0
  43. package/src/sdk/logs.ts +200 -0
  44. package/src/sdk/publisher.ts +145 -0
  45. package/src/sdk/service.ts +562 -0
  46. package/src/service/create-receipt.ts +178 -0
  47. package/src/token/jws-profile.ts +174 -0
@@ -0,0 +1,276 @@
1
+ import { decodeProtectedHeader } from "../cose/protected-header.ts";
2
+ import { decodeReceiptEnvelope, verifyReceiptEnvelope } from "../cose/sign1.ts";
3
+ import { deriveTokenIdentifiers, toHex } from "../crypto/identifiers.ts";
4
+ import { openReceiptBody } from "../hpke/receipt.ts";
5
+ import {
6
+ type CanonicalLogUrl,
7
+ logUrlsEqual,
8
+ } from "../log/canonical-url.ts";
9
+ import {
10
+ type LogCompleteness,
11
+ type TransparencyLogEntry,
12
+ type VerificationLog,
13
+ } from "../log/types.ts";
14
+ import { decodeReceiptBody, type ReceiptBody } from "../receipt/body.ts";
15
+ import {
16
+ type JsonIdentityRegistry,
17
+ assertKeyNotRevoked,
18
+ resolveServiceKey,
19
+ } from "../registry/json-registry.ts";
20
+
21
+ export type VerificationRejectionCode =
22
+ | "log_url_mismatch"
23
+ | "untrusted_log"
24
+ | "inclusion_proof_failed"
25
+ | "token_ref_mismatch"
26
+ | "unknown_kid"
27
+ | "revoked_key"
28
+ | "cose_signature_failed"
29
+ | "hpke_open_failed"
30
+ | "invalid_receipt_body";
31
+
32
+ export type VerifyReceiptsInput = {
33
+ authorizationTokenBytes: Uint8Array;
34
+ trustedLogs: readonly VerificationLog[];
35
+ registry: JsonIdentityRegistry;
36
+ ownerPrivateKey: Uint8Array;
37
+ };
38
+
39
+ export type VerifiedReceipt = {
40
+ status: "valid" | "duplicate";
41
+ receipt: ReceiptBody;
42
+ serviceIdentifier: string;
43
+ kidHex: string;
44
+ tokenRefHex: string;
45
+ logUrl: CanonicalLogUrl;
46
+ logCompleteness: LogCompleteness;
47
+ integratedTime: string;
48
+ duplicateOf?: number;
49
+ sameSecondActivity: boolean;
50
+ };
51
+
52
+ export type RejectedReceipt = {
53
+ status: "rejected";
54
+ code: VerificationRejectionCode;
55
+ message: string;
56
+ logUrl?: CanonicalLogUrl;
57
+ integratedTime?: string;
58
+ };
59
+
60
+ export type VerifyReceiptsResult = {
61
+ receipts: VerifiedReceipt[];
62
+ rejected: RejectedReceipt[];
63
+ };
64
+
65
+ export function verifyReceipts(input: VerifyReceiptsInput): VerifyReceiptsResult {
66
+ const identifiers = deriveTokenIdentifiers(input.authorizationTokenBytes);
67
+ const trustedLogUrls = input.trustedLogs.map((log) => log.logUrl);
68
+ const receipts: VerifiedReceipt[] = [];
69
+ const rejected: RejectedReceipt[] = [];
70
+ const exactDedup = new Map<string, number>();
71
+ const sameSecond = new Map<string, number>();
72
+
73
+ for (const log of input.trustedLogs) {
74
+ const result = log.queryByTokenRef(identifiers.sello_token_ref);
75
+
76
+ for (const entry of result.entries) {
77
+ const verified = verifyOneEntry({
78
+ entry,
79
+ log,
80
+ logCompleteness: result.completeness,
81
+ trustedLogUrls,
82
+ tokenRef: identifiers.sello_token_ref,
83
+ registry: input.registry,
84
+ ownerPrivateKey: input.ownerPrivateKey,
85
+ });
86
+
87
+ if (verified.status === "rejected") {
88
+ rejected.push(verified);
89
+ continue;
90
+ }
91
+
92
+ const exactKey = buildExactDedupKey(verified);
93
+ const existingIndex = exactDedup.get(exactKey);
94
+
95
+ if (existingIndex !== undefined) {
96
+ receipts.push({
97
+ ...verified,
98
+ status: "duplicate",
99
+ duplicateOf: existingIndex,
100
+ });
101
+ continue;
102
+ }
103
+
104
+ const sameSecondKey = buildSameSecondKey(verified);
105
+ const sameSecondIndex = sameSecond.get(sameSecondKey);
106
+
107
+ if (sameSecondIndex !== undefined) {
108
+ receipts[sameSecondIndex] = {
109
+ ...receipts[sameSecondIndex],
110
+ sameSecondActivity: true,
111
+ };
112
+ verified.sameSecondActivity = true;
113
+ } else {
114
+ sameSecond.set(sameSecondKey, receipts.length);
115
+ }
116
+
117
+ exactDedup.set(exactKey, receipts.length);
118
+ receipts.push(verified);
119
+ }
120
+ }
121
+
122
+ return { receipts, rejected };
123
+ }
124
+
125
+ type VerifyOneEntryInput = {
126
+ entry: TransparencyLogEntry;
127
+ log: VerificationLog;
128
+ logCompleteness: LogCompleteness;
129
+ trustedLogUrls: readonly CanonicalLogUrl[];
130
+ tokenRef: Uint8Array;
131
+ registry: JsonIdentityRegistry;
132
+ ownerPrivateKey: Uint8Array;
133
+ };
134
+
135
+ function verifyOneEntry(input: VerifyOneEntryInput): VerifiedReceipt | RejectedReceipt {
136
+ let protectedHeaderBytes: Uint8Array;
137
+ let payload: Uint8Array;
138
+ let protectedHeader: ReturnType<typeof decodeProtectedHeader>;
139
+
140
+ try {
141
+ const envelope = decodeReceiptEnvelope(input.entry.envelope);
142
+ protectedHeaderBytes = envelope.protectedBytes;
143
+ payload = envelope.payload;
144
+ protectedHeader = decodeProtectedHeader(protectedHeaderBytes);
145
+ } catch (error) {
146
+ return reject("invalid_receipt_body", error, input.entry);
147
+ }
148
+
149
+ if (!logUrlsEqual(protectedHeader.sello_log_url, input.log.logUrl)) {
150
+ return reject(
151
+ "log_url_mismatch",
152
+ "receipt log URL does not match returning log",
153
+ input.entry,
154
+ );
155
+ }
156
+
157
+ if (!input.trustedLogUrls.some((logUrl) => logUrlsEqual(logUrl, input.log.logUrl))) {
158
+ return reject("untrusted_log", "returning log is not trusted", input.entry);
159
+ }
160
+
161
+ if (!bytesEqual(protectedHeader.sello_token_ref, input.tokenRef)) {
162
+ return reject(
163
+ "token_ref_mismatch",
164
+ "receipt token ref does not match requested token",
165
+ input.entry,
166
+ );
167
+ }
168
+
169
+ if (!input.log.verifyInclusionProof(input.entry)) {
170
+ return reject("inclusion_proof_failed", "inclusion proof failed", input.entry);
171
+ }
172
+
173
+ let service;
174
+ try {
175
+ service = resolveServiceKey(input.registry, protectedHeader.kid);
176
+ } catch (error) {
177
+ return reject("unknown_kid", error, input.entry);
178
+ }
179
+
180
+ try {
181
+ assertKeyNotRevoked(
182
+ input.registry,
183
+ protectedHeader.kid,
184
+ input.entry.integratedTime,
185
+ );
186
+ } catch (error) {
187
+ return reject("revoked_key", error, input.entry);
188
+ }
189
+
190
+ try {
191
+ verifyReceiptEnvelope({
192
+ envelope: input.entry.envelope,
193
+ servicePublicKey: service.publicKeyEd25519,
194
+ });
195
+ } catch (error) {
196
+ return reject("cose_signature_failed", error, input.entry);
197
+ }
198
+
199
+ let plaintext;
200
+ try {
201
+ plaintext = openReceiptBody({
202
+ payload,
203
+ ownerPrivateKey: input.ownerPrivateKey,
204
+ protectedHeaderBytes,
205
+ serviceIdentifier: service.serviceIdentifier,
206
+ selloTokenRef: protectedHeader.sello_token_ref,
207
+ });
208
+ } catch (error) {
209
+ return reject("hpke_open_failed", error, input.entry);
210
+ }
211
+
212
+ try {
213
+ return {
214
+ status: "valid",
215
+ receipt: decodeReceiptBody(plaintext),
216
+ serviceIdentifier: service.serviceIdentifier,
217
+ kidHex: toHex(protectedHeader.kid),
218
+ tokenRefHex: toHex(protectedHeader.sello_token_ref),
219
+ logUrl: input.log.logUrl,
220
+ logCompleteness: input.logCompleteness,
221
+ integratedTime: input.entry.integratedTime,
222
+ sameSecondActivity: false,
223
+ };
224
+ } catch (error) {
225
+ return reject("invalid_receipt_body", error, input.entry);
226
+ }
227
+ }
228
+
229
+ function buildExactDedupKey(record: VerifiedReceipt): string {
230
+ return [
231
+ buildSameSecondKey(record),
232
+ record.receipt["action-type"],
233
+ toHex(record.receipt["action-input-hash"]),
234
+ toHex(record.receipt["action-output-hash"]),
235
+ ].join("|");
236
+ }
237
+
238
+ function buildSameSecondKey(record: VerifiedReceipt): string {
239
+ return [
240
+ record.kidHex,
241
+ record.tokenRefHex,
242
+ truncateTimestampToSecond(record.receipt.timestamp),
243
+ ].join("|");
244
+ }
245
+
246
+ function truncateTimestampToSecond(timestamp: string): string {
247
+ return timestamp.replace(/\.\d+Z$/, "Z");
248
+ }
249
+
250
+ function reject(
251
+ code: VerificationRejectionCode,
252
+ error: unknown,
253
+ entry: TransparencyLogEntry,
254
+ ): RejectedReceipt {
255
+ return {
256
+ status: "rejected",
257
+ code,
258
+ message: error instanceof Error ? error.message : String(error),
259
+ logUrl: entry.logUrl,
260
+ integratedTime: entry.integratedTime,
261
+ };
262
+ }
263
+
264
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
265
+ if (a.byteLength !== b.byteLength) {
266
+ return false;
267
+ }
268
+
269
+ for (let index = 0; index < a.byteLength; index += 1) {
270
+ if (a[index] !== b[index]) {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ return true;
276
+ }
@@ -0,0 +1,210 @@
1
+ import {
2
+ type CborMap,
3
+ type CborTagged,
4
+ cborTag,
5
+ decodeCbor,
6
+ encodeCbor,
7
+ } from "../cbor.ts";
8
+ import { assertAgentIdentifier } from "../crypto/identifiers.ts";
9
+
10
+ export type ResultStatus = "success" | "error" | "denied";
11
+
12
+ export type ReceiptBody = {
13
+ "agent-identifier": string;
14
+ "action-type": string;
15
+ "action-input-hash": Uint8Array;
16
+ "action-output-hash": Uint8Array;
17
+ "result-status": ResultStatus;
18
+ timestamp: string;
19
+ "service-defined-fields"?: CborMap;
20
+ };
21
+
22
+ export const ZERO_SHA256_DIGEST = new Uint8Array(32);
23
+
24
+ const RESULT_STATUSES = new Set<ResultStatus>(["success", "error", "denied"]);
25
+ const RECEIPT_KEYS = new Set([
26
+ "agent-identifier",
27
+ "action-type",
28
+ "action-input-hash",
29
+ "action-output-hash",
30
+ "result-status",
31
+ "timestamp",
32
+ "service-defined-fields",
33
+ ]);
34
+
35
+ export function encodeReceiptBody(receipt: ReceiptBody): Uint8Array {
36
+ validateReceiptBody(receipt);
37
+
38
+ const map: CborMap = new Map([
39
+ ["agent-identifier", receipt["agent-identifier"]],
40
+ ["action-type", receipt["action-type"]],
41
+ ["action-input-hash", receipt["action-input-hash"]],
42
+ ["action-output-hash", receipt["action-output-hash"]],
43
+ ["result-status", receipt["result-status"]],
44
+ ["timestamp", cborTag(0, receipt.timestamp)],
45
+ ]);
46
+
47
+ if (receipt["service-defined-fields"]) {
48
+ map.set("service-defined-fields", receipt["service-defined-fields"]);
49
+ }
50
+
51
+ return encodeCbor(map);
52
+ }
53
+
54
+ export function decodeReceiptBody(bytes: Uint8Array): ReceiptBody {
55
+ const value = decodeCbor(bytes);
56
+
57
+ if (!(value instanceof Map)) {
58
+ throw new TypeError("receipt body must be a CBOR map");
59
+ }
60
+
61
+ for (const key of value.keys()) {
62
+ if (typeof key !== "string" || !RECEIPT_KEYS.has(key)) {
63
+ throw new TypeError(`receipt body contains unknown field ${String(key)}`);
64
+ }
65
+ }
66
+
67
+ const timestamp = value.get("timestamp");
68
+ const receipt: ReceiptBody = {
69
+ "agent-identifier": expectString(value, "agent-identifier"),
70
+ "action-type": expectString(value, "action-type"),
71
+ "action-input-hash": expectBytes(value, "action-input-hash"),
72
+ "action-output-hash": expectBytes(value, "action-output-hash"),
73
+ "result-status": expectResultStatus(value, "result-status"),
74
+ timestamp: expectTag0Timestamp(timestamp),
75
+ };
76
+
77
+ if (value.has("service-defined-fields")) {
78
+ const serviceFields = value.get("service-defined-fields");
79
+ if (!(serviceFields instanceof Map)) {
80
+ throw new TypeError("service-defined-fields must be a CBOR map");
81
+ }
82
+ assertServiceDefinedFields(serviceFields);
83
+ receipt["service-defined-fields"] = serviceFields;
84
+ }
85
+
86
+ validateReceiptBody(receipt);
87
+ return receipt;
88
+ }
89
+
90
+ export function validateReceiptBody(receipt: ReceiptBody): void {
91
+ assertObject(receipt, "receipt");
92
+ assertAgentIdentifier(receipt["agent-identifier"], "agent-identifier");
93
+ assertString(receipt["action-type"], "action-type");
94
+ assertSha256Digest(receipt["action-input-hash"], "action-input-hash");
95
+ assertSha256Digest(receipt["action-output-hash"], "action-output-hash");
96
+ assertResultStatus(receipt["result-status"], "result-status");
97
+ assertUtcTimestamp(receipt.timestamp, "timestamp");
98
+
99
+ if (
100
+ receipt["result-status"] === "denied" &&
101
+ !bytesEqual(receipt["action-output-hash"], ZERO_SHA256_DIGEST)
102
+ ) {
103
+ throw new TypeError("denied receipts must use all-zero action-output-hash");
104
+ }
105
+
106
+ if (receipt["service-defined-fields"] !== undefined) {
107
+ if (!(receipt["service-defined-fields"] instanceof Map)) {
108
+ throw new TypeError("service-defined-fields must be a Map");
109
+ }
110
+ assertServiceDefinedFields(receipt["service-defined-fields"]);
111
+ }
112
+ }
113
+
114
+ function expectString(map: CborMap, key: string): string {
115
+ const value = map.get(key);
116
+ assertString(value, key);
117
+ return value;
118
+ }
119
+
120
+ function expectBytes(map: CborMap, key: string): Uint8Array {
121
+ const value = map.get(key);
122
+ assertSha256Digest(value, key);
123
+ return value;
124
+ }
125
+
126
+ function expectResultStatus(map: CborMap, key: string): ResultStatus {
127
+ const value = map.get(key);
128
+ assertResultStatus(value, key);
129
+ return value;
130
+ }
131
+
132
+ function expectTag0Timestamp(value: unknown): string {
133
+ if (!isTagged(value) || value.tag !== 0 || typeof value.value !== "string") {
134
+ throw new TypeError("timestamp must be CBOR tag 0 containing a string");
135
+ }
136
+
137
+ assertUtcTimestamp(value.value, "timestamp");
138
+ return value.value;
139
+ }
140
+
141
+ function assertObject(value: unknown, name: string): asserts value is object {
142
+ if (typeof value !== "object" || value === null) {
143
+ throw new TypeError(`${name} must be an object`);
144
+ }
145
+ }
146
+
147
+ function assertString(value: unknown, name: string): asserts value is string {
148
+ if (typeof value !== "string") {
149
+ throw new TypeError(`${name} must be a string`);
150
+ }
151
+ }
152
+
153
+ function assertSha256Digest(value: unknown, name: string): asserts value is Uint8Array {
154
+ if (!(value instanceof Uint8Array) || value.byteLength !== 32) {
155
+ throw new TypeError(`${name} must be a 32-byte SHA-256 digest`);
156
+ }
157
+ }
158
+
159
+ function assertResultStatus(value: unknown, name: string): asserts value is ResultStatus {
160
+ if (typeof value !== "string" || !RESULT_STATUSES.has(value as ResultStatus)) {
161
+ throw new TypeError(`${name} must be success, error, or denied`);
162
+ }
163
+ }
164
+
165
+ function assertUtcTimestamp(value: unknown, name: string): asserts value is string {
166
+ assertString(value, name);
167
+
168
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value)) {
169
+ throw new TypeError(`${name} must be an RFC 3339 UTC timestamp`);
170
+ }
171
+
172
+ const parsed = Date.parse(value);
173
+ if (Number.isNaN(parsed)) {
174
+ throw new TypeError(`${name} must be a valid timestamp`);
175
+ }
176
+ }
177
+
178
+ function assertServiceDefinedFields(value: CborMap): void {
179
+ for (const [key, entryValue] of value.entries()) {
180
+ if (typeof key !== "string") {
181
+ throw new TypeError("service-defined-fields keys must be service identifiers");
182
+ }
183
+ if (!(entryValue instanceof Map)) {
184
+ throw new TypeError("service-defined-fields values must be CBOR maps");
185
+ }
186
+ }
187
+ }
188
+
189
+ function isTagged(value: unknown): value is CborTagged {
190
+ return (
191
+ typeof value === "object" &&
192
+ value !== null &&
193
+ "tag" in value &&
194
+ "value" in value
195
+ );
196
+ }
197
+
198
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
199
+ if (a.byteLength !== b.byteLength) {
200
+ return false;
201
+ }
202
+
203
+ for (let index = 0; index < a.byteLength; index += 1) {
204
+ if (a[index] !== b[index]) {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ return true;
210
+ }
@@ -0,0 +1,233 @@
1
+ import {
2
+ assertEd25519PublicKey,
3
+ assertEd25519Signature,
4
+ signEd25519,
5
+ verifyEd25519Signature,
6
+ } from "../crypto/ed25519.ts";
7
+ import { toHex } from "../crypto/identifiers.ts";
8
+
9
+ export type RegistryEntry = {
10
+ kidHex: string;
11
+ serviceIdentifier: string;
12
+ publicKeyEd25519: Uint8Array;
13
+ };
14
+
15
+ export type RevocationEntry = {
16
+ kidHex: string;
17
+ revokedAt: string;
18
+ };
19
+
20
+ export type JsonIdentityRegistry = {
21
+ entries: Map<string, RegistryEntry>;
22
+ revoked: Map<string, RevocationEntry>;
23
+ };
24
+
25
+ export type LoadSignedRegistryInput = {
26
+ registryBytes: Uint8Array;
27
+ signatureBase64Url: string;
28
+ trustRootPublicKey: Uint8Array;
29
+ };
30
+
31
+ const textDecoder = new TextDecoder("utf-8", { fatal: true });
32
+ const BASE64URL_32_BYTE_LENGTH = 43;
33
+
34
+ export function signRegistryJson(
35
+ registryBytes: Uint8Array,
36
+ trustRootPrivateKey: Uint8Array,
37
+ ): string {
38
+ assertBytes(registryBytes, "registryBytes");
39
+ return base64urlEncode(signEd25519(registryBytes, trustRootPrivateKey));
40
+ }
41
+
42
+ export function loadSignedRegistry(
43
+ input: LoadSignedRegistryInput,
44
+ ): JsonIdentityRegistry {
45
+ verifyRegistrySignature(
46
+ input.registryBytes,
47
+ input.signatureBase64Url,
48
+ input.trustRootPublicKey,
49
+ );
50
+
51
+ return parseRegistry(input.registryBytes);
52
+ }
53
+
54
+ export function verifyRegistrySignature(
55
+ registryBytes: Uint8Array,
56
+ signatureBase64Url: string,
57
+ trustRootPublicKey: Uint8Array,
58
+ ): void {
59
+ assertBytes(registryBytes, "registryBytes");
60
+ assertEd25519PublicKey(trustRootPublicKey, "trustRootPublicKey");
61
+ const signature = base64urlDecode(signatureBase64Url, "registry signature");
62
+ assertEd25519Signature(signature, "registry signature");
63
+
64
+ if (!verifyEd25519Signature(registryBytes, signature, trustRootPublicKey)) {
65
+ throw new TypeError("registry signature verification failed");
66
+ }
67
+ }
68
+
69
+ export function parseRegistry(registryBytes: Uint8Array): JsonIdentityRegistry {
70
+ assertBytes(registryBytes, "registryBytes");
71
+ const parsed = JSON.parse(textDecoder.decode(registryBytes));
72
+
73
+ if (!isRecord(parsed)) {
74
+ throw new TypeError("registry must be a JSON object");
75
+ }
76
+
77
+ const entries = new Map<string, RegistryEntry>();
78
+ const revoked = parseRevoked(parsed.revoked);
79
+
80
+ for (const [kidHex, value] of Object.entries(parsed)) {
81
+ if (kidHex === "revoked") {
82
+ continue;
83
+ }
84
+
85
+ assertKidHex(kidHex, "registry kid");
86
+
87
+ if (!isRecord(value)) {
88
+ throw new TypeError(`registry entry ${kidHex} must be an object`);
89
+ }
90
+
91
+ const serviceIdentifier = value.service_identifier;
92
+ if (typeof serviceIdentifier !== "string" || serviceIdentifier.length === 0) {
93
+ throw new TypeError(`registry entry ${kidHex} service_identifier must be a non-empty string`);
94
+ }
95
+
96
+ const encodedPublicKey = value.public_key_ed25519;
97
+ if (typeof encodedPublicKey !== "string") {
98
+ throw new TypeError(`registry entry ${kidHex} public_key_ed25519 must be a string`);
99
+ }
100
+
101
+ const publicKeyEd25519 = base64urlDecodeFixed32(
102
+ encodedPublicKey,
103
+ `registry entry ${kidHex} public_key_ed25519`,
104
+ );
105
+
106
+ entries.set(kidHex, {
107
+ kidHex,
108
+ serviceIdentifier,
109
+ publicKeyEd25519,
110
+ });
111
+ }
112
+
113
+ return { entries, revoked };
114
+ }
115
+
116
+ export function resolveServiceKey(
117
+ registry: JsonIdentityRegistry,
118
+ kid: Uint8Array,
119
+ ): RegistryEntry {
120
+ const kidHex = toHex(kid);
121
+ const entry = registry.entries.get(kidHex);
122
+
123
+ if (!entry) {
124
+ throw new TypeError(`unknown kid ${kidHex}`);
125
+ }
126
+
127
+ return {
128
+ kidHex: entry.kidHex,
129
+ serviceIdentifier: entry.serviceIdentifier,
130
+ publicKeyEd25519: new Uint8Array(entry.publicKeyEd25519),
131
+ };
132
+ }
133
+
134
+ export function assertKeyNotRevoked(
135
+ registry: JsonIdentityRegistry,
136
+ kid: Uint8Array,
137
+ integratedTime?: string | Date,
138
+ ): void {
139
+ const kidHex = toHex(kid);
140
+ const revoked = registry.revoked.get(kidHex);
141
+
142
+ if (!revoked) {
143
+ return;
144
+ }
145
+
146
+ if (integratedTime === undefined) {
147
+ throw new TypeError(`kid ${kidHex} is revoked and requires verifiable integrated time`);
148
+ }
149
+
150
+ const integratedTimeMs =
151
+ integratedTime instanceof Date
152
+ ? integratedTime.getTime()
153
+ : parseUtcTimestamp(integratedTime, "integratedTime");
154
+ const revokedAtMs = parseUtcTimestamp(revoked.revokedAt, `revoked_at for ${kidHex}`);
155
+
156
+ if (integratedTimeMs >= revokedAtMs) {
157
+ throw new TypeError(`kid ${kidHex} was revoked at ${revoked.revokedAt}`);
158
+ }
159
+ }
160
+
161
+ function parseRevoked(value: unknown): Map<string, RevocationEntry> {
162
+ const revoked = new Map<string, RevocationEntry>();
163
+
164
+ if (value === undefined) {
165
+ return revoked;
166
+ }
167
+
168
+ if (!isRecord(value)) {
169
+ throw new TypeError("registry revoked must be an object");
170
+ }
171
+
172
+ for (const [kidHex, entry] of Object.entries(value)) {
173
+ assertKidHex(kidHex, "revoked kid");
174
+
175
+ if (!isRecord(entry) || typeof entry.revoked_at !== "string") {
176
+ throw new TypeError(`revoked entry ${kidHex} must contain revoked_at`);
177
+ }
178
+
179
+ parseUtcTimestamp(entry.revoked_at, `revoked_at for ${kidHex}`);
180
+ revoked.set(kidHex, { kidHex, revokedAt: entry.revoked_at });
181
+ }
182
+
183
+ return revoked;
184
+ }
185
+
186
+ function base64urlEncode(bytes: Uint8Array): string {
187
+ return Buffer.from(bytes).toString("base64url");
188
+ }
189
+
190
+ function base64urlDecodeFixed32(value: string, name: string): Uint8Array {
191
+ if (value.length !== BASE64URL_32_BYTE_LENGTH) {
192
+ throw new TypeError(`${name} must be base64url encoding of 32 bytes`);
193
+ }
194
+
195
+ const decoded = base64urlDecode(value, name);
196
+ assertEd25519PublicKey(decoded, name);
197
+ return decoded;
198
+ }
199
+
200
+ function base64urlDecode(value: string, name: string): Uint8Array {
201
+ if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) {
202
+ throw new TypeError(`${name} must be unpadded base64url`);
203
+ }
204
+
205
+ return Uint8Array.from(Buffer.from(value, "base64url"));
206
+ }
207
+
208
+ function assertKidHex(value: string, name: string): void {
209
+ if (!/^(?:[0-9a-f]{2})+$/.test(value)) {
210
+ throw new TypeError(`${name} must be lowercase even-length hex`);
211
+ }
212
+ }
213
+
214
+ function parseUtcTimestamp(value: string, name: string): number {
215
+ if (
216
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value) ||
217
+ Number.isNaN(Date.parse(value))
218
+ ) {
219
+ throw new TypeError(`${name} must be an RFC 3339 UTC timestamp`);
220
+ }
221
+
222
+ return Date.parse(value);
223
+ }
224
+
225
+ function assertBytes(value: unknown, name: string): asserts value is Uint8Array {
226
+ if (!(value instanceof Uint8Array)) {
227
+ throw new TypeError(`${name} must be a Uint8Array`);
228
+ }
229
+ }
230
+
231
+ function isRecord(value: unknown): value is Record<string, unknown> {
232
+ return typeof value === "object" && value !== null && !Array.isArray(value);
233
+ }