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