From ccb5eb0dd245bcc43ec4e685a991ff1047ac108f Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Tue, 29 Aug 2023 02:41:32 +0200 Subject: [PATCH] Atomic linking --- package.json | 2 +- ts/components/App.tsx | 13 +- ts/components/StandaloneRegistration.tsx | 26 +- ts/state/smart/App.tsx | 27 +- .../textsecure/AccountManager_test.ts | 2 +- .../textsecure/generate_keys_test.ts | 65 +- ts/textsecure/AccountManager.ts | 574 ++++++++++++------ ts/textsecure/WebAPI.ts | 384 ++++++++---- ts/types/ServiceId.ts | 10 + ts/types/VerificationTransport.ts | 7 + yarn.lock | 8 +- 11 files changed, 735 insertions(+), 383 deletions(-) create mode 100644 ts/types/VerificationTransport.ts diff --git a/package.json b/package.json index 0a50ddb34..3f9ed39c1 100644 --- a/package.json +++ b/package.json @@ -196,7 +196,7 @@ "@electron/fuses": "1.5.0", "@formatjs/intl": "2.6.7", "@mixer/parallel-prettier": "2.0.3", - "@signalapp/mock-server": "4.0.1", + "@signalapp/mock-server": "4.1.0", "@storybook/addon-a11y": "6.5.6", "@storybook/addon-actions": "6.5.6", "@storybook/addon-controls": "6.5.6", diff --git a/ts/components/App.tsx b/ts/components/App.tsx index 11e1b983b..5343f060e 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -10,6 +10,7 @@ import type { MenuOptionsType, MenuActionType } from '../types/menu'; import type { AnyToast } from '../types/Toast'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import type { LocalizerType } from '../types/Util'; +import type { VerificationTransport } from '../types/VerificationTransport'; import { ThemeType } from '../types/Util'; import { AppViewType } from '../state/ducks/app'; import { SmartInstallScreen } from '../state/smart/InstallScreen'; @@ -22,7 +23,11 @@ import { useReducedMotion } from '../hooks/useReducedMotion'; type PropsType = { appView: AppViewType; openInbox: () => void; - registerSingleDevice: (number: string, code: string) => Promise; + registerSingleDevice: ( + number: string, + code: string, + sessionId: string + ) => Promise; renderCallManager: () => JSX.Element; renderGlobalModalContainer: () => JSX.Element; i18n: LocalizerType; @@ -30,10 +35,10 @@ type PropsType = { renderStoryViewer: (closeView: () => unknown) => JSX.Element; renderLightbox: () => JSX.Element | null; requestVerification: ( - type: 'sms' | 'voice', number: string, - token: string - ) => Promise; + captcha: string, + transport: VerificationTransport + ) => Promise<{ sessionId: string }>; theme: ThemeType; isMaximized: boolean; isFullScreen: boolean; diff --git a/ts/components/StandaloneRegistration.tsx b/ts/components/StandaloneRegistration.tsx index 81de522c0..6253cba3d 100644 --- a/ts/components/StandaloneRegistration.tsx +++ b/ts/components/StandaloneRegistration.tsx @@ -10,6 +10,7 @@ import { strictAssert } from '../util/assert'; import * as log from '../logging/log'; import { parseNumber } from '../util/libphonenumberUtil'; import { getChallengeURL } from '../challenge'; +import { VerificationTransport } from '../types/VerificationTransport'; function PhoneInput({ onValidation, @@ -100,11 +101,15 @@ export function StandaloneRegistration({ }: { onComplete: () => void; requestVerification: ( - type: 'sms' | 'voice', number: string, - token: string + captcha: string, + transport: VerificationTransport + ) => Promise<{ sessionId: string }>; + registerSingleDevice: ( + number: string, + code: string, + sessionId: string ) => Promise; - registerSingleDevice: (number: string, code: string) => Promise; }): JSX.Element { useEffect(() => { window.IPC.readyForUpdates(); @@ -115,10 +120,11 @@ export function StandaloneRegistration({ const [number, setNumber] = useState(undefined); const [code, setCode] = useState(''); const [error, setError] = useState(undefined); + const [sessionId, setSessionId] = useState(undefined); const [status, setStatus] = useState(undefined); const onRequestCode = useCallback( - async (type: 'sms' | 'voice') => { + async (transport: VerificationTransport) => { if (!isValidNumber) { return; } @@ -141,7 +147,8 @@ export function StandaloneRegistration({ }); try { - void requestVerification(type, number, token); + const result = await requestVerification(number, token, transport); + setSessionId(result.sessionId); setError(undefined); } catch (err) { setError(err.message); @@ -155,7 +162,7 @@ export function StandaloneRegistration({ e.preventDefault(); e.stopPropagation(); - void onRequestCode('sms'); + void onRequestCode(VerificationTransport.SMS); }, [onRequestCode] ); @@ -165,7 +172,7 @@ export function StandaloneRegistration({ e.preventDefault(); e.stopPropagation(); - void onRequestCode('voice'); + void onRequestCode(VerificationTransport.Voice); }, [onRequestCode] ); @@ -185,14 +192,14 @@ export function StandaloneRegistration({ event.preventDefault(); event.stopPropagation(); - if (!isValidNumber || !isValidCode) { + if (!isValidNumber || !isValidCode || !sessionId) { return; } strictAssert(number != null && code.length > 0, 'Missing number or code'); try { - await registerSingleDevice(number, code); + await registerSingleDevice(number, code, sessionId); onComplete(); } catch (err) { setStatus(err.message); @@ -203,6 +210,7 @@ export function StandaloneRegistration({ onComplete, number, code, + sessionId, setStatus, isValidNumber, isValidCode, diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 39a46624a..595f7f888 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -6,8 +6,10 @@ import { connect } from 'react-redux'; import type { MenuItemConstructorOptions } from 'electron'; import type { MenuActionType } from '../../types/menu'; +import type { VerificationTransport } from '../../types/VerificationTransport'; import { App } from '../../components/App'; import OS from '../../util/os/osMain'; +import { strictAssert } from '../../util/assert'; import { SmartCallManager } from './CallManager'; import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartLightbox } from './Lightbox'; @@ -61,20 +63,23 @@ const mapStateToProps = (state: StateType) => { ), renderInbox, requestVerification: ( - type: 'sms' | 'voice', number: string, - token: string - ): Promise => { - const accountManager = window.getAccountManager(); + captcha: string, + transport: VerificationTransport + ): Promise<{ sessionId: string }> => { + const { server } = window.textsecure; + strictAssert(server !== undefined, 'WebAPI not available'); - if (type === 'sms') { - return accountManager.requestSMSVerification(number, token); - } - - return accountManager.requestVoiceVerification(number, token); + return server.requestVerification(number, captcha, transport); }, - registerSingleDevice: (number: string, code: string): Promise => { - return window.getAccountManager().registerSingleDevice(number, code); + registerSingleDevice: ( + number: string, + code: string, + sessionId: string + ): Promise => { + return window + .getAccountManager() + .registerSingleDevice(number, code, sessionId); }, theme: getTheme(state), diff --git a/ts/test-electron/textsecure/AccountManager_test.ts b/ts/test-electron/textsecure/AccountManager_test.ts index 91831ed84..787b586fa 100644 --- a/ts/test-electron/textsecure/AccountManager_test.ts +++ b/ts/test-electron/textsecure/AccountManager_test.ts @@ -66,7 +66,7 @@ describe('AccountManager', () => { it('handles falsey deviceName', () => { const encrypted = accountManager.encryptDeviceName('', identityKey); - assert.strictEqual(encrypted, null); + assert.strictEqual(encrypted, undefined); }); }); diff --git a/ts/test-electron/textsecure/generate_keys_test.ts b/ts/test-electron/textsecure/generate_keys_test.ts index 416a2d3bf..704da159d 100644 --- a/ts/test-electron/textsecure/generate_keys_test.ts +++ b/ts/test-electron/textsecure/generate_keys_test.ts @@ -7,7 +7,7 @@ import { constantTimeEqual } from '../../Crypto'; import { generateKeyPair } from '../../Curve'; import type { UploadKeysType } from '../../textsecure/WebAPI'; import AccountManager from '../../textsecure/AccountManager'; -import type { PreKeyType, SignedPreKeyType } from '../../textsecure/Types.d'; +import type { PreKeyType } from '../../textsecure/Types.d'; import { ServiceIdKind, normalizeAci } from '../../types/ServiceId'; const { textsecure } = window; @@ -43,15 +43,6 @@ describe('Key generation', function thisNeeded() { assert(key, `kyber pre key ${keyId} not found`); }); } - function itStoresSignedPreKey(keyId: number): void { - it(`signed prekey ${keyId} is valid`, async () => { - const keyPair = await textsecure.storage.protocol.loadSignedPreKey( - ourServiceId, - keyId - ); - assert(keyPair, `SignedPreKey ${keyId} not found`); - }); - } async function validateResultPreKey( resultKey: Pick @@ -65,24 +56,6 @@ describe('Key generation', function thisNeeded() { } assertEqualBuffers(resultKey.publicKey, keyPair.publicKey().serialize()); } - async function validateResultSignedKey( - resultSignedKey?: Pick - ) { - if (!resultSignedKey) { - throw new Error('validateResultSignedKey: No signed prekey provided!'); - } - const keyPair = await textsecure.storage.protocol.loadSignedPreKey( - ourServiceId, - resultSignedKey.keyId - ); - if (!keyPair) { - throw new Error(`SignedPreKey ${resultSignedKey.keyId} not found`); - } - assertEqualBuffers( - resultSignedKey.publicKey, - keyPair.publicKey().serialize() - ); - } before(async () => { await textsecure.storage.protocol.clearPreKeyStore(); @@ -108,17 +81,19 @@ describe('Key generation', function thisNeeded() { before(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const accountManager = new AccountManager({} as any); - result = await accountManager._generateKeys(count, ServiceIdKind.ACI); + result = await accountManager._generateSingleUseKeys( + ServiceIdKind.ACI, + count + ); }); describe('generates the basics', () => { for (let i = 1; i <= count; i += 1) { itStoresPreKey(i); } - for (let i = 1; i <= count + 1; i += 1) { + for (let i = 1; i <= count; i += 1) { itStoresKyberPreKey(i); } - itStoresSignedPreKey(1); }); it(`result contains ${count} preKeys`, () => { @@ -139,28 +114,24 @@ describe('Key generation', function thisNeeded() { const preKeys = result.preKeys || []; await Promise.all(preKeys.map(validateResultPreKey)); }); - it('returns a signed prekey', () => { - assert.strictEqual(result.signedPreKey?.keyId, 1); - assert.instanceOf(result.signedPreKey?.signature, Uint8Array); - return validateResultSignedKey(result.signedPreKey); - }); }); describe('the second time', () => { before(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const accountManager = new AccountManager({} as any); - result = await accountManager._generateKeys(count, ServiceIdKind.ACI); + result = await accountManager._generateSingleUseKeys( + ServiceIdKind.ACI, + count + ); }); describe('generates the basics', () => { for (let i = 1; i <= 2 * count; i += 1) { itStoresPreKey(i); } - for (let i = 1; i <= 2 * count + 2; i += 1) { + for (let i = 1; i <= 2 * count; i += 1) { itStoresKyberPreKey(i); } - itStoresSignedPreKey(1); - itStoresSignedPreKey(2); }); it(`result contains ${count} preKeys`, () => { @@ -181,11 +152,6 @@ describe('Key generation', function thisNeeded() { const preKeys = result.preKeys || []; await Promise.all(preKeys.map(validateResultPreKey)); }); - it('returns a signed prekey', () => { - assert.strictEqual(result.signedPreKey?.keyId, 2); - assert.instanceOf(result.signedPreKey?.signature, Uint8Array); - return validateResultSignedKey(result.signedPreKey); - }); }); describe('the third time, after keys are confirmed', () => { before(async () => { @@ -194,7 +160,10 @@ describe('Key generation', function thisNeeded() { await accountManager._confirmKeys(result, ServiceIdKind.ACI); - result = await accountManager._generateKeys(count, ServiceIdKind.ACI); + result = await accountManager._generateSingleUseKeys( + ServiceIdKind.ACI, + count + ); }); describe('generates the basics', () => { @@ -202,11 +171,9 @@ describe('Key generation', function thisNeeded() { itStoresPreKey(i); } // Note: no new last resort kyber key generated - for (let i = 1; i <= 3 * count + 2; i += 1) { + for (let i = 1; i <= 3 * count; i += 1) { itStoresKyberPreKey(i); } - itStoresSignedPreKey(1); - itStoresSignedPreKey(2); }); it(`result contains ${count} preKeys`, () => { diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index c3142cda9..dfccbc8e9 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -3,6 +3,7 @@ import PQueue from 'p-queue'; import { isNumber, omit, orderBy } from 'lodash'; +import type { KyberPreKeyRecord } from '@signalapp/libsignal-client'; import EventTarget from './EventTarget'; import type { @@ -13,6 +14,7 @@ import type { WebAPIType, } from './WebAPI'; import type { + CompatSignedPreKeyType, CompatPreKeyType, KeyPairType, KyberPreKeyType, @@ -38,10 +40,11 @@ import { generatePreKey, generateKyberPreKey, } from '../Curve'; -import type { ServiceIdString, PniString } from '../types/ServiceId'; +import type { ServiceIdString, AciString, PniString } from '../types/ServiceId'; import { ServiceIdKind, normalizeAci, + normalizePni, toTaggedPni, isUntaggedPniString, } from '../types/ServiceId'; @@ -51,6 +54,7 @@ import { assertDev, strictAssert } from '../util/assert'; import { getRegionCodeForNumber } from '../util/libphonenumberUtil'; import { getProvisioningUrl } from '../util/getProvisioningUrl'; import { isNotNil } from '../util/isNotNil'; +import { missingCaseError } from '../util/missingCaseError'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import type { StorageAccessType } from '../types/Storage'; @@ -105,18 +109,53 @@ const SIGNED_PRE_KEY_UPDATE_TIME_KEY: StorageKeyByServiceIdKind = { [ServiceIdKind.PNI]: 'signedKeyUpdateTimePNI', }; -type CreateAccountOptionsType = Readonly<{ +enum AccountType { + Primary = 'Primary', + Linked = 'Linked', +} + +type CreateAccountSharedOptionsType = Readonly<{ number: string; verificationCode: string; aciKeyPair: KeyPairType; - pniKeyPair?: KeyPairType; - profileKey?: Uint8Array; - deviceName?: string; - userAgent?: string; - readReceipts?: boolean; - accessKey?: Uint8Array; + pniKeyPair: KeyPairType; + profileKey: Uint8Array; }>; +type CreatePrimaryDeviceOptionsType = Readonly<{ + type: AccountType.Primary; + + deviceName?: undefined; + ourAci?: undefined; + ourPni?: undefined; + userAgent?: undefined; + + readReceipts: true; + + accessKey: Uint8Array; + sessionId: string; +}> & + CreateAccountSharedOptionsType; + +type CreateLinkedDeviceOptionsType = Readonly<{ + type: AccountType.Linked; + + deviceName: string; + ourAci: AciString; + ourPni: PniString; + userAgent?: string; + + readReceipts: boolean; + + accessKey?: undefined; + sessionId?: undefined; +}> & + CreateAccountSharedOptionsType; + +type CreateAccountOptionsType = + | CreatePrimaryDeviceOptionsType + | CreateLinkedDeviceOptionsType; + function getNextKeyId( kind: ServiceIdKind, keys: StorageKeyByServiceIdKind @@ -135,6 +174,42 @@ function getNextKeyId( return STARTING_KEY_ID; } +function kyberPreKeyToUploadSignedPreKey( + record: KyberPreKeyRecord +): UploadSignedPreKeyType { + return { + keyId: record.id(), + publicKey: record.publicKey().serialize(), + signature: record.signature(), + }; +} + +function kyberPreKeyToStoredSignedPreKey( + record: KyberPreKeyRecord, + ourServiceId: ServiceIdString +): Omit { + return { + createdAt: Date.now(), + data: record.serialize(), + isConfirmed: false, + isLastResort: true, + keyId: record.id(), + ourServiceId, + }; +} + +function signedPreKeyToUploadSignedPreKey({ + keyId, + keyPair, + signature, +}: CompatSignedPreKeyType): UploadSignedPreKeyType { + return { + keyId, + publicKey: keyPair.pubKey, + signature, + }; +} + export default class AccountManager extends EventTarget { pending: Promise; @@ -153,17 +228,12 @@ export default class AccountManager extends EventTarget { return this.pendingQueue.add(taskWithTimeout); } - async requestVoiceVerification(number: string, token: string): Promise { - return this.server.requestVerificationVoice(number, token); - } - - async requestSMSVerification(number: string, token: string): Promise { - return this.server.requestVerificationSMS(number, token); - } - - encryptDeviceName(name: string, identityKey: KeyPairType): string | null { + encryptDeviceName( + name: string, + identityKey: KeyPairType + ): string | undefined { if (!name) { - return null; + return undefined; } const encrypted = encryptDeviceName(name, identityKey.pubKey); @@ -224,7 +294,8 @@ export default class AccountManager extends EventTarget { async registerSingleDevice( number: string, - verificationCode: string + verificationCode: string, + sessionId: string ): Promise { await this.queueTask(async () => { const aciKeyPair = generateKeyPair(); @@ -235,22 +306,16 @@ export default class AccountManager extends EventTarget { const registrationBaton = this.server.startRegistration(); try { await this.createAccount({ + type: AccountType.Primary, number, verificationCode, + sessionId, aciKeyPair, pniKeyPair, profileKey, accessKey, + readReceipts: true, }); - - const uploadKeys = async (kind: ServiceIdKind) => { - const keys = await this._generateKeys(PRE_KEY_GEN_BATCH_SIZE, kind); - await this.server.registerKeys(keys, kind); - await this._confirmKeys(keys, kind); - }; - - await uploadKeys(ServiceIdKind.ACI); - await uploadKeys(ServiceIdKind.PNI); } finally { this.server.finishRegistration(registrationBaton); } @@ -332,17 +397,27 @@ export default class AccountManager extends EventTarget { if ( !provisionMessage.number || !provisionMessage.provisioningCode || - !provisionMessage.aciKeyPair + !provisionMessage.aciKeyPair || + !provisionMessage.pniKeyPair || + !provisionMessage.profileKey || + !provisionMessage.aci || + !isUntaggedPniString(provisionMessage.pni) ) { throw new Error( 'AccountManager.registerSecondDevice: Provision message was missing key data' ); } - const registrationBaton = this.server.startRegistration(); + const ourAci = normalizeAci(provisionMessage.aci, 'provisionMessage.aci'); + const ourPni = normalizePni( + toTaggedPni(provisionMessage.pni), + 'provisionMessage.pni' + ); + const registrationBaton = this.server.startRegistration(); try { await this.createAccount({ + type: AccountType.Linked, number: provisionMessage.number, verificationCode: provisionMessage.provisioningCode, aciKeyPair: provisionMessage.aciKeyPair, @@ -350,32 +425,10 @@ export default class AccountManager extends EventTarget { profileKey: provisionMessage.profileKey, deviceName, userAgent: provisionMessage.userAgent, - readReceipts: provisionMessage.readReceipts, + ourAci, + ourPni, + readReceipts: Boolean(provisionMessage.readReceipts), }); - - const uploadKeys = async (kind: ServiceIdKind) => { - const keys = await this._generateKeys(PRE_KEY_GEN_BATCH_SIZE, kind); - - try { - await this.server.registerKeys(keys, kind); - await this._confirmKeys(keys, kind); - } catch (error) { - if (kind === ServiceIdKind.PNI) { - log.error( - 'Failed to upload PNI prekeys. Moving on', - Errors.toLogFormat(error) - ); - return; - } - - throw error; - } - }; - - await uploadKeys(ServiceIdKind.ACI); - if (provisionMessage.pniKeyPair) { - await uploadKeys(ServiceIdKind.PNI); - } } finally { this.server.finishRegistration(registrationBaton); } @@ -406,8 +459,10 @@ export default class AccountManager extends EventTarget { private async generateNewPreKeys( serviceIdKind: ServiceIdKind, - count: number + count = PRE_KEY_GEN_BATCH_SIZE ): Promise> { + const ourServiceId = + window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); const logId = `AccountManager.generateNewPreKeys(${serviceIdKind})`; const { storage } = window.textsecure; const store = storage.protocol; @@ -415,7 +470,6 @@ export default class AccountManager extends EventTarget { const startId = getNextKeyId(serviceIdKind, PRE_KEY_ID_KEY); log.info(`${logId}: Generating ${count} new keys starting at ${startId}`); - const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind); if (typeof startId !== 'number') { throw new Error( `${logId}: Invalid ${PRE_KEY_ID_KEY[serviceIdKind]} in storage` @@ -440,7 +494,7 @@ export default class AccountManager extends EventTarget { private async generateNewKyberPreKeys( serviceIdKind: ServiceIdKind, - count: number + count = PRE_KEY_GEN_BATCH_SIZE ): Promise> { const logId = `AccountManager.generateNewKyberPreKeys(${serviceIdKind})`; const { storage } = window.textsecure; @@ -449,13 +503,13 @@ export default class AccountManager extends EventTarget { const startId = getNextKeyId(serviceIdKind, KYBER_KEY_ID_KEY); log.info(`${logId}: Generating ${count} new keys starting at ${startId}`); - const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind); if (typeof startId !== 'number') { throw new Error( `${logId}: Invalid ${KYBER_KEY_ID_KEY[serviceIdKind]} in storage` ); } + const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind); const identityKey = this.getIdentityKeyOrThrow(ourServiceId); const toSave: Array> = []; @@ -515,10 +569,7 @@ export default class AccountManager extends EventTarget { log.info( `${logId}: Server prekey count is ${preKeyCount}, generating a new set` ); - preKeys = await this.generateNewPreKeys( - serviceIdKind, - PRE_KEY_GEN_BATCH_SIZE - ); + preKeys = await this.generateNewPreKeys(serviceIdKind); } let pqPreKeys: Array | undefined; @@ -526,10 +577,7 @@ export default class AccountManager extends EventTarget { log.info( `${logId}: Server kyber prekey count is ${kyberPreKeyCount}, generating a new set` ); - pqPreKeys = await this.generateNewKyberPreKeys( - serviceIdKind, - PRE_KEY_GEN_BATCH_SIZE - ); + pqPreKeys = await this.generateNewKyberPreKeys(serviceIdKind); } const pqLastResortPreKey = await this.maybeUpdateLastResortKyberKey( @@ -608,14 +656,12 @@ export default class AccountManager extends EventTarget { return false; } - private async maybeUpdateSignedPreKey( - serviceIdKind: ServiceIdKind - ): Promise { - const logId = `AccountManager.maybeUpdateSignedPreKey(${serviceIdKind})`; - const store = window.textsecure.storage.protocol; + private async generateSignedPreKey( + serviceIdKind: ServiceIdKind, + identityKey: KeyPairType + ): Promise { + const logId = `AccountManager.generateSignedPreKey(${serviceIdKind})`; - const ourServiceId = - window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); const signedKeyId = getNextKeyId(serviceIdKind, SIGNED_PRE_KEY_ID_KEY); if (typeof signedKeyId !== 'number') { throw new Error( @@ -623,6 +669,26 @@ export default class AccountManager extends EventTarget { ); } + const key = await generateSignedPreKey(identityKey, signedKeyId); + log.info(`${logId}: Saving new signed prekey`, key.keyId); + + await window.textsecure.storage.put( + SIGNED_PRE_KEY_ID_KEY[serviceIdKind], + signedKeyId + 1 + ); + + return key; + } + + private async maybeUpdateSignedPreKey( + serviceIdKind: ServiceIdKind + ): Promise { + const ourServiceId = + window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); + const identityKey = this.getIdentityKeyOrThrow(ourServiceId); + const logId = `AccountManager.maybeUpdateSignedPreKey(${serviceIdKind}, ${ourServiceId})`; + const store = window.textsecure.storage.protocol; + const keys = await store.loadSignedPreKeys(ourServiceId); const sortedKeys = orderBy(keys, ['created_at'], ['desc']); const confirmedKeys = sortedKeys.filter(key => key.confirmed); @@ -647,34 +713,20 @@ export default class AccountManager extends EventTarget { return; } - const identityKey = this.getIdentityKeyOrThrow(ourServiceId); - - const key = await generateSignedPreKey(identityKey, signedKeyId); + const key = await this.generateSignedPreKey(serviceIdKind, identityKey); log.info(`${logId}: Saving new signed prekey`, key.keyId); - await Promise.all([ - window.textsecure.storage.put( - SIGNED_PRE_KEY_ID_KEY[serviceIdKind], - signedKeyId + 1 - ), - store.storeSignedPreKey(ourServiceId, key.keyId, key.keyPair), - ]); + await store.storeSignedPreKey(ourServiceId, key.keyId, key.keyPair); - return { - keyId: key.keyId, - publicKey: key.keyPair.pubKey, - signature: key.signature, - }; + return signedPreKeyToUploadSignedPreKey(key); } - private async maybeUpdateLastResortKyberKey( - serviceIdKind: ServiceIdKind - ): Promise { - const logId = `maybeUpdateLastResortKyberKey(${serviceIdKind})`; - const store = window.textsecure.storage.protocol; + private async generateLastResortKyberKey( + serviceIdKind: ServiceIdKind, + identityKey: KeyPairType + ): Promise { + const logId = `generateLastRestortKyberKey(${serviceIdKind})`; - const ourServiceId = - window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); const kyberKeyId = getNextKeyId(serviceIdKind, KYBER_KEY_ID_KEY); if (typeof kyberKeyId !== 'number') { throw new Error( @@ -682,6 +734,27 @@ export default class AccountManager extends EventTarget { ); } + const keyId = kyberKeyId; + const record = await generateKyberPreKey(identityKey, keyId); + log.info(`${logId}: Saving new last resort prekey`, keyId); + + await window.textsecure.storage.put( + KYBER_KEY_ID_KEY[serviceIdKind], + kyberKeyId + 1 + ); + + return record; + } + + private async maybeUpdateLastResortKyberKey( + serviceIdKind: ServiceIdKind + ): Promise { + const ourServiceId = + window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); + const identityKey = this.getIdentityKeyOrThrow(ourServiceId); + const logId = `maybeUpdateLastResortKyberKey(${serviceIdKind}, ${ourServiceId})`; + const store = window.textsecure.storage.protocol; + const keys = store.loadKyberPreKeys(ourServiceId, { isLastResort: true }); const sortedKeys = orderBy(keys, ['createdAt'], ['desc']); const confirmedKeys = sortedKeys.filter(key => key.isConfirmed); @@ -706,33 +779,16 @@ export default class AccountManager extends EventTarget { return; } - const identityKey = this.getIdentityKeyOrThrow(ourServiceId); + const record = await this.generateLastResortKyberKey( + serviceIdKind, + identityKey + ); + log.info(`${logId}: Saving new last resort prekey`, record.id()); + const key = kyberPreKeyToStoredSignedPreKey(record, ourServiceId); - const keyId = kyberKeyId; - const record = await generateKyberPreKey(identityKey, keyId); - log.info(`${logId}: Saving new last resort prekey`, keyId); - const key = { - createdAt: Date.now(), - data: record.serialize(), - isConfirmed: false, - isLastResort: true, - keyId, - ourServiceId, - }; + await store.storeKyberPreKeys(ourServiceId, [key]); - await Promise.all([ - window.textsecure.storage.put( - KYBER_KEY_ID_KEY[serviceIdKind], - kyberKeyId + 1 - ), - store.storeKyberPreKeys(ourServiceId, [key]), - ]); - - return { - keyId, - publicKey: record.publicKey().serialize(), - signature: record.signature(), - }; + return kyberPreKeyToUploadSignedPreKey(record); } // Exposed only for tests @@ -846,10 +902,10 @@ export default class AccountManager extends EventTarget { } async _cleanPreKeys(serviceIdKind: ServiceIdKind): Promise { - const ourServiceId = - window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); const store = window.textsecure.storage.protocol; const logId = `AccountManager.cleanPreKeys(${serviceIdKind})`; + const ourServiceId = + window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); const preKeys = store.loadPreKeys(ourServiceId); const toDelete: Array = []; @@ -874,10 +930,10 @@ export default class AccountManager extends EventTarget { } async _cleanKyberPreKeys(serviceIdKind: ServiceIdKind): Promise { - const ourServiceId = - window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); const store = window.textsecure.storage.protocol; const logId = `AccountManager.cleanKyberPreKeys(${serviceIdKind})`; + const ourServiceId = + window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); const preKeys = store.loadKyberPreKeys(ourServiceId, { isLastResort: false, @@ -903,17 +959,19 @@ export default class AccountManager extends EventTarget { } } - async createAccount({ - number, - verificationCode, - aciKeyPair, - pniKeyPair, - profileKey, - deviceName, - userAgent, - readReceipts, - accessKey, - }: CreateAccountOptionsType): Promise { + private async createAccount( + options: CreateAccountOptionsType + ): Promise { + const { + number, + verificationCode, + aciKeyPair, + pniKeyPair, + profileKey, + readReceipts, + userAgent, + } = options; + const { storage } = window.textsecure; let password = Bytes.toBase64(getRandomBytes(16)); password = password.substring(0, password.length - 2); @@ -924,36 +982,20 @@ export default class AccountManager extends EventTarget { const previousACI = storage.user.getAci(); const previousPNI = storage.user.getPni(); - let encryptedDeviceName; - if (deviceName) { - encryptedDeviceName = this.encryptDeviceName(deviceName, aciKeyPair); - await this.deviceNameIsEncrypted(); - } - log.info( `createAccount: Number is ${number}, password has length: ${ password ? password.length : 'none' }` ); - const response = await this.server.confirmCode({ - number, - code: verificationCode, - newPassword: password, - registrationId, - pniRegistrationId, - deviceName: encryptedDeviceName, - accessKey, - }); - - const ourAci = normalizeAci(response.uuid, 'createAccount'); - strictAssert( - isUntaggedPniString(response.pni), - 'Response pni must be untagged' - ); - const ourPni = toTaggedPni(response.pni); - - const uuidChanged = previousACI && ourAci && previousACI !== ourAci; + let uuidChanged: boolean; + if (options.type === AccountType.Primary) { + uuidChanged = true; + } else if (options.type === AccountType.Linked) { + uuidChanged = previousACI != null && previousACI !== options.ourAci; + } else { + throw missingCaseError(options); + } // We only consider the number changed if we didn't have a UUID before const numberChanged = @@ -1004,6 +1046,96 @@ export default class AccountManager extends EventTarget { ]); } + let ourAci: AciString; + let ourPni: PniString; + let deviceId: number; + + const aciPqLastResortPreKey = await this.generateLastResortKyberKey( + ServiceIdKind.ACI, + aciKeyPair + ); + const pniPqLastResortPreKey = await this.generateLastResortKyberKey( + ServiceIdKind.PNI, + pniKeyPair + ); + const aciSignedPreKey = await this.generateSignedPreKey( + ServiceIdKind.ACI, + aciKeyPair + ); + const pniSignedPreKey = await this.generateSignedPreKey( + ServiceIdKind.PNI, + pniKeyPair + ); + + const keysToUpload = { + aciPqLastResortPreKey: kyberPreKeyToUploadSignedPreKey( + aciPqLastResortPreKey + ), + aciSignedPreKey: signedPreKeyToUploadSignedPreKey(aciSignedPreKey), + pniPqLastResortPreKey: kyberPreKeyToUploadSignedPreKey( + pniPqLastResortPreKey + ), + pniSignedPreKey: signedPreKeyToUploadSignedPreKey(pniSignedPreKey), + }; + + if (options.type === AccountType.Primary) { + const response = await this.server.createAccount({ + number, + code: verificationCode, + newPassword: password, + registrationId, + pniRegistrationId, + accessKey: options.accessKey, + sessionId: options.sessionId, + aciPublicKey: aciKeyPair.pubKey, + pniPublicKey: pniKeyPair.pubKey, + ...keysToUpload, + }); + + ourAci = normalizeAci(response.uuid, 'createAccount'); + strictAssert( + isUntaggedPniString(response.pni), + 'Response pni must be untagged' + ); + ourPni = toTaggedPni(response.pni); + deviceId = 1; + } else if (options.type === AccountType.Linked) { + const encryptedDeviceName = this.encryptDeviceName( + options.deviceName, + aciKeyPair + ); + await this.deviceNameIsEncrypted(); + + const response = await this.server.linkDevice({ + number, + verificationCode, + encryptedDeviceName, + newPassword: password, + registrationId, + pniRegistrationId, + ...keysToUpload, + }); + + ourAci = normalizeAci(response.uuid, 'createAccount'); + strictAssert( + isUntaggedPniString(response.pni), + 'Response pni must be untagged' + ); + ourPni = toTaggedPni(response.pni); + deviceId = response.deviceId ?? 1; + + strictAssert( + ourAci === options.ourAci, + 'Server response has unexpected ACI' + ); + strictAssert( + ourPni === options.ourPni, + 'Server response has unexpected PNI' + ); + } else { + throw missingCaseError(options); + } + // `setCredentials` needs to be called // before `saveIdentifyWithAttributes` since `saveIdentityWithAttributes` // indirectly calls `ConversationController.getConversationId()` which @@ -1014,8 +1146,8 @@ export default class AccountManager extends EventTarget { aci: ourAci, pni: ourPni, number, - deviceId: response.deviceId ?? 1, - deviceName: deviceName ?? undefined, + deviceId, + deviceName: options.deviceName, password, }); @@ -1047,22 +1179,16 @@ export default class AccountManager extends EventTarget { ...identityAttrs, publicKey: aciKeyPair.pubKey, }), - pniKeyPair - ? storage.protocol.saveIdentityWithAttributes(ourPni, { - ...identityAttrs, - publicKey: pniKeyPair.pubKey, - }) - : Promise.resolve(), + storage.protocol.saveIdentityWithAttributes(ourPni, { + ...identityAttrs, + publicKey: pniKeyPair.pubKey, + }), ]); const identityKeyMap = { ...(storage.get('identityKeyMap') || {}), [ourAci]: aciKeyPair, - ...(pniKeyPair - ? { - [ourPni]: pniKeyPair, - } - : {}), + [ourPni]: pniKeyPair, }; const registrationIdMap = { ...(storage.get('registrationIdMap') || {}), @@ -1072,9 +1198,7 @@ export default class AccountManager extends EventTarget { await storage.put('identityKeyMap', identityKeyMap); await storage.put('registrationIdMap', registrationIdMap); - if (profileKey) { - await ourProfileKeyService.set(profileKey); - } + await ourProfileKeyService.set(profileKey); if (userAgent) { await storage.put('userAgent', userAgent); } @@ -1084,20 +1208,82 @@ export default class AccountManager extends EventTarget { const regionCode = getRegionCodeForNumber(number); await storage.put('regionCode', regionCode); await storage.protocol.hydrateCaches(); + + const store = storage.protocol; + + await store.storeSignedPreKey( + ourAci, + aciSignedPreKey.keyId, + aciSignedPreKey.keyPair + ); + await store.storeSignedPreKey( + ourPni, + pniSignedPreKey.keyId, + pniSignedPreKey.keyPair + ); + await store.storeKyberPreKeys(ourAci, [ + kyberPreKeyToStoredSignedPreKey(aciPqLastResortPreKey, ourAci), + ]); + await store.storeKyberPreKeys(ourPni, [ + kyberPreKeyToStoredSignedPreKey(pniPqLastResortPreKey, ourPni), + ]); + + await this._confirmKeys( + { + pqLastResortPreKey: keysToUpload.aciPqLastResortPreKey, + signedPreKey: keysToUpload.aciSignedPreKey, + }, + ServiceIdKind.ACI + ); + await this._confirmKeys( + { + pqLastResortPreKey: keysToUpload.pniPqLastResortPreKey, + signedPreKey: keysToUpload.pniSignedPreKey, + }, + ServiceIdKind.PNI + ); + + const uploadKeys = async (kind: ServiceIdKind) => { + try { + const keys = await this._generateSingleUseKeys(kind); + await this.server.registerKeys(keys, kind); + } catch (error) { + if (kind === ServiceIdKind.PNI) { + log.error( + 'Failed to upload PNI prekeys. Moving on', + Errors.toLogFormat(error) + ); + return; + } + + throw error; + } + }; + + await Promise.all([ + uploadKeys(ServiceIdKind.ACI), + uploadKeys(ServiceIdKind.PNI), + ]); } // Exposed only for testing public async _confirmKeys( - keys: UploadKeysType, + { + signedPreKey, + pqLastResortPreKey, + }: Readonly<{ + signedPreKey?: UploadSignedPreKeyType; + pqLastResortPreKey?: UploadSignedPreKeyType; + }>, serviceIdKind: ServiceIdKind ): Promise { + const ourServiceId = + window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); const logId = `AccountManager.confirmKeys(${serviceIdKind})`; const { storage } = window.textsecure; const store = storage.protocol; - const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind); const updatedAt = Date.now(); - const { signedPreKey, pqLastResortPreKey } = keys; if (signedPreKey) { log.info(`${logId}: confirming signed prekey key`, signedPreKey.keyId); await store.confirmSignedPreKey(ourServiceId, signedPreKey.keyId); @@ -1125,47 +1311,31 @@ export default class AccountManager extends EventTarget { } // Very similar to maybeUpdateKeys, but will always generate prekeys and doesn't upload - async _generateKeys( - count: number, + async _generateSingleUseKeys( serviceIdKind: ServiceIdKind, - maybeIdentityKey?: KeyPairType + count = PRE_KEY_GEN_BATCH_SIZE ): Promise { - const logId = `AcountManager.generateKeys(${serviceIdKind})`; - const { storage } = window.textsecure; - const store = storage.protocol; - const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind); - - const identityKey = - maybeIdentityKey ?? store.getIdentityKeyPair(ourServiceId); - strictAssert(identityKey, 'generateKeys: No identity key pair!'); + const ourServiceId = + window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); + const logId = `AccountManager.generateKeys(${serviceIdKind}, ${ourServiceId})`; const preKeys = await this.generateNewPreKeys(serviceIdKind, count); const pqPreKeys = await this.generateNewKyberPreKeys(serviceIdKind, count); - const pqLastResortPreKey = await this.maybeUpdateLastResortKyberKey( - serviceIdKind - ); - const signedPreKey = await this.maybeUpdateSignedPreKey(serviceIdKind); log.info( `${logId}: Generated ` + `${preKeys.length} pre keys, ` + - `${pqPreKeys.length} kyber pre keys, ` + - `${pqLastResortPreKey ? 'a' : 'NO'} last resort kyber pre key, ` + - `and ${signedPreKey ? 'a' : 'NO'} signed pre key.` + `${pqPreKeys.length} kyber pre keys` ); // These are primarily for the summaries they log out await this._cleanPreKeys(serviceIdKind); await this._cleanKyberPreKeys(serviceIdKind); - await this._cleanLastResortKeys(serviceIdKind); - await this._cleanSignedPreKeys(serviceIdKind); return { - identityKey: identityKey.pubKey, + identityKey: this.getIdentityKeyOrThrow(ourServiceId).pubKey, preKeys, pqPreKeys, - pqLastResortPreKey, - signedPreKey, }; } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 1ce7a5264..ac1830e73 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -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([ 'getGroupCredentials', // Devices - 'devices', + 'linkDevice', 'registerCapabilities', 'supportUnauthenticatedDelivery', @@ -751,12 +758,6 @@ const whoamiResultZod = z.object({ }); export type WhoamiResultType = z.infer; -export type ConfirmCodeResultType = Readonly<{ - uuid: AciString; - pni: UntaggedPniString; - deviceId?: number; -}>; - export type CdsLookupOptionsType = Readonly<{ e164s: ReadonlyArray; acis?: ReadonlyArray; @@ -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; + 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; + +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; - confirmCode: ( - options: ConfirmCodeOptionsType - ) => Promise; + createAccount: ( + options: CreateAccountOptionsType + ) => Promise; createGroup: ( group: Proto.IGroup, options: GroupCredentialsType @@ -928,7 +971,6 @@ export type WebAPIType = { } ) => Promise; getAvatar: (path: string) => Promise; - getDevices: () => Promise; getHasSubscription: (subscriberId: Uint8Array) => Promise; getGroup: (options: GroupCredentialsType) => Promise; getGroupFromLink: ( @@ -996,6 +1038,7 @@ export type WebAPIType = { href: string, abortSignal: AbortSignal ) => Promise; + linkDevice: (options: LinkDeviceOptionsType) => Promise; makeProxiedRequest: ( targetUrl: string, options?: ProxiedRequestOptionsType @@ -1044,8 +1087,11 @@ export type WebAPIType = { ) => Promise; registerSupportForUnauthenticatedDelivery: () => Promise; reportMessage: (options: ReportMessageOptionsType) => Promise; - requestVerificationSMS: (number: string, token: string) => Promise; - requestVerificationVoice: (number: string, token: string) => Promise; + requestVerification: ( + number: string, + captcha: string, + transport: VerificationTransport + ) => Promise; checkAccountExistence: (serviceId: ServiceIdString) => Promise; 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 + ): Promise { // 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({ diff --git a/ts/types/ServiceId.ts b/ts/types/ServiceId.ts index 01e1a12c3..08ecfa5f4 100644 --- a/ts/types/ServiceId.ts +++ b/ts/types/ServiceId.ts @@ -184,6 +184,16 @@ export const aciSchema = z return x; }); +export const untaggedPniSchema = z + .string() + .refine(isUntaggedPniString) + .transform(x => { + if (!isUntaggedPniString(x)) { + throw new Error('Refine did not throw!'); + } + return x; + }); + export const serviceIdSchema = z .string() .refine(isServiceIdString) diff --git a/ts/types/VerificationTransport.ts b/ts/types/VerificationTransport.ts new file mode 100644 index 000000000..8f2915419 --- /dev/null +++ b/ts/types/VerificationTransport.ts @@ -0,0 +1,7 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export enum VerificationTransport { + SMS = 'SMS', + Voice = 'Voice', +} diff --git a/yarn.lock b/yarn.lock index 82becdb23..6c72eba96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3379,10 +3379,10 @@ node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/mock-server@4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-4.0.1.tgz#099d1c42ca945b25fb9fbd5642b17969ab0b0256" - integrity sha512-XuU9AGHR6D/aGenlSbs0s5MQve9FLs4S2h437CDJtTdtZao6tHuPYJz701m8CFcpLAujTYNV/BsWnvVgfcvvlQ== +"@signalapp/mock-server@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-4.1.0.tgz#2a96981a3e375df0cbab37476fa448b99df3d143" + integrity sha512-sVcw384ZjkymsQ4f8GSgUTaF3IIhaMBIYqW76Trzf0U46Uw8gD3hhGjBSBb5GAJQWgJKcAusirXhx/D5mF8z3Q== dependencies: "@signalapp/libsignal-client" "^0.30.2" debug "^4.3.2"