@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/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { SecureChannel } from "./channel.js";
2
- export type { SecureChannelConfig, ChannelState, MessageMetadata, PersistedState, LegacyPersistedState, DeviceSession, } from "./types.js";
3
- export declare const VERSION = "0.1.0";
2
+ export type { SecureChannelConfig, ChannelState, MessageMetadata, PersistedState, LegacyPersistedState, DeviceSession, HistoryEntry, } from "./types.js";
3
+ export declare const VERSION = "0.3.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,EACd,oBAAoB,EACpB,aAAa,GACd,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,EACb,YAAY,GACb,MAAM,YAAY,CAAC;AACpB,eAAO,MAAM,OAAO,UAAU,CAAC"}
package/dist/index.js CHANGED
@@ -45052,7 +45052,8 @@ function migratePersistedState(raw) {
45052
45052
  identityKeypair: legacy.identityKeypair,
45053
45053
  ephemeralKeypair: legacy.ephemeralKeypair,
45054
45054
  fingerprint: legacy.fingerprint,
45055
- lastMessageTimestamp: legacy.lastMessageTimestamp
45055
+ lastMessageTimestamp: legacy.lastMessageTimestamp,
45056
+ messageHistory: []
45056
45057
  };
45057
45058
  }
45058
45059
  var SecureChannel = class extends EventEmitter {
@@ -45099,6 +45100,9 @@ var SecureChannel = class extends EventEmitter {
45099
45100
  const raw = await loadState(this.config.dataDir);
45100
45101
  if (raw) {
45101
45102
  this._persisted = migratePersistedState(raw);
45103
+ if (!this._persisted.messageHistory) {
45104
+ this._persisted.messageHistory = [];
45105
+ }
45102
45106
  this._deviceId = this._persisted.deviceId;
45103
45107
  this._deviceJwt = this._persisted.deviceJwt;
45104
45108
  this._primaryConversationId = this._persisted.primaryConversationId;
@@ -45110,7 +45114,8 @@ var SecureChannel = class extends EventEmitter {
45110
45114
  const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
45111
45115
  this._sessions.set(convId, {
45112
45116
  ownerDeviceId: sessionData.ownerDeviceId,
45113
- ratchet
45117
+ ratchet,
45118
+ activated: sessionData.activated ?? false
45114
45119
  });
45115
45120
  }
45116
45121
  }
@@ -45119,6 +45124,24 @@ var SecureChannel = class extends EventEmitter {
45119
45124
  }
45120
45125
  await this._enroll();
45121
45126
  }
45127
+ /**
45128
+ * Append a message to persistent history for cross-device replay.
45129
+ */
45130
+ _appendHistory(sender, text) {
45131
+ if (!this._persisted) return;
45132
+ if (!this._persisted.messageHistory) {
45133
+ this._persisted.messageHistory = [];
45134
+ }
45135
+ const maxSize = this.config.maxHistorySize ?? 500;
45136
+ this._persisted.messageHistory.push({
45137
+ sender,
45138
+ text,
45139
+ ts: (/* @__PURE__ */ new Date()).toISOString()
45140
+ });
45141
+ if (this._persisted.messageHistory.length > maxSize) {
45142
+ this._persisted.messageHistory = this._persisted.messageHistory.slice(-maxSize);
45143
+ }
45144
+ }
45122
45145
  /**
45123
45146
  * Encrypt and send a message to ALL owner devices (fanout).
45124
45147
  * Each session gets the same plaintext encrypted independently.
@@ -45127,8 +45150,12 @@ var SecureChannel = class extends EventEmitter {
45127
45150
  if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
45128
45151
  throw new Error("Channel is not ready");
45129
45152
  }
45153
+ this._appendHistory("agent", plaintext);
45130
45154
  const messageGroupId = randomUUID();
45131
45155
  for (const [convId, session] of this._sessions) {
45156
+ if (!session.activated) {
45157
+ continue;
45158
+ }
45132
45159
  const encrypted = session.ratchet.encrypt(plaintext);
45133
45160
  const transport = encryptedMessageToTransport(encrypted);
45134
45161
  this._ws.send(
@@ -45199,7 +45226,8 @@ var SecureChannel = class extends EventEmitter {
45199
45226
  publicKey: bytesToHex(ephemeral.publicKey),
45200
45227
  privateKey: bytesToHex(ephemeral.privateKey)
45201
45228
  },
45202
- fingerprint: result.fingerprint
45229
+ fingerprint: result.fingerprint,
45230
+ messageHistory: []
45203
45231
  };
45204
45232
  this._poll();
45205
45233
  } catch (err) {
@@ -45250,36 +45278,73 @@ var SecureChannel = class extends EventEmitter {
45250
45278
  this._deviceJwt = result.device_jwt;
45251
45279
  const identity = this._persisted.identityKeypair;
45252
45280
  const ephemeral = this._persisted.ephemeralKeypair;
45253
- const sharedSecret = performX3DH({
45254
- myIdentityPrivate: hexToBytes(identity.privateKey),
45255
- myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45256
- theirIdentityPublic: hexToBytes(result.owner_identity_public_key),
45257
- theirEphemeralPublic: hexToBytes(
45258
- result.owner_ephemeral_public_key ?? result.owner_identity_public_key
45259
- ),
45260
- isInitiator: false
45261
- });
45262
- const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45263
- publicKey: hexToBytes(identity.publicKey),
45264
- privateKey: hexToBytes(identity.privateKey),
45265
- keyType: "ed25519"
45266
- });
45267
- this._sessions.set(primary.conversation_id, {
45268
- ownerDeviceId: primary.owner_device_id,
45269
- ratchet
45270
- });
45281
+ const sessions = {};
45282
+ for (const conv of conversations) {
45283
+ const ownerIdentityKey = conv.owner_identity_public_key || result.owner_identity_public_key;
45284
+ const ownerEphemeralKey = conv.owner_ephemeral_public_key || result.owner_ephemeral_public_key || ownerIdentityKey;
45285
+ const sharedSecret = performX3DH({
45286
+ myIdentityPrivate: hexToBytes(identity.privateKey),
45287
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45288
+ theirIdentityPublic: hexToBytes(ownerIdentityKey),
45289
+ theirEphemeralPublic: hexToBytes(ownerEphemeralKey),
45290
+ isInitiator: false
45291
+ });
45292
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45293
+ publicKey: hexToBytes(identity.publicKey),
45294
+ privateKey: hexToBytes(identity.privateKey),
45295
+ keyType: "ed25519"
45296
+ });
45297
+ this._sessions.set(conv.conversation_id, {
45298
+ ownerDeviceId: conv.owner_device_id,
45299
+ ratchet,
45300
+ activated: false
45301
+ // Wait for owner's first message before sending to this session
45302
+ });
45303
+ sessions[conv.conversation_id] = {
45304
+ ownerDeviceId: conv.owner_device_id,
45305
+ ratchetState: ratchet.serialize()
45306
+ };
45307
+ console.log(
45308
+ `[SecureChannel] Session initialized for conv ${conv.conversation_id.slice(0, 8)}... (owner ${conv.owner_device_id.slice(0, 8)}..., primary=${conv.is_primary})`
45309
+ );
45310
+ }
45271
45311
  this._persisted = {
45272
45312
  ...this._persisted,
45273
45313
  deviceJwt: result.device_jwt,
45274
45314
  primaryConversationId: primary.conversation_id,
45275
- sessions: {
45276
- [primary.conversation_id]: {
45277
- ownerDeviceId: primary.owner_device_id,
45278
- ratchetState: ratchet.serialize()
45279
- }
45280
- }
45315
+ sessions,
45316
+ messageHistory: this._persisted.messageHistory ?? []
45281
45317
  };
45282
45318
  await saveState(this.config.dataDir, this._persisted);
45319
+ if (this.config.webhookUrl) {
45320
+ try {
45321
+ const webhookResp = await fetch(
45322
+ `${this.config.apiUrl}/api/v1/devices/self/webhook`,
45323
+ {
45324
+ method: "PATCH",
45325
+ headers: {
45326
+ "Content-Type": "application/json",
45327
+ Authorization: `Bearer ${this._deviceJwt}`
45328
+ },
45329
+ body: JSON.stringify({ webhook_url: this.config.webhookUrl })
45330
+ }
45331
+ );
45332
+ if (webhookResp.ok) {
45333
+ const webhookData = await webhookResp.json();
45334
+ console.log(
45335
+ `[SecureChannel] Webhook registered: ${this.config.webhookUrl} (secret: ${webhookData.webhook_secret?.slice(0, 8)}...)`
45336
+ );
45337
+ this.emit("webhook_registered", {
45338
+ url: this.config.webhookUrl,
45339
+ secret: webhookData.webhook_secret
45340
+ });
45341
+ } else {
45342
+ console.warn(`[SecureChannel] Webhook registration failed: ${webhookResp.status}`);
45343
+ }
45344
+ } catch (err) {
45345
+ console.warn(`[SecureChannel] Webhook registration error: ${err}`);
45346
+ }
45347
+ }
45283
45348
  this._connect();
45284
45349
  } catch (err) {
45285
45350
  this._handleError(err);
@@ -45350,6 +45415,10 @@ var SecureChannel = class extends EventEmitter {
45350
45415
  ciphertext: msgData.ciphertext
45351
45416
  });
45352
45417
  const plaintext = session.ratchet.decrypt(encrypted);
45418
+ if (!session.activated) {
45419
+ session.activated = true;
45420
+ console.log(`[SecureChannel] Session ${convId.slice(0, 8)}... activated by first owner message`);
45421
+ }
45353
45422
  let messageText;
45354
45423
  let messageType;
45355
45424
  try {
@@ -45360,7 +45429,14 @@ var SecureChannel = class extends EventEmitter {
45360
45429
  messageType = "message";
45361
45430
  messageText = plaintext;
45362
45431
  }
45432
+ if (messageType === "session_init") {
45433
+ console.log(`[SecureChannel] session_init received for ${convId.slice(0, 8)}..., replaying history`);
45434
+ await this._replayHistoryToSession(convId);
45435
+ await this._persistState();
45436
+ return;
45437
+ }
45363
45438
  if (messageType === "message") {
45439
+ this._appendHistory("owner", messageText);
45364
45440
  const metadata = {
45365
45441
  messageId: msgData.message_id,
45366
45442
  conversationId: convId,
@@ -45389,6 +45465,7 @@ var SecureChannel = class extends EventEmitter {
45389
45465
  });
45390
45466
  for (const [siblingConvId, siblingSession] of this._sessions) {
45391
45467
  if (siblingConvId === sourceConvId) continue;
45468
+ if (!siblingSession.activated) continue;
45392
45469
  const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
45393
45470
  const syncTransport = encryptedMessageToTransport(syncEncrypted);
45394
45471
  this._ws.send(
@@ -45403,6 +45480,38 @@ var SecureChannel = class extends EventEmitter {
45403
45480
  );
45404
45481
  }
45405
45482
  }
45483
+ /**
45484
+ * Send stored message history to a newly-activated session.
45485
+ * Batches all history into a single encrypted message.
45486
+ */
45487
+ async _replayHistoryToSession(convId) {
45488
+ const session = this._sessions.get(convId);
45489
+ if (!session || !session.activated || !this._ws) return;
45490
+ const history = this._persisted?.messageHistory ?? [];
45491
+ if (history.length === 0) {
45492
+ console.log(`[SecureChannel] No history to replay for ${convId.slice(0, 8)}...`);
45493
+ return;
45494
+ }
45495
+ console.log(
45496
+ `[SecureChannel] Replaying ${history.length} messages to session ${convId.slice(0, 8)}...`
45497
+ );
45498
+ const replayPayload = JSON.stringify({
45499
+ type: "history_replay",
45500
+ messages: history
45501
+ });
45502
+ const encrypted = session.ratchet.encrypt(replayPayload);
45503
+ const transport = encryptedMessageToTransport(encrypted);
45504
+ this._ws.send(
45505
+ JSON.stringify({
45506
+ event: "message",
45507
+ data: {
45508
+ conversation_id: convId,
45509
+ header_blob: transport.header_blob,
45510
+ ciphertext: transport.ciphertext
45511
+ }
45512
+ })
45513
+ );
45514
+ }
45406
45515
  /**
45407
45516
  * Handle a device_linked event: a new owner device has joined.
45408
45517
  * Fetches the new device's public keys, performs X3DH, and initializes
@@ -45413,27 +45522,20 @@ var SecureChannel = class extends EventEmitter {
45413
45522
  `[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
45414
45523
  );
45415
45524
  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) {
45525
+ if (!event.owner_identity_public_key) {
45423
45526
  console.error(
45424
- `[SecureChannel] Failed to fetch keys for linked device: ${keysRes.status}`
45527
+ `[SecureChannel] device_linked event missing owner keys for conv ${event.conversation_id.slice(0, 8)}...`
45425
45528
  );
45426
45529
  return;
45427
45530
  }
45428
- const keys = await keysRes.json();
45429
45531
  const identity = this._persisted.identityKeypair;
45430
45532
  const ephemeral = this._persisted.ephemeralKeypair;
45431
45533
  const sharedSecret = performX3DH({
45432
45534
  myIdentityPrivate: hexToBytes(identity.privateKey),
45433
45535
  myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45434
- theirIdentityPublic: hexToBytes(keys.identity_public_key),
45536
+ theirIdentityPublic: hexToBytes(event.owner_identity_public_key),
45435
45537
  theirEphemeralPublic: hexToBytes(
45436
- keys.ephemeral_public_key ?? keys.identity_public_key
45538
+ event.owner_ephemeral_public_key ?? event.owner_identity_public_key
45437
45539
  ),
45438
45540
  isInitiator: false
45439
45541
  });
@@ -45444,15 +45546,18 @@ var SecureChannel = class extends EventEmitter {
45444
45546
  });
45445
45547
  this._sessions.set(event.conversation_id, {
45446
45548
  ownerDeviceId: event.owner_device_id,
45447
- ratchet
45549
+ ratchet,
45550
+ activated: false
45551
+ // Wait for owner's first message
45448
45552
  });
45449
45553
  this._persisted.sessions[event.conversation_id] = {
45450
45554
  ownerDeviceId: event.owner_device_id,
45451
- ratchetState: ratchet.serialize()
45555
+ ratchetState: ratchet.serialize(),
45556
+ activated: false
45452
45557
  };
45453
45558
  await this._persistState();
45454
45559
  console.log(
45455
- `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}...`
45560
+ `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}... (conv ${event.conversation_id.slice(0, 8)}...)`
45456
45561
  );
45457
45562
  } catch (err) {
45458
45563
  console.error(
@@ -45491,6 +45596,10 @@ var SecureChannel = class extends EventEmitter {
45491
45596
  ciphertext: msg.ciphertext
45492
45597
  });
45493
45598
  const plaintext = session.ratchet.decrypt(encrypted);
45599
+ if (!session.activated) {
45600
+ session.activated = true;
45601
+ console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
45602
+ }
45494
45603
  let messageText;
45495
45604
  let messageType;
45496
45605
  try {
@@ -45502,6 +45611,7 @@ var SecureChannel = class extends EventEmitter {
45502
45611
  messageText = plaintext;
45503
45612
  }
45504
45613
  if (messageType === "message") {
45614
+ this._appendHistory("owner", messageText);
45505
45615
  const metadata = {
45506
45616
  messageId: msg.id,
45507
45617
  conversationId: msg.conversation_id,
@@ -45552,7 +45662,8 @@ var SecureChannel = class extends EventEmitter {
45552
45662
  for (const [convId, session] of this._sessions) {
45553
45663
  this._persisted.sessions[convId] = {
45554
45664
  ownerDeviceId: session.ownerDeviceId,
45555
- ratchetState: session.ratchet.serialize()
45665
+ ratchetState: session.ratchet.serialize(),
45666
+ activated: session.activated
45556
45667
  };
45557
45668
  }
45558
45669
  await saveState(this.config.dataDir, this._persisted);
@@ -45560,7 +45671,7 @@ var SecureChannel = class extends EventEmitter {
45560
45671
  };
45561
45672
 
45562
45673
  // src/index.ts
45563
- var VERSION = "0.1.0";
45674
+ var VERSION = "0.3.0";
45564
45675
  export {
45565
45676
  SecureChannel,
45566
45677
  VERSION