@rine-network/core 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 (36) hide show
  1. package/dist/index.js +904 -0
  2. package/dist/src/api-types.d.ts +127 -0
  3. package/dist/src/config.d.ts +20 -0
  4. package/dist/src/crypto/envelope.d.ts +7 -0
  5. package/dist/src/crypto/hpke.d.ts +2 -0
  6. package/dist/src/crypto/index.d.ts +8 -0
  7. package/dist/src/crypto/ingest.d.ts +9 -0
  8. package/dist/src/crypto/keys.d.ts +27 -0
  9. package/dist/src/crypto/message.d.ts +31 -0
  10. package/dist/src/crypto/sender-keys-helpers.d.ts +5 -0
  11. package/dist/src/crypto/sender-keys.d.ts +30 -0
  12. package/dist/src/crypto/sign.d.ts +2 -0
  13. package/dist/src/errors.d.ts +7 -0
  14. package/dist/src/http.d.ts +42 -0
  15. package/dist/src/index.d.ts +10 -0
  16. package/dist/src/resolve-agent.d.ts +17 -0
  17. package/dist/src/resolve-handle.d.ts +10 -0
  18. package/dist/src/sender-key-ops.d.ts +19 -0
  19. package/dist/src/timelock.d.ts +2 -0
  20. package/dist/src/types.d.ts +19 -0
  21. package/dist/test/config.test.d.ts +1 -0
  22. package/dist/test/crypto/envelope.test.d.ts +1 -0
  23. package/dist/test/crypto/hpke.test.d.ts +1 -0
  24. package/dist/test/crypto/ingest.test.d.ts +1 -0
  25. package/dist/test/crypto/integration.test.d.ts +1 -0
  26. package/dist/test/crypto/keys.test.d.ts +1 -0
  27. package/dist/test/crypto/message.test.d.ts +1 -0
  28. package/dist/test/crypto/sender-keys.test.d.ts +1 -0
  29. package/dist/test/crypto/sign.test.d.ts +1 -0
  30. package/dist/test/errors.test.d.ts +1 -0
  31. package/dist/test/http.test.d.ts +1 -0
  32. package/dist/test/resolve-agent.test.d.ts +1 -0
  33. package/dist/test/resolve-handle.test.d.ts +1 -0
  34. package/dist/test/sender-key-ops.test.d.ts +1 -0
  35. package/dist/test/timelock.test.d.ts +1 -0
  36. package/package.json +55 -0
