@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.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { SecureChannel } from "./channel.js";
2
- export type { SecureChannelConfig, ChannelState, MessageMetadata, PersistedState, } from "./types.js";
2
+ export type { SecureChannelConfig, ChannelState, MessageMetadata, PersistedState, LegacyPersistedState, DeviceSession, } from "./types.js";
3
3
  export declare const VERSION = "0.1.0";
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,YAAY,EACV,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,cAAc,GACf,MAAM,YAAY,CAAC;AACpB,eAAO,MAAM,OAAO,UAAU,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,YAAY,EACV,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,cAAc,EACd,oBAAoB,EACpB,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,eAAO,MAAM,OAAO,UAAU,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/channel.ts
2
2
  import { EventEmitter } from "node:events";
3
+ import { randomUUID } from "node:crypto";
3
4
 
4
5
  // ../../node_modules/libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs
5
6
  var __filename;
@@ -45033,6 +45034,27 @@ async function activateDevice(apiUrl, deviceId) {
45033
45034
  var POLL_INTERVAL_MS = 5e3;
45034
45035
  var RECONNECT_BASE_MS = 1e3;
45035
45036
  var RECONNECT_MAX_MS = 3e4;
45037
+ function migratePersistedState(raw) {
45038
+ if (raw.sessions && raw.primaryConversationId) {
45039
+ return raw;
45040
+ }
45041
+ const legacy = raw;
45042
+ return {
45043
+ deviceId: legacy.deviceId,
45044
+ deviceJwt: legacy.deviceJwt,
45045
+ primaryConversationId: legacy.conversationId,
45046
+ sessions: {
45047
+ [legacy.conversationId]: {
45048
+ ownerDeviceId: "",
45049
+ ratchetState: legacy.ratchetState
45050
+ }
45051
+ },
45052
+ identityKeypair: legacy.identityKeypair,
45053
+ ephemeralKeypair: legacy.ephemeralKeypair,
45054
+ fingerprint: legacy.fingerprint,
45055
+ lastMessageTimestamp: legacy.lastMessageTimestamp
45056
+ };
45057
+ }
45036
45058
  var SecureChannel = class extends EventEmitter {
45037
45059
  constructor(config) {
45038
45060
  super();
@@ -45041,9 +45063,9 @@ var SecureChannel = class extends EventEmitter {
45041
45063
  _state = "idle";
45042
45064
  _deviceId = null;
45043
45065
  _fingerprint = null;
45044
- _conversationId = null;
45066
+ _primaryConversationId = "";
45045
45067
  _deviceJwt = null;
45046
- _ratchet = null;
45068
+ _sessions = /* @__PURE__ */ new Map();
45047
45069
  _ws = null;
45048
45070
  _pollTimer = null;
45049
45071
  _reconnectAttempt = 0;
@@ -45059,41 +45081,68 @@ var SecureChannel = class extends EventEmitter {
45059
45081
  get fingerprint() {
45060
45082
  return this._fingerprint;
45061
45083
  }
45084
+ /** Returns the primary conversation ID (backward-compatible). */
45062
45085
  get conversationId() {
45063
- return this._conversationId;
45086
+ return this._primaryConversationId || null;
45087
+ }
45088
+ /** Returns all active conversation IDs. */
45089
+ get conversationIds() {
45090
+ return Array.from(this._sessions.keys());
45091
+ }
45092
+ /** Returns the number of active sessions. */
45093
+ get sessionCount() {
45094
+ return this._sessions.size;
45064
45095
  }
45065
45096
  async start() {
45066
45097
  this._stopped = false;
45067
45098
  await libsodium_wrappers_default.ready;
45068
- const persisted = await loadState(this.config.dataDir);
45069
- if (persisted) {
45070
- this._persisted = persisted;
45071
- this._deviceId = persisted.deviceId;
45072
- this._deviceJwt = persisted.deviceJwt;
45073
- this._conversationId = persisted.conversationId;
45074
- this._fingerprint = persisted.fingerprint;
45075
- this._ratchet = DoubleRatchet.deserialize(persisted.ratchetState);
45099
+ const raw = await loadState(this.config.dataDir);
45100
+ if (raw) {
45101
+ this._persisted = migratePersistedState(raw);
45102
+ this._deviceId = this._persisted.deviceId;
45103
+ this._deviceJwt = this._persisted.deviceJwt;
45104
+ this._primaryConversationId = this._persisted.primaryConversationId;
45105
+ this._fingerprint = this._persisted.fingerprint;
45106
+ for (const [convId, sessionData] of Object.entries(
45107
+ this._persisted.sessions
45108
+ )) {
45109
+ if (sessionData.ratchetState) {
45110
+ const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
45111
+ this._sessions.set(convId, {
45112
+ ownerDeviceId: sessionData.ownerDeviceId,
45113
+ ratchet
45114
+ });
45115
+ }
45116
+ }
45076
45117
  this._connect();
45077
45118
  return;
45078
45119
  }
45079
45120
  await this._enroll();
45080
45121
  }
45122
+ /**
45123
+ * Encrypt and send a message to ALL owner devices (fanout).
45124
+ * Each session gets the same plaintext encrypted independently.
45125
+ */
45081
45126
  async send(plaintext) {
45082
- if (this._state !== "ready" || !this._ratchet || !this._ws) {
45127
+ if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
45083
45128
  throw new Error("Channel is not ready");
45084
45129
  }
45085
- const encrypted = this._ratchet.encrypt(plaintext);
45086
- const transport = encryptedMessageToTransport(encrypted);
45087
- this._ws.send(
45088
- JSON.stringify({
45089
- event: "message",
45090
- data: {
45091
- conversation_id: this._conversationId,
45092
- header_blob: transport.header_blob,
45093
- ciphertext: transport.ciphertext
45094
- }
45095
- })
45096
- );
45130
+ const messageGroupId = randomUUID();
45131
+ for (const [convId, session] of this._sessions) {
45132
+ const encrypted = session.ratchet.encrypt(plaintext);
45133
+ const transport = encryptedMessageToTransport(encrypted);
45134
+ this._ws.send(
45135
+ JSON.stringify({
45136
+ event: "message",
45137
+ data: {
45138
+ conversation_id: convId,
45139
+ header_blob: transport.header_blob,
45140
+ ciphertext: transport.ciphertext,
45141
+ message_group_id: messageGroupId
45142
+ }
45143
+ })
45144
+ );
45145
+ }
45097
45146
  await this._persistState();
45098
45147
  }
45099
45148
  async stop() {
@@ -45138,8 +45187,10 @@ var SecureChannel = class extends EventEmitter {
45138
45187
  deviceId: result.device_id,
45139
45188
  deviceJwt: "",
45140
45189
  // set after activation
45141
- conversationId: "",
45190
+ primaryConversationId: "",
45142
45191
  // set after activation
45192
+ sessions: {},
45193
+ // populated after activation
45143
45194
  identityKeypair: {
45144
45195
  publicKey: bytesToHex(identity.publicKey),
45145
45196
  privateKey: bytesToHex(identity.privateKey)
@@ -45148,9 +45199,7 @@ var SecureChannel = class extends EventEmitter {
45148
45199
  publicKey: bytesToHex(ephemeral.publicKey),
45149
45200
  privateKey: bytesToHex(ephemeral.privateKey)
45150
45201
  },
45151
- fingerprint: result.fingerprint,
45152
- ratchetState: ""
45153
- // set after activation
45202
+ fingerprint: result.fingerprint
45154
45203
  };
45155
45204
  this._poll();
45156
45205
  } catch (err) {
@@ -45189,11 +45238,19 @@ var SecureChannel = class extends EventEmitter {
45189
45238
  this.config.apiUrl,
45190
45239
  this._deviceId
45191
45240
  );
45192
- this._conversationId = result.conversation_id;
45241
+ const conversations = result.conversations || [
45242
+ {
45243
+ conversation_id: result.conversation_id,
45244
+ owner_device_id: "",
45245
+ is_primary: true
45246
+ }
45247
+ ];
45248
+ const primary = conversations.find((c2) => c2.is_primary) || conversations[0];
45249
+ this._primaryConversationId = primary.conversation_id;
45193
45250
  this._deviceJwt = result.device_jwt;
45194
45251
  const identity = this._persisted.identityKeypair;
45195
45252
  const ephemeral = this._persisted.ephemeralKeypair;
45196
- const sharedSecret = await performX3DH({
45253
+ const sharedSecret = performX3DH({
45197
45254
  myIdentityPrivate: hexToBytes(identity.privateKey),
45198
45255
  myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45199
45256
  theirIdentityPublic: hexToBytes(result.owner_identity_public_key),
@@ -45202,16 +45259,25 @@ var SecureChannel = class extends EventEmitter {
45202
45259
  ),
45203
45260
  isInitiator: false
45204
45261
  });
45205
- this._ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45262
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45206
45263
  publicKey: hexToBytes(identity.publicKey),
45207
45264
  privateKey: hexToBytes(identity.privateKey),
45208
45265
  keyType: "ed25519"
45209
45266
  });
45267
+ this._sessions.set(primary.conversation_id, {
45268
+ ownerDeviceId: primary.owner_device_id,
45269
+ ratchet
45270
+ });
45210
45271
  this._persisted = {
45211
45272
  ...this._persisted,
45212
45273
  deviceJwt: result.device_jwt,
45213
- conversationId: result.conversation_id,
45214
- ratchetState: this._ratchet.serialize()
45274
+ primaryConversationId: primary.conversation_id,
45275
+ sessions: {
45276
+ [primary.conversation_id]: {
45277
+ ownerDeviceId: primary.owner_device_id,
45278
+ ratchetState: ratchet.serialize()
45279
+ }
45280
+ }
45215
45281
  };
45216
45282
  await saveState(this.config.dataDir, this._persisted);
45217
45283
  this._connect();
@@ -45244,23 +45310,12 @@ var SecureChannel = class extends EventEmitter {
45244
45310
  this._handleError(new Error("Device was revoked"));
45245
45311
  return;
45246
45312
  }
45313
+ if (data.event === "device_linked") {
45314
+ await this._handleDeviceLinked(data.data);
45315
+ return;
45316
+ }
45247
45317
  if (data.event === "message") {
45248
- const msgData = data.data;
45249
- if (msgData.sender_device_id === this._deviceId) return;
45250
- const encrypted = transportToEncryptedMessage({
45251
- header_blob: msgData.header_blob,
45252
- ciphertext: msgData.ciphertext
45253
- });
45254
- const plaintext = this._ratchet.decrypt(encrypted);
45255
- await this._persistState();
45256
- const metadata = {
45257
- messageId: msgData.message_id,
45258
- conversationId: msgData.conversation_id,
45259
- timestamp: msgData.created_at
45260
- };
45261
- this.emit("message", plaintext, metadata);
45262
- this.config.onMessage?.(plaintext, metadata);
45263
- this._persisted.lastMessageTimestamp = msgData.created_at;
45318
+ await this._handleIncomingMessage(data.data);
45264
45319
  }
45265
45320
  } catch (err) {
45266
45321
  this.emit("error", err);
@@ -45275,6 +45330,142 @@ var SecureChannel = class extends EventEmitter {
45275
45330
  this.emit("error", err);
45276
45331
  });
45277
45332
  }
45333
+ /**
45334
+ * Handle an incoming encrypted message from a specific conversation.
45335
+ * Decrypts using the appropriate session ratchet, emits to the agent,
45336
+ * and relays as sync messages to sibling sessions.
45337
+ */
45338
+ async _handleIncomingMessage(msgData) {
45339
+ if (msgData.sender_device_id === this._deviceId) return;
45340
+ const convId = msgData.conversation_id;
45341
+ const session = this._sessions.get(convId);
45342
+ if (!session) {
45343
+ console.warn(
45344
+ `[SecureChannel] No session for conversation ${convId}, skipping`
45345
+ );
45346
+ return;
45347
+ }
45348
+ const encrypted = transportToEncryptedMessage({
45349
+ header_blob: msgData.header_blob,
45350
+ ciphertext: msgData.ciphertext
45351
+ });
45352
+ const plaintext = session.ratchet.decrypt(encrypted);
45353
+ let messageText;
45354
+ let messageType;
45355
+ try {
45356
+ const parsed = JSON.parse(plaintext);
45357
+ messageType = parsed.type || "message";
45358
+ messageText = parsed.text || plaintext;
45359
+ } catch {
45360
+ messageType = "message";
45361
+ messageText = plaintext;
45362
+ }
45363
+ if (messageType === "message") {
45364
+ const metadata = {
45365
+ messageId: msgData.message_id,
45366
+ conversationId: convId,
45367
+ timestamp: msgData.created_at
45368
+ };
45369
+ this.emit("message", messageText, metadata);
45370
+ this.config.onMessage?.(messageText, metadata);
45371
+ await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText);
45372
+ }
45373
+ if (this._persisted) {
45374
+ this._persisted.lastMessageTimestamp = msgData.created_at;
45375
+ }
45376
+ await this._persistState();
45377
+ }
45378
+ /**
45379
+ * Relay an owner's message to all sibling sessions as encrypted sync messages.
45380
+ * This allows all owner devices to see messages from any single device.
45381
+ */
45382
+ async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText) {
45383
+ if (!this._ws || this._sessions.size <= 1) return;
45384
+ const syncPayload = JSON.stringify({
45385
+ type: "sync",
45386
+ sender: senderOwnerDeviceId,
45387
+ text: messageText,
45388
+ ts: (/* @__PURE__ */ new Date()).toISOString()
45389
+ });
45390
+ for (const [siblingConvId, siblingSession] of this._sessions) {
45391
+ if (siblingConvId === sourceConvId) continue;
45392
+ const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
45393
+ const syncTransport = encryptedMessageToTransport(syncEncrypted);
45394
+ this._ws.send(
45395
+ JSON.stringify({
45396
+ event: "message",
45397
+ data: {
45398
+ conversation_id: siblingConvId,
45399
+ header_blob: syncTransport.header_blob,
45400
+ ciphertext: syncTransport.ciphertext
45401
+ }
45402
+ })
45403
+ );
45404
+ }
45405
+ }
45406
+ /**
45407
+ * Handle a device_linked event: a new owner device has joined.
45408
+ * Fetches the new device's public keys, performs X3DH, and initializes
45409
+ * a new ratchet session.
45410
+ */
45411
+ async _handleDeviceLinked(event) {
45412
+ console.log(
45413
+ `[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
45414
+ );
45415
+ try {
45416
+ const keysRes = await fetch(
45417
+ `${this.config.apiUrl}/api/v1/conversations/${event.conversation_id}/keys`,
45418
+ {
45419
+ headers: { Authorization: `Bearer ${this._deviceJwt}` }
45420
+ }
45421
+ );
45422
+ if (!keysRes.ok) {
45423
+ console.error(
45424
+ `[SecureChannel] Failed to fetch keys for linked device: ${keysRes.status}`
45425
+ );
45426
+ return;
45427
+ }
45428
+ const keys = await keysRes.json();
45429
+ const identity = this._persisted.identityKeypair;
45430
+ const ephemeral = this._persisted.ephemeralKeypair;
45431
+ const sharedSecret = performX3DH({
45432
+ myIdentityPrivate: hexToBytes(identity.privateKey),
45433
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45434
+ theirIdentityPublic: hexToBytes(keys.identity_public_key),
45435
+ theirEphemeralPublic: hexToBytes(
45436
+ keys.ephemeral_public_key ?? keys.identity_public_key
45437
+ ),
45438
+ isInitiator: false
45439
+ });
45440
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45441
+ publicKey: hexToBytes(identity.publicKey),
45442
+ privateKey: hexToBytes(identity.privateKey),
45443
+ keyType: "ed25519"
45444
+ });
45445
+ this._sessions.set(event.conversation_id, {
45446
+ ownerDeviceId: event.owner_device_id,
45447
+ ratchet
45448
+ });
45449
+ this._persisted.sessions[event.conversation_id] = {
45450
+ ownerDeviceId: event.owner_device_id,
45451
+ ratchetState: ratchet.serialize()
45452
+ };
45453
+ await this._persistState();
45454
+ console.log(
45455
+ `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}...`
45456
+ );
45457
+ } catch (err) {
45458
+ console.error(
45459
+ `[SecureChannel] Failed to handle device_linked:`,
45460
+ err
45461
+ );
45462
+ this.emit("error", err);
45463
+ }
45464
+ }
45465
+ /**
45466
+ * Sync missed messages across ALL sessions.
45467
+ * For each conversation, fetches messages since last sync and decrypts.
45468
+ */
45278
45469
  async _syncMissedMessages() {
45279
45470
  if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt) return;
45280
45471
  try {
@@ -45287,19 +45478,38 @@ var SecureChannel = class extends EventEmitter {
45287
45478
  const messages = await res.json();
45288
45479
  for (const msg of messages) {
45289
45480
  if (msg.sender_device_id === this._deviceId) continue;
45481
+ const session = this._sessions.get(msg.conversation_id);
45482
+ if (!session) {
45483
+ console.warn(
45484
+ `[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`
45485
+ );
45486
+ continue;
45487
+ }
45290
45488
  try {
45291
45489
  const encrypted = transportToEncryptedMessage({
45292
45490
  header_blob: msg.header_blob,
45293
45491
  ciphertext: msg.ciphertext
45294
45492
  });
45295
- const plaintext = this._ratchet.decrypt(encrypted);
45296
- const metadata = {
45297
- messageId: msg.id,
45298
- conversationId: msg.conversation_id,
45299
- timestamp: msg.created_at
45300
- };
45301
- this.emit("message", plaintext, metadata);
45302
- this.config.onMessage?.(plaintext, metadata);
45493
+ const plaintext = session.ratchet.decrypt(encrypted);
45494
+ let messageText;
45495
+ let messageType;
45496
+ try {
45497
+ const parsed = JSON.parse(plaintext);
45498
+ messageType = parsed.type || "message";
45499
+ messageText = parsed.text || plaintext;
45500
+ } catch {
45501
+ messageType = "message";
45502
+ messageText = plaintext;
45503
+ }
45504
+ if (messageType === "message") {
45505
+ const metadata = {
45506
+ messageId: msg.id,
45507
+ conversationId: msg.conversation_id,
45508
+ timestamp: msg.created_at
45509
+ };
45510
+ this.emit("message", messageText, metadata);
45511
+ this.config.onMessage?.(messageText, metadata);
45512
+ }
45303
45513
  this._persisted.lastMessageTimestamp = msg.created_at;
45304
45514
  } catch (err) {
45305
45515
  this.emit("error", err);
@@ -45333,9 +45543,18 @@ var SecureChannel = class extends EventEmitter {
45333
45543
  this._setState("error");
45334
45544
  this.emit("error", err);
45335
45545
  }
45546
+ /**
45547
+ * Persist all ratchet session states to disk.
45548
+ * Syncs live ratchet states back into the persisted sessions map.
45549
+ */
45336
45550
  async _persistState() {
45337
- if (!this._persisted || !this._ratchet) return;
45338
- this._persisted.ratchetState = this._ratchet.serialize();
45551
+ if (!this._persisted || this._sessions.size === 0) return;
45552
+ for (const [convId, session] of this._sessions) {
45553
+ this._persisted.sessions[convId] = {
45554
+ ownerDeviceId: session.ownerDeviceId,
45555
+ ratchetState: session.ratchet.serialize()
45556
+ };
45557
+ }
45339
45558
  await saveState(this.config.dataDir, this._persisted);
45340
45559
  }
45341
45560
  };