From 0c8c3328051e2b11e09d486b8ef5a6c4cfbbfc02 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 9 Mar 2022 11:28:40 -0800 Subject: [PATCH] Use new CDS implementation in staging --- app/main.ts | 11 +- config/default.json | 11 +- config/production.json | 3 + preload.js | 3 +- protos/ContactDiscovery.proto | 30 +++-- sticker-creator/preload.js | 3 +- ts/textsecure/CDSSocket.ts | 211 +++++++++++++++++++----------- ts/textsecure/CDSSocketManager.ts | 31 +++-- ts/textsecure/WebAPI.ts | 90 ++++++++++--- ts/textsecure/WebSocket.ts | 3 + ts/util/getBasicAuth.ts | 18 +++ 11 files changed, 284 insertions(+), 130 deletions(-) create mode 100644 ts/util/getBasicAuth.ts diff --git a/app/main.ts b/app/main.ts index 53adc9474..7c1e9a8e7 100644 --- a/app/main.ts +++ b/app/main.ts @@ -308,12 +308,15 @@ function prepareUrl( serverUrl: config.get('serverUrl'), storageUrl: config.get('storageUrl'), updatesUrl: config.get('updatesUrl'), - directoryUrl: config.get('directoryUrl'), - directoryEnclaveId: config.get('directoryEnclaveId'), - directoryTrustAnchor: config.get('directoryTrustAnchor'), + directoryVersion: config.get('directoryVersion') || 1, + directoryUrl: config.get('directoryUrl') || undefined, + directoryEnclaveId: + config.get('directoryEnclaveId') || undefined, + directoryTrustAnchor: + config.get('directoryTrustAnchor') || undefined, directoryV2Url: config.get('directoryV2Url'), directoryV2PublicKey: config.get('directoryV2PublicKey'), - directoryV2CodeHash: config.get('directoryV2CodeHash'), + directoryV2CodeHashes: config.get('directoryV2CodeHashes'), cdnUrl0: config.get('cdn').get('0'), cdnUrl2: config.get('cdn').get('2'), certificateAuthority: config.get('certificateAuthority'), diff --git a/config/default.json b/config/default.json index 99b6cc328..dad522728 100644 --- a/config/default.json +++ b/config/default.json @@ -1,12 +1,15 @@ { "serverUrl": "https://chat.staging.signal.org", "storageUrl": "https://storage-staging.signal.org", - "directoryUrl": "https://api-staging.directory.signal.org", - "directoryEnclaveId": "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15", - "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", + "directoryVersion": 2, + "directoryUrl": null, + "directoryEnclaveId": null, + "directoryTrustAnchor": null, "directoryV2Url": "https://cdsh.staging.signal.org", "directoryV2PublicKey": "2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74", - "directoryV2CodeHash": "8c8025d787b4e7da35047c342a96c24a7119fd23ed9a3a774454a06315b4852a", + "directoryV2CodeHashes": [ + "2f79dc6c1599b71c70fc2d14f3ea2e3bc65134436eb87011c88845b137af673a" + ], "cdn": { "0": "https://cdn-staging.signal.org", "2": "https://cdn2-staging.signal.org" diff --git a/config/production.json b/config/production.json index dc1dd603a..b407570ac 100644 --- a/config/production.json +++ b/config/production.json @@ -1,7 +1,10 @@ { "serverUrl": "https://chat.signal.org", "storageUrl": "https://storage.signal.org", + "directoryVersion": 1, "directoryUrl": "https://api.directory.signal.org", + "directoryEnclaveId": "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15", + "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", "cdn": { "0": "https://cdn.signal.org", "2": "https://cdn2.signal.org" diff --git a/preload.js b/preload.js index d82fc398a..36bd5612d 100644 --- a/preload.js +++ b/preload.js @@ -376,12 +376,13 @@ try { url: config.serverUrl, storageUrl: config.storageUrl, updatesUrl: config.updatesUrl, + directoryVersion: parseInt(config.directoryVersion, 10), directoryUrl: config.directoryUrl, directoryEnclaveId: config.directoryEnclaveId, directoryTrustAnchor: config.directoryTrustAnchor, directoryV2Url: config.directoryV2Url, directoryV2PublicKey: config.directoryV2PublicKey, - directoryV2CodeHash: config.directoryV2CodeHash, + directoryV2CodeHashes: (config.directoryV2CodeHashes || '').split(','), cdnUrlObject: { 0: config.cdnUrl0, 2: config.cdnUrl2, diff --git a/protos/ContactDiscovery.proto b/protos/ContactDiscovery.proto index 340355dd6..a98dc0bbe 100644 --- a/protos/ContactDiscovery.proto +++ b/protos/ContactDiscovery.proto @@ -4,16 +4,23 @@ 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; + optional bytes aci_uak_pairs = 1; + + // Each E164 is an 8-byte big-endian number, as 8 bytes. + optional bytes prev_e164s = 2; + optional bytes new_e164s = 3; + optional bytes discard_e164s = 4; + + // If true, the client has more pairs or e164s to send. If false or unset, + // this is the client's last request, and processing should commence. + optional bool has_more = 5; + + // If set, a token which allows rate limiting to discount the e164s in + // the request's prev_e164s, only counting new_e164s. If not set, then + // rate limiting considers both prev_e164s' and new_e164s' size. + optional bytes token = 6; } message CDSClientResponse { @@ -29,7 +36,7 @@ message CDSClientResponse { // 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; + optional bytes e164_pni_aci_triples = 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 @@ -37,4 +44,9 @@ message CDSClientResponse { // the same request after the provided number of seconds has passed, // we expect it should work. optional int32 retry_after_secs = 2; + + // A token which allows subsequent calls' rate limiting to discount the + // e164s sent up in this request, only counting those in the next + // request's new_e164s. + optional bytes token = 3; } diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index 5b3acce78..b0b47b156 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -56,12 +56,13 @@ const WebAPI = initializeWebAPI({ url: config.serverUrl, storageUrl: config.storageUrl, updatesUrl: config.updatesUrl, + directoryVersion: parseInt(config.directoryVersion, 10), directoryUrl: config.directoryUrl, directoryEnclaveId: config.directoryEnclaveId, directoryTrustAnchor: config.directoryTrustAnchor, directoryV2Url: config.directoryV2Url, directoryV2PublicKey: config.directoryV2PublicKey, - directoryV2CodeHash: config.directoryV2CodeHash, + directoryV2CodeHashes: (config.directoryV2CodeHashes || '').split(','), cdnUrlObject: { 0: config.cdnUrl0, 2: config.cdnUrl2, diff --git a/ts/textsecure/CDSSocket.ts b/ts/textsecure/CDSSocket.ts index 632e50c45..755de7e82 100644 --- a/ts/textsecure/CDSSocket.ts +++ b/ts/textsecure/CDSSocket.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-only import { EventEmitter } from 'events'; +import { noop } from 'lodash'; +import { Readable } from 'stream'; import type { HsmEnclaveClient } from '@signalapp/signal-client'; import type { connection as WebSocket } from 'websocket'; import Long from 'long'; @@ -10,6 +12,7 @@ import { strictAssert } from '../util/assert'; import { dropNull } from '../util/dropNull'; import { explodePromise } from '../util/explodePromise'; import * as durations from '../util/durations'; +import * as log from '../logging/log'; import type { UUIDStringType } from '../types/UUID'; import { UUID_BYTE_SIZE } from '../types/UUID'; import * as Bytes from '../Bytes'; @@ -23,13 +26,24 @@ enum State { Closed, } -export type CDSRequestOptionsType = Readonly<{ - e164s: ReadonlyArray; - acis: ReadonlyArray; - accessKeys: ReadonlyArray; - auth: CDSAuthType; - timeout?: number; -}>; +export type CDSRequestOptionsType = Readonly< + { + auth: CDSAuthType; + e164s: ReadonlyArray; + timeout?: number; + } & ( + | { + version: 1; + acis?: undefined; + accessKeys?: undefined; + } + | { + version: 2; + acis: ReadonlyArray; + accessKeys: ReadonlyArray; + } + ) +>; export type CDSAuthType = Readonly<{ username: string; @@ -50,19 +64,23 @@ export type CDSSocketResponseType = Readonly<{ retryAfterSecs?: number; }>; +const MAX_E164_COUNT = 5000; const HANDSHAKE_TIMEOUT = 10 * durations.SECOND; const REQUEST_TIMEOUT = 10 * durations.SECOND; -const VERSION = new Uint8Array([0x02]); -const USERNAME_LENGTH = 32; -const PASSWORD_LENGTH = 31; const E164_BYTE_SIZE = 8; +const TRIPLE_BYTE_SIZE = UUID_BYTE_SIZE * 2 + E164_BYTE_SIZE; export class CDSSocket extends EventEmitter { private state = State.Handshake; private readonly finishedHandshake: Promise; - private readonly requestQueue = new Array<(buffer: Buffer) => void>(); + private readonly responseStream = new Readable({ + read: noop, + + // Don't coalesce separate websocket messages + objectMode: true, + }); constructor( private readonly socket: WebSocket, @@ -93,15 +111,25 @@ export class CDSSocket extends EventEmitter { return; } - const requestHandler = this.requestQueue.shift(); - strictAssert( - requestHandler !== undefined, - 'No handler for incoming CDS data' - ); - - requestHandler(this.enclaveClient.establishedRecv(binaryData)); + try { + this.responseStream.push( + this.enclaveClient.establishedRecv(binaryData) + ); + } catch (error) { + this.responseStream.destroy(error); + } }); socket.on('close', (code, reason) => { + if (this.state === State.Established) { + if (code === 1000) { + this.responseStream.push(null); + } else { + this.responseStream.destroy( + new Error(`Socket closed with code ${code} and reason ${reason}`) + ); + } + } + this.state = State.Closed; this.emit('close', code, reason); }); @@ -115,37 +143,32 @@ export class CDSSocket extends EventEmitter { } public async request({ - e164s, - acis, - accessKeys, - auth, + version, timeout = REQUEST_TIMEOUT, + e164s, + acis = [], + accessKeys = [], }: CDSRequestOptionsType): Promise { + strictAssert( + e164s.length < MAX_E164_COUNT, + 'CDSSocket does not support paging. Use this for one-off requests' + ); + + log.info('CDSSocket.request(): awaiting handshake'); await this.finishedHandshake; strictAssert( this.state === State.Established, 'Connection not established' ); - const username = Bytes.fromString(auth.username); - const password = Bytes.fromString(auth.password); - strictAssert( - username.length === USERNAME_LENGTH, - 'Invalid username length' - ); - strictAssert( - password.length === PASSWORD_LENGTH, - 'Invalid password length' - ); - strictAssert( acis.length === accessKeys.length, `Number of ACIs ${acis.length} is different ` + `from number of access keys ${accessKeys.length}` ); - const aciUakPair = new Array(); + const aciUakPairs = new Array(); for (let i = 0; i < acis.length; i += 1) { - aciUakPair.push( + aciUakPairs.push( Bytes.concatenate([ uuidToBytes(acis[i]), Bytes.fromBase64(accessKeys[i]), @@ -154,64 +177,55 @@ export class CDSSocket extends EventEmitter { } const request = Proto.CDSClientRequest.encode({ - username, - password, - e164: e164s.map(e164 => { - // Long.fromString handles numbers with or without a leading '+' - return new Uint8Array(Long.fromString(e164).toBytesBE()); - }), - aciUakPair, + newE164s: Buffer.concat( + e164s.map(e164 => { + // Long.fromString handles numbers with or without a leading '+' + return new Uint8Array(Long.fromString(e164).toBytesBE()); + }) + ), + aciUakPairs: Buffer.concat(aciUakPairs), }).finish(); - const { promise, resolve, reject } = explodePromise(); - const timer = Timers.setTimeout(() => { - reject(new Error('CDS request timed out')); + this.responseStream.destroy(new Error('CDS request timed out')); }, timeout); + log.info(`CDSSocket.request(): sending version=${version} request`); this.socket.sendBytes( - this.enclaveClient.establishedSend(Buffer.concat([VERSION, request])) + this.enclaveClient.establishedSend( + Buffer.concat([Buffer.from([version]), request]) + ) ); - this.requestQueue.push(resolve); - strictAssert( - this.requestQueue.length === 1, - 'Concurrent use of CDS shold not happen' - ); - const responseBytes = await promise; - Timers.clearTimeout(timer); + const resultMap: Map = new Map(); + let retryAfterSecs: number | undefined; - const response = Proto.CDSClientResponse.decode(responseBytes); + for await (const message of this.responseStream) { + log.info('CDSSocket.request(): processing response message'); - const dictionary: Record = - Object.create(null); + const response = Proto.CDSClientResponse.decode(message); + const newRetryAfterSecs = dropNull(response.retryAfterSecs); - for (const tripleBytes of response.e164PniAciTriple ?? []) { - strictAssert( - tripleBytes.length === UUID_BYTE_SIZE * 2 + E164_BYTE_SIZE, - 'Invalid size of CDS response triple' - ); + decodeSingleResponse(resultMap, response); - 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 }; + if (newRetryAfterSecs) { + retryAfterSecs = Math.max(newRetryAfterSecs, retryAfterSecs ?? 0); + } } + const result: Record = + Object.create(null); + + for (const [key, value] of resultMap) { + result[key] = value; + } + + log.info('CDSSocket.request(): done'); + Timers.clearTimeout(timer); + return { - dictionary, - retryAfterSecs: dropNull(response.retryAfterSecs), + dictionary: result, + retryAfterSecs, }; } @@ -239,3 +253,44 @@ export class CDSSocket extends EventEmitter { return super.emit(type, ...args); } } + +function decodeSingleResponse( + resultMap: Map, + response: Proto.CDSClientResponse +): void { + for ( + let i = 0; + i < response.e164PniAciTriples.length; + i += TRIPLE_BYTE_SIZE + ) { + const tripleBytes = response.e164PniAciTriples.slice( + i, + i + TRIPLE_BYTE_SIZE + ); + strictAssert( + tripleBytes.length === TRIPLE_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 e164Long = Long.fromBytesBE(Array.from(e164Bytes)); + if (e164Long.isZero()) { + continue; + } + + const e164 = `+${e164Long.toString()}`; + const pni = bytesToUuid(pniBytes); + const aci = bytesToUuid(aciBytes); + + resultMap.set(e164, { pni, aci }); + } +} diff --git a/ts/textsecure/CDSSocketManager.ts b/ts/textsecure/CDSSocketManager.ts index e947e27fe..add4813b1 100644 --- a/ts/textsecure/CDSSocketManager.ts +++ b/ts/textsecure/CDSSocketManager.ts @@ -9,10 +9,12 @@ import * as Bytes from '../Bytes'; import { prefixPublicKey } from '../Curve'; import type { AbortableProcess } from '../util/AbortableProcess'; import * as durations from '../util/durations'; +import { getBasicAuth } from '../util/getBasicAuth'; import { sleep } from '../util/sleep'; import * as log from '../logging/log'; import { CDSSocket } from './CDSSocket'; import type { + CDSAuthType, CDSRequestOptionsType, CDSSocketDictionaryType, } from './CDSSocket'; @@ -21,7 +23,7 @@ import { connect as connectWebSocket } from './WebSocket'; export type CDSSocketManagerOptionsType = Readonly<{ url: string; publicKey: string; - codeHash: string; + codeHashes: ReadonlyArray; certificateAuthority: string; proxyUrl?: string; version: string; @@ -32,7 +34,7 @@ export type CDSResponseType = CDSSocketDictionaryType; export class CDSSocketManager { private readonly publicKey: PublicKey; - private readonly codeHash: Buffer; + private readonly codeHashes: Array; private readonly proxyAgent?: ReturnType; @@ -42,7 +44,9 @@ export class CDSSocketManager { this.publicKey = PublicKey.deserialize( Buffer.from(prefixPublicKey(Bytes.fromHex(options.publicKey))) ); - this.codeHash = Buffer.from(Bytes.fromHex(options.codeHash)); + this.codeHashes = options.codeHashes.map(hash => + Buffer.from(Bytes.fromHex(hash)) + ); if (options.proxyUrl) { this.proxyAgent = new ProxyAgent(options.proxyUrl); } @@ -58,8 +62,10 @@ export class CDSSocketManager { await sleep(delay); } + const { auth } = options; + log.info('CDSSocketManager: connecting socket'); - const socket = await this.connect().getResult(); + const socket = await this.connect(auth).getResult(); log.info('CDSSocketManager: connected socket'); try { @@ -79,16 +85,14 @@ export class CDSSocketManager { } } - private connect(): AbortableProcess { - const enclaveClient = HsmEnclaveClient.new(this.publicKey, [this.codeHash]); + private connect(auth: CDSAuthType): AbortableProcess { + const enclaveClient = HsmEnclaveClient.new(this.publicKey, this.codeHashes); - const { - publicKey: publicKeyHex, - codeHash: codeHashHex, - version, - } = this.options; + const { publicKey: publicKeyHex, codeHashes, version } = this.options; - const url = `${this.options.url}/discovery/${publicKeyHex}/${codeHashHex}`; + const url = `${ + this.options.url + }/discovery/${publicKeyHex}/${codeHashes.join(',')}`; return connectWebSocket({ name: 'CDSSocket', @@ -96,6 +100,9 @@ export class CDSSocketManager { version, proxyAgent: this.proxyAgent, certificateAuthority: this.options.certificateAuthority, + extraHeaders: { + authorization: getBasicAuth(auth), + }, createResource: (socket: WebSocket): CDSSocket => { return new CDSSocket(socket, enclaveClient); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index e32a3e06b..b4d92cd0d 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -34,6 +34,7 @@ import { getUserAgent } from '../util/getUserAgent'; import { getStreamWithTimeout } from '../util/getStreamWithTimeout'; import { formatAcceptLanguageHeader } from '../util/userLanguages'; import { toWebSafeBase64 } from '../util/webSafeBase64'; +import { getBasicAuth } from '../util/getBasicAuth'; import type { SocketStatus } from '../types/SocketStatus'; import { toLogFormat } from '../types/errors'; import { isPackIdValid, redactPackId } from '../types/Stickers'; @@ -338,10 +339,10 @@ async function _promiseAjax( fetchOptions.headers['Unidentified-Access-Key'] = accessKey; } } else if (options.user && options.password) { - const auth = Bytes.toBase64( - Bytes.fromString(`${options.user}:${options.password}`) - ); - fetchOptions.headers.Authorization = `Basic ${auth}`; + fetchOptions.headers.Authorization = getBasicAuth({ + username: options.user, + password: options.password, + }); } if (options.contentType) { @@ -596,12 +597,13 @@ type InitializeOptionsType = { url: string; storageUrl: string; updatesUrl: string; - directoryEnclaveId: string; - directoryTrustAnchor: string; - directoryUrl: string; + directoryVersion: number; + directoryUrl?: string; + directoryEnclaveId?: string; + directoryTrustAnchor?: string; directoryV2Url: string; directoryV2PublicKey: string; - directoryV2CodeHash: string; + directoryV2CodeHashes: ReadonlyArray; cdnUrlObject: { readonly '0': string; readonly [propName: string]: string; @@ -999,12 +1001,13 @@ export function initialize({ url, storageUrl, updatesUrl, + directoryVersion, + directoryUrl, directoryEnclaveId, directoryTrustAnchor, - directoryUrl, directoryV2Url, directoryV2PublicKey, - directoryV2CodeHash, + directoryV2CodeHashes, cdnUrlObject, certificateAuthority, contentProxyUrl, @@ -1020,14 +1023,26 @@ export function initialize({ if (!is.string(updatesUrl)) { throw new Error('WebAPI.initialize: Invalid updatesUrl'); } - if (!is.string(directoryEnclaveId)) { - throw new Error('WebAPI.initialize: Invalid directory enclave id'); - } - if (!is.string(directoryTrustAnchor)) { - throw new Error('WebAPI.initialize: Invalid directory enclave id'); - } - if (!is.string(directoryUrl)) { - throw new Error('WebAPI.initialize: Invalid directory url'); + if (directoryVersion === 1) { + if (!is.string(directoryEnclaveId)) { + throw new Error('WebAPI.initialize: Invalid directory enclave id'); + } + if (!is.string(directoryTrustAnchor)) { + throw new Error('WebAPI.initialize: Invalid directory trust anchor'); + } + if (!is.string(directoryUrl)) { + throw new Error('WebAPI.initialize: Invalid directory url'); + } + } else { + if (directoryEnclaveId) { + throw new Error('WebAPI.initialize: Invalid directory enclave id'); + } + if (directoryTrustAnchor) { + throw new Error('WebAPI.initialize: Invalid directory trust anchor'); + } + if (directoryUrl) { + throw new Error('WebAPI.initialize: Invalid directory url'); + } } if (!is.string(directoryV2Url)) { throw new Error('WebAPI.initialize: Invalid directory V2 url'); @@ -1035,7 +1050,7 @@ export function initialize({ if (!is.string(directoryV2PublicKey)) { throw new Error('WebAPI.initialize: Invalid directory V2 public key'); } - if (!is.string(directoryV2CodeHash)) { + if (!is.array(directoryV2CodeHashes)) { throw new Error('WebAPI.initialize: Invalid directory V2 code hash'); } if (!is.object(cdnUrlObject)) { @@ -1104,7 +1119,7 @@ export function initialize({ const cdsSocketManager = new CDSSocketManager({ url: directoryV2Url, publicKey: directoryV2PublicKey, - codeHash: directoryV2CodeHash, + codeHashes: directoryV2CodeHashes, certificateAuthority, version, proxyUrl, @@ -2723,6 +2738,7 @@ export function initialize({ username: string; password: string; }> { + strictAssert(directoryVersion === 1, 'Legacy CDS should not be used'); return (await _ajax({ call: 'directoryAuth', httpType: 'GET', @@ -2748,6 +2764,9 @@ export function initialize({ serverStaticPublic: Uint8Array; quote: Uint8Array; }) { + strictAssert(directoryVersion === 1, 'Legacy CDS should not be used'); + strictAssert(directoryEnclaveId, 'Legacy CDS needs directoryEnclaveId'); + const SGX_CONSTANTS = getSgxConstants(); const quote = Buffer.from(quoteBytes); @@ -2835,6 +2854,8 @@ export function initialize({ }, encodedQuote: string ) { + strictAssert(directoryVersion === 1, 'Legacy CDS should not be used'); + // Parse timestamp as UTC const { timestamp } = signatureBody; const utcTimestamp = timestamp.endsWith('Z') @@ -2862,6 +2883,12 @@ export function initialize({ signatureBody: string, certificates: string ) { + strictAssert(directoryVersion === 1, 'Legacy CDS should not be used'); + strictAssert( + directoryTrustAnchor, + 'Legacy CDS needs directoryTrustAnchor' + ); + const CERT_PREFIX = '-----BEGIN CERTIFICATE-----'; const pem = compact( certificates.split(CERT_PREFIX).map(match => { @@ -2922,6 +2949,8 @@ export function initialize({ username: string; password: string; }) { + strictAssert(directoryVersion === 1, 'Legacy CDS should not be used'); + const keyPair = generateKeyPair(); const { privKey, pubKey } = keyPair; // Remove first "key type" byte from public key @@ -3051,7 +3080,7 @@ export function initialize({ }; } - async function getUuidsForE164s( + async function getLegacyUuidsForE164s( e164s: ReadonlyArray ): Promise> { const directoryAuth = await getDirectoryAuth(); @@ -3127,6 +3156,24 @@ export function initialize({ return zipObject(e164s, uuids); } + async function getUuidsForE164s( + e164s: ReadonlyArray + ): Promise> { + if (directoryVersion === 1) { + return getLegacyUuidsForE164s(e164s); + } + + const auth = await getDirectoryAuthV2(); + + const dictionary = await cdsSocketManager.request({ + version: 1, + auth, + e164s, + }); + + return mapValues(dictionary, value => value.aci ?? null); + } + async function getUuidsForE164sV2({ e164s, acis, @@ -3135,6 +3182,7 @@ export function initialize({ const auth = await getDirectoryAuthV2(); return cdsSocketManager.request({ + version: 2, auth, e164s, acis, diff --git a/ts/textsecure/WebSocket.ts b/ts/textsecure/WebSocket.ts index 200462a0a..af2afcac1 100644 --- a/ts/textsecure/WebSocket.ts +++ b/ts/textsecure/WebSocket.ts @@ -28,6 +28,7 @@ export type ConnectOptionsType = Readonly<{ version: string; proxyAgent?: ReturnType; timeout?: number; + extraHeaders?: Record; createResource(socket: WebSocket): Resource; }>; @@ -38,6 +39,7 @@ export function connect({ certificateAuthority, version, proxyAgent, + extraHeaders = {}, timeout = TEN_SECONDS, createResource, }: ConnectOptionsType): AbortableProcess { @@ -46,6 +48,7 @@ export function connect({ .replace('http://', 'ws://'); const headers = { + ...extraHeaders, 'User-Agent': getUserAgent(version), }; const client = new WebSocketClient({ diff --git a/ts/util/getBasicAuth.ts b/ts/util/getBasicAuth.ts new file mode 100644 index 000000000..64cb06d61 --- /dev/null +++ b/ts/util/getBasicAuth.ts @@ -0,0 +1,18 @@ +// Copyright 2020-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { fromString, toBase64 } from '../Bytes'; + +export type GetBasicAuthOptionsType = Readonly<{ + username: string; + password: string; +}>; + +export function getBasicAuth({ + username, + password, +}: GetBasicAuthOptionsType): string { + const auth = toBase64(fromString(`${username}:${password}`)); + + return `Basic ${auth}`; +}