@vex-chat/libvex 8.0.0 → 9.1.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 +54 -21
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +292 -164
- 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 +139 -113
- 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 +476 -219
- package/src/__tests__/harness/shared-suite.ts +110 -11
- package/src/__tests__/http.test.ts +22 -0
- package/src/__tests__/multi-device-delivery.test.ts +147 -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 +117 -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 +162 -139
- 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
|
}
|
|
@@ -435,6 +455,8 @@ export interface Channels {
|
|
|
435
455
|
retrieve: (serverID: string) => Promise<Channel[]>;
|
|
436
456
|
/** Gets one channel by ID. */
|
|
437
457
|
retrieveByID: (channelID: string) => Promise<Channel | null>;
|
|
458
|
+
/** Changes a channel's display name. */
|
|
459
|
+
update: (channelID: string, name: string) => Promise<Channel>;
|
|
438
460
|
/** Lists users currently visible in a channel. */
|
|
439
461
|
userList: (channelID: string) => Promise<User[]>;
|
|
440
462
|
}
|
|
@@ -504,9 +526,8 @@ export interface Devices {
|
|
|
504
526
|
}) => Promise<void>;
|
|
505
527
|
/**
|
|
506
528
|
* Approves a pending device registration request as the current device.
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
* approving device.
|
|
529
|
+
* The server verifies a signature from this approved device before
|
|
530
|
+
* admitting the pending device into the account cluster.
|
|
510
531
|
*/
|
|
511
532
|
approveRequest: (requestID: string) => Promise<Device>;
|
|
512
533
|
/**
|
|
@@ -547,10 +568,8 @@ export interface Devices {
|
|
|
547
568
|
* the request's private signing key by signing the challenge issued by
|
|
548
569
|
* the server in the original 202 response.
|
|
549
570
|
*
|
|
550
|
-
* @param args
|
|
551
|
-
*
|
|
552
|
-
* @param args.challenge - The hex challenge issued in the same 202
|
|
553
|
-
* response (or {@link DeviceApprovalRequiredError.challenge}).
|
|
571
|
+
* @param args - The request ID and hex challenge returned by `/register`
|
|
572
|
+
* (or carried by {@link DeviceApprovalRequiredError}).
|
|
554
573
|
* @returns The current {@link PendingDeviceRequest} or `null` if the
|
|
555
574
|
* server no longer has a record of it.
|
|
556
575
|
*/
|
|
@@ -702,6 +721,11 @@ export interface Keys {
|
|
|
702
721
|
* @ignore
|
|
703
722
|
*/
|
|
704
723
|
export interface Me {
|
|
724
|
+
/** Replace the account password after proving the current password. */
|
|
725
|
+
changePassword: (
|
|
726
|
+
currentPassword: string,
|
|
727
|
+
newPassword: string,
|
|
728
|
+
) => Promise<void>;
|
|
705
729
|
/** Returns metadata for the currently authenticated device. */
|
|
706
730
|
device: () => Device;
|
|
707
731
|
/** Uploads and sets a new avatar image for the current user. */
|
|
@@ -831,7 +855,7 @@ export interface Passkeys {
|
|
|
831
855
|
options: any;
|
|
832
856
|
requestID: string;
|
|
833
857
|
}>;
|
|
834
|
-
/** Remove a passkey
|
|
858
|
+
/** Remove a passkey using the account's approved-device session. */
|
|
835
859
|
delete: (passkeyID: string) => Promise<void>;
|
|
836
860
|
/** Delete one of the account's devices using the passkey session. */
|
|
837
861
|
deleteDevice: (deviceID: string) => Promise<void>;
|
|
@@ -868,6 +892,8 @@ export interface Passkeys {
|
|
|
868
892
|
recoverDeviceRequest: (requestID: string) => Promise<Device>;
|
|
869
893
|
/** Reject a pending device-enrollment request using the passkey session. */
|
|
870
894
|
rejectDeviceRequest: (requestID: string) => Promise<void>;
|
|
895
|
+
/** Replace a forgotten password using this fresh passkey session. */
|
|
896
|
+
resetPassword: (newPassword: string) => Promise<void>;
|
|
871
897
|
}
|
|
872
898
|
|
|
873
899
|
export type PendingDeviceApprovalStatus =
|
|
@@ -881,13 +907,8 @@ export interface PendingDeviceRegistration {
|
|
|
881
907
|
expiresAt: string;
|
|
882
908
|
requestID: string;
|
|
883
909
|
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;
|
|
910
|
+
/** Existing user's ID used to render the account confirmation step. */
|
|
911
|
+
userID: string;
|
|
891
912
|
}
|
|
892
913
|
|
|
893
914
|
export interface PendingDeviceRequest {
|
|
@@ -1110,6 +1131,7 @@ const retryRequestNotifyData = z.union([
|
|
|
1110
1131
|
mailID: z.string(),
|
|
1111
1132
|
}),
|
|
1112
1133
|
]);
|
|
1134
|
+
const serverChangeNotifyData = z.string().min(1).max(128);
|
|
1113
1135
|
|
|
1114
1136
|
/**
|
|
1115
1137
|
* Event signatures emitted by {@link Client}.
|
|
@@ -1146,6 +1168,8 @@ export interface ClientEvents {
|
|
|
1146
1168
|
ready: () => void;
|
|
1147
1169
|
/** Session healing requested a retry for a specific mail ID. */
|
|
1148
1170
|
retryRequest: (retry: RetryRequest) => void;
|
|
1171
|
+
/** Server metadata, channels, membership, or permissions changed. */
|
|
1172
|
+
serverChange: (serverID: string) => void;
|
|
1149
1173
|
/** A new encryption session was established with a peer device. */
|
|
1150
1174
|
session: (session: Session, user: User) => void;
|
|
1151
1175
|
}
|
|
@@ -1191,6 +1215,11 @@ export interface Moderation {
|
|
|
1191
1215
|
fetchPermissionList: (serverID: string) => Promise<Permission[]>;
|
|
1192
1216
|
/** Removes a user from a server by revoking their server permission(s). */
|
|
1193
1217
|
kick: (userID: string, serverID: string) => Promise<void>;
|
|
1218
|
+
/** Changes a server member's role. */
|
|
1219
|
+
setRole: (
|
|
1220
|
+
permissionID: string,
|
|
1221
|
+
powerLevel: 0 | 50 | 100,
|
|
1222
|
+
) => Promise<Permission>;
|
|
1194
1223
|
}
|
|
1195
1224
|
|
|
1196
1225
|
/**
|
|
@@ -1211,14 +1240,22 @@ export interface Servers {
|
|
|
1211
1240
|
create: (name: string) => Promise<Server>;
|
|
1212
1241
|
/** Deletes a server. */
|
|
1213
1242
|
delete: (serverID: string) => Promise<void>;
|
|
1243
|
+
/** Returns the public URL for an immutable server icon ID. */
|
|
1244
|
+
iconURL: (iconID: string) => string;
|
|
1214
1245
|
/** Leaves a server by removing the user's permission entry. */
|
|
1215
1246
|
leave: (serverID: string) => Promise<void>;
|
|
1247
|
+
/** Removes a server's icon. */
|
|
1248
|
+
removeIcon: (serverID: string) => Promise<Server>;
|
|
1216
1249
|
/** Lists servers available to the authenticated user. */
|
|
1217
1250
|
retrieve: () => Promise<Server[]>;
|
|
1218
1251
|
/** Gets one server by ID. */
|
|
1219
1252
|
retrieveByID: (serverID: string) => Promise<null | Server>;
|
|
1220
1253
|
/** Fetches servers and channels in one request for fast bootstraps. */
|
|
1221
1254
|
retrieveWithChannels: () => Promise<ServerChannelBootstrap>;
|
|
1255
|
+
/** Uploads and sets a server icon. */
|
|
1256
|
+
setIcon: (serverID: string, icon: Uint8Array) => Promise<Server>;
|
|
1257
|
+
/** Changes a server's display name. */
|
|
1258
|
+
update: (serverID: string, name: string) => Promise<Server>;
|
|
1222
1259
|
}
|
|
1223
1260
|
|
|
1224
1261
|
/**
|
|
@@ -1439,6 +1476,8 @@ export class Client {
|
|
|
1439
1476
|
* @returns The Channel object, or null.
|
|
1440
1477
|
*/
|
|
1441
1478
|
retrieveByID: this.getChannelByID.bind(this),
|
|
1479
|
+
/** Changes a channel's display name. */
|
|
1480
|
+
update: this.updateChannel.bind(this),
|
|
1442
1481
|
/**
|
|
1443
1482
|
* Retrieves a channel's userlist.
|
|
1444
1483
|
* @param channelID - The channel to retrieve the userlist for.
|
|
@@ -1526,6 +1565,7 @@ export class Client {
|
|
|
1526
1565
|
* Helpers for information/actions related to the currently authenticated account.
|
|
1527
1566
|
*/
|
|
1528
1567
|
public me: Me = {
|
|
1568
|
+
changePassword: this.changePassword.bind(this),
|
|
1529
1569
|
/**
|
|
1530
1570
|
* Retrieves current device details.
|
|
1531
1571
|
*
|
|
@@ -1593,6 +1633,7 @@ export class Client {
|
|
|
1593
1633
|
public moderation: Moderation = {
|
|
1594
1634
|
fetchPermissionList: this.fetchPermissionList.bind(this),
|
|
1595
1635
|
kick: this.kickUser.bind(this),
|
|
1636
|
+
setRole: this.setServerMemberRole.bind(this),
|
|
1596
1637
|
};
|
|
1597
1638
|
|
|
1598
1639
|
/**
|
|
@@ -1618,6 +1659,7 @@ export class Client {
|
|
|
1618
1659
|
listDevices: this.passkeyListDevices.bind(this),
|
|
1619
1660
|
recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
|
|
1620
1661
|
rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
|
|
1662
|
+
resetPassword: this.resetPasswordWithPasskey.bind(this),
|
|
1621
1663
|
};
|
|
1622
1664
|
|
|
1623
1665
|
/**
|
|
@@ -1652,7 +1694,9 @@ export class Client {
|
|
|
1652
1694
|
* @param serverID - The server to delete.
|
|
1653
1695
|
*/
|
|
1654
1696
|
delete: this.deleteServer.bind(this),
|
|
1697
|
+
iconURL: this.getServerIconURL.bind(this),
|
|
1655
1698
|
leave: this.leaveServer.bind(this),
|
|
1699
|
+
removeIcon: this.removeServerIcon.bind(this),
|
|
1656
1700
|
/**
|
|
1657
1701
|
* Retrieves all servers the logged in user has access to.
|
|
1658
1702
|
*
|
|
@@ -1666,6 +1710,8 @@ export class Client {
|
|
|
1666
1710
|
*/
|
|
1667
1711
|
retrieveByID: this.getServerByID.bind(this),
|
|
1668
1712
|
retrieveWithChannels: this.getServerChannelBootstrap.bind(this),
|
|
1713
|
+
setIcon: this.uploadServerIcon.bind(this),
|
|
1714
|
+
update: this.updateServer.bind(this),
|
|
1669
1715
|
};
|
|
1670
1716
|
|
|
1671
1717
|
/**
|
|
@@ -1730,7 +1776,7 @@ export class Client {
|
|
|
1730
1776
|
|
|
1731
1777
|
private readonly decryptFailureCounts = new Map<string, number>();
|
|
1732
1778
|
|
|
1733
|
-
private device
|
|
1779
|
+
private device: Device | undefined;
|
|
1734
1780
|
|
|
1735
1781
|
private deviceRecords: Record<string, Device> = {};
|
|
1736
1782
|
// ── Event subscription (composition over inheritance) ───────────────
|
|
@@ -1787,7 +1833,7 @@ export class Client {
|
|
|
1787
1833
|
private socket: WebSocketLike;
|
|
1788
1834
|
private token: null | string = null;
|
|
1789
1835
|
|
|
1790
|
-
private user
|
|
1836
|
+
private user: undefined | User;
|
|
1791
1837
|
|
|
1792
1838
|
private userRecords: Record<string, User> = {};
|
|
1793
1839
|
private xKeyRing?: XKeyRing;
|
|
@@ -1914,14 +1960,32 @@ export class Client {
|
|
|
1914
1960
|
|
|
1915
1961
|
let resolvedStorage = storage;
|
|
1916
1962
|
if (!resolvedStorage) {
|
|
1917
|
-
const
|
|
1963
|
+
const nodeStorageModule = "./storage/node.js";
|
|
1964
|
+
const isNodeStorageModule = (
|
|
1965
|
+
value: unknown,
|
|
1966
|
+
): value is {
|
|
1967
|
+
createNodeStorage: (
|
|
1968
|
+
dbPath: string,
|
|
1969
|
+
atRestAesKey: Uint8Array,
|
|
1970
|
+
) => Storage;
|
|
1971
|
+
} =>
|
|
1972
|
+
typeof value === "object" &&
|
|
1973
|
+
value !== null &&
|
|
1974
|
+
"createNodeStorage" in value &&
|
|
1975
|
+
typeof value.createNodeStorage === "function";
|
|
1976
|
+
const nodeStorage: unknown = await import(
|
|
1977
|
+
/* @vite-ignore */ nodeStorageModule
|
|
1978
|
+
);
|
|
1979
|
+
if (!isNodeStorageModule(nodeStorage)) {
|
|
1980
|
+
throw new Error("Node storage adapter is unavailable.");
|
|
1981
|
+
}
|
|
1918
1982
|
const dbFileName = options?.inMemoryDb
|
|
1919
1983
|
? ":memory:"
|
|
1920
1984
|
: XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
|
|
1921
1985
|
const dbPath = options?.dbFolder
|
|
1922
1986
|
? options.dbFolder + "/" + dbFileName
|
|
1923
1987
|
: dbFileName;
|
|
1924
|
-
resolvedStorage = createNodeStorage(dbPath, atRestAes);
|
|
1988
|
+
resolvedStorage = nodeStorage.createNodeStorage(dbPath, atRestAes);
|
|
1925
1989
|
}
|
|
1926
1990
|
|
|
1927
1991
|
await resolvedStorage.init();
|
|
@@ -2292,10 +2356,35 @@ export class Client {
|
|
|
2292
2356
|
}
|
|
2293
2357
|
|
|
2294
2358
|
/**
|
|
2295
|
-
*
|
|
2359
|
+
* Ends the local authenticated session and disconnects realtime transport.
|
|
2360
|
+
* Spire access tokens are stateless, so local credential removal is the
|
|
2361
|
+
* authoritative logout boundary.
|
|
2296
2362
|
*/
|
|
2297
2363
|
public async logout(): Promise<void> {
|
|
2298
|
-
|
|
2364
|
+
try {
|
|
2365
|
+
if (this.token) {
|
|
2366
|
+
await this.http.post(this.getHost() + "/goodbye");
|
|
2367
|
+
}
|
|
2368
|
+
} catch {
|
|
2369
|
+
// Logout must still complete locally when the server is unreachable.
|
|
2370
|
+
} finally {
|
|
2371
|
+
this.token = null;
|
|
2372
|
+
this.user = undefined;
|
|
2373
|
+
this.device = undefined;
|
|
2374
|
+
delete this.http.defaults.headers.common.Authorization;
|
|
2375
|
+
delete this.http.defaults.headers.common["X-Device-Token"];
|
|
2376
|
+
this.autoReconnectEnabled = false;
|
|
2377
|
+
this.postAuthVersion++;
|
|
2378
|
+
if (this.reconnectTimer) {
|
|
2379
|
+
clearTimeout(this.reconnectTimer);
|
|
2380
|
+
this.reconnectTimer = null;
|
|
2381
|
+
}
|
|
2382
|
+
if (this.pingInterval) {
|
|
2383
|
+
clearInterval(this.pingInterval);
|
|
2384
|
+
this.pingInterval = null;
|
|
2385
|
+
}
|
|
2386
|
+
this.socket.close();
|
|
2387
|
+
}
|
|
2299
2388
|
}
|
|
2300
2389
|
|
|
2301
2390
|
/** Removes an event listener. See {@link ClientEvents} for available events. */
|
|
@@ -2367,8 +2456,8 @@ export class Client {
|
|
|
2367
2456
|
/**
|
|
2368
2457
|
* Registers a new account on the server.
|
|
2369
2458
|
*
|
|
2370
|
-
* @param username -
|
|
2371
|
-
* @param password - Password for
|
|
2459
|
+
* @param username - Username to register.
|
|
2460
|
+
* @param password - Password for the account and device approval request.
|
|
2372
2461
|
* @returns `[user, null]` on success, `[null, error]` on failure.
|
|
2373
2462
|
*
|
|
2374
2463
|
* @example
|
|
@@ -2377,139 +2466,18 @@ export class Client {
|
|
|
2377
2466
|
* ```
|
|
2378
2467
|
*/
|
|
2379
2468
|
public async register(
|
|
2380
|
-
username
|
|
2381
|
-
password
|
|
2469
|
+
username: string,
|
|
2470
|
+
password: string,
|
|
2382
2471
|
): 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
|
-
}
|
|
2472
|
+
const passwordError = registrationPasswordError(password);
|
|
2473
|
+
if (passwordError) {
|
|
2474
|
+
return [null, passwordError];
|
|
2475
|
+
}
|
|
2476
|
+
return this.registerAccountOrEnrollment(
|
|
2477
|
+
username,
|
|
2478
|
+
"create-account",
|
|
2479
|
+
password,
|
|
2480
|
+
);
|
|
2513
2481
|
}
|
|
2514
2482
|
|
|
2515
2483
|
removeAllListeners(event?: keyof ClientEvents): this {
|
|
@@ -2517,6 +2485,36 @@ export class Client {
|
|
|
2517
2485
|
return this;
|
|
2518
2486
|
}
|
|
2519
2487
|
|
|
2488
|
+
/**
|
|
2489
|
+
* Requests approval for a new device using the account password. This
|
|
2490
|
+
* enrollment flow never creates an account when the username is unknown.
|
|
2491
|
+
*/
|
|
2492
|
+
public async requestDeviceEnrollment(
|
|
2493
|
+
username: string,
|
|
2494
|
+
password: string,
|
|
2495
|
+
): Promise<[null | User, Error | null]> {
|
|
2496
|
+
const passwordError = registrationPasswordError(password);
|
|
2497
|
+
if (passwordError) {
|
|
2498
|
+
return [null, passwordError];
|
|
2499
|
+
}
|
|
2500
|
+
return this.registerAccountOrEnrollment(
|
|
2501
|
+
username,
|
|
2502
|
+
"enroll-device",
|
|
2503
|
+
password,
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/**
|
|
2508
|
+
* Requests enrollment for this device after a successful passkey ceremony.
|
|
2509
|
+
* This is not account registration: the passkey bearer must identify an
|
|
2510
|
+
* existing account, and the server returns a pending device request.
|
|
2511
|
+
*/
|
|
2512
|
+
public async requestDeviceEnrollmentWithPasskey(
|
|
2513
|
+
username: string,
|
|
2514
|
+
): Promise<[null | User, Error | null]> {
|
|
2515
|
+
return this.registerAccountOrEnrollment(username, "enroll-device");
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2520
2518
|
/**
|
|
2521
2519
|
* Updates the local retention cap (1–30 days) and prunes immediately.
|
|
2522
2520
|
* Does not affect server-side storage.
|
|
@@ -2737,6 +2735,21 @@ export class Client {
|
|
|
2737
2735
|
};
|
|
2738
2736
|
}
|
|
2739
2737
|
|
|
2738
|
+
private async changePassword(
|
|
2739
|
+
currentPassword: string,
|
|
2740
|
+
newPassword: string,
|
|
2741
|
+
): Promise<void> {
|
|
2742
|
+
const passwordError = registrationPasswordError(newPassword);
|
|
2743
|
+
if (passwordError) throw passwordError;
|
|
2744
|
+
if (
|
|
2745
|
+
currentPassword.length === 0 ||
|
|
2746
|
+
currentPassword.length > ACCOUNT_PASSWORD_MAX_LENGTH
|
|
2747
|
+
) {
|
|
2748
|
+
throw new Error("Current password is invalid.");
|
|
2749
|
+
}
|
|
2750
|
+
await this.replaceAccountPassword({ currentPassword, newPassword });
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2740
2753
|
private async createChannel(
|
|
2741
2754
|
name: string,
|
|
2742
2755
|
serverID: string,
|
|
@@ -2848,13 +2861,16 @@ export class Client {
|
|
|
2848
2861
|
return decodeHttpResponse(InviteCodec, res.data);
|
|
2849
2862
|
}
|
|
2850
2863
|
|
|
2851
|
-
private async createPreKey(
|
|
2864
|
+
private async createPreKey(
|
|
2865
|
+
kind: "one-time" | "signed",
|
|
2866
|
+
): Promise<UnsavedPreKey> {
|
|
2852
2867
|
return this.runWithThisCryptoProfile(async () => {
|
|
2853
2868
|
const preKeyPair = await xBoxKeyPairAsync();
|
|
2854
|
-
const toSign =
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2869
|
+
const toSign = xPreKeySignaturePayload(
|
|
2870
|
+
preKeyPair.publicKey,
|
|
2871
|
+
kind,
|
|
2872
|
+
this.cryptoProfile,
|
|
2873
|
+
);
|
|
2858
2874
|
return {
|
|
2859
2875
|
keyPair: preKeyPair,
|
|
2860
2876
|
signature: await xSignAsync(toSign, this.signKeys.secretKey),
|
|
@@ -2864,7 +2880,9 @@ export class Client {
|
|
|
2864
2880
|
|
|
2865
2881
|
private async createServer(name: string): Promise<Server> {
|
|
2866
2882
|
const res = await this.http.post(
|
|
2867
|
-
this.getHost() + "/
|
|
2883
|
+
this.getHost() + "/servers",
|
|
2884
|
+
msgpack.encode({ name }),
|
|
2885
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
2868
2886
|
);
|
|
2869
2887
|
return decodeHttpResponse(ServerCodec, res.data);
|
|
2870
2888
|
}
|
|
@@ -2959,6 +2977,7 @@ export class Client {
|
|
|
2959
2977
|
|
|
2960
2978
|
// shared secret key
|
|
2961
2979
|
const SK = xKDF(IKM);
|
|
2980
|
+
const initialSubkeys = xMessageKeySubkeys(SK);
|
|
2962
2981
|
const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
|
|
2963
2982
|
|
|
2964
2983
|
const AD = fips
|
|
@@ -2972,7 +2991,11 @@ export class Client {
|
|
|
2972
2991
|
);
|
|
2973
2992
|
|
|
2974
2993
|
const nonce = xMakeNonce();
|
|
2975
|
-
const cipher = await xSecretboxAsync(
|
|
2994
|
+
const cipher = await xSecretboxAsync(
|
|
2995
|
+
message,
|
|
2996
|
+
nonce,
|
|
2997
|
+
initialSubkeys.encryptionKey,
|
|
2998
|
+
);
|
|
2976
2999
|
|
|
2977
3000
|
const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
|
|
2978
3001
|
const ephKeyWire = ephemeralKeys.publicKey;
|
|
@@ -3001,7 +3024,7 @@ export class Client {
|
|
|
3001
3024
|
sender: this.getDevice().deviceID,
|
|
3002
3025
|
};
|
|
3003
3026
|
|
|
3004
|
-
const hmac = xHMAC(mail,
|
|
3027
|
+
const hmac = xHMAC(mail, initialSubkeys.authenticationKey);
|
|
3005
3028
|
|
|
3006
3029
|
const msg: ResourceMsg = {
|
|
3007
3030
|
action: "CREATE",
|
|
@@ -3190,6 +3213,7 @@ export class Client {
|
|
|
3190
3213
|
return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
|
|
3191
3214
|
.calls;
|
|
3192
3215
|
}
|
|
3216
|
+
|
|
3193
3217
|
private async fetchIceServers(): Promise<IceServerConfig[]> {
|
|
3194
3218
|
const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
|
|
3195
3219
|
responseType: "json",
|
|
@@ -3338,7 +3362,6 @@ export class Client {
|
|
|
3338
3362
|
this.http.defaults.headers.common.Authorization = `Bearer ${decoded.token}`;
|
|
3339
3363
|
return decoded;
|
|
3340
3364
|
}
|
|
3341
|
-
|
|
3342
3365
|
private async finishPasskeyRegistration(args: {
|
|
3343
3366
|
name: string;
|
|
3344
3367
|
requestID: string;
|
|
@@ -3657,6 +3680,9 @@ export class Client {
|
|
|
3657
3680
|
this.getDevice().deviceID +
|
|
3658
3681
|
"/mail",
|
|
3659
3682
|
);
|
|
3683
|
+
if (!this.token || this.isManualCloseInFlight()) {
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3660
3686
|
const mailBuffer = new Uint8Array(res.data);
|
|
3661
3687
|
const rawInbox = z
|
|
3662
3688
|
.array(mailInboxEntry)
|
|
@@ -3779,6 +3805,10 @@ export class Client {
|
|
|
3779
3805
|
return decodeHttpResponse(ServerChannelBootstrapCodec, res.data);
|
|
3780
3806
|
}
|
|
3781
3807
|
|
|
3808
|
+
private getServerIconURL(iconID: string): string {
|
|
3809
|
+
return this.getHost() + "/server-icon/" + encodeURIComponent(iconID);
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3782
3812
|
private async getServerList(): Promise<Server[]> {
|
|
3783
3813
|
const res = await this.http.get(
|
|
3784
3814
|
this.getHost() + "/user/" + this.getUser().userID + "/servers",
|
|
@@ -3895,6 +3925,13 @@ export class Client {
|
|
|
3895
3925
|
}
|
|
3896
3926
|
}
|
|
3897
3927
|
break;
|
|
3928
|
+
case "serverChange": {
|
|
3929
|
+
const parsed = serverChangeNotifyData.safeParse(msg.data);
|
|
3930
|
+
if (parsed.success) {
|
|
3931
|
+
this.emitter.emit("serverChange", parsed.data);
|
|
3932
|
+
}
|
|
3933
|
+
break;
|
|
3934
|
+
}
|
|
3898
3935
|
default:
|
|
3899
3936
|
break;
|
|
3900
3937
|
}
|
|
@@ -4055,10 +4092,11 @@ export class Client {
|
|
|
4055
4092
|
preKey: PreKeysCrypto,
|
|
4056
4093
|
): Promise<boolean> {
|
|
4057
4094
|
return this.runWithThisCryptoProfile(async () => {
|
|
4058
|
-
const payload =
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4095
|
+
const payload = xPreKeySignaturePayload(
|
|
4096
|
+
preKey.keyPair.publicKey,
|
|
4097
|
+
"signed",
|
|
4098
|
+
this.cryptoProfile,
|
|
4099
|
+
);
|
|
4062
4100
|
const opened = await xSignOpenAsync(
|
|
4063
4101
|
preKey.signature,
|
|
4064
4102
|
this.signKeys.publicKey,
|
|
@@ -4286,7 +4324,7 @@ export class Client {
|
|
|
4286
4324
|
!preKeys ||
|
|
4287
4325
|
!(await this.isPreKeySignedByCurrentDevice(preKeys))
|
|
4288
4326
|
) {
|
|
4289
|
-
const unsaved = await this.createPreKey();
|
|
4327
|
+
const unsaved = await this.createPreKey("signed");
|
|
4290
4328
|
const [saved] = await this.database.savePreKeys(
|
|
4291
4329
|
[unsaved],
|
|
4292
4330
|
false,
|
|
@@ -4648,10 +4686,14 @@ export class Client {
|
|
|
4648
4686
|
|
|
4649
4687
|
// shared secret key
|
|
4650
4688
|
const SK = xKDF(IKM);
|
|
4689
|
+
const initialSubkeys = xMessageKeySubkeys(SK);
|
|
4651
4690
|
const PK = (await xBoxKeyPairFromSecretAsync(SK))
|
|
4652
4691
|
.publicKey;
|
|
4653
4692
|
|
|
4654
|
-
const hmac = xHMAC(
|
|
4693
|
+
const hmac = xHMAC(
|
|
4694
|
+
mail,
|
|
4695
|
+
initialSubkeys.authenticationKey,
|
|
4696
|
+
);
|
|
4655
4697
|
|
|
4656
4698
|
// associated data
|
|
4657
4699
|
const AD = fipsRead
|
|
@@ -4701,7 +4743,7 @@ export class Client {
|
|
|
4701
4743
|
const unsealed = await xSecretboxOpenAsync(
|
|
4702
4744
|
new Uint8Array(mail.cipher),
|
|
4703
4745
|
new Uint8Array(mail.nonce),
|
|
4704
|
-
|
|
4746
|
+
initialSubkeys.encryptionKey,
|
|
4705
4747
|
);
|
|
4706
4748
|
if (unsealed) {
|
|
4707
4749
|
let plaintext = "";
|
|
@@ -4918,12 +4960,16 @@ export class Client {
|
|
|
4918
4960
|
);
|
|
4919
4961
|
}
|
|
4920
4962
|
|
|
4921
|
-
|
|
4963
|
+
const messageKey = takeReceiveMessageKey(
|
|
4922
4964
|
candidateSession,
|
|
4923
4965
|
ratchetHeader.dhPub,
|
|
4924
4966
|
ratchetHeader.n,
|
|
4925
4967
|
);
|
|
4926
|
-
let
|
|
4968
|
+
let messageSubkeys = xMessageKeySubkeys(messageKey);
|
|
4969
|
+
let HMAC = xHMAC(
|
|
4970
|
+
mail,
|
|
4971
|
+
messageSubkeys.authenticationKey,
|
|
4972
|
+
);
|
|
4927
4973
|
|
|
4928
4974
|
if (
|
|
4929
4975
|
!XUtils.bytesEqual(HMAC, header) &&
|
|
@@ -4942,9 +4988,11 @@ export class Client {
|
|
|
4942
4988
|
ratchetHeader.dhPub,
|
|
4943
4989
|
ratchetHeader.n,
|
|
4944
4990
|
);
|
|
4991
|
+
const ratchetedSubkeys =
|
|
4992
|
+
xMessageKeySubkeys(ratchetedMessageKey);
|
|
4945
4993
|
const ratchetedHMAC = xHMAC(
|
|
4946
4994
|
mail,
|
|
4947
|
-
|
|
4995
|
+
ratchetedSubkeys.authenticationKey,
|
|
4948
4996
|
);
|
|
4949
4997
|
if (XUtils.bytesEqual(ratchetedHMAC, header)) {
|
|
4950
4998
|
if (libvexDebugDmEnabled()) {
|
|
@@ -4958,7 +5006,7 @@ export class Client {
|
|
|
4958
5006
|
);
|
|
4959
5007
|
}
|
|
4960
5008
|
candidateSession = ratchetedCandidate;
|
|
4961
|
-
|
|
5009
|
+
messageSubkeys = ratchetedSubkeys;
|
|
4962
5010
|
HMAC = ratchetedHMAC;
|
|
4963
5011
|
}
|
|
4964
5012
|
}
|
|
@@ -4999,7 +5047,7 @@ export class Client {
|
|
|
4999
5047
|
const decrypted = await xSecretboxOpenAsync(
|
|
5000
5048
|
new Uint8Array(mail.cipher),
|
|
5001
5049
|
new Uint8Array(mail.nonce),
|
|
5002
|
-
|
|
5050
|
+
messageSubkeys.encryptionKey,
|
|
5003
5051
|
);
|
|
5004
5052
|
|
|
5005
5053
|
if (decrypted) {
|
|
@@ -5144,6 +5192,99 @@ export class Client {
|
|
|
5144
5192
|
return decodeHttpResponse(PermissionCodec, res.data);
|
|
5145
5193
|
}
|
|
5146
5194
|
|
|
5195
|
+
private async registerAccountOrEnrollment(
|
|
5196
|
+
username: string,
|
|
5197
|
+
intent: RegistrationPayload["intent"],
|
|
5198
|
+
password?: string,
|
|
5199
|
+
): Promise<[null | User, Error | null]> {
|
|
5200
|
+
const resolvedUsername = username.trim().toLowerCase();
|
|
5201
|
+
if (!/^\w{3,19}$/.test(resolvedUsername)) {
|
|
5202
|
+
return [
|
|
5203
|
+
null,
|
|
5204
|
+
new Error(
|
|
5205
|
+
"Username must be between three and nineteen letters, digits, or underscores.",
|
|
5206
|
+
),
|
|
5207
|
+
];
|
|
5208
|
+
}
|
|
5209
|
+
while (!this.xKeyRing) {
|
|
5210
|
+
await sleep(100);
|
|
5211
|
+
}
|
|
5212
|
+
const regKey = await this.getToken("register");
|
|
5213
|
+
if (regKey) {
|
|
5214
|
+
const signKey = XUtils.encodeHex(this.signKeys.publicKey);
|
|
5215
|
+
const signed = XUtils.encodeHex(
|
|
5216
|
+
await xSignAsync(
|
|
5217
|
+
Uint8Array.from(uuid.parse(regKey.key)),
|
|
5218
|
+
this.signKeys.secretKey,
|
|
5219
|
+
),
|
|
5220
|
+
);
|
|
5221
|
+
const preKeyIndex = this.xKeyRing.preKeys.index;
|
|
5222
|
+
const regMsg: RegistrationPayload = {
|
|
5223
|
+
deviceName: this.options?.deviceName ?? "unknown",
|
|
5224
|
+
intent,
|
|
5225
|
+
preKey: XUtils.encodeHex(
|
|
5226
|
+
this.xKeyRing.preKeys.keyPair.publicKey,
|
|
5227
|
+
),
|
|
5228
|
+
preKeyIndex,
|
|
5229
|
+
preKeySignature: XUtils.encodeHex(
|
|
5230
|
+
this.xKeyRing.preKeys.signature,
|
|
5231
|
+
),
|
|
5232
|
+
signed,
|
|
5233
|
+
signKey,
|
|
5234
|
+
username: resolvedUsername,
|
|
5235
|
+
};
|
|
5236
|
+
if (password !== undefined) {
|
|
5237
|
+
regMsg.password = password;
|
|
5238
|
+
}
|
|
5239
|
+
try {
|
|
5240
|
+
const res = await this.http.post(
|
|
5241
|
+
this.getHost() + "/register",
|
|
5242
|
+
msgpack.encode(regMsg),
|
|
5243
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
5244
|
+
);
|
|
5245
|
+
|
|
5246
|
+
try {
|
|
5247
|
+
const { device, token, user } = decodeHttpResponse(
|
|
5248
|
+
RegisterResponseCodec,
|
|
5249
|
+
res.data,
|
|
5250
|
+
);
|
|
5251
|
+
this.device = device;
|
|
5252
|
+
this.setUser(user);
|
|
5253
|
+
this.token = token;
|
|
5254
|
+
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
5255
|
+
} catch {
|
|
5256
|
+
const pendingApproval = decodeHttpResponse(
|
|
5257
|
+
RegisterPendingApprovalCodec,
|
|
5258
|
+
res.data,
|
|
5259
|
+
);
|
|
5260
|
+
return [
|
|
5261
|
+
null,
|
|
5262
|
+
new DeviceApprovalRequiredError({
|
|
5263
|
+
challenge: pendingApproval.challenge,
|
|
5264
|
+
expiresAt: pendingApproval.expiresAt,
|
|
5265
|
+
requestID: pendingApproval.requestID,
|
|
5266
|
+
userID: pendingApproval.userID,
|
|
5267
|
+
}),
|
|
5268
|
+
];
|
|
5269
|
+
}
|
|
5270
|
+
return [this.getUser(), null];
|
|
5271
|
+
} catch (err: unknown) {
|
|
5272
|
+
if (isHttpError(err) && err.response) {
|
|
5273
|
+
return [
|
|
5274
|
+
null,
|
|
5275
|
+
new Error(spireErrorBodyMessage(err.response.data)),
|
|
5276
|
+
];
|
|
5277
|
+
}
|
|
5278
|
+
return [
|
|
5279
|
+
null,
|
|
5280
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
5281
|
+
];
|
|
5282
|
+
}
|
|
5283
|
+
} else {
|
|
5284
|
+
return [null, new Error("Couldn't get regkey from server.")];
|
|
5285
|
+
}
|
|
5286
|
+
}
|
|
5287
|
+
|
|
5147
5288
|
private registerDecryptFailure(mail: MailWS): number {
|
|
5148
5289
|
const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
|
|
5149
5290
|
this.decryptFailureCounts.set(mail.mailID, count);
|
|
@@ -5211,6 +5352,30 @@ export class Client {
|
|
|
5211
5352
|
);
|
|
5212
5353
|
}
|
|
5213
5354
|
|
|
5355
|
+
private async removeServerIcon(serverID: string): Promise<Server> {
|
|
5356
|
+
const res = await this.http.delete(
|
|
5357
|
+
this.getHost() + "/server-icon/" + serverID,
|
|
5358
|
+
);
|
|
5359
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
5360
|
+
}
|
|
5361
|
+
|
|
5362
|
+
private async replaceAccountPassword(payload: {
|
|
5363
|
+
currentPassword?: string;
|
|
5364
|
+
newPassword: string;
|
|
5365
|
+
}): Promise<void> {
|
|
5366
|
+
await this.http.patch(
|
|
5367
|
+
this.getHost() + "/user/" + this.getUser().userID + "/password",
|
|
5368
|
+
msgpack.encode(payload),
|
|
5369
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
5370
|
+
);
|
|
5371
|
+
}
|
|
5372
|
+
|
|
5373
|
+
private async resetPasswordWithPasskey(newPassword: string): Promise<void> {
|
|
5374
|
+
const passwordError = registrationPasswordError(newPassword);
|
|
5375
|
+
if (passwordError) throw passwordError;
|
|
5376
|
+
await this.replaceAccountPassword({ newPassword });
|
|
5377
|
+
}
|
|
5378
|
+
|
|
5214
5379
|
private async respond(msg: ChallMsg) {
|
|
5215
5380
|
const response: RespMsg = {
|
|
5216
5381
|
signed: await xSignAsync(
|
|
@@ -5322,7 +5487,7 @@ export class Client {
|
|
|
5322
5487
|
challenge: newDevice.challenge,
|
|
5323
5488
|
expiresAt: newDevice.expiresAt,
|
|
5324
5489
|
requestID: newDevice.requestID,
|
|
5325
|
-
userID: newDevice.userID
|
|
5490
|
+
userID: newDevice.userID,
|
|
5326
5491
|
});
|
|
5327
5492
|
} else {
|
|
5328
5493
|
throw new Error("Error registering device.");
|
|
@@ -5600,8 +5765,8 @@ export class Client {
|
|
|
5600
5765
|
a.deviceID.localeCompare(b.deviceID, "en"),
|
|
5601
5766
|
);
|
|
5602
5767
|
|
|
5603
|
-
|
|
5604
|
-
|
|
5768
|
+
const successfulOwnerIDs = new Set<string>();
|
|
5769
|
+
const lastErrorByOwnerID = new Map<string, unknown>();
|
|
5605
5770
|
for (
|
|
5606
5771
|
let index = 0;
|
|
5607
5772
|
index < stableDevices.length;
|
|
@@ -5612,15 +5777,19 @@ export class Client {
|
|
|
5612
5777
|
index + MAIL_FANOUT_CONCURRENCY,
|
|
5613
5778
|
);
|
|
5614
5779
|
const results = await Promise.all(
|
|
5615
|
-
batch.map(async (device)
|
|
5780
|
+
batch.map(async (device) => {
|
|
5616
5781
|
const ownerRecord =
|
|
5617
5782
|
device.owner === myUserID
|
|
5618
5783
|
? this.getUser()
|
|
5619
5784
|
: this.userRecords[device.owner];
|
|
5620
5785
|
if (!ownerRecord) {
|
|
5621
|
-
return
|
|
5622
|
-
|
|
5623
|
-
|
|
5786
|
+
return {
|
|
5787
|
+
device,
|
|
5788
|
+
error: new Error(
|
|
5789
|
+
`Missing owner record for device ${device.deviceID}.`,
|
|
5790
|
+
),
|
|
5791
|
+
ok: false as const,
|
|
5792
|
+
};
|
|
5624
5793
|
}
|
|
5625
5794
|
try {
|
|
5626
5795
|
await this.sendMailWithRecovery(
|
|
@@ -5631,35 +5800,41 @@ export class Client {
|
|
|
5631
5800
|
mailID,
|
|
5632
5801
|
false,
|
|
5633
5802
|
);
|
|
5634
|
-
return
|
|
5635
|
-
} catch (
|
|
5636
|
-
return
|
|
5803
|
+
return { device, ok: true as const };
|
|
5804
|
+
} catch (error: unknown) {
|
|
5805
|
+
return { device, error, ok: false as const };
|
|
5637
5806
|
}
|
|
5638
5807
|
}),
|
|
5639
5808
|
);
|
|
5640
5809
|
for (const result of results) {
|
|
5641
|
-
if (result
|
|
5642
|
-
|
|
5643
|
-
|
|
5810
|
+
if (result.ok) {
|
|
5811
|
+
successfulOwnerIDs.add(result.device.owner);
|
|
5812
|
+
} else {
|
|
5813
|
+
lastErrorByOwnerID.set(result.device.owner, result.error);
|
|
5644
5814
|
}
|
|
5645
5815
|
}
|
|
5646
|
-
if (failCount === stableDevices.length) {
|
|
5647
|
-
break;
|
|
5648
|
-
}
|
|
5649
5816
|
}
|
|
5650
5817
|
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
if (
|
|
5657
|
-
const
|
|
5658
|
-
|
|
5659
|
-
|
|
5818
|
+
const requiredOwnerIDs =
|
|
5819
|
+
peerUserIDs.length > 0 ? peerUserIDs : [myUserID];
|
|
5820
|
+
const unreachableOwnerIDs = requiredOwnerIDs.filter(
|
|
5821
|
+
(ownerID) => !successfulOwnerIDs.has(ownerID),
|
|
5822
|
+
);
|
|
5823
|
+
if (unreachableOwnerIDs.length > 0) {
|
|
5824
|
+
const lastError = lastErrorByOwnerID.get(
|
|
5825
|
+
unreachableOwnerIDs.at(-1) ?? "",
|
|
5826
|
+
);
|
|
5827
|
+
if (
|
|
5828
|
+
unreachableOwnerIDs.length === requiredOwnerIDs.length &&
|
|
5829
|
+
lastError instanceof Error
|
|
5830
|
+
) {
|
|
5831
|
+
throw lastError;
|
|
5832
|
+
}
|
|
5833
|
+
const deliveryError = new Error(
|
|
5834
|
+
`Message could not be delivered to ${String(unreachableOwnerIDs.length)} channel member(s).`,
|
|
5660
5835
|
);
|
|
5661
|
-
|
|
5662
|
-
throw
|
|
5836
|
+
deliveryError.cause = lastError;
|
|
5837
|
+
throw deliveryError;
|
|
5663
5838
|
}
|
|
5664
5839
|
}
|
|
5665
5840
|
|
|
@@ -5717,6 +5892,7 @@ export class Client {
|
|
|
5717
5892
|
await ratchetStepSend(session);
|
|
5718
5893
|
}
|
|
5719
5894
|
const { messageKey, n } = takeSendMessageKey(session);
|
|
5895
|
+
const messageSubkeys = xMessageKeySubkeys(messageKey);
|
|
5720
5896
|
const ratchetHeader = {
|
|
5721
5897
|
dhPub: session.DHsPublic,
|
|
5722
5898
|
n,
|
|
@@ -5724,7 +5900,11 @@ export class Client {
|
|
|
5724
5900
|
version: 1 as const,
|
|
5725
5901
|
};
|
|
5726
5902
|
const nonce = xMakeNonce();
|
|
5727
|
-
const cipher = await xSecretboxAsync(
|
|
5903
|
+
const cipher = await xSecretboxAsync(
|
|
5904
|
+
msg,
|
|
5905
|
+
nonce,
|
|
5906
|
+
messageSubkeys.encryptionKey,
|
|
5907
|
+
);
|
|
5728
5908
|
const extra = encodeRatchetHeader(ratchetHeader);
|
|
5729
5909
|
|
|
5730
5910
|
const mail: MailWS = {
|
|
@@ -5749,7 +5929,7 @@ export class Client {
|
|
|
5749
5929
|
type: "resource",
|
|
5750
5930
|
};
|
|
5751
5931
|
|
|
5752
|
-
const hmac = xHMAC(mail,
|
|
5932
|
+
const hmac = xHMAC(mail, messageSubkeys.authenticationKey);
|
|
5753
5933
|
|
|
5754
5934
|
const fwdOut = forward
|
|
5755
5935
|
? messageSchema.parse(msgpack.decode(msg))
|
|
@@ -5905,6 +6085,7 @@ export class Client {
|
|
|
5905
6085
|
}
|
|
5906
6086
|
let lastErr: unknown;
|
|
5907
6087
|
let failCount = 0;
|
|
6088
|
+
let successCount = 0;
|
|
5908
6089
|
// One logical DM fan-outs to multiple recipient devices. Reuse a
|
|
5909
6090
|
// single mailID so local/UI dedupe treats it as one message.
|
|
5910
6091
|
const messageMailID = uuid.v4();
|
|
@@ -5968,28 +6149,20 @@ export class Client {
|
|
|
5968
6149
|
if (result !== undefined) {
|
|
5969
6150
|
lastErr = result;
|
|
5970
6151
|
failCount += 1;
|
|
6152
|
+
} else {
|
|
6153
|
+
successCount += 1;
|
|
5971
6154
|
}
|
|
5972
6155
|
}
|
|
5973
6156
|
if (failCount === deviceList.length) {
|
|
5974
6157
|
break;
|
|
5975
6158
|
}
|
|
5976
6159
|
}
|
|
5977
|
-
if (
|
|
6160
|
+
if (successCount === 0) {
|
|
5978
6161
|
const base =
|
|
5979
6162
|
lastErr instanceof Error
|
|
5980
6163
|
? lastErr
|
|
5981
6164
|
: new Error(String(lastErr));
|
|
5982
|
-
|
|
5983
|
-
throw base;
|
|
5984
|
-
}
|
|
5985
|
-
// Multi-device: do not “succeed” when only one device of several got mail —
|
|
5986
|
-
// callers and tests have no per-device result and the other copy times out.
|
|
5987
|
-
const partial = new Error(
|
|
5988
|
-
`Direct message failed to reach ${String(failCount)} of ` +
|
|
5989
|
-
`${String(deviceList.length)} peer device(s) (X3DH/post).`,
|
|
5990
|
-
);
|
|
5991
|
-
partial.cause = base;
|
|
5992
|
-
throw partial;
|
|
6165
|
+
throw base;
|
|
5993
6166
|
}
|
|
5994
6167
|
if (
|
|
5995
6168
|
userID !== this.getUser().userID &&
|
|
@@ -6035,6 +6208,18 @@ export class Client {
|
|
|
6035
6208
|
return decodeHttpResponse(AccountEntitlementsCodec, res.data);
|
|
6036
6209
|
}
|
|
6037
6210
|
|
|
6211
|
+
private async setServerMemberRole(
|
|
6212
|
+
permissionID: string,
|
|
6213
|
+
powerLevel: 0 | 50 | 100,
|
|
6214
|
+
): Promise<Permission> {
|
|
6215
|
+
const res = await this.http.patch(
|
|
6216
|
+
this.getHost() + "/permission/" + permissionID,
|
|
6217
|
+
msgpack.encode({ powerLevel }),
|
|
6218
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6219
|
+
);
|
|
6220
|
+
return decodeHttpResponse(PermissionCodec, res.data);
|
|
6221
|
+
}
|
|
6222
|
+
|
|
6038
6223
|
private setUser(user: User): void {
|
|
6039
6224
|
this.user = user;
|
|
6040
6225
|
// Fresh identity / token: drop stale 404 negative-cache entries so a
|
|
@@ -6101,7 +6286,7 @@ export class Client {
|
|
|
6101
6286
|
const otks: UnsavedPreKey[] = [];
|
|
6102
6287
|
|
|
6103
6288
|
for (let i = 0; i < amount; i++) {
|
|
6104
|
-
otks.push(await this.createPreKey());
|
|
6289
|
+
otks.push(await this.createPreKey("one-time"));
|
|
6105
6290
|
}
|
|
6106
6291
|
|
|
6107
6292
|
const savedKeys = await this.database.savePreKeys(otks, true);
|
|
@@ -6115,6 +6300,30 @@ export class Client {
|
|
|
6115
6300
|
);
|
|
6116
6301
|
}
|
|
6117
6302
|
|
|
6303
|
+
private async updateChannel(
|
|
6304
|
+
channelID: string,
|
|
6305
|
+
name: string,
|
|
6306
|
+
): Promise<Channel> {
|
|
6307
|
+
const res = await this.http.patch(
|
|
6308
|
+
this.getHost() + "/channel/" + channelID,
|
|
6309
|
+
msgpack.encode({ name }),
|
|
6310
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6311
|
+
);
|
|
6312
|
+
return decodeHttpResponse(ChannelCodec, res.data);
|
|
6313
|
+
}
|
|
6314
|
+
|
|
6315
|
+
private async updateServer(
|
|
6316
|
+
serverID: string,
|
|
6317
|
+
name: string,
|
|
6318
|
+
): Promise<Server> {
|
|
6319
|
+
const res = await this.http.patch(
|
|
6320
|
+
this.getHost() + "/server/" + serverID,
|
|
6321
|
+
msgpack.encode({ name }),
|
|
6322
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6323
|
+
);
|
|
6324
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6325
|
+
}
|
|
6326
|
+
|
|
6118
6327
|
private async uploadAvatar(avatar: Uint8Array): Promise<void> {
|
|
6119
6328
|
const canUseMultipart =
|
|
6120
6329
|
typeof FormData !== "undefined" &&
|
|
@@ -6230,4 +6439,52 @@ export class Client {
|
|
|
6230
6439
|
return null;
|
|
6231
6440
|
}
|
|
6232
6441
|
}
|
|
6442
|
+
|
|
6443
|
+
private async uploadServerIcon(
|
|
6444
|
+
serverID: string,
|
|
6445
|
+
icon: Uint8Array,
|
|
6446
|
+
): Promise<Server> {
|
|
6447
|
+
const canUseMultipart =
|
|
6448
|
+
typeof FormData !== "undefined" &&
|
|
6449
|
+
(() => {
|
|
6450
|
+
try {
|
|
6451
|
+
void new Blob([new Uint8Array([1, 2, 3])]);
|
|
6452
|
+
return true;
|
|
6453
|
+
} catch {
|
|
6454
|
+
return false;
|
|
6455
|
+
}
|
|
6456
|
+
})();
|
|
6457
|
+
|
|
6458
|
+
if (canUseMultipart) {
|
|
6459
|
+
const payload = new FormData();
|
|
6460
|
+
payload.set("icon", new Blob([new Uint8Array(icon)]));
|
|
6461
|
+
const res = await this.http.post(
|
|
6462
|
+
this.getHost() + "/server-icon/" + serverID,
|
|
6463
|
+
payload,
|
|
6464
|
+
{
|
|
6465
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
6466
|
+
onUploadProgress: (progressEvent) => {
|
|
6467
|
+
const { loaded, total = 0 } = progressEvent;
|
|
6468
|
+
this.emitter.emit("fileProgress", {
|
|
6469
|
+
direction: "upload",
|
|
6470
|
+
loaded,
|
|
6471
|
+
progress: Math.round(
|
|
6472
|
+
(loaded * 100) / (progressEvent.total ?? 1),
|
|
6473
|
+
),
|
|
6474
|
+
token: serverID,
|
|
6475
|
+
total,
|
|
6476
|
+
});
|
|
6477
|
+
},
|
|
6478
|
+
},
|
|
6479
|
+
);
|
|
6480
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6481
|
+
}
|
|
6482
|
+
|
|
6483
|
+
const res = await this.http.post(
|
|
6484
|
+
this.getHost() + "/server-icon/" + serverID + "/json",
|
|
6485
|
+
msgpack.encode({ file: XUtils.encodeBase64(icon) }),
|
|
6486
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6487
|
+
);
|
|
6488
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6489
|
+
}
|
|
6233
6490
|
}
|