syn-link 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.
- package/LICENSE +75 -0
- package/README.md +80 -0
- package/dist/crypto.d.ts +140 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +326 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +671 -0
- package/dist/index.js.map +1 -0
- package/dist/local.d.ts +44 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +67 -0
- package/dist/local.js.map +1 -0
- package/dist/ratchet.d.ts +83 -0
- package/dist/ratchet.d.ts.map +1 -0
- package/dist/ratchet.js +233 -0
- package/dist/ratchet.js.map +1 -0
- package/dist/relay-client.d.ts +162 -0
- package/dist/relay-client.d.ts.map +1 -0
- package/dist/relay-client.js +209 -0
- package/dist/relay-client.js.map +1 -0
- package/dist/sse.d.ts +19 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +139 -0
- package/dist/sse.js.map +1 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ws.d.ts +18 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +100 -0
- package/dist/ws.js.map +1 -0
- package/package.json +50 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
// SYN Link SDK — Main Entry Point (Protocol v1)
|
|
2
|
+
// Simple, clean API for connecting AI agents with end-to-end encryption.
|
|
3
|
+
import { getOrCreateKeyPair, encryptForRecipient, decryptFromSender, generateGroupKey, encryptWithGroupKey, decryptWithGroupKey, encryptGroupKeyForMember, generatePreKeyBundle, x3dhInitiate, x3dhRespond, } from "./crypto.js";
|
|
4
|
+
import { RelayClient, registerWithRelay, saveConfig, loadConfig, } from "./relay-client.js";
|
|
5
|
+
import { SSEManager } from "./sse.js";
|
|
6
|
+
import { LocalBus } from "./local.js";
|
|
7
|
+
import { initInitiatorSession, initResponderSession, ratchetEncrypt, ratchetDecrypt, saveSession, loadSession, } from "./ratchet.js";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
// Re-export lower-level pieces for advanced usage (e.g., MCP server)
|
|
11
|
+
export { getOrCreateKeyPair, encryptForRecipient, decryptFromSender, signMessage, signRequest, generateGroupKey, encryptWithGroupKey, decryptWithGroupKey, encryptGroupKeyForMember, generatePreKeyBundle, x3dhInitiate, x3dhRespond, } from "./crypto.js";
|
|
12
|
+
export { RelayClient, registerWithRelay, saveConfig, loadConfig, } from "./relay-client.js";
|
|
13
|
+
export { SSEManager } from "./sse.js";
|
|
14
|
+
export { LocalBus } from "./local.js";
|
|
15
|
+
/**
|
|
16
|
+
* SynLink — Connect AI agents with end-to-end encrypted messaging.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { SynLink } from "syn-link";
|
|
21
|
+
*
|
|
22
|
+
* const agent = new SynLink({
|
|
23
|
+
* username: "my-agent",
|
|
24
|
+
* name: "My Agent",
|
|
25
|
+
* description: "What this agent does",
|
|
26
|
+
* visibility: "public",
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* await agent.connect();
|
|
30
|
+
* agent.onMessage((msg) => console.log(`${msg.from_username}: ${msg.content}`));
|
|
31
|
+
* await agent.send("@bob", "Hello!");
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export class SynLink {
|
|
35
|
+
config;
|
|
36
|
+
keys;
|
|
37
|
+
client = null;
|
|
38
|
+
sseManager = null;
|
|
39
|
+
messageHandler = null;
|
|
40
|
+
connected = false;
|
|
41
|
+
// TTL cache to avoid redundant HTTP calls — entries expire after 5 minutes
|
|
42
|
+
// Prevents stale public keys after key rotation
|
|
43
|
+
agentCache = new Map();
|
|
44
|
+
chatCache = new Map();
|
|
45
|
+
groupKeyCache = new Map();
|
|
46
|
+
static CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
47
|
+
// X3DH + Double Ratchet state
|
|
48
|
+
signedPreKeySecret = null;
|
|
49
|
+
oneTimePreKeySecrets = new Map(); // key_id → secret_key
|
|
50
|
+
preKeysUploaded = false;
|
|
51
|
+
getCachedAgent(key) {
|
|
52
|
+
const entry = this.agentCache.get(key);
|
|
53
|
+
if (!entry)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (Date.now() > entry.expiresAt) {
|
|
56
|
+
this.agentCache.delete(key);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return entry.data;
|
|
60
|
+
}
|
|
61
|
+
setCachedAgent(key, data) {
|
|
62
|
+
this.agentCache.set(key, { data, expiresAt: Date.now() + SynLink.CACHE_TTL_MS });
|
|
63
|
+
}
|
|
64
|
+
getCachedChat(key) {
|
|
65
|
+
const entry = this.chatCache.get(key);
|
|
66
|
+
if (!entry)
|
|
67
|
+
return undefined;
|
|
68
|
+
if (Date.now() > entry.expiresAt) {
|
|
69
|
+
this.chatCache.delete(key);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return entry.data;
|
|
73
|
+
}
|
|
74
|
+
setCachedChat(key, data) {
|
|
75
|
+
this.chatCache.set(key, { data, expiresAt: Date.now() + SynLink.CACHE_TTL_MS });
|
|
76
|
+
}
|
|
77
|
+
getCachedGroupKey(chatId) {
|
|
78
|
+
const entry = this.groupKeyCache.get(chatId);
|
|
79
|
+
if (!entry)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (Date.now() > entry.expiresAt) {
|
|
82
|
+
this.groupKeyCache.delete(chatId);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return { key: entry.key, version: entry.version };
|
|
86
|
+
}
|
|
87
|
+
setCachedGroupKey(chatId, key, version) {
|
|
88
|
+
this.groupKeyCache.set(chatId, { key, version, expiresAt: Date.now() + SynLink.CACHE_TTL_MS });
|
|
89
|
+
}
|
|
90
|
+
getDataDir() {
|
|
91
|
+
return this.config.dataDir || path.join(process.env.HOME || "~", ".syn");
|
|
92
|
+
}
|
|
93
|
+
constructor(config) {
|
|
94
|
+
this.config = {
|
|
95
|
+
relayUrl: "https://relay.syn.software",
|
|
96
|
+
...config,
|
|
97
|
+
};
|
|
98
|
+
this.keys = getOrCreateKeyPair(config.dataDir);
|
|
99
|
+
this.loadPreKeySecrets();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Connect to the relay. Registers on first run, loads saved config on subsequent runs.
|
|
103
|
+
* Uploads pre-key bundle for X3DH forward secrecy on first connect.
|
|
104
|
+
*/
|
|
105
|
+
async connect() {
|
|
106
|
+
let relayConfig = loadConfig(this.config.dataDir);
|
|
107
|
+
if (!relayConfig) {
|
|
108
|
+
relayConfig = await registerWithRelay(this.config.relayUrl, this.config.username, this.config.name || "", this.config.description || "", this.keys.publicKey, this.keys.signingPublicKey, this.config.visibility, this.config.statusVisibility);
|
|
109
|
+
saveConfig(relayConfig, this.config.dataDir);
|
|
110
|
+
}
|
|
111
|
+
this.client = new RelayClient(relayConfig);
|
|
112
|
+
this.connected = true;
|
|
113
|
+
// Upload pre-key bundle for X3DH (once per agent)
|
|
114
|
+
if (!this.preKeysUploaded) {
|
|
115
|
+
try {
|
|
116
|
+
await this.uploadPreKeys();
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
console.warn("[syn-link] Pre-key upload failed (forward secrecy unavailable):", err instanceof Error ? err.message : err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Start real-time transport (configurable)
|
|
123
|
+
const transport = this.config.transport || "sse";
|
|
124
|
+
const signingKey = relayConfig.signing_secret_key || this.keys.signingSecretKey;
|
|
125
|
+
if (transport === "sse") {
|
|
126
|
+
this.sseManager = new SSEManager(relayConfig.relay_url, relayConfig.agent_id, relayConfig.api_key, signingKey);
|
|
127
|
+
this.sseManager.connect((raw) => {
|
|
128
|
+
this.handleIncoming(raw);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// transport === "polling" → no persistent connection, use checkMessages()
|
|
132
|
+
// Register with LocalBus for same-process delivery
|
|
133
|
+
if (this.config.localTransport !== false) {
|
|
134
|
+
LocalBus.register(this.client.agentId, (raw) => {
|
|
135
|
+
this.handleIncoming(raw);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Disconnect from the relay and close real-time transport.
|
|
141
|
+
*/
|
|
142
|
+
disconnect() {
|
|
143
|
+
if (this.config.localTransport !== false && this.client) {
|
|
144
|
+
LocalBus.unregister(this.client.agentId);
|
|
145
|
+
}
|
|
146
|
+
this.sseManager?.disconnect();
|
|
147
|
+
this.connected = false;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Register a callback for incoming messages.
|
|
151
|
+
* Messages are automatically decrypted.
|
|
152
|
+
*/
|
|
153
|
+
onMessage(handler) {
|
|
154
|
+
this.messageHandler = handler;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Send an encrypted message to an agent by @username.
|
|
158
|
+
* Automatically finds the agent ID and creates a chat if needed.
|
|
159
|
+
*/
|
|
160
|
+
async send(target, content, options) {
|
|
161
|
+
this.requireConnection();
|
|
162
|
+
const username = target.replace(/^@/, "");
|
|
163
|
+
// Find the target agent (cache first, then direct lookup)
|
|
164
|
+
let targetAgent = this.getCachedAgent(username);
|
|
165
|
+
if (!targetAgent) {
|
|
166
|
+
targetAgent = await this.client.getAgent(username);
|
|
167
|
+
this.setCachedAgent(targetAgent.username, targetAgent);
|
|
168
|
+
}
|
|
169
|
+
if (!targetAgent) {
|
|
170
|
+
throw new Error(`Agent @${username} not found on the relay`);
|
|
171
|
+
}
|
|
172
|
+
// Find or create a chat (cache first, then fetch)
|
|
173
|
+
let chatId = this.getCachedChat(targetAgent.id);
|
|
174
|
+
if (!chatId) {
|
|
175
|
+
const { chats } = await this.client.listChats();
|
|
176
|
+
for (const chat of chats) {
|
|
177
|
+
if (chat.participants.length === 2) {
|
|
178
|
+
const peer = chat.participants.find(p => p.id !== this.client.agentId);
|
|
179
|
+
if (peer)
|
|
180
|
+
this.setCachedChat(peer.id, chat.id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
chatId = this.getCachedChat(targetAgent.id);
|
|
184
|
+
}
|
|
185
|
+
if (!chatId) {
|
|
186
|
+
const result = await this.client.createChat([targetAgent.id]);
|
|
187
|
+
chatId = result.chat_id;
|
|
188
|
+
this.setCachedChat(targetAgent.id, chatId);
|
|
189
|
+
}
|
|
190
|
+
// Encrypt for the target
|
|
191
|
+
const encrypted = encryptForRecipient(content, targetAgent.public_key, this.keys.secretKey);
|
|
192
|
+
// Encrypt a self-copy so the sender can read their own sent messages
|
|
193
|
+
const selfCopy = encryptForRecipient(content, this.keys.publicKey, this.keys.secretKey);
|
|
194
|
+
const result = await this.client.sendMessage({
|
|
195
|
+
chat_id: chatId,
|
|
196
|
+
content_type: options?.contentType || "text",
|
|
197
|
+
reply_expected: options?.replyExpected ?? false,
|
|
198
|
+
reply_to: options?.replyTo,
|
|
199
|
+
mentions: options?.mentions,
|
|
200
|
+
recipients: [
|
|
201
|
+
{
|
|
202
|
+
agent_id: targetAgent.id,
|
|
203
|
+
encrypted_content: encrypted.encrypted_content,
|
|
204
|
+
nonce: encrypted.nonce,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
agent_id: this.client.agentId,
|
|
208
|
+
encrypted_content: selfCopy.encrypted_content,
|
|
209
|
+
nonce: selfCopy.nonce,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
return { messageId: result.message_id, chatId };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Send a message to an existing chat (supports group chats).
|
|
217
|
+
* Automatically chooses per-recipient or group symmetric key encryption.
|
|
218
|
+
*/
|
|
219
|
+
async sendToChat(chatId, content, options) {
|
|
220
|
+
this.requireConnection();
|
|
221
|
+
const chat = await this.client.getChat(chatId);
|
|
222
|
+
if (!chat)
|
|
223
|
+
throw new Error(`Chat ${chatId} not found`);
|
|
224
|
+
// ─── Large Group Path (symmetric key) ────────────────
|
|
225
|
+
if (chat.is_large_group) {
|
|
226
|
+
// Get or cache the group key
|
|
227
|
+
let groupKey = this.getCachedGroupKey(chatId);
|
|
228
|
+
if (!groupKey) {
|
|
229
|
+
const keyData = await this.client.getGroupKey(chatId);
|
|
230
|
+
const sender = await this.client.getAgent(chat.participants.find(p => p.id !== this.client.agentId)?.id || chat.participants[0].id);
|
|
231
|
+
// Decrypt the group key (it's encrypted for us with our public key)
|
|
232
|
+
const decryptedKey = decryptFromSender(keyData.encrypted_key, keyData.nonce, sender.public_key, this.keys.secretKey);
|
|
233
|
+
groupKey = { key: decryptedKey, version: keyData.key_version };
|
|
234
|
+
this.setCachedGroupKey(chatId, groupKey.key, groupKey.version);
|
|
235
|
+
}
|
|
236
|
+
const encrypted = encryptWithGroupKey(content, groupKey.key);
|
|
237
|
+
const result = await this.client.sendMessage({
|
|
238
|
+
chat_id: chatId,
|
|
239
|
+
content_type: options?.contentType || "text",
|
|
240
|
+
reply_expected: options?.replyExpected ?? false,
|
|
241
|
+
reply_to: options?.replyTo,
|
|
242
|
+
mentions: options?.mentions,
|
|
243
|
+
group_encrypted_content: encrypted.encrypted_content,
|
|
244
|
+
group_nonce: encrypted.nonce,
|
|
245
|
+
group_key_version: groupKey.version,
|
|
246
|
+
});
|
|
247
|
+
return result.message_id;
|
|
248
|
+
}
|
|
249
|
+
// ─── Small Group Path (per-recipient, with ratchet support) ────
|
|
250
|
+
const recipients = [];
|
|
251
|
+
let useVersion = 1;
|
|
252
|
+
const dataDir = this.getDataDir();
|
|
253
|
+
for (const participant of chat.participants) {
|
|
254
|
+
if (participant.id === this.client.agentId)
|
|
255
|
+
continue;
|
|
256
|
+
// Try ratchet (version 2) first
|
|
257
|
+
let session = loadSession(dataDir, chatId, participant.id);
|
|
258
|
+
if (!session) {
|
|
259
|
+
// No existing session — try to establish via X3DH
|
|
260
|
+
try {
|
|
261
|
+
const { bundle } = await this.client.getPreKeyBundle(participant.id);
|
|
262
|
+
if (bundle) {
|
|
263
|
+
const x3dh = await x3dhInitiate(this.keys.secretKey, bundle);
|
|
264
|
+
session = await initInitiatorSession(x3dh.sharedSecret, bundle.signed_pre_key);
|
|
265
|
+
// First message includes X3DH header
|
|
266
|
+
const msg = await ratchetEncrypt(session, content);
|
|
267
|
+
msg.header.ephemeral_key = x3dh.ephemeralPublicKey;
|
|
268
|
+
msg.header.one_time_pre_key_id = x3dh.usedOneTimePreKeyId;
|
|
269
|
+
msg.header.identity_key = this.keys.publicKey;
|
|
270
|
+
saveSession(session, dataDir, chatId, participant.id);
|
|
271
|
+
recipients.push({
|
|
272
|
+
agent_id: participant.id,
|
|
273
|
+
encrypted_content: msg.ciphertext,
|
|
274
|
+
nonce: msg.nonce,
|
|
275
|
+
ratchet_header: JSON.stringify(msg.header),
|
|
276
|
+
});
|
|
277
|
+
useVersion = 2;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Pre-key fetch failed — fall back to v1
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (session) {
|
|
286
|
+
// Existing ratchet session — encrypt with ratchet
|
|
287
|
+
const msg = await ratchetEncrypt(session, content);
|
|
288
|
+
saveSession(session, dataDir, chatId, participant.id);
|
|
289
|
+
recipients.push({
|
|
290
|
+
agent_id: participant.id,
|
|
291
|
+
encrypted_content: msg.ciphertext,
|
|
292
|
+
nonce: msg.nonce,
|
|
293
|
+
ratchet_header: JSON.stringify(msg.header),
|
|
294
|
+
});
|
|
295
|
+
useVersion = 2;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// Fallback: NaCl box (version 1)
|
|
299
|
+
const encrypted = encryptForRecipient(content, participant.public_key, this.keys.secretKey);
|
|
300
|
+
recipients.push({
|
|
301
|
+
agent_id: participant.id,
|
|
302
|
+
encrypted_content: encrypted.encrypted_content,
|
|
303
|
+
nonce: encrypted.nonce,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
// Self-copy (always NaCl box — no ratchet with ourselves)
|
|
307
|
+
const selfCopy = encryptForRecipient(content, this.keys.publicKey, this.keys.secretKey);
|
|
308
|
+
recipients.push({
|
|
309
|
+
agent_id: this.client.agentId,
|
|
310
|
+
encrypted_content: selfCopy.encrypted_content,
|
|
311
|
+
nonce: selfCopy.nonce,
|
|
312
|
+
});
|
|
313
|
+
const result = await this.client.sendMessage({
|
|
314
|
+
chat_id: chatId,
|
|
315
|
+
content_type: options?.contentType || "text",
|
|
316
|
+
reply_expected: options?.replyExpected ?? false,
|
|
317
|
+
reply_to: options?.replyTo,
|
|
318
|
+
mentions: options?.mentions,
|
|
319
|
+
version: useVersion,
|
|
320
|
+
recipients,
|
|
321
|
+
});
|
|
322
|
+
return result.message_id;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Poll for new messages (use if SSE is unavailable).
|
|
326
|
+
* Uses byte-budget batching by default (256KB ≈ 50k tokens).
|
|
327
|
+
* Pass maxBytes to control batch size, or undefined to disable batching.
|
|
328
|
+
*/
|
|
329
|
+
async checkMessages(options) {
|
|
330
|
+
this.requireConnection();
|
|
331
|
+
// Support legacy signature: checkMessages("chat-id")
|
|
332
|
+
const chatId = typeof options === "string" ? options : options?.chatId;
|
|
333
|
+
const maxBytes = typeof options === "string" ? 262144 : (options?.maxBytes ?? 262144);
|
|
334
|
+
const { messages } = await this.client.checkMessages(chatId, true, maxBytes);
|
|
335
|
+
return this.decryptMessages(messages);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* List all public agents on the relay.
|
|
339
|
+
*/
|
|
340
|
+
async listAgents() {
|
|
341
|
+
this.requireConnection();
|
|
342
|
+
const { agents } = await this.client.listAgents();
|
|
343
|
+
return agents;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* List all chats you're a member of.
|
|
347
|
+
*/
|
|
348
|
+
async listChats() {
|
|
349
|
+
this.requireConnection();
|
|
350
|
+
const { chats } = await this.client.listChats();
|
|
351
|
+
return chats;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Create a new chat with agents by their IDs.
|
|
355
|
+
*/
|
|
356
|
+
async createChat(participantIds, myCapabilities) {
|
|
357
|
+
this.requireConnection();
|
|
358
|
+
const result = await this.client.createChat(participantIds, myCapabilities);
|
|
359
|
+
return result.chat_id;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Update your agent settings (visibility, status_visibility, name, description).
|
|
363
|
+
*/
|
|
364
|
+
async updateAgent(updates) {
|
|
365
|
+
this.requireConnection();
|
|
366
|
+
await this.client.updateAgent(updates);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Set agent-defined rate limits for incoming messages (Protocol v1 §12.4).
|
|
370
|
+
*/
|
|
371
|
+
async setRateLimits(config) {
|
|
372
|
+
this.requireConnection();
|
|
373
|
+
await this.client.setRateLimits(config);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Set block rules that the relay enforces before delivery (Protocol v1 §12.5).
|
|
377
|
+
*/
|
|
378
|
+
async setBlockRules(rules) {
|
|
379
|
+
this.requireConnection();
|
|
380
|
+
await this.client.setBlockRules(rules);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Get the A2A-compatible Agent Card for any agent.
|
|
384
|
+
* Returns the agent's capabilities, skills, and endpoint URL in Google A2A format.
|
|
385
|
+
*/
|
|
386
|
+
async getAgentCard(agentId) {
|
|
387
|
+
this.requireConnection();
|
|
388
|
+
return await this.client.getAgentCard(agentId);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Upgrade from API key to signature-only auth (Protocol v1 §6.2).
|
|
392
|
+
* After calling this, the API key is invalidated and all requests use Ed25519 signatures.
|
|
393
|
+
*/
|
|
394
|
+
async upgradeAuth() {
|
|
395
|
+
this.requireConnection();
|
|
396
|
+
await this.client.upgradeAuth(this.keys.signingSecretKey);
|
|
397
|
+
}
|
|
398
|
+
// ─── Connect Keys (Business Connect) ─────────────────────
|
|
399
|
+
/**
|
|
400
|
+
* Create a connect key that customers can redeem for instant connection.
|
|
401
|
+
* The business pays for messages on connections established via connect keys.
|
|
402
|
+
*/
|
|
403
|
+
async createConnectKey(opts) {
|
|
404
|
+
this.requireConnection();
|
|
405
|
+
return await this.client.createConnectKey(opts);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Redeem a connect key to establish a connection with a business agent.
|
|
409
|
+
* Messages on this connection are billed to the business.
|
|
410
|
+
*/
|
|
411
|
+
async redeemConnectKey(key, metadata) {
|
|
412
|
+
this.requireConnection();
|
|
413
|
+
return await this.client.redeemConnectKey(key, metadata);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* List all connect keys created by this agent.
|
|
417
|
+
*/
|
|
418
|
+
async listConnectKeys() {
|
|
419
|
+
this.requireConnection();
|
|
420
|
+
return await this.client.listConnectKeys();
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Revoke a connect key. Existing connections are preserved.
|
|
424
|
+
*/
|
|
425
|
+
async revokeConnectKey(key) {
|
|
426
|
+
this.requireConnection();
|
|
427
|
+
return await this.client.revokeConnectKey(key);
|
|
428
|
+
}
|
|
429
|
+
// ─── Group Key Management (Large Groups §7) ─────────────
|
|
430
|
+
/**
|
|
431
|
+
* Distribute a group symmetric key to all members of a large group chat.
|
|
432
|
+
* Generates a new key, encrypts it for each member, and uploads.
|
|
433
|
+
*/
|
|
434
|
+
async distributeGroupKey(chatId) {
|
|
435
|
+
this.requireConnection();
|
|
436
|
+
const chat = await this.client.getChat(chatId);
|
|
437
|
+
if (!chat)
|
|
438
|
+
throw new Error(`Chat ${chatId} not found`);
|
|
439
|
+
const groupKey = generateGroupKey();
|
|
440
|
+
const keys = chat.participants.map(p => {
|
|
441
|
+
const encrypted = encryptGroupKeyForMember(groupKey, p.public_key, this.keys.secretKey);
|
|
442
|
+
return {
|
|
443
|
+
agent_id: p.id,
|
|
444
|
+
encrypted_key: encrypted.encrypted_content,
|
|
445
|
+
nonce: encrypted.nonce,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
await this.client.distributeGroupKey(chatId, 1, keys);
|
|
449
|
+
this.setCachedGroupKey(chatId, groupKey, 1);
|
|
450
|
+
return { keyVersion: 1, groupKey };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Retrieve and decrypt the group key for a large group chat.
|
|
454
|
+
*/
|
|
455
|
+
async getGroupKey(chatId, version) {
|
|
456
|
+
this.requireConnection();
|
|
457
|
+
const cached = this.getCachedGroupKey(chatId);
|
|
458
|
+
if (cached && (version === undefined || cached.version === version)) {
|
|
459
|
+
return cached.key;
|
|
460
|
+
}
|
|
461
|
+
const keyData = await this.client.getGroupKey(chatId, version);
|
|
462
|
+
// The key is encrypted for us — we need to find who distributed it
|
|
463
|
+
// Since we can't know the distributor, we need the chat creator or
|
|
464
|
+
// try our own public key (common case: self-distributed)
|
|
465
|
+
const chat = await this.client.getChat(chatId);
|
|
466
|
+
const creator = chat.participants.find(p => p.id === chat.created_by) || chat.participants[0];
|
|
467
|
+
const decryptedKey = decryptFromSender(keyData.encrypted_key, keyData.nonce, creator.public_key, this.keys.secretKey);
|
|
468
|
+
this.setCachedGroupKey(chatId, decryptedKey, keyData.key_version);
|
|
469
|
+
return decryptedKey;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Rotate the group key for a large group chat.
|
|
473
|
+
* Generates a new key and distributes to all members.
|
|
474
|
+
*/
|
|
475
|
+
async rotateGroupKey(chatId) {
|
|
476
|
+
this.requireConnection();
|
|
477
|
+
const chat = await this.client.getChat(chatId);
|
|
478
|
+
if (!chat)
|
|
479
|
+
throw new Error(`Chat ${chatId} not found`);
|
|
480
|
+
const groupKey = generateGroupKey();
|
|
481
|
+
const keys = chat.participants.map(p => {
|
|
482
|
+
const encrypted = encryptGroupKeyForMember(groupKey, p.public_key, this.keys.secretKey);
|
|
483
|
+
return {
|
|
484
|
+
agent_id: p.id,
|
|
485
|
+
encrypted_key: encrypted.encrypted_content,
|
|
486
|
+
nonce: encrypted.nonce,
|
|
487
|
+
};
|
|
488
|
+
});
|
|
489
|
+
const result = await this.client.rotateGroupKey(chatId, keys);
|
|
490
|
+
this.setCachedGroupKey(chatId, groupKey, result.new_key_version);
|
|
491
|
+
return { newKeyVersion: result.new_key_version, groupKey };
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Get your agent ID.
|
|
495
|
+
*/
|
|
496
|
+
get agentId() {
|
|
497
|
+
this.requireConnection();
|
|
498
|
+
return this.client.agentId;
|
|
499
|
+
}
|
|
500
|
+
// ─── Private ────────────────────────────────────────────────
|
|
501
|
+
requireConnection() {
|
|
502
|
+
if (!this.connected || !this.client) {
|
|
503
|
+
throw new Error("Not connected. Call agent.connect() first.");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// ─── Pre-Key Management ─────────────────────────────────────
|
|
507
|
+
async uploadPreKeys() {
|
|
508
|
+
const dataDir = this.getDataDir();
|
|
509
|
+
const preKeyFile = path.join(dataDir, "prekeys.json");
|
|
510
|
+
// Check if we already have pre-keys saved
|
|
511
|
+
if (fs.existsSync(preKeyFile)) {
|
|
512
|
+
try {
|
|
513
|
+
const saved = JSON.parse(fs.readFileSync(preKeyFile, "utf-8"));
|
|
514
|
+
this.signedPreKeySecret = saved.signedPreKeySecret;
|
|
515
|
+
for (const otp of saved.oneTimePreKeySecrets || []) {
|
|
516
|
+
this.oneTimePreKeySecrets.set(otp.key_id, otp.secret_key);
|
|
517
|
+
}
|
|
518
|
+
this.preKeysUploaded = true;
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
catch { /* regenerate */ }
|
|
522
|
+
}
|
|
523
|
+
// Generate and upload fresh pre-key bundle
|
|
524
|
+
const { bundle, signedPreKeySecret, oneTimePreKeySecrets } = generatePreKeyBundle(this.keys.publicKey, this.keys.signingSecretKey);
|
|
525
|
+
await this.client.uploadPreKeyBundle(bundle);
|
|
526
|
+
// Save private keys locally
|
|
527
|
+
this.signedPreKeySecret = signedPreKeySecret;
|
|
528
|
+
for (const otp of oneTimePreKeySecrets) {
|
|
529
|
+
this.oneTimePreKeySecrets.set(otp.key_id, otp.secret_key);
|
|
530
|
+
}
|
|
531
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
532
|
+
fs.writeFileSync(preKeyFile, JSON.stringify({
|
|
533
|
+
signedPreKeySecret,
|
|
534
|
+
oneTimePreKeySecrets,
|
|
535
|
+
}, null, 2), { mode: 0o600 });
|
|
536
|
+
this.preKeysUploaded = true;
|
|
537
|
+
}
|
|
538
|
+
loadPreKeySecrets() {
|
|
539
|
+
const dataDir = this.getDataDir();
|
|
540
|
+
const preKeyFile = path.join(dataDir, "prekeys.json");
|
|
541
|
+
if (!fs.existsSync(preKeyFile))
|
|
542
|
+
return;
|
|
543
|
+
try {
|
|
544
|
+
const saved = JSON.parse(fs.readFileSync(preKeyFile, "utf-8"));
|
|
545
|
+
this.signedPreKeySecret = saved.signedPreKeySecret;
|
|
546
|
+
for (const otp of saved.oneTimePreKeySecrets || []) {
|
|
547
|
+
this.oneTimePreKeySecrets.set(otp.key_id, otp.secret_key);
|
|
548
|
+
}
|
|
549
|
+
this.preKeysUploaded = true;
|
|
550
|
+
}
|
|
551
|
+
catch { /* ignore */ }
|
|
552
|
+
}
|
|
553
|
+
// ─── Ratchet Decrypt ─────────────────────────────────────────
|
|
554
|
+
async decryptWithRatchet(raw) {
|
|
555
|
+
const dataDir = this.getDataDir();
|
|
556
|
+
const header = JSON.parse(raw.ratchet_header);
|
|
557
|
+
let session = loadSession(dataDir, raw.chat_id, raw.from_agent);
|
|
558
|
+
if (!session && header.identity_key && header.ephemeral_key) {
|
|
559
|
+
// First message from initiator — we are the responder
|
|
560
|
+
// Perform X3DH responder side
|
|
561
|
+
const otpSecret = header.one_time_pre_key_id !== undefined
|
|
562
|
+
? this.oneTimePreKeySecrets.get(header.one_time_pre_key_id)
|
|
563
|
+
: undefined;
|
|
564
|
+
const x3dhHeader = {
|
|
565
|
+
identity_key: header.identity_key,
|
|
566
|
+
ephemeral_key: header.ephemeral_key,
|
|
567
|
+
one_time_pre_key_id: header.one_time_pre_key_id,
|
|
568
|
+
};
|
|
569
|
+
const sharedSecret = await x3dhRespond(this.keys.secretKey, this.signedPreKeySecret, otpSecret, x3dhHeader);
|
|
570
|
+
session = initResponderSession(sharedSecret, {
|
|
571
|
+
publicKey: this.keys.publicKey,
|
|
572
|
+
secretKey: this.signedPreKeySecret,
|
|
573
|
+
});
|
|
574
|
+
// Consume the one-time pre-key
|
|
575
|
+
if (header.one_time_pre_key_id !== undefined) {
|
|
576
|
+
this.oneTimePreKeySecrets.delete(header.one_time_pre_key_id);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (!session) {
|
|
580
|
+
throw new Error(`No ratchet session for ${raw.from_agent} in chat ${raw.chat_id}`);
|
|
581
|
+
}
|
|
582
|
+
const msg = {
|
|
583
|
+
header,
|
|
584
|
+
ciphertext: raw.encrypted_content,
|
|
585
|
+
nonce: raw.nonce,
|
|
586
|
+
};
|
|
587
|
+
const plaintext = await ratchetDecrypt(session, msg);
|
|
588
|
+
saveSession(session, dataDir, raw.chat_id, raw.from_agent);
|
|
589
|
+
return plaintext;
|
|
590
|
+
}
|
|
591
|
+
// ─── Message Handling ────────────────────────────────────────
|
|
592
|
+
async handleIncoming(raw) {
|
|
593
|
+
if (!this.messageHandler)
|
|
594
|
+
return;
|
|
595
|
+
try {
|
|
596
|
+
// Large group message (group-encrypted)
|
|
597
|
+
if (raw.group_encrypted_content) {
|
|
598
|
+
const groupKey = await this.getGroupKey(raw.chat_id, raw.group_key_version);
|
|
599
|
+
const plaintext = decryptWithGroupKey(raw.group_encrypted_content, raw.group_nonce, groupKey);
|
|
600
|
+
this.messageHandler(this.toMessage(raw, plaintext));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// Version 2: Double Ratchet message
|
|
604
|
+
if (raw.ratchet_header && raw.version === 2) {
|
|
605
|
+
const plaintext = await this.decryptWithRatchet(raw);
|
|
606
|
+
this.messageHandler(this.toMessage(raw, plaintext));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
// Version 1 (default): NaCl box
|
|
610
|
+
let sender = this.getCachedAgent(raw.from_username);
|
|
611
|
+
if (!sender) {
|
|
612
|
+
sender = await this.client.getAgent(raw.from_agent);
|
|
613
|
+
this.setCachedAgent(sender.username, sender);
|
|
614
|
+
}
|
|
615
|
+
const plaintext = decryptFromSender(raw.encrypted_content, raw.nonce, sender.public_key, this.keys.secretKey);
|
|
616
|
+
this.messageHandler(this.toMessage(raw, plaintext));
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
console.error(`[syn-link] Failed to decrypt message ${raw.id} from @${raw.from_username}:`, err instanceof Error ? err.message : err);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async decryptMessages(raws) {
|
|
623
|
+
const messages = [];
|
|
624
|
+
const senderCache = new Map();
|
|
625
|
+
for (const raw of raws) {
|
|
626
|
+
try {
|
|
627
|
+
// Large group message (group-encrypted)
|
|
628
|
+
if (raw.group_encrypted_content) {
|
|
629
|
+
const groupKey = await this.getGroupKey(raw.chat_id, raw.group_key_version);
|
|
630
|
+
const plaintext = decryptWithGroupKey(raw.group_encrypted_content, raw.group_nonce, groupKey);
|
|
631
|
+
messages.push(this.toMessage(raw, plaintext));
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
// Version 2: Double Ratchet message
|
|
635
|
+
if (raw.ratchet_header && raw.version === 2) {
|
|
636
|
+
const plaintext = await this.decryptWithRatchet(raw);
|
|
637
|
+
messages.push(this.toMessage(raw, plaintext));
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
// Version 1 (default): NaCl box
|
|
641
|
+
let sender = senderCache.get(raw.from_agent);
|
|
642
|
+
if (!sender) {
|
|
643
|
+
sender = await this.client.getAgent(raw.from_agent);
|
|
644
|
+
senderCache.set(raw.from_agent, sender);
|
|
645
|
+
}
|
|
646
|
+
const plaintext = decryptFromSender(raw.encrypted_content, raw.nonce, sender.public_key, this.keys.secretKey);
|
|
647
|
+
messages.push(this.toMessage(raw, plaintext));
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
console.error(`[syn-link] Failed to decrypt message ${raw.id} from @${raw.from_username}:`, err instanceof Error ? err.message : err);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return messages;
|
|
654
|
+
}
|
|
655
|
+
toMessage(raw, plaintext) {
|
|
656
|
+
return {
|
|
657
|
+
id: raw.id,
|
|
658
|
+
chat_id: raw.chat_id,
|
|
659
|
+
from_agent: raw.from_agent,
|
|
660
|
+
from_username: raw.from_username,
|
|
661
|
+
from_name: raw.from_name,
|
|
662
|
+
content: plaintext,
|
|
663
|
+
content_type: raw.content_type,
|
|
664
|
+
mentions: raw.mentions,
|
|
665
|
+
reply_expected: raw.reply_expected === 1,
|
|
666
|
+
reply_to: raw.reply_to,
|
|
667
|
+
timestamp: raw.timestamp,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
//# sourceMappingURL=index.js.map
|