@vex-chat/libvex 7.1.5 → 7.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/Client.d.ts +32 -1
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +184 -4
- package/dist/Client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +10 -6
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +270 -0
- package/src/__tests__/storage-sqlite.test.ts +122 -0
- package/src/index.ts +11 -1
- package/src/storage/sqlite.ts +21 -13
package/src/Client.ts
CHANGED
|
@@ -15,6 +15,10 @@ import type {
|
|
|
15
15
|
import type { KeyPair } from "@vex-chat/crypto";
|
|
16
16
|
import type {
|
|
17
17
|
ActionToken,
|
|
18
|
+
CallEvent,
|
|
19
|
+
CallResourceData,
|
|
20
|
+
CallSession,
|
|
21
|
+
CallSignalPayload,
|
|
18
22
|
ChallMsg,
|
|
19
23
|
Channel,
|
|
20
24
|
Device,
|
|
@@ -22,6 +26,7 @@ import type {
|
|
|
22
26
|
Emoji,
|
|
23
27
|
FileResponse,
|
|
24
28
|
FileSQL,
|
|
29
|
+
IceServerConfig,
|
|
25
30
|
Invite,
|
|
26
31
|
KeyBundle,
|
|
27
32
|
MailWS,
|
|
@@ -70,6 +75,9 @@ import {
|
|
|
70
75
|
XUtils,
|
|
71
76
|
} from "@vex-chat/crypto";
|
|
72
77
|
import {
|
|
78
|
+
CallEventSchema,
|
|
79
|
+
CallSessionSchema,
|
|
80
|
+
IceServerConfigSchema,
|
|
73
81
|
MailType,
|
|
74
82
|
MailWSSchema,
|
|
75
83
|
PermissionSchema,
|
|
@@ -190,6 +198,16 @@ function debugLibvexDm(
|
|
|
190
198
|
console.error(`[libvex:debug-dm] ${payload}`);
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
function errorFromUnknown(err: unknown): Error {
|
|
202
|
+
if (err instanceof Error) {
|
|
203
|
+
return err;
|
|
204
|
+
}
|
|
205
|
+
if (typeof err === "string") {
|
|
206
|
+
return new Error(err);
|
|
207
|
+
}
|
|
208
|
+
return new Error(JSON.stringify(err));
|
|
209
|
+
}
|
|
210
|
+
|
|
193
211
|
function ignoreSocketTeardown(err: unknown): void {
|
|
194
212
|
if (err instanceof WebSocketNotOpenError) return;
|
|
195
213
|
// Re-throw anything else as a real unhandled rejection so it
|
|
@@ -356,6 +374,28 @@ import { uuidToUint8 } from "./utils/uint8uuid.js";
|
|
|
356
374
|
|
|
357
375
|
const _protocolMsgRegex = /��\w+:\w+��/g;
|
|
358
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Voice-call signaling operations.
|
|
379
|
+
*
|
|
380
|
+
* `libvex` moves authenticated call control over Spire. Platform apps own
|
|
381
|
+
* WebRTC/media capture and pass offers, answers, and ICE candidates through
|
|
382
|
+
* these methods.
|
|
383
|
+
*/
|
|
384
|
+
export interface Calls {
|
|
385
|
+
accept: (callID: string, signal?: CallSignalPayload) => Promise<CallEvent>;
|
|
386
|
+
active: () => Promise<CallSession[]>;
|
|
387
|
+
cancel: (callID: string) => Promise<CallEvent>;
|
|
388
|
+
hangup: (callID: string) => Promise<CallEvent>;
|
|
389
|
+
ice: (callID: string, signal: CallSignalPayload) => Promise<CallEvent>;
|
|
390
|
+
iceServers: () => Promise<IceServerConfig[]>;
|
|
391
|
+
reject: (callID: string) => Promise<CallEvent>;
|
|
392
|
+
signal: (callID: string, signal: CallSignalPayload) => Promise<CallEvent>;
|
|
393
|
+
startDM: (
|
|
394
|
+
recipientUserID: string,
|
|
395
|
+
signal?: CallSignalPayload,
|
|
396
|
+
) => Promise<CallEvent>;
|
|
397
|
+
}
|
|
398
|
+
|
|
359
399
|
/**
|
|
360
400
|
* @ignore
|
|
361
401
|
*/
|
|
@@ -1034,6 +1074,8 @@ const retryRequestNotifyData = z.union([
|
|
|
1034
1074
|
* and {@link Client.once}.
|
|
1035
1075
|
*/
|
|
1036
1076
|
export interface ClientEvents {
|
|
1077
|
+
/** Voice-call signaling changed or an incoming call was received. */
|
|
1078
|
+
call: (event: CallEvent) => void;
|
|
1037
1079
|
/** The client has been shut down (via {@link Client.close}). */
|
|
1038
1080
|
closed: () => void;
|
|
1039
1081
|
/** WebSocket authorized by the server; pre-auth setup begins. */
|
|
@@ -1280,6 +1322,35 @@ export class Client {
|
|
|
1280
1322
|
|
|
1281
1323
|
private static readonly NOT_FOUND_TTL = 30 * 60 * 1000;
|
|
1282
1324
|
|
|
1325
|
+
/**
|
|
1326
|
+
* Voice-call signaling operations.
|
|
1327
|
+
*
|
|
1328
|
+
* Platform apps own native media capture/WebRTC. These methods only move
|
|
1329
|
+
* authenticated signaling and call state over Spire.
|
|
1330
|
+
*/
|
|
1331
|
+
public calls: Calls = {
|
|
1332
|
+
accept: (callID: string, signal?: CallSignalPayload) =>
|
|
1333
|
+
this.sendCallResource("ACCEPT", {
|
|
1334
|
+
callID,
|
|
1335
|
+
...(signal ? { signal } : {}),
|
|
1336
|
+
}),
|
|
1337
|
+
active: this.fetchActiveCalls.bind(this),
|
|
1338
|
+
cancel: (callID: string) => this.sendCallResource("CANCEL", { callID }),
|
|
1339
|
+
hangup: (callID: string) => this.sendCallResource("HANGUP", { callID }),
|
|
1340
|
+
ice: (callID: string, signal: CallSignalPayload) =>
|
|
1341
|
+
this.sendCallResource("ICE", { callID, signal }),
|
|
1342
|
+
iceServers: this.fetchIceServers.bind(this),
|
|
1343
|
+
reject: (callID: string) => this.sendCallResource("REJECT", { callID }),
|
|
1344
|
+
signal: (callID: string, signal: CallSignalPayload) =>
|
|
1345
|
+
this.sendCallResource("SIGNAL", { callID, signal }),
|
|
1346
|
+
startDM: (recipientUserID: string, signal?: CallSignalPayload) =>
|
|
1347
|
+
this.sendCallResource("INVITE", {
|
|
1348
|
+
conversationType: "dm",
|
|
1349
|
+
recipientUserID,
|
|
1350
|
+
...(signal ? { signal } : {}),
|
|
1351
|
+
}),
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1283
1354
|
/**
|
|
1284
1355
|
* Browser-safe NODE_ENV accessor.
|
|
1285
1356
|
* Uses indirect lookup so the bare `process` global never appears in
|
|
@@ -2488,6 +2559,7 @@ export class Client {
|
|
|
2488
2559
|
private acknowledgeRepeatedDecryptFailure(
|
|
2489
2560
|
mail: MailWS,
|
|
2490
2561
|
count: number,
|
|
2562
|
+
timestamp: string,
|
|
2491
2563
|
): void {
|
|
2492
2564
|
if (count < 2) return;
|
|
2493
2565
|
if (libvexDebugDmEnabled()) {
|
|
@@ -2498,6 +2570,7 @@ export class Client {
|
|
|
2498
2570
|
thisDevice: this.getDevice().deviceID,
|
|
2499
2571
|
});
|
|
2500
2572
|
}
|
|
2573
|
+
this.emitUndecryptedMessage(mail, timestamp);
|
|
2501
2574
|
this.acknowledgeInboundMail(mail);
|
|
2502
2575
|
}
|
|
2503
2576
|
|
|
@@ -2978,6 +3051,7 @@ export class Client {
|
|
|
2978
3051
|
}
|
|
2979
3052
|
});
|
|
2980
3053
|
}
|
|
3054
|
+
|
|
2981
3055
|
private async deliverMailResourceOverSocket(
|
|
2982
3056
|
msg: ResourceMsg,
|
|
2983
3057
|
header: Uint8Array,
|
|
@@ -3007,6 +3081,7 @@ export class Client {
|
|
|
3007
3081
|
});
|
|
3008
3082
|
});
|
|
3009
3083
|
}
|
|
3084
|
+
|
|
3010
3085
|
private deviceListFailureDetail(err: unknown): string {
|
|
3011
3086
|
if (!isHttpError(err)) {
|
|
3012
3087
|
return "";
|
|
@@ -3021,6 +3096,39 @@ export class Client {
|
|
|
3021
3096
|
return "";
|
|
3022
3097
|
}
|
|
3023
3098
|
|
|
3099
|
+
private emitUndecryptedMessage(mail: MailWS, timestamp: string): void {
|
|
3100
|
+
this.emitter.emit("message", {
|
|
3101
|
+
authorID: mail.authorID,
|
|
3102
|
+
decrypted: false,
|
|
3103
|
+
direction: "incoming",
|
|
3104
|
+
forward: mail.forward,
|
|
3105
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
3106
|
+
mailID: mail.mailID,
|
|
3107
|
+
message: "",
|
|
3108
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
3109
|
+
readerID: mail.readerID,
|
|
3110
|
+
recipient: mail.recipient,
|
|
3111
|
+
sender: mail.sender,
|
|
3112
|
+
timestamp,
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
private async fetchActiveCalls(): Promise<CallSession[]> {
|
|
3117
|
+
const res = await this.http.get(this.getHost() + "/calls/active", {
|
|
3118
|
+
responseType: "json",
|
|
3119
|
+
});
|
|
3120
|
+
return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
|
|
3121
|
+
.calls;
|
|
3122
|
+
}
|
|
3123
|
+
private async fetchIceServers(): Promise<IceServerConfig[]> {
|
|
3124
|
+
const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
|
|
3125
|
+
responseType: "json",
|
|
3126
|
+
});
|
|
3127
|
+
return z
|
|
3128
|
+
.object({ iceServers: z.array(IceServerConfigSchema) })
|
|
3129
|
+
.parse(res.data).iceServers;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3024
3132
|
/**
|
|
3025
3133
|
* Gets a list of permissions for a server.
|
|
3026
3134
|
*
|
|
@@ -3661,6 +3769,14 @@ export class Client {
|
|
|
3661
3769
|
|
|
3662
3770
|
private async handleNotify(msg: NotifyMsg) {
|
|
3663
3771
|
switch (msg.event) {
|
|
3772
|
+
case "call":
|
|
3773
|
+
case "callInvite": {
|
|
3774
|
+
const parsed = CallEventSchema.safeParse(msg.data);
|
|
3775
|
+
if (parsed.success) {
|
|
3776
|
+
this.emitter.emit("call", parsed.data);
|
|
3777
|
+
}
|
|
3778
|
+
break;
|
|
3779
|
+
}
|
|
3664
3780
|
case "deviceRequest": {
|
|
3665
3781
|
const parsed = deviceRequestNotifyData.safeParse(msg.data);
|
|
3666
3782
|
if (parsed.success) {
|
|
@@ -4322,11 +4438,14 @@ export class Client {
|
|
|
4322
4438
|
);
|
|
4323
4439
|
|
|
4324
4440
|
if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
|
|
4441
|
+
const failureCount =
|
|
4442
|
+
this.registerDecryptFailure(mail);
|
|
4325
4443
|
if (libvexDebugDmEnabled()) {
|
|
4326
4444
|
try {
|
|
4327
4445
|
debugLibvexDm(
|
|
4328
4446
|
"readMail initial: abort (otk index mismatch)",
|
|
4329
4447
|
{
|
|
4448
|
+
attempts: failureCount,
|
|
4330
4449
|
mailID: mail.mailID,
|
|
4331
4450
|
otkIndex: String(
|
|
4332
4451
|
otk?.index ?? "null",
|
|
@@ -4345,6 +4464,17 @@ export class Client {
|
|
|
4345
4464
|
);
|
|
4346
4465
|
}
|
|
4347
4466
|
}
|
|
4467
|
+
if (failureCount === 1) {
|
|
4468
|
+
this.emitter.emit("retryRequest", {
|
|
4469
|
+
mailID: mail.mailID,
|
|
4470
|
+
source: "decrypt_failure",
|
|
4471
|
+
});
|
|
4472
|
+
}
|
|
4473
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4474
|
+
mail,
|
|
4475
|
+
failureCount,
|
|
4476
|
+
timestamp,
|
|
4477
|
+
);
|
|
4348
4478
|
return;
|
|
4349
4479
|
}
|
|
4350
4480
|
|
|
@@ -4363,11 +4493,14 @@ export class Client {
|
|
|
4363
4493
|
return c;
|
|
4364
4494
|
})();
|
|
4365
4495
|
if (!IK_A) {
|
|
4496
|
+
const failureCount =
|
|
4497
|
+
this.registerDecryptFailure(mail);
|
|
4366
4498
|
if (libvexDebugDmEnabled()) {
|
|
4367
4499
|
try {
|
|
4368
4500
|
debugLibvexDm(
|
|
4369
4501
|
"readMail initial: abort (IK_A null, Ed→X25519?)",
|
|
4370
4502
|
{
|
|
4503
|
+
attempts: failureCount,
|
|
4371
4504
|
fips: String(fipsRead),
|
|
4372
4505
|
mailID: mail.mailID,
|
|
4373
4506
|
thisDevice:
|
|
@@ -4383,6 +4516,17 @@ export class Client {
|
|
|
4383
4516
|
);
|
|
4384
4517
|
}
|
|
4385
4518
|
}
|
|
4519
|
+
if (failureCount === 1) {
|
|
4520
|
+
this.emitter.emit("retryRequest", {
|
|
4521
|
+
mailID: mail.mailID,
|
|
4522
|
+
source: "decrypt_failure",
|
|
4523
|
+
});
|
|
4524
|
+
}
|
|
4525
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4526
|
+
mail,
|
|
4527
|
+
failureCount,
|
|
4528
|
+
timestamp,
|
|
4529
|
+
);
|
|
4386
4530
|
return;
|
|
4387
4531
|
}
|
|
4388
4532
|
const EK_A = ephKey;
|
|
@@ -4431,11 +4575,14 @@ export class Client {
|
|
|
4431
4575
|
);
|
|
4432
4576
|
|
|
4433
4577
|
if (!XUtils.bytesEqual(hmac, header)) {
|
|
4578
|
+
const failureCount =
|
|
4579
|
+
this.registerDecryptFailure(mail);
|
|
4434
4580
|
if (libvexDebugDmEnabled()) {
|
|
4435
4581
|
try {
|
|
4436
4582
|
debugLibvexDm(
|
|
4437
4583
|
"readMail initial: abort (HMAC mismatch)",
|
|
4438
4584
|
{
|
|
4585
|
+
attempts: failureCount,
|
|
4439
4586
|
mailID: mail.mailID,
|
|
4440
4587
|
preKeyIndex: String(preKeyIndex),
|
|
4441
4588
|
thisDevice:
|
|
@@ -4451,6 +4598,17 @@ export class Client {
|
|
|
4451
4598
|
);
|
|
4452
4599
|
}
|
|
4453
4600
|
}
|
|
4601
|
+
if (failureCount === 1) {
|
|
4602
|
+
this.emitter.emit("retryRequest", {
|
|
4603
|
+
mailID: mail.mailID,
|
|
4604
|
+
source: "decrypt_failure",
|
|
4605
|
+
});
|
|
4606
|
+
}
|
|
4607
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4608
|
+
mail,
|
|
4609
|
+
failureCount,
|
|
4610
|
+
timestamp,
|
|
4611
|
+
);
|
|
4454
4612
|
return;
|
|
4455
4613
|
}
|
|
4456
4614
|
const unsealed = await xSecretboxOpenAsync(
|
|
@@ -4582,15 +4740,29 @@ export class Client {
|
|
|
4582
4740
|
}
|
|
4583
4741
|
this.acknowledgeInboundMail(mail);
|
|
4584
4742
|
} else {
|
|
4743
|
+
const failureCount =
|
|
4744
|
+
this.registerDecryptFailure(mail);
|
|
4585
4745
|
if (libvexDebugDmEnabled()) {
|
|
4586
4746
|
debugLibvexDm(
|
|
4587
4747
|
"readMail initial: abort (xSecretboxOpen null)",
|
|
4588
4748
|
{
|
|
4749
|
+
attempts: failureCount,
|
|
4589
4750
|
mailID: mail.mailID,
|
|
4590
4751
|
preKeyIndex: String(preKeyIndex),
|
|
4591
4752
|
},
|
|
4592
4753
|
);
|
|
4593
4754
|
}
|
|
4755
|
+
if (failureCount === 1) {
|
|
4756
|
+
this.emitter.emit("retryRequest", {
|
|
4757
|
+
mailID: mail.mailID,
|
|
4758
|
+
source: "decrypt_failure",
|
|
4759
|
+
});
|
|
4760
|
+
}
|
|
4761
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4762
|
+
mail,
|
|
4763
|
+
failureCount,
|
|
4764
|
+
timestamp,
|
|
4765
|
+
);
|
|
4594
4766
|
}
|
|
4595
4767
|
break;
|
|
4596
4768
|
case MailType.subsequent: {
|
|
@@ -4613,7 +4785,20 @@ export class Client {
|
|
|
4613
4785
|
}
|
|
4614
4786
|
|
|
4615
4787
|
if (!session) {
|
|
4788
|
+
const failureCount =
|
|
4789
|
+
this.registerDecryptFailure(mail);
|
|
4616
4790
|
healSession();
|
|
4791
|
+
if (failureCount === 1) {
|
|
4792
|
+
this.emitter.emit("retryRequest", {
|
|
4793
|
+
mailID: mail.mailID,
|
|
4794
|
+
source: "decrypt_failure",
|
|
4795
|
+
});
|
|
4796
|
+
}
|
|
4797
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4798
|
+
mail,
|
|
4799
|
+
failureCount,
|
|
4800
|
+
timestamp,
|
|
4801
|
+
);
|
|
4617
4802
|
return;
|
|
4618
4803
|
}
|
|
4619
4804
|
|
|
@@ -4717,6 +4902,7 @@ export class Client {
|
|
|
4717
4902
|
this.acknowledgeRepeatedDecryptFailure(
|
|
4718
4903
|
mail,
|
|
4719
4904
|
failureCount,
|
|
4905
|
+
timestamp,
|
|
4720
4906
|
);
|
|
4721
4907
|
return;
|
|
4722
4908
|
}
|
|
@@ -4808,6 +4994,7 @@ export class Client {
|
|
|
4808
4994
|
this.acknowledgeRepeatedDecryptFailure(
|
|
4809
4995
|
mail,
|
|
4810
4996
|
failureCount,
|
|
4997
|
+
timestamp,
|
|
4811
4998
|
);
|
|
4812
4999
|
}
|
|
4813
5000
|
break;
|
|
@@ -5179,6 +5366,89 @@ export class Client {
|
|
|
5179
5366
|
}
|
|
5180
5367
|
}
|
|
5181
5368
|
|
|
5369
|
+
private async sendCallResource(
|
|
5370
|
+
action: string,
|
|
5371
|
+
data: CallResourceData,
|
|
5372
|
+
): Promise<CallEvent> {
|
|
5373
|
+
const msg: ResourceMsg = {
|
|
5374
|
+
action,
|
|
5375
|
+
data,
|
|
5376
|
+
resourceType: "call",
|
|
5377
|
+
transmissionID: uuid.v4(),
|
|
5378
|
+
type: "resource",
|
|
5379
|
+
};
|
|
5380
|
+
|
|
5381
|
+
return await new Promise<CallEvent>((resolve, reject) => {
|
|
5382
|
+
const settle = (err: null | unknown, event?: CallEvent) => {
|
|
5383
|
+
this.socket.off("message", callback);
|
|
5384
|
+
if (err !== null) {
|
|
5385
|
+
reject(errorFromUnknown(err));
|
|
5386
|
+
return;
|
|
5387
|
+
}
|
|
5388
|
+
if (!event) {
|
|
5389
|
+
reject(new Error("Call signaling response was empty."));
|
|
5390
|
+
return;
|
|
5391
|
+
}
|
|
5392
|
+
resolve(event);
|
|
5393
|
+
};
|
|
5394
|
+
|
|
5395
|
+
const callback = (packedMsg: Uint8Array) => {
|
|
5396
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
5397
|
+
if (receivedMsg.transmissionID !== msg.transmissionID) {
|
|
5398
|
+
return;
|
|
5399
|
+
}
|
|
5400
|
+
|
|
5401
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
5402
|
+
if (!parsed.success) {
|
|
5403
|
+
settle(
|
|
5404
|
+
"Call signaling failed: " + JSON.stringify(receivedMsg),
|
|
5405
|
+
);
|
|
5406
|
+
return;
|
|
5407
|
+
}
|
|
5408
|
+
|
|
5409
|
+
if (parsed.data.type === "success") {
|
|
5410
|
+
const event = CallEventSchema.safeParse(parsed.data.data);
|
|
5411
|
+
if (!event.success) {
|
|
5412
|
+
settle(
|
|
5413
|
+
"Invalid call signaling response: " +
|
|
5414
|
+
JSON.stringify(event.error.issues),
|
|
5415
|
+
);
|
|
5416
|
+
return;
|
|
5417
|
+
}
|
|
5418
|
+
settle(null, event.data);
|
|
5419
|
+
return;
|
|
5420
|
+
}
|
|
5421
|
+
|
|
5422
|
+
if (parsed.data.type === "error") {
|
|
5423
|
+
settle(new Error(parsed.data.error));
|
|
5424
|
+
return;
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5427
|
+
if (
|
|
5428
|
+
parsed.data.type === "notify" &&
|
|
5429
|
+
(parsed.data.event === "call" ||
|
|
5430
|
+
parsed.data.event === "callInvite")
|
|
5431
|
+
) {
|
|
5432
|
+
const event = CallEventSchema.safeParse(parsed.data.data);
|
|
5433
|
+
if (event.success) {
|
|
5434
|
+
settle(null, event.data);
|
|
5435
|
+
}
|
|
5436
|
+
return;
|
|
5437
|
+
}
|
|
5438
|
+
|
|
5439
|
+
settle(
|
|
5440
|
+
"Unexpected call signaling response: " +
|
|
5441
|
+
JSON.stringify(parsed.data),
|
|
5442
|
+
);
|
|
5443
|
+
};
|
|
5444
|
+
|
|
5445
|
+
this.socket.on("message", callback);
|
|
5446
|
+
this.send(msg).catch((err: unknown) => {
|
|
5447
|
+
settle(err);
|
|
5448
|
+
});
|
|
5449
|
+
});
|
|
5450
|
+
}
|
|
5451
|
+
|
|
5182
5452
|
private async sendGroupMessage(
|
|
5183
5453
|
channelID: string,
|
|
5184
5454
|
message: string,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Message } from "../index.js";
|
|
2
|
+
import type { ClientDatabase } from "../storage/schema.js";
|
|
3
|
+
|
|
4
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
5
|
+
import { Kysely, SqliteDialect } from "kysely";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { SqliteStorage } from "../storage/sqlite.js";
|
|
9
|
+
|
|
10
|
+
describe("SqliteStorage message at-rest encryption", () => {
|
|
11
|
+
it("stores generated decrypt-failure placeholders as the only plaintext exception", async () => {
|
|
12
|
+
const { db, storage } = makeStorage();
|
|
13
|
+
try {
|
|
14
|
+
await storage.init();
|
|
15
|
+
|
|
16
|
+
const placeholder = makeMessage({
|
|
17
|
+
decrypted: false,
|
|
18
|
+
mailID: "placeholder-mail",
|
|
19
|
+
message: "",
|
|
20
|
+
nonce: nonceHex(1),
|
|
21
|
+
});
|
|
22
|
+
await storage.saveMessage(placeholder);
|
|
23
|
+
|
|
24
|
+
const row = await messageRow(db, placeholder.mailID);
|
|
25
|
+
expect(row?.decrypted).toBe(0);
|
|
26
|
+
expect(row?.extra).toBeNull();
|
|
27
|
+
expect(row?.message).toBe("");
|
|
28
|
+
|
|
29
|
+
const history = await storage.getMessageHistory(
|
|
30
|
+
placeholder.authorID,
|
|
31
|
+
);
|
|
32
|
+
expect(history).toMatchObject([
|
|
33
|
+
{
|
|
34
|
+
decrypted: false,
|
|
35
|
+
mailID: placeholder.mailID,
|
|
36
|
+
message: "",
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
} finally {
|
|
40
|
+
await storage.close();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("seals non-placeholder undecrypted messages and round-trips their content", async () => {
|
|
45
|
+
const { db, storage } = makeStorage();
|
|
46
|
+
try {
|
|
47
|
+
await storage.init();
|
|
48
|
+
|
|
49
|
+
const imported = makeMessage({
|
|
50
|
+
decrypted: false,
|
|
51
|
+
extra: JSON.stringify({ imported: true }),
|
|
52
|
+
mailID: "imported-mail",
|
|
53
|
+
message: "cleartext should not sit in sqlite",
|
|
54
|
+
nonce: nonceHex(2),
|
|
55
|
+
});
|
|
56
|
+
await storage.saveMessage(imported);
|
|
57
|
+
|
|
58
|
+
const row = await messageRow(db, imported.mailID);
|
|
59
|
+
expect(row?.decrypted).toBe(0);
|
|
60
|
+
expect(row?.extra).toBeNull();
|
|
61
|
+
expect(row?.message).not.toContain(imported.message);
|
|
62
|
+
expect(row?.message).not.toContain(imported.extra ?? "");
|
|
63
|
+
|
|
64
|
+
const history = await storage.getMessageHistory(imported.authorID);
|
|
65
|
+
expect(history).toMatchObject([
|
|
66
|
+
{
|
|
67
|
+
decrypted: false,
|
|
68
|
+
extra: imported.extra,
|
|
69
|
+
mailID: imported.mailID,
|
|
70
|
+
message: imported.message,
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
} finally {
|
|
74
|
+
await storage.close();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function makeMessage(overrides: Partial<Message>): Message {
|
|
80
|
+
return {
|
|
81
|
+
authorID: "peer-user",
|
|
82
|
+
decrypted: true,
|
|
83
|
+
direction: "incoming",
|
|
84
|
+
forward: false,
|
|
85
|
+
group: null,
|
|
86
|
+
mailID: "mail",
|
|
87
|
+
message: "hello",
|
|
88
|
+
nonce: nonceHex(0),
|
|
89
|
+
readerID: "local-user",
|
|
90
|
+
recipient: "local-device",
|
|
91
|
+
sender: "peer-device",
|
|
92
|
+
timestamp: "2026-06-01T00:00:00.000Z",
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function makeStorage(): {
|
|
98
|
+
db: Kysely<ClientDatabase>;
|
|
99
|
+
storage: SqliteStorage;
|
|
100
|
+
} {
|
|
101
|
+
const db = new Kysely<ClientDatabase>({
|
|
102
|
+
dialect: new SqliteDialect({
|
|
103
|
+
database: new BetterSqlite3(":memory:"),
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
db,
|
|
108
|
+
storage: new SqliteStorage(db, new Uint8Array(32).fill(7)),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function messageRow(db: Kysely<ClientDatabase>, mailID: string) {
|
|
113
|
+
return await db
|
|
114
|
+
.selectFrom("messages")
|
|
115
|
+
.select(["decrypted", "extra", "message"])
|
|
116
|
+
.where("mailID", "=", mailID)
|
|
117
|
+
.executeTakeFirst();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function nonceHex(value: number): string {
|
|
121
|
+
return value.toString(16).padStart(48, "0");
|
|
122
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export { Client, DeviceApprovalRequiredError } from "./Client.js";
|
|
8
8
|
export type {
|
|
9
|
+
Calls,
|
|
9
10
|
Channel,
|
|
10
11
|
Channels,
|
|
11
12
|
ClientEvents,
|
|
@@ -96,4 +97,13 @@ export type {
|
|
|
96
97
|
UnsavedPreKey,
|
|
97
98
|
} from "./types/index.js";
|
|
98
99
|
// Re-export app-facing types
|
|
99
|
-
export type {
|
|
100
|
+
export type {
|
|
101
|
+
CallAction,
|
|
102
|
+
CallEvent,
|
|
103
|
+
CallParticipant,
|
|
104
|
+
CallSession,
|
|
105
|
+
CallSignalPayload,
|
|
106
|
+
IceServerConfig,
|
|
107
|
+
Invite,
|
|
108
|
+
Passkey,
|
|
109
|
+
} from "@vex-chat/types";
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -538,24 +538,30 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
538
538
|
return;
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
-
// Encrypt plaintext with at-rest key before saving to disk.
|
|
542
541
|
const storedPlaintext = encodeStoredMessagePlaintext(message);
|
|
542
|
+
const isPlaintextFailurePlaceholder =
|
|
543
|
+
!message.decrypted &&
|
|
544
|
+
message.message === "" &&
|
|
545
|
+
message.extra === undefined;
|
|
543
546
|
const fips = getCryptoProfile() === "fips";
|
|
544
|
-
const
|
|
545
|
-
?
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
547
|
+
const encryptedMessage = isPlaintextFailurePlaceholder
|
|
548
|
+
? storedPlaintext
|
|
549
|
+
: XUtils.encodeHex(
|
|
550
|
+
fips
|
|
551
|
+
? await xSecretboxAsync(
|
|
552
|
+
XUtils.decodeUTF8(storedPlaintext),
|
|
553
|
+
XUtils.decodeHex(message.nonce),
|
|
554
|
+
this.atRestAesKey,
|
|
555
|
+
)
|
|
556
|
+
: xSecretbox(
|
|
557
|
+
XUtils.decodeUTF8(storedPlaintext),
|
|
558
|
+
XUtils.decodeHex(message.nonce),
|
|
559
|
+
this.atRestAesKey,
|
|
560
|
+
),
|
|
554
561
|
);
|
|
555
562
|
if (this.isClosingNow()) {
|
|
556
563
|
return;
|
|
557
564
|
}
|
|
558
|
-
const encryptedMessage = XUtils.encodeHex(ct);
|
|
559
565
|
|
|
560
566
|
try {
|
|
561
567
|
await this.db
|
|
@@ -787,7 +793,9 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
787
793
|
for (const msg of messages) {
|
|
788
794
|
const decryptedFlag = msg.decrypted !== 0;
|
|
789
795
|
let plaintext = msg.message;
|
|
790
|
-
|
|
796
|
+
const isPlaintextFailurePlaceholder =
|
|
797
|
+
!decryptedFlag && msg.message === "" && msg.extra === null;
|
|
798
|
+
if (!isPlaintextFailurePlaceholder) {
|
|
791
799
|
const cipher = XUtils.decodeHex(msg.message);
|
|
792
800
|
const nonce = XUtils.decodeHex(msg.nonce);
|
|
793
801
|
const decrypted = fips
|