@agentvault/secure-channel 0.1.2 → 0.2.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,21 @@
1
+ /**
2
+ * Functional QA Test Suite — Agent Setup & Connection
3
+ *
4
+ * Tests the full agent lifecycle against the production API:
5
+ * 1. Package installation & CLI
6
+ * 2. Crypto operations (key gen, proof, fingerprint)
7
+ * 3. API connectivity & error handling
8
+ * 4. Full enrollment flow (create invite → enroll → approve → activate → connect)
9
+ *
10
+ * Requires env vars:
11
+ * CLERK_SECRET_KEY — Clerk backend secret for session token creation
12
+ * CLERK_SESSION_ID — Active Clerk session ID (for Eric's account)
13
+ * API_URL — Backend API URL (default: https://api.agentvault.chat)
14
+ *
15
+ * Rate limit note: enrollment is limited to 5 requests/IP/10min.
16
+ * This suite uses 2 enrollment calls (4.2 + 4.9). Error-case enrollment
17
+ * tests (3.3, 3.4) are removed to conserve rate limit budget.
18
+ * Test 4.2 will retry with backoff if rate-limited (up to 3 retries, 30s apart).
19
+ */
20
+ export {};
21
+ //# sourceMappingURL=functional.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"functional.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/functional.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=multi-session.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multi-session.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/multi-session.test.ts"],"names":[],"mappings":""}
package/dist/channel.d.ts CHANGED
@@ -5,9 +5,9 @@ export declare class SecureChannel extends EventEmitter {
5
5
  private _state;
6
6
  private _deviceId;
7
7
  private _fingerprint;
8
- private _conversationId;
8
+ private _primaryConversationId;
9
9
  private _deviceJwt;
10
- private _ratchet;
10
+ private _sessions;
11
11
  private _ws;
12
12
  private _pollTimer;
13
13
  private _reconnectAttempt;
@@ -18,18 +18,52 @@ export declare class SecureChannel extends EventEmitter {
18
18
  get state(): ChannelState;
19
19
  get deviceId(): string | null;
20
20
  get fingerprint(): string | null;
21
+ /** Returns the primary conversation ID (backward-compatible). */
21
22
  get conversationId(): string | null;
23
+ /** Returns all active conversation IDs. */
24
+ get conversationIds(): string[];
25
+ /** Returns the number of active sessions. */
26
+ get sessionCount(): number;
22
27
  start(): Promise<void>;
28
+ /**
29
+ * Encrypt and send a message to ALL owner devices (fanout).
30
+ * Each session gets the same plaintext encrypted independently.
31
+ */
23
32
  send(plaintext: string): Promise<void>;
24
33
  stop(): Promise<void>;
25
34
  private _enroll;
26
35
  private _poll;
27
36
  private _activate;
28
37
  private _connect;
38
+ /**
39
+ * Handle an incoming encrypted message from a specific conversation.
40
+ * Decrypts using the appropriate session ratchet, emits to the agent,
41
+ * and relays as sync messages to sibling sessions.
42
+ */
43
+ private _handleIncomingMessage;
44
+ /**
45
+ * Relay an owner's message to all sibling sessions as encrypted sync messages.
46
+ * This allows all owner devices to see messages from any single device.
47
+ */
48
+ private _relaySyncToSiblings;
49
+ /**
50
+ * Handle a device_linked event: a new owner device has joined.
51
+ * Fetches the new device's public keys, performs X3DH, and initializes
52
+ * a new ratchet session.
53
+ */
54
+ private _handleDeviceLinked;
55
+ /**
56
+ * Sync missed messages across ALL sessions.
57
+ * For each conversation, fetches messages since last sync and decrypts.
58
+ */
29
59
  private _syncMissedMessages;
30
60
  private _scheduleReconnect;
31
61
  private _setState;
32
62
  private _handleError;
63
+ /**
64
+ * Persist all ratchet session states to disk.
65
+ * Syncs live ratchet states back into the persisted sessions map.
66
+ */
33
67
  private _persistState;
34
68
  }
35
69
  //# sourceMappingURL=channel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAY3C,OAAO,KAAK,EACV,mBAAmB,EACnB,YAAY,EAGb,MAAM,YAAY,CAAC;AAkBpB,qBAAa,aAAc,SAAQ,YAAY;IAcjC,OAAO,CAAC,MAAM;IAb1B,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,GAAG,CAA0B;IACrC,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,eAAe,CAA8C;IACrE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAA+B;gBAE7B,MAAM,EAAE,mBAAmB;IAI/C,IAAI,KAAK,IAAI,YAAY,CAExB;IAED,IAAI,QAAQ,IAAI,MAAM,GAAG,IAAI,CAE5B;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,IAAI,CAE/B;IAED,IAAI,cAAc,IAAI,MAAM,GAAG,IAAI,CAElC;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAqBtB,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAuBtC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAoBb,OAAO;IA+CrB,OAAO,CAAC,KAAK;YAgCC,SAAS;IAgDvB,OAAO,CAAC,QAAQ;YAgFF,mBAAmB;IAuDjC,OAAO,CAAC,kBAAkB;IAgB1B,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;YAKN,aAAa;CAK5B"}
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAa3C,OAAO,KAAK,EACV,mBAAmB,EACnB,YAAY,EAIb,MAAM,YAAY,CAAC;AAgDpB,qBAAa,aAAc,SAAQ,YAAY;IAiBjC,OAAO,CAAC,MAAM;IAhB1B,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,sBAAsB,CAAc;IAC5C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,SAAS,CAGH;IACd,OAAO,CAAC,GAAG,CAA0B;IACrC,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,eAAe,CAA8C;IACrE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAA+B;gBAE7B,MAAM,EAAE,mBAAmB;IAI/C,IAAI,KAAK,IAAI,YAAY,CAExB;IAED,IAAI,QAAQ,IAAI,MAAM,GAAG,IAAI,CAE5B;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,IAAI,CAE/B;IAED,iEAAiE;IACjE,IAAI,cAAc,IAAI,MAAM,GAAG,IAAI,CAElC;IAED,2CAA2C;IAC3C,IAAI,eAAe,IAAI,MAAM,EAAE,CAE9B;IAED,6CAA6C;IAC7C,IAAI,YAAY,IAAI,MAAM,CAEzB;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAkC5B;;;OAGG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA4BtC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAoBb,OAAO;IA+CrB,OAAO,CAAC,KAAK;YAgCC,SAAS;IAyEvB,OAAO,CAAC,QAAQ;IA4DhB;;;;OAIG;YACW,sBAAsB;IAmEpC;;;OAGG;YACW,oBAAoB;IAiClC;;;;OAIG;YACW,mBAAmB;IAuEjC;;;OAGG;YACW,mBAAmB;IA+EjC,OAAO,CAAC,kBAAkB;IAgB1B,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;IAKpB;;;OAGG;YACW,aAAa;CAa5B"}
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/channel.ts
4
4
  import { EventEmitter } from "node:events";
5
+ import { randomUUID } from "node:crypto";
5
6
 
6
7
  // ../../node_modules/libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs
7
8
  var __filename;
@@ -45035,6 +45036,27 @@ async function activateDevice(apiUrl2, deviceId) {
45035
45036
  var POLL_INTERVAL_MS = 5e3;
45036
45037
  var RECONNECT_BASE_MS = 1e3;
45037
45038
  var RECONNECT_MAX_MS = 3e4;
45039
+ function migratePersistedState(raw) {
45040
+ if (raw.sessions && raw.primaryConversationId) {
45041
+ return raw;
45042
+ }
45043
+ const legacy = raw;
45044
+ return {
45045
+ deviceId: legacy.deviceId,
45046
+ deviceJwt: legacy.deviceJwt,
45047
+ primaryConversationId: legacy.conversationId,
45048
+ sessions: {
45049
+ [legacy.conversationId]: {
45050
+ ownerDeviceId: "",
45051
+ ratchetState: legacy.ratchetState
45052
+ }
45053
+ },
45054
+ identityKeypair: legacy.identityKeypair,
45055
+ ephemeralKeypair: legacy.ephemeralKeypair,
45056
+ fingerprint: legacy.fingerprint,
45057
+ lastMessageTimestamp: legacy.lastMessageTimestamp
45058
+ };
45059
+ }
45038
45060
  var SecureChannel = class extends EventEmitter {
45039
45061
  constructor(config) {
45040
45062
  super();
@@ -45043,9 +45065,9 @@ var SecureChannel = class extends EventEmitter {
45043
45065
  _state = "idle";
45044
45066
  _deviceId = null;
45045
45067
  _fingerprint = null;
45046
- _conversationId = null;
45068
+ _primaryConversationId = "";
45047
45069
  _deviceJwt = null;
45048
- _ratchet = null;
45070
+ _sessions = /* @__PURE__ */ new Map();
45049
45071
  _ws = null;
45050
45072
  _pollTimer = null;
45051
45073
  _reconnectAttempt = 0;
@@ -45061,41 +45083,68 @@ var SecureChannel = class extends EventEmitter {
45061
45083
  get fingerprint() {
45062
45084
  return this._fingerprint;
45063
45085
  }
45086
+ /** Returns the primary conversation ID (backward-compatible). */
45064
45087
  get conversationId() {
45065
- return this._conversationId;
45088
+ return this._primaryConversationId || null;
45089
+ }
45090
+ /** Returns all active conversation IDs. */
45091
+ get conversationIds() {
45092
+ return Array.from(this._sessions.keys());
45093
+ }
45094
+ /** Returns the number of active sessions. */
45095
+ get sessionCount() {
45096
+ return this._sessions.size;
45066
45097
  }
45067
45098
  async start() {
45068
45099
  this._stopped = false;
45069
45100
  await libsodium_wrappers_default.ready;
45070
- const persisted = await loadState(this.config.dataDir);
45071
- if (persisted) {
45072
- this._persisted = persisted;
45073
- this._deviceId = persisted.deviceId;
45074
- this._deviceJwt = persisted.deviceJwt;
45075
- this._conversationId = persisted.conversationId;
45076
- this._fingerprint = persisted.fingerprint;
45077
- this._ratchet = DoubleRatchet.deserialize(persisted.ratchetState);
45101
+ const raw = await loadState(this.config.dataDir);
45102
+ if (raw) {
45103
+ this._persisted = migratePersistedState(raw);
45104
+ this._deviceId = this._persisted.deviceId;
45105
+ this._deviceJwt = this._persisted.deviceJwt;
45106
+ this._primaryConversationId = this._persisted.primaryConversationId;
45107
+ this._fingerprint = this._persisted.fingerprint;
45108
+ for (const [convId, sessionData] of Object.entries(
45109
+ this._persisted.sessions
45110
+ )) {
45111
+ if (sessionData.ratchetState) {
45112
+ const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
45113
+ this._sessions.set(convId, {
45114
+ ownerDeviceId: sessionData.ownerDeviceId,
45115
+ ratchet
45116
+ });
45117
+ }
45118
+ }
45078
45119
  this._connect();
45079
45120
  return;
45080
45121
  }
45081
45122
  await this._enroll();
45082
45123
  }
45124
+ /**
45125
+ * Encrypt and send a message to ALL owner devices (fanout).
45126
+ * Each session gets the same plaintext encrypted independently.
45127
+ */
45083
45128
  async send(plaintext) {
45084
- if (this._state !== "ready" || !this._ratchet || !this._ws) {
45129
+ if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
45085
45130
  throw new Error("Channel is not ready");
45086
45131
  }
45087
- const encrypted = this._ratchet.encrypt(plaintext);
45088
- const transport = encryptedMessageToTransport(encrypted);
45089
- this._ws.send(
45090
- JSON.stringify({
45091
- event: "message",
45092
- data: {
45093
- conversation_id: this._conversationId,
45094
- header_blob: transport.header_blob,
45095
- ciphertext: transport.ciphertext
45096
- }
45097
- })
45098
- );
45132
+ const messageGroupId = randomUUID();
45133
+ for (const [convId, session] of this._sessions) {
45134
+ const encrypted = session.ratchet.encrypt(plaintext);
45135
+ const transport = encryptedMessageToTransport(encrypted);
45136
+ this._ws.send(
45137
+ JSON.stringify({
45138
+ event: "message",
45139
+ data: {
45140
+ conversation_id: convId,
45141
+ header_blob: transport.header_blob,
45142
+ ciphertext: transport.ciphertext,
45143
+ message_group_id: messageGroupId
45144
+ }
45145
+ })
45146
+ );
45147
+ }
45099
45148
  await this._persistState();
45100
45149
  }
45101
45150
  async stop() {
@@ -45140,8 +45189,10 @@ var SecureChannel = class extends EventEmitter {
45140
45189
  deviceId: result.device_id,
45141
45190
  deviceJwt: "",
45142
45191
  // set after activation
45143
- conversationId: "",
45192
+ primaryConversationId: "",
45144
45193
  // set after activation
45194
+ sessions: {},
45195
+ // populated after activation
45145
45196
  identityKeypair: {
45146
45197
  publicKey: bytesToHex(identity.publicKey),
45147
45198
  privateKey: bytesToHex(identity.privateKey)
@@ -45150,9 +45201,7 @@ var SecureChannel = class extends EventEmitter {
45150
45201
  publicKey: bytesToHex(ephemeral.publicKey),
45151
45202
  privateKey: bytesToHex(ephemeral.privateKey)
45152
45203
  },
45153
- fingerprint: result.fingerprint,
45154
- ratchetState: ""
45155
- // set after activation
45204
+ fingerprint: result.fingerprint
45156
45205
  };
45157
45206
  this._poll();
45158
45207
  } catch (err) {
@@ -45191,11 +45240,19 @@ var SecureChannel = class extends EventEmitter {
45191
45240
  this.config.apiUrl,
45192
45241
  this._deviceId
45193
45242
  );
45194
- this._conversationId = result.conversation_id;
45243
+ const conversations = result.conversations || [
45244
+ {
45245
+ conversation_id: result.conversation_id,
45246
+ owner_device_id: "",
45247
+ is_primary: true
45248
+ }
45249
+ ];
45250
+ const primary = conversations.find((c2) => c2.is_primary) || conversations[0];
45251
+ this._primaryConversationId = primary.conversation_id;
45195
45252
  this._deviceJwt = result.device_jwt;
45196
45253
  const identity = this._persisted.identityKeypair;
45197
45254
  const ephemeral = this._persisted.ephemeralKeypair;
45198
- const sharedSecret = await performX3DH({
45255
+ const sharedSecret = performX3DH({
45199
45256
  myIdentityPrivate: hexToBytes(identity.privateKey),
45200
45257
  myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45201
45258
  theirIdentityPublic: hexToBytes(result.owner_identity_public_key),
@@ -45204,16 +45261,25 @@ var SecureChannel = class extends EventEmitter {
45204
45261
  ),
45205
45262
  isInitiator: false
45206
45263
  });
45207
- this._ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45264
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45208
45265
  publicKey: hexToBytes(identity.publicKey),
45209
45266
  privateKey: hexToBytes(identity.privateKey),
45210
45267
  keyType: "ed25519"
45211
45268
  });
45269
+ this._sessions.set(primary.conversation_id, {
45270
+ ownerDeviceId: primary.owner_device_id,
45271
+ ratchet
45272
+ });
45212
45273
  this._persisted = {
45213
45274
  ...this._persisted,
45214
45275
  deviceJwt: result.device_jwt,
45215
- conversationId: result.conversation_id,
45216
- ratchetState: this._ratchet.serialize()
45276
+ primaryConversationId: primary.conversation_id,
45277
+ sessions: {
45278
+ [primary.conversation_id]: {
45279
+ ownerDeviceId: primary.owner_device_id,
45280
+ ratchetState: ratchet.serialize()
45281
+ }
45282
+ }
45217
45283
  };
45218
45284
  await saveState(this.config.dataDir, this._persisted);
45219
45285
  this._connect();
@@ -45246,23 +45312,12 @@ var SecureChannel = class extends EventEmitter {
45246
45312
  this._handleError(new Error("Device was revoked"));
45247
45313
  return;
45248
45314
  }
45315
+ if (data.event === "device_linked") {
45316
+ await this._handleDeviceLinked(data.data);
45317
+ return;
45318
+ }
45249
45319
  if (data.event === "message") {
45250
- const msgData = data.data;
45251
- if (msgData.sender_device_id === this._deviceId) return;
45252
- const encrypted = transportToEncryptedMessage({
45253
- header_blob: msgData.header_blob,
45254
- ciphertext: msgData.ciphertext
45255
- });
45256
- const plaintext = this._ratchet.decrypt(encrypted);
45257
- await this._persistState();
45258
- const metadata = {
45259
- messageId: msgData.message_id,
45260
- conversationId: msgData.conversation_id,
45261
- timestamp: msgData.created_at
45262
- };
45263
- this.emit("message", plaintext, metadata);
45264
- this.config.onMessage?.(plaintext, metadata);
45265
- this._persisted.lastMessageTimestamp = msgData.created_at;
45320
+ await this._handleIncomingMessage(data.data);
45266
45321
  }
45267
45322
  } catch (err) {
45268
45323
  this.emit("error", err);
@@ -45277,6 +45332,142 @@ var SecureChannel = class extends EventEmitter {
45277
45332
  this.emit("error", err);
45278
45333
  });
45279
45334
  }
45335
+ /**
45336
+ * Handle an incoming encrypted message from a specific conversation.
45337
+ * Decrypts using the appropriate session ratchet, emits to the agent,
45338
+ * and relays as sync messages to sibling sessions.
45339
+ */
45340
+ async _handleIncomingMessage(msgData) {
45341
+ if (msgData.sender_device_id === this._deviceId) return;
45342
+ const convId = msgData.conversation_id;
45343
+ const session = this._sessions.get(convId);
45344
+ if (!session) {
45345
+ console.warn(
45346
+ `[SecureChannel] No session for conversation ${convId}, skipping`
45347
+ );
45348
+ return;
45349
+ }
45350
+ const encrypted = transportToEncryptedMessage({
45351
+ header_blob: msgData.header_blob,
45352
+ ciphertext: msgData.ciphertext
45353
+ });
45354
+ const plaintext = session.ratchet.decrypt(encrypted);
45355
+ let messageText;
45356
+ let messageType;
45357
+ try {
45358
+ const parsed = JSON.parse(plaintext);
45359
+ messageType = parsed.type || "message";
45360
+ messageText = parsed.text || plaintext;
45361
+ } catch {
45362
+ messageType = "message";
45363
+ messageText = plaintext;
45364
+ }
45365
+ if (messageType === "message") {
45366
+ const metadata = {
45367
+ messageId: msgData.message_id,
45368
+ conversationId: convId,
45369
+ timestamp: msgData.created_at
45370
+ };
45371
+ this.emit("message", messageText, metadata);
45372
+ this.config.onMessage?.(messageText, metadata);
45373
+ await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText);
45374
+ }
45375
+ if (this._persisted) {
45376
+ this._persisted.lastMessageTimestamp = msgData.created_at;
45377
+ }
45378
+ await this._persistState();
45379
+ }
45380
+ /**
45381
+ * Relay an owner's message to all sibling sessions as encrypted sync messages.
45382
+ * This allows all owner devices to see messages from any single device.
45383
+ */
45384
+ async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText) {
45385
+ if (!this._ws || this._sessions.size <= 1) return;
45386
+ const syncPayload = JSON.stringify({
45387
+ type: "sync",
45388
+ sender: senderOwnerDeviceId,
45389
+ text: messageText,
45390
+ ts: (/* @__PURE__ */ new Date()).toISOString()
45391
+ });
45392
+ for (const [siblingConvId, siblingSession] of this._sessions) {
45393
+ if (siblingConvId === sourceConvId) continue;
45394
+ const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
45395
+ const syncTransport = encryptedMessageToTransport(syncEncrypted);
45396
+ this._ws.send(
45397
+ JSON.stringify({
45398
+ event: "message",
45399
+ data: {
45400
+ conversation_id: siblingConvId,
45401
+ header_blob: syncTransport.header_blob,
45402
+ ciphertext: syncTransport.ciphertext
45403
+ }
45404
+ })
45405
+ );
45406
+ }
45407
+ }
45408
+ /**
45409
+ * Handle a device_linked event: a new owner device has joined.
45410
+ * Fetches the new device's public keys, performs X3DH, and initializes
45411
+ * a new ratchet session.
45412
+ */
45413
+ async _handleDeviceLinked(event) {
45414
+ console.log(
45415
+ `[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
45416
+ );
45417
+ try {
45418
+ const keysRes = await fetch(
45419
+ `${this.config.apiUrl}/api/v1/conversations/${event.conversation_id}/keys`,
45420
+ {
45421
+ headers: { Authorization: `Bearer ${this._deviceJwt}` }
45422
+ }
45423
+ );
45424
+ if (!keysRes.ok) {
45425
+ console.error(
45426
+ `[SecureChannel] Failed to fetch keys for linked device: ${keysRes.status}`
45427
+ );
45428
+ return;
45429
+ }
45430
+ const keys = await keysRes.json();
45431
+ const identity = this._persisted.identityKeypair;
45432
+ const ephemeral = this._persisted.ephemeralKeypair;
45433
+ const sharedSecret = performX3DH({
45434
+ myIdentityPrivate: hexToBytes(identity.privateKey),
45435
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45436
+ theirIdentityPublic: hexToBytes(keys.identity_public_key),
45437
+ theirEphemeralPublic: hexToBytes(
45438
+ keys.ephemeral_public_key ?? keys.identity_public_key
45439
+ ),
45440
+ isInitiator: false
45441
+ });
45442
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45443
+ publicKey: hexToBytes(identity.publicKey),
45444
+ privateKey: hexToBytes(identity.privateKey),
45445
+ keyType: "ed25519"
45446
+ });
45447
+ this._sessions.set(event.conversation_id, {
45448
+ ownerDeviceId: event.owner_device_id,
45449
+ ratchet
45450
+ });
45451
+ this._persisted.sessions[event.conversation_id] = {
45452
+ ownerDeviceId: event.owner_device_id,
45453
+ ratchetState: ratchet.serialize()
45454
+ };
45455
+ await this._persistState();
45456
+ console.log(
45457
+ `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}...`
45458
+ );
45459
+ } catch (err) {
45460
+ console.error(
45461
+ `[SecureChannel] Failed to handle device_linked:`,
45462
+ err
45463
+ );
45464
+ this.emit("error", err);
45465
+ }
45466
+ }
45467
+ /**
45468
+ * Sync missed messages across ALL sessions.
45469
+ * For each conversation, fetches messages since last sync and decrypts.
45470
+ */
45280
45471
  async _syncMissedMessages() {
45281
45472
  if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt) return;
45282
45473
  try {
@@ -45289,19 +45480,38 @@ var SecureChannel = class extends EventEmitter {
45289
45480
  const messages = await res.json();
45290
45481
  for (const msg of messages) {
45291
45482
  if (msg.sender_device_id === this._deviceId) continue;
45483
+ const session = this._sessions.get(msg.conversation_id);
45484
+ if (!session) {
45485
+ console.warn(
45486
+ `[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`
45487
+ );
45488
+ continue;
45489
+ }
45292
45490
  try {
45293
45491
  const encrypted = transportToEncryptedMessage({
45294
45492
  header_blob: msg.header_blob,
45295
45493
  ciphertext: msg.ciphertext
45296
45494
  });
45297
- const plaintext = this._ratchet.decrypt(encrypted);
45298
- const metadata = {
45299
- messageId: msg.id,
45300
- conversationId: msg.conversation_id,
45301
- timestamp: msg.created_at
45302
- };
45303
- this.emit("message", plaintext, metadata);
45304
- this.config.onMessage?.(plaintext, metadata);
45495
+ const plaintext = session.ratchet.decrypt(encrypted);
45496
+ let messageText;
45497
+ let messageType;
45498
+ try {
45499
+ const parsed = JSON.parse(plaintext);
45500
+ messageType = parsed.type || "message";
45501
+ messageText = parsed.text || plaintext;
45502
+ } catch {
45503
+ messageType = "message";
45504
+ messageText = plaintext;
45505
+ }
45506
+ if (messageType === "message") {
45507
+ const metadata = {
45508
+ messageId: msg.id,
45509
+ conversationId: msg.conversation_id,
45510
+ timestamp: msg.created_at
45511
+ };
45512
+ this.emit("message", messageText, metadata);
45513
+ this.config.onMessage?.(messageText, metadata);
45514
+ }
45305
45515
  this._persisted.lastMessageTimestamp = msg.created_at;
45306
45516
  } catch (err) {
45307
45517
  this.emit("error", err);
@@ -45335,9 +45545,18 @@ var SecureChannel = class extends EventEmitter {
45335
45545
  this._setState("error");
45336
45546
  this.emit("error", err);
45337
45547
  }
45548
+ /**
45549
+ * Persist all ratchet session states to disk.
45550
+ * Syncs live ratchet states back into the persisted sessions map.
45551
+ */
45338
45552
  async _persistState() {
45339
- if (!this._persisted || !this._ratchet) return;
45340
- this._persisted.ratchetState = this._ratchet.serialize();
45553
+ if (!this._persisted || this._sessions.size === 0) return;
45554
+ for (const [convId, session] of this._sessions) {
45555
+ this._persisted.sessions[convId] = {
45556
+ ownerDeviceId: session.ownerDeviceId,
45557
+ ratchetState: session.ratchet.serialize()
45558
+ };
45559
+ }
45341
45560
  await saveState(this.config.dataDir, this._persisted);
45342
45561
  }
45343
45562
  };