@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,427 @@
1
+ /**
2
+ * Aroha Web-Native Identity — did:aroha-web:
3
+ *
4
+ * A DNS-anchored DID method that requires NO central Aroha registry.
5
+ * Identity is verified by TWO independent channels simultaneously:
6
+ *
7
+ * 1. HTTPS — DID Document served at:
8
+ * https://{domain}/.well-known/aroha/agents/{path}/did.json
9
+ *
10
+ * 2. DNS TXT — Key-commitment record at:
11
+ * _aroha.{domain} IN TXT "v=aroha1; path={path}; keyhash={sha256 of pubkey}"
12
+ *
13
+ * Both channels must agree. Domain hijacking via HTTPS alone is insufficient —
14
+ * the attacker would also need to control DNS to forge the key-commitment.
15
+ * This is a meaningful security upgrade over single-channel HTTPS identity
16
+ * (did:wba, did:web).
17
+ *
18
+ * Key rotation:
19
+ * Rotations are cryptographically chained — the new key is signed by the old
20
+ * key and a 24-hour cool-down window is enforced before the new key is trusted.
21
+ * The rotation record is embedded in the DID Document under `keyHistory`.
22
+ *
23
+ * DID format:
24
+ * did:aroha-web:{domain}[:{path}]
25
+ * e.g. did:aroha-web:stripe.com:payments-agent
26
+ * did:aroha-web:internal.example.corp:invoicing
27
+ *
28
+ * Resolution produces the same DIDDocument format as did:aroha: — full
29
+ * interoperability with the rest of the Aroha stack.
30
+ */
31
+
32
+ import * as ed from "@noble/ed25519";
33
+ import { sha256 } from "@noble/hashes/sha256";
34
+ import { sha512 } from "@noble/hashes/sha512";
35
+ import { bytesToHex, randomBytes } from "@noble/hashes/utils";
36
+ import {
37
+ type DIDDocument,
38
+ type AgentKeyPair,
39
+ type VerificationMethod,
40
+ type KeyRotationRecord,
41
+ toMultibase,
42
+ fromMultibase,
43
+ } from "./did.js";
44
+
45
+ ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
46
+
47
+ // ─── Types ────────────────────────────────────────────────────────────────────
48
+
49
+ export interface WebDIDDocument extends DIDDocument {
50
+ /** Two-factor verification metadata. */
51
+ arohaWeb: {
52
+ domain: string;
53
+ path: string;
54
+ /** SHA-256 hex of the active public key — must match DNS TXT. */
55
+ keyCommitmentHash: string;
56
+ /** Rotation chain (newest-first). Null if this is the genesis key. */
57
+ keyHistory: KeyRotationRecord[];
58
+ };
59
+ }
60
+
61
+ export { type KeyRotationRecord } from "./did.js";
62
+
63
+ export interface WebAgentKeyPair extends AgentKeyPair {
64
+ /** The DNS TXT record value to publish at _aroha.{domain}. */
65
+ dnsTXTRecord: string;
66
+ /** The URL where the DID Document should be served. */
67
+ wellKnownUrl: string;
68
+ /** The full typed document (superset of DIDDocument). */
69
+ webDocument: WebDIDDocument;
70
+ }
71
+
72
+ export interface WebDIDResolutionResult {
73
+ document: WebDIDDocument;
74
+ /** True if DNS TXT key-commitment matched the document. */
75
+ dnsVerified: boolean;
76
+ /** True if the active key's cool-down window has passed. */
77
+ keyTrusted: boolean;
78
+ /** Reason if keyTrusted is false. */
79
+ trustReason?: string;
80
+ }
81
+
82
+ // ─── DID parsing ──────────────────────────────────────────────────────────────
83
+
84
+ /** Parse `did:aroha-web:domain[:path]` into its components. */
85
+ export function parseWebDID(did: string): { domain: string; path: string } {
86
+ const prefix = "did:aroha-web:";
87
+ if (!did.startsWith(prefix)) {
88
+ throw new Error(`Not a did:aroha-web: identifier: ${did}`);
89
+ }
90
+ const rest = did.slice(prefix.length);
91
+ const colonIdx = rest.indexOf(":");
92
+ if (colonIdx === -1) {
93
+ return { domain: rest, path: "agent" };
94
+ }
95
+ return {
96
+ domain: rest.slice(0, colonIdx),
97
+ path: rest.slice(colonIdx + 1).replace(/:/g, "/"),
98
+ };
99
+ }
100
+
101
+ export function buildWebDID(domain: string, path: string): string {
102
+ const normalised = path.replace(/\//g, ":");
103
+ return `did:aroha-web:${domain}:${normalised}`;
104
+ }
105
+
106
+ export function isWebDID(did: string): boolean {
107
+ return did.startsWith("did:aroha-web:");
108
+ }
109
+
110
+ // ─── Well-known path ──────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * The HTTP path at which the DID Document should be served.
114
+ * e.g. `did:aroha-web:stripe.com:payments` → `/.well-known/aroha/agents/payments/did.json`
115
+ */
116
+ export function wellKnownPath(did: string): string {
117
+ const { path } = parseWebDID(did);
118
+ return `/.well-known/aroha/agents/${path}/did.json`;
119
+ }
120
+
121
+ /**
122
+ * The full HTTPS URL for a given did:aroha-web: identifier.
123
+ */
124
+ export function wellKnownUrl(did: string): string {
125
+ const { domain, path } = parseWebDID(did);
126
+ return `https://${domain}/.well-known/aroha/agents/${path}/did.json`;
127
+ }
128
+
129
+ // ─── DNS TXT record ───────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Returns the DNS TXT record VALUE (not the full DNS record) for the
133
+ * key-commitment. Publish at: `_aroha.{domain} IN TXT "{this value}"`
134
+ *
135
+ * Format: `v=aroha1; path={path}; keyhash={sha256hex}`
136
+ */
137
+ export function dnsTXTValue(did: string, publicKey: Uint8Array): string {
138
+ const { path } = parseWebDID(did);
139
+ const keyhash = bytesToHex(sha256(publicKey));
140
+ return `v=aroha1; path=${path}; keyhash=${keyhash}`;
141
+ }
142
+
143
+ /** Parse a DNS TXT value back to its fields. Returns null if invalid. */
144
+ export function parseDNSTXT(txt: string): { path: string; keyhash: string } | null {
145
+ if (!txt.includes("v=aroha1")) return null;
146
+ const fields: Record<string, string> = {};
147
+ for (const part of txt.split(";")) {
148
+ const eq = part.indexOf("=");
149
+ if (eq === -1) continue;
150
+ fields[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
151
+ }
152
+ if (!fields.path || !fields.keyhash) return null;
153
+ return { path: fields.path, keyhash: fields.keyhash };
154
+ }
155
+
156
+ // ─── Key commitment hash ──────────────────────────────────────────────────────
157
+
158
+ export function keyCommitmentHash(publicKey: Uint8Array): string {
159
+ return bytesToHex(sha256(publicKey));
160
+ }
161
+
162
+ // ─── Generate ─────────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Generate a new did:aroha-web: identity.
166
+ *
167
+ * Returns the key pair, the DID Document to serve at the well-known path,
168
+ * the DNS TXT value to publish, and the well-known URL.
169
+ *
170
+ * @param domain The company domain (e.g. "stripe.com")
171
+ * @param path Agent identifier within the domain (e.g. "payments-agent")
172
+ * @param endpoint The Aroha HTTP endpoint URL (e.g. "https://agents.stripe.com/aroha/v1")
173
+ */
174
+ export async function generateWebAgent(
175
+ domain: string,
176
+ path: string,
177
+ endpoint?: string
178
+ ): Promise<WebAgentKeyPair> {
179
+ const privateKey = ed.utils.randomPrivateKey();
180
+ const publicKey = await ed.getPublicKeyAsync(privateKey);
181
+
182
+ const did = buildWebDID(domain, path);
183
+ const keyId = `${did}#key-1`;
184
+ const now = new Date().toISOString();
185
+ const agentEndpoint = endpoint ?? `https://${domain}/aroha/v1`;
186
+
187
+ const webDoc: WebDIDDocument = {
188
+ "@context": [
189
+ "https://www.w3.org/ns/did/v1",
190
+ "https://w3id.org/security/suites/ed25519-2020/v1",
191
+ "https://aroha-labs.com/contexts/aroha-web/v1",
192
+ ],
193
+ id: did,
194
+ verificationMethod: [
195
+ {
196
+ id: keyId,
197
+ type: "Ed25519VerificationKey2020",
198
+ controller: did,
199
+ publicKeyMultibase: toMultibase(publicKey),
200
+ },
201
+ ],
202
+ authentication: [keyId],
203
+ assertionMethod: [keyId],
204
+ keyAgreement: [keyId],
205
+ service: [
206
+ {
207
+ id: `${did}#aroha`,
208
+ type: "ArohaEndpoint",
209
+ serviceEndpoint: agentEndpoint,
210
+ },
211
+ ],
212
+ created: now,
213
+ updated: now,
214
+ arohaWeb: {
215
+ domain,
216
+ path,
217
+ keyCommitmentHash: keyCommitmentHash(publicKey),
218
+ keyHistory: [],
219
+ },
220
+ };
221
+
222
+ const txt = dnsTXTValue(did, publicKey);
223
+ const url = wellKnownUrl(did);
224
+
225
+ return {
226
+ did,
227
+ privateKey,
228
+ publicKey,
229
+ document: webDoc,
230
+ webDocument: webDoc,
231
+ dnsTXTRecord: txt,
232
+ wellKnownUrl: url,
233
+ };
234
+ }
235
+
236
+ // ─── Key rotation ──────────────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Rotate to a new key pair. The old key signs a rotation record that is
240
+ * embedded in the new DID Document. The new key is not trusted until
241
+ * `coolDownHours` (default 24h) after the rotation is announced.
242
+ *
243
+ * Publish the returned `webDocument` at the well-known URL and update the
244
+ * DNS TXT record with `dnsTXTRecord` to complete the rotation.
245
+ */
246
+ export async function rotateKey(
247
+ current: WebAgentKeyPair,
248
+ coolDownHours = 24
249
+ ): Promise<WebAgentKeyPair> {
250
+ const newPrivateKey = ed.utils.randomPrivateKey();
251
+ const newPublicKey = await ed.getPublicKeyAsync(newPrivateKey);
252
+
253
+ const oldPubKeyMultibase = toMultibase(current.publicKey);
254
+ const now = new Date();
255
+ const trustAfter = new Date(now.getTime() + coolDownHours * 3_600_000);
256
+
257
+ // Rotation payload: predecessorKey ∥ newKeyHash ∥ rotatedAt
258
+ const newKeyHash = keyCommitmentHash(newPublicKey);
259
+ const payload = new TextEncoder().encode(
260
+ oldPubKeyMultibase + newKeyHash + now.toISOString()
261
+ );
262
+ const sig = await ed.signAsync(payload, current.privateKey);
263
+
264
+ const rotation: KeyRotationRecord = {
265
+ rotatedAt: now.toISOString(),
266
+ trustAfter: trustAfter.toISOString(),
267
+ predecessorKey: oldPubKeyMultibase,
268
+ newKeyHash,
269
+ predecessorSignature: Buffer.from(sig).toString("base64url"),
270
+ };
271
+
272
+ const { domain, path } = current.webDocument.arohaWeb;
273
+ const did = buildWebDID(domain, path);
274
+ const keyId = `${did}#key-1`;
275
+ const ts = now.toISOString();
276
+
277
+ const newDoc: WebDIDDocument = {
278
+ ...current.webDocument,
279
+ verificationMethod: [
280
+ {
281
+ id: keyId,
282
+ type: "Ed25519VerificationKey2020",
283
+ controller: did,
284
+ publicKeyMultibase: toMultibase(newPublicKey),
285
+ },
286
+ ],
287
+ updated: ts,
288
+ arohaWeb: {
289
+ ...current.webDocument.arohaWeb,
290
+ keyCommitmentHash: keyCommitmentHash(newPublicKey),
291
+ keyHistory: [rotation, ...current.webDocument.arohaWeb.keyHistory],
292
+ },
293
+ };
294
+
295
+ return {
296
+ did,
297
+ privateKey: newPrivateKey,
298
+ publicKey: newPublicKey,
299
+ document: newDoc,
300
+ webDocument: newDoc,
301
+ dnsTXTRecord: dnsTXTValue(did, newPublicKey),
302
+ wellKnownUrl: wellKnownUrl(did),
303
+ };
304
+ }
305
+
306
+ // ─── Resolve ──────────────────────────────────────────────────────────────────
307
+
308
+ /**
309
+ * Resolve a did:aroha-web: identifier.
310
+ *
311
+ * Fetches the DID Document over HTTPS and, if `verifyDNS` is true,
312
+ * also verifies the DNS TXT key-commitment. Both must match for full
313
+ * two-factor verification.
314
+ *
315
+ * In Node.js server environments, pass a custom `dnsResolver` to perform
316
+ * the DNS TXT lookup. In browser or edge environments, set `verifyDNS: false`
317
+ * (single-channel HTTPS-only, same security as did:wba).
318
+ *
319
+ * @param did The did:aroha-web: identifier to resolve
320
+ * @param verifyDNS Whether to check the DNS TXT record (default: true)
321
+ * @param dnsResolver Optional custom DNS TXT resolver
322
+ */
323
+ export async function resolveWebDID(
324
+ did: string,
325
+ verifyDNS = true,
326
+ dnsResolver?: (domain: string) => Promise<string[]>,
327
+ fetchTimeoutMs = 10_000
328
+ ): Promise<WebDIDResolutionResult> {
329
+ const url = wellKnownUrl(did);
330
+
331
+ // ── Step 1: Fetch DID Document over HTTPS ─────────────────────────────────
332
+ const ctrl = new AbortController();
333
+ const timer = setTimeout(() => ctrl.abort(), fetchTimeoutMs);
334
+ let res: Response;
335
+ try {
336
+ res = await fetch(url, {
337
+ headers: { Accept: "application/json" },
338
+ signal: ctrl.signal,
339
+ });
340
+ } finally {
341
+ clearTimeout(timer);
342
+ }
343
+ if (!res.ok) {
344
+ throw new Error(`Failed to resolve ${did}: HTTP ${res.status} from ${url}`);
345
+ }
346
+ const doc = await res.json() as WebDIDDocument;
347
+
348
+ if (doc.id !== did) {
349
+ throw new Error(`DID mismatch: document claims ${doc.id}, expected ${did}`);
350
+ }
351
+
352
+ const activeKey = fromMultibase(doc.verificationMethod[0].publicKeyMultibase);
353
+
354
+ // ── Step 2: Verify all rotation records in keyHistory ────────────────────
355
+ if (doc.arohaWeb?.keyHistory?.length > 0) {
356
+ for (const rotation of doc.arohaWeb.keyHistory) {
357
+ const rotationValid = await verifyKeyRotation(rotation);
358
+ if (!rotationValid) {
359
+ throw new Error(
360
+ `DID ${did}: key rotation record (rotatedAt ${rotation.rotatedAt}) has invalid predecessor signature`
361
+ );
362
+ }
363
+ }
364
+ }
365
+
366
+ // ── Step 4: Check key rotation cool-down ─────────────────────────────────
367
+ let keyTrusted = true;
368
+ let trustReason: string | undefined;
369
+ if (doc.arohaWeb?.keyHistory?.length > 0) {
370
+ const latestRotation = doc.arohaWeb.keyHistory[0];
371
+ const trustAfter = new Date(latestRotation.trustAfter);
372
+ if (new Date() < trustAfter) {
373
+ keyTrusted = false;
374
+ trustReason = `Key rotation cool-down active until ${latestRotation.trustAfter}`;
375
+ }
376
+ }
377
+
378
+ // ── Step 5: DNS TXT key-commitment verification ────────────────────────────
379
+ let dnsVerified = false;
380
+ if (verifyDNS) {
381
+ const { domain, path } = parseWebDID(did);
382
+ let txtRecords: string[] = [];
383
+ if (dnsResolver) {
384
+ txtRecords = await dnsResolver(domain).catch(() => []);
385
+ } else if (typeof (globalThis as Record<string, unknown>)["Deno"] !== "undefined") {
386
+ // Deno DNS API
387
+ try {
388
+ const denoGlobal = (globalThis as unknown as { Deno: { resolveDns(n: string, t: string): Promise<string[][]> } }).Deno;
389
+ const records = await denoGlobal
390
+ .resolveDns(`_aroha.${domain}`, "TXT");
391
+ txtRecords = records.flat();
392
+ } catch { /* DNS unavailable — fall back to HTTPS-only */ }
393
+ }
394
+ // Node.js: caller must pass dnsResolver (avoids bundling dns module in core)
395
+
396
+ for (const txt of txtRecords) {
397
+ const parsed = parseDNSTXT(txt);
398
+ if (!parsed) continue;
399
+ if (parsed.path !== path) continue;
400
+ const expectedHash = keyCommitmentHash(activeKey);
401
+ if (parsed.keyhash === expectedHash) {
402
+ dnsVerified = true;
403
+ break;
404
+ }
405
+ }
406
+ }
407
+
408
+ return { document: doc, dnsVerified, keyTrusted, trustReason };
409
+ }
410
+
411
+ /**
412
+ * Verify a key rotation record — confirms the predecessor key signed the
413
+ * rotation event voluntarily (prevents unauthorized key replacement).
414
+ */
415
+ export async function verifyKeyRotation(rotation: KeyRotationRecord): Promise<boolean> {
416
+ try {
417
+ const predKey = fromMultibase(rotation.predecessorKey);
418
+ const newHash = rotation.newKeyHash;
419
+ const payload = new TextEncoder().encode(
420
+ rotation.predecessorKey + newHash + rotation.rotatedAt
421
+ );
422
+ const sig = Buffer.from(rotation.predecessorSignature, "base64url");
423
+ return await ed.verifyAsync(sig, payload, predKey);
424
+ } catch {
425
+ return false;
426
+ }
427
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @aroha-sdk/core — Aroha Protocol Layers 0–3
3
+ *
4
+ * Layer 0: Transport → ArohaServer, ArohaClient
5
+ * Layer 1: Identity → generateDID, DIDDocument, issueCredential, verifyCredential
6
+ * Auth → issueCredential, verifyCredential, ArohaRole, RbacPolicy, checkPermission
7
+ * Layer 3: Messaging → buildEnvelope, validateEnvelope, ArohaEnvelope, all message types
8
+ * Crypto: → signMessage, verifyMessageSignature, encryptBody, decryptBody
9
+ */
10
+
11
+ export * from "./identity/index.js";
12
+ export * from "./crypto/index.js";
13
+ export * from "./messages/index.js";
14
+ export * from "./transport/index.js";