@vex-chat/libvex 8.0.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 +34 -21
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +193 -131
- 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 +305 -180
- 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 {
|
|
@@ -1526,6 +1545,7 @@ export class Client {
|
|
|
1526
1545
|
* Helpers for information/actions related to the currently authenticated account.
|
|
1527
1546
|
*/
|
|
1528
1547
|
public me: Me = {
|
|
1548
|
+
changePassword: this.changePassword.bind(this),
|
|
1529
1549
|
/**
|
|
1530
1550
|
* Retrieves current device details.
|
|
1531
1551
|
*
|
|
@@ -1618,6 +1638,7 @@ export class Client {
|
|
|
1618
1638
|
listDevices: this.passkeyListDevices.bind(this),
|
|
1619
1639
|
recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
|
|
1620
1640
|
rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
|
|
1641
|
+
resetPassword: this.resetPasswordWithPasskey.bind(this),
|
|
1621
1642
|
};
|
|
1622
1643
|
|
|
1623
1644
|
/**
|
|
@@ -1730,7 +1751,7 @@ export class Client {
|
|
|
1730
1751
|
|
|
1731
1752
|
private readonly decryptFailureCounts = new Map<string, number>();
|
|
1732
1753
|
|
|
1733
|
-
private device
|
|
1754
|
+
private device: Device | undefined;
|
|
1734
1755
|
|
|
1735
1756
|
private deviceRecords: Record<string, Device> = {};
|
|
1736
1757
|
// ── Event subscription (composition over inheritance) ───────────────
|
|
@@ -1787,7 +1808,7 @@ export class Client {
|
|
|
1787
1808
|
private socket: WebSocketLike;
|
|
1788
1809
|
private token: null | string = null;
|
|
1789
1810
|
|
|
1790
|
-
private user
|
|
1811
|
+
private user: undefined | User;
|
|
1791
1812
|
|
|
1792
1813
|
private userRecords: Record<string, User> = {};
|
|
1793
1814
|
private xKeyRing?: XKeyRing;
|
|
@@ -1914,14 +1935,32 @@ export class Client {
|
|
|
1914
1935
|
|
|
1915
1936
|
let resolvedStorage = storage;
|
|
1916
1937
|
if (!resolvedStorage) {
|
|
1917
|
-
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
|
+
}
|
|
1918
1957
|
const dbFileName = options?.inMemoryDb
|
|
1919
1958
|
? ":memory:"
|
|
1920
1959
|
: XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
|
|
1921
1960
|
const dbPath = options?.dbFolder
|
|
1922
1961
|
? options.dbFolder + "/" + dbFileName
|
|
1923
1962
|
: dbFileName;
|
|
1924
|
-
resolvedStorage = createNodeStorage(dbPath, atRestAes);
|
|
1963
|
+
resolvedStorage = nodeStorage.createNodeStorage(dbPath, atRestAes);
|
|
1925
1964
|
}
|
|
1926
1965
|
|
|
1927
1966
|
await resolvedStorage.init();
|
|
@@ -2292,10 +2331,35 @@ export class Client {
|
|
|
2292
2331
|
}
|
|
2293
2332
|
|
|
2294
2333
|
/**
|
|
2295
|
-
*
|
|
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.
|
|
2296
2337
|
*/
|
|
2297
2338
|
public async logout(): Promise<void> {
|
|
2298
|
-
|
|
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
|
+
}
|
|
2299
2363
|
}
|
|
2300
2364
|
|
|
2301
2365
|
/** Removes an event listener. See {@link ClientEvents} for available events. */
|
|
@@ -2367,8 +2431,8 @@ export class Client {
|
|
|
2367
2431
|
/**
|
|
2368
2432
|
* Registers a new account on the server.
|
|
2369
2433
|
*
|
|
2370
|
-
* @param username -
|
|
2371
|
-
* @param password - Password for
|
|
2434
|
+
* @param username - Username to register.
|
|
2435
|
+
* @param password - Password for the account and device approval request.
|
|
2372
2436
|
* @returns `[user, null]` on success, `[null, error]` on failure.
|
|
2373
2437
|
*
|
|
2374
2438
|
* @example
|
|
@@ -2377,139 +2441,18 @@ export class Client {
|
|
|
2377
2441
|
* ```
|
|
2378
2442
|
*/
|
|
2379
2443
|
public async register(
|
|
2380
|
-
username
|
|
2381
|
-
password
|
|
2444
|
+
username: string,
|
|
2445
|
+
password: string,
|
|
2382
2446
|
): Promise<[null | User, Error | null]> {
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
username?.trim().length !== 0 && username !== undefined
|
|
2393
|
-
? username.trim().toLowerCase()
|
|
2394
|
-
: Client.randomUsername();
|
|
2395
|
-
const resolvedPassword =
|
|
2396
|
-
password?.trim().length !== 0 && password !== undefined
|
|
2397
|
-
? password
|
|
2398
|
-
: undefined;
|
|
2399
|
-
const signKey = XUtils.encodeHex(this.signKeys.publicKey);
|
|
2400
|
-
const signed = XUtils.encodeHex(
|
|
2401
|
-
await xSignAsync(
|
|
2402
|
-
Uint8Array.from(uuid.parse(regKey.key)),
|
|
2403
|
-
this.signKeys.secretKey,
|
|
2404
|
-
),
|
|
2405
|
-
);
|
|
2406
|
-
const preKeyIndex = this.xKeyRing.preKeys.index;
|
|
2407
|
-
const regMsg: RegistrationPayload = {
|
|
2408
|
-
deviceName: this.options?.deviceName ?? "unknown",
|
|
2409
|
-
preKey: XUtils.encodeHex(
|
|
2410
|
-
this.xKeyRing.preKeys.keyPair.publicKey,
|
|
2411
|
-
),
|
|
2412
|
-
preKeyIndex,
|
|
2413
|
-
preKeySignature: XUtils.encodeHex(
|
|
2414
|
-
this.xKeyRing.preKeys.signature,
|
|
2415
|
-
),
|
|
2416
|
-
signed,
|
|
2417
|
-
signKey,
|
|
2418
|
-
username: resolvedUsername,
|
|
2419
|
-
};
|
|
2420
|
-
if (resolvedPassword !== undefined) {
|
|
2421
|
-
regMsg.password = resolvedPassword;
|
|
2422
|
-
}
|
|
2423
|
-
try {
|
|
2424
|
-
const res = await this.http.post(
|
|
2425
|
-
this.getHost() + "/register",
|
|
2426
|
-
msgpack.encode(regMsg),
|
|
2427
|
-
{ headers: { "Content-Type": "application/msgpack" } },
|
|
2428
|
-
);
|
|
2429
|
-
|
|
2430
|
-
// New key-cluster server response: { device, token, user }.
|
|
2431
|
-
// Legacy response (still deployed in some environments): user only.
|
|
2432
|
-
let didDecodeRegisterResponse = false;
|
|
2433
|
-
let pendingApproval: null | PendingDeviceRegistration = null;
|
|
2434
|
-
try {
|
|
2435
|
-
const { device, token, user } = decodeHttpResponse(
|
|
2436
|
-
RegisterResponseCodec,
|
|
2437
|
-
res.data,
|
|
2438
|
-
);
|
|
2439
|
-
this.device = device;
|
|
2440
|
-
this.setUser(user);
|
|
2441
|
-
this.token = token;
|
|
2442
|
-
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
2443
|
-
didDecodeRegisterResponse = true;
|
|
2444
|
-
} catch {
|
|
2445
|
-
// fall through to legacy decode path
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2448
|
-
if (!didDecodeRegisterResponse) {
|
|
2449
|
-
try {
|
|
2450
|
-
pendingApproval = decodeHttpResponse(
|
|
2451
|
-
RegisterPendingApprovalCodec,
|
|
2452
|
-
res.data,
|
|
2453
|
-
);
|
|
2454
|
-
} catch {
|
|
2455
|
-
// fall through to legacy decode path
|
|
2456
|
-
}
|
|
2457
|
-
}
|
|
2458
|
-
|
|
2459
|
-
if (!didDecodeRegisterResponse) {
|
|
2460
|
-
if (pendingApproval !== null) {
|
|
2461
|
-
return [
|
|
2462
|
-
null,
|
|
2463
|
-
new DeviceApprovalRequiredError({
|
|
2464
|
-
challenge: pendingApproval.challenge,
|
|
2465
|
-
expiresAt: pendingApproval.expiresAt,
|
|
2466
|
-
requestID: pendingApproval.requestID,
|
|
2467
|
-
userID: pendingApproval.userID ?? null,
|
|
2468
|
-
}),
|
|
2469
|
-
];
|
|
2470
|
-
}
|
|
2471
|
-
const legacyUser = decodeHttpResponse(UserCodec, res.data);
|
|
2472
|
-
this.setUser(legacyUser);
|
|
2473
|
-
|
|
2474
|
-
// Legacy servers require /auth after /register to get a JWT.
|
|
2475
|
-
if (resolvedPassword === undefined) {
|
|
2476
|
-
return [
|
|
2477
|
-
null,
|
|
2478
|
-
new Error(
|
|
2479
|
-
"Legacy register succeeded without a password, so the SDK could not complete login.",
|
|
2480
|
-
),
|
|
2481
|
-
];
|
|
2482
|
-
}
|
|
2483
|
-
const loginResult = await this.login(
|
|
2484
|
-
resolvedUsername,
|
|
2485
|
-
resolvedPassword,
|
|
2486
|
-
);
|
|
2487
|
-
if (!loginResult.ok) {
|
|
2488
|
-
return [
|
|
2489
|
-
null,
|
|
2490
|
-
new Error(
|
|
2491
|
-
loginResult.error ??
|
|
2492
|
-
"Legacy register succeeded but login failed.",
|
|
2493
|
-
),
|
|
2494
|
-
];
|
|
2495
|
-
}
|
|
2496
|
-
}
|
|
2497
|
-
return [this.getUser(), null];
|
|
2498
|
-
} catch (err: unknown) {
|
|
2499
|
-
if (isHttpError(err) && err.response) {
|
|
2500
|
-
return [
|
|
2501
|
-
null,
|
|
2502
|
-
new Error(spireErrorBodyMessage(err.response.data)),
|
|
2503
|
-
];
|
|
2504
|
-
}
|
|
2505
|
-
return [
|
|
2506
|
-
null,
|
|
2507
|
-
err instanceof Error ? err : new Error(String(err)),
|
|
2508
|
-
];
|
|
2509
|
-
}
|
|
2510
|
-
} else {
|
|
2511
|
-
return [null, new Error("Couldn't get regkey from server.")];
|
|
2512
|
-
}
|
|
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
|
+
);
|
|
2513
2456
|
}
|
|
2514
2457
|
|
|
2515
2458
|
removeAllListeners(event?: keyof ClientEvents): this {
|
|
@@ -2517,6 +2460,36 @@ export class Client {
|
|
|
2517
2460
|
return this;
|
|
2518
2461
|
}
|
|
2519
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
|
+
|
|
2520
2493
|
/**
|
|
2521
2494
|
* Updates the local retention cap (1–30 days) and prunes immediately.
|
|
2522
2495
|
* Does not affect server-side storage.
|
|
@@ -2737,6 +2710,21 @@ export class Client {
|
|
|
2737
2710
|
};
|
|
2738
2711
|
}
|
|
2739
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
|
+
|
|
2740
2728
|
private async createChannel(
|
|
2741
2729
|
name: string,
|
|
2742
2730
|
serverID: string,
|
|
@@ -2848,13 +2836,16 @@ export class Client {
|
|
|
2848
2836
|
return decodeHttpResponse(InviteCodec, res.data);
|
|
2849
2837
|
}
|
|
2850
2838
|
|
|
2851
|
-
private async createPreKey(
|
|
2839
|
+
private async createPreKey(
|
|
2840
|
+
kind: "one-time" | "signed",
|
|
2841
|
+
): Promise<UnsavedPreKey> {
|
|
2852
2842
|
return this.runWithThisCryptoProfile(async () => {
|
|
2853
2843
|
const preKeyPair = await xBoxKeyPairAsync();
|
|
2854
|
-
const toSign =
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2844
|
+
const toSign = xPreKeySignaturePayload(
|
|
2845
|
+
preKeyPair.publicKey,
|
|
2846
|
+
kind,
|
|
2847
|
+
this.cryptoProfile,
|
|
2848
|
+
);
|
|
2858
2849
|
return {
|
|
2859
2850
|
keyPair: preKeyPair,
|
|
2860
2851
|
signature: await xSignAsync(toSign, this.signKeys.secretKey),
|
|
@@ -2959,6 +2950,7 @@ export class Client {
|
|
|
2959
2950
|
|
|
2960
2951
|
// shared secret key
|
|
2961
2952
|
const SK = xKDF(IKM);
|
|
2953
|
+
const initialSubkeys = xMessageKeySubkeys(SK);
|
|
2962
2954
|
const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
|
|
2963
2955
|
|
|
2964
2956
|
const AD = fips
|
|
@@ -2972,7 +2964,11 @@ export class Client {
|
|
|
2972
2964
|
);
|
|
2973
2965
|
|
|
2974
2966
|
const nonce = xMakeNonce();
|
|
2975
|
-
const cipher = await xSecretboxAsync(
|
|
2967
|
+
const cipher = await xSecretboxAsync(
|
|
2968
|
+
message,
|
|
2969
|
+
nonce,
|
|
2970
|
+
initialSubkeys.encryptionKey,
|
|
2971
|
+
);
|
|
2976
2972
|
|
|
2977
2973
|
const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
|
|
2978
2974
|
const ephKeyWire = ephemeralKeys.publicKey;
|
|
@@ -3001,7 +2997,7 @@ export class Client {
|
|
|
3001
2997
|
sender: this.getDevice().deviceID,
|
|
3002
2998
|
};
|
|
3003
2999
|
|
|
3004
|
-
const hmac = xHMAC(mail,
|
|
3000
|
+
const hmac = xHMAC(mail, initialSubkeys.authenticationKey);
|
|
3005
3001
|
|
|
3006
3002
|
const msg: ResourceMsg = {
|
|
3007
3003
|
action: "CREATE",
|
|
@@ -3190,6 +3186,7 @@ export class Client {
|
|
|
3190
3186
|
return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
|
|
3191
3187
|
.calls;
|
|
3192
3188
|
}
|
|
3189
|
+
|
|
3193
3190
|
private async fetchIceServers(): Promise<IceServerConfig[]> {
|
|
3194
3191
|
const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
|
|
3195
3192
|
responseType: "json",
|
|
@@ -3258,7 +3255,6 @@ export class Client {
|
|
|
3258
3255
|
return [null, isHttpError(err) ? err : null];
|
|
3259
3256
|
}
|
|
3260
3257
|
}
|
|
3261
|
-
|
|
3262
3258
|
private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
|
|
3263
3259
|
if (this.isManualCloseInFlight()) {
|
|
3264
3260
|
return [];
|
|
@@ -3657,6 +3653,9 @@ export class Client {
|
|
|
3657
3653
|
this.getDevice().deviceID +
|
|
3658
3654
|
"/mail",
|
|
3659
3655
|
);
|
|
3656
|
+
if (!this.token || this.isManualCloseInFlight()) {
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3660
3659
|
const mailBuffer = new Uint8Array(res.data);
|
|
3661
3660
|
const rawInbox = z
|
|
3662
3661
|
.array(mailInboxEntry)
|
|
@@ -4055,10 +4054,11 @@ export class Client {
|
|
|
4055
4054
|
preKey: PreKeysCrypto,
|
|
4056
4055
|
): Promise<boolean> {
|
|
4057
4056
|
return this.runWithThisCryptoProfile(async () => {
|
|
4058
|
-
const payload =
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4057
|
+
const payload = xPreKeySignaturePayload(
|
|
4058
|
+
preKey.keyPair.publicKey,
|
|
4059
|
+
"signed",
|
|
4060
|
+
this.cryptoProfile,
|
|
4061
|
+
);
|
|
4062
4062
|
const opened = await xSignOpenAsync(
|
|
4063
4063
|
preKey.signature,
|
|
4064
4064
|
this.signKeys.publicKey,
|
|
@@ -4286,7 +4286,7 @@ export class Client {
|
|
|
4286
4286
|
!preKeys ||
|
|
4287
4287
|
!(await this.isPreKeySignedByCurrentDevice(preKeys))
|
|
4288
4288
|
) {
|
|
4289
|
-
const unsaved = await this.createPreKey();
|
|
4289
|
+
const unsaved = await this.createPreKey("signed");
|
|
4290
4290
|
const [saved] = await this.database.savePreKeys(
|
|
4291
4291
|
[unsaved],
|
|
4292
4292
|
false,
|
|
@@ -4648,10 +4648,14 @@ export class Client {
|
|
|
4648
4648
|
|
|
4649
4649
|
// shared secret key
|
|
4650
4650
|
const SK = xKDF(IKM);
|
|
4651
|
+
const initialSubkeys = xMessageKeySubkeys(SK);
|
|
4651
4652
|
const PK = (await xBoxKeyPairFromSecretAsync(SK))
|
|
4652
4653
|
.publicKey;
|
|
4653
4654
|
|
|
4654
|
-
const hmac = xHMAC(
|
|
4655
|
+
const hmac = xHMAC(
|
|
4656
|
+
mail,
|
|
4657
|
+
initialSubkeys.authenticationKey,
|
|
4658
|
+
);
|
|
4655
4659
|
|
|
4656
4660
|
// associated data
|
|
4657
4661
|
const AD = fipsRead
|
|
@@ -4701,7 +4705,7 @@ export class Client {
|
|
|
4701
4705
|
const unsealed = await xSecretboxOpenAsync(
|
|
4702
4706
|
new Uint8Array(mail.cipher),
|
|
4703
4707
|
new Uint8Array(mail.nonce),
|
|
4704
|
-
|
|
4708
|
+
initialSubkeys.encryptionKey,
|
|
4705
4709
|
);
|
|
4706
4710
|
if (unsealed) {
|
|
4707
4711
|
let plaintext = "";
|
|
@@ -4918,12 +4922,16 @@ export class Client {
|
|
|
4918
4922
|
);
|
|
4919
4923
|
}
|
|
4920
4924
|
|
|
4921
|
-
|
|
4925
|
+
const messageKey = takeReceiveMessageKey(
|
|
4922
4926
|
candidateSession,
|
|
4923
4927
|
ratchetHeader.dhPub,
|
|
4924
4928
|
ratchetHeader.n,
|
|
4925
4929
|
);
|
|
4926
|
-
let
|
|
4930
|
+
let messageSubkeys = xMessageKeySubkeys(messageKey);
|
|
4931
|
+
let HMAC = xHMAC(
|
|
4932
|
+
mail,
|
|
4933
|
+
messageSubkeys.authenticationKey,
|
|
4934
|
+
);
|
|
4927
4935
|
|
|
4928
4936
|
if (
|
|
4929
4937
|
!XUtils.bytesEqual(HMAC, header) &&
|
|
@@ -4942,9 +4950,11 @@ export class Client {
|
|
|
4942
4950
|
ratchetHeader.dhPub,
|
|
4943
4951
|
ratchetHeader.n,
|
|
4944
4952
|
);
|
|
4953
|
+
const ratchetedSubkeys =
|
|
4954
|
+
xMessageKeySubkeys(ratchetedMessageKey);
|
|
4945
4955
|
const ratchetedHMAC = xHMAC(
|
|
4946
4956
|
mail,
|
|
4947
|
-
|
|
4957
|
+
ratchetedSubkeys.authenticationKey,
|
|
4948
4958
|
);
|
|
4949
4959
|
if (XUtils.bytesEqual(ratchetedHMAC, header)) {
|
|
4950
4960
|
if (libvexDebugDmEnabled()) {
|
|
@@ -4958,7 +4968,7 @@ export class Client {
|
|
|
4958
4968
|
);
|
|
4959
4969
|
}
|
|
4960
4970
|
candidateSession = ratchetedCandidate;
|
|
4961
|
-
|
|
4971
|
+
messageSubkeys = ratchetedSubkeys;
|
|
4962
4972
|
HMAC = ratchetedHMAC;
|
|
4963
4973
|
}
|
|
4964
4974
|
}
|
|
@@ -4999,7 +5009,7 @@ export class Client {
|
|
|
4999
5009
|
const decrypted = await xSecretboxOpenAsync(
|
|
5000
5010
|
new Uint8Array(mail.cipher),
|
|
5001
5011
|
new Uint8Array(mail.nonce),
|
|
5002
|
-
|
|
5012
|
+
messageSubkeys.encryptionKey,
|
|
5003
5013
|
);
|
|
5004
5014
|
|
|
5005
5015
|
if (decrypted) {
|
|
@@ -5144,6 +5154,99 @@ export class Client {
|
|
|
5144
5154
|
return decodeHttpResponse(PermissionCodec, res.data);
|
|
5145
5155
|
}
|
|
5146
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
|
+
|
|
5147
5250
|
private registerDecryptFailure(mail: MailWS): number {
|
|
5148
5251
|
const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
|
|
5149
5252
|
this.decryptFailureCounts.set(mail.mailID, count);
|
|
@@ -5211,6 +5314,23 @@ export class Client {
|
|
|
5211
5314
|
);
|
|
5212
5315
|
}
|
|
5213
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
|
+
|
|
5214
5334
|
private async respond(msg: ChallMsg) {
|
|
5215
5335
|
const response: RespMsg = {
|
|
5216
5336
|
signed: await xSignAsync(
|
|
@@ -5322,7 +5442,7 @@ export class Client {
|
|
|
5322
5442
|
challenge: newDevice.challenge,
|
|
5323
5443
|
expiresAt: newDevice.expiresAt,
|
|
5324
5444
|
requestID: newDevice.requestID,
|
|
5325
|
-
userID: newDevice.userID
|
|
5445
|
+
userID: newDevice.userID,
|
|
5326
5446
|
});
|
|
5327
5447
|
} else {
|
|
5328
5448
|
throw new Error("Error registering device.");
|
|
@@ -5717,6 +5837,7 @@ export class Client {
|
|
|
5717
5837
|
await ratchetStepSend(session);
|
|
5718
5838
|
}
|
|
5719
5839
|
const { messageKey, n } = takeSendMessageKey(session);
|
|
5840
|
+
const messageSubkeys = xMessageKeySubkeys(messageKey);
|
|
5720
5841
|
const ratchetHeader = {
|
|
5721
5842
|
dhPub: session.DHsPublic,
|
|
5722
5843
|
n,
|
|
@@ -5724,7 +5845,11 @@ export class Client {
|
|
|
5724
5845
|
version: 1 as const,
|
|
5725
5846
|
};
|
|
5726
5847
|
const nonce = xMakeNonce();
|
|
5727
|
-
const cipher = await xSecretboxAsync(
|
|
5848
|
+
const cipher = await xSecretboxAsync(
|
|
5849
|
+
msg,
|
|
5850
|
+
nonce,
|
|
5851
|
+
messageSubkeys.encryptionKey,
|
|
5852
|
+
);
|
|
5728
5853
|
const extra = encodeRatchetHeader(ratchetHeader);
|
|
5729
5854
|
|
|
5730
5855
|
const mail: MailWS = {
|
|
@@ -5749,7 +5874,7 @@ export class Client {
|
|
|
5749
5874
|
type: "resource",
|
|
5750
5875
|
};
|
|
5751
5876
|
|
|
5752
|
-
const hmac = xHMAC(mail,
|
|
5877
|
+
const hmac = xHMAC(mail, messageSubkeys.authenticationKey);
|
|
5753
5878
|
|
|
5754
5879
|
const fwdOut = forward
|
|
5755
5880
|
? messageSchema.parse(msgpack.decode(msg))
|
|
@@ -6101,7 +6226,7 @@ export class Client {
|
|
|
6101
6226
|
const otks: UnsavedPreKey[] = [];
|
|
6102
6227
|
|
|
6103
6228
|
for (let i = 0; i < amount; i++) {
|
|
6104
|
-
otks.push(await this.createPreKey());
|
|
6229
|
+
otks.push(await this.createPreKey("one-time"));
|
|
6105
6230
|
}
|
|
6106
6231
|
|
|
6107
6232
|
const savedKeys = await this.database.savePreKeys(otks, true);
|