@vex-chat/libvex 5.5.2 → 6.0.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"sqlSessionToCrypto.js","sourceRoot":"","sources":["../../src/utils/sqlSessionToCrypto.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C,MAAM,UAAU,kBAAkB,CAAC,OAAmB;IAClD,OAAO;QACH,WAAW,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,WAAW,CAAC;QAClD,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,SAAS,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC;QAC9C,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,EAAE,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC;QAChC,MAAM,EAAE,OAAO,CAAC,MAAM;KACzB,CAAC;AACN,CAAC"}
1
+ {"version":3,"file":"sqlSessionToCrypto.js","sourceRoot":"","sources":["../../src/utils/sqlSessionToCrypto.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C,MAAM,UAAU,kBAAkB,CAAC,OAAmB;IAClD,MAAM,WAAW,GAAG,gBAAgB,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IAC1D,OAAO;QACH,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI;QACvD,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI;QACvD,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI;QACvD,UAAU,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,UAAU,CAAC;QAChD,SAAS,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC;QAC9C,WAAW,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,WAAW,CAAC;QAClD,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,EAAE,EAAE,OAAO,CAAC,EAAE;QACd,EAAE,EAAE,OAAO,CAAC,EAAE;QACd,EAAE,EAAE,OAAO,CAAC,EAAE;QACd,SAAS,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,SAAS,CAAC;QAC9C,EAAE,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC;QAChC,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,EAAE,EAAE,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC;QAChC,WAAW;QACX,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,QAAQ,EAAE,OAAO,CAAC,QAAQ;KAC7B,CAAC;AACN,CAAC;AAED,SAAS,gBAAgB,CAAC,GAAW;IACjC,IAAI,CAAC;QACD,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACxC,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YAChD,OAAO,EAAE,CAAC;QACd,CAAC;QACD,MAAM,GAAG,GAA2B,EAAE,CAAC;QACvC,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YAC1C,IAAI,OAAO,CAAC,KAAK,QAAQ,EAAE,CAAC;gBACxB,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;YACf,CAAC;QACL,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,EAAE,CAAC;IACd,CAAC;AACL,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vex-chat/libvex",
3
- "version": "5.5.2",
3
+ "version": "6.0.1",
4
4
  "description": "Library for communicating with xchat server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -93,8 +93,8 @@
93
93
  "msgpackr": "^1.11.9",
94
94
  "uuid": "^14.0.0",
95
95
  "zod": "^4.3.6",
96
- "@vex-chat/crypto": "^2.1.2",
97
- "@vex-chat/types": "^2.0.0"
96
+ "@vex-chat/types": "^3.0.0",
97
+ "@vex-chat/crypto": "^3.0.0"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "better-sqlite3": ">=11.0.0"
package/src/Client.ts CHANGED
@@ -80,14 +80,23 @@ import { z } from "zod/v4";
80
80
  import { WebSocketAdapter } from "./transport/websocket.js";
81
81
  import {
82
82
  decodeFipsInitialExtraV1,
83
- decodeFipsSubsequentExtraV1,
84
83
  encodeFipsInitialExtraV1,
85
- encodeFipsSubsequentExtraV1,
86
84
  fipsP256AdFromIdentityPubs,
87
85
  fipsP256PreKeySignPayload,
88
86
  isFipsInitialExtraV1,
89
- isFipsSubsequentExtraV1,
90
87
  } from "./utils/fipsMailExtra.js";
88
+ import {
89
+ decodeRatchetHeader,
90
+ deriveBootstrapSendChain,
91
+ encodeRatchetHeader,
92
+ hasRemoteDhChanged,
93
+ initRatchetSession,
94
+ ratchetStepReceive,
95
+ ratchetStepSend,
96
+ sessionToSqlPatch,
97
+ takeReceiveMessageKey,
98
+ takeSendMessageKey,
99
+ } from "./utils/ratchet.js";
91
100
 
92
101
  function debugLibvexDm(
93
102
  msg: string,
@@ -1309,9 +1318,6 @@ export class Client {
1309
1318
  return [signKey, ephKey, ad, index];
1310
1319
  }
1311
1320
  case MailType.subsequent:
1312
- if (isFipsSubsequentExtraV1(extra)) {
1313
- return [decodeFipsSubsequentExtraV1(extra)];
1314
- }
1315
1321
  return [extra];
1316
1322
  default:
1317
1323
  return [];
@@ -2094,7 +2100,9 @@ export class Client {
2094
2100
  // discard the ephemeral keys
2095
2101
  await this.newEphemeralKeys();
2096
2102
 
2103
+ const ratchet = await initRatchetSession(SK, "initiator");
2097
2104
  const sessionEntry: SessionSQL = {
2105
+ ...ratchet,
2098
2106
  deviceID: device.deviceID,
2099
2107
  fingerprint: XUtils.encodeHex(AD),
2100
2108
  lastUsed: new Date().toISOString(),
@@ -2114,6 +2122,7 @@ export class Client {
2114
2122
  const forwardedMsg = forward
2115
2123
  ? messageSchema.parse(msgpack.decode(message))
2116
2124
  : null;
2125
+ const shouldEmitHandshakeMessage = forward || message.length > 0;
2117
2126
  const emitMsg: Message = forwardedMsg
2118
2127
  ? { ...forwardedMsg, forward: true }
2119
2128
  : {
@@ -2130,7 +2139,9 @@ export class Client {
2130
2139
  sender: mail.sender,
2131
2140
  timestamp: new Date().toISOString(),
2132
2141
  };
2133
- this.emitter.emit("message", emitMsg);
2142
+ if (shouldEmitHandshakeMessage) {
2143
+ this.emitter.emit("message", emitMsg);
2144
+ }
2134
2145
 
2135
2146
  // send mail and wait for response
2136
2147
  await new Promise((res, rej) => {
@@ -3220,7 +3231,11 @@ export class Client {
3220
3231
  timestamp: timestamp,
3221
3232
  };
3222
3233
 
3223
- this.emitter.emit("message", message);
3234
+ const shouldEmitIncomingInitial =
3235
+ mail.forward || plaintext.length > 0;
3236
+ if (shouldEmitIncomingInitial) {
3237
+ this.emitter.emit("message", message);
3238
+ }
3224
3239
  if (libvexDebugDmEnabled()) {
3225
3240
  try {
3226
3241
  debugLibvexDm(
@@ -3271,7 +3286,12 @@ export class Client {
3271
3286
  deviceEntry;
3272
3287
 
3273
3288
  // save session
3289
+ const ratchet = await initRatchetSession(
3290
+ SK,
3291
+ "receiver",
3292
+ );
3274
3293
  const newSession: SessionSQL = {
3294
+ ...ratchet,
3275
3295
  deviceID: mail.sender,
3276
3296
  fingerprint: XUtils.encodeHex(AD),
3277
3297
  lastUsed: new Date().toISOString(),
@@ -3305,19 +3325,12 @@ export class Client {
3305
3325
  }
3306
3326
  break;
3307
3327
  case MailType.subsequent: {
3308
- const extraBuf = new Uint8Array(mail.extra);
3309
- const publicKey = isFipsSubsequentExtraV1(extraBuf)
3310
- ? decodeFipsSubsequentExtraV1(extraBuf)
3311
- : Client.deserializeExtra(
3312
- mail.mailType,
3313
- extraBuf,
3314
- )[0];
3315
- if (!publicKey) {
3316
- throw new Error(
3317
- "Malformed subsequent mail extra: missing publicKey",
3318
- );
3319
- }
3320
- let session = await this.getSessionByPubkey(publicKey);
3328
+ const ratchetHeader = decodeRatchetHeader(
3329
+ new Uint8Array(mail.extra),
3330
+ );
3331
+ let session = await this.database.getSessionByDeviceID(
3332
+ mail.sender,
3333
+ );
3321
3334
  let retries = 0;
3322
3335
  while (!session) {
3323
3336
  if (retries >= 3) {
@@ -3325,14 +3338,44 @@ export class Client {
3325
3338
  }
3326
3339
  await sleep(100 * 2 ** retries);
3327
3340
  retries++;
3328
- session = await this.getSessionByPubkey(publicKey);
3341
+ session = await this.database.getSessionByDeviceID(
3342
+ mail.sender,
3343
+ );
3329
3344
  }
3330
3345
 
3331
3346
  if (!session) {
3332
3347
  void healSession();
3333
3348
  return;
3334
3349
  }
3335
- const HMAC = xHMAC(mail, session.SK);
3350
+
3351
+ const firstInboundFromSubsequent = !session.DHr;
3352
+ if (firstInboundFromSubsequent) {
3353
+ session.DHr = ratchetHeader.dhPub;
3354
+ // First inbound after X3DH initial mail has no prior DH ratchet.
3355
+ // If this side has no receiving chain yet (initiator path),
3356
+ // derive the bootstrap receive chain to match peer's first
3357
+ // bootstrap send chain.
3358
+ if (!session.CKr) {
3359
+ session.CKr = deriveBootstrapSendChain(
3360
+ session.RK,
3361
+ );
3362
+ }
3363
+ } else if (
3364
+ hasRemoteDhChanged(session.DHr, ratchetHeader.dhPub)
3365
+ ) {
3366
+ await ratchetStepReceive(
3367
+ session,
3368
+ ratchetHeader.dhPub,
3369
+ ratchetHeader.pn,
3370
+ );
3371
+ }
3372
+
3373
+ const messageKey = takeReceiveMessageKey(
3374
+ session,
3375
+ ratchetHeader.dhPub,
3376
+ ratchetHeader.n,
3377
+ );
3378
+ const HMAC = xHMAC(mail, messageKey);
3336
3379
 
3337
3380
  if (!XUtils.bytesEqual(HMAC, header)) {
3338
3381
  void healSession();
@@ -3342,7 +3385,7 @@ export class Client {
3342
3385
  const decrypted = await xSecretboxOpenAsync(
3343
3386
  new Uint8Array(mail.cipher),
3344
3387
  new Uint8Array(mail.nonce),
3345
- session.SK,
3388
+ messageKey,
3346
3389
  );
3347
3390
 
3348
3391
  if (decrypted) {
@@ -3374,9 +3417,34 @@ export class Client {
3374
3417
  };
3375
3418
  this.emitter.emit("message", message);
3376
3419
 
3377
- void this.database.markSessionUsed(
3378
- session.sessionID,
3379
- );
3420
+ const sqlPatch = sessionToSqlPatch(session);
3421
+ const persisted: SessionSQL = {
3422
+ CKr: sqlPatch.CKr,
3423
+ CKs: sqlPatch.CKs,
3424
+ deviceID: mail.sender,
3425
+ DHr: sqlPatch.DHr,
3426
+ DHsPrivate: sqlPatch.DHsPrivate,
3427
+ DHsPublic: sqlPatch.DHsPublic,
3428
+ fingerprint: XUtils.encodeHex(
3429
+ session.fingerprint,
3430
+ ),
3431
+ lastUsed: new Date().toISOString(),
3432
+ mode: session.mode,
3433
+ Nr: sqlPatch.Nr,
3434
+ Ns: sqlPatch.Ns,
3435
+ PN: sqlPatch.PN,
3436
+ publicKey: XUtils.encodeHex(session.publicKey),
3437
+ RK: sqlPatch.RK,
3438
+ sessionID: session.sessionID,
3439
+ SK: XUtils.encodeHex(session.SK),
3440
+ skippedKeys: sqlPatch.skippedKeys,
3441
+ userID: session.userID,
3442
+ verified: session.verified,
3443
+ };
3444
+ await this.database.saveSession(persisted);
3445
+ this.sessionRecords[
3446
+ XUtils.encodeHex(session.publicKey)
3447
+ ] = session;
3380
3448
  } else {
3381
3449
  void healSession();
3382
3450
  this.emitter.emit("retryRequest", {
@@ -3718,12 +3786,19 @@ export class Client {
3718
3786
  });
3719
3787
  }
3720
3788
 
3789
+ if (!session.CKs) {
3790
+ await ratchetStepSend(session);
3791
+ }
3792
+ const { messageKey, n } = takeSendMessageKey(session);
3793
+ const ratchetHeader = {
3794
+ dhPub: session.DHsPublic,
3795
+ n,
3796
+ pn: session.PN,
3797
+ version: 1 as const,
3798
+ };
3721
3799
  const nonce = xMakeNonce();
3722
- const cipher = await xSecretboxAsync(msg, nonce, session.SK);
3723
- const extra =
3724
- this.cryptoProfile === "fips"
3725
- ? encodeFipsSubsequentExtraV1(session.publicKey)
3726
- : session.publicKey;
3800
+ const cipher = await xSecretboxAsync(msg, nonce, messageKey);
3801
+ const extra = encodeRatchetHeader(ratchetHeader);
3727
3802
 
3728
3803
  const mail: MailWS = {
3729
3804
  authorID: this.getUser().userID,
@@ -3747,7 +3822,7 @@ export class Client {
3747
3822
  type: "resource",
3748
3823
  };
3749
3824
 
3750
- const hmac = xHMAC(mail, session.SK);
3825
+ const hmac = xHMAC(mail, messageKey);
3751
3826
 
3752
3827
  const fwdOut = forward
3753
3828
  ? messageSchema.parse(msgpack.decode(msg))
@@ -3770,6 +3845,31 @@ export class Client {
3770
3845
  };
3771
3846
  this.emitter.emit("message", outMsg);
3772
3847
 
3848
+ const sqlPatch = sessionToSqlPatch(session);
3849
+ const persisted: SessionSQL = {
3850
+ CKr: sqlPatch.CKr,
3851
+ CKs: sqlPatch.CKs,
3852
+ deviceID: device.deviceID,
3853
+ DHr: sqlPatch.DHr,
3854
+ DHsPrivate: sqlPatch.DHsPrivate,
3855
+ DHsPublic: sqlPatch.DHsPublic,
3856
+ fingerprint: XUtils.encodeHex(session.fingerprint),
3857
+ lastUsed: new Date().toISOString(),
3858
+ mode: session.mode,
3859
+ Nr: sqlPatch.Nr,
3860
+ Ns: sqlPatch.Ns,
3861
+ PN: sqlPatch.PN,
3862
+ publicKey: XUtils.encodeHex(session.publicKey),
3863
+ RK: sqlPatch.RK,
3864
+ sessionID: session.sessionID,
3865
+ SK: XUtils.encodeHex(session.SK),
3866
+ skippedKeys: sqlPatch.skippedKeys,
3867
+ userID: session.userID,
3868
+ verified: session.verified,
3869
+ };
3870
+ await this.database.saveSession(persisted);
3871
+ this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
3872
+
3773
3873
  await new Promise((res, rej) => {
3774
3874
  const callback = (packedMsg: Uint8Array) => {
3775
3875
  const [_header, receivedMsg] =
@@ -234,7 +234,12 @@ export class MemoryStorage extends EventEmitter implements Storage {
234
234
  }
235
235
 
236
236
  saveSession(session: SessionSQL): Promise<void> {
237
- if (!this.sessions.find((s) => s.SK === session.SK)) {
237
+ const idx = this.sessions.findIndex(
238
+ (s) => s.sessionID === session.sessionID,
239
+ );
240
+ if (idx >= 0) {
241
+ this.sessions[idx] = session;
242
+ } else {
238
243
  this.sessions.push(session);
239
244
  }
240
245
  return Promise.resolve();
@@ -261,14 +266,31 @@ export class MemoryStorage extends EventEmitter implements Storage {
261
266
  }
262
267
 
263
268
  private sqlToCrypto(s: SessionSQL): SessionCrypto {
269
+ let skippedKeys: Record<string, string> = {};
270
+ try {
271
+ skippedKeys = JSON.parse(s.skippedKeys) as Record<string, string>;
272
+ } catch {
273
+ skippedKeys = {};
274
+ }
264
275
  return {
276
+ CKr: s.CKr ? XUtils.decodeHex(s.CKr) : null,
277
+ CKs: s.CKs ? XUtils.decodeHex(s.CKs) : null,
278
+ DHr: s.DHr ? XUtils.decodeHex(s.DHr) : null,
279
+ DHsPrivate: XUtils.decodeHex(s.DHsPrivate),
280
+ DHsPublic: XUtils.decodeHex(s.DHsPublic),
265
281
  fingerprint: XUtils.decodeHex(s.fingerprint),
266
282
  lastUsed: s.lastUsed,
267
283
  mode: s.mode,
284
+ Nr: s.Nr,
285
+ Ns: s.Ns,
286
+ PN: s.PN,
268
287
  publicKey: XUtils.decodeHex(s.publicKey),
288
+ RK: XUtils.decodeHex(s.RK),
269
289
  sessionID: s.sessionID,
270
290
  SK: XUtils.decodeHex(s.SK),
291
+ skippedKeys,
271
292
  userID: s.userID,
293
+ verified: s.verified,
272
294
  };
273
295
  }
274
296
  }
@@ -126,6 +126,80 @@ export function platformSuite(
126
126
  }
127
127
  });
128
128
 
129
+ test("two-user DM round-trip decrypts on both clients", async () => {
130
+ const SK1 = await e2eGenerateSecretKey();
131
+ const opts1: ClientOptions = e2eClientOptionsBase();
132
+ const storage1 = await makeStorage(SK1, opts1);
133
+ const client1 = await Client.create(SK1, opts1, storage1);
134
+ const username1 = Client.randomUsername();
135
+ const password1 = "test-pw-1";
136
+
137
+ const SK2 = await e2eGenerateSecretKey();
138
+ const opts2: ClientOptions = e2eClientOptionsBase();
139
+ const storage2 = await makeStorage(SK2, opts2);
140
+ const client2 = await Client.create(SK2, opts2, storage2);
141
+ const username2 = Client.randomUsername();
142
+ const password2 = "test-pw-2";
143
+
144
+ try {
145
+ const [_user1, regErr1] = await client1.register(
146
+ username1,
147
+ password1,
148
+ );
149
+ expect(regErr1).toBeNull();
150
+
151
+ const loginErr1 = await client1.login(username1, password1);
152
+ expect(loginErr1.ok).toBe(true);
153
+ await connectAndWait(client1, "client1-roundtrip");
154
+
155
+ const [user2, regErr] = await client2.register(
156
+ username2,
157
+ password2,
158
+ );
159
+ expect(regErr).toBeNull();
160
+
161
+ const loginErr = await client2.login(username2, password2);
162
+ expect(loginErr.ok).toBe(true);
163
+ await connectAndWait(client2, "client2-roundtrip");
164
+
165
+ const outbound1 = "roundtrip u1->u2";
166
+ const receiveOnClient2 = waitForMessage(
167
+ client2,
168
+ (m) =>
169
+ m.direction === "incoming" &&
170
+ m.authorID === client1.me.user().userID &&
171
+ m.message === outbound1,
172
+ `[${platformName}] roundtrip receive on client2`,
173
+ 15_000,
174
+ );
175
+ await client1.messages.send(user2!.userID, outbound1);
176
+ const inbound2 = await receiveOnClient2;
177
+ expect(inbound2.decrypted).toBe(true);
178
+ expect(inbound2.message).toBe(outbound1);
179
+
180
+ const outbound2 = "roundtrip u2->u1";
181
+ const receiveOnClient1 = waitForMessage(
182
+ client1,
183
+ (m) =>
184
+ m.direction === "incoming" &&
185
+ m.authorID === user2!.userID &&
186
+ m.message === outbound2,
187
+ `[${platformName}] roundtrip receive on client1`,
188
+ 15_000,
189
+ );
190
+ await client2.messages.send(
191
+ client1.me.user().userID,
192
+ outbound2,
193
+ );
194
+ const inbound1 = await receiveOnClient1;
195
+ expect(inbound1.decrypted).toBe(true);
196
+ expect(inbound1.message).toBe(outbound2);
197
+ } finally {
198
+ await client1.close().catch(() => {});
199
+ await client2.close().catch(() => {});
200
+ }
201
+ }, 60_000);
202
+
129
203
  test("group messaging in channel", async () => {
130
204
  const SK2 = await e2eGenerateSecretKey();
131
205
  const opts2: ClientOptions = e2eClientOptionsBase();