@agentvault/secure-channel 0.2.0 → 0.4.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/channel.d.ts CHANGED
@@ -25,6 +25,10 @@ export declare class SecureChannel extends EventEmitter {
25
25
  /** Returns the number of active sessions. */
26
26
  get sessionCount(): number;
27
27
  start(): Promise<void>;
28
+ /**
29
+ * Append a message to persistent history for cross-device replay.
30
+ */
31
+ private _appendHistory;
28
32
  /**
29
33
  * Encrypt and send a message to ALL owner devices (fanout).
30
34
  * Each session gets the same plaintext encrypted independently.
@@ -46,6 +50,11 @@ export declare class SecureChannel extends EventEmitter {
46
50
  * This allows all owner devices to see messages from any single device.
47
51
  */
48
52
  private _relaySyncToSiblings;
53
+ /**
54
+ * Send stored message history to a newly-activated session.
55
+ * Batches all history into a single encrypted message.
56
+ */
57
+ private _replayHistoryToSession;
49
58
  /**
50
59
  * Handle a device_linked event: a new owner device has joined.
51
60
  * Fetches the new device's public keys, performs X3DH, and initializes
@@ -1 +1 @@
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"}
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,EAKb,MAAM,YAAY,CAAC;AAiDpB,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;IAsC5B;;OAEG;IACH,OAAO,CAAC,cAAc;IAmBtB;;;OAGG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCtC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAoBb,OAAO;IAgDrB,OAAO,CAAC,KAAK;YAgCC,SAAS;IA2HvB,OAAO,CAAC,QAAQ;IA4DhB;;;;OAIG;YACW,sBAAsB;IAoFpC;;;OAGG;YACW,oBAAoB;IAmClC;;;OAGG;YACW,uBAAuB;IAkCrC;;;;OAIG;YACW,mBAAmB;IAkEjC;;;OAGG;YACW,mBAAmB;IAwFjC,OAAO,CAAC,kBAAkB;IAgB1B,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;IAKpB;;;OAGG;YACW,aAAa;CAc5B"}
package/dist/cli.js CHANGED
@@ -45054,7 +45054,8 @@ function migratePersistedState(raw) {
45054
45054
  identityKeypair: legacy.identityKeypair,
45055
45055
  ephemeralKeypair: legacy.ephemeralKeypair,
45056
45056
  fingerprint: legacy.fingerprint,
45057
- lastMessageTimestamp: legacy.lastMessageTimestamp
45057
+ lastMessageTimestamp: legacy.lastMessageTimestamp,
45058
+ messageHistory: []
45058
45059
  };
45059
45060
  }
45060
45061
  var SecureChannel = class extends EventEmitter {
@@ -45101,6 +45102,9 @@ var SecureChannel = class extends EventEmitter {
45101
45102
  const raw = await loadState(this.config.dataDir);
45102
45103
  if (raw) {
45103
45104
  this._persisted = migratePersistedState(raw);
45105
+ if (!this._persisted.messageHistory) {
45106
+ this._persisted.messageHistory = [];
45107
+ }
45104
45108
  this._deviceId = this._persisted.deviceId;
45105
45109
  this._deviceJwt = this._persisted.deviceJwt;
45106
45110
  this._primaryConversationId = this._persisted.primaryConversationId;
@@ -45112,7 +45116,8 @@ var SecureChannel = class extends EventEmitter {
45112
45116
  const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
45113
45117
  this._sessions.set(convId, {
45114
45118
  ownerDeviceId: sessionData.ownerDeviceId,
45115
- ratchet
45119
+ ratchet,
45120
+ activated: sessionData.activated ?? false
45116
45121
  });
45117
45122
  }
45118
45123
  }
@@ -45121,6 +45126,24 @@ var SecureChannel = class extends EventEmitter {
45121
45126
  }
45122
45127
  await this._enroll();
45123
45128
  }
45129
+ /**
45130
+ * Append a message to persistent history for cross-device replay.
45131
+ */
45132
+ _appendHistory(sender, text) {
45133
+ if (!this._persisted) return;
45134
+ if (!this._persisted.messageHistory) {
45135
+ this._persisted.messageHistory = [];
45136
+ }
45137
+ const maxSize = this.config.maxHistorySize ?? 500;
45138
+ this._persisted.messageHistory.push({
45139
+ sender,
45140
+ text,
45141
+ ts: (/* @__PURE__ */ new Date()).toISOString()
45142
+ });
45143
+ if (this._persisted.messageHistory.length > maxSize) {
45144
+ this._persisted.messageHistory = this._persisted.messageHistory.slice(-maxSize);
45145
+ }
45146
+ }
45124
45147
  /**
45125
45148
  * Encrypt and send a message to ALL owner devices (fanout).
45126
45149
  * Each session gets the same plaintext encrypted independently.
@@ -45129,8 +45152,12 @@ var SecureChannel = class extends EventEmitter {
45129
45152
  if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
45130
45153
  throw new Error("Channel is not ready");
45131
45154
  }
45155
+ this._appendHistory("agent", plaintext);
45132
45156
  const messageGroupId = randomUUID();
45133
45157
  for (const [convId, session] of this._sessions) {
45158
+ if (!session.activated) {
45159
+ continue;
45160
+ }
45134
45161
  const encrypted = session.ratchet.encrypt(plaintext);
45135
45162
  const transport = encryptedMessageToTransport(encrypted);
45136
45163
  this._ws.send(
@@ -45201,7 +45228,8 @@ var SecureChannel = class extends EventEmitter {
45201
45228
  publicKey: bytesToHex(ephemeral.publicKey),
45202
45229
  privateKey: bytesToHex(ephemeral.privateKey)
45203
45230
  },
45204
- fingerprint: result.fingerprint
45231
+ fingerprint: result.fingerprint,
45232
+ messageHistory: []
45205
45233
  };
45206
45234
  this._poll();
45207
45235
  } catch (err) {
@@ -45252,36 +45280,73 @@ var SecureChannel = class extends EventEmitter {
45252
45280
  this._deviceJwt = result.device_jwt;
45253
45281
  const identity = this._persisted.identityKeypair;
45254
45282
  const ephemeral = this._persisted.ephemeralKeypair;
45255
- const sharedSecret = performX3DH({
45256
- myIdentityPrivate: hexToBytes(identity.privateKey),
45257
- myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45258
- theirIdentityPublic: hexToBytes(result.owner_identity_public_key),
45259
- theirEphemeralPublic: hexToBytes(
45260
- result.owner_ephemeral_public_key ?? result.owner_identity_public_key
45261
- ),
45262
- isInitiator: false
45263
- });
45264
- const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45265
- publicKey: hexToBytes(identity.publicKey),
45266
- privateKey: hexToBytes(identity.privateKey),
45267
- keyType: "ed25519"
45268
- });
45269
- this._sessions.set(primary.conversation_id, {
45270
- ownerDeviceId: primary.owner_device_id,
45271
- ratchet
45272
- });
45283
+ const sessions = {};
45284
+ for (const conv of conversations) {
45285
+ const ownerIdentityKey = conv.owner_identity_public_key || result.owner_identity_public_key;
45286
+ const ownerEphemeralKey = conv.owner_ephemeral_public_key || result.owner_ephemeral_public_key || ownerIdentityKey;
45287
+ const sharedSecret = performX3DH({
45288
+ myIdentityPrivate: hexToBytes(identity.privateKey),
45289
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45290
+ theirIdentityPublic: hexToBytes(ownerIdentityKey),
45291
+ theirEphemeralPublic: hexToBytes(ownerEphemeralKey),
45292
+ isInitiator: false
45293
+ });
45294
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45295
+ publicKey: hexToBytes(identity.publicKey),
45296
+ privateKey: hexToBytes(identity.privateKey),
45297
+ keyType: "ed25519"
45298
+ });
45299
+ this._sessions.set(conv.conversation_id, {
45300
+ ownerDeviceId: conv.owner_device_id,
45301
+ ratchet,
45302
+ activated: false
45303
+ // Wait for owner's first message before sending to this session
45304
+ });
45305
+ sessions[conv.conversation_id] = {
45306
+ ownerDeviceId: conv.owner_device_id,
45307
+ ratchetState: ratchet.serialize()
45308
+ };
45309
+ console.log(
45310
+ `[SecureChannel] Session initialized for conv ${conv.conversation_id.slice(0, 8)}... (owner ${conv.owner_device_id.slice(0, 8)}..., primary=${conv.is_primary})`
45311
+ );
45312
+ }
45273
45313
  this._persisted = {
45274
45314
  ...this._persisted,
45275
45315
  deviceJwt: result.device_jwt,
45276
45316
  primaryConversationId: primary.conversation_id,
45277
- sessions: {
45278
- [primary.conversation_id]: {
45279
- ownerDeviceId: primary.owner_device_id,
45280
- ratchetState: ratchet.serialize()
45281
- }
45282
- }
45317
+ sessions,
45318
+ messageHistory: this._persisted.messageHistory ?? []
45283
45319
  };
45284
45320
  await saveState(this.config.dataDir, this._persisted);
45321
+ if (this.config.webhookUrl) {
45322
+ try {
45323
+ const webhookResp = await fetch(
45324
+ `${this.config.apiUrl}/api/v1/devices/self/webhook`,
45325
+ {
45326
+ method: "PATCH",
45327
+ headers: {
45328
+ "Content-Type": "application/json",
45329
+ Authorization: `Bearer ${this._deviceJwt}`
45330
+ },
45331
+ body: JSON.stringify({ webhook_url: this.config.webhookUrl })
45332
+ }
45333
+ );
45334
+ if (webhookResp.ok) {
45335
+ const webhookData = await webhookResp.json();
45336
+ console.log(
45337
+ `[SecureChannel] Webhook registered: ${this.config.webhookUrl} (secret: ${webhookData.webhook_secret?.slice(0, 8)}...)`
45338
+ );
45339
+ this.emit("webhook_registered", {
45340
+ url: this.config.webhookUrl,
45341
+ secret: webhookData.webhook_secret
45342
+ });
45343
+ } else {
45344
+ console.warn(`[SecureChannel] Webhook registration failed: ${webhookResp.status}`);
45345
+ }
45346
+ } catch (err) {
45347
+ console.warn(`[SecureChannel] Webhook registration error: ${err}`);
45348
+ }
45349
+ }
45285
45350
  this._connect();
45286
45351
  } catch (err) {
45287
45352
  this._handleError(err);
@@ -45352,6 +45417,10 @@ var SecureChannel = class extends EventEmitter {
45352
45417
  ciphertext: msgData.ciphertext
45353
45418
  });
45354
45419
  const plaintext = session.ratchet.decrypt(encrypted);
45420
+ if (!session.activated) {
45421
+ session.activated = true;
45422
+ console.log(`[SecureChannel] Session ${convId.slice(0, 8)}... activated by first owner message`);
45423
+ }
45355
45424
  let messageText;
45356
45425
  let messageType;
45357
45426
  try {
@@ -45362,7 +45431,14 @@ var SecureChannel = class extends EventEmitter {
45362
45431
  messageType = "message";
45363
45432
  messageText = plaintext;
45364
45433
  }
45434
+ if (messageType === "session_init") {
45435
+ console.log(`[SecureChannel] session_init received for ${convId.slice(0, 8)}..., replaying history`);
45436
+ await this._replayHistoryToSession(convId);
45437
+ await this._persistState();
45438
+ return;
45439
+ }
45365
45440
  if (messageType === "message") {
45441
+ this._appendHistory("owner", messageText);
45366
45442
  const metadata = {
45367
45443
  messageId: msgData.message_id,
45368
45444
  conversationId: convId,
@@ -45391,6 +45467,7 @@ var SecureChannel = class extends EventEmitter {
45391
45467
  });
45392
45468
  for (const [siblingConvId, siblingSession] of this._sessions) {
45393
45469
  if (siblingConvId === sourceConvId) continue;
45470
+ if (!siblingSession.activated) continue;
45394
45471
  const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
45395
45472
  const syncTransport = encryptedMessageToTransport(syncEncrypted);
45396
45473
  this._ws.send(
@@ -45405,6 +45482,38 @@ var SecureChannel = class extends EventEmitter {
45405
45482
  );
45406
45483
  }
45407
45484
  }
45485
+ /**
45486
+ * Send stored message history to a newly-activated session.
45487
+ * Batches all history into a single encrypted message.
45488
+ */
45489
+ async _replayHistoryToSession(convId) {
45490
+ const session = this._sessions.get(convId);
45491
+ if (!session || !session.activated || !this._ws) return;
45492
+ const history = this._persisted?.messageHistory ?? [];
45493
+ if (history.length === 0) {
45494
+ console.log(`[SecureChannel] No history to replay for ${convId.slice(0, 8)}...`);
45495
+ return;
45496
+ }
45497
+ console.log(
45498
+ `[SecureChannel] Replaying ${history.length} messages to session ${convId.slice(0, 8)}...`
45499
+ );
45500
+ const replayPayload = JSON.stringify({
45501
+ type: "history_replay",
45502
+ messages: history
45503
+ });
45504
+ const encrypted = session.ratchet.encrypt(replayPayload);
45505
+ const transport = encryptedMessageToTransport(encrypted);
45506
+ this._ws.send(
45507
+ JSON.stringify({
45508
+ event: "message",
45509
+ data: {
45510
+ conversation_id: convId,
45511
+ header_blob: transport.header_blob,
45512
+ ciphertext: transport.ciphertext
45513
+ }
45514
+ })
45515
+ );
45516
+ }
45408
45517
  /**
45409
45518
  * Handle a device_linked event: a new owner device has joined.
45410
45519
  * Fetches the new device's public keys, performs X3DH, and initializes
@@ -45415,27 +45524,20 @@ var SecureChannel = class extends EventEmitter {
45415
45524
  `[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
45416
45525
  );
45417
45526
  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) {
45527
+ if (!event.owner_identity_public_key) {
45425
45528
  console.error(
45426
- `[SecureChannel] Failed to fetch keys for linked device: ${keysRes.status}`
45529
+ `[SecureChannel] device_linked event missing owner keys for conv ${event.conversation_id.slice(0, 8)}...`
45427
45530
  );
45428
45531
  return;
45429
45532
  }
45430
- const keys = await keysRes.json();
45431
45533
  const identity = this._persisted.identityKeypair;
45432
45534
  const ephemeral = this._persisted.ephemeralKeypair;
45433
45535
  const sharedSecret = performX3DH({
45434
45536
  myIdentityPrivate: hexToBytes(identity.privateKey),
45435
45537
  myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45436
- theirIdentityPublic: hexToBytes(keys.identity_public_key),
45538
+ theirIdentityPublic: hexToBytes(event.owner_identity_public_key),
45437
45539
  theirEphemeralPublic: hexToBytes(
45438
- keys.ephemeral_public_key ?? keys.identity_public_key
45540
+ event.owner_ephemeral_public_key ?? event.owner_identity_public_key
45439
45541
  ),
45440
45542
  isInitiator: false
45441
45543
  });
@@ -45446,15 +45548,18 @@ var SecureChannel = class extends EventEmitter {
45446
45548
  });
45447
45549
  this._sessions.set(event.conversation_id, {
45448
45550
  ownerDeviceId: event.owner_device_id,
45449
- ratchet
45551
+ ratchet,
45552
+ activated: false
45553
+ // Wait for owner's first message
45450
45554
  });
45451
45555
  this._persisted.sessions[event.conversation_id] = {
45452
45556
  ownerDeviceId: event.owner_device_id,
45453
- ratchetState: ratchet.serialize()
45557
+ ratchetState: ratchet.serialize(),
45558
+ activated: false
45454
45559
  };
45455
45560
  await this._persistState();
45456
45561
  console.log(
45457
- `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}...`
45562
+ `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}... (conv ${event.conversation_id.slice(0, 8)}...)`
45458
45563
  );
45459
45564
  } catch (err) {
45460
45565
  console.error(
@@ -45493,6 +45598,10 @@ var SecureChannel = class extends EventEmitter {
45493
45598
  ciphertext: msg.ciphertext
45494
45599
  });
45495
45600
  const plaintext = session.ratchet.decrypt(encrypted);
45601
+ if (!session.activated) {
45602
+ session.activated = true;
45603
+ console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
45604
+ }
45496
45605
  let messageText;
45497
45606
  let messageType;
45498
45607
  try {
@@ -45504,6 +45613,7 @@ var SecureChannel = class extends EventEmitter {
45504
45613
  messageText = plaintext;
45505
45614
  }
45506
45615
  if (messageType === "message") {
45616
+ this._appendHistory("owner", messageText);
45507
45617
  const metadata = {
45508
45618
  messageId: msg.id,
45509
45619
  conversationId: msg.conversation_id,
@@ -45554,7 +45664,8 @@ var SecureChannel = class extends EventEmitter {
45554
45664
  for (const [convId, session] of this._sessions) {
45555
45665
  this._persisted.sessions[convId] = {
45556
45666
  ownerDeviceId: session.ownerDeviceId,
45557
- ratchetState: session.ratchet.serialize()
45667
+ ratchetState: session.ratchet.serialize(),
45668
+ activated: session.activated
45558
45669
  };
45559
45670
  }
45560
45671
  await saveState(this.config.dataDir, this._persisted);