@vex-chat/libvex 7.4.0 → 9.0.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 +39 -24
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +194 -124
- package/dist/Client.js.map +1 -1
- package/dist/codecs.d.ts +8 -4
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +2 -5
- package/dist/codecs.js.map +1 -1
- package/dist/http.js +15 -1
- package/dist/http.js.map +1 -1
- package/dist/keystore/node.d.ts.map +1 -1
- package/dist/keystore/node.js +9 -11
- package/dist/keystore/node.js.map +1 -1
- package/dist/storage/sqlite.d.ts +6 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +89 -56
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/utils/verifyKeyBundle.d.ts.map +1 -1
- package/dist/utils/verifyKeyBundle.js +7 -10
- package/dist/utils/verifyKeyBundle.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +310 -173
- package/src/__tests__/harness/shared-suite.ts +82 -11
- package/src/__tests__/http.test.ts +22 -0
- package/src/__tests__/node-keystore.test.ts +69 -0
- package/src/__tests__/prekey-reuse.test.ts +13 -13
- package/src/__tests__/storage-sqlite.test.ts +90 -1
- package/src/__tests__/verifyKeyBundle.test.ts +11 -8
- package/src/codecs.ts +2 -5
- package/src/http.ts +16 -1
- package/src/keystore/node.ts +17 -19
- package/src/storage/sqlite.ts +109 -84
- package/src/utils/verifyKeyBundle.ts +13 -13
package/src/Client.ts
CHANGED
|
@@ -68,7 +68,9 @@ import {
|
|
|
68
68
|
xKDF,
|
|
69
69
|
XKeyConvert,
|
|
70
70
|
xMakeNonce,
|
|
71
|
+
xMessageKeySubkeys,
|
|
71
72
|
xMnemonic,
|
|
73
|
+
xPreKeySignaturePayload,
|
|
72
74
|
xRandomBytes,
|
|
73
75
|
xSecretboxAsync,
|
|
74
76
|
xSecretboxOpenAsync,
|
|
@@ -81,6 +83,8 @@ import {
|
|
|
81
83
|
XUtils,
|
|
82
84
|
} from "@vex-chat/crypto";
|
|
83
85
|
import {
|
|
86
|
+
ACCOUNT_PASSWORD_MAX_LENGTH,
|
|
87
|
+
ACCOUNT_PASSWORD_MIN_LENGTH,
|
|
84
88
|
CallEventSchema,
|
|
85
89
|
CallSessionSchema,
|
|
86
90
|
IceServerConfigSchema,
|
|
@@ -113,7 +117,6 @@ import {
|
|
|
113
117
|
decodeFipsInitialExtraV1,
|
|
114
118
|
encodeFipsInitialExtraV1,
|
|
115
119
|
fipsP256AdFromIdentityPubs,
|
|
116
|
-
fipsP256PreKeySignPayload,
|
|
117
120
|
isFipsInitialExtraV1,
|
|
118
121
|
} from "./utils/fipsMailExtra.js";
|
|
119
122
|
import {
|
|
@@ -283,6 +286,23 @@ function libvexDebugLevel(): "debug" | "trace" {
|
|
|
283
286
|
}
|
|
284
287
|
}
|
|
285
288
|
|
|
289
|
+
function registrationPasswordError(password: string): Error | null {
|
|
290
|
+
if (
|
|
291
|
+
password.trim().length === 0 ||
|
|
292
|
+
password.length < ACCOUNT_PASSWORD_MIN_LENGTH
|
|
293
|
+
) {
|
|
294
|
+
return new Error(
|
|
295
|
+
`Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
|
|
299
|
+
return new Error(
|
|
300
|
+
`Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
286
306
|
function sleep(ms: number): Promise<void> {
|
|
287
307
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
288
308
|
}
|
|
@@ -504,9 +524,8 @@ export interface Devices {
|
|
|
504
524
|
}) => Promise<void>;
|
|
505
525
|
/**
|
|
506
526
|
* Approves a pending device registration request as the current device.
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
* approving device.
|
|
527
|
+
* The server verifies a signature from this approved device before
|
|
528
|
+
* admitting the pending device into the account cluster.
|
|
510
529
|
*/
|
|
511
530
|
approveRequest: (requestID: string) => Promise<Device>;
|
|
512
531
|
/**
|
|
@@ -547,10 +566,8 @@ export interface Devices {
|
|
|
547
566
|
* the request's private signing key by signing the challenge issued by
|
|
548
567
|
* the server in the original 202 response.
|
|
549
568
|
*
|
|
550
|
-
* @param args
|
|
551
|
-
*
|
|
552
|
-
* @param args.challenge - The hex challenge issued in the same 202
|
|
553
|
-
* response (or {@link DeviceApprovalRequiredError.challenge}).
|
|
569
|
+
* @param args - The request ID and hex challenge returned by `/register`
|
|
570
|
+
* (or carried by {@link DeviceApprovalRequiredError}).
|
|
554
571
|
* @returns The current {@link PendingDeviceRequest} or `null` if the
|
|
555
572
|
* server no longer has a record of it.
|
|
556
573
|
*/
|
|
@@ -702,6 +719,11 @@ export interface Keys {
|
|
|
702
719
|
* @ignore
|
|
703
720
|
*/
|
|
704
721
|
export interface Me {
|
|
722
|
+
/** Replace the account password after proving the current password. */
|
|
723
|
+
changePassword: (
|
|
724
|
+
currentPassword: string,
|
|
725
|
+
newPassword: string,
|
|
726
|
+
) => Promise<void>;
|
|
705
727
|
/** Returns metadata for the currently authenticated device. */
|
|
706
728
|
device: () => Device;
|
|
707
729
|
/** Uploads and sets a new avatar image for the current user. */
|
|
@@ -831,7 +853,7 @@ export interface Passkeys {
|
|
|
831
853
|
options: any;
|
|
832
854
|
requestID: string;
|
|
833
855
|
}>;
|
|
834
|
-
/** Remove a passkey
|
|
856
|
+
/** Remove a passkey using the account's approved-device session. */
|
|
835
857
|
delete: (passkeyID: string) => Promise<void>;
|
|
836
858
|
/** Delete one of the account's devices using the passkey session. */
|
|
837
859
|
deleteDevice: (deviceID: string) => Promise<void>;
|
|
@@ -868,6 +890,8 @@ export interface Passkeys {
|
|
|
868
890
|
recoverDeviceRequest: (requestID: string) => Promise<Device>;
|
|
869
891
|
/** Reject a pending device-enrollment request using the passkey session. */
|
|
870
892
|
rejectDeviceRequest: (requestID: string) => Promise<void>;
|
|
893
|
+
/** Replace a forgotten password using this fresh passkey session. */
|
|
894
|
+
resetPassword: (newPassword: string) => Promise<void>;
|
|
871
895
|
}
|
|
872
896
|
|
|
873
897
|
export type PendingDeviceApprovalStatus =
|
|
@@ -881,13 +905,8 @@ export interface PendingDeviceRegistration {
|
|
|
881
905
|
expiresAt: string;
|
|
882
906
|
requestID: string;
|
|
883
907
|
status: "pending_approval";
|
|
884
|
-
/**
|
|
885
|
-
|
|
886
|
-
* servers that don't include it; when present, the new device can
|
|
887
|
-
* fetch the public avatar from `/avatar/:userID` (no auth required)
|
|
888
|
-
* to power an "is this you?" confirmation.
|
|
889
|
-
*/
|
|
890
|
-
userID?: string | undefined;
|
|
908
|
+
/** Existing user's ID used to render the account confirmation step. */
|
|
909
|
+
userID: string;
|
|
891
910
|
}
|
|
892
911
|
|
|
893
912
|
export interface PendingDeviceRequest {
|
|
@@ -1306,8 +1325,10 @@ export interface Users {
|
|
|
1306
1325
|
* const client = await Client.create(privateKey);
|
|
1307
1326
|
*
|
|
1308
1327
|
* // you must register once before you can log in
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1328
|
+
* const username = Client.randomUsername();
|
|
1329
|
+
* const password = "correct horse battery staple";
|
|
1330
|
+
* await client.register(username, password);
|
|
1331
|
+
* await client.login(username, password);
|
|
1311
1332
|
*
|
|
1312
1333
|
* // The ready event fires after connect() finishes post-auth setup.
|
|
1313
1334
|
* // Wait for it before performing messaging or user operations.
|
|
@@ -1524,6 +1545,7 @@ export class Client {
|
|
|
1524
1545
|
* Helpers for information/actions related to the currently authenticated account.
|
|
1525
1546
|
*/
|
|
1526
1547
|
public me: Me = {
|
|
1548
|
+
changePassword: this.changePassword.bind(this),
|
|
1527
1549
|
/**
|
|
1528
1550
|
* Retrieves current device details.
|
|
1529
1551
|
*
|
|
@@ -1616,6 +1638,7 @@ export class Client {
|
|
|
1616
1638
|
listDevices: this.passkeyListDevices.bind(this),
|
|
1617
1639
|
recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
|
|
1618
1640
|
rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
|
|
1641
|
+
resetPassword: this.resetPasswordWithPasskey.bind(this),
|
|
1619
1642
|
};
|
|
1620
1643
|
|
|
1621
1644
|
/**
|
|
@@ -1728,7 +1751,7 @@ export class Client {
|
|
|
1728
1751
|
|
|
1729
1752
|
private readonly decryptFailureCounts = new Map<string, number>();
|
|
1730
1753
|
|
|
1731
|
-
private device
|
|
1754
|
+
private device: Device | undefined;
|
|
1732
1755
|
|
|
1733
1756
|
private deviceRecords: Record<string, Device> = {};
|
|
1734
1757
|
// ── Event subscription (composition over inheritance) ───────────────
|
|
@@ -1785,7 +1808,7 @@ export class Client {
|
|
|
1785
1808
|
private socket: WebSocketLike;
|
|
1786
1809
|
private token: null | string = null;
|
|
1787
1810
|
|
|
1788
|
-
private user
|
|
1811
|
+
private user: undefined | User;
|
|
1789
1812
|
|
|
1790
1813
|
private userRecords: Record<string, User> = {};
|
|
1791
1814
|
private xKeyRing?: XKeyRing;
|
|
@@ -1912,14 +1935,32 @@ export class Client {
|
|
|
1912
1935
|
|
|
1913
1936
|
let resolvedStorage = storage;
|
|
1914
1937
|
if (!resolvedStorage) {
|
|
1915
|
-
const
|
|
1938
|
+
const nodeStorageModule = "./storage/node.js";
|
|
1939
|
+
const isNodeStorageModule = (
|
|
1940
|
+
value: unknown,
|
|
1941
|
+
): value is {
|
|
1942
|
+
createNodeStorage: (
|
|
1943
|
+
dbPath: string,
|
|
1944
|
+
atRestAesKey: Uint8Array,
|
|
1945
|
+
) => Storage;
|
|
1946
|
+
} =>
|
|
1947
|
+
typeof value === "object" &&
|
|
1948
|
+
value !== null &&
|
|
1949
|
+
"createNodeStorage" in value &&
|
|
1950
|
+
typeof value.createNodeStorage === "function";
|
|
1951
|
+
const nodeStorage: unknown = await import(
|
|
1952
|
+
/* @vite-ignore */ nodeStorageModule
|
|
1953
|
+
);
|
|
1954
|
+
if (!isNodeStorageModule(nodeStorage)) {
|
|
1955
|
+
throw new Error("Node storage adapter is unavailable.");
|
|
1956
|
+
}
|
|
1916
1957
|
const dbFileName = options?.inMemoryDb
|
|
1917
1958
|
? ":memory:"
|
|
1918
1959
|
: XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
|
|
1919
1960
|
const dbPath = options?.dbFolder
|
|
1920
1961
|
? options.dbFolder + "/" + dbFileName
|
|
1921
1962
|
: dbFileName;
|
|
1922
|
-
resolvedStorage = createNodeStorage(dbPath, atRestAes);
|
|
1963
|
+
resolvedStorage = nodeStorage.createNodeStorage(dbPath, atRestAes);
|
|
1923
1964
|
}
|
|
1924
1965
|
|
|
1925
1966
|
await resolvedStorage.init();
|
|
@@ -2290,10 +2331,35 @@ export class Client {
|
|
|
2290
2331
|
}
|
|
2291
2332
|
|
|
2292
2333
|
/**
|
|
2293
|
-
*
|
|
2334
|
+
* Ends the local authenticated session and disconnects realtime transport.
|
|
2335
|
+
* Spire access tokens are stateless, so local credential removal is the
|
|
2336
|
+
* authoritative logout boundary.
|
|
2294
2337
|
*/
|
|
2295
2338
|
public async logout(): Promise<void> {
|
|
2296
|
-
|
|
2339
|
+
try {
|
|
2340
|
+
if (this.token) {
|
|
2341
|
+
await this.http.post(this.getHost() + "/goodbye");
|
|
2342
|
+
}
|
|
2343
|
+
} catch {
|
|
2344
|
+
// Logout must still complete locally when the server is unreachable.
|
|
2345
|
+
} finally {
|
|
2346
|
+
this.token = null;
|
|
2347
|
+
this.user = undefined;
|
|
2348
|
+
this.device = undefined;
|
|
2349
|
+
delete this.http.defaults.headers.common.Authorization;
|
|
2350
|
+
delete this.http.defaults.headers.common["X-Device-Token"];
|
|
2351
|
+
this.autoReconnectEnabled = false;
|
|
2352
|
+
this.postAuthVersion++;
|
|
2353
|
+
if (this.reconnectTimer) {
|
|
2354
|
+
clearTimeout(this.reconnectTimer);
|
|
2355
|
+
this.reconnectTimer = null;
|
|
2356
|
+
}
|
|
2357
|
+
if (this.pingInterval) {
|
|
2358
|
+
clearInterval(this.pingInterval);
|
|
2359
|
+
this.pingInterval = null;
|
|
2360
|
+
}
|
|
2361
|
+
this.socket.close();
|
|
2362
|
+
}
|
|
2297
2363
|
}
|
|
2298
2364
|
|
|
2299
2365
|
/** Removes an event listener. See {@link ClientEvents} for available events. */
|
|
@@ -2365,139 +2431,28 @@ export class Client {
|
|
|
2365
2431
|
/**
|
|
2366
2432
|
* Registers a new account on the server.
|
|
2367
2433
|
*
|
|
2368
|
-
* @param username -
|
|
2369
|
-
* @param password -
|
|
2434
|
+
* @param username - Username to register.
|
|
2435
|
+
* @param password - Password for the account and device approval request.
|
|
2370
2436
|
* @returns `[user, null]` on success, `[null, error]` on failure.
|
|
2371
2437
|
*
|
|
2372
2438
|
* @example
|
|
2373
2439
|
* ```ts
|
|
2374
|
-
* const [user, err] = await client.register("MyUsername");
|
|
2440
|
+
* const [user, err] = await client.register("MyUsername", "correct horse battery staple");
|
|
2375
2441
|
* ```
|
|
2376
2442
|
*/
|
|
2377
2443
|
public async register(
|
|
2378
|
-
username
|
|
2379
|
-
password
|
|
2444
|
+
username: string,
|
|
2445
|
+
password: string,
|
|
2380
2446
|
): Promise<[null | User, Error | null]> {
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
username?.trim().length !== 0 && username !== undefined
|
|
2391
|
-
? username.trim().toLowerCase()
|
|
2392
|
-
: Client.randomUsername();
|
|
2393
|
-
const resolvedPassword =
|
|
2394
|
-
password?.trim().length !== 0 && password !== undefined
|
|
2395
|
-
? password
|
|
2396
|
-
: uuid.v4();
|
|
2397
|
-
const signKey = XUtils.encodeHex(this.signKeys.publicKey);
|
|
2398
|
-
const signed = XUtils.encodeHex(
|
|
2399
|
-
await xSignAsync(
|
|
2400
|
-
Uint8Array.from(uuid.parse(regKey.key)),
|
|
2401
|
-
this.signKeys.secretKey,
|
|
2402
|
-
),
|
|
2403
|
-
);
|
|
2404
|
-
const preKeyIndex = this.xKeyRing.preKeys.index;
|
|
2405
|
-
const regMsg: RegistrationPayload = {
|
|
2406
|
-
deviceName: this.options?.deviceName ?? "unknown",
|
|
2407
|
-
password: resolvedPassword,
|
|
2408
|
-
preKey: XUtils.encodeHex(
|
|
2409
|
-
this.xKeyRing.preKeys.keyPair.publicKey,
|
|
2410
|
-
),
|
|
2411
|
-
preKeyIndex,
|
|
2412
|
-
preKeySignature: XUtils.encodeHex(
|
|
2413
|
-
this.xKeyRing.preKeys.signature,
|
|
2414
|
-
),
|
|
2415
|
-
signed,
|
|
2416
|
-
signKey,
|
|
2417
|
-
username: resolvedUsername,
|
|
2418
|
-
};
|
|
2419
|
-
try {
|
|
2420
|
-
const res = await this.http.post(
|
|
2421
|
-
this.getHost() + "/register",
|
|
2422
|
-
msgpack.encode(regMsg),
|
|
2423
|
-
{ headers: { "Content-Type": "application/msgpack" } },
|
|
2424
|
-
);
|
|
2425
|
-
|
|
2426
|
-
// New key-cluster server response: { device, token, user }.
|
|
2427
|
-
// Legacy response (still deployed in some environments): user only.
|
|
2428
|
-
let didDecodeRegisterResponse = false;
|
|
2429
|
-
let pendingApproval: null | PendingDeviceRegistration = null;
|
|
2430
|
-
try {
|
|
2431
|
-
const { device, token, user } = decodeHttpResponse(
|
|
2432
|
-
RegisterResponseCodec,
|
|
2433
|
-
res.data,
|
|
2434
|
-
);
|
|
2435
|
-
this.device = device;
|
|
2436
|
-
this.setUser(user);
|
|
2437
|
-
this.token = token;
|
|
2438
|
-
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
2439
|
-
didDecodeRegisterResponse = true;
|
|
2440
|
-
} catch {
|
|
2441
|
-
// fall through to legacy decode path
|
|
2442
|
-
}
|
|
2443
|
-
|
|
2444
|
-
if (!didDecodeRegisterResponse) {
|
|
2445
|
-
try {
|
|
2446
|
-
pendingApproval = decodeHttpResponse(
|
|
2447
|
-
RegisterPendingApprovalCodec,
|
|
2448
|
-
res.data,
|
|
2449
|
-
);
|
|
2450
|
-
} catch {
|
|
2451
|
-
// fall through to legacy decode path
|
|
2452
|
-
}
|
|
2453
|
-
}
|
|
2454
|
-
|
|
2455
|
-
if (!didDecodeRegisterResponse) {
|
|
2456
|
-
if (pendingApproval !== null) {
|
|
2457
|
-
return [
|
|
2458
|
-
null,
|
|
2459
|
-
new DeviceApprovalRequiredError({
|
|
2460
|
-
challenge: pendingApproval.challenge,
|
|
2461
|
-
expiresAt: pendingApproval.expiresAt,
|
|
2462
|
-
requestID: pendingApproval.requestID,
|
|
2463
|
-
userID: pendingApproval.userID ?? null,
|
|
2464
|
-
}),
|
|
2465
|
-
];
|
|
2466
|
-
}
|
|
2467
|
-
const legacyUser = decodeHttpResponse(UserCodec, res.data);
|
|
2468
|
-
this.setUser(legacyUser);
|
|
2469
|
-
|
|
2470
|
-
// Legacy servers require /auth after /register to get a JWT.
|
|
2471
|
-
const loginResult = await this.login(
|
|
2472
|
-
resolvedUsername,
|
|
2473
|
-
resolvedPassword,
|
|
2474
|
-
);
|
|
2475
|
-
if (!loginResult.ok) {
|
|
2476
|
-
return [
|
|
2477
|
-
null,
|
|
2478
|
-
new Error(
|
|
2479
|
-
loginResult.error ??
|
|
2480
|
-
"Legacy register succeeded but login failed.",
|
|
2481
|
-
),
|
|
2482
|
-
];
|
|
2483
|
-
}
|
|
2484
|
-
}
|
|
2485
|
-
return [this.getUser(), null];
|
|
2486
|
-
} catch (err: unknown) {
|
|
2487
|
-
if (isHttpError(err) && err.response) {
|
|
2488
|
-
return [
|
|
2489
|
-
null,
|
|
2490
|
-
new Error(spireErrorBodyMessage(err.response.data)),
|
|
2491
|
-
];
|
|
2492
|
-
}
|
|
2493
|
-
return [
|
|
2494
|
-
null,
|
|
2495
|
-
err instanceof Error ? err : new Error(String(err)),
|
|
2496
|
-
];
|
|
2497
|
-
}
|
|
2498
|
-
} else {
|
|
2499
|
-
return [null, new Error("Couldn't get regkey from server.")];
|
|
2500
|
-
}
|
|
2447
|
+
const passwordError = registrationPasswordError(password);
|
|
2448
|
+
if (passwordError) {
|
|
2449
|
+
return [null, passwordError];
|
|
2450
|
+
}
|
|
2451
|
+
return this.registerAccountOrEnrollment(
|
|
2452
|
+
username,
|
|
2453
|
+
"create-account",
|
|
2454
|
+
password,
|
|
2455
|
+
);
|
|
2501
2456
|
}
|
|
2502
2457
|
|
|
2503
2458
|
removeAllListeners(event?: keyof ClientEvents): this {
|
|
@@ -2505,6 +2460,36 @@ export class Client {
|
|
|
2505
2460
|
return this;
|
|
2506
2461
|
}
|
|
2507
2462
|
|
|
2463
|
+
/**
|
|
2464
|
+
* Requests approval for a new device using the account password. This
|
|
2465
|
+
* enrollment flow never creates an account when the username is unknown.
|
|
2466
|
+
*/
|
|
2467
|
+
public async requestDeviceEnrollment(
|
|
2468
|
+
username: string,
|
|
2469
|
+
password: string,
|
|
2470
|
+
): Promise<[null | User, Error | null]> {
|
|
2471
|
+
const passwordError = registrationPasswordError(password);
|
|
2472
|
+
if (passwordError) {
|
|
2473
|
+
return [null, passwordError];
|
|
2474
|
+
}
|
|
2475
|
+
return this.registerAccountOrEnrollment(
|
|
2476
|
+
username,
|
|
2477
|
+
"enroll-device",
|
|
2478
|
+
password,
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* Requests enrollment for this device after a successful passkey ceremony.
|
|
2484
|
+
* This is not account registration: the passkey bearer must identify an
|
|
2485
|
+
* existing account, and the server returns a pending device request.
|
|
2486
|
+
*/
|
|
2487
|
+
public async requestDeviceEnrollmentWithPasskey(
|
|
2488
|
+
username: string,
|
|
2489
|
+
): Promise<[null | User, Error | null]> {
|
|
2490
|
+
return this.registerAccountOrEnrollment(username, "enroll-device");
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2508
2493
|
/**
|
|
2509
2494
|
* Updates the local retention cap (1–30 days) and prunes immediately.
|
|
2510
2495
|
* Does not affect server-side storage.
|
|
@@ -2725,6 +2710,21 @@ export class Client {
|
|
|
2725
2710
|
};
|
|
2726
2711
|
}
|
|
2727
2712
|
|
|
2713
|
+
private async changePassword(
|
|
2714
|
+
currentPassword: string,
|
|
2715
|
+
newPassword: string,
|
|
2716
|
+
): Promise<void> {
|
|
2717
|
+
const passwordError = registrationPasswordError(newPassword);
|
|
2718
|
+
if (passwordError) throw passwordError;
|
|
2719
|
+
if (
|
|
2720
|
+
currentPassword.length === 0 ||
|
|
2721
|
+
currentPassword.length > ACCOUNT_PASSWORD_MAX_LENGTH
|
|
2722
|
+
) {
|
|
2723
|
+
throw new Error("Current password is invalid.");
|
|
2724
|
+
}
|
|
2725
|
+
await this.replaceAccountPassword({ currentPassword, newPassword });
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
2728
|
private async createChannel(
|
|
2729
2729
|
name: string,
|
|
2730
2730
|
serverID: string,
|
|
@@ -2836,13 +2836,16 @@ export class Client {
|
|
|
2836
2836
|
return decodeHttpResponse(InviteCodec, res.data);
|
|
2837
2837
|
}
|
|
2838
2838
|
|
|
2839
|
-
private async createPreKey(
|
|
2839
|
+
private async createPreKey(
|
|
2840
|
+
kind: "one-time" | "signed",
|
|
2841
|
+
): Promise<UnsavedPreKey> {
|
|
2840
2842
|
return this.runWithThisCryptoProfile(async () => {
|
|
2841
2843
|
const preKeyPair = await xBoxKeyPairAsync();
|
|
2842
|
-
const toSign =
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2844
|
+
const toSign = xPreKeySignaturePayload(
|
|
2845
|
+
preKeyPair.publicKey,
|
|
2846
|
+
kind,
|
|
2847
|
+
this.cryptoProfile,
|
|
2848
|
+
);
|
|
2846
2849
|
return {
|
|
2847
2850
|
keyPair: preKeyPair,
|
|
2848
2851
|
signature: await xSignAsync(toSign, this.signKeys.secretKey),
|
|
@@ -2947,6 +2950,7 @@ export class Client {
|
|
|
2947
2950
|
|
|
2948
2951
|
// shared secret key
|
|
2949
2952
|
const SK = xKDF(IKM);
|
|
2953
|
+
const initialSubkeys = xMessageKeySubkeys(SK);
|
|
2950
2954
|
const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
|
|
2951
2955
|
|
|
2952
2956
|
const AD = fips
|
|
@@ -2960,7 +2964,11 @@ export class Client {
|
|
|
2960
2964
|
);
|
|
2961
2965
|
|
|
2962
2966
|
const nonce = xMakeNonce();
|
|
2963
|
-
const cipher = await xSecretboxAsync(
|
|
2967
|
+
const cipher = await xSecretboxAsync(
|
|
2968
|
+
message,
|
|
2969
|
+
nonce,
|
|
2970
|
+
initialSubkeys.encryptionKey,
|
|
2971
|
+
);
|
|
2964
2972
|
|
|
2965
2973
|
const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
|
|
2966
2974
|
const ephKeyWire = ephemeralKeys.publicKey;
|
|
@@ -2989,7 +2997,7 @@ export class Client {
|
|
|
2989
2997
|
sender: this.getDevice().deviceID,
|
|
2990
2998
|
};
|
|
2991
2999
|
|
|
2992
|
-
const hmac = xHMAC(mail,
|
|
3000
|
+
const hmac = xHMAC(mail, initialSubkeys.authenticationKey);
|
|
2993
3001
|
|
|
2994
3002
|
const msg: ResourceMsg = {
|
|
2995
3003
|
action: "CREATE",
|
|
@@ -3178,6 +3186,7 @@ export class Client {
|
|
|
3178
3186
|
return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
|
|
3179
3187
|
.calls;
|
|
3180
3188
|
}
|
|
3189
|
+
|
|
3181
3190
|
private async fetchIceServers(): Promise<IceServerConfig[]> {
|
|
3182
3191
|
const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
|
|
3183
3192
|
responseType: "json",
|
|
@@ -3246,7 +3255,6 @@ export class Client {
|
|
|
3246
3255
|
return [null, isHttpError(err) ? err : null];
|
|
3247
3256
|
}
|
|
3248
3257
|
}
|
|
3249
|
-
|
|
3250
3258
|
private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
|
|
3251
3259
|
if (this.isManualCloseInFlight()) {
|
|
3252
3260
|
return [];
|
|
@@ -3645,6 +3653,9 @@ export class Client {
|
|
|
3645
3653
|
this.getDevice().deviceID +
|
|
3646
3654
|
"/mail",
|
|
3647
3655
|
);
|
|
3656
|
+
if (!this.token || this.isManualCloseInFlight()) {
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3648
3659
|
const mailBuffer = new Uint8Array(res.data);
|
|
3649
3660
|
const rawInbox = z
|
|
3650
3661
|
.array(mailInboxEntry)
|
|
@@ -4043,10 +4054,11 @@ export class Client {
|
|
|
4043
4054
|
preKey: PreKeysCrypto,
|
|
4044
4055
|
): Promise<boolean> {
|
|
4045
4056
|
return this.runWithThisCryptoProfile(async () => {
|
|
4046
|
-
const payload =
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4057
|
+
const payload = xPreKeySignaturePayload(
|
|
4058
|
+
preKey.keyPair.publicKey,
|
|
4059
|
+
"signed",
|
|
4060
|
+
this.cryptoProfile,
|
|
4061
|
+
);
|
|
4050
4062
|
const opened = await xSignOpenAsync(
|
|
4051
4063
|
preKey.signature,
|
|
4052
4064
|
this.signKeys.publicKey,
|
|
@@ -4274,7 +4286,7 @@ export class Client {
|
|
|
4274
4286
|
!preKeys ||
|
|
4275
4287
|
!(await this.isPreKeySignedByCurrentDevice(preKeys))
|
|
4276
4288
|
) {
|
|
4277
|
-
const unsaved = await this.createPreKey();
|
|
4289
|
+
const unsaved = await this.createPreKey("signed");
|
|
4278
4290
|
const [saved] = await this.database.savePreKeys(
|
|
4279
4291
|
[unsaved],
|
|
4280
4292
|
false,
|
|
@@ -4636,10 +4648,14 @@ export class Client {
|
|
|
4636
4648
|
|
|
4637
4649
|
// shared secret key
|
|
4638
4650
|
const SK = xKDF(IKM);
|
|
4651
|
+
const initialSubkeys = xMessageKeySubkeys(SK);
|
|
4639
4652
|
const PK = (await xBoxKeyPairFromSecretAsync(SK))
|
|
4640
4653
|
.publicKey;
|
|
4641
4654
|
|
|
4642
|
-
const hmac = xHMAC(
|
|
4655
|
+
const hmac = xHMAC(
|
|
4656
|
+
mail,
|
|
4657
|
+
initialSubkeys.authenticationKey,
|
|
4658
|
+
);
|
|
4643
4659
|
|
|
4644
4660
|
// associated data
|
|
4645
4661
|
const AD = fipsRead
|
|
@@ -4689,7 +4705,7 @@ export class Client {
|
|
|
4689
4705
|
const unsealed = await xSecretboxOpenAsync(
|
|
4690
4706
|
new Uint8Array(mail.cipher),
|
|
4691
4707
|
new Uint8Array(mail.nonce),
|
|
4692
|
-
|
|
4708
|
+
initialSubkeys.encryptionKey,
|
|
4693
4709
|
);
|
|
4694
4710
|
if (unsealed) {
|
|
4695
4711
|
let plaintext = "";
|
|
@@ -4906,12 +4922,16 @@ export class Client {
|
|
|
4906
4922
|
);
|
|
4907
4923
|
}
|
|
4908
4924
|
|
|
4909
|
-
|
|
4925
|
+
const messageKey = takeReceiveMessageKey(
|
|
4910
4926
|
candidateSession,
|
|
4911
4927
|
ratchetHeader.dhPub,
|
|
4912
4928
|
ratchetHeader.n,
|
|
4913
4929
|
);
|
|
4914
|
-
let
|
|
4930
|
+
let messageSubkeys = xMessageKeySubkeys(messageKey);
|
|
4931
|
+
let HMAC = xHMAC(
|
|
4932
|
+
mail,
|
|
4933
|
+
messageSubkeys.authenticationKey,
|
|
4934
|
+
);
|
|
4915
4935
|
|
|
4916
4936
|
if (
|
|
4917
4937
|
!XUtils.bytesEqual(HMAC, header) &&
|
|
@@ -4930,9 +4950,11 @@ export class Client {
|
|
|
4930
4950
|
ratchetHeader.dhPub,
|
|
4931
4951
|
ratchetHeader.n,
|
|
4932
4952
|
);
|
|
4953
|
+
const ratchetedSubkeys =
|
|
4954
|
+
xMessageKeySubkeys(ratchetedMessageKey);
|
|
4933
4955
|
const ratchetedHMAC = xHMAC(
|
|
4934
4956
|
mail,
|
|
4935
|
-
|
|
4957
|
+
ratchetedSubkeys.authenticationKey,
|
|
4936
4958
|
);
|
|
4937
4959
|
if (XUtils.bytesEqual(ratchetedHMAC, header)) {
|
|
4938
4960
|
if (libvexDebugDmEnabled()) {
|
|
@@ -4946,7 +4968,7 @@ export class Client {
|
|
|
4946
4968
|
);
|
|
4947
4969
|
}
|
|
4948
4970
|
candidateSession = ratchetedCandidate;
|
|
4949
|
-
|
|
4971
|
+
messageSubkeys = ratchetedSubkeys;
|
|
4950
4972
|
HMAC = ratchetedHMAC;
|
|
4951
4973
|
}
|
|
4952
4974
|
}
|
|
@@ -4987,7 +5009,7 @@ export class Client {
|
|
|
4987
5009
|
const decrypted = await xSecretboxOpenAsync(
|
|
4988
5010
|
new Uint8Array(mail.cipher),
|
|
4989
5011
|
new Uint8Array(mail.nonce),
|
|
4990
|
-
|
|
5012
|
+
messageSubkeys.encryptionKey,
|
|
4991
5013
|
);
|
|
4992
5014
|
|
|
4993
5015
|
if (decrypted) {
|
|
@@ -5132,6 +5154,99 @@ export class Client {
|
|
|
5132
5154
|
return decodeHttpResponse(PermissionCodec, res.data);
|
|
5133
5155
|
}
|
|
5134
5156
|
|
|
5157
|
+
private async registerAccountOrEnrollment(
|
|
5158
|
+
username: string,
|
|
5159
|
+
intent: RegistrationPayload["intent"],
|
|
5160
|
+
password?: string,
|
|
5161
|
+
): Promise<[null | User, Error | null]> {
|
|
5162
|
+
const resolvedUsername = username.trim().toLowerCase();
|
|
5163
|
+
if (!/^\w{3,19}$/.test(resolvedUsername)) {
|
|
5164
|
+
return [
|
|
5165
|
+
null,
|
|
5166
|
+
new Error(
|
|
5167
|
+
"Username must be between three and nineteen letters, digits, or underscores.",
|
|
5168
|
+
),
|
|
5169
|
+
];
|
|
5170
|
+
}
|
|
5171
|
+
while (!this.xKeyRing) {
|
|
5172
|
+
await sleep(100);
|
|
5173
|
+
}
|
|
5174
|
+
const regKey = await this.getToken("register");
|
|
5175
|
+
if (regKey) {
|
|
5176
|
+
const signKey = XUtils.encodeHex(this.signKeys.publicKey);
|
|
5177
|
+
const signed = XUtils.encodeHex(
|
|
5178
|
+
await xSignAsync(
|
|
5179
|
+
Uint8Array.from(uuid.parse(regKey.key)),
|
|
5180
|
+
this.signKeys.secretKey,
|
|
5181
|
+
),
|
|
5182
|
+
);
|
|
5183
|
+
const preKeyIndex = this.xKeyRing.preKeys.index;
|
|
5184
|
+
const regMsg: RegistrationPayload = {
|
|
5185
|
+
deviceName: this.options?.deviceName ?? "unknown",
|
|
5186
|
+
intent,
|
|
5187
|
+
preKey: XUtils.encodeHex(
|
|
5188
|
+
this.xKeyRing.preKeys.keyPair.publicKey,
|
|
5189
|
+
),
|
|
5190
|
+
preKeyIndex,
|
|
5191
|
+
preKeySignature: XUtils.encodeHex(
|
|
5192
|
+
this.xKeyRing.preKeys.signature,
|
|
5193
|
+
),
|
|
5194
|
+
signed,
|
|
5195
|
+
signKey,
|
|
5196
|
+
username: resolvedUsername,
|
|
5197
|
+
};
|
|
5198
|
+
if (password !== undefined) {
|
|
5199
|
+
regMsg.password = password;
|
|
5200
|
+
}
|
|
5201
|
+
try {
|
|
5202
|
+
const res = await this.http.post(
|
|
5203
|
+
this.getHost() + "/register",
|
|
5204
|
+
msgpack.encode(regMsg),
|
|
5205
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
5206
|
+
);
|
|
5207
|
+
|
|
5208
|
+
try {
|
|
5209
|
+
const { device, token, user } = decodeHttpResponse(
|
|
5210
|
+
RegisterResponseCodec,
|
|
5211
|
+
res.data,
|
|
5212
|
+
);
|
|
5213
|
+
this.device = device;
|
|
5214
|
+
this.setUser(user);
|
|
5215
|
+
this.token = token;
|
|
5216
|
+
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
5217
|
+
} catch {
|
|
5218
|
+
const pendingApproval = decodeHttpResponse(
|
|
5219
|
+
RegisterPendingApprovalCodec,
|
|
5220
|
+
res.data,
|
|
5221
|
+
);
|
|
5222
|
+
return [
|
|
5223
|
+
null,
|
|
5224
|
+
new DeviceApprovalRequiredError({
|
|
5225
|
+
challenge: pendingApproval.challenge,
|
|
5226
|
+
expiresAt: pendingApproval.expiresAt,
|
|
5227
|
+
requestID: pendingApproval.requestID,
|
|
5228
|
+
userID: pendingApproval.userID,
|
|
5229
|
+
}),
|
|
5230
|
+
];
|
|
5231
|
+
}
|
|
5232
|
+
return [this.getUser(), null];
|
|
5233
|
+
} catch (err: unknown) {
|
|
5234
|
+
if (isHttpError(err) && err.response) {
|
|
5235
|
+
return [
|
|
5236
|
+
null,
|
|
5237
|
+
new Error(spireErrorBodyMessage(err.response.data)),
|
|
5238
|
+
];
|
|
5239
|
+
}
|
|
5240
|
+
return [
|
|
5241
|
+
null,
|
|
5242
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
5243
|
+
];
|
|
5244
|
+
}
|
|
5245
|
+
} else {
|
|
5246
|
+
return [null, new Error("Couldn't get regkey from server.")];
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
|
|
5135
5250
|
private registerDecryptFailure(mail: MailWS): number {
|
|
5136
5251
|
const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
|
|
5137
5252
|
this.decryptFailureCounts.set(mail.mailID, count);
|
|
@@ -5199,6 +5314,23 @@ export class Client {
|
|
|
5199
5314
|
);
|
|
5200
5315
|
}
|
|
5201
5316
|
|
|
5317
|
+
private async replaceAccountPassword(payload: {
|
|
5318
|
+
currentPassword?: string;
|
|
5319
|
+
newPassword: string;
|
|
5320
|
+
}): Promise<void> {
|
|
5321
|
+
await this.http.patch(
|
|
5322
|
+
this.getHost() + "/user/" + this.getUser().userID + "/password",
|
|
5323
|
+
msgpack.encode(payload),
|
|
5324
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
5325
|
+
);
|
|
5326
|
+
}
|
|
5327
|
+
|
|
5328
|
+
private async resetPasswordWithPasskey(newPassword: string): Promise<void> {
|
|
5329
|
+
const passwordError = registrationPasswordError(newPassword);
|
|
5330
|
+
if (passwordError) throw passwordError;
|
|
5331
|
+
await this.replaceAccountPassword({ newPassword });
|
|
5332
|
+
}
|
|
5333
|
+
|
|
5202
5334
|
private async respond(msg: ChallMsg) {
|
|
5203
5335
|
const response: RespMsg = {
|
|
5204
5336
|
signed: await xSignAsync(
|
|
@@ -5310,7 +5442,7 @@ export class Client {
|
|
|
5310
5442
|
challenge: newDevice.challenge,
|
|
5311
5443
|
expiresAt: newDevice.expiresAt,
|
|
5312
5444
|
requestID: newDevice.requestID,
|
|
5313
|
-
userID: newDevice.userID
|
|
5445
|
+
userID: newDevice.userID,
|
|
5314
5446
|
});
|
|
5315
5447
|
} else {
|
|
5316
5448
|
throw new Error("Error registering device.");
|
|
@@ -5705,6 +5837,7 @@ export class Client {
|
|
|
5705
5837
|
await ratchetStepSend(session);
|
|
5706
5838
|
}
|
|
5707
5839
|
const { messageKey, n } = takeSendMessageKey(session);
|
|
5840
|
+
const messageSubkeys = xMessageKeySubkeys(messageKey);
|
|
5708
5841
|
const ratchetHeader = {
|
|
5709
5842
|
dhPub: session.DHsPublic,
|
|
5710
5843
|
n,
|
|
@@ -5712,7 +5845,11 @@ export class Client {
|
|
|
5712
5845
|
version: 1 as const,
|
|
5713
5846
|
};
|
|
5714
5847
|
const nonce = xMakeNonce();
|
|
5715
|
-
const cipher = await xSecretboxAsync(
|
|
5848
|
+
const cipher = await xSecretboxAsync(
|
|
5849
|
+
msg,
|
|
5850
|
+
nonce,
|
|
5851
|
+
messageSubkeys.encryptionKey,
|
|
5852
|
+
);
|
|
5716
5853
|
const extra = encodeRatchetHeader(ratchetHeader);
|
|
5717
5854
|
|
|
5718
5855
|
const mail: MailWS = {
|
|
@@ -5737,7 +5874,7 @@ export class Client {
|
|
|
5737
5874
|
type: "resource",
|
|
5738
5875
|
};
|
|
5739
5876
|
|
|
5740
|
-
const hmac = xHMAC(mail,
|
|
5877
|
+
const hmac = xHMAC(mail, messageSubkeys.authenticationKey);
|
|
5741
5878
|
|
|
5742
5879
|
const fwdOut = forward
|
|
5743
5880
|
? messageSchema.parse(msgpack.decode(msg))
|
|
@@ -6089,7 +6226,7 @@ export class Client {
|
|
|
6089
6226
|
const otks: UnsavedPreKey[] = [];
|
|
6090
6227
|
|
|
6091
6228
|
for (let i = 0; i < amount; i++) {
|
|
6092
|
-
otks.push(await this.createPreKey());
|
|
6229
|
+
otks.push(await this.createPreKey("one-time"));
|
|
6093
6230
|
}
|
|
6094
6231
|
|
|
6095
6232
|
const savedKeys = await this.database.savePreKeys(otks, true);
|