@aroha-sdk/core 1.0.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.
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ generateDID,
4
+ rotateDIDKey,
5
+ revokeKey,
6
+ verifyRevocation,
7
+ buildDIDDocument,
8
+ extractPublicKey,
9
+ extractEndpoint,
10
+ extractNamespace,
11
+ toMultibase,
12
+ fromMultibase,
13
+ } from "./did.js";
14
+
15
+ describe("toMultibase / fromMultibase", () => {
16
+ it("round-trips a Uint8Array", () => {
17
+ const bytes = new Uint8Array(32).fill(42);
18
+ const encoded = toMultibase(bytes);
19
+ expect(encoded).toMatch(/^z/);
20
+ const decoded = fromMultibase(encoded);
21
+ expect(decoded).toEqual(bytes);
22
+ });
23
+
24
+ it("throws on non-base58btc multibase prefix", () => {
25
+ expect(() => fromMultibase("mSomeBase64")).toThrow("Only base58btc");
26
+ });
27
+ });
28
+
29
+ describe("generateDID", () => {
30
+ it("produces a DID with the expected format", async () => {
31
+ const kp = await generateDID("test-agent", "https://example.com/aroha");
32
+ expect(kp.did).toBe("did:aroha:test-agent");
33
+ expect(kp.privateKey).toHaveLength(32);
34
+ expect(kp.publicKey).toHaveLength(32);
35
+ });
36
+
37
+ it("embeds the endpoint in the DID document service", async () => {
38
+ const kp = await generateDID("my-agent", "https://agent.example.com");
39
+ const endpoint = extractEndpoint(kp.document);
40
+ expect(endpoint).toBe("https://agent.example.com");
41
+ });
42
+
43
+ it("embeds the public key in the verification method", async () => {
44
+ const kp = await generateDID("key-agent", "https://example.com");
45
+ const extracted = extractPublicKey(kp.document);
46
+ expect(extracted).toEqual(kp.publicKey);
47
+ });
48
+
49
+ it("sets @context, authentication, assertionMethod, keyAgreement", async () => {
50
+ const kp = await generateDID("ctx-agent", "https://example.com");
51
+ const doc = kp.document;
52
+ expect(doc["@context"]).toContain("https://www.w3.org/ns/did/v1");
53
+ expect(doc.authentication).toContain(`${kp.did}#key-1`);
54
+ expect(doc.assertionMethod).toContain(`${kp.did}#key-1`);
55
+ expect(doc.keyAgreement).toContain(`${kp.did}#key-1`);
56
+ });
57
+ });
58
+
59
+ describe("buildDIDDocument", () => {
60
+ it("reconstructs a document from an existing key", async () => {
61
+ const original = await generateDID("rebuild-agent", "https://example.com");
62
+ const doc = buildDIDDocument(
63
+ "rebuild-agent",
64
+ original.publicKey,
65
+ "https://example.com",
66
+ original.document.created
67
+ );
68
+ expect(doc.id).toBe("did:aroha:rebuild-agent");
69
+ expect(extractPublicKey(doc)).toEqual(original.publicKey);
70
+ });
71
+ });
72
+
73
+ describe("extractPublicKey / extractEndpoint", () => {
74
+ it("throws when there are no verification methods", () => {
75
+ const doc = {
76
+ "@context": [],
77
+ id: "did:aroha:empty",
78
+ verificationMethod: [],
79
+ authentication: [],
80
+ assertionMethod: [],
81
+ keyAgreement: [],
82
+ service: [],
83
+ created: "",
84
+ updated: "",
85
+ };
86
+ expect(() => extractPublicKey(doc)).toThrow("no verification methods");
87
+ });
88
+
89
+ it("throws when there is no ArohaEndpoint service", async () => {
90
+ const kp = await generateDID("no-svc", "https://example.com");
91
+ const doc = { ...kp.document, service: [] };
92
+ expect(() => extractEndpoint(doc)).toThrow("no ArohaEndpoint service");
93
+ });
94
+ });
95
+
96
+ describe("rotateDIDKey", () => {
97
+ it("returns a new keypair with a different public key", async () => {
98
+ const original = await generateDID("my-agent", "https://example.com");
99
+ const rotated = await rotateDIDKey(original);
100
+ expect(Buffer.from(rotated.publicKey).toString("hex"))
101
+ .not.toBe(Buffer.from(original.publicKey).toString("hex"));
102
+ });
103
+
104
+ it("adds a keyHistory entry signed by the predecessor", async () => {
105
+ const original = await generateDID("my-agent", "https://example.com");
106
+ const rotated = await rotateDIDKey(original);
107
+ expect(rotated.document.keyHistory).toHaveLength(1);
108
+ const record = rotated.document.keyHistory![0];
109
+ expect(record.rotatedAt).toBeTruthy();
110
+ expect(record.predecessorSignature).toBeTruthy();
111
+ });
112
+
113
+ it("rotation record has valid predecessor signature", async () => {
114
+ const { verifyKeyRotation } = await import("./web-did.js");
115
+ const original = await generateDID("my-agent", "https://example.com");
116
+ const rotated = await rotateDIDKey(original);
117
+ const record = rotated.document.keyHistory![0];
118
+ expect(await verifyKeyRotation(record)).toBe(true);
119
+ });
120
+
121
+ it("extractPublicKey returns the new key after rotation (past cool-down)", async () => {
122
+ const original = await generateDID("my-agent", "https://example.com");
123
+ const rotated = await rotateDIDKey(original, 0); // 0-hour cool-down for test
124
+ const extracted = extractPublicKey(rotated.document);
125
+ expect(Buffer.from(extracted).toString("hex"))
126
+ .toBe(Buffer.from(rotated.publicKey).toString("hex"));
127
+ });
128
+
129
+ it("extractPublicKey throws during cool-down window", async () => {
130
+ const original = await generateDID("my-agent", "https://example.com");
131
+ const rotated = await rotateDIDKey(original, 24); // 24-hour cool-down
132
+ expect(() => extractPublicKey(rotated.document)).toThrow(/cool-down/i);
133
+ });
134
+ });
135
+
136
+ import { generateWebAgent, rotateKey, verifyKeyRotation } from "./web-did.js";
137
+
138
+ describe("resolveWebDID — rotation chain verification", () => {
139
+ it("verifyKeyRotation returns true for a valid rotation", async () => {
140
+ const agent = await generateWebAgent("example.com", "test-agent");
141
+ const rotated = await rotateKey(agent);
142
+ const record = rotated.webDocument.arohaWeb.keyHistory[0];
143
+ expect(await verifyKeyRotation(record)).toBe(true);
144
+ });
145
+
146
+ it("verifyKeyRotation returns false for a tampered rotation record", async () => {
147
+ const agent = await generateWebAgent("example.com", "test-agent");
148
+ const rotated = await rotateKey(agent);
149
+ const record = { ...rotated.webDocument.arohaWeb.keyHistory[0] };
150
+ // Tamper with the signature
151
+ record.predecessorSignature = "invalidsignature";
152
+ expect(await verifyKeyRotation(record)).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("Emergency key revocation", () => {
157
+ it("revokeKey adds revokedKeys entry to DID document", async () => {
158
+ const agent = await generateDID("revoke-test", "http://localhost");
159
+ const revoked = await revokeKey(agent, "Compromised — emergency revocation");
160
+ expect(revoked.document.revokedKeys).toHaveLength(1);
161
+ expect(revoked.document.revokedKeys![0].reason).toBe("Compromised — emergency revocation");
162
+ expect(revoked.document.revokedKeys![0].keyMultibase).toBe(
163
+ agent.document.verificationMethod[0].publicKeyMultibase
164
+ );
165
+ });
166
+
167
+ it("extractPublicKey throws when current key is in revokedKeys", async () => {
168
+ const agent = await generateDID("revoke-test-2", "http://localhost");
169
+ // Simulate a document where the active verificationMethod key is itself listed as revoked
170
+ const currentKeyMultibase = agent.document.verificationMethod[0].publicKeyMultibase;
171
+ const docWithRevokedCurrentKey = {
172
+ ...agent.document,
173
+ revokedKeys: [
174
+ {
175
+ keyMultibase: currentKeyMultibase,
176
+ revokedAt: new Date().toISOString(),
177
+ reason: "stolen",
178
+ },
179
+ ],
180
+ };
181
+ expect(() => extractPublicKey(docWithRevokedCurrentKey)).toThrow(/revoked/);
182
+ });
183
+
184
+ it("revokeKey rotates to a new keypair immediately (no cooldown)", async () => {
185
+ const agent = await generateDID("revoke-test-3", "http://localhost");
186
+ const revoked = await revokeKey(agent, "stolen");
187
+ // New key should be immediately trusted — no cooldown
188
+ const newPubKey = extractPublicKey(revoked.document);
189
+ expect(newPubKey).not.toEqual(agent.publicKey);
190
+ });
191
+
192
+ it("revokeKey signs the revocation with the old key", async () => {
193
+ const agent = await generateDID("revoke-test-4", "http://localhost");
194
+ const revoked = await revokeKey(agent, "test");
195
+ expect(revoked.document.revokedKeys![0].selfSignature).toBeDefined();
196
+ expect(revoked.document.revokedKeys![0].selfSignature!.length).toBeGreaterThan(0);
197
+ });
198
+
199
+ it("selfSignature is cryptographically valid — verifyRevocation returns true", async () => {
200
+ const agent = await generateDID("revoke-verify-test", "http://localhost");
201
+ const revoked = await revokeKey(agent, "testing verification");
202
+ const record = revoked.document.revokedKeys![0];
203
+ const isValid = await verifyRevocation(record, agent.publicKey);
204
+ expect(isValid).toBe(true);
205
+ });
206
+ });
207
+
208
+ describe("DID namespacing", () => {
209
+ it("generateDID with namespace produces did:aroha:namespace.agentId", async () => {
210
+ const agent = await generateDID("invoice-processor", "http://localhost", { namespace: "acme-corp" });
211
+ expect(agent.did).toBe("did:aroha:acme-corp.invoice-processor");
212
+ expect(agent.document.id).toBe("did:aroha:acme-corp.invoice-processor");
213
+ });
214
+
215
+ it("generateDID without namespace is unchanged (backwards compat)", async () => {
216
+ const agent = await generateDID("invoice-processor", "http://localhost");
217
+ expect(agent.did).toBe("did:aroha:invoice-processor");
218
+ });
219
+
220
+ it("generateDID throws on invalid namespace", async () => {
221
+ await expect(generateDID("agent", "http://localhost", { namespace: "UPPER_CASE" }))
222
+ .rejects.toThrow(/Invalid namespace/);
223
+ });
224
+
225
+ it("extractNamespace returns null for un-namespaced DID", () => {
226
+ expect(extractNamespace("did:aroha:flight-agent")).toBeNull();
227
+ });
228
+
229
+ it("extractNamespace returns the namespace for namespaced DID", () => {
230
+ expect(extractNamespace("did:aroha:acme-corp.invoice-processor")).toBe("acme-corp");
231
+ });
232
+ });
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Aroha DID (Decentralized Identifier) implementation.
3
+ *
4
+ * DID format: did:aroha:<unique-id>
5
+ * Spec: W3C DID Core 1.0 (https://www.w3.org/TR/did-core/)
6
+ *
7
+ * A DID Document contains:
8
+ * - Verification methods (public keys)
9
+ * - Authentication references
10
+ * - Service endpoints (Aroha agent endpoint)
11
+ */
12
+
13
+ import * as ed from "@noble/ed25519";
14
+ import { sha256 } from "@noble/hashes/sha256";
15
+ import { sha512 } from "@noble/hashes/sha512";
16
+ import { bytesToHex, randomBytes } from "@noble/hashes/utils";
17
+
18
+ // noble/ed25519 v2 requires sha512 sync
19
+ ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
20
+
21
+ // ─── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ export interface VerificationMethod {
24
+ id: string;
25
+ type: "Ed25519VerificationKey2020";
26
+ controller: string;
27
+ /** Base58-encoded public key with multibase prefix 'z' */
28
+ publicKeyMultibase: string;
29
+ }
30
+
31
+ export interface ServiceEndpoint {
32
+ id: string;
33
+ type: "ArohaEndpoint";
34
+ serviceEndpoint: string;
35
+ }
36
+
37
+ export interface KeyRotationRecord {
38
+ /** ISO8601 — when the rotation was announced. */
39
+ rotatedAt: string;
40
+ /** ISO8601 — earliest time the new key becomes trusted (rotatedAt + coolDownHours). */
41
+ trustAfter: string;
42
+ /** Multibase pubkey of the replaced (predecessor) key. */
43
+ predecessorKey: string;
44
+ /** SHA-256 hex of the new key. */
45
+ newKeyHash: string;
46
+ /**
47
+ * Ed25519 signature over `predecessorKey || newKeyHash || rotatedAt`,
48
+ * signed by the predecessor key. Proves voluntary key rotation.
49
+ */
50
+ predecessorSignature: string;
51
+ }
52
+
53
+ export interface RevokedKeyRecord {
54
+ /** Multibase public key that was revoked. */
55
+ keyMultibase: string;
56
+ /** ISO8601 — when revocation was announced. */
57
+ revokedAt: string;
58
+ /** Human-readable reason (e.g. "Compromised — see incident #42"). */
59
+ reason: string;
60
+ /**
61
+ * Ed25519 signature over `keyMultibase || revokedAt || reason`
62
+ * signed by the key being revoked. Proves intentional revocation.
63
+ */
64
+ selfSignature?: string;
65
+ }
66
+
67
+ export interface DIDDocument {
68
+ "@context": string[];
69
+ id: string;
70
+ verificationMethod: VerificationMethod[];
71
+ authentication: string[];
72
+ assertionMethod: string[];
73
+ keyAgreement: string[];
74
+ service: ServiceEndpoint[];
75
+ created: string;
76
+ updated: string;
77
+ /** Rotation history (newest-first). Present only after at least one rotation. */
78
+ keyHistory?: KeyRotationRecord[];
79
+ /** Keys that have been explicitly revoked and must never be trusted. */
80
+ revokedKeys?: RevokedKeyRecord[];
81
+ }
82
+
83
+ export interface AgentKeyPair {
84
+ did: string;
85
+ privateKey: Uint8Array;
86
+ publicKey: Uint8Array;
87
+ document: DIDDocument;
88
+ }
89
+
90
+ // ─── Base58 encoding (multibase 'z' prefix) ───────────────────────────────────
91
+
92
+ const BASE58_ALPHABET =
93
+ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
94
+
95
+ function encodeBase58(bytes: Uint8Array): string {
96
+ let num = BigInt("0x" + bytesToHex(bytes));
97
+ const digits: number[] = [];
98
+ while (num > 0n) {
99
+ digits.push(Number(num % 58n));
100
+ num /= 58n;
101
+ }
102
+ // leading zeros
103
+ for (const b of bytes) {
104
+ if (b !== 0) break;
105
+ digits.push(0);
106
+ }
107
+ return digits
108
+ .reverse()
109
+ .map((d) => BASE58_ALPHABET[d])
110
+ .join("");
111
+ }
112
+
113
+ function decodeBase58(s: string): Uint8Array {
114
+ let num = 0n;
115
+ for (const c of s) {
116
+ const idx = BASE58_ALPHABET.indexOf(c);
117
+ if (idx < 0) throw new Error(`Invalid base58 character: ${c}`);
118
+ num = num * 58n + BigInt(idx);
119
+ }
120
+ const hex = num.toString(16).padStart(64, "0");
121
+ return Uint8Array.from(Buffer.from(hex, "hex"));
122
+ }
123
+
124
+ export function toMultibase(bytes: Uint8Array): string {
125
+ return "z" + encodeBase58(bytes);
126
+ }
127
+
128
+ export function fromMultibase(multibase: string): Uint8Array {
129
+ if (!multibase.startsWith("z")) {
130
+ throw new Error("Only base58btc multibase (prefix 'z') is supported");
131
+ }
132
+ return decodeBase58(multibase.slice(1));
133
+ }
134
+
135
+ // ─── DID Generation ───────────────────────────────────────────────────────────
136
+
137
+ export interface GenerateDIDOptions {
138
+ /**
139
+ * Optional tenant namespace — produces did:aroha:<namespace>.<agentId>.
140
+ * Must match /^[a-z0-9-]+$/.
141
+ */
142
+ namespace?: string;
143
+ }
144
+
145
+ /**
146
+ * Generate a new Aroha DID with a fresh Ed25519 keypair.
147
+ *
148
+ * @param agentId Human-readable identifier (e.g. "expedia-flights-v2")
149
+ * @param endpoint Aroha endpoint URL for the agent
150
+ * @param options Optional generation options (e.g. namespace for multi-tenancy)
151
+ */
152
+ export async function generateDID(
153
+ agentId: string,
154
+ endpoint: string,
155
+ options: GenerateDIDOptions = {}
156
+ ): Promise<AgentKeyPair> {
157
+ const { namespace } = options;
158
+ if (namespace && !/^[a-z0-9-]+$/.test(namespace)) {
159
+ throw new Error(`Invalid namespace "${namespace}" — must match /^[a-z0-9-]+$/`);
160
+ }
161
+ const qualifiedId = namespace ? `${namespace}.${agentId}` : agentId;
162
+
163
+ const privateKey = ed.utils.randomPrivateKey();
164
+ const publicKey = await ed.getPublicKeyAsync(privateKey);
165
+
166
+ const did = `did:aroha:${qualifiedId}`;
167
+ const now = new Date().toISOString();
168
+ const keyId = `${did}#key-1`;
169
+
170
+ const document: DIDDocument = {
171
+ "@context": [
172
+ "https://www.w3.org/ns/did/v1",
173
+ "https://w3id.org/security/suites/ed25519-2020/v1",
174
+ ],
175
+ id: did,
176
+ verificationMethod: [
177
+ {
178
+ id: keyId,
179
+ type: "Ed25519VerificationKey2020",
180
+ controller: did,
181
+ publicKeyMultibase: toMultibase(publicKey),
182
+ },
183
+ ],
184
+ authentication: [keyId],
185
+ assertionMethod: [keyId],
186
+ keyAgreement: [keyId],
187
+ service: [
188
+ {
189
+ id: `${did}#aroha`,
190
+ type: "ArohaEndpoint",
191
+ serviceEndpoint: endpoint,
192
+ },
193
+ ],
194
+ created: now,
195
+ updated: now,
196
+ };
197
+
198
+ return { did, privateKey, publicKey, document };
199
+ }
200
+
201
+ /**
202
+ * Reconstruct a DID Document from an existing keypair.
203
+ * Used when loading a saved agent identity from storage.
204
+ */
205
+ export function buildDIDDocument(
206
+ agentId: string,
207
+ publicKey: Uint8Array,
208
+ endpoint: string,
209
+ created: string
210
+ ): DIDDocument {
211
+ const did = `did:aroha:${agentId}`;
212
+ const keyId = `${did}#key-1`;
213
+ const now = new Date().toISOString();
214
+
215
+ return {
216
+ "@context": [
217
+ "https://www.w3.org/ns/did/v1",
218
+ "https://w3id.org/security/suites/ed25519-2020/v1",
219
+ ],
220
+ id: did,
221
+ verificationMethod: [
222
+ {
223
+ id: keyId,
224
+ type: "Ed25519VerificationKey2020",
225
+ controller: did,
226
+ publicKeyMultibase: toMultibase(publicKey),
227
+ },
228
+ ],
229
+ authentication: [keyId],
230
+ assertionMethod: [keyId],
231
+ keyAgreement: [keyId],
232
+ service: [
233
+ {
234
+ id: `${did}#aroha`,
235
+ type: "ArohaEndpoint",
236
+ serviceEndpoint: endpoint,
237
+ },
238
+ ],
239
+ created,
240
+ updated: now,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Extract public key bytes from a DID Document's first verification method.
246
+ * Throws if a key rotation cool-down window is still active.
247
+ */
248
+ export function extractPublicKey(doc: DIDDocument): Uint8Array {
249
+ const vm = doc.verificationMethod[0];
250
+ if (!vm) throw new Error(`DID ${doc.id} has no verification methods`);
251
+
252
+ // Check if the current key has been explicitly revoked
253
+ if (doc.revokedKeys && doc.revokedKeys.length > 0) {
254
+ const currentKeyMultibase = vm.publicKeyMultibase;
255
+ const revoked = doc.revokedKeys.find(r => r.keyMultibase === currentKeyMultibase);
256
+ if (revoked) {
257
+ throw new Error(
258
+ `DID ${doc.id} key ${currentKeyMultibase.slice(0, 12)}... was revoked at ${revoked.revokedAt}: ${revoked.reason}`
259
+ );
260
+ }
261
+ }
262
+
263
+ if (doc.keyHistory && doc.keyHistory.length > 0) {
264
+ const latest = doc.keyHistory[0];
265
+ if (new Date() < new Date(latest.trustAfter)) {
266
+ throw new Error(
267
+ `DID ${doc.id} key rotation cool-down active until ${latest.trustAfter}`
268
+ );
269
+ }
270
+ }
271
+
272
+ return fromMultibase(vm.publicKeyMultibase);
273
+ }
274
+
275
+ /**
276
+ * Extract the Aroha service endpoint URL from a DID Document.
277
+ */
278
+ export function extractEndpoint(doc: DIDDocument): string {
279
+ const svc = doc.service.find((s) => s.type === "ArohaEndpoint");
280
+ if (!svc) throw new Error(`DID ${doc.id} has no ArohaEndpoint service`);
281
+ return svc.serviceEndpoint;
282
+ }
283
+
284
+ // ─── Key rotation ─────────────────────────────────────────────────────────────
285
+
286
+ function keyCommitmentHash(publicKey: Uint8Array): string {
287
+ return bytesToHex(sha256(publicKey));
288
+ }
289
+
290
+ /**
291
+ * Rotate to a new keypair for a did:aroha: identity.
292
+ * The predecessor key signs the rotation record.
293
+ * The new key is not trusted until coolDownHours have elapsed.
294
+ */
295
+ export async function rotateDIDKey(
296
+ current: AgentKeyPair,
297
+ coolDownHours = 24
298
+ ): Promise<AgentKeyPair> {
299
+ const newPrivateKey = ed.utils.randomPrivateKey();
300
+ const newPublicKey = await ed.getPublicKeyAsync(newPrivateKey);
301
+
302
+ const oldPubMultibase = toMultibase(current.publicKey);
303
+ const now = new Date();
304
+ const trustAfter = new Date(now.getTime() + coolDownHours * 3_600_000);
305
+
306
+ const newKeyHash = keyCommitmentHash(newPublicKey);
307
+ // Payload format matches verifyKeyRotation in web-did.ts:
308
+ // predecessorKey (multibase) + newKeyHash (hex) + rotatedAt (ISO8601)
309
+ const payload = new TextEncoder().encode(
310
+ oldPubMultibase + newKeyHash + now.toISOString()
311
+ );
312
+ const sig = await ed.signAsync(payload, current.privateKey);
313
+
314
+ const rotation: KeyRotationRecord = {
315
+ rotatedAt: now.toISOString(),
316
+ trustAfter: trustAfter.toISOString(),
317
+ predecessorKey: oldPubMultibase,
318
+ newKeyHash,
319
+ predecessorSignature: Buffer.from(sig).toString("base64url"),
320
+ };
321
+
322
+ const keyId = `${current.did}#key-1`;
323
+ const newDoc: DIDDocument = {
324
+ ...current.document,
325
+ verificationMethod: [
326
+ {
327
+ id: keyId,
328
+ type: "Ed25519VerificationKey2020",
329
+ controller: current.did,
330
+ publicKeyMultibase: toMultibase(newPublicKey),
331
+ },
332
+ ],
333
+ updated: now.toISOString(),
334
+ keyHistory: [rotation, ...(current.document.keyHistory ?? [])],
335
+ };
336
+
337
+ return { did: current.did, privateKey: newPrivateKey, publicKey: newPublicKey, document: newDoc };
338
+ }
339
+
340
+ /**
341
+ * Emergency key revocation — immediately marks the current key as revoked
342
+ * and rotates to a new keypair with NO cooldown period.
343
+ *
344
+ * Use when a private key is known or suspected to be compromised.
345
+ * After calling this, publish the updated DID Document immediately.
346
+ *
347
+ * @param current The current agent keypair (needed to sign the revocation)
348
+ * @param reason Human-readable reason for revocation
349
+ */
350
+ export async function revokeKey(
351
+ current: AgentKeyPair,
352
+ reason: string
353
+ ): Promise<AgentKeyPair> {
354
+ const newPrivateKey = ed.utils.randomPrivateKey();
355
+ const newPublicKey = await ed.getPublicKeyAsync(newPrivateKey);
356
+
357
+ const now = new Date().toISOString();
358
+ const oldKeyMultibase = toMultibase(current.publicKey);
359
+
360
+ // Sign revocation with the key being revoked (proves intentional)
361
+ const payload = new TextEncoder().encode(oldKeyMultibase + now + reason);
362
+ const sig = await ed.signAsync(payload, current.privateKey);
363
+
364
+ const revokedRecord: RevokedKeyRecord = {
365
+ keyMultibase: oldKeyMultibase,
366
+ revokedAt: now,
367
+ reason,
368
+ selfSignature: Buffer.from(sig).toString("base64url"),
369
+ };
370
+
371
+ const keyId = `${current.did}#key-1`;
372
+ const newDoc: DIDDocument = {
373
+ ...current.document,
374
+ verificationMethod: [
375
+ {
376
+ id: keyId,
377
+ type: "Ed25519VerificationKey2020",
378
+ controller: current.did,
379
+ publicKeyMultibase: toMultibase(newPublicKey),
380
+ },
381
+ ],
382
+ updated: now,
383
+ // No keyHistory entry — no cooldown, immediate trust of new key
384
+ revokedKeys: [revokedRecord, ...(current.document.revokedKeys ?? [])],
385
+ };
386
+
387
+ return { did: current.did, privateKey: newPrivateKey, publicKey: newPublicKey, document: newDoc };
388
+ }
389
+
390
+ /**
391
+ * Verify a revocation record's self-signature.
392
+ * Returns true if the signature is valid — i.e., the holder of the revoked
393
+ * key intentionally created this record.
394
+ *
395
+ * @param record The RevokedKeyRecord to verify
396
+ * @param publicKey The public key that was revoked (to verify against)
397
+ */
398
+ export async function verifyRevocation(
399
+ record: RevokedKeyRecord,
400
+ publicKey: Uint8Array
401
+ ): Promise<boolean> {
402
+ if (!record.selfSignature) return false;
403
+ try {
404
+ const payload = new TextEncoder().encode(
405
+ record.keyMultibase + record.revokedAt + record.reason
406
+ );
407
+ const sig = Buffer.from(record.selfSignature, "base64url");
408
+ return await ed.verifyAsync(sig, payload, publicKey);
409
+ } catch {
410
+ return false;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Extract the namespace from a namespaced DID.
416
+ * Returns null for un-namespaced DIDs.
417
+ *
418
+ * Example:
419
+ * extractNamespace("did:aroha:acme-corp.invoice-processor") → "acme-corp"
420
+ * extractNamespace("did:aroha:flight-agent") → null
421
+ */
422
+ export function extractNamespace(did: string): string | null {
423
+ const suffix = did.replace("did:aroha:", "");
424
+ const dotIndex = suffix.indexOf(".");
425
+ return dotIndex >= 0 ? suffix.slice(0, dotIndex) : null;
426
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./did.js";
2
+ export * from "./credentials.js";
3
+ export * from "./web-did.js";
4
+ export * from "./did-cache.js";