@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,160 @@
1
+ /**
2
+ * Aroha Protocol — Layer 3 Encryption
3
+ *
4
+ * Implements the spec rule:
5
+ * "For sensitive body fields, senders SHOULD encrypt using DIDComm v2 JWE
6
+ * with the recipient's public key."
7
+ *
8
+ * Uses X25519 ECDH for key agreement + AES-256-GCM for content encryption.
9
+ * This is a protocol-level primitive. It does not know what is being encrypted
10
+ * or why — that is the application's concern.
11
+ */
12
+
13
+ import { x25519, edwardsToMontgomeryPub, edwardsToMontgomeryPriv } from "@noble/curves/ed25519";
14
+ import { randomBytes, concatBytes } from "@noble/hashes/utils";
15
+ import { sha256 } from "@noble/hashes/sha256";
16
+
17
+ // ─── Types ────────────────────────────────────────────────────────────────────
18
+
19
+ export interface EncryptedBody {
20
+ /** Algorithm identifier */
21
+ alg: "ECDH-ES+A256GCM";
22
+ /** Base64url-encoded ephemeral X25519 public key */
23
+ epk: string;
24
+ /** Base64url-encoded IV (12 bytes for AES-GCM) */
25
+ iv: string;
26
+ /** Base64url-encoded ciphertext */
27
+ ciphertext: string;
28
+ /** Base64url-encoded AES-GCM authentication tag (16 bytes) */
29
+ tag: string;
30
+ }
31
+
32
+ // ─── Encrypt ──────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Encrypt a message body for a specific recipient.
36
+ *
37
+ * @param plaintext The body object to encrypt (will be JSON-serialized)
38
+ * @param recipientPubKey Recipient's Ed25519 public key (32 bytes).
39
+ * We convert it to X25519 for ECDH.
40
+ */
41
+ export async function encryptBody(
42
+ plaintext: Record<string, unknown>,
43
+ recipientPubKey: Uint8Array
44
+ ): Promise<EncryptedBody> {
45
+ // Convert Ed25519 pubkey to X25519 for key exchange
46
+ const recipientX25519 = ed25519PubKeyToX25519(recipientPubKey);
47
+
48
+ // Generate ephemeral X25519 keypair
49
+ const ephemeralPriv = x25519.utils.randomPrivateKey();
50
+ const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
51
+
52
+ // ECDH shared secret
53
+ const sharedSecret = x25519.getSharedSecret(ephemeralPriv, recipientX25519);
54
+
55
+ // Derive AES-256-GCM key from shared secret via SHA-256
56
+ const aesKey = sha256(sharedSecret);
57
+
58
+ // Encrypt with AES-256-GCM
59
+ const iv = randomBytes(12);
60
+ const plaintextBytes = new TextEncoder().encode(JSON.stringify(plaintext));
61
+ const { ciphertext, tag } = await aesGcmEncrypt(aesKey, iv, plaintextBytes);
62
+
63
+ return {
64
+ alg: "ECDH-ES+A256GCM",
65
+ epk: Buffer.from(ephemeralPub).toString("base64url"),
66
+ iv: Buffer.from(iv).toString("base64url"),
67
+ ciphertext: Buffer.from(ciphertext).toString("base64url"),
68
+ tag: Buffer.from(tag).toString("base64url"),
69
+ };
70
+ }
71
+
72
+ // ─── Decrypt ──────────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Decrypt an encrypted message body.
76
+ *
77
+ * @param encrypted The EncryptedBody from the message
78
+ * @param privateKey Recipient's Ed25519 private key (32 bytes)
79
+ */
80
+ export async function decryptBody(
81
+ encrypted: EncryptedBody,
82
+ privateKey: Uint8Array
83
+ ): Promise<Record<string, unknown>> {
84
+ // Convert Ed25519 private key to X25519
85
+ const x25519Priv = ed25519PrivKeyToX25519(privateKey);
86
+
87
+ const ephemeralPub = Buffer.from(encrypted.epk, "base64url");
88
+ const sharedSecret = x25519.getSharedSecret(x25519Priv, ephemeralPub);
89
+ const aesKey = sha256(sharedSecret);
90
+
91
+ const iv = Buffer.from(encrypted.iv, "base64url");
92
+ const ciphertext = Buffer.from(encrypted.ciphertext, "base64url");
93
+ const tag = Buffer.from(encrypted.tag, "base64url");
94
+
95
+ const plaintext = await aesGcmDecrypt(aesKey, iv, ciphertext, tag);
96
+ return JSON.parse(new TextDecoder().decode(plaintext));
97
+ }
98
+
99
+ // ─── Key Conversion ───────────────────────────────────────────────────────────
100
+ // Ed25519 keys can be converted to X25519 (Curve25519) via the birational map
101
+ // between the two curve models. @noble/curves implements the correct conversion.
102
+
103
+ function ed25519PubKeyToX25519(edPub: Uint8Array): Uint8Array {
104
+ return edwardsToMontgomeryPub(edPub);
105
+ }
106
+
107
+ function ed25519PrivKeyToX25519(edPriv: Uint8Array): Uint8Array {
108
+ return edwardsToMontgomeryPriv(edPriv);
109
+ }
110
+
111
+ // ─── AES-256-GCM (Web Crypto API) ────────────────────────────────────────────
112
+
113
+ async function aesGcmEncrypt(
114
+ key: Uint8Array,
115
+ iv: Uint8Array,
116
+ plaintext: Uint8Array
117
+ ): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {
118
+ const cryptoKey = await crypto.subtle.importKey(
119
+ "raw",
120
+ key,
121
+ { name: "AES-GCM" },
122
+ false,
123
+ ["encrypt"]
124
+ );
125
+
126
+ const encrypted = await crypto.subtle.encrypt(
127
+ { name: "AES-GCM", iv, tagLength: 128 },
128
+ cryptoKey,
129
+ plaintext
130
+ );
131
+
132
+ const buf = new Uint8Array(encrypted);
133
+ const ciphertext = buf.slice(0, buf.length - 16);
134
+ const tag = buf.slice(buf.length - 16);
135
+ return { ciphertext, tag };
136
+ }
137
+
138
+ async function aesGcmDecrypt(
139
+ key: Uint8Array,
140
+ iv: Uint8Array,
141
+ ciphertext: Uint8Array,
142
+ tag: Uint8Array
143
+ ): Promise<Uint8Array> {
144
+ const cryptoKey = await crypto.subtle.importKey(
145
+ "raw",
146
+ key,
147
+ { name: "AES-GCM" },
148
+ false,
149
+ ["decrypt"]
150
+ );
151
+
152
+ const combined = concatBytes(ciphertext, tag);
153
+ const decrypted = await crypto.subtle.decrypt(
154
+ { name: "AES-GCM", iv, tagLength: 128 },
155
+ cryptoKey,
156
+ combined
157
+ );
158
+
159
+ return new Uint8Array(decrypted);
160
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./signing.js";
2
+ export * from "./encryption.js";
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as ed from "@noble/ed25519";
3
+ import { sha512 } from "@noble/hashes/sha512";
4
+ import { signMessage, verifyMessageSignature, canonicalizeMessage } from "./signing.js";
5
+
6
+ ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
7
+
8
+ async function makeKeyPair() {
9
+ const priv = ed.utils.randomPrivateKey();
10
+ const pub = await ed.getPublicKeyAsync(priv);
11
+ return { priv, pub };
12
+ }
13
+
14
+ describe("canonicalizeMessage", () => {
15
+ it("excludes the proof field", () => {
16
+ const msg = { a: 1, proof: { proofValue: "sig" }, b: 2 };
17
+ const canon = canonicalizeMessage(msg);
18
+ expect(JSON.parse(canon)).not.toHaveProperty("proof");
19
+ });
20
+
21
+ it("sorts keys lexicographically", () => {
22
+ const msg = { z: 3, a: 1, m: 2 };
23
+ const canon = canonicalizeMessage(msg);
24
+ expect(canon).toBe(JSON.stringify({ a: 1, m: 2, z: 3 }));
25
+ });
26
+
27
+ it("sorts nested object keys", () => {
28
+ const msg = { outer: { z: 1, a: 2 } };
29
+ const canon = canonicalizeMessage(msg);
30
+ expect(JSON.parse(canon).outer).toEqual({ a: 2, z: 1 });
31
+ });
32
+
33
+ it("preserves arrays", () => {
34
+ const msg = { list: [3, 1, 2] };
35
+ const canon = canonicalizeMessage(msg);
36
+ expect(JSON.parse(canon).list).toEqual([3, 1, 2]);
37
+ });
38
+ });
39
+
40
+ describe("signMessage / verifyMessageSignature", () => {
41
+ it("produces a valid signature that verifies", async () => {
42
+ const { priv, pub } = await makeKeyPair();
43
+ const msg = { id: "test-msg", body: { hello: "world" } };
44
+ const proof = await signMessage(msg, priv, "did:aroha:test#key-1");
45
+
46
+ expect(proof.type).toBe("Ed25519Signature2020");
47
+ expect(proof.proofPurpose).toBe("authentication");
48
+ expect(proof.verificationMethod).toBe("did:aroha:test#key-1");
49
+ expect(proof.proofValue).toBeTruthy();
50
+
51
+ const valid = await verifyMessageSignature({ ...msg, proof }, pub);
52
+ expect(valid).toBe(true);
53
+ });
54
+
55
+ it("rejects a tampered message", async () => {
56
+ const { priv, pub } = await makeKeyPair();
57
+ const msg = { id: "test-msg", body: { hello: "world" } };
58
+ const proof = await signMessage(msg, priv, "did:aroha:test#key-1");
59
+
60
+ const tampered = { id: "TAMPERED", body: { hello: "world" }, proof };
61
+ const valid = await verifyMessageSignature(tampered, pub);
62
+ expect(valid).toBe(false);
63
+ });
64
+
65
+ it("rejects a message with wrong public key", async () => {
66
+ const { priv } = await makeKeyPair();
67
+ const { pub: wrongPub } = await makeKeyPair();
68
+ const msg = { id: "test-msg" };
69
+ const proof = await signMessage(msg, priv, "did:aroha:test#key-1");
70
+ const valid = await verifyMessageSignature({ ...msg, proof }, wrongPub);
71
+ expect(valid).toBe(false);
72
+ });
73
+
74
+ it("returns false when proof is missing", async () => {
75
+ const { pub } = await makeKeyPair();
76
+ const valid = await verifyMessageSignature({ id: "no-proof" }, pub);
77
+ expect(valid).toBe(false);
78
+ });
79
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Aroha Protocol — Layer 3 Signing
3
+ *
4
+ * Implements the signing rule from the spec:
5
+ * "The proof.proofValue MUST be the Ed25519 signature over the canonical
6
+ * JSON of the message with the proof field omitted, keys sorted
7
+ * lexicographically."
8
+ *
9
+ * This module is pure protocol infrastructure. It knows nothing about
10
+ * what the message body contains or what the agent does with it.
11
+ */
12
+
13
+ import * as ed from "@noble/ed25519";
14
+ import { sha512 } from "@noble/hashes/sha512";
15
+
16
+ ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
17
+
18
+ // ─── Types ────────────────────────────────────────────────────────────────────
19
+
20
+ export interface MessageProof {
21
+ type: "Ed25519Signature2020";
22
+ created: string;
23
+ verificationMethod: string; // did:aroha:<id>#key-1
24
+ proofPurpose: "authentication";
25
+ proofValue: string; // base64url-encoded Ed25519 signature
26
+ }
27
+
28
+ // ─── Signing ──────────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Sign a Aroha message.
32
+ * Returns a proof object to embed in the message envelope.
33
+ *
34
+ * @param message Full message object (proof field will be excluded before signing)
35
+ * @param privateKey Ed25519 private key (32 bytes)
36
+ * @param verificationMethodId e.g. "did:aroha:my-agent#key-1"
37
+ */
38
+ export async function signMessage(
39
+ message: Record<string, unknown>,
40
+ privateKey: Uint8Array,
41
+ verificationMethodId: string
42
+ ): Promise<MessageProof> {
43
+ const canonical = canonicalizeMessage(message);
44
+ const bytes = new TextEncoder().encode(canonical);
45
+ const signature = await ed.signAsync(bytes, privateKey);
46
+
47
+ return {
48
+ type: "Ed25519Signature2020",
49
+ created: new Date().toISOString(),
50
+ verificationMethod: verificationMethodId,
51
+ proofPurpose: "authentication",
52
+ proofValue: Buffer.from(signature).toString("base64url"),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Verify a Aroha message signature.
58
+ *
59
+ * @param message Full message object including the proof field
60
+ * @param publicKey Ed25519 public key (32 bytes) of the claimed sender
61
+ * @returns true if signature is valid, false otherwise
62
+ */
63
+ export async function verifyMessageSignature(
64
+ message: Record<string, unknown>,
65
+ publicKey: Uint8Array
66
+ ): Promise<boolean> {
67
+ const proof = message.proof as MessageProof | undefined;
68
+ if (!proof?.proofValue) return false;
69
+
70
+ const canonical = canonicalizeMessage(message); // excludes proof
71
+ const bytes = new TextEncoder().encode(canonical);
72
+
73
+ try {
74
+ const signature = Buffer.from(proof.proofValue, "base64url");
75
+ return await ed.verifyAsync(signature, bytes, publicKey);
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ // ─── Canonicalization ─────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Produce the canonical JSON string of a message for signing/verification.
85
+ *
86
+ * Per spec: exclude the "proof" field, sort all keys lexicographically
87
+ * at every level of nesting.
88
+ */
89
+ export function canonicalizeMessage(message: Record<string, unknown>): string {
90
+ const { proof: _excluded, ...rest } = message;
91
+ return JSON.stringify(sortKeysDeep(rest));
92
+ }
93
+
94
+ function sortKeysDeep(value: unknown): unknown {
95
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
96
+ if (value !== null && typeof value === "object") {
97
+ const sorted: Record<string, unknown> = {};
98
+ for (const key of Object.keys(value as object).sort()) {
99
+ sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
100
+ }
101
+ return sorted;
102
+ }
103
+ return value;
104
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Aroha Verifiable Credentials (VCs)
3
+ *
4
+ * Based on W3C Verifiable Credentials Data Model 2.0.
5
+ * VCs allow agents to prove claims about themselves (corporate identity,
6
+ * compliance certifications, capability authorization) without revealing
7
+ * more than necessary (selective disclosure via ZKP proofs).
8
+ *
9
+ * Issuance flow:
10
+ * Trust Anchor (e.g. Expedia Corp) issues a VC to its agent.
11
+ * The VC is signed with the issuer's private key.
12
+ * The agent presents the VC during negotiation.
13
+ * The counterpart verifies the VC signature.
14
+ */
15
+
16
+ import * as ed from "@noble/ed25519";
17
+ import { sha512 } from "@noble/hashes/sha512";
18
+ import { v4 as uuidv4 } from "uuid";
19
+
20
+ ed.etc.sha512Sync = (...m: Parameters<typeof sha512>) => sha512(...m);
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────────
23
+
24
+ export type TrustLevel = 0 | 1 | 2 | 3 | 4;
25
+
26
+ export const TRUST_LEVEL_DESCRIPTIONS: Record<TrustLevel, string> = {
27
+ 0: "Public — read capability manifests only",
28
+ 1: "Registered — on-chain registration + stake",
29
+ 2: "Verified — VC from recognized trust anchor",
30
+ 3: "Reputable — reputation score >= 0.8",
31
+ 4: "User-trusted — explicit user authorization",
32
+ };
33
+
34
+ export interface CredentialSubject {
35
+ id: string; // DID of the subject (the agent being described)
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export interface VerifiableCredential {
40
+ "@context": string[];
41
+ id: string;
42
+ type: string[];
43
+ issuer: string; // DID of the issuer (e.g. did:aroha:expedia-corp)
44
+ issuanceDate: string;
45
+ expirationDate?: string;
46
+ credentialSubject: CredentialSubject;
47
+ proof?: VCProof;
48
+ }
49
+
50
+ export interface VCProof {
51
+ type: "Ed25519Signature2020";
52
+ created: string;
53
+ verificationMethod: string; // issuer DID + key fragment
54
+ proofPurpose: "assertionMethod";
55
+ proofValue: string; // base64url-encoded signature
56
+ }
57
+
58
+ // ─── Standard Aroha credential types ──────────────────────────────────────────
59
+
60
+ /** Issued by a company to vouch that an agent is operated by them. */
61
+ export interface CorporateIdentityCredential extends VerifiableCredential {
62
+ credentialSubject: {
63
+ id: string;
64
+ companyName: string;
65
+ companyDomain: string;
66
+ registeredCountry: string;
67
+ };
68
+ }
69
+
70
+ /** Issued by a compliance body (e.g. PCI-DSS, HIPAA auditor). */
71
+ export interface ComplianceCertificationCredential extends VerifiableCredential {
72
+ credentialSubject: {
73
+ id: string;
74
+ standard: string; // "PCI-DSS-L1" | "HIPAA" | "SOC2-T2" | ...
75
+ certifiedUntil: string;
76
+ scope: string;
77
+ };
78
+ }
79
+
80
+ /** Issued by a platform operator to authorize an agent to act. */
81
+ export interface CapabilityAuthorizationCredential extends VerifiableCredential {
82
+ credentialSubject: {
83
+ id: string;
84
+ authorizedCapabilities: string[];
85
+ maxTransactionValue?: { amount: number; currency: string };
86
+ };
87
+ }
88
+
89
+ // ─── Issue / Verify ───────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Issue a Verifiable Credential.
93
+ * The issuer signs the credential's canonical JSON with their Ed25519 key.
94
+ */
95
+ export async function issueCredential(
96
+ credential: Omit<VerifiableCredential, "proof" | "id">,
97
+ issuerPrivateKey: Uint8Array,
98
+ issuerKeyId: string
99
+ ): Promise<VerifiableCredential> {
100
+ const id = `urn:uuid:${uuidv4()}`;
101
+ const unsigned: VerifiableCredential = { ...credential, id };
102
+
103
+ const payload = JSON.stringify(canonicalize(unsigned));
104
+ const payloadBytes = new TextEncoder().encode(payload);
105
+
106
+ const signature = await ed.signAsync(payloadBytes, issuerPrivateKey);
107
+ const proofValue = Buffer.from(signature).toString("base64url");
108
+
109
+ const proof: VCProof = {
110
+ type: "Ed25519Signature2020",
111
+ created: new Date().toISOString(),
112
+ verificationMethod: issuerKeyId,
113
+ proofPurpose: "assertionMethod",
114
+ proofValue,
115
+ };
116
+
117
+ return { ...unsigned, proof };
118
+ }
119
+
120
+ /**
121
+ * Verify a Verifiable Credential's signature.
122
+ * Returns true if the signature is valid.
123
+ */
124
+ export async function verifyCredential(
125
+ credential: VerifiableCredential,
126
+ issuerPublicKey: Uint8Array
127
+ ): Promise<boolean> {
128
+ if (!credential.proof) return false;
129
+
130
+ const { proof, ...unsigned } = credential;
131
+ const payload = JSON.stringify(canonicalize(unsigned as VerifiableCredential));
132
+ const payloadBytes = new TextEncoder().encode(payload);
133
+
134
+ const signature = Buffer.from(proof.proofValue, "base64url");
135
+ return ed.verifyAsync(signature, payloadBytes, issuerPublicKey);
136
+ }
137
+
138
+ /**
139
+ * Check if a credential is expired.
140
+ */
141
+ export function isCredentialExpired(credential: VerifiableCredential): boolean {
142
+ if (!credential.expirationDate) return false;
143
+ return new Date(credential.expirationDate) < new Date();
144
+ }
145
+
146
+ // ─── Deterministic JSON (canonical form for signing) ──────────────────────────
147
+
148
+ function canonicalize(obj: unknown): unknown {
149
+ if (Array.isArray(obj)) return obj.map(canonicalize);
150
+ if (obj !== null && typeof obj === "object") {
151
+ return Object.keys(obj as object)
152
+ .sort()
153
+ .reduce(
154
+ (acc, key) => {
155
+ (acc as Record<string, unknown>)[key] = canonicalize(
156
+ (obj as Record<string, unknown>)[key]
157
+ );
158
+ return acc;
159
+ },
160
+ {} as Record<string, unknown>
161
+ );
162
+ }
163
+ return obj;
164
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * DID Resolution Cache
3
+ *
4
+ * Wraps any PublicKeyResolver with an in-memory cache.
5
+ *
6
+ * Cache strategy by DID method:
7
+ * did:aroha: Permanent — the public key IS the DID (base58 of key bytes).
8
+ * The key can never change without changing the DID itself.
9
+ * did:aroha-web: TTL-based — keys rotate with a 24-hour cooldown per spec.
10
+ * Default TTL: 23h to stay just inside the rotation window.
11
+ * Other methods: TTL-based with configurable default.
12
+ *
13
+ * At 1000 messages/second resolving 20 known agents, this eliminates
14
+ * ~50,000 redundant base58 decodes and ~1,000 DNS+HTTPS fetches per second.
15
+ */
16
+
17
+ export interface DidCacheConfig {
18
+ /** TTL for did:aroha-web: entries in ms. Default: 82_800_000 (23 hours) */
19
+ webDidTtlMs?: number;
20
+ /** TTL for unknown DID methods. Default: 300_000 (5 minutes) */
21
+ defaultTtlMs?: number;
22
+ /** Maximum number of entries before LRU eviction. Default: 1000 */
23
+ maxSize?: number;
24
+ }
25
+
26
+ export type PublicKeyResolver = (senderDID: string) => Promise<Uint8Array | null>;
27
+
28
+ interface CacheEntry {
29
+ key: Uint8Array | null;
30
+ expiresAt: number; // Infinity for permanent entries
31
+ lastUsed: number;
32
+ }
33
+
34
+ export class DidResolutionCache {
35
+ private readonly webDidTtlMs: number;
36
+ private readonly defaultTtlMs: number;
37
+ private readonly maxSize: number;
38
+ private readonly cache = new Map<string, CacheEntry>();
39
+
40
+ constructor(config: DidCacheConfig = {}) {
41
+ this.webDidTtlMs = config.webDidTtlMs ?? 82_800_000; // 23h
42
+ this.defaultTtlMs = config.defaultTtlMs ?? 300_000; // 5m
43
+ this.maxSize = config.maxSize ?? 1_000;
44
+ }
45
+
46
+ /**
47
+ * Wrap a resolver function with cache semantics.
48
+ * The returned resolver is a drop-in replacement.
49
+ */
50
+ wrap(resolver: PublicKeyResolver): PublicKeyResolver {
51
+ return async (did: string): Promise<Uint8Array | null> => {
52
+ const cached = this.get(did);
53
+ if (cached !== undefined) return cached;
54
+
55
+ const key = await resolver(did);
56
+ this.set(did, key);
57
+ return key;
58
+ };
59
+ }
60
+
61
+ /** Manually populate the cache (e.g. from known agent registry). */
62
+ set(did: string, key: Uint8Array | null): void {
63
+ if (this.cache.size >= this.maxSize) this.evictLru();
64
+
65
+ const ttl = this.ttlFor(did);
66
+ this.cache.set(did, {
67
+ key,
68
+ expiresAt: ttl === Infinity ? Infinity : Date.now() + ttl,
69
+ lastUsed: Date.now(),
70
+ });
71
+ }
72
+
73
+ /** Returns undefined on cache miss or expiry. */
74
+ get(did: string): Uint8Array | null | undefined {
75
+ const entry = this.cache.get(did);
76
+ if (!entry) return undefined;
77
+ if (entry.expiresAt !== Infinity && Date.now() > entry.expiresAt) {
78
+ this.cache.delete(did);
79
+ return undefined;
80
+ }
81
+ entry.lastUsed = Date.now();
82
+ return entry.key;
83
+ }
84
+
85
+ /** Invalidate a specific DID (e.g. after a key rotation event). */
86
+ invalidate(did: string): void {
87
+ this.cache.delete(did);
88
+ }
89
+
90
+ get size(): number {
91
+ return this.cache.size;
92
+ }
93
+
94
+ private ttlFor(did: string): number {
95
+ if (did.startsWith("did:aroha:") && !did.startsWith("did:aroha-web:")) {
96
+ return Infinity; // key is encoded in the DID itself — never rotates
97
+ }
98
+ if (did.startsWith("did:aroha-web:")) {
99
+ return this.webDidTtlMs;
100
+ }
101
+ return this.defaultTtlMs;
102
+ }
103
+
104
+ private evictLru(): void {
105
+ let oldest: [string, CacheEntry] | null = null;
106
+ for (const entry of this.cache.entries()) {
107
+ if (!oldest || entry[1].lastUsed < oldest[1].lastUsed) {
108
+ oldest = entry;
109
+ }
110
+ }
111
+ if (oldest) this.cache.delete(oldest[0]);
112
+ }
113
+ }