@agentvault/secure-channel 0.1.2 → 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/__tests__/functional.test.d.ts +21 -0
- package/dist/__tests__/functional.test.d.ts.map +1 -0
- package/dist/__tests__/multi-session.test.d.ts +2 -0
- package/dist/__tests__/multi-session.test.d.ts.map +1 -0
- package/dist/channel.d.ts +45 -2
- package/dist/channel.d.ts.map +1 -1
- package/dist/cli.js +400 -70
- package/dist/cli.js.map +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +401 -71
- package/dist/index.js.map +3 -3
- package/dist/state.d.ts +6 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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,28 @@ 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
|
+
messageHistory: []
|
|
45059
|
+
};
|
|
45060
|
+
}
|
|
45038
45061
|
var SecureChannel = class extends EventEmitter {
|
|
45039
45062
|
constructor(config) {
|
|
45040
45063
|
super();
|
|
@@ -45043,9 +45066,9 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45043
45066
|
_state = "idle";
|
|
45044
45067
|
_deviceId = null;
|
|
45045
45068
|
_fingerprint = null;
|
|
45046
|
-
|
|
45069
|
+
_primaryConversationId = "";
|
|
45047
45070
|
_deviceJwt = null;
|
|
45048
|
-
|
|
45071
|
+
_sessions = /* @__PURE__ */ new Map();
|
|
45049
45072
|
_ws = null;
|
|
45050
45073
|
_pollTimer = null;
|
|
45051
45074
|
_reconnectAttempt = 0;
|
|
@@ -45061,41 +45084,94 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45061
45084
|
get fingerprint() {
|
|
45062
45085
|
return this._fingerprint;
|
|
45063
45086
|
}
|
|
45087
|
+
/** Returns the primary conversation ID (backward-compatible). */
|
|
45064
45088
|
get conversationId() {
|
|
45065
|
-
return this.
|
|
45089
|
+
return this._primaryConversationId || null;
|
|
45090
|
+
}
|
|
45091
|
+
/** Returns all active conversation IDs. */
|
|
45092
|
+
get conversationIds() {
|
|
45093
|
+
return Array.from(this._sessions.keys());
|
|
45094
|
+
}
|
|
45095
|
+
/** Returns the number of active sessions. */
|
|
45096
|
+
get sessionCount() {
|
|
45097
|
+
return this._sessions.size;
|
|
45066
45098
|
}
|
|
45067
45099
|
async start() {
|
|
45068
45100
|
this._stopped = false;
|
|
45069
45101
|
await libsodium_wrappers_default.ready;
|
|
45070
|
-
const
|
|
45071
|
-
if (
|
|
45072
|
-
this._persisted =
|
|
45073
|
-
this.
|
|
45074
|
-
|
|
45075
|
-
|
|
45076
|
-
this.
|
|
45077
|
-
this.
|
|
45102
|
+
const raw = await loadState(this.config.dataDir);
|
|
45103
|
+
if (raw) {
|
|
45104
|
+
this._persisted = migratePersistedState(raw);
|
|
45105
|
+
if (!this._persisted.messageHistory) {
|
|
45106
|
+
this._persisted.messageHistory = [];
|
|
45107
|
+
}
|
|
45108
|
+
this._deviceId = this._persisted.deviceId;
|
|
45109
|
+
this._deviceJwt = this._persisted.deviceJwt;
|
|
45110
|
+
this._primaryConversationId = this._persisted.primaryConversationId;
|
|
45111
|
+
this._fingerprint = this._persisted.fingerprint;
|
|
45112
|
+
for (const [convId, sessionData] of Object.entries(
|
|
45113
|
+
this._persisted.sessions
|
|
45114
|
+
)) {
|
|
45115
|
+
if (sessionData.ratchetState) {
|
|
45116
|
+
const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
|
|
45117
|
+
this._sessions.set(convId, {
|
|
45118
|
+
ownerDeviceId: sessionData.ownerDeviceId,
|
|
45119
|
+
ratchet,
|
|
45120
|
+
activated: sessionData.activated ?? false
|
|
45121
|
+
});
|
|
45122
|
+
}
|
|
45123
|
+
}
|
|
45078
45124
|
this._connect();
|
|
45079
45125
|
return;
|
|
45080
45126
|
}
|
|
45081
45127
|
await this._enroll();
|
|
45082
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
|
+
}
|
|
45147
|
+
/**
|
|
45148
|
+
* Encrypt and send a message to ALL owner devices (fanout).
|
|
45149
|
+
* Each session gets the same plaintext encrypted independently.
|
|
45150
|
+
*/
|
|
45083
45151
|
async send(plaintext) {
|
|
45084
|
-
if (this._state !== "ready" ||
|
|
45152
|
+
if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
|
|
45085
45153
|
throw new Error("Channel is not ready");
|
|
45086
45154
|
}
|
|
45087
|
-
|
|
45088
|
-
const
|
|
45089
|
-
this.
|
|
45090
|
-
|
|
45091
|
-
|
|
45092
|
-
|
|
45093
|
-
|
|
45094
|
-
|
|
45095
|
-
|
|
45096
|
-
|
|
45097
|
-
|
|
45098
|
-
|
|
45155
|
+
this._appendHistory("agent", plaintext);
|
|
45156
|
+
const messageGroupId = randomUUID();
|
|
45157
|
+
for (const [convId, session] of this._sessions) {
|
|
45158
|
+
if (!session.activated) {
|
|
45159
|
+
continue;
|
|
45160
|
+
}
|
|
45161
|
+
const encrypted = session.ratchet.encrypt(plaintext);
|
|
45162
|
+
const transport = encryptedMessageToTransport(encrypted);
|
|
45163
|
+
this._ws.send(
|
|
45164
|
+
JSON.stringify({
|
|
45165
|
+
event: "message",
|
|
45166
|
+
data: {
|
|
45167
|
+
conversation_id: convId,
|
|
45168
|
+
header_blob: transport.header_blob,
|
|
45169
|
+
ciphertext: transport.ciphertext,
|
|
45170
|
+
message_group_id: messageGroupId
|
|
45171
|
+
}
|
|
45172
|
+
})
|
|
45173
|
+
);
|
|
45174
|
+
}
|
|
45099
45175
|
await this._persistState();
|
|
45100
45176
|
}
|
|
45101
45177
|
async stop() {
|
|
@@ -45140,8 +45216,10 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45140
45216
|
deviceId: result.device_id,
|
|
45141
45217
|
deviceJwt: "",
|
|
45142
45218
|
// set after activation
|
|
45143
|
-
|
|
45219
|
+
primaryConversationId: "",
|
|
45144
45220
|
// set after activation
|
|
45221
|
+
sessions: {},
|
|
45222
|
+
// populated after activation
|
|
45145
45223
|
identityKeypair: {
|
|
45146
45224
|
publicKey: bytesToHex(identity.publicKey),
|
|
45147
45225
|
privateKey: bytesToHex(identity.privateKey)
|
|
@@ -45151,8 +45229,7 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45151
45229
|
privateKey: bytesToHex(ephemeral.privateKey)
|
|
45152
45230
|
},
|
|
45153
45231
|
fingerprint: result.fingerprint,
|
|
45154
|
-
|
|
45155
|
-
// set after activation
|
|
45232
|
+
messageHistory: []
|
|
45156
45233
|
};
|
|
45157
45234
|
this._poll();
|
|
45158
45235
|
} catch (err) {
|
|
@@ -45191,31 +45268,85 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45191
45268
|
this.config.apiUrl,
|
|
45192
45269
|
this._deviceId
|
|
45193
45270
|
);
|
|
45194
|
-
|
|
45271
|
+
const conversations = result.conversations || [
|
|
45272
|
+
{
|
|
45273
|
+
conversation_id: result.conversation_id,
|
|
45274
|
+
owner_device_id: "",
|
|
45275
|
+
is_primary: true
|
|
45276
|
+
}
|
|
45277
|
+
];
|
|
45278
|
+
const primary = conversations.find((c2) => c2.is_primary) || conversations[0];
|
|
45279
|
+
this._primaryConversationId = primary.conversation_id;
|
|
45195
45280
|
this._deviceJwt = result.device_jwt;
|
|
45196
45281
|
const identity = this._persisted.identityKeypair;
|
|
45197
45282
|
const ephemeral = this._persisted.ephemeralKeypair;
|
|
45198
|
-
const
|
|
45199
|
-
|
|
45200
|
-
|
|
45201
|
-
|
|
45202
|
-
|
|
45203
|
-
|
|
45204
|
-
|
|
45205
|
-
|
|
45206
|
-
|
|
45207
|
-
|
|
45208
|
-
|
|
45209
|
-
|
|
45210
|
-
|
|
45211
|
-
|
|
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
|
+
}
|
|
45212
45313
|
this._persisted = {
|
|
45213
45314
|
...this._persisted,
|
|
45214
45315
|
deviceJwt: result.device_jwt,
|
|
45215
|
-
|
|
45216
|
-
|
|
45316
|
+
primaryConversationId: primary.conversation_id,
|
|
45317
|
+
sessions,
|
|
45318
|
+
messageHistory: this._persisted.messageHistory ?? []
|
|
45217
45319
|
};
|
|
45218
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
|
+
}
|
|
45219
45350
|
this._connect();
|
|
45220
45351
|
} catch (err) {
|
|
45221
45352
|
this._handleError(err);
|
|
@@ -45246,23 +45377,12 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45246
45377
|
this._handleError(new Error("Device was revoked"));
|
|
45247
45378
|
return;
|
|
45248
45379
|
}
|
|
45380
|
+
if (data.event === "device_linked") {
|
|
45381
|
+
await this._handleDeviceLinked(data.data);
|
|
45382
|
+
return;
|
|
45383
|
+
}
|
|
45249
45384
|
if (data.event === "message") {
|
|
45250
|
-
|
|
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;
|
|
45385
|
+
await this._handleIncomingMessage(data.data);
|
|
45266
45386
|
}
|
|
45267
45387
|
} catch (err) {
|
|
45268
45388
|
this.emit("error", err);
|
|
@@ -45277,6 +45397,182 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45277
45397
|
this.emit("error", err);
|
|
45278
45398
|
});
|
|
45279
45399
|
}
|
|
45400
|
+
/**
|
|
45401
|
+
* Handle an incoming encrypted message from a specific conversation.
|
|
45402
|
+
* Decrypts using the appropriate session ratchet, emits to the agent,
|
|
45403
|
+
* and relays as sync messages to sibling sessions.
|
|
45404
|
+
*/
|
|
45405
|
+
async _handleIncomingMessage(msgData) {
|
|
45406
|
+
if (msgData.sender_device_id === this._deviceId) return;
|
|
45407
|
+
const convId = msgData.conversation_id;
|
|
45408
|
+
const session = this._sessions.get(convId);
|
|
45409
|
+
if (!session) {
|
|
45410
|
+
console.warn(
|
|
45411
|
+
`[SecureChannel] No session for conversation ${convId}, skipping`
|
|
45412
|
+
);
|
|
45413
|
+
return;
|
|
45414
|
+
}
|
|
45415
|
+
const encrypted = transportToEncryptedMessage({
|
|
45416
|
+
header_blob: msgData.header_blob,
|
|
45417
|
+
ciphertext: msgData.ciphertext
|
|
45418
|
+
});
|
|
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
|
+
}
|
|
45424
|
+
let messageText;
|
|
45425
|
+
let messageType;
|
|
45426
|
+
try {
|
|
45427
|
+
const parsed = JSON.parse(plaintext);
|
|
45428
|
+
messageType = parsed.type || "message";
|
|
45429
|
+
messageText = parsed.text || plaintext;
|
|
45430
|
+
} catch {
|
|
45431
|
+
messageType = "message";
|
|
45432
|
+
messageText = plaintext;
|
|
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
|
+
}
|
|
45440
|
+
if (messageType === "message") {
|
|
45441
|
+
this._appendHistory("owner", messageText);
|
|
45442
|
+
const metadata = {
|
|
45443
|
+
messageId: msgData.message_id,
|
|
45444
|
+
conversationId: convId,
|
|
45445
|
+
timestamp: msgData.created_at
|
|
45446
|
+
};
|
|
45447
|
+
this.emit("message", messageText, metadata);
|
|
45448
|
+
this.config.onMessage?.(messageText, metadata);
|
|
45449
|
+
await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText);
|
|
45450
|
+
}
|
|
45451
|
+
if (this._persisted) {
|
|
45452
|
+
this._persisted.lastMessageTimestamp = msgData.created_at;
|
|
45453
|
+
}
|
|
45454
|
+
await this._persistState();
|
|
45455
|
+
}
|
|
45456
|
+
/**
|
|
45457
|
+
* Relay an owner's message to all sibling sessions as encrypted sync messages.
|
|
45458
|
+
* This allows all owner devices to see messages from any single device.
|
|
45459
|
+
*/
|
|
45460
|
+
async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText) {
|
|
45461
|
+
if (!this._ws || this._sessions.size <= 1) return;
|
|
45462
|
+
const syncPayload = JSON.stringify({
|
|
45463
|
+
type: "sync",
|
|
45464
|
+
sender: senderOwnerDeviceId,
|
|
45465
|
+
text: messageText,
|
|
45466
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
45467
|
+
});
|
|
45468
|
+
for (const [siblingConvId, siblingSession] of this._sessions) {
|
|
45469
|
+
if (siblingConvId === sourceConvId) continue;
|
|
45470
|
+
if (!siblingSession.activated) continue;
|
|
45471
|
+
const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
|
|
45472
|
+
const syncTransport = encryptedMessageToTransport(syncEncrypted);
|
|
45473
|
+
this._ws.send(
|
|
45474
|
+
JSON.stringify({
|
|
45475
|
+
event: "message",
|
|
45476
|
+
data: {
|
|
45477
|
+
conversation_id: siblingConvId,
|
|
45478
|
+
header_blob: syncTransport.header_blob,
|
|
45479
|
+
ciphertext: syncTransport.ciphertext
|
|
45480
|
+
}
|
|
45481
|
+
})
|
|
45482
|
+
);
|
|
45483
|
+
}
|
|
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
|
+
}
|
|
45517
|
+
/**
|
|
45518
|
+
* Handle a device_linked event: a new owner device has joined.
|
|
45519
|
+
* Fetches the new device's public keys, performs X3DH, and initializes
|
|
45520
|
+
* a new ratchet session.
|
|
45521
|
+
*/
|
|
45522
|
+
async _handleDeviceLinked(event) {
|
|
45523
|
+
console.log(
|
|
45524
|
+
`[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
|
|
45525
|
+
);
|
|
45526
|
+
try {
|
|
45527
|
+
if (!event.owner_identity_public_key) {
|
|
45528
|
+
console.error(
|
|
45529
|
+
`[SecureChannel] device_linked event missing owner keys for conv ${event.conversation_id.slice(0, 8)}...`
|
|
45530
|
+
);
|
|
45531
|
+
return;
|
|
45532
|
+
}
|
|
45533
|
+
const identity = this._persisted.identityKeypair;
|
|
45534
|
+
const ephemeral = this._persisted.ephemeralKeypair;
|
|
45535
|
+
const sharedSecret = performX3DH({
|
|
45536
|
+
myIdentityPrivate: hexToBytes(identity.privateKey),
|
|
45537
|
+
myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
|
|
45538
|
+
theirIdentityPublic: hexToBytes(event.owner_identity_public_key),
|
|
45539
|
+
theirEphemeralPublic: hexToBytes(
|
|
45540
|
+
event.owner_ephemeral_public_key ?? event.owner_identity_public_key
|
|
45541
|
+
),
|
|
45542
|
+
isInitiator: false
|
|
45543
|
+
});
|
|
45544
|
+
const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
|
|
45545
|
+
publicKey: hexToBytes(identity.publicKey),
|
|
45546
|
+
privateKey: hexToBytes(identity.privateKey),
|
|
45547
|
+
keyType: "ed25519"
|
|
45548
|
+
});
|
|
45549
|
+
this._sessions.set(event.conversation_id, {
|
|
45550
|
+
ownerDeviceId: event.owner_device_id,
|
|
45551
|
+
ratchet,
|
|
45552
|
+
activated: false
|
|
45553
|
+
// Wait for owner's first message
|
|
45554
|
+
});
|
|
45555
|
+
this._persisted.sessions[event.conversation_id] = {
|
|
45556
|
+
ownerDeviceId: event.owner_device_id,
|
|
45557
|
+
ratchetState: ratchet.serialize(),
|
|
45558
|
+
activated: false
|
|
45559
|
+
};
|
|
45560
|
+
await this._persistState();
|
|
45561
|
+
console.log(
|
|
45562
|
+
`[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}... (conv ${event.conversation_id.slice(0, 8)}...)`
|
|
45563
|
+
);
|
|
45564
|
+
} catch (err) {
|
|
45565
|
+
console.error(
|
|
45566
|
+
`[SecureChannel] Failed to handle device_linked:`,
|
|
45567
|
+
err
|
|
45568
|
+
);
|
|
45569
|
+
this.emit("error", err);
|
|
45570
|
+
}
|
|
45571
|
+
}
|
|
45572
|
+
/**
|
|
45573
|
+
* Sync missed messages across ALL sessions.
|
|
45574
|
+
* For each conversation, fetches messages since last sync and decrypts.
|
|
45575
|
+
*/
|
|
45280
45576
|
async _syncMissedMessages() {
|
|
45281
45577
|
if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt) return;
|
|
45282
45578
|
try {
|
|
@@ -45289,19 +45585,43 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45289
45585
|
const messages = await res.json();
|
|
45290
45586
|
for (const msg of messages) {
|
|
45291
45587
|
if (msg.sender_device_id === this._deviceId) continue;
|
|
45588
|
+
const session = this._sessions.get(msg.conversation_id);
|
|
45589
|
+
if (!session) {
|
|
45590
|
+
console.warn(
|
|
45591
|
+
`[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`
|
|
45592
|
+
);
|
|
45593
|
+
continue;
|
|
45594
|
+
}
|
|
45292
45595
|
try {
|
|
45293
45596
|
const encrypted = transportToEncryptedMessage({
|
|
45294
45597
|
header_blob: msg.header_blob,
|
|
45295
45598
|
ciphertext: msg.ciphertext
|
|
45296
45599
|
});
|
|
45297
|
-
const plaintext =
|
|
45298
|
-
|
|
45299
|
-
|
|
45300
|
-
|
|
45301
|
-
|
|
45302
|
-
|
|
45303
|
-
|
|
45304
|
-
|
|
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
|
+
}
|
|
45605
|
+
let messageText;
|
|
45606
|
+
let messageType;
|
|
45607
|
+
try {
|
|
45608
|
+
const parsed = JSON.parse(plaintext);
|
|
45609
|
+
messageType = parsed.type || "message";
|
|
45610
|
+
messageText = parsed.text || plaintext;
|
|
45611
|
+
} catch {
|
|
45612
|
+
messageType = "message";
|
|
45613
|
+
messageText = plaintext;
|
|
45614
|
+
}
|
|
45615
|
+
if (messageType === "message") {
|
|
45616
|
+
this._appendHistory("owner", messageText);
|
|
45617
|
+
const metadata = {
|
|
45618
|
+
messageId: msg.id,
|
|
45619
|
+
conversationId: msg.conversation_id,
|
|
45620
|
+
timestamp: msg.created_at
|
|
45621
|
+
};
|
|
45622
|
+
this.emit("message", messageText, metadata);
|
|
45623
|
+
this.config.onMessage?.(messageText, metadata);
|
|
45624
|
+
}
|
|
45305
45625
|
this._persisted.lastMessageTimestamp = msg.created_at;
|
|
45306
45626
|
} catch (err) {
|
|
45307
45627
|
this.emit("error", err);
|
|
@@ -45335,9 +45655,19 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45335
45655
|
this._setState("error");
|
|
45336
45656
|
this.emit("error", err);
|
|
45337
45657
|
}
|
|
45658
|
+
/**
|
|
45659
|
+
* Persist all ratchet session states to disk.
|
|
45660
|
+
* Syncs live ratchet states back into the persisted sessions map.
|
|
45661
|
+
*/
|
|
45338
45662
|
async _persistState() {
|
|
45339
|
-
if (!this._persisted ||
|
|
45340
|
-
|
|
45663
|
+
if (!this._persisted || this._sessions.size === 0) return;
|
|
45664
|
+
for (const [convId, session] of this._sessions) {
|
|
45665
|
+
this._persisted.sessions[convId] = {
|
|
45666
|
+
ownerDeviceId: session.ownerDeviceId,
|
|
45667
|
+
ratchetState: session.ratchet.serialize(),
|
|
45668
|
+
activated: session.activated
|
|
45669
|
+
};
|
|
45670
|
+
}
|
|
45341
45671
|
await saveState(this.config.dataDir, this._persisted);
|
|
45342
45672
|
}
|
|
45343
45673
|
};
|