Atomic linking
This commit is contained in:
parent
cbd16b90bb
commit
ccb5eb0dd2
11 changed files with 735 additions and 383 deletions
|
@ -30,6 +30,7 @@ import { getBasicAuth } from '../util/getBasicAuth';
|
|||
import { isPnpEnabled } from '../util/isPnpEnabled';
|
||||
import { createHTTPSAgent } from '../util/createHTTPSAgent';
|
||||
import type { SocketStatus } from '../types/SocketStatus';
|
||||
import { VerificationTransport } from '../types/VerificationTransport';
|
||||
import { toLogFormat } from '../types/errors';
|
||||
import { isPackIdValid, redactPackId } from '../types/Stickers';
|
||||
import type {
|
||||
|
@ -37,7 +38,12 @@ import type {
|
|||
AciString,
|
||||
UntaggedPniString,
|
||||
} from '../types/ServiceId';
|
||||
import { ServiceIdKind, serviceIdSchema, aciSchema } from '../types/ServiceId';
|
||||
import {
|
||||
ServiceIdKind,
|
||||
serviceIdSchema,
|
||||
aciSchema,
|
||||
untaggedPniSchema,
|
||||
} from '../types/ServiceId';
|
||||
import type { DirectoryConfigType } from '../types/RendererConfig';
|
||||
import * as Bytes from '../Bytes';
|
||||
import { randomInt } from '../Crypto';
|
||||
|
@ -482,7 +488,6 @@ function makeHTTPError(
|
|||
}
|
||||
|
||||
const URL_CALLS = {
|
||||
accounts: 'v1/accounts',
|
||||
accountExistence: 'v1/accounts/account',
|
||||
attachmentId: 'v3/attachments/form/upload',
|
||||
attestation: 'v1/attestation',
|
||||
|
@ -491,7 +496,6 @@ const URL_CALLS = {
|
|||
challenge: 'v1/challenge',
|
||||
config: 'v1/config',
|
||||
deliveryCert: 'v1/certificate/delivery',
|
||||
devices: 'v1/devices',
|
||||
directoryAuthV2: 'v2/directory/auth',
|
||||
discovery: 'v1/discovery',
|
||||
getGroupAvatarUpload: 'v1/groups/avatar/form',
|
||||
|
@ -507,10 +511,12 @@ const URL_CALLS = {
|
|||
groupsViaLink: 'v1/groups/join/',
|
||||
groupToken: 'v1/groups/token',
|
||||
keys: 'v2/keys',
|
||||
linkDevice: 'v1/devices/link',
|
||||
messages: 'v1/messages',
|
||||
multiRecipient: 'v1/messages/multi_recipient',
|
||||
phoneNumberDiscoverability: 'v2/accounts/phone_number_discoverability',
|
||||
profile: 'v1/profile',
|
||||
registration: 'v1/registration',
|
||||
registerCapabilities: 'v1/devices/capabilities',
|
||||
reportMessage: 'v1/messages/report',
|
||||
signed: 'v2/keys/signed',
|
||||
|
@ -525,6 +531,7 @@ const URL_CALLS = {
|
|||
reserveUsername: 'v1/accounts/username_hash/reserve',
|
||||
confirmUsername: 'v1/accounts/username_hash/confirm',
|
||||
usernameLink: 'v1/accounts/username_link',
|
||||
verificationSession: 'v1/verification/session',
|
||||
whoami: 'v1/accounts/whoami',
|
||||
};
|
||||
|
||||
|
@ -548,7 +555,7 @@ const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
|||
'getGroupCredentials',
|
||||
|
||||
// Devices
|
||||
'devices',
|
||||
'linkDevice',
|
||||
'registerCapabilities',
|
||||
'supportUnauthenticatedDelivery',
|
||||
|
||||
|
@ -751,12 +758,6 @@ const whoamiResultZod = z.object({
|
|||
});
|
||||
export type WhoamiResultType = z.infer<typeof whoamiResultZod>;
|
||||
|
||||
export type ConfirmCodeResultType = Readonly<{
|
||||
uuid: AciString;
|
||||
pni: UntaggedPniString;
|
||||
deviceId?: number;
|
||||
}>;
|
||||
|
||||
export type CdsLookupOptionsType = Readonly<{
|
||||
e164s: ReadonlyArray<string>;
|
||||
acis?: ReadonlyArray<AciString>;
|
||||
|
@ -864,16 +865,29 @@ export type ResolveUsernameLinkResultType = z.infer<
|
|||
typeof resolveUsernameLinkResultZod
|
||||
>;
|
||||
|
||||
export type ConfirmCodeOptionsType = Readonly<{
|
||||
export type CreateAccountOptionsType = Readonly<{
|
||||
sessionId: string;
|
||||
number: string;
|
||||
code: string;
|
||||
newPassword: string;
|
||||
registrationId: number;
|
||||
pniRegistrationId: number;
|
||||
deviceName?: string | null;
|
||||
accessKey?: Uint8Array;
|
||||
accessKey: Uint8Array;
|
||||
aciPublicKey: Uint8Array;
|
||||
pniPublicKey: Uint8Array;
|
||||
aciSignedPreKey: UploadSignedPreKeyType;
|
||||
pniSignedPreKey: UploadSignedPreKeyType;
|
||||
aciPqLastResortPreKey: UploadSignedPreKeyType;
|
||||
pniPqLastResortPreKey: UploadSignedPreKeyType;
|
||||
}>;
|
||||
|
||||
const linkDeviceResultZod = z.object({
|
||||
uuid: aciSchema,
|
||||
pni: untaggedPniSchema,
|
||||
deviceId: z.number(),
|
||||
});
|
||||
export type LinkDeviceResultType = z.infer<typeof linkDeviceResultZod>;
|
||||
|
||||
export type ReportMessageOptionsType = Readonly<{
|
||||
senderAci: AciString;
|
||||
serverGuid: string;
|
||||
|
@ -901,14 +915,43 @@ export type ServerKeyCountType = {
|
|||
pqCount: number;
|
||||
};
|
||||
|
||||
export type LinkDeviceOptionsType = Readonly<{
|
||||
number: string;
|
||||
verificationCode: string;
|
||||
encryptedDeviceName?: string;
|
||||
newPassword: string;
|
||||
registrationId: number;
|
||||
pniRegistrationId: number;
|
||||
aciSignedPreKey: UploadSignedPreKeyType;
|
||||
pniSignedPreKey: UploadSignedPreKeyType;
|
||||
aciPqLastResortPreKey: UploadSignedPreKeyType;
|
||||
pniPqLastResortPreKey: UploadSignedPreKeyType;
|
||||
}>;
|
||||
|
||||
const createAccountResultZod = z.object({
|
||||
uuid: aciSchema,
|
||||
pni: untaggedPniSchema,
|
||||
});
|
||||
export type CreateAccountResultType = z.infer<typeof createAccountResultZod>;
|
||||
|
||||
const verificationSessionZod = z.object({
|
||||
id: z.string(),
|
||||
allowedToRequestCode: z.boolean(),
|
||||
verified: z.boolean(),
|
||||
});
|
||||
|
||||
export type RequestVerificationResultType = Readonly<{
|
||||
sessionId: string;
|
||||
}>;
|
||||
|
||||
export type WebAPIType = {
|
||||
startRegistration(): unknown;
|
||||
finishRegistration(baton: unknown): void;
|
||||
cancelInflightRequests: (reason: string) => void;
|
||||
cdsLookup: (options: CdsLookupOptionsType) => Promise<CDSResponseType>;
|
||||
confirmCode: (
|
||||
options: ConfirmCodeOptionsType
|
||||
) => Promise<ConfirmCodeResultType>;
|
||||
createAccount: (
|
||||
options: CreateAccountOptionsType
|
||||
) => Promise<CreateAccountResultType>;
|
||||
createGroup: (
|
||||
group: Proto.IGroup,
|
||||
options: GroupCredentialsType
|
||||
|
@ -928,7 +971,6 @@ export type WebAPIType = {
|
|||
}
|
||||
) => Promise<Uint8Array>;
|
||||
getAvatar: (path: string) => Promise<Uint8Array>;
|
||||
getDevices: () => Promise<GetDevicesResultType>;
|
||||
getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>;
|
||||
getGroup: (options: GroupCredentialsType) => Promise<Proto.Group>;
|
||||
getGroupFromLink: (
|
||||
|
@ -996,6 +1038,7 @@ export type WebAPIType = {
|
|||
href: string,
|
||||
abortSignal: AbortSignal
|
||||
) => Promise<null | linkPreviewFetch.LinkPreviewImage>;
|
||||
linkDevice: (options: LinkDeviceOptionsType) => Promise<LinkDeviceResultType>;
|
||||
makeProxiedRequest: (
|
||||
targetUrl: string,
|
||||
options?: ProxiedRequestOptionsType
|
||||
|
@ -1044,8 +1087,11 @@ export type WebAPIType = {
|
|||
) => Promise<void>;
|
||||
registerSupportForUnauthenticatedDelivery: () => Promise<void>;
|
||||
reportMessage: (options: ReportMessageOptionsType) => Promise<void>;
|
||||
requestVerificationSMS: (number: string, token: string) => Promise<void>;
|
||||
requestVerificationVoice: (number: string, token: string) => Promise<void>;
|
||||
requestVerification: (
|
||||
number: string,
|
||||
captcha: string,
|
||||
transport: VerificationTransport
|
||||
) => Promise<RequestVerificationResultType>;
|
||||
checkAccountExistence: (serviceId: ServiceIdString) => Promise<boolean>;
|
||||
sendMessages: (
|
||||
destination: ServiceIdString,
|
||||
|
@ -1110,6 +1156,12 @@ export type UploadPreKeyType = {
|
|||
};
|
||||
export type UploadKyberPreKeyType = UploadSignedPreKeyType;
|
||||
|
||||
type SerializedSignedPreKeyType = Readonly<{
|
||||
keyId: number;
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
}>;
|
||||
|
||||
export type UploadKeysType = {
|
||||
identityKey: Uint8Array;
|
||||
|
||||
|
@ -1321,7 +1373,7 @@ export function initialize({
|
|||
cdsLookup,
|
||||
checkAccountExistence,
|
||||
checkSockets,
|
||||
confirmCode,
|
||||
createAccount,
|
||||
confirmUsername,
|
||||
createGroup,
|
||||
deleteUsername,
|
||||
|
@ -1338,7 +1390,6 @@ export function initialize({
|
|||
getBadgeImageFile,
|
||||
getBoostBadgesFromServer,
|
||||
getConfig,
|
||||
getDevices,
|
||||
getGroup,
|
||||
getGroupAvatar,
|
||||
getGroupCredentials,
|
||||
|
@ -1361,6 +1412,7 @@ export function initialize({
|
|||
getStorageCredentials,
|
||||
getStorageManifest,
|
||||
getStorageRecords,
|
||||
linkDevice,
|
||||
logout,
|
||||
makeProxiedRequest,
|
||||
makeSfuRequest,
|
||||
|
@ -1381,8 +1433,7 @@ export function initialize({
|
|||
resolveUsernameLink,
|
||||
replaceUsernameLink,
|
||||
reportMessage,
|
||||
requestVerificationSMS,
|
||||
requestVerificationVoice,
|
||||
requestVerification,
|
||||
reserveUsername,
|
||||
sendChallengeResponse,
|
||||
sendMessages,
|
||||
|
@ -1470,6 +1521,22 @@ export function initialize({
|
|||
}
|
||||
}
|
||||
|
||||
function serializeSignedPreKey(
|
||||
preKey?: UploadSignedPreKeyType
|
||||
): SerializedSignedPreKeyType | undefined {
|
||||
if (preKey == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { keyId, publicKey, signature } = preKey;
|
||||
|
||||
return {
|
||||
keyId,
|
||||
publicKey: Bytes.toBase64(publicKey),
|
||||
signature: Bytes.toBase64(signature),
|
||||
};
|
||||
}
|
||||
|
||||
function serviceIdKindToQuery(kind: ServiceIdKind): string {
|
||||
let value: string;
|
||||
if (kind === ServiceIdKind.ACI) {
|
||||
|
@ -2005,20 +2072,64 @@ export function initialize({
|
|||
});
|
||||
}
|
||||
|
||||
async function requestVerificationSMS(number: string, token: string) {
|
||||
await _ajax({
|
||||
call: 'accounts',
|
||||
httpType: 'GET',
|
||||
urlParameters: `/sms/code/${number}?captcha=${token}`,
|
||||
});
|
||||
}
|
||||
async function requestVerification(
|
||||
number: string,
|
||||
captcha: string,
|
||||
transport: VerificationTransport
|
||||
) {
|
||||
// Create a new blank session using just a E164
|
||||
let session = verificationSessionZod.parse(
|
||||
await _ajax({
|
||||
call: 'verificationSession',
|
||||
httpType: 'POST',
|
||||
responseType: 'json',
|
||||
jsonData: {
|
||||
number,
|
||||
},
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
async function requestVerificationVoice(number: string, token: string) {
|
||||
await _ajax({
|
||||
call: 'accounts',
|
||||
httpType: 'GET',
|
||||
urlParameters: `/voice/code/${number}?captcha=${token}`,
|
||||
});
|
||||
// Submit a captcha solution to the session
|
||||
session = verificationSessionZod.parse(
|
||||
await _ajax({
|
||||
call: 'verificationSession',
|
||||
httpType: 'PATCH',
|
||||
urlParameters: `/${encodeURIComponent(session.id)}`,
|
||||
responseType: 'json',
|
||||
jsonData: {
|
||||
captcha,
|
||||
},
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
// Verify that captcha was accepted
|
||||
if (!session.allowedToRequestCode) {
|
||||
throw new Error('requestVerification: Not allowed to send code');
|
||||
}
|
||||
|
||||
// Request an SMS or Voice confirmation
|
||||
session = verificationSessionZod.parse(
|
||||
await _ajax({
|
||||
call: 'verificationSession',
|
||||
httpType: 'POST',
|
||||
urlParameters: `/${encodeURIComponent(session.id)}/code`,
|
||||
responseType: 'json',
|
||||
jsonData: {
|
||||
client: 'ios',
|
||||
transport:
|
||||
transport === VerificationTransport.SMS ? 'sms' : 'voice',
|
||||
},
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
// Return sessionId to be used in `createAccount`
|
||||
return { sessionId: session.id };
|
||||
}
|
||||
|
||||
async function checkAccountExistence(serviceId: ServiceIdString) {
|
||||
|
@ -2065,58 +2176,151 @@ export function initialize({
|
|||
current.resolve();
|
||||
}
|
||||
|
||||
async function confirmCode({
|
||||
number,
|
||||
code,
|
||||
newPassword,
|
||||
registrationId,
|
||||
pniRegistrationId,
|
||||
deviceName,
|
||||
accessKey,
|
||||
}: ConfirmCodeOptionsType) {
|
||||
const capabilities: CapabilitiesUploadType = {
|
||||
pni: isPnpEnabled(),
|
||||
};
|
||||
|
||||
const jsonData = {
|
||||
capabilities,
|
||||
fetchesMessages: true,
|
||||
name: deviceName || undefined,
|
||||
registrationId,
|
||||
pniRegistrationId,
|
||||
supportsSms: false,
|
||||
unidentifiedAccessKey: accessKey
|
||||
? Bytes.toBase64(accessKey)
|
||||
: undefined,
|
||||
unrestrictedUnidentifiedAccess: false,
|
||||
};
|
||||
|
||||
const call = deviceName ? 'devices' : 'accounts';
|
||||
const urlPrefix = deviceName ? '/' : '/code/';
|
||||
|
||||
async function _withNewCredentials<
|
||||
Result extends { uuid: AciString; deviceId?: number }
|
||||
>(
|
||||
{ username: newUsername, password: newPassword }: WebAPICredentials,
|
||||
callback: () => Promise<Result>
|
||||
): Promise<Result> {
|
||||
// Reset old websocket credentials and disconnect.
|
||||
// AccountManager is our only caller and it will trigger
|
||||
// `registration_done` which will update credentials.
|
||||
await logout();
|
||||
|
||||
// Update REST credentials, though. We need them for the call below
|
||||
username = number;
|
||||
username = newUsername;
|
||||
password = newPassword;
|
||||
|
||||
const response = (await _ajax({
|
||||
isRegistration: true,
|
||||
call,
|
||||
httpType: 'PUT',
|
||||
responseType: 'json',
|
||||
urlParameters: urlPrefix + code,
|
||||
jsonData,
|
||||
})) as ConfirmCodeResultType;
|
||||
const result = await callback();
|
||||
|
||||
const { uuid: aci = newUsername, deviceId = 1 } = result;
|
||||
|
||||
// Set final REST credentials to let `registerKeys` succeed.
|
||||
username = `${response.uuid || number}.${response.deviceId || 1}`;
|
||||
username = `${aci}.${deviceId}`;
|
||||
password = newPassword;
|
||||
|
||||
return response;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createAccount({
|
||||
sessionId,
|
||||
number,
|
||||
code,
|
||||
newPassword,
|
||||
registrationId,
|
||||
pniRegistrationId,
|
||||
accessKey,
|
||||
aciPublicKey,
|
||||
pniPublicKey,
|
||||
aciSignedPreKey,
|
||||
pniSignedPreKey,
|
||||
aciPqLastResortPreKey,
|
||||
pniPqLastResortPreKey,
|
||||
}: CreateAccountOptionsType) {
|
||||
const session = verificationSessionZod.parse(
|
||||
await _ajax({
|
||||
isRegistration: true,
|
||||
call: 'verificationSession',
|
||||
httpType: 'PUT',
|
||||
urlParameters: `/${encodeURIComponent(sessionId)}/code`,
|
||||
responseType: 'json',
|
||||
jsonData: {
|
||||
code,
|
||||
},
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
if (!session.verified) {
|
||||
throw new Error('createAccount: invalid code');
|
||||
}
|
||||
|
||||
const jsonData = {
|
||||
sessionId: session.id,
|
||||
accountAttributes: {
|
||||
fetchesMessages: true,
|
||||
registrationId,
|
||||
pniRegistrationId,
|
||||
capabilities: {
|
||||
pni: isPnpEnabled(),
|
||||
},
|
||||
unidentifiedAccessKey: Bytes.toBase64(accessKey),
|
||||
},
|
||||
requireAtomic: true,
|
||||
skipDeviceTransfer: true,
|
||||
aciIdentityKey: Bytes.toBase64(aciPublicKey),
|
||||
pniIdentityKey: Bytes.toBase64(pniPublicKey),
|
||||
aciSignedPreKey: serializeSignedPreKey(aciSignedPreKey),
|
||||
pniSignedPreKey: serializeSignedPreKey(pniSignedPreKey),
|
||||
aciPqLastResortPreKey: serializeSignedPreKey(aciPqLastResortPreKey),
|
||||
pniPqLastResortPreKey: serializeSignedPreKey(pniPqLastResortPreKey),
|
||||
};
|
||||
|
||||
return _withNewCredentials(
|
||||
{
|
||||
username: number,
|
||||
password: newPassword,
|
||||
},
|
||||
async () => {
|
||||
const responseJson = await _ajax({
|
||||
isRegistration: true,
|
||||
call: 'registration',
|
||||
httpType: 'POST',
|
||||
responseType: 'json',
|
||||
jsonData,
|
||||
});
|
||||
|
||||
return createAccountResultZod.parse(responseJson);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function linkDevice({
|
||||
number,
|
||||
verificationCode,
|
||||
encryptedDeviceName,
|
||||
newPassword,
|
||||
registrationId,
|
||||
pniRegistrationId,
|
||||
aciSignedPreKey,
|
||||
pniSignedPreKey,
|
||||
aciPqLastResortPreKey,
|
||||
pniPqLastResortPreKey,
|
||||
}: LinkDeviceOptionsType) {
|
||||
const jsonData = {
|
||||
verificationCode,
|
||||
accountAttributes: {
|
||||
fetchesMessages: true,
|
||||
name: encryptedDeviceName,
|
||||
registrationId,
|
||||
pniRegistrationId,
|
||||
capabilities: {
|
||||
pni: isPnpEnabled(),
|
||||
},
|
||||
},
|
||||
aciSignedPreKey: serializeSignedPreKey(aciSignedPreKey),
|
||||
pniSignedPreKey: serializeSignedPreKey(pniSignedPreKey),
|
||||
aciPqLastResortPreKey: serializeSignedPreKey(aciPqLastResortPreKey),
|
||||
pniPqLastResortPreKey: serializeSignedPreKey(pniPqLastResortPreKey),
|
||||
};
|
||||
return _withNewCredentials(
|
||||
{
|
||||
username: number,
|
||||
password: newPassword,
|
||||
},
|
||||
async () => {
|
||||
const responseJson = await _ajax({
|
||||
isRegistration: true,
|
||||
call: 'linkDevice',
|
||||
httpType: 'PUT',
|
||||
responseType: 'json',
|
||||
jsonData,
|
||||
});
|
||||
|
||||
return linkDeviceResultZod.parse(responseJson);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function updateDeviceName(deviceName: string) {
|
||||
|
@ -2137,14 +2341,6 @@ export function initialize({
|
|||
})) as GetIceServersResultType;
|
||||
}
|
||||
|
||||
async function getDevices() {
|
||||
return (await _ajax({
|
||||
call: 'devices',
|
||||
httpType: 'GET',
|
||||
responseType: 'json',
|
||||
})) as GetDevicesResultType;
|
||||
}
|
||||
|
||||
type JSONSignedPreKeyType = {
|
||||
keyId: number;
|
||||
publicKey: string;
|
||||
|
@ -2203,24 +2399,8 @@ export function initialize({
|
|||
identityKey: Bytes.toBase64(genKeys.identityKey),
|
||||
preKeys,
|
||||
pqPreKeys,
|
||||
...(genKeys.pqLastResortPreKey
|
||||
? {
|
||||
pqLastResortPreKey: {
|
||||
keyId: genKeys.pqLastResortPreKey.keyId,
|
||||
publicKey: Bytes.toBase64(genKeys.pqLastResortPreKey.publicKey),
|
||||
signature: Bytes.toBase64(genKeys.pqLastResortPreKey.signature),
|
||||
},
|
||||
}
|
||||
: null),
|
||||
...(genKeys.signedPreKey
|
||||
? {
|
||||
signedPreKey: {
|
||||
keyId: genKeys.signedPreKey.keyId,
|
||||
publicKey: Bytes.toBase64(genKeys.signedPreKey.publicKey),
|
||||
signature: Bytes.toBase64(genKeys.signedPreKey.signature),
|
||||
},
|
||||
}
|
||||
: null),
|
||||
pqLastResortPreKey: serializeSignedPreKey(genKeys.pqLastResortPreKey),
|
||||
signedPreKey: serializeSignedPreKey(genKeys.signedPreKey),
|
||||
};
|
||||
|
||||
await _ajax({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue