diff --git a/config/default.json b/config/default.json index b8407955074..2ac56386e72 100644 --- a/config/default.json +++ b/config/default.json @@ -6,7 +6,7 @@ "directoryTrustAnchor": "-----BEGIN CERTIFICATE-----\nMIIFSzCCA7OgAwIBAgIJANEHdl0yo7CUMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV\nBAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwLU2FudGEgQ2xhcmExGjAYBgNV\nBAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQDDCdJbnRlbCBTR1ggQXR0ZXN0\nYXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwIBcNMTYxMTE0MTUzNzMxWhgPMjA0OTEy\nMzEyMzU5NTlaMH4xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwL\nU2FudGEgQ2xhcmExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQD\nDCdJbnRlbCBTR1ggQXR0ZXN0YXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwggGiMA0G\nCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCfPGR+tXc8u1EtJzLA10Feu1Wg+p7e\nLmSRmeaCHbkQ1TF3Nwl3RmpqXkeGzNLd69QUnWovYyVSndEMyYc3sHecGgfinEeh\nrgBJSEdsSJ9FpaFdesjsxqzGRa20PYdnnfWcCTvFoulpbFR4VBuXnnVLVzkUvlXT\nL/TAnd8nIZk0zZkFJ7P5LtePvykkar7LcSQO85wtcQe0R1Raf/sQ6wYKaKmFgCGe\nNpEJUmg4ktal4qgIAxk+QHUxQE42sxViN5mqglB0QJdUot/o9a/V/mMeH8KvOAiQ\nbyinkNndn+Bgk5sSV5DFgF0DffVqmVMblt5p3jPtImzBIH0QQrXJq39AT8cRwP5H\nafuVeLHcDsRp6hol4P+ZFIhu8mmbI1u0hH3W/0C2BuYXB5PC+5izFFh/nP0lc2Lf\n6rELO9LZdnOhpL1ExFOq9H/B8tPQ84T3Sgb4nAifDabNt/zu6MmCGo5U8lwEFtGM\nRoOaX4AS+909x00lYnmtwsDVWv9vBiJCXRsCAwEAAaOByTCBxjBgBgNVHR8EWTBX\nMFWgU6BRhk9odHRwOi8vdHJ1c3RlZHNlcnZpY2VzLmludGVsLmNvbS9jb250ZW50\nL0NSTC9TR1gvQXR0ZXN0YXRpb25SZXBvcnRTaWduaW5nQ0EuY3JsMB0GA1UdDgQW\nBBR4Q3t2pn680K9+QjfrNXw7hwFRPDAfBgNVHSMEGDAWgBR4Q3t2pn680K9+Qjfr\nNXw7hwFRPDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkq\nhkiG9w0BAQsFAAOCAYEAeF8tYMXICvQqeXYQITkV2oLJsp6J4JAqJabHWxYJHGir\nIEqucRiJSSx+HjIJEUVaj8E0QjEud6Y5lNmXlcjqRXaCPOqK0eGRz6hi+ripMtPZ\nsFNaBwLQVV905SDjAzDzNIDnrcnXyB4gcDFCvwDFKKgLRjOB/WAqgscDUoGq5ZVi\nzLUzTqiQPmULAQaB9c6Oti6snEFJiCQ67JLyW/E83/frzCmO5Ru6WjU4tmsmy8Ra\nUd4APK0wZTGtfPXU7w+IBdG5Ez0kE1qzxGQaL4gINJ1zMyleDnbuS8UicjJijvqA\n152Sq049ESDz+1rRGc2NVEqh1KaGXmtXvqxXcTB+Ljy5Bw2ke0v8iGngFBPqCTVB\n3op5KBG3RjbF6RRSzwzuWfL7QErNC8WEy5yDVARzTA5+xmBc388v9Dm21HGfcC8O\nDD+gT9sSpssq0ascmvH49MOgjt1yoysLtdCtJW/9FZpoOypaHx0R+mJTLwPXVMrv\nDaVzWh5aiEx+idkSGMnX\n-----END CERTIFICATE-----\n", "directoryV2Url": "https://cdsh.staging.signal.org", "directoryV2PublicKey": "2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74", - "directoryV2CodeHash": "ec58c0d7561de8d5657f3a4b22a635eaa305204e9359dcc80a99dfd0c5f1cbf2", + "directoryV2CodeHash": "8c8025d787b4e7da35047c342a96c24a7119fd23ed9a3a774454a06315b4852a", "cdn": { "0": "https://cdn-staging.signal.org", "2": "https://cdn2-staging.signal.org" diff --git a/protos/ContactDiscovery.proto b/protos/ContactDiscovery.proto new file mode 100644 index 00000000000..340355dd66d --- /dev/null +++ b/protos/ContactDiscovery.proto @@ -0,0 +1,40 @@ +// Copyright 2021-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +package signalservice; + +message CDSClientRequest { + // From Signal /v2/directory/auth + optional bytes username = 1; + optional bytes password = 2; + + // Each e164 is a big-endian uint64 (8 bytes). + repeated bytes e164 = 3; + + // Each ACI/UAK pair is a 32-byte buffer, containing the 16-byte ACI followed + // by its 16-byte UAK. + repeated bytes aci_uak_pair = 4; +} + +message CDSClientResponse { + // Each triple is an 8-byte e164, a 16-byte PNI, and a 16-byte ACI. + // If the e164 was not found, PNI and ACI are all zeros. If the PNI + // was found but the ACI was not, the PNI will be non-zero and the ACI + // will be all zeros. ACI will be returned if one of the returned + // PNIs has an ACI/UAK pair that matches. + // + // Should the request be successful (IE: a successful status returned), + // |e164_pni_aci_triple| will always equal |e164| of the request, + // so the entire marshalled size of the response will be (2+32)*|e164|, + // where the additional 2 bytes are the id/type/length additions of the + // protobuf marshaling added to each byte array. This avoids any data + // leakage based on the size of the encrypted output. + repeated bytes e164_pni_aci_triple = 1; + + // If the user has run out of quota for lookups, they will receive + // a response with just the following field set, followed by a websocket + // closure of type 4008 (RESOURCE_EXHAUSTED). Should they retry exactly + // the same request after the provided number of seconds has passed, + // we expect it should work. + optional int32 retry_after_secs = 2; +} diff --git a/ts/Crypto.ts b/ts/Crypto.ts index b1cdc6e169a..ab4c0d1c99b 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -12,7 +12,7 @@ import { calculateAgreement, generateKeyPair } from './Curve'; import * as log from './logging/log'; import { HashType, CipherType } from './types/Crypto'; import { ProfileDecryptError } from './types/errors'; -import { UUID } from './types/UUID'; +import { UUID, UUID_BYTE_SIZE } from './types/UUID'; import type { UUIDStringType } from './types/UUID'; export { HashType, CipherType }; @@ -458,7 +458,7 @@ export function uuidToBytes(uuid: string): Uint8Array { } export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType { - if (bytes.byteLength !== 16) { + if (bytes.byteLength !== UUID_BYTE_SIZE) { log.warn( 'bytesToUuid: received an Uint8Array of invalid length. ' + 'Returning undefined' @@ -475,8 +475,8 @@ export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType { export function splitUuids(buffer: Uint8Array): Array { const uuids = []; - for (let i = 0; i < buffer.byteLength; i += 16) { - const bytes = getBytes(buffer, i, 16); + for (let i = 0; i < buffer.byteLength; i += UUID_BYTE_SIZE) { + const bytes = getBytes(buffer, i, UUID_BYTE_SIZE); const hex = Bytes.toHex(bytes); const chunks = [ hex.substring(0, 8), diff --git a/ts/textsecure/CDSSocket.ts b/ts/textsecure/CDSSocket.ts index cb878266d72..632e50c4506 100644 --- a/ts/textsecure/CDSSocket.ts +++ b/ts/textsecure/CDSSocket.ts @@ -7,12 +7,15 @@ import type { connection as WebSocket } from 'websocket'; import Long from 'long'; import { strictAssert } from '../util/assert'; +import { dropNull } from '../util/dropNull'; import { explodePromise } from '../util/explodePromise'; import * as durations from '../util/durations'; import type { UUIDStringType } from '../types/UUID'; +import { UUID_BYTE_SIZE } from '../types/UUID'; import * as Bytes from '../Bytes'; import * as Timers from '../Timers'; -import { splitUuids } from '../Crypto'; +import { uuidToBytes, bytesToUuid } from '../Crypto'; +import { SignalService as Proto } from '../protobuf'; enum State { Handshake, @@ -22,6 +25,8 @@ enum State { export type CDSRequestOptionsType = Readonly<{ e164s: ReadonlyArray; + acis: ReadonlyArray; + accessKeys: ReadonlyArray; auth: CDSAuthType; timeout?: number; }>; @@ -31,11 +36,26 @@ export type CDSAuthType = Readonly<{ password: string; }>; +export type CDSSocketDictionaryEntryType = Readonly<{ + aci: UUIDStringType | undefined; + pni: UUIDStringType | undefined; +}>; + +export type CDSSocketDictionaryType = Readonly< + Record +>; + +export type CDSSocketResponseType = Readonly<{ + dictionary: CDSSocketDictionaryType; + retryAfterSecs?: number; +}>; + const HANDSHAKE_TIMEOUT = 10 * durations.SECOND; const REQUEST_TIMEOUT = 10 * durations.SECOND; -const VERSION = new Uint8Array([0x01]); +const VERSION = new Uint8Array([0x02]); const USERNAME_LENGTH = 32; const PASSWORD_LENGTH = 31; +const E164_BYTE_SIZE = 8; export class CDSSocket extends EventEmitter { private state = State.Handshake; @@ -96,9 +116,11 @@ export class CDSSocket extends EventEmitter { public async request({ e164s, + acis, + accessKeys, auth, timeout = REQUEST_TIMEOUT, - }: CDSRequestOptionsType): Promise> { + }: CDSRequestOptionsType): Promise { await this.finishedHandshake; strictAssert( this.state === State.Established, @@ -116,15 +138,30 @@ export class CDSSocket extends EventEmitter { 'Invalid password length' ); - const request = Bytes.concatenate([ - VERSION, + strictAssert( + acis.length === accessKeys.length, + `Number of ACIs ${acis.length} is different ` + + `from number of access keys ${accessKeys.length}` + ); + const aciUakPair = new Array(); + for (let i = 0; i < acis.length; i += 1) { + aciUakPair.push( + Bytes.concatenate([ + uuidToBytes(acis[i]), + Bytes.fromBase64(accessKeys[i]), + ]) + ); + } + + const request = Proto.CDSClientRequest.encode({ username, password, - ...e164s.map(e164 => { + e164: e164s.map(e164 => { // Long.fromString handles numbers with or without a leading '+' return new Uint8Array(Long.fromString(e164).toBytesBE()); }), - ]); + aciUakPair, + }).finish(); const { promise, resolve, reject } = explodePromise(); @@ -133,7 +170,7 @@ export class CDSSocket extends EventEmitter { }, timeout); this.socket.sendBytes( - this.enclaveClient.establishedSend(Buffer.from(request)) + this.enclaveClient.establishedSend(Buffer.concat([VERSION, request])) ); this.requestQueue.push(resolve); @@ -141,11 +178,41 @@ export class CDSSocket extends EventEmitter { this.requestQueue.length === 1, 'Concurrent use of CDS shold not happen' ); - const uuids = await promise; - + const responseBytes = await promise; Timers.clearTimeout(timer); - return splitUuids(uuids); + const response = Proto.CDSClientResponse.decode(responseBytes); + + const dictionary: Record = + Object.create(null); + + for (const tripleBytes of response.e164PniAciTriple ?? []) { + strictAssert( + tripleBytes.length === UUID_BYTE_SIZE * 2 + E164_BYTE_SIZE, + 'Invalid size of CDS response triple' + ); + + let offset = 0; + const e164Bytes = tripleBytes.slice(offset, offset + E164_BYTE_SIZE); + offset += E164_BYTE_SIZE; + + const pniBytes = tripleBytes.slice(offset, offset + UUID_BYTE_SIZE); + offset += UUID_BYTE_SIZE; + + const aciBytes = tripleBytes.slice(offset, offset + UUID_BYTE_SIZE); + offset += UUID_BYTE_SIZE; + + const e164 = `+${Long.fromBytesBE(Array.from(e164Bytes)).toString()}`; + const pni = bytesToUuid(pniBytes); + const aci = bytesToUuid(aciBytes); + + dictionary[e164] = { pni, aci }; + } + + return { + dictionary, + retryAfterSecs: dropNull(response.retryAfterSecs), + }; } // EventEmitter types diff --git a/ts/textsecure/CDSSocketManager.ts b/ts/textsecure/CDSSocketManager.ts index 027495e4fec..e947e27feec 100644 --- a/ts/textsecure/CDSSocketManager.ts +++ b/ts/textsecure/CDSSocketManager.ts @@ -8,10 +8,14 @@ import type { connection as WebSocket } from 'websocket'; import * as Bytes from '../Bytes'; import { prefixPublicKey } from '../Curve'; import type { AbortableProcess } from '../util/AbortableProcess'; +import * as durations from '../util/durations'; +import { sleep } from '../util/sleep'; import * as log from '../logging/log'; -import type { UUIDStringType } from '../types/UUID'; import { CDSSocket } from './CDSSocket'; -import type { CDSRequestOptionsType } from './CDSSocket'; +import type { + CDSRequestOptionsType, + CDSSocketDictionaryType, +} from './CDSSocket'; import { connect as connectWebSocket } from './WebSocket'; export type CDSSocketManagerOptionsType = Readonly<{ @@ -23,6 +27,8 @@ export type CDSSocketManagerOptionsType = Readonly<{ version: string; }>; +export type CDSResponseType = CDSSocketDictionaryType; + export class CDSSocketManager { private readonly publicKey: PublicKey; @@ -30,6 +36,8 @@ export class CDSSocketManager { private readonly proxyAgent?: ReturnType; + private retryAfter?: number; + constructor(private readonly options: CDSSocketManagerOptionsType) { this.publicKey = PublicKey.deserialize( Buffer.from(prefixPublicKey(Bytes.fromHex(options.publicKey))) @@ -42,13 +50,29 @@ export class CDSSocketManager { public async request( options: CDSRequestOptionsType - ): Promise> { + ): Promise { + if (this.retryAfter !== undefined) { + const delay = Math.max(0, this.retryAfter - Date.now()); + + log.info(`CDSSocketManager: waiting ${delay}ms before retrying`); + await sleep(delay); + } + log.info('CDSSocketManager: connecting socket'); const socket = await this.connect().getResult(); log.info('CDSSocketManager: connected socket'); try { - return await socket.request(options); + const { dictionary, retryAfterSecs = 0 } = await socket.request(options); + + if (retryAfterSecs > 0) { + this.retryAfter = Math.max( + this.retryAfter ?? Date.now(), + Date.now() + retryAfterSecs * durations.SECOND + ); + } + + return dictionary; } finally { log.info('CDSSocketManager: closing socket'); socket.close(3000, 'Normal'); diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index ed6991d7a91..a60601dd8bc 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -41,6 +41,7 @@ import type { SendLogCallbackType, } from './OutgoingMessage'; import OutgoingMessage from './OutgoingMessage'; +import type { CDSResponseType } from './CDSSocketManager'; import * as Bytes from '../Bytes'; import { getRandomBytes, getZeroes, encryptAttachment } from '../Crypto'; import type { @@ -2074,9 +2075,15 @@ export default class MessageSender { } async getUuidsForE164sV2( - numbers: ReadonlyArray - ): Promise> { - return this.server.getUuidsForE164sV2(numbers); + e164s: ReadonlyArray, + acis: ReadonlyArray, + accessKeys: ReadonlyArray + ): Promise { + return this.server.getUuidsForE164sV2({ + e164s, + acis, + accessKeys, + }); } async getAvatar(path: string): Promise> { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 0e895a78945..1c637375560 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -55,6 +55,7 @@ import type { StorageServiceCredentials, } from '../textsecure.d'; import { SocketManager } from './SocketManager'; +import type { CDSResponseType } from './CDSSocketManager'; import { CDSSocketManager } from './CDSSocketManager'; import type WebSocketResource from './WebsocketResources'; import { SignalService as Proto } from '../protobuf'; @@ -758,6 +759,12 @@ export type ConfirmCodeResultType = Readonly<{ deviceId?: number; }>; +export type GetUuidsForE164sV2OptionsType = Readonly<{ + e164s: ReadonlyArray; + acis: ReadonlyArray; + accessKeys: ReadonlyArray; +}>; + export type WebAPIType = { confirmCode: ( number: string, @@ -840,8 +847,8 @@ export type WebAPIType = { e164s: ReadonlyArray ) => Promise>; getUuidsForE164sV2: ( - e164s: ReadonlyArray - ) => Promise>; + options: GetUuidsForE164sV2OptionsType + ) => Promise; fetchLinkPreviewMetadata: ( href: string, abortSignal: AbortSignal @@ -3005,17 +3012,19 @@ export function initialize({ return zipObject(e164s, uuids); } - async function getUuidsForE164sV2( - e164s: ReadonlyArray - ): Promise> { + async function getUuidsForE164sV2({ + e164s, + acis, + accessKeys, + }: GetUuidsForE164sV2OptionsType): Promise { const auth = await getDirectoryAuthV2(); - const uuids = await cdsSocketManager.request({ + return cdsSocketManager.request({ auth, e164s, + acis, + accessKeys, }); - - return zipObject(e164s, uuids); } } } diff --git a/ts/types/UUID.ts b/ts/types/UUID.ts index 2060a3aa91c..be2872384fb 100644 --- a/ts/types/UUID.ts +++ b/ts/types/UUID.ts @@ -14,6 +14,8 @@ export enum UUIDKind { Unknown = 'Unknown', } +export const UUID_BYTE_SIZE = 16; + export const isValidUuid = (value: unknown): value is UUIDStringType => typeof value === 'string' && /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(