@vex-chat/libvex 6.5.2 → 6.5.4
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/README.md +2 -1
- package/dist/Client.d.ts +52 -51
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +74 -5
- package/dist/Client.js.map +1 -1
- package/dist/utils/verifyKeyBundle.d.ts +9 -0
- package/dist/utils/verifyKeyBundle.d.ts.map +1 -0
- package/dist/utils/verifyKeyBundle.js +33 -0
- package/dist/utils/verifyKeyBundle.js.map +1 -0
- package/package.json +1 -1
- package/src/Client.ts +147 -61
- package/src/__tests__/verifyKeyBundle.test.ts +161 -0
- package/src/utils/verifyKeyBundle.ts +74 -0
package/src/Client.ts
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
takeReceiveMessageKey,
|
|
110
110
|
takeSendMessageKey,
|
|
111
111
|
} from "./utils/ratchet.js";
|
|
112
|
+
import { verifyKeyBundleSignatures } from "./utils/verifyKeyBundle.js";
|
|
112
113
|
|
|
113
114
|
/**
|
|
114
115
|
* Thrown by {@link Client.register} when the server determined the supplied
|
|
@@ -350,18 +351,6 @@ import { uuidToUint8 } from "./utils/uint8uuid.js";
|
|
|
350
351
|
|
|
351
352
|
const _protocolMsgRegex = /��\w+:\w+��/g;
|
|
352
353
|
|
|
353
|
-
/**
|
|
354
|
-
* Permission is a permission to a resource.
|
|
355
|
-
*
|
|
356
|
-
* Common fields:
|
|
357
|
-
* - `permissionID`: unique permission row ID
|
|
358
|
-
* - `userID`: user receiving this grant
|
|
359
|
-
* - `resourceID`: target server/channel/etc.
|
|
360
|
-
* - `resourceType`: type string for the resource
|
|
361
|
-
* - `powerLevel`: authorization level
|
|
362
|
-
*/
|
|
363
|
-
export type { Permission } from "@vex-chat/types";
|
|
364
|
-
|
|
365
354
|
/**
|
|
366
355
|
* @ignore
|
|
367
356
|
*/
|
|
@@ -378,27 +367,6 @@ export interface Channels {
|
|
|
378
367
|
userList: (channelID: string) => Promise<User[]>;
|
|
379
368
|
}
|
|
380
369
|
|
|
381
|
-
/**
|
|
382
|
-
* Device record associated with a user account.
|
|
383
|
-
*
|
|
384
|
-
* Common fields:
|
|
385
|
-
* - `deviceID`: unique device identifier
|
|
386
|
-
* - `owner`: owning user ID
|
|
387
|
-
* - `signKey`: signing public key
|
|
388
|
-
* - `name`: user-facing device name
|
|
389
|
-
* - `lastLogin`: last login timestamp string
|
|
390
|
-
* - `deleted`: soft-delete flag
|
|
391
|
-
*/
|
|
392
|
-
export type { Device } from "@vex-chat/types";
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Public passkey record returned by `client.passkeys.list()` and
|
|
396
|
-
* `client.passkeys.finishRegistration()`. Server-private fields
|
|
397
|
-
* (credential ID, public key, COSE algorithm, signature counter) are
|
|
398
|
-
* never exposed.
|
|
399
|
-
*/
|
|
400
|
-
export type { Passkey } from "@vex-chat/types";
|
|
401
|
-
|
|
402
370
|
/**
|
|
403
371
|
* ClientOptions are the options you can pass into the client.
|
|
404
372
|
*/
|
|
@@ -436,8 +404,41 @@ export interface ClientOptions {
|
|
|
436
404
|
unsafeHttp?: boolean;
|
|
437
405
|
}
|
|
438
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Permission is a permission to a resource.
|
|
409
|
+
*
|
|
410
|
+
* Common fields:
|
|
411
|
+
* - `permissionID`: unique permission row ID
|
|
412
|
+
* - `userID`: user receiving this grant
|
|
413
|
+
* - `resourceID`: target server/channel/etc.
|
|
414
|
+
* - `resourceType`: type string for the resource
|
|
415
|
+
* - `powerLevel`: authorization level
|
|
416
|
+
*/
|
|
417
|
+
export type { Permission } from "@vex-chat/types";
|
|
418
|
+
|
|
439
419
|
export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
|
|
440
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Device record associated with a user account.
|
|
423
|
+
*
|
|
424
|
+
* Common fields:
|
|
425
|
+
* - `deviceID`: unique device identifier
|
|
426
|
+
* - `owner`: owning user ID
|
|
427
|
+
* - `signKey`: signing public key
|
|
428
|
+
* - `name`: user-facing device name
|
|
429
|
+
* - `lastLogin`: last login timestamp string
|
|
430
|
+
* - `deleted`: soft-delete flag
|
|
431
|
+
*/
|
|
432
|
+
export type { Device } from "@vex-chat/types";
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Public passkey record returned by `client.passkeys.list()` and
|
|
436
|
+
* `client.passkeys.finishRegistration()`. Server-private fields
|
|
437
|
+
* (credential ID, public key, COSE algorithm, signature counter) are
|
|
438
|
+
* never exposed.
|
|
439
|
+
*/
|
|
440
|
+
export type { Passkey } from "@vex-chat/types";
|
|
441
|
+
|
|
441
442
|
/**
|
|
442
443
|
* @ignore
|
|
443
444
|
*/
|
|
@@ -556,6 +557,29 @@ export interface Files {
|
|
|
556
557
|
retrieve: (fileID: string, key: string) => Promise<FileResponse | null>;
|
|
557
558
|
}
|
|
558
559
|
|
|
560
|
+
/**
|
|
561
|
+
* @ignore
|
|
562
|
+
*/
|
|
563
|
+
export interface Invites {
|
|
564
|
+
/** Creates an invite for a server and duration. */
|
|
565
|
+
create: (serverID: string, duration: string) => Promise<Invite>;
|
|
566
|
+
/** Redeems an invite and returns the created permission grant. */
|
|
567
|
+
redeem: (inviteID: string) => Promise<Permission>;
|
|
568
|
+
/** Lists active invites for a server. */
|
|
569
|
+
retrieve: (serverID: string) => Promise<Invite[]>;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Keys are a pair of ed25519 public and private keys,
|
|
574
|
+
* encoded as hex strings.
|
|
575
|
+
*/
|
|
576
|
+
export interface Keys {
|
|
577
|
+
/** Secret Ed25519 key as hex. Store securely. */
|
|
578
|
+
private: string;
|
|
579
|
+
/** Public Ed25519 key as hex. */
|
|
580
|
+
public: string;
|
|
581
|
+
}
|
|
582
|
+
|
|
559
583
|
/**
|
|
560
584
|
* Channel is a chat channel on a server.
|
|
561
585
|
*
|
|
@@ -581,29 +605,6 @@ export type { Server } from "@vex-chat/types";
|
|
|
581
605
|
*/
|
|
582
606
|
export type { ServerChannelBootstrap } from "@vex-chat/types";
|
|
583
607
|
|
|
584
|
-
/**
|
|
585
|
-
* @ignore
|
|
586
|
-
*/
|
|
587
|
-
export interface Invites {
|
|
588
|
-
/** Creates an invite for a server and duration. */
|
|
589
|
-
create: (serverID: string, duration: string) => Promise<Invite>;
|
|
590
|
-
/** Redeems an invite and returns the created permission grant. */
|
|
591
|
-
redeem: (inviteID: string) => Promise<Permission>;
|
|
592
|
-
/** Lists active invites for a server. */
|
|
593
|
-
retrieve: (serverID: string) => Promise<Invite[]>;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* Keys are a pair of ed25519 public and private keys,
|
|
598
|
-
* encoded as hex strings.
|
|
599
|
-
*/
|
|
600
|
-
export interface Keys {
|
|
601
|
-
/** Secret Ed25519 key as hex. Store securely. */
|
|
602
|
-
private: string;
|
|
603
|
-
/** Public Ed25519 key as hex. */
|
|
604
|
-
public: string;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
608
|
/**
|
|
608
609
|
* @ignore
|
|
609
610
|
*/
|
|
@@ -753,6 +754,59 @@ export interface RetryRequest {
|
|
|
753
754
|
source: "decrypt_failure" | "server_notify";
|
|
754
755
|
}
|
|
755
756
|
|
|
757
|
+
function compareInboxEntries(
|
|
758
|
+
a: [Uint8Array, MailWS, string],
|
|
759
|
+
b: [Uint8Array, MailWS, string],
|
|
760
|
+
): number {
|
|
761
|
+
const timeCmp = a[2].localeCompare(b[2]);
|
|
762
|
+
if (timeCmp !== 0) {
|
|
763
|
+
return timeCmp;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const aMail = a[1];
|
|
767
|
+
const bMail = b[1];
|
|
768
|
+
if (aMail.sender !== bMail.sender) {
|
|
769
|
+
return aMail.sender.localeCompare(bMail.sender, "en");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const typeCmp = aMail.mailType - bMail.mailType;
|
|
773
|
+
if (typeCmp !== 0) {
|
|
774
|
+
return typeCmp;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (
|
|
778
|
+
aMail.mailType === MailType.subsequent &&
|
|
779
|
+
bMail.mailType === MailType.subsequent
|
|
780
|
+
) {
|
|
781
|
+
const aHeader = tryDecodeRatchetHeader(aMail.extra);
|
|
782
|
+
const bHeader = tryDecodeRatchetHeader(bMail.extra);
|
|
783
|
+
if (aHeader && bHeader) {
|
|
784
|
+
const dhCmp = XUtils.encodeHex(aHeader.dhPub).localeCompare(
|
|
785
|
+
XUtils.encodeHex(bHeader.dhPub),
|
|
786
|
+
"en",
|
|
787
|
+
);
|
|
788
|
+
if (dhCmp !== 0) {
|
|
789
|
+
return dhCmp;
|
|
790
|
+
}
|
|
791
|
+
const pnCmp = aHeader.pn - bHeader.pn;
|
|
792
|
+
if (pnCmp !== 0) {
|
|
793
|
+
return pnCmp;
|
|
794
|
+
}
|
|
795
|
+
return aHeader.n - bHeader.n;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return aMail.nonce.toString().localeCompare(bMail.nonce.toString(), "en");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function tryDecodeRatchetHeader(extra: Uint8Array) {
|
|
803
|
+
try {
|
|
804
|
+
return decodeRatchetHeader(extra);
|
|
805
|
+
} catch {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
756
810
|
/** Zod schema matching the {@link Message} interface for forwarded-message decode. */
|
|
757
811
|
const messageSchema: z.ZodType<Message> = z.object({
|
|
758
812
|
authorID: z.string(),
|
|
@@ -2440,6 +2494,11 @@ export class Client {
|
|
|
2440
2494
|
|
|
2441
2495
|
try {
|
|
2442
2496
|
keyBundle = await this.retrieveKeyBundle(device.deviceID);
|
|
2497
|
+
await verifyKeyBundleSignatures(
|
|
2498
|
+
keyBundle,
|
|
2499
|
+
device,
|
|
2500
|
+
this.cryptoProfile,
|
|
2501
|
+
);
|
|
2443
2502
|
} catch (e) {
|
|
2444
2503
|
if (allowKeyBundleFailure) {
|
|
2445
2504
|
return;
|
|
@@ -3001,7 +3060,7 @@ export class Client {
|
|
|
3001
3060
|
const rawInbox = z
|
|
3002
3061
|
.array(mailInboxEntry)
|
|
3003
3062
|
.parse(msgpack.decode(mailBuffer));
|
|
3004
|
-
const inbox = rawInbox.sort(
|
|
3063
|
+
const inbox = rawInbox.sort(compareInboxEntries);
|
|
3005
3064
|
|
|
3006
3065
|
if (libvexDebugDmEnabled()) {
|
|
3007
3066
|
const did = (() => {
|
|
@@ -3046,8 +3105,9 @@ export class Client {
|
|
|
3046
3105
|
fetchErr,
|
|
3047
3106
|
);
|
|
3048
3107
|
}
|
|
3108
|
+
} finally {
|
|
3109
|
+
this.fetchingMail = false;
|
|
3049
3110
|
}
|
|
3050
|
-
this.fetchingMail = false;
|
|
3051
3111
|
}
|
|
3052
3112
|
|
|
3053
3113
|
private async getMessageHistory(userID: string): Promise<Message[]> {
|
|
@@ -3204,7 +3264,6 @@ export class Client {
|
|
|
3204
3264
|
}
|
|
3205
3265
|
case "mail":
|
|
3206
3266
|
await this.getMail();
|
|
3207
|
-
this.fetchingMail = false;
|
|
3208
3267
|
break;
|
|
3209
3268
|
case "permission":
|
|
3210
3269
|
this.emitter.emit(
|
|
@@ -3232,6 +3291,27 @@ export class Client {
|
|
|
3232
3291
|
}
|
|
3233
3292
|
}
|
|
3234
3293
|
|
|
3294
|
+
private handleTerminalSocketState(reason: string): boolean {
|
|
3295
|
+
const { readyState } = this.socket;
|
|
3296
|
+
if (readyState !== 2 && readyState !== 3) {
|
|
3297
|
+
return false;
|
|
3298
|
+
}
|
|
3299
|
+
if (this.isManualCloseInFlight()) {
|
|
3300
|
+
return true;
|
|
3301
|
+
}
|
|
3302
|
+
if (this.pingInterval) {
|
|
3303
|
+
clearInterval(this.pingInterval);
|
|
3304
|
+
this.pingInterval = null;
|
|
3305
|
+
}
|
|
3306
|
+
debugLibvexDm("websocket-terminal-state", {
|
|
3307
|
+
readyState,
|
|
3308
|
+
reason,
|
|
3309
|
+
});
|
|
3310
|
+
this.emitter.emit("disconnect");
|
|
3311
|
+
this.scheduleReconnect();
|
|
3312
|
+
return true;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3235
3315
|
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3236
3316
|
|
|
3237
3317
|
/**
|
|
@@ -3289,6 +3369,7 @@ export class Client {
|
|
|
3289
3369
|
this.socket.send(new TextEncoder().encode(authMsg));
|
|
3290
3370
|
} catch (err: unknown) {
|
|
3291
3371
|
if (err instanceof WebSocketNotOpenError) {
|
|
3372
|
+
this.handleTerminalSocketState("auth-open");
|
|
3292
3373
|
return;
|
|
3293
3374
|
}
|
|
3294
3375
|
throw err;
|
|
@@ -3522,6 +3603,9 @@ export class Client {
|
|
|
3522
3603
|
}
|
|
3523
3604
|
|
|
3524
3605
|
private ping() {
|
|
3606
|
+
if (this.handleTerminalSocketState("ping")) {
|
|
3607
|
+
return;
|
|
3608
|
+
}
|
|
3525
3609
|
if (!this.isAlive) {
|
|
3526
3610
|
// Previous ping went unanswered — the WebSocket is half-open
|
|
3527
3611
|
// (e.g., the network stack silently dropped the flow without a
|
|
@@ -3540,6 +3624,7 @@ export class Client {
|
|
|
3540
3624
|
} catch {
|
|
3541
3625
|
// socket may already be CLOSING/CLOSED; ignore.
|
|
3542
3626
|
}
|
|
3627
|
+
this.scheduleReconnect();
|
|
3543
3628
|
return;
|
|
3544
3629
|
}
|
|
3545
3630
|
this.setAlive(false);
|
|
@@ -3641,7 +3726,6 @@ export class Client {
|
|
|
3641
3726
|
try {
|
|
3642
3727
|
await this.getMail();
|
|
3643
3728
|
count++;
|
|
3644
|
-
this.fetchingMail = false;
|
|
3645
3729
|
|
|
3646
3730
|
if (count > 10) {
|
|
3647
3731
|
void this.negotiateOTK();
|
|
@@ -4221,7 +4305,9 @@ export class Client {
|
|
|
4221
4305
|
},
|
|
4222
4306
|
);
|
|
4223
4307
|
}
|
|
4224
|
-
|
|
4308
|
+
if (failureCount >= 2) {
|
|
4309
|
+
healSession();
|
|
4310
|
+
}
|
|
4225
4311
|
if (failureCount === 1) {
|
|
4226
4312
|
this.emitter.emit("retryRequest", {
|
|
4227
4313
|
mailID: mail.mailID,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CryptoProfile, KeyPair } from "@vex-chat/crypto";
|
|
8
|
+
import type { Device, KeyBundle, KeyBundleEntry } from "@vex-chat/types";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
setCryptoProfile,
|
|
12
|
+
xBoxKeyPairAsync,
|
|
13
|
+
xConstants,
|
|
14
|
+
xEcdhKeyPairFromEcdsaKeyPairAsync,
|
|
15
|
+
xEncode,
|
|
16
|
+
xSignAsync,
|
|
17
|
+
xSignKeyPairAsync,
|
|
18
|
+
XUtils,
|
|
19
|
+
} from "@vex-chat/crypto";
|
|
20
|
+
|
|
21
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
22
|
+
|
|
23
|
+
import { fipsP256PreKeySignPayload } from "../utils/fipsMailExtra.js";
|
|
24
|
+
import { verifyKeyBundleSignatures } from "../utils/verifyKeyBundle.js";
|
|
25
|
+
|
|
26
|
+
describe.sequential("verifyKeyBundleSignatures", () => {
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
setCryptoProfile("tweetnacl");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("accepts a valid tweetnacl key bundle", async () => {
|
|
32
|
+
const { device, keyBundle } = await makeBundle("tweetnacl");
|
|
33
|
+
|
|
34
|
+
await expect(
|
|
35
|
+
verifyKeyBundleSignatures(keyBundle, device, "tweetnacl"),
|
|
36
|
+
).resolves.toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a tampered signed prekey public key", async () => {
|
|
40
|
+
const { device, keyBundle } = await makeBundle("tweetnacl");
|
|
41
|
+
const tampered = cloneBundle(keyBundle);
|
|
42
|
+
tampered.preKey.publicKey = flipFirstByte(tampered.preKey.publicKey);
|
|
43
|
+
|
|
44
|
+
await expect(
|
|
45
|
+
verifyKeyBundleSignatures(tampered, device, "tweetnacl"),
|
|
46
|
+
).rejects.toThrow("signed prekey signature is invalid");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects a tampered one-time prekey public key", async () => {
|
|
50
|
+
const { device, keyBundle } = await makeBundle("tweetnacl", true);
|
|
51
|
+
const tampered = cloneBundle(keyBundle);
|
|
52
|
+
if (!tampered.otk) {
|
|
53
|
+
throw new Error("Expected OTK fixture.");
|
|
54
|
+
}
|
|
55
|
+
tampered.otk.publicKey = flipFirstByte(tampered.otk.publicKey);
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
verifyKeyBundleSignatures(tampered, device, "tweetnacl"),
|
|
59
|
+
).rejects.toThrow("one-time prekey signature is invalid");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects a bundle identity key that does not match the device", async () => {
|
|
63
|
+
const { device, keyBundle } = await makeBundle("tweetnacl");
|
|
64
|
+
const other = await xSignKeyPairAsync();
|
|
65
|
+
const tampered = cloneBundle(keyBundle);
|
|
66
|
+
tampered.signKey = other.publicKey;
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
verifyKeyBundleSignatures(tampered, device, "tweetnacl"),
|
|
70
|
+
).rejects.toThrow("identity key does not match");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("accepts a valid FIPS key bundle", async () => {
|
|
74
|
+
const { device, keyBundle } = await makeBundle("fips", true);
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
verifyKeyBundleSignatures(keyBundle, device, "fips"),
|
|
78
|
+
).resolves.toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function cloneBundle(bundle: KeyBundle): KeyBundle {
|
|
83
|
+
return {
|
|
84
|
+
...(bundle.otk ? { otk: cloneEntry(bundle.otk) } : {}),
|
|
85
|
+
preKey: cloneEntry(bundle.preKey),
|
|
86
|
+
signKey: Uint8Array.from(bundle.signKey),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cloneEntry(entry: KeyBundleEntry): KeyBundleEntry {
|
|
91
|
+
return {
|
|
92
|
+
deviceID: entry.deviceID,
|
|
93
|
+
index: entry.index,
|
|
94
|
+
publicKey: Uint8Array.from(entry.publicKey),
|
|
95
|
+
signature: Uint8Array.from(entry.signature),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function flipFirstByte(value: Uint8Array): Uint8Array {
|
|
100
|
+
const out = Uint8Array.from(value);
|
|
101
|
+
const first = out[0];
|
|
102
|
+
if (first === undefined) {
|
|
103
|
+
throw new Error("Cannot tamper empty byte array.");
|
|
104
|
+
}
|
|
105
|
+
out[0] = first ^ 0xff;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function makeBundle(
|
|
110
|
+
profile: CryptoProfile,
|
|
111
|
+
includeOtk = false,
|
|
112
|
+
): Promise<{ device: Device; keyBundle: KeyBundle }> {
|
|
113
|
+
setCryptoProfile(profile);
|
|
114
|
+
const signKeys = await xSignKeyPairAsync();
|
|
115
|
+
const identityPublic =
|
|
116
|
+
profile === "fips"
|
|
117
|
+
? (await xEcdhKeyPairFromEcdsaKeyPairAsync(signKeys)).publicKey
|
|
118
|
+
: signKeys.publicKey;
|
|
119
|
+
const device: Device = {
|
|
120
|
+
deleted: false,
|
|
121
|
+
deviceID: "device-a",
|
|
122
|
+
lastLogin: new Date(0).toISOString(),
|
|
123
|
+
name: "test-device",
|
|
124
|
+
owner: "user-a",
|
|
125
|
+
signKey: XUtils.encodeHex(signKeys.publicKey),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const keyBundle: KeyBundle = {
|
|
129
|
+
preKey: await makeBundleEntry(signKeys, profile, device.deviceID, 1),
|
|
130
|
+
signKey: identityPublic,
|
|
131
|
+
};
|
|
132
|
+
if (includeOtk) {
|
|
133
|
+
keyBundle.otk = await makeBundleEntry(
|
|
134
|
+
signKeys,
|
|
135
|
+
profile,
|
|
136
|
+
device.deviceID,
|
|
137
|
+
2,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { device, keyBundle };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function makeBundleEntry(
|
|
145
|
+
signKeys: KeyPair,
|
|
146
|
+
profile: CryptoProfile,
|
|
147
|
+
deviceID: string,
|
|
148
|
+
index: number,
|
|
149
|
+
): Promise<KeyBundleEntry> {
|
|
150
|
+
const preKey = await xBoxKeyPairAsync();
|
|
151
|
+
const payload =
|
|
152
|
+
profile === "fips"
|
|
153
|
+
? fipsP256PreKeySignPayload(preKey.publicKey)
|
|
154
|
+
: xEncode(xConstants.CURVE, preKey.publicKey);
|
|
155
|
+
return {
|
|
156
|
+
deviceID,
|
|
157
|
+
index,
|
|
158
|
+
publicKey: preKey.publicKey,
|
|
159
|
+
signature: await xSignAsync(payload, signKeys.secretKey),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CryptoProfile } from "@vex-chat/crypto";
|
|
8
|
+
import type { Device, KeyBundle, KeyBundleEntry } from "@vex-chat/types";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
fipsEcdhRawPublicKeyFromEcdsaSpkiAsync,
|
|
12
|
+
xConstants,
|
|
13
|
+
xEncode,
|
|
14
|
+
xSignOpenAsync,
|
|
15
|
+
XUtils,
|
|
16
|
+
} from "@vex-chat/crypto";
|
|
17
|
+
|
|
18
|
+
import { fipsP256PreKeySignPayload } from "./fipsMailExtra.js";
|
|
19
|
+
|
|
20
|
+
export async function verifyKeyBundleSignatures(
|
|
21
|
+
keyBundle: KeyBundle,
|
|
22
|
+
device: Device,
|
|
23
|
+
cryptoProfile: CryptoProfile,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const deviceSignKey = XUtils.decodeHex(device.signKey);
|
|
26
|
+
const expectedIdentity =
|
|
27
|
+
cryptoProfile === "fips"
|
|
28
|
+
? await fipsEcdhRawPublicKeyFromEcdsaSpkiAsync(deviceSignKey)
|
|
29
|
+
: deviceSignKey;
|
|
30
|
+
|
|
31
|
+
if (!XUtils.bytesEqual(expectedIdentity, keyBundle.signKey)) {
|
|
32
|
+
throw new Error("Key bundle identity key does not match device.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await verifyKeyBundleEntrySignature(
|
|
36
|
+
keyBundle.preKey,
|
|
37
|
+
device,
|
|
38
|
+
deviceSignKey,
|
|
39
|
+
cryptoProfile,
|
|
40
|
+
"signed prekey",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (keyBundle.otk) {
|
|
44
|
+
await verifyKeyBundleEntrySignature(
|
|
45
|
+
keyBundle.otk,
|
|
46
|
+
device,
|
|
47
|
+
deviceSignKey,
|
|
48
|
+
cryptoProfile,
|
|
49
|
+
"one-time prekey",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function verifyKeyBundleEntrySignature(
|
|
55
|
+
entry: KeyBundleEntry,
|
|
56
|
+
device: Device,
|
|
57
|
+
deviceSignKey: Uint8Array,
|
|
58
|
+
cryptoProfile: CryptoProfile,
|
|
59
|
+
label: string,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
if (entry.deviceID !== device.deviceID) {
|
|
62
|
+
throw new Error(`Key bundle ${label} belongs to a different device.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const payload =
|
|
66
|
+
cryptoProfile === "fips"
|
|
67
|
+
? fipsP256PreKeySignPayload(entry.publicKey)
|
|
68
|
+
: xEncode(xConstants.CURVE, entry.publicKey);
|
|
69
|
+
const opened = await xSignOpenAsync(entry.signature, deviceSignKey);
|
|
70
|
+
|
|
71
|
+
if (!opened || !XUtils.bytesEqual(opened, payload)) {
|
|
72
|
+
throw new Error(`Key bundle ${label} signature is invalid.`);
|
|
73
|
+
}
|
|
74
|
+
}
|