@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/__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 +36 -2
- package/dist/channel.d.ts.map +1 -1
- package/dist/cli.js +278 -59
- package/dist/cli.js.map +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +278 -59
- 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 +21 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional QA Test Suite — Agent Setup & Connection
|
|
3
|
+
*
|
|
4
|
+
* Tests the full agent lifecycle against the production API:
|
|
5
|
+
* 1. Package installation & CLI
|
|
6
|
+
* 2. Crypto operations (key gen, proof, fingerprint)
|
|
7
|
+
* 3. API connectivity & error handling
|
|
8
|
+
* 4. Full enrollment flow (create invite → enroll → approve → activate → connect)
|
|
9
|
+
*
|
|
10
|
+
* Requires env vars:
|
|
11
|
+
* CLERK_SECRET_KEY — Clerk backend secret for session token creation
|
|
12
|
+
* CLERK_SESSION_ID — Active Clerk session ID (for Eric's account)
|
|
13
|
+
* API_URL — Backend API URL (default: https://api.agentvault.chat)
|
|
14
|
+
*
|
|
15
|
+
* Rate limit note: enrollment is limited to 5 requests/IP/10min.
|
|
16
|
+
* This suite uses 2 enrollment calls (4.2 + 4.9). Error-case enrollment
|
|
17
|
+
* tests (3.3, 3.4) are removed to conserve rate limit budget.
|
|
18
|
+
* Test 4.2 will retry with backoff if rate-limited (up to 3 retries, 30s apart).
|
|
19
|
+
*/
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=functional.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"functional.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/functional.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"multi-session.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/multi-session.test.ts"],"names":[],"mappings":""}
|
package/dist/channel.d.ts
CHANGED
|
@@ -5,9 +5,9 @@ export declare class SecureChannel extends EventEmitter {
|
|
|
5
5
|
private _state;
|
|
6
6
|
private _deviceId;
|
|
7
7
|
private _fingerprint;
|
|
8
|
-
private
|
|
8
|
+
private _primaryConversationId;
|
|
9
9
|
private _deviceJwt;
|
|
10
|
-
private
|
|
10
|
+
private _sessions;
|
|
11
11
|
private _ws;
|
|
12
12
|
private _pollTimer;
|
|
13
13
|
private _reconnectAttempt;
|
|
@@ -18,18 +18,52 @@ export declare class SecureChannel extends EventEmitter {
|
|
|
18
18
|
get state(): ChannelState;
|
|
19
19
|
get deviceId(): string | null;
|
|
20
20
|
get fingerprint(): string | null;
|
|
21
|
+
/** Returns the primary conversation ID (backward-compatible). */
|
|
21
22
|
get conversationId(): string | null;
|
|
23
|
+
/** Returns all active conversation IDs. */
|
|
24
|
+
get conversationIds(): string[];
|
|
25
|
+
/** Returns the number of active sessions. */
|
|
26
|
+
get sessionCount(): number;
|
|
22
27
|
start(): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Encrypt and send a message to ALL owner devices (fanout).
|
|
30
|
+
* Each session gets the same plaintext encrypted independently.
|
|
31
|
+
*/
|
|
23
32
|
send(plaintext: string): Promise<void>;
|
|
24
33
|
stop(): Promise<void>;
|
|
25
34
|
private _enroll;
|
|
26
35
|
private _poll;
|
|
27
36
|
private _activate;
|
|
28
37
|
private _connect;
|
|
38
|
+
/**
|
|
39
|
+
* Handle an incoming encrypted message from a specific conversation.
|
|
40
|
+
* Decrypts using the appropriate session ratchet, emits to the agent,
|
|
41
|
+
* and relays as sync messages to sibling sessions.
|
|
42
|
+
*/
|
|
43
|
+
private _handleIncomingMessage;
|
|
44
|
+
/**
|
|
45
|
+
* Relay an owner's message to all sibling sessions as encrypted sync messages.
|
|
46
|
+
* This allows all owner devices to see messages from any single device.
|
|
47
|
+
*/
|
|
48
|
+
private _relaySyncToSiblings;
|
|
49
|
+
/**
|
|
50
|
+
* Handle a device_linked event: a new owner device has joined.
|
|
51
|
+
* Fetches the new device's public keys, performs X3DH, and initializes
|
|
52
|
+
* a new ratchet session.
|
|
53
|
+
*/
|
|
54
|
+
private _handleDeviceLinked;
|
|
55
|
+
/**
|
|
56
|
+
* Sync missed messages across ALL sessions.
|
|
57
|
+
* For each conversation, fetches messages since last sync and decrypts.
|
|
58
|
+
*/
|
|
29
59
|
private _syncMissedMessages;
|
|
30
60
|
private _scheduleReconnect;
|
|
31
61
|
private _setState;
|
|
32
62
|
private _handleError;
|
|
63
|
+
/**
|
|
64
|
+
* Persist all ratchet session states to disk.
|
|
65
|
+
* Syncs live ratchet states back into the persisted sessions map.
|
|
66
|
+
*/
|
|
33
67
|
private _persistState;
|
|
34
68
|
}
|
|
35
69
|
//# sourceMappingURL=channel.d.ts.map
|
package/dist/channel.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;
|
|
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"}
|
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,27 @@ 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
|
+
};
|
|
45059
|
+
}
|
|
45038
45060
|
var SecureChannel = class extends EventEmitter {
|
|
45039
45061
|
constructor(config) {
|
|
45040
45062
|
super();
|
|
@@ -45043,9 +45065,9 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45043
45065
|
_state = "idle";
|
|
45044
45066
|
_deviceId = null;
|
|
45045
45067
|
_fingerprint = null;
|
|
45046
|
-
|
|
45068
|
+
_primaryConversationId = "";
|
|
45047
45069
|
_deviceJwt = null;
|
|
45048
|
-
|
|
45070
|
+
_sessions = /* @__PURE__ */ new Map();
|
|
45049
45071
|
_ws = null;
|
|
45050
45072
|
_pollTimer = null;
|
|
45051
45073
|
_reconnectAttempt = 0;
|
|
@@ -45061,41 +45083,68 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45061
45083
|
get fingerprint() {
|
|
45062
45084
|
return this._fingerprint;
|
|
45063
45085
|
}
|
|
45086
|
+
/** Returns the primary conversation ID (backward-compatible). */
|
|
45064
45087
|
get conversationId() {
|
|
45065
|
-
return this.
|
|
45088
|
+
return this._primaryConversationId || null;
|
|
45089
|
+
}
|
|
45090
|
+
/** Returns all active conversation IDs. */
|
|
45091
|
+
get conversationIds() {
|
|
45092
|
+
return Array.from(this._sessions.keys());
|
|
45093
|
+
}
|
|
45094
|
+
/** Returns the number of active sessions. */
|
|
45095
|
+
get sessionCount() {
|
|
45096
|
+
return this._sessions.size;
|
|
45066
45097
|
}
|
|
45067
45098
|
async start() {
|
|
45068
45099
|
this._stopped = false;
|
|
45069
45100
|
await libsodium_wrappers_default.ready;
|
|
45070
|
-
const
|
|
45071
|
-
if (
|
|
45072
|
-
this._persisted =
|
|
45073
|
-
this._deviceId =
|
|
45074
|
-
this._deviceJwt =
|
|
45075
|
-
this.
|
|
45076
|
-
this._fingerprint =
|
|
45077
|
-
|
|
45101
|
+
const raw = await loadState(this.config.dataDir);
|
|
45102
|
+
if (raw) {
|
|
45103
|
+
this._persisted = migratePersistedState(raw);
|
|
45104
|
+
this._deviceId = this._persisted.deviceId;
|
|
45105
|
+
this._deviceJwt = this._persisted.deviceJwt;
|
|
45106
|
+
this._primaryConversationId = this._persisted.primaryConversationId;
|
|
45107
|
+
this._fingerprint = this._persisted.fingerprint;
|
|
45108
|
+
for (const [convId, sessionData] of Object.entries(
|
|
45109
|
+
this._persisted.sessions
|
|
45110
|
+
)) {
|
|
45111
|
+
if (sessionData.ratchetState) {
|
|
45112
|
+
const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
|
|
45113
|
+
this._sessions.set(convId, {
|
|
45114
|
+
ownerDeviceId: sessionData.ownerDeviceId,
|
|
45115
|
+
ratchet
|
|
45116
|
+
});
|
|
45117
|
+
}
|
|
45118
|
+
}
|
|
45078
45119
|
this._connect();
|
|
45079
45120
|
return;
|
|
45080
45121
|
}
|
|
45081
45122
|
await this._enroll();
|
|
45082
45123
|
}
|
|
45124
|
+
/**
|
|
45125
|
+
* Encrypt and send a message to ALL owner devices (fanout).
|
|
45126
|
+
* Each session gets the same plaintext encrypted independently.
|
|
45127
|
+
*/
|
|
45083
45128
|
async send(plaintext) {
|
|
45084
|
-
if (this._state !== "ready" ||
|
|
45129
|
+
if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
|
|
45085
45130
|
throw new Error("Channel is not ready");
|
|
45086
45131
|
}
|
|
45087
|
-
const
|
|
45088
|
-
const
|
|
45089
|
-
|
|
45090
|
-
|
|
45091
|
-
|
|
45092
|
-
|
|
45093
|
-
|
|
45094
|
-
|
|
45095
|
-
|
|
45096
|
-
|
|
45097
|
-
|
|
45098
|
-
|
|
45132
|
+
const messageGroupId = randomUUID();
|
|
45133
|
+
for (const [convId, session] of this._sessions) {
|
|
45134
|
+
const encrypted = session.ratchet.encrypt(plaintext);
|
|
45135
|
+
const transport = encryptedMessageToTransport(encrypted);
|
|
45136
|
+
this._ws.send(
|
|
45137
|
+
JSON.stringify({
|
|
45138
|
+
event: "message",
|
|
45139
|
+
data: {
|
|
45140
|
+
conversation_id: convId,
|
|
45141
|
+
header_blob: transport.header_blob,
|
|
45142
|
+
ciphertext: transport.ciphertext,
|
|
45143
|
+
message_group_id: messageGroupId
|
|
45144
|
+
}
|
|
45145
|
+
})
|
|
45146
|
+
);
|
|
45147
|
+
}
|
|
45099
45148
|
await this._persistState();
|
|
45100
45149
|
}
|
|
45101
45150
|
async stop() {
|
|
@@ -45140,8 +45189,10 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45140
45189
|
deviceId: result.device_id,
|
|
45141
45190
|
deviceJwt: "",
|
|
45142
45191
|
// set after activation
|
|
45143
|
-
|
|
45192
|
+
primaryConversationId: "",
|
|
45144
45193
|
// set after activation
|
|
45194
|
+
sessions: {},
|
|
45195
|
+
// populated after activation
|
|
45145
45196
|
identityKeypair: {
|
|
45146
45197
|
publicKey: bytesToHex(identity.publicKey),
|
|
45147
45198
|
privateKey: bytesToHex(identity.privateKey)
|
|
@@ -45150,9 +45201,7 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45150
45201
|
publicKey: bytesToHex(ephemeral.publicKey),
|
|
45151
45202
|
privateKey: bytesToHex(ephemeral.privateKey)
|
|
45152
45203
|
},
|
|
45153
|
-
fingerprint: result.fingerprint
|
|
45154
|
-
ratchetState: ""
|
|
45155
|
-
// set after activation
|
|
45204
|
+
fingerprint: result.fingerprint
|
|
45156
45205
|
};
|
|
45157
45206
|
this._poll();
|
|
45158
45207
|
} catch (err) {
|
|
@@ -45191,11 +45240,19 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45191
45240
|
this.config.apiUrl,
|
|
45192
45241
|
this._deviceId
|
|
45193
45242
|
);
|
|
45194
|
-
|
|
45243
|
+
const conversations = result.conversations || [
|
|
45244
|
+
{
|
|
45245
|
+
conversation_id: result.conversation_id,
|
|
45246
|
+
owner_device_id: "",
|
|
45247
|
+
is_primary: true
|
|
45248
|
+
}
|
|
45249
|
+
];
|
|
45250
|
+
const primary = conversations.find((c2) => c2.is_primary) || conversations[0];
|
|
45251
|
+
this._primaryConversationId = primary.conversation_id;
|
|
45195
45252
|
this._deviceJwt = result.device_jwt;
|
|
45196
45253
|
const identity = this._persisted.identityKeypair;
|
|
45197
45254
|
const ephemeral = this._persisted.ephemeralKeypair;
|
|
45198
|
-
const sharedSecret =
|
|
45255
|
+
const sharedSecret = performX3DH({
|
|
45199
45256
|
myIdentityPrivate: hexToBytes(identity.privateKey),
|
|
45200
45257
|
myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
|
|
45201
45258
|
theirIdentityPublic: hexToBytes(result.owner_identity_public_key),
|
|
@@ -45204,16 +45261,25 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45204
45261
|
),
|
|
45205
45262
|
isInitiator: false
|
|
45206
45263
|
});
|
|
45207
|
-
|
|
45264
|
+
const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
|
|
45208
45265
|
publicKey: hexToBytes(identity.publicKey),
|
|
45209
45266
|
privateKey: hexToBytes(identity.privateKey),
|
|
45210
45267
|
keyType: "ed25519"
|
|
45211
45268
|
});
|
|
45269
|
+
this._sessions.set(primary.conversation_id, {
|
|
45270
|
+
ownerDeviceId: primary.owner_device_id,
|
|
45271
|
+
ratchet
|
|
45272
|
+
});
|
|
45212
45273
|
this._persisted = {
|
|
45213
45274
|
...this._persisted,
|
|
45214
45275
|
deviceJwt: result.device_jwt,
|
|
45215
|
-
|
|
45216
|
-
|
|
45276
|
+
primaryConversationId: primary.conversation_id,
|
|
45277
|
+
sessions: {
|
|
45278
|
+
[primary.conversation_id]: {
|
|
45279
|
+
ownerDeviceId: primary.owner_device_id,
|
|
45280
|
+
ratchetState: ratchet.serialize()
|
|
45281
|
+
}
|
|
45282
|
+
}
|
|
45217
45283
|
};
|
|
45218
45284
|
await saveState(this.config.dataDir, this._persisted);
|
|
45219
45285
|
this._connect();
|
|
@@ -45246,23 +45312,12 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45246
45312
|
this._handleError(new Error("Device was revoked"));
|
|
45247
45313
|
return;
|
|
45248
45314
|
}
|
|
45315
|
+
if (data.event === "device_linked") {
|
|
45316
|
+
await this._handleDeviceLinked(data.data);
|
|
45317
|
+
return;
|
|
45318
|
+
}
|
|
45249
45319
|
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;
|
|
45320
|
+
await this._handleIncomingMessage(data.data);
|
|
45266
45321
|
}
|
|
45267
45322
|
} catch (err) {
|
|
45268
45323
|
this.emit("error", err);
|
|
@@ -45277,6 +45332,142 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45277
45332
|
this.emit("error", err);
|
|
45278
45333
|
});
|
|
45279
45334
|
}
|
|
45335
|
+
/**
|
|
45336
|
+
* Handle an incoming encrypted message from a specific conversation.
|
|
45337
|
+
* Decrypts using the appropriate session ratchet, emits to the agent,
|
|
45338
|
+
* and relays as sync messages to sibling sessions.
|
|
45339
|
+
*/
|
|
45340
|
+
async _handleIncomingMessage(msgData) {
|
|
45341
|
+
if (msgData.sender_device_id === this._deviceId) return;
|
|
45342
|
+
const convId = msgData.conversation_id;
|
|
45343
|
+
const session = this._sessions.get(convId);
|
|
45344
|
+
if (!session) {
|
|
45345
|
+
console.warn(
|
|
45346
|
+
`[SecureChannel] No session for conversation ${convId}, skipping`
|
|
45347
|
+
);
|
|
45348
|
+
return;
|
|
45349
|
+
}
|
|
45350
|
+
const encrypted = transportToEncryptedMessage({
|
|
45351
|
+
header_blob: msgData.header_blob,
|
|
45352
|
+
ciphertext: msgData.ciphertext
|
|
45353
|
+
});
|
|
45354
|
+
const plaintext = session.ratchet.decrypt(encrypted);
|
|
45355
|
+
let messageText;
|
|
45356
|
+
let messageType;
|
|
45357
|
+
try {
|
|
45358
|
+
const parsed = JSON.parse(plaintext);
|
|
45359
|
+
messageType = parsed.type || "message";
|
|
45360
|
+
messageText = parsed.text || plaintext;
|
|
45361
|
+
} catch {
|
|
45362
|
+
messageType = "message";
|
|
45363
|
+
messageText = plaintext;
|
|
45364
|
+
}
|
|
45365
|
+
if (messageType === "message") {
|
|
45366
|
+
const metadata = {
|
|
45367
|
+
messageId: msgData.message_id,
|
|
45368
|
+
conversationId: convId,
|
|
45369
|
+
timestamp: msgData.created_at
|
|
45370
|
+
};
|
|
45371
|
+
this.emit("message", messageText, metadata);
|
|
45372
|
+
this.config.onMessage?.(messageText, metadata);
|
|
45373
|
+
await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText);
|
|
45374
|
+
}
|
|
45375
|
+
if (this._persisted) {
|
|
45376
|
+
this._persisted.lastMessageTimestamp = msgData.created_at;
|
|
45377
|
+
}
|
|
45378
|
+
await this._persistState();
|
|
45379
|
+
}
|
|
45380
|
+
/**
|
|
45381
|
+
* Relay an owner's message to all sibling sessions as encrypted sync messages.
|
|
45382
|
+
* This allows all owner devices to see messages from any single device.
|
|
45383
|
+
*/
|
|
45384
|
+
async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText) {
|
|
45385
|
+
if (!this._ws || this._sessions.size <= 1) return;
|
|
45386
|
+
const syncPayload = JSON.stringify({
|
|
45387
|
+
type: "sync",
|
|
45388
|
+
sender: senderOwnerDeviceId,
|
|
45389
|
+
text: messageText,
|
|
45390
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
45391
|
+
});
|
|
45392
|
+
for (const [siblingConvId, siblingSession] of this._sessions) {
|
|
45393
|
+
if (siblingConvId === sourceConvId) continue;
|
|
45394
|
+
const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
|
|
45395
|
+
const syncTransport = encryptedMessageToTransport(syncEncrypted);
|
|
45396
|
+
this._ws.send(
|
|
45397
|
+
JSON.stringify({
|
|
45398
|
+
event: "message",
|
|
45399
|
+
data: {
|
|
45400
|
+
conversation_id: siblingConvId,
|
|
45401
|
+
header_blob: syncTransport.header_blob,
|
|
45402
|
+
ciphertext: syncTransport.ciphertext
|
|
45403
|
+
}
|
|
45404
|
+
})
|
|
45405
|
+
);
|
|
45406
|
+
}
|
|
45407
|
+
}
|
|
45408
|
+
/**
|
|
45409
|
+
* Handle a device_linked event: a new owner device has joined.
|
|
45410
|
+
* Fetches the new device's public keys, performs X3DH, and initializes
|
|
45411
|
+
* a new ratchet session.
|
|
45412
|
+
*/
|
|
45413
|
+
async _handleDeviceLinked(event) {
|
|
45414
|
+
console.log(
|
|
45415
|
+
`[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
|
|
45416
|
+
);
|
|
45417
|
+
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) {
|
|
45425
|
+
console.error(
|
|
45426
|
+
`[SecureChannel] Failed to fetch keys for linked device: ${keysRes.status}`
|
|
45427
|
+
);
|
|
45428
|
+
return;
|
|
45429
|
+
}
|
|
45430
|
+
const keys = await keysRes.json();
|
|
45431
|
+
const identity = this._persisted.identityKeypair;
|
|
45432
|
+
const ephemeral = this._persisted.ephemeralKeypair;
|
|
45433
|
+
const sharedSecret = performX3DH({
|
|
45434
|
+
myIdentityPrivate: hexToBytes(identity.privateKey),
|
|
45435
|
+
myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
|
|
45436
|
+
theirIdentityPublic: hexToBytes(keys.identity_public_key),
|
|
45437
|
+
theirEphemeralPublic: hexToBytes(
|
|
45438
|
+
keys.ephemeral_public_key ?? keys.identity_public_key
|
|
45439
|
+
),
|
|
45440
|
+
isInitiator: false
|
|
45441
|
+
});
|
|
45442
|
+
const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
|
|
45443
|
+
publicKey: hexToBytes(identity.publicKey),
|
|
45444
|
+
privateKey: hexToBytes(identity.privateKey),
|
|
45445
|
+
keyType: "ed25519"
|
|
45446
|
+
});
|
|
45447
|
+
this._sessions.set(event.conversation_id, {
|
|
45448
|
+
ownerDeviceId: event.owner_device_id,
|
|
45449
|
+
ratchet
|
|
45450
|
+
});
|
|
45451
|
+
this._persisted.sessions[event.conversation_id] = {
|
|
45452
|
+
ownerDeviceId: event.owner_device_id,
|
|
45453
|
+
ratchetState: ratchet.serialize()
|
|
45454
|
+
};
|
|
45455
|
+
await this._persistState();
|
|
45456
|
+
console.log(
|
|
45457
|
+
`[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}...`
|
|
45458
|
+
);
|
|
45459
|
+
} catch (err) {
|
|
45460
|
+
console.error(
|
|
45461
|
+
`[SecureChannel] Failed to handle device_linked:`,
|
|
45462
|
+
err
|
|
45463
|
+
);
|
|
45464
|
+
this.emit("error", err);
|
|
45465
|
+
}
|
|
45466
|
+
}
|
|
45467
|
+
/**
|
|
45468
|
+
* Sync missed messages across ALL sessions.
|
|
45469
|
+
* For each conversation, fetches messages since last sync and decrypts.
|
|
45470
|
+
*/
|
|
45280
45471
|
async _syncMissedMessages() {
|
|
45281
45472
|
if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt) return;
|
|
45282
45473
|
try {
|
|
@@ -45289,19 +45480,38 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45289
45480
|
const messages = await res.json();
|
|
45290
45481
|
for (const msg of messages) {
|
|
45291
45482
|
if (msg.sender_device_id === this._deviceId) continue;
|
|
45483
|
+
const session = this._sessions.get(msg.conversation_id);
|
|
45484
|
+
if (!session) {
|
|
45485
|
+
console.warn(
|
|
45486
|
+
`[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`
|
|
45487
|
+
);
|
|
45488
|
+
continue;
|
|
45489
|
+
}
|
|
45292
45490
|
try {
|
|
45293
45491
|
const encrypted = transportToEncryptedMessage({
|
|
45294
45492
|
header_blob: msg.header_blob,
|
|
45295
45493
|
ciphertext: msg.ciphertext
|
|
45296
45494
|
});
|
|
45297
|
-
const plaintext =
|
|
45298
|
-
|
|
45299
|
-
|
|
45300
|
-
|
|
45301
|
-
|
|
45302
|
-
|
|
45303
|
-
|
|
45304
|
-
|
|
45495
|
+
const plaintext = session.ratchet.decrypt(encrypted);
|
|
45496
|
+
let messageText;
|
|
45497
|
+
let messageType;
|
|
45498
|
+
try {
|
|
45499
|
+
const parsed = JSON.parse(plaintext);
|
|
45500
|
+
messageType = parsed.type || "message";
|
|
45501
|
+
messageText = parsed.text || plaintext;
|
|
45502
|
+
} catch {
|
|
45503
|
+
messageType = "message";
|
|
45504
|
+
messageText = plaintext;
|
|
45505
|
+
}
|
|
45506
|
+
if (messageType === "message") {
|
|
45507
|
+
const metadata = {
|
|
45508
|
+
messageId: msg.id,
|
|
45509
|
+
conversationId: msg.conversation_id,
|
|
45510
|
+
timestamp: msg.created_at
|
|
45511
|
+
};
|
|
45512
|
+
this.emit("message", messageText, metadata);
|
|
45513
|
+
this.config.onMessage?.(messageText, metadata);
|
|
45514
|
+
}
|
|
45305
45515
|
this._persisted.lastMessageTimestamp = msg.created_at;
|
|
45306
45516
|
} catch (err) {
|
|
45307
45517
|
this.emit("error", err);
|
|
@@ -45335,9 +45545,18 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45335
45545
|
this._setState("error");
|
|
45336
45546
|
this.emit("error", err);
|
|
45337
45547
|
}
|
|
45548
|
+
/**
|
|
45549
|
+
* Persist all ratchet session states to disk.
|
|
45550
|
+
* Syncs live ratchet states back into the persisted sessions map.
|
|
45551
|
+
*/
|
|
45338
45552
|
async _persistState() {
|
|
45339
|
-
if (!this._persisted ||
|
|
45340
|
-
|
|
45553
|
+
if (!this._persisted || this._sessions.size === 0) return;
|
|
45554
|
+
for (const [convId, session] of this._sessions) {
|
|
45555
|
+
this._persisted.sessions[convId] = {
|
|
45556
|
+
ownerDeviceId: session.ownerDeviceId,
|
|
45557
|
+
ratchetState: session.ratchet.serialize()
|
|
45558
|
+
};
|
|
45559
|
+
}
|
|
45341
45560
|
await saveState(this.config.dataDir, this._persisted);
|
|
45342
45561
|
}
|
|
45343
45562
|
};
|