CDSI Support
This commit is contained in:
parent
038ec9e05d
commit
253e050262
23 changed files with 1432 additions and 1000 deletions
39
app/main.ts
39
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<string> {
|
||||
const theme = await getResolvedThemeSetting();
|
||||
|
||||
const urlParams: RendererConfigType = {
|
||||
name: packageJson.productName,
|
||||
locale: getLocale().name,
|
||||
version: app.getVersion(),
|
||||
buildCreation: config.get<number>('buildCreation'),
|
||||
buildExpiration: config.get<number>('buildExpiration'),
|
||||
serverUrl: config.get<string>('serverUrl'),
|
||||
storageUrl: config.get<string>('storageUrl'),
|
||||
updatesUrl: config.get<string>('updatesUrl'),
|
||||
const directoryConfig = directoryConfigSchema.safeParse({
|
||||
directoryVersion: config.get<number | undefined>('directoryVersion') || 1,
|
||||
directoryUrl: config.get<string | null>('directoryUrl') || undefined,
|
||||
directoryEnclaveId:
|
||||
|
@ -388,6 +383,28 @@ async function prepareUrl(
|
|||
config.get<string | null>('directoryV2PublicKey') || undefined,
|
||||
directoryV2CodeHashes:
|
||||
config.get<Array<string> | null>('directoryV2CodeHashes') || undefined,
|
||||
directoryV3Url: config.get<string | null>('directoryV3Url') || undefined,
|
||||
directoryV3MRENCLAVE:
|
||||
config.get<string | null>('directoryV3MRENCLAVE') || undefined,
|
||||
directoryV3Root: config.get<string | null>('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<number>('buildCreation'),
|
||||
buildExpiration: config.get<number>('buildExpiration'),
|
||||
serverUrl: config.get<string>('serverUrl'),
|
||||
storageUrl: config.get<string>('storageUrl'),
|
||||
updatesUrl: config.get<string>('updatesUrl'),
|
||||
cdnUrl0: config.get<ConfigType>('cdn').get<string>('0'),
|
||||
cdnUrl2: config.get<ConfigType>('cdn').get<string>('2'),
|
||||
certificateAuthority: config.get<string>('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()),
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<string>;
|
||||
timeout?: number;
|
||||
} & (
|
||||
| {
|
||||
version: 1;
|
||||
acis?: undefined;
|
||||
accessKeys?: undefined;
|
||||
}
|
||||
| {
|
||||
version: 2;
|
||||
acis: ReadonlyArray<UUIDStringType>;
|
||||
accessKeys: ReadonlyArray<string>;
|
||||
}
|
||||
)
|
||||
>;
|
||||
|
||||
export type CDSAuthType = Readonly<{
|
||||
username: string;
|
||||
password: string;
|
||||
}>;
|
||||
|
||||
export type CDSSocketDictionaryEntryType = Readonly<{
|
||||
aci: UUIDStringType | undefined;
|
||||
pni: UUIDStringType | undefined;
|
||||
}>;
|
||||
|
||||
export type CDSSocketDictionaryType = Readonly<
|
||||
Record<string, CDSSocketDictionaryEntryType>
|
||||
>;
|
||||
|
||||
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<void>;
|
||||
|
||||
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<void>();
|
||||
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<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'
|
||||
);
|
||||
|
||||
strictAssert(
|
||||
acis.length === accessKeys.length,
|
||||
`Number of ACIs ${acis.length} is different ` +
|
||||
`from number of access keys ${accessKeys.length}`
|
||||
);
|
||||
const aciUakPairs = new Array<Uint8Array>();
|
||||
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<string, CDSSocketDictionaryEntryType> = 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<string, CDSSocketDictionaryEntryType> =
|
||||
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<any>) => 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<any>): boolean {
|
||||
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 });
|
||||
}
|
||||
}
|
|
@ -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<string>;
|
||||
certificateAuthority: string;
|
||||
proxyUrl?: string;
|
||||
version: string;
|
||||
}>;
|
||||
|
||||
export type CDSResponseType = CDSSocketDictionaryType;
|
||||
|
||||
export class CDSSocketManager {
|
||||
private readonly publicKey: Buffer;
|
||||
|
||||
private readonly codeHashes: Array<Buffer>;
|
||||
|
||||
private readonly proxyAgent?: ReturnType<typeof ProxyAgent>;
|
||||
|
||||
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<CDSResponseType> {
|
||||
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<CDSSocket> {
|
||||
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<CDSSocket>({
|
||||
name: 'CDSSocket',
|
||||
url,
|
||||
version,
|
||||
proxyAgent: this.proxyAgent,
|
||||
certificateAuthority: this.options.certificateAuthority,
|
||||
extraHeaders: {
|
||||
authorization: getBasicAuth(auth),
|
||||
},
|
||||
|
||||
createResource: (socket: WebSocket): CDSSocket => {
|
||||
return new CDSSocket(socket, enclaveClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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<string | undefined>
|
||||
): RedactUrl {
|
||||
|
@ -211,8 +165,8 @@ type PromiseAjaxOptionsType = {
|
|||
}
|
||||
);
|
||||
|
||||
type JSONWithDetailsType = {
|
||||
data: unknown;
|
||||
type JSONWithDetailsType<Data = unknown> = {
|
||||
data: Data;
|
||||
contentType: string | null;
|
||||
response: Response;
|
||||
};
|
||||
|
@ -594,17 +548,46 @@ const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
|||
'storageToken',
|
||||
]);
|
||||
|
||||
type DirectoryV1OptionsType = Readonly<{
|
||||
directoryVersion: 1;
|
||||
directoryUrl: string;
|
||||
directoryEnclaveId: string;
|
||||
directoryTrustAnchor: string;
|
||||
}>;
|
||||
|
||||
type DirectoryV2OptionsType = Readonly<{
|
||||
directoryVersion: 2;
|
||||
directoryV2Url: string;
|
||||
directoryV2PublicKey: string;
|
||||
directoryV2CodeHashes: ReadonlyArray<string>;
|
||||
}>;
|
||||
|
||||
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<string>;
|
||||
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<LegacyCDSPutAttestationResponseType>;
|
||||
|
||||
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<string>
|
||||
): Promise<Dictionary<UUIDStringType | null>> {
|
||||
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<string>
|
||||
): Promise<Dictionary<UUIDStringType | null>> {
|
||||
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<UUIDStringType | null> = {};
|
||||
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<CDSResponseType> {
|
||||
const auth = await getDirectoryAuthV2();
|
||||
|
||||
return cdsSocketManager.request({
|
||||
version: 2,
|
||||
auth,
|
||||
return cds.request({
|
||||
e164s,
|
||||
acis,
|
||||
accessKeys,
|
||||
|
|
67
ts/textsecure/cds/CDSBase.ts
Normal file
67
ts/textsecure/cds/CDSBase.ts
Normal file
|
@ -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<CDSAuthType>;
|
||||
}>;
|
||||
|
||||
export type CachedAuthType = Readonly<{
|
||||
timestamp: number;
|
||||
auth: CDSAuthType;
|
||||
}>;
|
||||
|
||||
export abstract class CDSBase<
|
||||
Options extends CDSBaseOptionsType = CDSBaseOptionsType
|
||||
> {
|
||||
protected readonly logger: LoggerType;
|
||||
protected readonly proxyAgent?: ReturnType<typeof ProxyAgent>;
|
||||
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<CDSResponseType>;
|
||||
|
||||
protected async getAuth(): Promise<CDSAuthType> {
|
||||
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;
|
||||
}
|
||||
}
|
50
ts/textsecure/cds/CDSH.ts
Normal file
50
ts/textsecure/cds/CDSH.ts
Normal file
|
@ -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<string>;
|
||||
}> &
|
||||
CDSSocketManagerBaseOptionsType;
|
||||
|
||||
export class CDSH extends CDSSocketManagerBase<CDSHSocket, CDSHOptionsType> {
|
||||
private readonly publicKey: Buffer;
|
||||
|
||||
private readonly codeHashes: Array<Buffer>;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
49
ts/textsecure/cds/CDSHSocket.ts
Normal file
49
ts/textsecure/cds/CDSHSocket.ts
Normal file
|
@ -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<CDSHSocketOptionsType> {
|
||||
public override async handshake(): Promise<void> {
|
||||
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<void> {
|
||||
this.socket.sendBytes(
|
||||
this.options.enclaveClient.establishedSend(
|
||||
Buffer.concat([Buffer.from([version]), request])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected override async decryptResponse(
|
||||
ciphertext: Buffer
|
||||
): Promise<Buffer> {
|
||||
return this.options.enclaveClient.establishedRecv(ciphertext);
|
||||
}
|
||||
}
|
43
ts/textsecure/cds/CDSI.ts
Normal file
43
ts/textsecure/cds/CDSI.ts
Normal file
|
@ -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<CDSISocket, CDSIOptionsType> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
100
ts/textsecure/cds/CDSISocket.ts
Normal file
100
ts/textsecure/cds/CDSISocket.ts
Normal file
|
@ -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<CDSISocketOptionsType> {
|
||||
private privCdsClient: Cds2Client | undefined;
|
||||
|
||||
public override async handshake(): Promise<void> {
|
||||
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<void> {
|
||||
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<Buffer> {
|
||||
return this.cdsClient.establishedRecv(ciphertext);
|
||||
}
|
||||
|
||||
//
|
||||
// Private
|
||||
//
|
||||
|
||||
private get cdsClient(): Cds2Client {
|
||||
strictAssert(this.privCdsClient, 'CDSISocket did not start handshake');
|
||||
return this.privCdsClient;
|
||||
}
|
||||
}
|
260
ts/textsecure/cds/CDSSocketBase.ts
Normal file
260
ts/textsecure/cds/CDSSocketBase.ts
Normal file
|
@ -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<Buffer>;
|
||||
|
||||
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<void> {
|
||||
return this.socket.close(code, reason);
|
||||
}
|
||||
|
||||
public async request({
|
||||
e164s,
|
||||
acis,
|
||||
accessKeys,
|
||||
}: CDSRequestOptionsType): Promise<CDSSocketResponseType> {
|
||||
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<Uint8Array>();
|
||||
|
||||
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<string, CDSResponseEntryType> = 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<void>;
|
||||
|
||||
protected abstract sendRequest(version: number, data: Buffer): Promise<void>;
|
||||
|
||||
protected abstract decryptResponse(ciphertext: Buffer): Promise<Buffer>;
|
||||
|
||||
// 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<any>) => 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<any>): boolean {
|
||||
return super.emit(type, ...args);
|
||||
}
|
||||
|
||||
//
|
||||
// Private
|
||||
//
|
||||
|
||||
private iterateSocket(): AsyncIterator<Buffer> {
|
||||
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<string, CDSResponseEntryType>,
|
||||
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 });
|
||||
}
|
||||
}
|
107
ts/textsecure/cds/CDSSocketManagerBase.ts
Normal file
107
ts/textsecure/cds/CDSSocketManagerBase.ts
Normal file
|
@ -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<Options> {
|
||||
private retryAfter?: number;
|
||||
|
||||
public async request(
|
||||
options: CDSRequestOptionsType
|
||||
): Promise<CDSResponseType> {
|
||||
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<Socket> {
|
||||
return connectWebSocket<Socket>({
|
||||
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;
|
||||
}
|
455
ts/textsecure/cds/LegacyCDS.ts
Normal file
455
ts/textsecure/cds/LegacyCDS.ts
Normal file
|
@ -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<LegacyCDSPutAttestationResultType>;
|
||||
fetchDiscoveryData: (
|
||||
auth: CDSAuthType,
|
||||
data: Record<string, unknown>,
|
||||
cookie?: string
|
||||
) => Promise<LegacyCDSDiscoveryResponseType>;
|
||||
}> &
|
||||
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<LegacyCDSOptionsType> {
|
||||
public override async request({
|
||||
e164s,
|
||||
acis,
|
||||
accessKeys,
|
||||
}: CDSRequestOptionsType): Promise<CDSResponseType> {
|
||||
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<string, CDSResponseEntryType>();
|
||||
|
||||
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<AttestationMapType> {
|
||||
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!');
|
||||
}
|
||||
}
|
23
ts/textsecure/cds/Types.d.ts
vendored
Normal file
23
ts/textsecure/cds/Types.d.ts
vendored
Normal file
|
@ -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<string, CDSResponseEntryType>;
|
||||
|
||||
export type CDSRequestOptionsType = Readonly<{
|
||||
e164s: ReadonlyArray<string>;
|
||||
acis?: ReadonlyArray<UUIDStringType>;
|
||||
accessKeys?: ReadonlyArray<string>;
|
||||
timeout?: number;
|
||||
}>;
|
|
@ -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<typeof directoryConfigSchema>;
|
||||
|
||||
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(),
|
||||
|
|
1
ts/window.d.ts
vendored
1
ts/window.d.ts
vendored
|
@ -233,7 +233,6 @@ declare global {
|
|||
|
||||
preloadStartTime: number;
|
||||
preloadEndTime: number;
|
||||
preloadConnectTime: number;
|
||||
|
||||
removeSetupMenuItems: () => unknown;
|
||||
showPermissionsPopup: (
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
10
yarn.lock
10
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==
|
||||
|
|
Loading…
Reference in a new issue