Use new CDS implementation in staging

This commit is contained in:
Fedor Indutny 2022-03-09 11:28:40 -08:00 committed by GitHub
parent 5774fdef9f
commit 0c8c332805
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 284 additions and 130 deletions

View file

@ -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<string>;
acis: ReadonlyArray<UUIDStringType>;
accessKeys: ReadonlyArray<string>;
auth: CDSAuthType;
timeout?: number;
}>;
export type CDSRequestOptionsType = Readonly<
{
auth: CDSAuthType;
e164s: ReadonlyArray<string>;
timeout?: number;
} & (
| {
version: 1;
acis?: undefined;
accessKeys?: undefined;
}
| {
version: 2;
acis: ReadonlyArray<UUIDStringType>;
accessKeys: ReadonlyArray<string>;
}
)
>;
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<void>;
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<CDSSocketResponseType> {
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<Uint8Array>();
const aciUakPairs = new Array<Uint8Array>();
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<Buffer>();
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<string, CDSSocketDictionaryEntryType> = 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<string, CDSSocketDictionaryEntryType> =
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<string, CDSSocketDictionaryEntryType> =
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<string, CDSSocketDictionaryEntryType>,
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 });
}
}

View file

@ -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<string>;
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<Buffer>;
private readonly proxyAgent?: ReturnType<typeof ProxyAgent>;
@ -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<CDSSocket> {
const enclaveClient = HsmEnclaveClient.new(this.publicKey, [this.codeHash]);
private connect(auth: CDSAuthType): AbortableProcess<CDSSocket> {
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<CDSSocket>({
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);

View file

@ -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<string>;
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<string>
): Promise<Dictionary<UUIDStringType | null>> {
const directoryAuth = await getDirectoryAuth();
@ -3127,6 +3156,24 @@ export function initialize({
return zipObject(e164s, uuids);
}
async function getUuidsForE164s(
e164s: ReadonlyArray<string>
): Promise<Dictionary<UUIDStringType | null>> {
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,

View file

@ -28,6 +28,7 @@ export type ConnectOptionsType<Resource extends IResource> = Readonly<{
version: string;
proxyAgent?: ReturnType<typeof ProxyAgent>;
timeout?: number;
extraHeaders?: Record<string, string>;
createResource(socket: WebSocket): Resource;
}>;
@ -38,6 +39,7 @@ export function connect<Resource extends IResource>({
certificateAuthority,
version,
proxyAgent,
extraHeaders = {},
timeout = TEN_SECONDS,
createResource,
}: ConnectOptionsType<Resource>): AbortableProcess<Resource> {
@ -46,6 +48,7 @@ export function connect<Resource extends IResource>({
.replace('http://', 'ws://');
const headers = {
...extraHeaders,
'User-Agent': getUserAgent(version),
};
const client = new WebSocketClient({