diff --git a/app/main.ts b/app/main.ts index 46624a00d..d4e2971ce 100644 --- a/app/main.ts +++ b/app/main.ts @@ -49,7 +49,10 @@ import './startup_config'; import type { ConfigType } from './config'; import type { RendererConfigType } from '../ts/types/RendererConfig'; -import { rendererConfigSchema } from '../ts/types/RendererConfig'; +import { + directoryConfigSchema, + rendererConfigSchema, +} from '../ts/types/RendererConfig'; import config from './config'; import { Environment, @@ -368,15 +371,7 @@ async function prepareUrl( ): Promise { const theme = await getResolvedThemeSetting(); - const urlParams: RendererConfigType = { - name: packageJson.productName, - locale: getLocale().name, - version: app.getVersion(), - buildCreation: config.get('buildCreation'), - buildExpiration: config.get('buildExpiration'), - serverUrl: config.get('serverUrl'), - storageUrl: config.get('storageUrl'), - updatesUrl: config.get('updatesUrl'), + const directoryConfig = directoryConfigSchema.safeParse({ directoryVersion: config.get('directoryVersion') || 1, directoryUrl: config.get('directoryUrl') || undefined, directoryEnclaveId: @@ -388,6 +383,28 @@ async function prepareUrl( config.get('directoryV2PublicKey') || undefined, directoryV2CodeHashes: config.get | null>('directoryV2CodeHashes') || undefined, + directoryV3Url: config.get('directoryV3Url') || undefined, + directoryV3MRENCLAVE: + config.get('directoryV3MRENCLAVE') || undefined, + directoryV3Root: config.get('directoryV3Root') || undefined, + }); + if (!directoryConfig.success) { + throw new Error( + `prepareUrl: Failed to parse renderer directory config ${JSON.stringify( + directoryConfig.error.flatten() + )}` + ); + } + + const urlParams: RendererConfigType = { + name: packageJson.productName, + locale: getLocale().name, + version: app.getVersion(), + buildCreation: config.get('buildCreation'), + buildExpiration: config.get('buildExpiration'), + serverUrl: config.get('serverUrl'), + storageUrl: config.get('storageUrl'), + updatesUrl: config.get('updatesUrl'), cdnUrl0: config.get('cdn').get('0'), cdnUrl2: config.get('cdn').get('2'), certificateAuthority: config.get('certificateAuthority'), @@ -408,6 +425,8 @@ async function prepareUrl( homePath: app.getPath('home'), crashDumpsPath: app.getPath('crashDumps'), + directoryConfig: directoryConfig.data, + // Only used by the main window isMainWindowFullScreen: Boolean(mainWindow?.isFullScreen()), diff --git a/config/default.json b/config/default.json index dad522728..d640a8817 100644 --- a/config/default.json +++ b/config/default.json @@ -1,15 +1,16 @@ { "serverUrl": "https://chat.staging.signal.org", "storageUrl": "https://storage-staging.signal.org", - "directoryVersion": 2, + "directoryVersion": 3, "directoryUrl": null, "directoryEnclaveId": null, "directoryTrustAnchor": null, - "directoryV2Url": "https://cdsh.staging.signal.org", - "directoryV2PublicKey": "2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74", - "directoryV2CodeHashes": [ - "2f79dc6c1599b71c70fc2d14f3ea2e3bc65134436eb87011c88845b137af673a" - ], + "directoryV2Url": null, + "directoryV2PublicKey": null, + "directoryV2CodeHashes": null, + "directoryV3Url": "https://cdsi.staging.signal.org", + "directoryV3MRENCLAVE": "51133fecb3fa18aaf0c8f64cb763656d3272d9faaacdb26ae7df082e414fb142", + "directoryV3Root": "-----BEGIN CERTIFICATE-----\nMIICjzCCAjSgAwIBAgIUImUM1lqdNInzg7SVUr9QGzknBqwwCgYIKoZIzj0EAwIw\naDEaMBgGA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENv\ncnBvcmF0aW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJ\nBgNVBAYTAlVTMB4XDTE4MDUyMTEwNDUxMFoXDTQ5MTIzMTIzNTk1OVowaDEaMBgG\nA1UEAwwRSW50ZWwgU0dYIFJvb3QgQ0ExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0\naW9uMRQwEgYDVQQHDAtTYW50YSBDbGFyYTELMAkGA1UECAwCQ0ExCzAJBgNVBAYT\nAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC6nEwMDIYZOj/iPWsCzaEKi7\n1OiOSLRFhWGjbnBVJfVnkY4u3IjkDYYL0MxO4mqsyYjlBalTVYxFP2sJBK5zlKOB\nuzCBuDAfBgNVHSMEGDAWgBQiZQzWWp00ifODtJVSv1AbOScGrDBSBgNVHR8ESzBJ\nMEegRaBDhkFodHRwczovL2NlcnRpZmljYXRlcy50cnVzdGVkc2VydmljZXMuaW50\nZWwuY29tL0ludGVsU0dYUm9vdENBLmRlcjAdBgNVHQ4EFgQUImUM1lqdNInzg7SV\nUr9QGzknBqwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwCgYI\nKoZIzj0EAwIDSQAwRgIhAOW/5QkR+S9CiSDcNoowLuPRLsWGf/Yi7GSX94BgwTwg\nAiEA4J0lrHoMs+Xo5o/sX6O9QWxHRAvZUGOdRQ7cvqRXaqI=\n-----END CERTIFICATE-----\n", "cdn": { "0": "https://cdn-staging.signal.org", "2": "https://cdn2-staging.signal.org" diff --git a/package.json b/package.json index fb055b3d8..a56b16fe3 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@indutny/frameless-titlebar": "2.2.0", "@popperjs/core": "2.9.2", "@react-spring/web": "9.4.5", - "@signalapp/libsignal-client": "0.16.0", + "@signalapp/libsignal-client": "0.17.0", "@sindresorhus/is": "0.8.0", "@types/fabric": "4.5.3", "abort-controller": "3.0.0", diff --git a/protos/ContactDiscovery.proto b/protos/ContactDiscovery.proto index a98dc0bbe..5206bbe4d 100644 --- a/protos/ContactDiscovery.proto +++ b/protos/ContactDiscovery.proto @@ -21,6 +21,10 @@ message CDSClientRequest { // 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; + + // After receiving a new token from the server, send back a message just + // containing a token_ack. + optional bool token_ack = 7; } message CDSClientResponse { diff --git a/sticker-creator/window/phase3-sticker-functions.ts b/sticker-creator/window/phase3-sticker-functions.ts index 39230de3c..6d9a74158 100644 --- a/sticker-creator/window/phase3-sticker-functions.ts +++ b/sticker-creator/window/phase3-sticker-functions.ts @@ -30,13 +30,7 @@ const WebAPI = initializeWebAPI({ url: config.serverUrl, storageUrl: config.storageUrl, updatesUrl: config.updatesUrl, - directoryVersion: config.directoryVersion, - directoryUrl: config.directoryUrl, - directoryEnclaveId: config.directoryEnclaveId, - directoryTrustAnchor: config.directoryTrustAnchor, - directoryV2Url: config.directoryV2Url, - directoryV2PublicKey: config.directoryV2PublicKey, - directoryV2CodeHashes: config.directoryV2CodeHashes, + directoryConfig: config.directoryConfig, cdnUrlObject: { 0: config.cdnUrl0, 2: config.cdnUrl2, diff --git a/ts/textsecure/CDSSocket.ts b/ts/textsecure/CDSSocket.ts deleted file mode 100644 index ea011bdd4..000000000 --- a/ts/textsecure/CDSSocket.ts +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { EventEmitter } from 'events'; -import { noop } from 'lodash'; -import { Readable } from 'stream'; -import type { HsmEnclaveClient } from '@signalapp/libsignal-client'; -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 * as log from '../logging/log'; -import type { UUIDStringType } from '../types/UUID'; -import { UUID_BYTE_SIZE } from '../types/UUID'; -import * as Bytes from '../Bytes'; -import * as Timers from '../Timers'; -import { uuidToBytes, bytesToUuid } from '../Crypto'; -import { SignalService as Proto } from '../protobuf'; - -enum State { - Handshake, - Established, - Closed, -} - -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; - 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 MAX_E164_COUNT = 5000; -const HANDSHAKE_TIMEOUT = 10 * durations.SECOND; -const REQUEST_TIMEOUT = 10 * durations.SECOND; -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 responseStream = new Readable({ - read: noop, - - // Don't coalesce separate websocket messages - objectMode: true, - }); - - constructor( - private readonly socket: WebSocket, - private readonly enclaveClient: HsmEnclaveClient - ) { - super(); - - const { - promise: finishedHandshake, - resolve, - reject, - } = explodePromise(); - this.finishedHandshake = finishedHandshake; - - const timer = Timers.setTimeout(() => { - reject(new Error('CDS handshake timed out')); - }, HANDSHAKE_TIMEOUT); - - socket.on('message', ({ type, binaryData }) => { - strictAssert(type === 'binary', 'Invalid CDS socket packet'); - strictAssert(binaryData, 'Invalid CDS socket packet'); - - if (this.state === State.Handshake) { - this.enclaveClient.completeHandshake(binaryData); - this.state = State.Established; - Timers.clearTimeout(timer); - resolve(); - return; - } - - 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); - }); - socket.on('error', (error: Error) => this.emit('error', error)); - - socket.sendBytes(this.enclaveClient.initialRequest()); - } - - public close(code: number, reason: string): void { - this.socket.close(code, reason); - } - - public async request({ - 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' - ); - - strictAssert( - acis.length === accessKeys.length, - `Number of ACIs ${acis.length} is different ` + - `from number of access keys ${accessKeys.length}` - ); - const aciUakPairs = new Array(); - for (let i = 0; i < acis.length; i += 1) { - aciUakPairs.push( - Bytes.concatenate([ - uuidToBytes(acis[i]), - Bytes.fromBase64(accessKeys[i]), - ]) - ); - } - - const request = Proto.CDSClientRequest.encode({ - 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 timer = Timers.setTimeout(() => { - 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([Buffer.from([version]), request]) - ) - ); - - const resultMap: Map = new Map(); - let retryAfterSecs: number | undefined; - - for await (const message of this.responseStream) { - log.info('CDSSocket.request(): processing response message'); - - const response = Proto.CDSClientResponse.decode(message); - const newRetryAfterSecs = dropNull(response.retryAfterSecs); - - decodeSingleResponse(resultMap, response); - - 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: result, - retryAfterSecs, - }; - } - - // EventEmitter types - - public override on( - type: 'close', - callback: (code: number, reason?: string) => void - ): this; - public override on(type: 'error', callback: (error: Error) => void): this; - - public override on( - type: string | symbol, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - listener: (...args: Array) => void - ): this { - return super.on(type, listener); - } - - public override emit(type: 'close', code: number, reason?: string): boolean; - public override emit(type: 'error', error: Error): boolean; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public override emit(type: string | symbol, ...args: Array): boolean { - 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 deleted file mode 100644 index 529ff40f6..000000000 --- a/ts/textsecure/CDSSocketManager.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import ProxyAgent from 'proxy-agent'; -import { HsmEnclaveClient } from '@signalapp/libsignal-client'; -import type { connection as WebSocket } from 'websocket'; - -import * as Bytes from '../Bytes'; -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'; -import { connect as connectWebSocket } from './WebSocket'; - -export type CDSSocketManagerOptionsType = Readonly<{ - url: string; - publicKey: string; - codeHashes: ReadonlyArray; - certificateAuthority: string; - proxyUrl?: string; - version: string; -}>; - -export type CDSResponseType = CDSSocketDictionaryType; - -export class CDSSocketManager { - private readonly publicKey: Buffer; - - private readonly codeHashes: Array; - - private readonly proxyAgent?: ReturnType; - - private retryAfter?: number; - - constructor(private readonly options: CDSSocketManagerOptionsType) { - this.publicKey = Buffer.from(Bytes.fromHex(options.publicKey)); - this.codeHashes = options.codeHashes.map(hash => - Buffer.from(Bytes.fromHex(hash)) - ); - if (options.proxyUrl) { - this.proxyAgent = new ProxyAgent(options.proxyUrl); - } - } - - public async request( - options: CDSRequestOptionsType - ): 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); - } - - const { auth } = options; - - log.info('CDSSocketManager: connecting socket'); - const socket = await this.connect(auth).getResult(); - log.info('CDSSocketManager: connected socket'); - - try { - 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'); - } - } - - private connect(auth: CDSAuthType): AbortableProcess { - const enclaveClient = HsmEnclaveClient.new(this.publicKey, this.codeHashes); - - const { publicKey: publicKeyHex, codeHashes, version } = this.options; - - const url = `${ - this.options.url - }/discovery/${publicKeyHex}/${codeHashes.join(',')}`; - - return connectWebSocket({ - name: 'CDSSocket', - url, - 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/SendMessage.ts b/ts/textsecure/SendMessage.ts index 7bf9c9b3c..628824fb2 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -48,7 +48,7 @@ import type { SendLogCallbackType, } from './OutgoingMessage'; import OutgoingMessage from './OutgoingMessage'; -import type { CDSResponseType } from './CDSSocketManager'; +import type { CDSResponseType } from './cds/Types.d'; import * as Bytes from '../Bytes'; import { getRandomBytes, getZeroes, encryptAttachment } from '../Crypto'; import { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index f920ea766..db800d99f 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable no-param-reassign */ -/* eslint-disable no-bitwise */ /* eslint-disable guard-for-in */ /* eslint-disable no-restricted-syntax */ /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -12,16 +11,12 @@ import type { Response } from 'node-fetch'; import fetch from 'node-fetch'; import ProxyAgent from 'proxy-agent'; import { Agent } from 'https'; -import pProps from 'p-props'; import type { Dictionary } from 'lodash'; -import { compact, escapeRegExp, isNumber, mapValues, zipObject } from 'lodash'; -import { createVerify } from 'crypto'; -import { pki } from 'node-forge'; +import { escapeRegExp, isNumber } from 'lodash'; import is from '@sindresorhus/is'; import PQueue from 'p-queue'; import { v4 as getGuid } from 'uuid'; import { z } from 'zod'; -import Long from 'long'; import type { Readable } from 'stream'; import { assert, strictAssert } from '../util/assert'; @@ -40,21 +35,17 @@ import { isPackIdValid, redactPackId } from '../types/Stickers'; import type { UUID, UUIDStringType } from '../types/UUID'; import { isValidUuid, UUIDKind } from '../types/UUID'; import * as Bytes from '../Bytes'; -import { - constantTimeEqual, - decryptAesGcm, - deriveSecrets, - encryptCdsDiscoveryRequest, - getRandomValue, - splitUuids, -} from '../Crypto'; -import { calculateAgreement, generateKeyPair } from '../Curve'; +import { getRandomValue } from '../Crypto'; import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch'; import { isBadgeImageFileUrlValid } from '../badges/isBadgeImageFileUrlValid'; import { SocketManager } from './SocketManager'; -import type { CDSResponseType } from './CDSSocketManager'; -import { CDSSocketManager } from './CDSSocketManager'; +import type { CDSAuthType, CDSResponseType } from './cds/Types.d'; +import type { CDSBase } from './cds/CDSBase'; +import { LegacyCDS } from './cds/LegacyCDS'; +import type { LegacyCDSPutAttestationResponseType } from './cds/LegacyCDS'; +import { CDSH } from './cds/CDSH'; +import { CDSI } from './cds/CDSI'; import type WebSocketResource from './WebsocketResources'; import { SignalService as Proto } from '../protobuf'; @@ -75,43 +66,6 @@ import { maybeParseUrl } from '../util/url'; // debugging failed requests. const DEBUG = false; -type SgxConstantsType = { - SGX_FLAGS_INITTED: Long; - SGX_FLAGS_DEBUG: Long; - SGX_FLAGS_MODE64BIT: Long; - SGX_FLAGS_PROVISION_KEY: Long; - SGX_FLAGS_EINITTOKEN_KEY: Long; - SGX_FLAGS_RESERVED: Long; - SGX_XFRM_LEGACY: Long; - SGX_XFRM_AVX: Long; - SGX_XFRM_RESERVED: Long; -}; - -let sgxConstantCache: SgxConstantsType | null = null; - -function makeLong(value: string): Long { - return Long.fromString(value); -} -function getSgxConstants() { - if (sgxConstantCache) { - return sgxConstantCache; - } - - sgxConstantCache = { - SGX_FLAGS_INITTED: makeLong('x0000000000000001L'), - SGX_FLAGS_DEBUG: makeLong('x0000000000000002L'), - SGX_FLAGS_MODE64BIT: makeLong('x0000000000000004L'), - SGX_FLAGS_PROVISION_KEY: makeLong('x0000000000000004L'), - SGX_FLAGS_EINITTOKEN_KEY: makeLong('x0000000000000004L'), - SGX_FLAGS_RESERVED: makeLong('xFFFFFFFFFFFFFFC8L'), - SGX_XFRM_LEGACY: makeLong('x0000000000000003L'), - SGX_XFRM_AVX: makeLong('x0000000000000006L'), - SGX_XFRM_RESERVED: makeLong('xFFFFFFFFFFFFFFF8L'), - }; - - return sgxConstantCache; -} - function _createRedactor( ...toReplace: ReadonlyArray ): RedactUrl { @@ -211,8 +165,8 @@ type PromiseAjaxOptionsType = { } ); -type JSONWithDetailsType = { - data: unknown; +type JSONWithDetailsType = { + data: Data; contentType: string | null; response: Response; }; @@ -594,17 +548,46 @@ const WEBSOCKET_CALLS = new Set([ 'storageToken', ]); +type DirectoryV1OptionsType = Readonly<{ + directoryVersion: 1; + directoryUrl: string; + directoryEnclaveId: string; + directoryTrustAnchor: string; +}>; + +type DirectoryV2OptionsType = Readonly<{ + directoryVersion: 2; + directoryV2Url: string; + directoryV2PublicKey: string; + directoryV2CodeHashes: ReadonlyArray; +}>; + +type DirectoryV3OptionsType = Readonly<{ + directoryVersion: 3; + directoryV3Url: string; + directoryV3MRENCLAVE: string; + directoryV3Root: string; +}>; + +type OptionalDirectoryFieldsType = { + directoryUrl?: unknown; + directoryEnclaveId?: unknown; + directoryTrustAnchor?: unknown; + directoryV2Url?: unknown; + directoryV2PublicKey?: unknown; + directoryV2CodeHashes?: unknown; + directoryV3Url?: unknown; + directoryV3MRENCLAVE?: unknown; + directoryV3Root?: unknown; +}; + +type DirectoryOptionsType = OptionalDirectoryFieldsType & + (DirectoryV1OptionsType | DirectoryV2OptionsType | DirectoryV3OptionsType); + type InitializeOptionsType = { url: string; storageUrl: string; updatesUrl: string; - directoryVersion: number; - directoryUrl?: string; - directoryEnclaveId?: string; - directoryTrustAnchor?: string; - directoryV2Url?: string; - directoryV2PublicKey?: string; - directoryV2CodeHashes?: ReadonlyArray; cdnUrlObject: { readonly '0': string; readonly [propName: string]: string; @@ -613,6 +596,7 @@ type InitializeOptionsType = { contentProxyUrl: string; proxyUrl: string | undefined; version: string; + directoryConfig: DirectoryOptionsType; }; export type MessageType = Readonly<{ @@ -1031,13 +1015,7 @@ export function initialize({ url, storageUrl, updatesUrl, - directoryVersion, - directoryUrl, - directoryEnclaveId, - directoryTrustAnchor, - directoryV2Url, - directoryV2PublicKey, - directoryV2CodeHashes, + directoryConfig, cdnUrlObject, certificateAuthority, contentProxyUrl, @@ -1053,36 +1031,6 @@ export function initialize({ if (!is.string(updatesUrl)) { throw new Error('WebAPI.initialize: Invalid updatesUrl'); } - 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'); - } - if (!is.string(directoryV2PublicKey)) { - throw new Error('WebAPI.initialize: Invalid directory V2 public key'); - } - if (!is.array(directoryV2CodeHashes)) { - throw new Error('WebAPI.initialize: Invalid directory V2 code hash'); - } if (!is.object(cdnUrlObject)) { throw new Error('WebAPI.initialize: Invalid cdnUrlObject'); } @@ -1146,21 +1094,128 @@ export function initialize({ socketManager.authenticate({ username, password }); } - const cdsUrl = directoryV2Url || directoryUrl; - if (!cdsUrl) { - throw new Error('No CDS url available!'); + let cds: CDSBase; + if (directoryConfig.directoryVersion === 1) { + const { directoryUrl, directoryEnclaveId, directoryTrustAnchor } = + directoryConfig; + + cds = new LegacyCDS({ + logger: log, + directoryEnclaveId, + directoryTrustAnchor, + proxyUrl, + + async putAttestation(auth, publicKey) { + const data = JSON.stringify({ + clientPublic: Bytes.toBase64(publicKey), + }); + const result = (await _outerAjax(null, { + certificateAuthority, + type: 'PUT', + contentType: 'application/json; charset=utf-8', + host: directoryUrl, + path: `${URL_CALLS.attestation}/${directoryEnclaveId}`, + user: auth.username, + password: auth.password, + responseType: 'jsonwithdetails', + data, + timeout: 30000, + version, + })) as JSONWithDetailsType; + + const { response, data: responseBody } = result; + + const cookie = response.headers.get('set-cookie') ?? undefined; + + return { cookie, responseBody }; + }, + + async fetchDiscoveryData(auth, data, cookie) { + const response = (await _outerAjax(null, { + certificateAuthority, + type: 'PUT', + headers: cookie + ? { + cookie, + } + : undefined, + contentType: 'application/json; charset=utf-8', + host: directoryUrl, + path: `${URL_CALLS.discovery}/${directoryEnclaveId}`, + user: auth.username, + password: auth.password, + responseType: 'json', + timeout: 30000, + data: JSON.stringify(data), + version, + })) as { + requestId: string; + iv: string; + data: string; + mac: string; + }; + + return { + requestId: Bytes.fromBase64(response.requestId), + iv: Bytes.fromBase64(response.iv), + data: Bytes.fromBase64(response.data), + mac: Bytes.fromBase64(response.mac), + }; + }, + + async getAuth() { + return (await _ajax({ + call: 'directoryAuth', + httpType: 'GET', + responseType: 'json', + })) as CDSAuthType; + }, + }); + } else if (directoryConfig.directoryVersion === 2) { + const { directoryV2Url, directoryV2PublicKey, directoryV2CodeHashes } = + directoryConfig; + + cds = new CDSH({ + logger: log, + proxyUrl, + + url: directoryV2Url, + publicKey: directoryV2PublicKey, + codeHashes: directoryV2CodeHashes, + certificateAuthority, + version, + + async getAuth() { + return (await _ajax({ + call: 'directoryAuthV2', + httpType: 'GET', + responseType: 'json', + })) as CDSAuthType; + }, + }); + } else if (directoryConfig.directoryVersion === 3) { + const { directoryV3Url, directoryV3MRENCLAVE, directoryV3Root } = + directoryConfig; + + cds = new CDSI({ + logger: log, + proxyUrl, + + url: directoryV3Url, + mrenclave: directoryV3MRENCLAVE, + root: directoryV3Root, + certificateAuthority, + version, + + async getAuth() { + return (await _ajax({ + call: 'directoryAuthV2', + httpType: 'GET', + responseType: 'json', + })) as CDSAuthType; + }, + }); } - if (!directoryV2PublicKey || !directoryV2CodeHashes?.length) { - throw new Error('No CDS public key or code hashes available'); - } - const cdsSocketManager = new CDSSocketManager({ - url: cdsUrl, - publicKey: directoryV2PublicKey, - codeHashes: directoryV2CodeHashes, - certificateAuthority, - version, - proxyUrl, - }); let fetchForLinkPreviews: linkPreviewFetch.FetchFn; if (proxyUrl) { @@ -2778,444 +2833,18 @@ export function initialize({ return socketManager.getProvisioningResource(handler); } - async function getDirectoryAuth(): Promise<{ - username: string; - password: string; - }> { - strictAssert(directoryVersion === 1, 'Legacy CDS should not be used'); - return (await _ajax({ - call: 'directoryAuth', - httpType: 'GET', - responseType: 'json', - })) as { username: string; password: string }; - } - - async function getDirectoryAuthV2(): Promise<{ - username: string; - password: string; - }> { - return (await _ajax({ - call: 'directoryAuthV2', - httpType: 'GET', - responseType: 'json', - })) as { username: string; password: string }; - } - - function validateAttestationQuote({ - serverStaticPublic, - quote: quoteBytes, - }: { - 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); - - const quoteVersion = quote.readInt16LE(0) & 0xffff; - if (quoteVersion < 0 || quoteVersion > 2) { - throw new Error(`Unknown version ${quoteVersion}`); - } - - const miscSelect = quote.slice(64, 64 + 4); - if (!miscSelect.every(byte => byte === 0)) { - throw new Error('Quote miscSelect invalid!'); - } - - const reserved1 = quote.slice(68, 68 + 28); - if (!reserved1.every(byte => byte === 0)) { - throw new Error('Quote reserved1 invalid!'); - } - - const flags = Long.fromBytesLE( - Array.from(quote.slice(96, 96 + 8).values()) - ); - if ( - flags.and(SGX_CONSTANTS.SGX_FLAGS_RESERVED).notEquals(0) || - flags.and(SGX_CONSTANTS.SGX_FLAGS_INITTED).equals(0) || - flags.and(SGX_CONSTANTS.SGX_FLAGS_MODE64BIT).equals(0) - ) { - throw new Error(`Quote flags invalid ${flags.toString()}`); - } - - const xfrm = Long.fromBytesLE( - Array.from(quote.slice(104, 104 + 8).values()) - ); - if (xfrm.and(SGX_CONSTANTS.SGX_XFRM_RESERVED).notEquals(0)) { - throw new Error(`Quote xfrm invalid ${xfrm}`); - } - - const mrenclave = quote.slice(112, 112 + 32); - const enclaveIdBytes = Bytes.fromHex(directoryEnclaveId); - if (mrenclave.compare(enclaveIdBytes) !== 0) { - throw new Error('Quote mrenclave invalid!'); - } - - const reserved2 = quote.slice(144, 144 + 32); - if (!reserved2.every(byte => byte === 0)) { - throw new Error('Quote reserved2 invalid!'); - } - - const reportData = quote.slice(368, 368 + 64); - const serverStaticPublicBytes = serverStaticPublic; - if ( - !reportData.every((byte, index) => { - if (index >= 32) { - return byte === 0; - } - return byte === serverStaticPublicBytes[index]; - }) - ) { - throw new Error('Quote report_data invalid!'); - } - - const reserved3 = quote.slice(208, 208 + 96); - if (!reserved3.every(byte => byte === 0)) { - throw new Error('Quote reserved3 invalid!'); - } - - const reserved4 = quote.slice(308, 308 + 60); - if (!reserved4.every(byte => byte === 0)) { - throw new Error('Quote reserved4 invalid!'); - } - - const signatureLength = quote.readInt32LE(432) >>> 0; - if (signatureLength !== quote.byteLength - 436) { - throw new Error(`Bad signatureLength ${signatureLength}`); - } - - // const signature = quote.slice(436, 436 + signatureLength); - } - - function validateAttestationSignatureBody( - signatureBody: { - timestamp: string; - version: number; - isvEnclaveQuoteBody: string; - isvEnclaveQuoteStatus: string; - }, - encodedQuote: string - ) { - strictAssert(directoryVersion === 1, 'Legacy CDS should not be used'); - - // Parse timestamp as UTC - const { timestamp } = signatureBody; - const utcTimestamp = timestamp.endsWith('Z') - ? timestamp - : `${timestamp}Z`; - const signatureTime = new Date(utcTimestamp).getTime(); - - const now = Date.now(); - if (signatureBody.version !== 3) { - throw new Error('Attestation signature invalid version!'); - } - if (!encodedQuote.startsWith(signatureBody.isvEnclaveQuoteBody)) { - throw new Error('Attestion signature mismatches quote!'); - } - if (signatureBody.isvEnclaveQuoteStatus !== 'OK') { - throw new Error('Attestation signature status not "OK"!'); - } - if (signatureTime < now - 24 * 60 * 60 * 1000) { - throw new Error('Attestation signature timestamp older than 24 hours!'); - } - } - - async function validateAttestationSignature( - signature: Uint8Array, - 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 => { - if (!match) { - return null; - } - - return `${CERT_PREFIX}${match}`; - }) - ); - if (pem.length < 2) { - throw new Error( - `validateAttestationSignature: Expect two or more entries; got ${pem.length}` - ); - } - - const verify = createVerify('RSA-SHA256'); - verify.update(Buffer.from(Bytes.fromString(signatureBody))); - const isValid = verify.verify(pem[0], Buffer.from(signature)); - if (!isValid) { - throw new Error('Validation of signature across signatureBody failed!'); - } - - const caStore = pki.createCaStore([directoryTrustAnchor]); - const chain = compact(pem.map(cert => pki.certificateFromPem(cert))); - const isChainValid = pki.verifyCertificateChain(caStore, chain); - if (!isChainValid) { - throw new Error('Validation of certificate chain failed!'); - } - - const leafCert = chain[0]; - const fieldCN = leafCert.subject.getField('CN'); - if ( - !fieldCN || - fieldCN.value !== 'Intel SGX Attestation Report Signing' - ) { - throw new Error('Leaf cert CN field had unexpected value'); - } - const fieldO = leafCert.subject.getField('O'); - if (!fieldO || fieldO.value !== 'Intel Corporation') { - throw new Error('Leaf cert O field had unexpected value'); - } - const fieldL = leafCert.subject.getField('L'); - if (!fieldL || fieldL.value !== 'Santa Clara') { - throw new Error('Leaf cert L field had unexpected value'); - } - const fieldST = leafCert.subject.getField('ST'); - if (!fieldST || fieldST.value !== 'CA') { - throw new Error('Leaf cert ST field had unexpected value'); - } - const fieldC = leafCert.subject.getField('C'); - if (!fieldC || fieldC.value !== 'US') { - throw new Error('Leaf cert C field had unexpected value'); - } - } - - async function putRemoteAttestation(auth: { - 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 - const slicedPubKey = pubKey.slice(1); - const pubKeyBase64 = Bytes.toBase64(slicedPubKey); - // Do request - const data = JSON.stringify({ clientPublic: pubKeyBase64 }); - const result: JSONWithDetailsType = (await _outerAjax(null, { - certificateAuthority, - type: 'PUT', - contentType: 'application/json; charset=utf-8', - host: directoryUrl, - path: `${URL_CALLS.attestation}/${directoryEnclaveId}`, - user: auth.username, - password: auth.password, - responseType: 'jsonwithdetails', - data, - timeout: 30000, - version, - })) as JSONWithDetailsType; - - const { data: responseBody, response } = result as { - data: { - attestations: Record< - string, - { - ciphertext: string; - iv: string; - quote: string; - serverEphemeralPublic: string; - serverStaticPublic: string; - signature: string; - signatureBody: string; - tag: string; - certificates: string; - } - >; - }; - response: Response; - }; - - const attestationsLength = Object.keys(responseBody.attestations).length; - if (attestationsLength > 3) { - throw new Error( - 'Got more than three attestations from the Contact Discovery Service' - ); - } - if (attestationsLength < 1) { - throw new Error( - 'Got no attestations from the Contact Discovery Service' - ); - } - - const cookie = response.headers.get('set-cookie'); - - // Decode response - return { - cookie, - attestations: await pProps( - responseBody.attestations, - async attestation => { - const decoded = { - ...attestation, - ciphertext: Bytes.fromBase64(attestation.ciphertext), - iv: Bytes.fromBase64(attestation.iv), - quote: Bytes.fromBase64(attestation.quote), - serverEphemeralPublic: Bytes.fromBase64( - attestation.serverEphemeralPublic - ), - serverStaticPublic: Bytes.fromBase64( - attestation.serverStaticPublic - ), - signature: Bytes.fromBase64(attestation.signature), - tag: Bytes.fromBase64(attestation.tag), - }; - - // Validate response - validateAttestationQuote(decoded); - validateAttestationSignatureBody( - JSON.parse(decoded.signatureBody), - attestation.quote - ); - await validateAttestationSignature( - decoded.signature, - decoded.signatureBody, - decoded.certificates - ); - - // Derive key - const ephemeralToEphemeral = calculateAgreement( - decoded.serverEphemeralPublic, - privKey - ); - const ephemeralToStatic = calculateAgreement( - decoded.serverStaticPublic, - privKey - ); - const masterSecret = Bytes.concatenate([ - ephemeralToEphemeral, - ephemeralToStatic, - ]); - const publicKeys = Bytes.concatenate([ - slicedPubKey, - decoded.serverEphemeralPublic, - decoded.serverStaticPublic, - ]); - const [clientKey, serverKey] = deriveSecrets( - masterSecret, - publicKeys, - new Uint8Array(0) - ); - - // Decrypt ciphertext into requestId - const requestId = decryptAesGcm( - serverKey, - decoded.iv, - Bytes.concatenate([decoded.ciphertext, decoded.tag]) - ); - - return { - clientKey, - serverKey, - requestId, - }; - } - ), - }; - } - - async function getLegacyUuidsForE164s( - e164s: ReadonlyArray - ): Promise> { - const directoryAuth = await getDirectoryAuth(); - const attestationResult = await putRemoteAttestation(directoryAuth); - - // Encrypt data for discovery - const data = await encryptCdsDiscoveryRequest( - attestationResult.attestations, - e164s - ); - const { cookie } = attestationResult; - - // Send discovery request - const discoveryResponse = (await _outerAjax(null, { - certificateAuthority, - type: 'PUT', - headers: cookie - ? { - cookie, - } - : undefined, - contentType: 'application/json; charset=utf-8', - host: directoryUrl, - path: `${URL_CALLS.discovery}/${directoryEnclaveId}`, - user: directoryAuth.username, - password: directoryAuth.password, - responseType: 'json', - timeout: 30000, - data: JSON.stringify(data), - version, - })) as { - requestId: string; - iv: string; - data: string; - mac: string; - }; - - // Decode discovery request response - const decodedDiscoveryResponse = mapValues(discoveryResponse, value => { - return Bytes.fromBase64(value); - }) as unknown as { - [K in keyof typeof discoveryResponse]: Uint8Array; - }; - - const returnedAttestation = Object.values( - attestationResult.attestations - ).find(at => - constantTimeEqual(at.requestId, decodedDiscoveryResponse.requestId) - ); - if (!returnedAttestation) { - throw new Error('No known attestations returned from CDS'); - } - - // Decrypt discovery response - const decryptedDiscoveryData = decryptAesGcm( - returnedAttestation.serverKey, - decodedDiscoveryResponse.iv, - Bytes.concatenate([ - decodedDiscoveryResponse.data, - decodedDiscoveryResponse.mac, - ]) - ); - - // Process and return result - const uuids = splitUuids(decryptedDiscoveryData); - - if (uuids.length !== e164s.length) { - throw new Error( - 'Returned set of UUIDs did not match returned set of e164s!' - ); - } - - 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, + const map = await cds.request({ e164s, }); - return mapValues(dictionary, value => value.aci ?? null); + const result: Dictionary = {}; + for (const [key, value] of map) { + result[key] = value.pni ?? value.aci ?? null; + } + return result; } async function getUuidsForE164sV2({ @@ -3223,11 +2852,7 @@ export function initialize({ acis, accessKeys, }: GetUuidsForE164sV2OptionsType): Promise { - const auth = await getDirectoryAuthV2(); - - return cdsSocketManager.request({ - version: 2, - auth, + return cds.request({ e164s, acis, accessKeys, diff --git a/ts/textsecure/cds/CDSBase.ts b/ts/textsecure/cds/CDSBase.ts new file mode 100644 index 000000000..5a39f8db0 --- /dev/null +++ b/ts/textsecure/cds/CDSBase.ts @@ -0,0 +1,67 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import ProxyAgent from 'proxy-agent'; + +import type { + CDSAuthType, + CDSRequestOptionsType, + CDSResponseType, +} from './Types.d'; +import type { LoggerType } from '../../types/Logging'; +import { isOlderThan } from '../../util/timestamp'; +import { HOUR } from '../../util/durations'; + +// It is 24 hours, but we don't want latency between server and client to be +// count. +const CACHED_AUTH_TTL = 23 * HOUR; + +export type CDSBaseOptionsType = Readonly<{ + logger: LoggerType; + proxyUrl?: string; + getAuth(): Promise; +}>; + +export type CachedAuthType = Readonly<{ + timestamp: number; + auth: CDSAuthType; +}>; + +export abstract class CDSBase< + Options extends CDSBaseOptionsType = CDSBaseOptionsType +> { + protected readonly logger: LoggerType; + protected readonly proxyAgent?: ReturnType; + protected cachedAuth?: CachedAuthType; + + constructor(protected readonly options: Options) { + this.logger = options.logger; + + if (options.proxyUrl) { + this.proxyAgent = new ProxyAgent(options.proxyUrl); + } + } + + public abstract request( + options: CDSRequestOptionsType + ): Promise; + + protected async getAuth(): Promise { + if (this.cachedAuth) { + if (isOlderThan(this.cachedAuth.timestamp, CACHED_AUTH_TTL)) { + this.cachedAuth = undefined; + } else { + return this.cachedAuth.auth; + } + } + + const auth = await this.options.getAuth(); + + this.cachedAuth = { + auth, + timestamp: Date.now(), + }; + + return auth; + } +} diff --git a/ts/textsecure/cds/CDSH.ts b/ts/textsecure/cds/CDSH.ts new file mode 100644 index 000000000..4025794ca --- /dev/null +++ b/ts/textsecure/cds/CDSH.ts @@ -0,0 +1,50 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { HsmEnclaveClient } from '@signalapp/libsignal-client'; +import type { connection as WebSocket } from 'websocket'; + +import * as Bytes from '../../Bytes'; +import { CDSHSocket } from './CDSHSocket'; +import type { CDSSocketManagerBaseOptionsType } from './CDSSocketManagerBase'; +import { CDSSocketManagerBase } from './CDSSocketManagerBase'; + +export type CDSHOptionsType = Readonly<{ + publicKey: string; + codeHashes: ReadonlyArray; +}> & + CDSSocketManagerBaseOptionsType; + +export class CDSH extends CDSSocketManagerBase { + private readonly publicKey: Buffer; + + private readonly codeHashes: Array; + + constructor(options: CDSHOptionsType) { + super(options); + + this.publicKey = Buffer.from(Bytes.fromHex(options.publicKey)); + this.codeHashes = options.codeHashes.map(hash => + Buffer.from(Bytes.fromHex(hash)) + ); + } + + protected override getSocketUrl(): string { + const { publicKey: publicKeyHex, codeHashes } = this.options; + + return ( + `${this.options.url}/discovery/${publicKeyHex}/` + + `${codeHashes.join(',')}` + ); + } + + protected override createSocket(socket: WebSocket): CDSHSocket { + const enclaveClient = HsmEnclaveClient.new(this.publicKey, this.codeHashes); + + return new CDSHSocket({ + logger: this.logger, + socket, + enclaveClient, + }); + } +} diff --git a/ts/textsecure/cds/CDSHSocket.ts b/ts/textsecure/cds/CDSHSocket.ts new file mode 100644 index 000000000..1e505b338 --- /dev/null +++ b/ts/textsecure/cds/CDSHSocket.ts @@ -0,0 +1,49 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { HsmEnclaveClient } from '@signalapp/libsignal-client'; + +import { strictAssert } from '../../util/assert'; +import { CDSSocketBase, CDSSocketState } from './CDSSocketBase'; +import type { CDSSocketBaseOptionsType } from './CDSSocketBase'; + +export type CDSHSocketOptionsType = Readonly<{ + enclaveClient: HsmEnclaveClient; +}> & + CDSSocketBaseOptionsType; + +export class CDSHSocket extends CDSSocketBase { + public override async handshake(): Promise { + strictAssert( + this.state === CDSSocketState.Open, + 'CDSH handshake called twice' + ); + this.state = CDSSocketState.Handshake; + + // Handshake + this.socket.sendBytes(this.options.enclaveClient.initialRequest()); + + const { done, value: message } = await this.socketIterator.next(); + strictAssert(!done, 'Expected CDSH handshake response'); + + this.options.enclaveClient.completeHandshake(message); + this.state = CDSSocketState.Established; + } + + protected override async sendRequest( + version: number, + request: Buffer + ): Promise { + this.socket.sendBytes( + this.options.enclaveClient.establishedSend( + Buffer.concat([Buffer.from([version]), request]) + ) + ); + } + + protected override async decryptResponse( + ciphertext: Buffer + ): Promise { + return this.options.enclaveClient.establishedRecv(ciphertext); + } +} diff --git a/ts/textsecure/cds/CDSI.ts b/ts/textsecure/cds/CDSI.ts new file mode 100644 index 000000000..85ce56980 --- /dev/null +++ b/ts/textsecure/cds/CDSI.ts @@ -0,0 +1,43 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { connection as WebSocket } from 'websocket'; + +import * as Bytes from '../../Bytes'; +import { CDSISocket } from './CDSISocket'; +import type { CDSSocketManagerBaseOptionsType } from './CDSSocketManagerBase'; +import { CDSSocketManagerBase } from './CDSSocketManagerBase'; + +export type CDSIOptionsType = Readonly<{ + mrenclave: string; + root: string; +}> & + CDSSocketManagerBaseOptionsType; + +export class CDSI extends CDSSocketManagerBase { + private readonly mrenclave: Buffer; + + private readonly trustedCaCert: Buffer; + + constructor(options: CDSIOptionsType) { + super(options); + + this.mrenclave = Buffer.from(Bytes.fromHex(options.mrenclave)); + this.trustedCaCert = Buffer.from(options.root); + } + + protected override getSocketUrl(): string { + const { mrenclave } = this.options; + + return `${this.options.url}/v1/${mrenclave}/discovery`; + } + + protected override createSocket(socket: WebSocket): CDSISocket { + return new CDSISocket({ + logger: this.logger, + socket, + mrenclave: this.mrenclave, + trustedCaCert: this.trustedCaCert, + }); + } +} diff --git a/ts/textsecure/cds/CDSISocket.ts b/ts/textsecure/cds/CDSISocket.ts new file mode 100644 index 000000000..48fe6a322 --- /dev/null +++ b/ts/textsecure/cds/CDSISocket.ts @@ -0,0 +1,100 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { Cds2Client } from '@signalapp/libsignal-client'; + +import { strictAssert } from '../../util/assert'; +import { DAY } from '../../util/durations'; +import { SignalService as Proto } from '../../protobuf'; +import { CDSSocketBase, CDSSocketState } from './CDSSocketBase'; +import type { CDSSocketBaseOptionsType } from './CDSSocketBase'; + +export type CDSISocketOptionsType = Readonly<{ + mrenclave: Buffer; + trustedCaCert: Buffer; +}> & + CDSSocketBaseOptionsType; + +export class CDSISocket extends CDSSocketBase { + private privCdsClient: Cds2Client | undefined; + + public override async handshake(): Promise { + strictAssert( + this.state === CDSSocketState.Open, + 'CDSI handshake called twice' + ); + this.state = CDSSocketState.Handshake; + + { + const { done, value: attestationMessage } = + await this.socketIterator.next(); + strictAssert(!done, 'CDSI socket closed before handshake'); + + const earliestValidTimestamp = new Date(Date.now() - DAY); + + strictAssert( + this.privCdsClient === undefined, + 'CDSI handshake called twice' + ); + this.privCdsClient = Cds2Client.new_NOT_FOR_PRODUCTION( + this.options.mrenclave, + this.options.trustedCaCert, + attestationMessage, + earliestValidTimestamp + ); + } + + this.socket.sendBytes(this.cdsClient.initialRequest()); + + { + const { done, value: message } = await this.socketIterator.next(); + strictAssert(!done, 'CDSI socket expected handshake data'); + + this.cdsClient.completeHandshake(message); + } + + this.state = CDSSocketState.Established; + } + + protected override async sendRequest( + _version: number, + request: Buffer + ): Promise { + this.socket.sendBytes(this.cdsClient.establishedSend(request)); + + const { done, value: ciphertext } = await this.socketIterator.next(); + strictAssert(!done, 'CDSISocket.sendRequest(): expected token message'); + + const message = await this.decryptResponse(ciphertext); + + this.logger.info('CDSISocket.sendRequest(): processing token message'); + + const { token } = Proto.CDSClientResponse.decode(message); + strictAssert(token, 'CDSISocket.sendRequest(): expected token'); + + this.socket.sendBytes( + this.cdsClient.establishedSend( + Buffer.from( + Proto.CDSClientRequest.encode({ + tokenAck: true, + }).finish() + ) + ) + ); + } + + protected override async decryptResponse( + ciphertext: Buffer + ): Promise { + return this.cdsClient.establishedRecv(ciphertext); + } + + // + // Private + // + + private get cdsClient(): Cds2Client { + strictAssert(this.privCdsClient, 'CDSISocket did not start handshake'); + return this.privCdsClient; + } +} diff --git a/ts/textsecure/cds/CDSSocketBase.ts b/ts/textsecure/cds/CDSSocketBase.ts new file mode 100644 index 000000000..a6ed7cdd1 --- /dev/null +++ b/ts/textsecure/cds/CDSSocketBase.ts @@ -0,0 +1,260 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { EventEmitter } from 'events'; +import { Readable } from 'stream'; +import { noop } from 'lodash'; +import type { connection as WebSocket } from 'websocket'; +import Long from 'long'; + +import type { LoggerType } from '../../types/Logging'; +import { strictAssert } from '../../util/assert'; +import { dropNull } from '../../util/dropNull'; +import { UUID_BYTE_SIZE } from '../../types/UUID'; +import * as Bytes from '../../Bytes'; +import { uuidToBytes, bytesToUuid } from '../../Crypto'; +import { SignalService as Proto } from '../../protobuf'; +import type { + CDSRequestOptionsType, + CDSResponseEntryType, + CDSResponseType, +} from './Types.d'; + +export type CDSSocketBaseOptionsType = Readonly<{ + logger: LoggerType; + socket: WebSocket; +}>; + +export type CDSSocketResponseType = Readonly<{ + response: CDSResponseType; + retryAfterSecs?: number; +}>; + +export enum CDSSocketState { + Open = 'Open', + Handshake = 'Handshake', + Established = 'Established', + Closed = 'Closed', +} + +const MAX_E164_COUNT = 5000; +const E164_BYTE_SIZE = 8; +const TRIPLE_BYTE_SIZE = UUID_BYTE_SIZE * 2 + E164_BYTE_SIZE; + +export abstract class CDSSocketBase< + Options extends CDSSocketBaseOptionsType = CDSSocketBaseOptionsType +> extends EventEmitter { + protected state = CDSSocketState.Open; + + protected readonly socket: WebSocket; + + protected readonly logger: LoggerType; + + protected readonly socketIterator: AsyncIterator; + + constructor(protected readonly options: Options) { + super(); + + // For easier access + this.logger = options.logger; + this.socket = options.socket; + + this.socketIterator = this.iterateSocket(); + } + + public async close(code: number, reason: string): Promise { + return this.socket.close(code, reason); + } + + public async request({ + e164s, + acis, + accessKeys, + }: CDSRequestOptionsType): Promise { + const log = this.logger; + + strictAssert( + e164s.length < MAX_E164_COUNT, + 'CDSSocket does not support paging. Use this for one-off requests' + ); + + strictAssert( + this.state === CDSSocketState.Established, + 'CDS Connection not established' + ); + + const aciUakPairs = new Array(); + + let version: 1 | 2; + if (acis) { + strictAssert(accessKeys, 'accessKeys are required when acis are present'); + + strictAssert( + acis.length === accessKeys.length, + `Number of ACIs ${acis.length} is different ` + + `from number of access keys ${accessKeys.length}` + ); + + version = 2; + + for (let i = 0; i < acis.length; i += 1) { + aciUakPairs.push( + Bytes.concatenate([ + uuidToBytes(acis[i]), + Bytes.fromBase64(accessKeys[i]), + ]) + ); + } + } else { + version = 1; + } + + const request = Proto.CDSClientRequest.encode({ + 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(); + + log.info(`CDSSocket.request(): sending version=${version} request`); + await this.sendRequest(version, Buffer.from(request)); + + const resultMap: Map = new Map(); + let retryAfterSecs: number | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value: ciphertext } = await this.socketIterator.next(); + if (done) { + this.state = CDSSocketState.Closed; + break; + } + + // eslint-disable-next-line no-await-in-loop + const message = await this.decryptResponse(ciphertext); + + log.info('CDSSocket.request(): processing response message'); + + const response = Proto.CDSClientResponse.decode(message); + const newRetryAfterSecs = dropNull(response.retryAfterSecs); + + decodeSingleResponse(resultMap, response); + + if (newRetryAfterSecs) { + retryAfterSecs = Math.max(newRetryAfterSecs, retryAfterSecs ?? 0); + } + } + + log.info('CDSSocket.request(): done'); + + return { + response: resultMap, + retryAfterSecs, + }; + } + + // Abstract methods + + public abstract handshake(): Promise; + + protected abstract sendRequest(version: number, data: Buffer): Promise; + + protected abstract decryptResponse(ciphertext: Buffer): Promise; + + // EventEmitter types + + public override on( + type: 'close', + callback: (code: number, reason?: string) => void + ): this; + public override on(type: 'error', callback: (error: Error) => void): this; + + public override on( + type: string | symbol, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (...args: Array) => void + ): this { + return super.on(type, listener); + } + + public override emit(type: 'close', code: number, reason?: string): boolean; + public override emit(type: 'error', error: Error): boolean; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public override emit(type: string | symbol, ...args: Array): boolean { + return super.emit(type, ...args); + } + + // + // Private + // + + private iterateSocket(): AsyncIterator { + const stream = new Readable({ read: noop, objectMode: true }); + + this.socket.on('message', ({ type, binaryData }) => { + strictAssert(type === 'binary', 'Invalid CDS socket packet'); + strictAssert(binaryData, 'Invalid CDS socket packet'); + + stream.push(binaryData); + }); + + this.socket.on('close', (code, reason) => { + if (code === 1000) { + stream.push(null); + } else { + stream.destroy( + new Error(`Socket closed with code ${code} and reason ${reason}`) + ); + } + }); + this.socket.on('error', (error: Error) => stream.destroy(error)); + + return stream[Symbol.asyncIterator](); + } +} + +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/cds/CDSSocketManagerBase.ts b/ts/textsecure/cds/CDSSocketManagerBase.ts new file mode 100644 index 000000000..0f92fe7b6 --- /dev/null +++ b/ts/textsecure/cds/CDSSocketManagerBase.ts @@ -0,0 +1,107 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { connection as WebSocket } from 'websocket'; +import pTimeout from 'p-timeout'; + +import type { AbortableProcess } from '../../util/AbortableProcess'; +import * as durations from '../../util/durations'; +import { getBasicAuth } from '../../util/getBasicAuth'; +import { sleep } from '../../util/sleep'; +import { SECOND } from '../../util/durations'; +import type { CDSBaseOptionsType } from './CDSBase'; +import { CDSBase } from './CDSBase'; +import type { CDSSocketBase } from './CDSSocketBase'; +import type { + CDSRequestOptionsType, + CDSResponseType, + CDSAuthType, +} from './Types.d'; +import { connect as connectWebSocket } from '../WebSocket'; + +const REQUEST_TIMEOUT = 10 * SECOND; + +export type CDSSocketManagerBaseOptionsType = Readonly<{ + url: string; + certificateAuthority: string; + version: string; +}> & + CDSBaseOptionsType; + +export abstract class CDSSocketManagerBase< + Socket extends CDSSocketBase, + Options extends CDSSocketManagerBaseOptionsType +> extends CDSBase { + private retryAfter?: number; + + public async request( + options: CDSRequestOptionsType + ): Promise { + const log = this.logger; + + if (this.retryAfter !== undefined) { + const delay = Math.max(0, this.retryAfter - Date.now()); + + log.info(`CDSSocketManager: waiting ${delay}ms before retrying`); + await sleep(delay); + } + + const auth = await this.getAuth(); + + log.info('CDSSocketManager: connecting socket'); + const socket = await this.connect(auth).getResult(); + log.info('CDSSocketManager: connected socket'); + + try { + let { timeout = REQUEST_TIMEOUT } = options; + + // Handshake + { + const start = Date.now(); + await pTimeout(socket.handshake(), timeout); + const duration = Date.now() - start; + + timeout = Math.max(timeout - duration, 0); + } + + // Send request + const { response, retryAfterSecs = 0 } = await pTimeout( + socket.request(options), + timeout + ); + + if (retryAfterSecs > 0) { + this.retryAfter = Math.max( + this.retryAfter ?? Date.now(), + Date.now() + retryAfterSecs * durations.SECOND + ); + } + + return response; + } finally { + log.info('CDSSocketManager: closing socket'); + socket.close(3000, 'Normal'); + } + } + + private connect(auth: CDSAuthType): AbortableProcess { + return connectWebSocket({ + name: 'CDSSocket', + url: this.getSocketUrl(), + version: this.options.version, + proxyAgent: this.proxyAgent, + certificateAuthority: this.options.certificateAuthority, + extraHeaders: { + authorization: getBasicAuth(auth), + }, + + createResource: (socket: WebSocket): Socket => { + return this.createSocket(socket); + }, + }); + } + + protected abstract getSocketUrl(): string; + + protected abstract createSocket(socket: WebSocket): Socket; +} diff --git a/ts/textsecure/cds/LegacyCDS.ts b/ts/textsecure/cds/LegacyCDS.ts new file mode 100644 index 000000000..3c7c56c1f --- /dev/null +++ b/ts/textsecure/cds/LegacyCDS.ts @@ -0,0 +1,455 @@ +// Copyright 2020-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +/* eslint-disable no-bitwise */ + +import pProps from 'p-props'; +import { compact } from 'lodash'; +import Long from 'long'; +import { createVerify } from 'crypto'; +import { pki } from 'node-forge'; + +import { + constantTimeEqual, + decryptAesGcm, + deriveSecrets, + encryptCdsDiscoveryRequest, + splitUuids, +} from '../../Crypto'; +import { calculateAgreement, generateKeyPair } from '../../Curve'; +import * as Bytes from '../../Bytes'; +import { strictAssert } from '../../util/assert'; +import { UUID } from '../../types/UUID'; +import type { CDSBaseOptionsType } from './CDSBase'; +import { CDSBase } from './CDSBase'; +import type { + CDSRequestOptionsType, + CDSResponseType, + CDSAuthType, + CDSResponseEntryType, +} from './Types.d'; + +export type LegacyCDSPutAttestationResponseType = Readonly<{ + attestations: Record< + string, + { + ciphertext: string; + iv: string; + quote: string; + serverEphemeralPublic: string; + serverStaticPublic: string; + signature: string; + signatureBody: string; + tag: string; + certificates: string; + } + >; +}>; + +export type LegacyCDSPutAttestationResultType = Readonly<{ + cookie?: string; + responseBody: LegacyCDSPutAttestationResponseType; +}>; + +export type LegacyCDSDiscoveryResponseType = Readonly<{ + requestId: Uint8Array; + iv: Uint8Array; + data: Uint8Array; + mac: Uint8Array; +}>; + +export type LegacyCDSOptionsType = Readonly<{ + directoryEnclaveId: string; + directoryTrustAnchor: string; + + putAttestation: ( + auth: CDSAuthType, + publicKey: Uint8Array + ) => Promise; + fetchDiscoveryData: ( + auth: CDSAuthType, + data: Record, + cookie?: string + ) => Promise; +}> & + CDSBaseOptionsType; + +type AttestationMapType = Readonly<{ + cookie?: string; + attestations: Record< + string, + Readonly<{ + clientKey: Uint8Array; + serverKey: Uint8Array; + requestId: Uint8Array; + }> + >; +}>; + +type SgxConstantsType = { + SGX_FLAGS_INITTED: Long; + SGX_FLAGS_DEBUG: Long; + SGX_FLAGS_MODE64BIT: Long; + SGX_FLAGS_PROVISION_KEY: Long; + SGX_FLAGS_EINITTOKEN_KEY: Long; + SGX_FLAGS_RESERVED: Long; + SGX_XFRM_LEGACY: Long; + SGX_XFRM_AVX: Long; + SGX_XFRM_RESERVED: Long; +}; + +let sgxConstantCache: SgxConstantsType | null = null; + +function makeLong(value: string): Long { + return Long.fromString(value); +} +function getSgxConstants() { + if (sgxConstantCache) { + return sgxConstantCache; + } + + sgxConstantCache = { + SGX_FLAGS_INITTED: makeLong('x0000000000000001L'), + SGX_FLAGS_DEBUG: makeLong('x0000000000000002L'), + SGX_FLAGS_MODE64BIT: makeLong('x0000000000000004L'), + SGX_FLAGS_PROVISION_KEY: makeLong('x0000000000000004L'), + SGX_FLAGS_EINITTOKEN_KEY: makeLong('x0000000000000004L'), + SGX_FLAGS_RESERVED: makeLong('xFFFFFFFFFFFFFFC8L'), + SGX_XFRM_LEGACY: makeLong('x0000000000000003L'), + SGX_XFRM_AVX: makeLong('x0000000000000006L'), + SGX_XFRM_RESERVED: makeLong('xFFFFFFFFFFFFFFF8L'), + }; + + return sgxConstantCache; +} + +export class LegacyCDS extends CDSBase { + public override async request({ + e164s, + acis, + accessKeys, + }: CDSRequestOptionsType): Promise { + strictAssert(!acis && !accessKeys, 'LegacyCDS does not support PNP'); + + const directoryAuth = await this.getAuth(); + const attestationResult = await this.putAttestation(directoryAuth); + + // Encrypt data for discovery + const data = await encryptCdsDiscoveryRequest( + attestationResult.attestations, + e164s + ); + const { cookie } = attestationResult; + + // Send discovery request + const discoveryResponse = await this.options.fetchDiscoveryData( + directoryAuth, + data, + cookie + ); + + const returnedAttestation = Object.values( + attestationResult.attestations + ).find(at => constantTimeEqual(at.requestId, discoveryResponse.requestId)); + if (!returnedAttestation) { + throw new Error('No known attestations returned from CDS'); + } + + // Decrypt discovery response + const decryptedDiscoveryData = decryptAesGcm( + returnedAttestation.serverKey, + discoveryResponse.iv, + Bytes.concatenate([discoveryResponse.data, discoveryResponse.mac]) + ); + + // Process and return result + const uuids = splitUuids(decryptedDiscoveryData); + + if (uuids.length !== e164s.length) { + throw new Error( + 'Returned set of UUIDs did not match returned set of e164s!' + ); + } + + const result = new Map(); + + for (const [i, e164] of e164s.entries()) { + const uuid = uuids[i]; + result.set(e164, { + aci: undefined, + pni: uuid ? UUID.cast(uuid) : undefined, + }); + } + + return result; + } + + // + // Private + // + + private async putAttestation(auth: CDSAuthType): Promise { + const { privKey, pubKey } = generateKeyPair(); + // Remove first "key type" byte from public key + const slicedPubKey = pubKey.slice(1); + // Do request + const { cookie, responseBody } = await this.options.putAttestation( + auth, + slicedPubKey + ); + + const attestationsLength = Object.keys(responseBody.attestations).length; + if (attestationsLength > 3) { + throw new Error( + 'Got more than three attestations from the Contact Discovery Service' + ); + } + if (attestationsLength < 1) { + throw new Error('Got no attestations from the Contact Discovery Service'); + } + + // Decode response + return { + cookie, + attestations: await pProps( + responseBody.attestations, + async attestation => { + const decoded = { + ...attestation, + ciphertext: Bytes.fromBase64(attestation.ciphertext), + iv: Bytes.fromBase64(attestation.iv), + quote: Bytes.fromBase64(attestation.quote), + serverEphemeralPublic: Bytes.fromBase64( + attestation.serverEphemeralPublic + ), + serverStaticPublic: Bytes.fromBase64( + attestation.serverStaticPublic + ), + signature: Bytes.fromBase64(attestation.signature), + tag: Bytes.fromBase64(attestation.tag), + }; + + // Validate response + this.validateAttestationQuote(decoded); + validateAttestationSignatureBody( + JSON.parse(decoded.signatureBody), + attestation.quote + ); + await this.validateAttestationSignature( + decoded.signature, + decoded.signatureBody, + decoded.certificates + ); + + // Derive key + const ephemeralToEphemeral = calculateAgreement( + decoded.serverEphemeralPublic, + privKey + ); + const ephemeralToStatic = calculateAgreement( + decoded.serverStaticPublic, + privKey + ); + const masterSecret = Bytes.concatenate([ + ephemeralToEphemeral, + ephemeralToStatic, + ]); + const publicKeys = Bytes.concatenate([ + slicedPubKey, + decoded.serverEphemeralPublic, + decoded.serverStaticPublic, + ]); + const [clientKey, serverKey] = deriveSecrets( + masterSecret, + publicKeys, + new Uint8Array(0) + ); + + // Decrypt ciphertext into requestId + const requestId = decryptAesGcm( + serverKey, + decoded.iv, + Bytes.concatenate([decoded.ciphertext, decoded.tag]) + ); + + return { + clientKey, + serverKey, + requestId, + }; + } + ), + }; + } + + private async validateAttestationSignature( + signature: Uint8Array, + signatureBody: string, + certificates: string + ) { + const CERT_PREFIX = '-----BEGIN CERTIFICATE-----'; + const pem = compact( + certificates.split(CERT_PREFIX).map(match => { + if (!match) { + return null; + } + + return `${CERT_PREFIX}${match}`; + }) + ); + if (pem.length < 2) { + throw new Error( + `validateAttestationSignature: Expect two or more entries; got ${pem.length}` + ); + } + + const verify = createVerify('RSA-SHA256'); + verify.update(Buffer.from(Bytes.fromString(signatureBody))); + const isValid = verify.verify(pem[0], Buffer.from(signature)); + if (!isValid) { + throw new Error('Validation of signature across signatureBody failed!'); + } + + const caStore = pki.createCaStore([this.options.directoryTrustAnchor]); + const chain = compact(pem.map(cert => pki.certificateFromPem(cert))); + const isChainValid = pki.verifyCertificateChain(caStore, chain); + if (!isChainValid) { + throw new Error('Validation of certificate chain failed!'); + } + + const leafCert = chain[0]; + const fieldCN = leafCert.subject.getField('CN'); + if (!fieldCN || fieldCN.value !== 'Intel SGX Attestation Report Signing') { + throw new Error('Leaf cert CN field had unexpected value'); + } + const fieldO = leafCert.subject.getField('O'); + if (!fieldO || fieldO.value !== 'Intel Corporation') { + throw new Error('Leaf cert O field had unexpected value'); + } + const fieldL = leafCert.subject.getField('L'); + if (!fieldL || fieldL.value !== 'Santa Clara') { + throw new Error('Leaf cert L field had unexpected value'); + } + const fieldST = leafCert.subject.getField('ST'); + if (!fieldST || fieldST.value !== 'CA') { + throw new Error('Leaf cert ST field had unexpected value'); + } + const fieldC = leafCert.subject.getField('C'); + if (!fieldC || fieldC.value !== 'US') { + throw new Error('Leaf cert C field had unexpected value'); + } + } + + private validateAttestationQuote({ + serverStaticPublic, + quote: quoteBytes, + }: { + serverStaticPublic: Uint8Array; + quote: Uint8Array; + }): void { + const SGX_CONSTANTS = getSgxConstants(); + const quote = Buffer.from(quoteBytes); + + const quoteVersion = quote.readInt16LE(0) & 0xffff; + if (quoteVersion < 0 || quoteVersion > 2) { + throw new Error(`Unknown version ${quoteVersion}`); + } + + const miscSelect = quote.slice(64, 64 + 4); + if (!miscSelect.every(byte => byte === 0)) { + throw new Error('Quote miscSelect invalid!'); + } + + const reserved1 = quote.slice(68, 68 + 28); + if (!reserved1.every(byte => byte === 0)) { + throw new Error('Quote reserved1 invalid!'); + } + + const flags = Long.fromBytesLE( + Array.from(quote.slice(96, 96 + 8).values()) + ); + if ( + flags.and(SGX_CONSTANTS.SGX_FLAGS_RESERVED).notEquals(0) || + flags.and(SGX_CONSTANTS.SGX_FLAGS_INITTED).equals(0) || + flags.and(SGX_CONSTANTS.SGX_FLAGS_MODE64BIT).equals(0) + ) { + throw new Error(`Quote flags invalid ${flags.toString()}`); + } + + const xfrm = Long.fromBytesLE( + Array.from(quote.slice(104, 104 + 8).values()) + ); + if (xfrm.and(SGX_CONSTANTS.SGX_XFRM_RESERVED).notEquals(0)) { + throw new Error(`Quote xfrm invalid ${xfrm}`); + } + + const mrenclave = quote.slice(112, 112 + 32); + const enclaveIdBytes = Bytes.fromHex(this.options.directoryEnclaveId); + if (mrenclave.compare(enclaveIdBytes) !== 0) { + throw new Error('Quote mrenclave invalid!'); + } + + const reserved2 = quote.slice(144, 144 + 32); + if (!reserved2.every(byte => byte === 0)) { + throw new Error('Quote reserved2 invalid!'); + } + + const reportData = quote.slice(368, 368 + 64); + const serverStaticPublicBytes = serverStaticPublic; + if ( + !reportData.every((byte, index) => { + if (index >= 32) { + return byte === 0; + } + return byte === serverStaticPublicBytes[index]; + }) + ) { + throw new Error('Quote report_data invalid!'); + } + + const reserved3 = quote.slice(208, 208 + 96); + if (!reserved3.every(byte => byte === 0)) { + throw new Error('Quote reserved3 invalid!'); + } + + const reserved4 = quote.slice(308, 308 + 60); + if (!reserved4.every(byte => byte === 0)) { + throw new Error('Quote reserved4 invalid!'); + } + + const signatureLength = quote.readInt32LE(432) >>> 0; + if (signatureLength !== quote.byteLength - 436) { + throw new Error(`Bad signatureLength ${signatureLength}`); + } + + // const signature = quote.slice(436, 436 + signatureLength); + } +} + +function validateAttestationSignatureBody( + signatureBody: { + timestamp: string; + version: number; + isvEnclaveQuoteBody: string; + isvEnclaveQuoteStatus: string; + }, + encodedQuote: string +) { + // Parse timestamp as UTC + const { timestamp } = signatureBody; + const utcTimestamp = timestamp.endsWith('Z') ? timestamp : `${timestamp}Z`; + const signatureTime = new Date(utcTimestamp).getTime(); + + const now = Date.now(); + if (signatureBody.version !== 3) { + throw new Error('Attestation signature invalid version!'); + } + if (!encodedQuote.startsWith(signatureBody.isvEnclaveQuoteBody)) { + throw new Error('Attestion signature mismatches quote!'); + } + if (signatureBody.isvEnclaveQuoteStatus !== 'OK') { + throw new Error('Attestation signature status not "OK"!'); + } + if (signatureTime < now - 24 * 60 * 60 * 1000) { + throw new Error('Attestation signature timestamp older than 24 hours!'); + } +} diff --git a/ts/textsecure/cds/Types.d.ts b/ts/textsecure/cds/Types.d.ts new file mode 100644 index 000000000..9e122ec8d --- /dev/null +++ b/ts/textsecure/cds/Types.d.ts @@ -0,0 +1,23 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { UUIDStringType } from '../../types/UUID'; + +export type CDSAuthType = Readonly<{ + username: string; + password: string; +}>; + +export type CDSResponseEntryType = Readonly<{ + aci: UUIDStringType | undefined; + pni: UUIDStringType | undefined; +}>; + +export type CDSResponseType = ReadonlyMap; + +export type CDSRequestOptionsType = Readonly<{ + e164s: ReadonlyArray; + acis?: ReadonlyArray; + accessKeys?: ReadonlyArray; + timeout?: number; +}>; diff --git a/ts/types/RendererConfig.ts b/ts/types/RendererConfig.ts index b4fb294b4..095f2bfed 100644 --- a/ts/types/RendererConfig.ts +++ b/ts/types/RendererConfig.ts @@ -11,11 +11,55 @@ export type ConfigRequiredStringType = z.infer< typeof configRequiredStringSchema >; +const configOptionalUnknownSchema = configRequiredStringSchema.or(z.unknown()); + const configOptionalStringSchema = configRequiredStringSchema.or(z.undefined()); export type configOptionalStringType = z.infer< typeof configOptionalStringSchema >; +const directoryV1ConfigSchema = z.object({ + directoryVersion: z.literal(1), + directoryEnclaveId: configRequiredStringSchema, + directoryTrustAnchor: configRequiredStringSchema, + directoryUrl: configRequiredStringSchema, +}); + +const directoryV2ConfigSchema = z.object({ + directoryVersion: z.literal(2), + directoryV2CodeHashes: z.array(z.string().nonempty()), + directoryV2PublicKey: configRequiredStringSchema, + directoryV2Url: configRequiredStringSchema, +}); + +const directoryV3ConfigSchema = z.object({ + directoryVersion: z.literal(3), + directoryV3Url: configRequiredStringSchema, + directoryV3MRENCLAVE: configRequiredStringSchema, + directoryV3Root: configRequiredStringSchema, +}); + +export const directoryConfigSchema = z + .object({ + // Unknown defaults + directoryEnclaveId: configOptionalUnknownSchema, + directoryTrustAnchor: configOptionalUnknownSchema, + directoryUrl: configOptionalUnknownSchema, + directoryV2CodeHashes: configOptionalUnknownSchema, + directoryV2PublicKey: configOptionalUnknownSchema, + directoryV2Url: configOptionalUnknownSchema, + directoryV3Url: configOptionalUnknownSchema, + directoryV3MRENCLAVE: configOptionalUnknownSchema, + directoryV3Root: configOptionalUnknownSchema, + }) + .and( + directoryV1ConfigSchema + .or(directoryV2ConfigSchema) + .or(directoryV3ConfigSchema) + ); + +export type DirectoryConfigType = z.infer; + export const rendererConfigSchema = z.object({ appInstance: configOptionalStringSchema, appStartInitialSpellcheckSetting: z.boolean(), @@ -26,13 +70,6 @@ export const rendererConfigSchema = z.object({ certificateAuthority: configRequiredStringSchema, contentProxyUrl: configRequiredStringSchema, crashDumpsPath: configRequiredStringSchema, - directoryEnclaveId: configOptionalStringSchema, - directoryTrustAnchor: configOptionalStringSchema, - directoryUrl: configOptionalStringSchema, - directoryV2CodeHashes: z.array(z.string().nonempty()).or(z.undefined()), - directoryV2PublicKey: configOptionalStringSchema, - directoryV2Url: configOptionalStringSchema, - directoryVersion: z.number(), enableCI: z.boolean(), environment: environmentSchema, homePath: configRequiredStringSchema, @@ -51,6 +88,7 @@ export const rendererConfigSchema = z.object({ updatesUrl: configRequiredStringSchema, userDataPath: configRequiredStringSchema, version: configRequiredStringSchema, + directoryConfig: directoryConfigSchema, // Only used by main window isMainWindowFullScreen: z.boolean(), diff --git a/ts/window.d.ts b/ts/window.d.ts index 72e36e688..2135139d4 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -233,7 +233,6 @@ declare global { preloadStartTime: number; preloadEndTime: number; - preloadConnectTime: number; removeSetupMenuItems: () => unknown; showPermissionsPopup: ( diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index a7b99947a..636aee433 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -109,16 +109,17 @@ window.isAfterVersion = (toCheck, baseVersion) => { window.setBadgeCount = count => ipc.send('set-badge-count', count); +let preloadConnectTime = 0; window.logAuthenticatedConnect = () => { - if (window.preloadConnectTime === 0) { - window.preloadConnectTime = Date.now(); + if (preloadConnectTime === 0) { + preloadConnectTime = Date.now(); } }; window.logAppLoadedEvent = ({ processedCount }) => ipc.send('signal-app-loaded', { preloadTime: window.preloadEndTime - window.preloadStartTime, - connectTime: window.preloadConnectTime - window.preloadEndTime, + connectTime: preloadConnectTime - window.preloadEndTime, processedCount, }); diff --git a/ts/windows/main/phase2-dependencies.ts b/ts/windows/main/phase2-dependencies.ts index e96313f1f..baae91ffa 100644 --- a/ts/windows/main/phase2-dependencies.ts +++ b/ts/windows/main/phase2-dependencies.ts @@ -29,13 +29,7 @@ window.WebAPI = window.textsecure.WebAPI.initialize({ url: config.serverUrl, storageUrl: config.storageUrl, updatesUrl: config.updatesUrl, - directoryVersion: config.directoryVersion, - directoryUrl: config.directoryUrl, - directoryEnclaveId: config.directoryEnclaveId, - directoryTrustAnchor: config.directoryTrustAnchor, - directoryV2Url: config.directoryV2Url, - directoryV2PublicKey: config.directoryV2PublicKey, - directoryV2CodeHashes: config.directoryV2CodeHashes, + directoryConfig: config.directoryConfig, cdnUrlObject: { 0: config.cdnUrl0, 2: config.cdnUrl2, diff --git a/yarn.lock b/yarn.lock index ffb046995..77d1e297c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1674,7 +1674,15 @@ "@react-spring/shared" "~9.4.5" "@react-spring/types" "~9.4.5" -"@signalapp/libsignal-client@0.16.0", "@signalapp/libsignal-client@^0.16.0": +"@signalapp/libsignal-client@0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.17.0.tgz#ffe6763d80f56148b45192bca29deb16f9a0aea8" + integrity sha512-O5bd/BURWnybh6KhRYSO3NmNb1/oySu5yJx5ELy3QsfeFvpMnTkr0/PcXd0MCvRiaoN+/a0TsnywMO43t6Nxsw== + dependencies: + node-gyp-build "^4.2.3" + uuid "^8.3.0" + +"@signalapp/libsignal-client@^0.16.0": version "0.16.0" resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.16.0.tgz#7acba54b7ba05f513cdcf7f555efa1ccc6ce0145" integrity sha512-/5EzlAcQoQReDomqV6VTtin5tvqvdUxoe8knSiz+L1kcLSlHA0So0zTR9WAdfQQ69t4q69vhaS4pu5yVI28YHA==