package/dist/index.js ADDED
@@ -0,0 +1,904 @@
1
+ import * as fs from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core";
5
+ import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
6
+ import { ed25519, x25519 } from "@noble/curves/ed25519.js";
7
+ import { hmac } from "@noble/hashes/hmac.js";
8
+ import { sha256 } from "@noble/hashes/sha2.js";
9
+ //#region src/errors.ts
10
+ var RineApiError = class extends Error {
11
+ constructor(status, detail, raw) {
12
+ super(`${status}: ${detail}`);
13
+ this.status = status;
14
+ this.detail = detail;
15
+ this.raw = raw;
16
+ this.name = "RineApiError";
17
+ }
18
+ };
19
+ function formatError(err) {
20
+ if (err instanceof RineApiError) return err.detail;
21
+ if (err instanceof Error) return err.message;
22
+ return String(err);
23
+ }
24
+ //#endregion
25
+ //#region src/config.ts
26
+ const DEFAULT_API_URL = "https://rine.network";
27
+ /** Resolve config directory. Priority: RINE_CONFIG_DIR env > cwd/.rine > ~/.config/rine */
28
+ function resolveConfigDir() {
29
+ if (process.env.RINE_CONFIG_DIR) return process.env.RINE_CONFIG_DIR;
30
+ const local = join(process.cwd(), ".rine");
31
+ if (fs.existsSync(local)) return local;
32
+ return join(homedir(), ".config", "rine");
33
+ }
34
+ /** Resolve API URL from environment or default. */
35
+ function resolveApiUrl() {
36
+ return (process.env.RINE_API_URL ?? "https://rine.network").replace(/\/$/, "");
37
+ }
38
+ function ensureDir(dir) {
39
+ fs.mkdirSync(dir, {
40
+ recursive: true,
41
+ mode: 448
42
+ });
43
+ }
44
+ function writeAtomic(path, content) {
45
+ const tmpPath = `${path}.tmp`;
46
+ fs.writeFileSync(tmpPath, content, "utf-8");
47
+ fs.renameSync(tmpPath, path);
48
+ fs.chmodSync(path, 384);
49
+ }
50
+ function loadCredentials(configDir) {
51
+ const path = join(configDir, "credentials.json");
52
+ try {
53
+ const raw = fs.readFileSync(path, "utf-8");
54
+ return JSON.parse(raw);
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+ function saveCredentials(configDir, creds) {
60
+ ensureDir(configDir);
61
+ writeAtomic(join(configDir, "credentials.json"), JSON.stringify(creds, null, 2));
62
+ }
63
+ function loadTokenCache(configDir) {
64
+ const path = join(configDir, "token_cache.json");
65
+ try {
66
+ const raw = fs.readFileSync(path, "utf-8");
67
+ return JSON.parse(raw);
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+ function saveTokenCache(configDir, cache) {
73
+ ensureDir(configDir);
74
+ writeAtomic(join(configDir, "token_cache.json"), JSON.stringify(cache, null, 2));
75
+ }
76
+ function cacheToken(configDir, profile, token) {
77
+ const cache = loadTokenCache(configDir);
78
+ cache[profile] = {
79
+ access_token: token.access_token,
80
+ expires_at: Date.now() / 1e3 + token.expires_in
81
+ };
82
+ saveTokenCache(configDir, cache);
83
+ }
84
+ /**
85
+ * Resolves credentials for a profile. Priority:
86
+ * 1. RINE_CLIENT_ID + RINE_CLIENT_SECRET env vars (both required)
87
+ * 2. credentials.json for the given profile
88
+ */
89
+ function getCredentialEntry(configDir, profile = "default") {
90
+ const envId = process.env.RINE_CLIENT_ID;
91
+ const envSecret = process.env.RINE_CLIENT_SECRET;
92
+ if (envId && envSecret) return {
93
+ client_id: envId,
94
+ client_secret: envSecret
95
+ };
96
+ return loadCredentials(configDir)[profile];
97
+ }
98
+ //#endregion
99
+ //#region src/http.ts
100
+ async function parseErrorDetail(res) {
101
+ try {
102
+ const body = await res.json();
103
+ if (typeof body.detail === "string") return body.detail;
104
+ if (Array.isArray(body.detail)) return body.detail.map((e) => e.msg).join("; ");
105
+ } catch {}
106
+ return res.statusText;
107
+ }
108
+ var HttpClient = class {
109
+ baseUrl;
110
+ tokenFn;
111
+ canRefresh;
112
+ defaultHeaders;
113
+ constructor(opts) {
114
+ this.baseUrl = opts.apiUrl;
115
+ this.tokenFn = opts.tokenFn;
116
+ this.canRefresh = opts.canRefresh ?? true;
117
+ this.defaultHeaders = opts.defaultHeaders ?? {};
118
+ }
119
+ async request(method, path, body, params, extraHeaders) {
120
+ let url = this.baseUrl + path;
121
+ if (params) {
122
+ const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v !== void 0 && v !== null).map(([k, v]) => [k, String(v)])).toString();
123
+ if (qs) url += `?${qs}`;
124
+ }
125
+ const doFetch = async (force) => {
126
+ const headers = {
127
+ Authorization: `Bearer ${await this.tokenFn(force)}`,
128
+ ...this.defaultHeaders,
129
+ ...extraHeaders
130
+ };
131
+ if (body !== void 0) headers["Content-Type"] = "application/json";
132
+ const init = {
133
+ method,
134
+ headers
135
+ };
136
+ if (body !== void 0) init.body = JSON.stringify(body);
137
+ return fetch(url, init);
138
+ };
139
+ let res = await doFetch();
140
+ if (res.status === 401 && this.canRefresh) res = await doFetch(true);
141
+ if (!res.ok) {
142
+ const detail = await parseErrorDetail(res);
143
+ throw new RineApiError(res.status, detail, res);
144
+ }
145
+ if (res.status === 204) return void 0;
146
+ return res.json();
147
+ }
148
+ get(path, params, extraHeaders) {
149
+ return this.request("GET", path, void 0, params, extraHeaders);
150
+ }
151
+ post(path, body, extraHeaders) {
152
+ return this.request("POST", path, body, void 0, extraHeaders);
153
+ }
154
+ put(path, body, extraHeaders) {
155
+ return this.request("PUT", path, body, void 0, extraHeaders);
156
+ }
157
+ patch(path, body, extraHeaders) {
158
+ return this.request("PATCH", path, body, void 0, extraHeaders);
159
+ }
160
+ delete(path, extraHeaders) {
161
+ return this.request("DELETE", path, void 0, void 0, extraHeaders);
162
+ }
163
+ /** Unauthenticated GET for public endpoints (e.g. /directory/*). */
164
+ static async publicGet(apiUrl, path, params) {
165
+ const qs = params?.toString();
166
+ const url = qs ? `${apiUrl}${path}?${qs}` : `${apiUrl}${path}`;
167
+ const res = await fetch(url, { headers: { Accept: "application/json" } });
168
+ if (!res.ok) {
169
+ const detail = await parseErrorDetail(res);
170
+ throw new RineApiError(res.status, detail, res);
171
+ }
172
+ return res.json();
173
+ }
174
+ };
175
+ /**
176
+ * Fetches an OAuth token from POST /oauth/token.
177
+ */
178
+ async function fetchOAuthToken(apiUrl, clientId, clientSecret) {
179
+ const res = await fetch(`${apiUrl}/oauth/token`, {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
182
+ body: new URLSearchParams({
183
+ grant_type: "client_credentials",
184
+ client_id: clientId,
185
+ client_secret: clientSecret
186
+ }).toString()
187
+ });
188
+ if (!res.ok) {
189
+ const body = await res.json().catch(() => ({}));
190
+ throw new RineApiError(res.status, body.detail ?? "Token request failed");
191
+ }
192
+ return res.json();
193
+ }
194
+ /**
195
+ * Resolves a valid access token. Priority:
196
+ * 1. opts.envToken — used directly, no cache/refresh
197
+ * 2. Token cache — returned if not expired (60s margin)
198
+ * 3. POST /oauth/token — fetches fresh token and caches it
199
+ */
200
+ async function getOrRefreshToken(configDir, apiUrl, entry, profileName, opts) {
201
+ if (opts?.envToken) return opts.envToken;
202
+ const now = Date.now() / 1e3;
203
+ if (!opts?.force) {
204
+ const cached = loadTokenCache(configDir)[profileName];
205
+ if (cached !== void 0 && cached.expires_at - now > 60) return cached.access_token;
206
+ }
207
+ if (!entry) throw new RineApiError(0, "No credentials found. Run login first.");
208
+ const data = await fetchOAuthToken(apiUrl, entry.client_id, entry.client_secret);
209
+ cacheToken(configDir, profileName, data);
210
+ return data.access_token;
211
+ }
212
+ //#endregion
213
+ //#region src/resolve-handle.ts
214
+ const UUID_ALIAS_RE = /\/agents\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
215
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
216
+ /**
217
+ * Resolves a handle (e.g. "agent@org.rine.network") to a UUID
218
+ * via WebFinger (RFC 7033).
219
+ *
220
+ * Returns the UUID string on success, or the original input unchanged
221
+ * on failure (caller validates UUID format and reports the error).
222
+ */
223
+ async function resolveHandleViaWebFinger(apiUrl, handle) {
224
+ try {
225
+ const params = new URLSearchParams({ resource: `acct:${handle}` });
226
+ const data = await HttpClient.publicGet(apiUrl, "/.well-known/webfinger", params);
227
+ for (const alias of data.aliases ?? []) {
228
+ const match = UUID_ALIAS_RE.exec(alias);
229
+ if (match?.[1]) return match[1];
230
+ }
231
+ } catch {}
232
+ return handle;
233
+ }
234
+ //#endregion
235
+ //#region src/resolve-agent.ts
236
+ /**
237
+ * Resolve a value that may be a UUID or a handle (containing @) to a UUID.
238
+ * Handles are resolved via WebFinger.
239
+ */
240
+ async function resolveToUuid(apiUrl, value) {
241
+ if (!value.includes("@")) {
242
+ if (UUID_RE.test(value)) return value;
243
+ throw new Error(`Invalid agent identifier '${value}': expected a UUID or handle (e.g. agent@org.rine.network).`);
244
+ }
245
+ const resolved = await resolveHandleViaWebFinger(apiUrl, value);
246
+ if (!UUID_RE.test(resolved)) throw new Error(`Cannot resolve handle "${value}" to agent ID. Ensure WebFinger is available or use a UUID.`);
247
+ return resolved;
248
+ }
249
+ /** Check if a value is a bare agent name (not a UUID, not a handle). */
250
+ function isBareAgentName(value) {
251
+ return !value.includes("@") && !UUID_RE.test(value);
252
+ }
253
+ /** Fetch the org's agent list. Pure function — no caching. */
254
+ async function fetchAgents(client) {
255
+ return (await client.get("/agents")).items;
256
+ }
257
+ /** Resolve a bare agent name against an agent list (case-insensitive). */
258
+ function resolveBareName(agents, name) {
259
+ const lower = name.toLowerCase();
260
+ const match = agents.find((a) => a.name.toLowerCase() === lower);
261
+ if (match) return match.id;
262
+ const lines = agents.map((a) => ` ${a.name} ${a.handle}`).join("\n");
263
+ throw new Error(agents.length > 0 ? `No agent named '${name}'. Available agents:\n${lines}` : `No agent named '${name}'. No active agents found.`);
264
+ }
265
+ /**
266
+ * Resolve which agent ID to use.
267
+ * Priority: explicit flag > asFlag > auto-resolve (single-agent shortcut).
268
+ * Accepts UUIDs, handles, or bare names.
269
+ */
270
+ async function resolveAgent(apiUrl, agents, explicit, asFlag) {
271
+ if (explicit) {
272
+ if (isBareAgentName(explicit)) return resolveBareName(agents, explicit);
273
+ return resolveToUuid(apiUrl, explicit);
274
+ }
275
+ if (asFlag) {
276
+ if (isBareAgentName(asFlag)) return resolveBareName(agents, asFlag);
277
+ return resolveToUuid(apiUrl, asFlag);
278
+ }
279
+ if (agents.length === 1) {
280
+ const agent = agents[0];
281
+ if (agent) return agent.id;
282
+ }
283
+ if (agents.length === 0) throw new Error("No active agents found.");
284
+ const lines = agents.map((a) => ` ${a.id} ${a.handle}`).join("\n");
285
+ throw new Error(`Multiple agents found. Specify which agent to use:\n${lines}`);
286
+ }
287
+ //#endregion
288
+ //#region src/timelock.ts
289
+ function solveTimeLock(baseHex, modulusHex, T) {
290
+ const N = BigInt(`0x${modulusHex}`);
291
+ let x = BigInt(`0x${baseHex}`) % N;
292
+ for (let i = 0; i < T; i++) x = x * x % N;
293
+ return x.toString(16);
294
+ }
295
+ async function solveTimeLockWithProgress(baseHex, modulusHex, T, onProgress) {
296
+ const N = BigInt(`0x${modulusHex}`);
297
+ let x = BigInt(`0x${baseHex}`) % N;
298
+ const step = Math.max(1, Math.floor(T / 100));
299
+ for (let i = 0; i < T; i++) {
300
+ x = x * x % N;
301
+ if (i % step === 0) {
302
+ onProgress?.(Math.floor(i / T * 100));
303
+ await new Promise((resolve) => setImmediate(resolve));
304
+ }
305
+ }
306
+ return x.toString(16);
307
+ }
308
+ //#endregion
309
+ //#region src/crypto/hpke.ts
310
+ const VERSION_HPKE = 1;
311
+ const ENC_SIZE = 32;
312
+ const INFO = new TextEncoder().encode("rine.e2ee.v1.hpke");
313
+ const suite = new CipherSuite({
314
+ kem: new DhkemX25519HkdfSha256(),
315
+ kdf: new HkdfSha256(),
316
+ aead: new Aes256Gcm()
317
+ });
318
+ async function seal(recipientPublicKey, innerEnvelope, aad) {
319
+ const pk = await suite.kem.deserializePublicKey(recipientPublicKey);
320
+ const sender = await suite.createSenderContext({
321
+ recipientPublicKey: pk,
322
+ info: INFO
323
+ });
324
+ const ciphertext = new Uint8Array(await sender.seal(innerEnvelope, aad));
325
+ const enc = new Uint8Array(sender.enc);
326
+ const out = new Uint8Array(1 + ENC_SIZE + ciphertext.length);
327
+ out[0] = VERSION_HPKE;
328
+ out.set(enc, 1);
329
+ out.set(ciphertext, 1 + ENC_SIZE);
330
+ return out;
331
+ }
332
+ async function open(recipientPrivateKey, encryptedPayload, aad) {
333
+ if (encryptedPayload.length < 1 + ENC_SIZE + 1) throw new Error("encrypted payload too short");
334
+ const version = encryptedPayload[0];
335
+ if (version !== VERSION_HPKE) throw new Error(`unsupported version: 0x${version?.toString(16).padStart(2, "0")}`);
336
+ const enc = encryptedPayload.slice(1, 1 + ENC_SIZE);
337
+ const ct = encryptedPayload.slice(1 + ENC_SIZE);
338
+ const sk = await suite.kem.deserializePrivateKey(recipientPrivateKey);
339
+ const recipient = await suite.createRecipientContext({
340
+ recipientKey: sk,
341
+ enc,
342
+ info: INFO
343
+ });
344
+ return new Uint8Array(await recipient.open(ct, aad));
345
+ }
346
+ //#endregion
347
+ //#region src/crypto/sign.ts
348
+ function signPayload(signingPrivateKey, plaintext) {
349
+ return ed25519.sign(plaintext, signingPrivateKey);
350
+ }
351
+ function verifySignature(signingPublicKey, plaintext, signature) {
352
+ try {
353
+ return ed25519.verify(signature, plaintext, signingPublicKey);
354
+ } catch {
355
+ return false;
356
+ }
357
+ }
358
+ //#endregion
359
+ //#region src/crypto/envelope.ts
360
+ const SIGNATURE_SIZE = 64;
361
+ const MAX_KID_LENGTH = 255;
362
+ function encodeEnvelope(kid, signature, payload) {
363
+ const kidBytes = new TextEncoder().encode(kid);
364
+ if (kidBytes.length > MAX_KID_LENGTH) throw new Error(`kid too long: ${kidBytes.length} bytes (max ${MAX_KID_LENGTH})`);
365
+ if (signature.length !== SIGNATURE_SIZE) throw new Error(`signature must be ${SIGNATURE_SIZE} bytes, got ${signature.length}`);
366
+ const buf = new Uint8Array(1 + kidBytes.length + SIGNATURE_SIZE + payload.length);
367
+ buf[0] = kidBytes.length;
368
+ buf.set(kidBytes, 1);
369
+ buf.set(signature, 1 + kidBytes.length);
370
+ buf.set(payload, 1 + kidBytes.length + SIGNATURE_SIZE);
371
+ return buf;
372
+ }
373
+ function decodeEnvelope(data) {
374
+ if (data.length < 1 + SIGNATURE_SIZE) throw new Error("envelope too short");
375
+ const kidLen = data[0];
376
+ const minLen = 1 + kidLen + SIGNATURE_SIZE;
377
+ if (data.length < minLen) throw new Error("envelope too short for declared kid length");
378
+ return {
379
+ kid: new TextDecoder("utf-8", { fatal: true }).decode(data.subarray(1, 1 + kidLen)),
380
+ signature: data.slice(1 + kidLen, 1 + kidLen + SIGNATURE_SIZE),
381
+ payload: data.slice(1 + kidLen + SIGNATURE_SIZE)
382
+ };
383
+ }
384
+ //#endregion
385
+ //#region src/crypto/keys.ts
386
+ function toBase64Url(bytes) {
387
+ return Buffer.from(bytes).toString("base64url");
388
+ }
389
+ function fromBase64Url(s) {
390
+ return new Uint8Array(Buffer.from(s, "base64url"));
391
+ }
392
+ function generateSigningKeyPair() {
393
+ const privateKey = ed25519.utils.randomSecretKey();
394
+ return {
395
+ privateKey,
396
+ publicKey: ed25519.getPublicKey(privateKey)
397
+ };
398
+ }
399
+ function generateEncryptionKeyPair() {
400
+ const privateKey = x25519.utils.randomSecretKey();
401
+ return {
402
+ privateKey,
403
+ publicKey: x25519.getPublicKey(privateKey)
404
+ };
405
+ }
406
+ function generateAgentKeys() {
407
+ return {
408
+ signing: generateSigningKeyPair(),
409
+ encryption: generateEncryptionKeyPair()
410
+ };
411
+ }
412
+ function signingPublicKeyToJWK(publicKey) {
413
+ return {
414
+ kty: "OKP",
415
+ crv: "Ed25519",
416
+ x: toBase64Url(publicKey)
417
+ };
418
+ }
419
+ function encryptionPublicKeyToJWK(publicKey) {
420
+ return {
421
+ kty: "OKP",
422
+ crv: "X25519",
423
+ x: toBase64Url(publicKey)
424
+ };
425
+ }
426
+ function jwkToPublicKey(jwk) {
427
+ const key = fromBase64Url(jwk.x);
428
+ if (key.length !== 32) throw new Error(`Invalid public key: expected 32 bytes, got ${key.length}`);
429
+ return key;
430
+ }
431
+ const KID_RE = /^rine:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
432
+ function agentIdFromKid(kid) {
433
+ const match = KID_RE.exec(kid);
434
+ if (!match) throw new Error(`Invalid sender KID format: ${kid}`);
435
+ return match[1];
436
+ }
437
+ const PATH_SAFE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
438
+ function validatePathId(id, label) {
439
+ if (!PATH_SAFE_RE.test(id)) throw new Error(`Invalid ${label}: must be a UUID, got '${id}'`);
440
+ }
441
+ function saveAgentKeys(configDir, agentId, keys) {
442
+ validatePathId(agentId, "agent ID");
443
+ const dir = join(configDir, "keys", agentId);
444
+ fs.mkdirSync(dir, {
445
+ recursive: true,
446
+ mode: 448
447
+ });
448
+ const sigPath = join(dir, "signing.key");
449
+ fs.writeFileSync(sigPath, toBase64Url(keys.signing.privateKey), "utf-8");
450
+ fs.chmodSync(sigPath, 384);
451
+ const encPath = join(dir, "encryption.key");
452
+ fs.writeFileSync(encPath, toBase64Url(keys.encryption.privateKey), "utf-8");
453
+ fs.chmodSync(encPath, 384);
454
+ }
455
+ function loadAgentKeys(configDir, agentId) {
456
+ validatePathId(agentId, "agent ID");
457
+ const dir = join(configDir, "keys", agentId);
458
+ const sigPriv = fromBase64Url(fs.readFileSync(join(dir, "signing.key"), "utf-8").trim());
459
+ if (sigPriv.length !== 32) throw new Error(`Corrupt signing key: expected 32 bytes, got ${sigPriv.length}`);
460
+ const sigPub = ed25519.getPublicKey(sigPriv);
461
+ const encPriv = fromBase64Url(fs.readFileSync(join(dir, "encryption.key"), "utf-8").trim());
462
+ if (encPriv.length !== 32) throw new Error(`Corrupt encryption key: expected 32 bytes, got ${encPriv.length}`);
463
+ const encPub = x25519.getPublicKey(encPriv);
464
+ return {
465
+ signing: {
466
+ privateKey: sigPriv,
467
+ publicKey: sigPub
468
+ },
469
+ encryption: {
470
+ privateKey: encPriv,
471
+ publicKey: encPub
472
+ }
473
+ };
474
+ }
475
+ function agentKeysExist(configDir, agentId) {
476
+ validatePathId(agentId, "agent ID");
477
+ const dir = join(configDir, "keys", agentId);
478
+ return fs.existsSync(join(dir, "signing.key")) && fs.existsSync(join(dir, "encryption.key"));
479
+ }
480
+ //#endregion
481
+ //#region src/crypto/sender-keys-helpers.ts
482
+ function uuidToBytes(uuid) {
483
+ const matches = uuid.replace(/-/g, "").match(/../g);
484
+ if (!matches) throw new Error(`Invalid UUID: ${uuid}`);
485
+ return new Uint8Array(matches.map((b) => Number.parseInt(b, 16)));
486
+ }
487
+ function bytesToUuid(bytes) {
488
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
489
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
490
+ }
491
+ function saveSenderKeyState(configDir, agentId, groupId, states) {
492
+ validatePathId(agentId, "agent ID");
493
+ validatePathId(groupId, "group ID");
494
+ const dir = join(configDir, "keys", agentId, "sender_keys");
495
+ fs.mkdirSync(dir, {
496
+ recursive: true,
497
+ mode: 448
498
+ });
499
+ const filePath = join(dir, `${groupId}.json`);
500
+ const data = states.map((s) => ({
501
+ ...s,
502
+ chainKey: toBase64Url(s.chainKey)
503
+ }));
504
+ fs.writeFileSync(filePath, JSON.stringify(data), "utf-8");
505
+ fs.chmodSync(filePath, 384);
506
+ }
507
+ function loadSenderKeyStates(configDir, agentId, groupId) {
508
+ validatePathId(agentId, "agent ID");
509
+ validatePathId(groupId, "group ID");
510
+ const filePath = join(configDir, "keys", agentId, "sender_keys", `${groupId}.json`);
511
+ if (!fs.existsSync(filePath)) return [];
512
+ const raw = fs.readFileSync(filePath, "utf-8");
513
+ return JSON.parse(raw).map((s) => ({
514
+ ...s,
515
+ chainKey: fromBase64Url(s.chainKey)
516
+ }));
517
+ }
518
+ //#endregion
519
+ //#region src/crypto/sender-keys.ts
520
+ const ROTATION_MESSAGE_LIMIT = 100;
521
+ const ROTATION_AGE_MS = 10080 * 60 * 1e3;
522
+ function needsRotation(state) {
523
+ if (state.messageIndex >= ROTATION_MESSAGE_LIMIT) return true;
524
+ if (state.createdAt && Date.now() - state.createdAt > ROTATION_AGE_MS) return true;
525
+ return false;
526
+ }
527
+ const VERSION_SENDER_KEY = 2;
528
+ const SENDER_KEY_ID_SIZE = 16;
529
+ const MESSAGE_INDEX_SIZE = 4;
530
+ const NONCE_SIZE = 12;
531
+ const MAX_SKIP = 1e3;
532
+ /** Copy Uint8Array into a plain ArrayBuffer-backed Uint8Array (required by Web Crypto). */
533
+ function toPlainBuffer(src) {
534
+ const buf = new Uint8Array(src.length);
535
+ buf.set(src);
536
+ return buf;
537
+ }
538
+ function generateSenderKey(groupId, senderAgentId) {
539
+ const chainKey = new Uint8Array(32);
540
+ crypto.getRandomValues(chainKey);
541
+ return {
542
+ senderKeyId: crypto.randomUUID(),
543
+ groupId,
544
+ senderAgentId,
545
+ chainKey,
546
+ messageIndex: 0,
547
+ createdAt: Date.now()
548
+ };
549
+ }
550
+ function deriveMessageKey(chainKey) {
551
+ return {
552
+ messageKey: hmac(sha256, chainKey, new Uint8Array([1])),
553
+ nextChainKey: hmac(sha256, chainKey, new Uint8Array([2]))
554
+ };
555
+ }
556
+ function advanceChain(state, targetIndex) {
557
+ if (targetIndex - state.messageIndex > MAX_SKIP) throw new Error(`too many skipped messages: ${targetIndex - state.messageIndex} (max ${MAX_SKIP})`);
558
+ const skippedKeys = /* @__PURE__ */ new Map();
559
+ let chainKey = state.chainKey;
560
+ let index = state.messageIndex;
561
+ while (index < targetIndex) {
562
+ const { messageKey, nextChainKey } = deriveMessageKey(chainKey);
563
+ skippedKeys.set(index, messageKey);
564
+ chainKey = nextChainKey;
565
+ index++;
566
+ }
567
+ return {
568
+ updatedState: {
569
+ ...state,
570
+ chainKey,
571
+ messageIndex: index
572
+ },
573
+ skippedKeys
574
+ };
575
+ }
576
+ /** Build AAD for sender-key encryption: binds ciphertext to sender_key_id and message_index. */
577
+ function buildSenderKeyAad(senderKeyId, messageIndex) {
578
+ const aad = new Uint8Array(SENDER_KEY_ID_SIZE + MESSAGE_INDEX_SIZE);
579
+ aad.set(senderKeyId, 0);
580
+ new DataView(aad.buffer).setUint32(SENDER_KEY_ID_SIZE, messageIndex, false);
581
+ return aad;
582
+ }
583
+ async function sealGroup(senderSigningPrivateKey, senderSigningKid, state, plaintext) {
584
+ const innerEnvelope = encodeEnvelope(senderSigningKid, signPayload(senderSigningPrivateKey, plaintext), plaintext);
585
+ const { messageKey, nextChainKey } = deriveMessageKey(state.chainKey);
586
+ const nonce = new Uint8Array(NONCE_SIZE);
587
+ crypto.getRandomValues(nonce);
588
+ const idBytes = uuidToBytes(state.senderKeyId);
589
+ const aad = buildSenderKeyAad(idBytes, state.messageIndex);
590
+ const cryptoKey = await crypto.subtle.importKey("raw", toPlainBuffer(messageKey), "AES-GCM", false, ["encrypt"]);
591
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({
592
+ name: "AES-GCM",
593
+ iv: nonce,
594
+ additionalData: toPlainBuffer(aad)
595
+ }, cryptoKey, toPlainBuffer(innerEnvelope)));
596
+ messageKey.fill(0);
597
+ const out = new Uint8Array(1 + SENDER_KEY_ID_SIZE + MESSAGE_INDEX_SIZE + NONCE_SIZE + ciphertext.length);
598
+ let offset = 0;
599
+ out[offset++] = VERSION_SENDER_KEY;
600
+ out.set(idBytes, offset);
601
+ offset += SENDER_KEY_ID_SIZE;
602
+ new DataView(out.buffer).setUint32(offset, state.messageIndex, false);
603
+ offset += MESSAGE_INDEX_SIZE;
604
+ out.set(nonce, offset);
605
+ offset += NONCE_SIZE;
606
+ out.set(ciphertext, offset);
607
+ return {
608
+ encryptedPayload: out,
609
+ updatedState: {
610
+ ...state,
611
+ chainKey: nextChainKey,
612
+ messageIndex: state.messageIndex + 1
613
+ }
614
+ };
615
+ }
616
+ async function openGroup(senderKeyStates, encryptedPayload) {
617
+ const minLen = 1 + SENDER_KEY_ID_SIZE + MESSAGE_INDEX_SIZE + NONCE_SIZE;
618
+ if (encryptedPayload.length < minLen) throw new Error("encrypted payload too short");
619
+ const version = encryptedPayload[0];
620
+ if (version !== VERSION_SENDER_KEY) throw new Error(`unsupported version: 0x${version?.toString(16).padStart(2, "0")}`);
621
+ let offset = 1;
622
+ const senderKeyId = bytesToUuid(encryptedPayload.slice(offset, offset + SENDER_KEY_ID_SIZE));
623
+ offset += SENDER_KEY_ID_SIZE;
624
+ const messageIndex = new DataView(encryptedPayload.buffer, encryptedPayload.byteOffset).getUint32(offset, false);
625
+ offset += MESSAGE_INDEX_SIZE;
626
+ const nonce = encryptedPayload.slice(offset, offset + NONCE_SIZE);
627
+ offset += NONCE_SIZE;
628
+ const ciphertext = encryptedPayload.slice(offset);
629
+ const state = senderKeyStates.get(senderKeyId);
630
+ if (!state) throw new Error(`unknown sender key id: ${senderKeyId}`);
631
+ let messageKey;
632
+ let updatedState;
633
+ if (messageIndex < state.messageIndex) {
634
+ const cached = state.skippedKeys?.[messageIndex];
635
+ if (!cached) throw new Error("message key expired or already consumed");
636
+ messageKey = fromBase64Url(cached);
637
+ const newSkipped = { ...state.skippedKeys };
638
+ delete newSkipped[messageIndex];
639
+ updatedState = {
640
+ ...state,
641
+ skippedKeys: newSkipped
642
+ };
643
+ } else if (messageIndex === state.messageIndex) {
644
+ const { messageKey: mk, nextChainKey } = deriveMessageKey(state.chainKey);
645
+ messageKey = mk;
646
+ updatedState = {
647
+ ...state,
648
+ chainKey: nextChainKey,
649
+ messageIndex: state.messageIndex + 1
650
+ };
651
+ } else {
652
+ const { updatedState: advanced, skippedKeys: newSkipped } = advanceChain(state, messageIndex);
653
+ const { messageKey: mk, nextChainKey } = deriveMessageKey(advanced.chainKey);
654
+ messageKey = mk;
655
+ const mergedSkipped = { ...state.skippedKeys ?? {} };
656
+ for (const [idx, key] of newSkipped) {
657
+ mergedSkipped[idx] = toBase64Url(key);
658
+ key.fill(0);
659
+ }
660
+ const entries = Object.entries(mergedSkipped);
661
+ if (entries.length > MAX_SKIP) {
662
+ entries.sort((a, b) => Number(a[0]) - Number(b[0]));
663
+ const excess = entries.length - MAX_SKIP;
664
+ for (let i = 0; i < excess; i++) delete mergedSkipped[Number(entries[i][0])];
665
+ }
666
+ updatedState = {
667
+ ...advanced,
668
+ chainKey: nextChainKey,
669
+ messageIndex: messageIndex + 1,
670
+ skippedKeys: mergedSkipped
671
+ };
672
+ }
673
+ const aad = buildSenderKeyAad(encryptedPayload.slice(1, 1 + SENDER_KEY_ID_SIZE), messageIndex);
674
+ const cryptoKey = await crypto.subtle.importKey("raw", toPlainBuffer(messageKey), "AES-GCM", false, ["decrypt"]);
675
+ const innerEnvelope = new Uint8Array(await crypto.subtle.decrypt({
676
+ name: "AES-GCM",
677
+ iv: nonce,
678
+ additionalData: toPlainBuffer(aad)
679
+ }, cryptoKey, toPlainBuffer(ciphertext)));
680
+ messageKey.fill(0);
681
+ return {
682
+ innerEnvelope,
683
+ senderKeyId,
684
+ messageIndex,
685
+ updatedState
686
+ };
687
+ }
688
+ //#endregion
689
+ //#region src/crypto/message.ts
690
+ async function verifyEnvelopeSender(decoded, client) {
691
+ let verified = false;
692
+ let verificationStatus = "unverifiable";
693
+ try {
694
+ const senderAgentId = agentIdFromKid(decoded.kid);
695
+ verified = verifySignature(jwkToPublicKey((await client.get(`/agents/${senderAgentId}/keys`)).signing_public_key), decoded.payload, decoded.signature);
696
+ verificationStatus = verified ? "verified" : "invalid";
697
+ } catch {}
698
+ return {
699
+ verified,
700
+ verificationStatus
701
+ };
702
+ }
703
+ function signingKid(agentId) {
704
+ return `rine:${agentId}`;
705
+ }
706
+ async function encryptMessage(configDir, senderAgentId, recipientEncryptionPk, payload) {
707
+ const keys = loadAgentKeys(configDir, senderAgentId);
708
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
709
+ const kid = signingKid(senderAgentId);
710
+ return {
711
+ encrypted_payload: toBase64Url(await seal(recipientEncryptionPk, encodeEnvelope(kid, signPayload(keys.signing.privateKey, plaintext), plaintext))),
712
+ encryption_version: "hpke-v1",
713
+ sender_signing_kid: kid
714
+ };
715
+ }
716
+ async function fetchRecipientEncryptionKey(client, agentId) {
717
+ return jwkToPublicKey((await client.get(`/agents/${agentId}/keys`)).encryption_public_key);
718
+ }
719
+ async function decryptMessage(configDir, recipientAgentId, encryptedPayloadB64, client) {
720
+ const keys = loadAgentKeys(configDir, recipientAgentId);
721
+ const encrypted = fromBase64Url(encryptedPayloadB64);
722
+ const decoded = decodeEnvelope(await open(keys.encryption.privateKey, encrypted));
723
+ const plaintext = new TextDecoder("utf-8", { fatal: true }).decode(decoded.payload);
724
+ const { verified, verificationStatus } = await verifyEnvelopeSender(decoded, client);
725
+ return {
726
+ plaintext,
727
+ senderKid: decoded.kid,
728
+ verified,
729
+ verificationStatus
730
+ };
731
+ }
732
+ async function encryptGroupMessage(configDir, senderAgentId, groupId, senderKeyState, payload) {
733
+ const keys = loadAgentKeys(configDir, senderAgentId);
734
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
735
+ const kid = signingKid(senderAgentId);
736
+ const selfReadKey = deriveMessageKey(senderKeyState.chainKey).messageKey;
737
+ const selfReadIndex = senderKeyState.messageIndex;
738
+ const { encryptedPayload, updatedState } = await sealGroup(keys.signing.privateKey, kid, senderKeyState, plaintext);
739
+ updatedState.skippedKeys = {
740
+ ...updatedState.skippedKeys ?? {},
741
+ [selfReadIndex]: toBase64Url(selfReadKey)
742
+ };
743
+ selfReadKey.fill(0);
744
+ saveSenderKeyState(configDir, senderAgentId, groupId, [...loadSenderKeyStates(configDir, senderAgentId, groupId).filter((s) => s.senderKeyId !== updatedState.senderKeyId), updatedState]);
745
+ return {
746
+ result: {
747
+ encrypted_payload: toBase64Url(encryptedPayload),
748
+ encryption_version: "sender-key-v1",
749
+ sender_signing_kid: kid
750
+ },
751
+ updatedState
752
+ };
753
+ }
754
+ async function decryptGroupMessage(configDir, recipientAgentId, groupId, encryptedPayloadB64, client) {
755
+ const states = loadSenderKeyStates(configDir, recipientAgentId, groupId);
756
+ const stateMap = /* @__PURE__ */ new Map();
757
+ for (const s of states) stateMap.set(s.senderKeyId, s);
758
+ const { innerEnvelope, updatedState, messageIndex } = await openGroup(stateMap, fromBase64Url(encryptedPayloadB64));
759
+ const decoded = decodeEnvelope(innerEnvelope);
760
+ const plaintext = new TextDecoder("utf-8", { fatal: true }).decode(decoded.payload);
761
+ const { verified, verificationStatus } = await verifyEnvelopeSender(decoded, client);
762
+ if (verificationStatus === "invalid") throw new Error("Sender signature verification failed");
763
+ if (updatedState.senderAgentId === recipientAgentId) {
764
+ const prevCached = stateMap.get(updatedState.senderKeyId)?.skippedKeys?.[messageIndex];
765
+ if (prevCached && !updatedState.skippedKeys?.[messageIndex]) updatedState.skippedKeys = {
766
+ ...updatedState.skippedKeys ?? {},
767
+ [messageIndex]: prevCached
768
+ };
769
+ }
770
+ stateMap.set(updatedState.senderKeyId, updatedState);
771
+ saveSenderKeyState(configDir, recipientAgentId, groupId, Array.from(stateMap.values()));
772
+ return {
773
+ plaintext,
774
+ senderKid: decoded.kid,
775
+ verified,
776
+ verificationStatus
777
+ };
778
+ }
779
+ function getAgentPublicKeys(configDir, agentId, agentKeys) {
780
+ const keys = agentKeys ?? loadAgentKeys(configDir, agentId);
781
+ return {
782
+ signing_public_key: signingPublicKeyToJWK(keys.signing.publicKey),
783
+ encryption_public_key: {
784
+ kty: "OKP",
785
+ crv: "X25519",
786
+ x: toBase64Url(keys.encryption.publicKey)
787
+ }
788
+ };
789
+ }
790
+ //#endregion
791
+ //#region src/crypto/ingest.ts
792
+ /**
793
+ * Auto-ingest a sender key distribution from a decrypted message.
794
+ * Only ingests if the message is a `rine.v1.sender_key_distribution`,
795
+ * the signature was verified, and the key ID is not already known.
796
+ *
797
+ * Returns true if a new key was ingested.
798
+ */
799
+ function ingestSenderKeyDistribution(configDir, agentId, messageType, result) {
800
+ if (messageType !== "rine.v1.sender_key_distribution") return false;
801
+ if (!result.verified) return false;
802
+ try {
803
+ const dist = JSON.parse(result.plaintext);
804
+ const existing = loadSenderKeyStates(configDir, agentId, dist.group_id);
805
+ if (existing.some((s) => s.senderKeyId === dist.sender_key_id)) return false;
806
+ const newState = {
807
+ senderKeyId: dist.sender_key_id,
808
+ groupId: dist.group_id,
809
+ senderAgentId: agentIdFromKid(result.senderKid),
810
+ chainKey: fromBase64Url(dist.chain_key),
811
+ messageIndex: dist.message_index
812
+ };
813
+ saveSenderKeyState(configDir, agentId, dist.group_id, [...existing, newState]);
814
+ return true;
815
+ } catch {
816
+ return false;
817
+ }
818
+ }
819
+ //#endregion
820
+ //#region src/sender-key-ops.ts
821
+ /**
822
+ * Distribute a sender key to group members via encrypted DMs.
823
+ * Returns which recipients succeeded/failed.
824
+ */
825
+ async function distributeSenderKey(client, configDir, senderAgentId, state, groupId, recipientIds, extraHeaders) {
826
+ if (recipientIds.length === 0) return {
827
+ succeeded: [],
828
+ failed: []
829
+ };
830
+ const batchKeys = await client.get("/agents/keys", { ids: recipientIds.join(",") });
831
+ const distPayload = {
832
+ group_id: groupId,
833
+ sender_key_id: state.senderKeyId,
834
+ chain_key: toBase64Url(state.chainKey),
835
+ message_index: state.messageIndex,
836
+ sender_agent_id: senderAgentId
837
+ };
838
+ const succeeded = [];
839
+ const failed = [];
840
+ for (const recipientId of recipientIds) {
841
+ const recipientKeyData = batchKeys.keys[recipientId];
842
+ if (!recipientKeyData) {
843
+ failed.push(recipientId);
844
+ continue;
845
+ }
846
+ try {
847
+ const encrypted = await encryptMessage(configDir, senderAgentId, fromBase64Url(recipientKeyData.encryption_public_key.x), distPayload);
848
+ await client.post("/messages", {
849
+ to_agent_id: recipientId,
850
+ type: "rine.v1.sender_key_distribution",
851
+ ...encrypted
852
+ }, extraHeaders);
853
+ succeeded.push(recipientId);
854
+ } catch {
855
+ failed.push(recipientId);
856
+ }
857
+ }
858
+ return {
859
+ succeeded,
860
+ failed
861
+ };
862
+ }
863
+ /**
864
+ * Get or create a sender key for a group, distributing it to members as needed.
865
+ * Handles rotation on member removal, periodic rotation, and new key generation.
866
+ */
867
+ async function getOrCreateSenderKey(client, configDir, senderAgentId, groupHandle, extraHeaders) {
868
+ const firstGroup = (await client.get("/groups", { handle: groupHandle })).items?.[0];
869
+ if (!firstGroup) throw new Error(`Group not found: ${groupHandle}`);
870
+ const groupId = firstGroup.id;
871
+ const memberIds = (await client.get(`/groups/${groupId}/members`)).items.map((m) => m.agent_id);
872
+ const senderIdLower = senderAgentId.toLowerCase();
873
+ if (!memberIds.some((id) => id.toLowerCase() === senderIdLower)) throw new Error("You are not a member of this group");
874
+ const recipientIds = memberIds.filter((id) => id.toLowerCase() !== senderIdLower);
875
+ const states = loadSenderKeyStates(configDir, senderAgentId, groupId);
876
+ const own = states.find((s) => s.senderAgentId === senderAgentId);
877
+ const currentMemberSet = new Set(memberIds.map((id) => id.toLowerCase()));
878
+ const memberRemoved = own?.distributedTo?.some((id) => !currentMemberSet.has(id.toLowerCase())) ?? false;
879
+ if (own && !needsRotation(own) && !memberRemoved) {
880
+ const alreadyDistributed = new Set((own.distributedTo ?? []).map((id) => id.toLowerCase()));
881
+ const undistributed = recipientIds.filter((id) => !alreadyDistributed.has(id.toLowerCase()));
882
+ if (undistributed.length > 0) {
883
+ const { succeeded } = await distributeSenderKey(client, configDir, senderAgentId, own, groupId, undistributed, extraHeaders);
884
+ if (succeeded.length > 0) {
885
+ own.distributedTo = [...own.distributedTo ?? [], ...succeeded];
886
+ saveSenderKeyState(configDir, senderAgentId, groupId, states);
887
+ }
888
+ }
889
+ return {
890
+ state: own,
891
+ groupId
892
+ };
893
+ }
894
+ const newState = generateSenderKey(groupId, senderAgentId);
895
+ const { succeeded } = await distributeSenderKey(client, configDir, senderAgentId, newState, groupId, recipientIds, extraHeaders);
896
+ newState.distributedTo = succeeded;
897
+ saveSenderKeyState(configDir, senderAgentId, groupId, [...memberRemoved ? states.filter((s) => currentMemberSet.has(s.senderAgentId.toLowerCase())) : states, newState]);
898
+ return {
899
+ state: newState,
900
+ groupId
901
+ };
902
+ }
903
+ //#endregion
904
+ export { DEFAULT_API_URL, HttpClient, RineApiError, UUID_RE, advanceChain, agentIdFromKid, agentKeysExist, bytesToUuid, cacheToken, decodeEnvelope, decryptGroupMessage, decryptMessage, deriveMessageKey, distributeSenderKey, encodeEnvelope, encryptGroupMessage, encryptMessage, encryptionPublicKeyToJWK, fetchAgents, fetchOAuthToken, fetchRecipientEncryptionKey, formatError, fromBase64Url, generateAgentKeys, generateEncryptionKeyPair, generateSenderKey, generateSigningKeyPair, getAgentPublicKeys, getCredentialEntry, getOrCreateSenderKey, getOrRefreshToken, ingestSenderKeyDistribution, isBareAgentName, jwkToPublicKey, loadAgentKeys, loadCredentials, loadSenderKeyStates, loadTokenCache, needsRotation, open, openGroup, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, saveAgentKeys, saveCredentials, saveSenderKeyState, saveTokenCache, seal, sealGroup, signPayload, signingPublicKeyToJWK, solveTimeLock, solveTimeLockWithProgress, toBase64Url, uuidToBytes, validatePathId, verifySignature };