Atomic linking

This commit is contained in:
Fedor Indutny 2023-08-29 02:41:32 +02:00 committed by GitHub
parent cbd16b90bb
commit ccb5eb0dd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 735 additions and 383 deletions

View file

@ -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({