Add v2 implementation of CDS HSM

This commit is contained in:
Fedor Indutny 2021-12-06 23:54:20 +01:00 committed by GitHub
parent 56a8e79413
commit b4b65c4f00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 180 additions and 31 deletions

View File

@ -6,7 +6,7 @@
"directoryTrustAnchor": "-----BEGIN CERTIFICATE-----\nMIIFSzCCA7OgAwIBAgIJANEHdl0yo7CUMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV\nBAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwLU2FudGEgQ2xhcmExGjAYBgNV\nBAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQDDCdJbnRlbCBTR1ggQXR0ZXN0\nYXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwIBcNMTYxMTE0MTUzNzMxWhgPMjA0OTEy\nMzEyMzU5NTlaMH4xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEUMBIGA1UEBwwL\nU2FudGEgQ2xhcmExGjAYBgNVBAoMEUludGVsIENvcnBvcmF0aW9uMTAwLgYDVQQD\nDCdJbnRlbCBTR1ggQXR0ZXN0YXRpb24gUmVwb3J0IFNpZ25pbmcgQ0EwggGiMA0G\nCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCfPGR+tXc8u1EtJzLA10Feu1Wg+p7e\nLmSRmeaCHbkQ1TF3Nwl3RmpqXkeGzNLd69QUnWovYyVSndEMyYc3sHecGgfinEeh\nrgBJSEdsSJ9FpaFdesjsxqzGRa20PYdnnfWcCTvFoulpbFR4VBuXnnVLVzkUvlXT\nL/TAnd8nIZk0zZkFJ7P5LtePvykkar7LcSQO85wtcQe0R1Raf/sQ6wYKaKmFgCGe\nNpEJUmg4ktal4qgIAxk+QHUxQE42sxViN5mqglB0QJdUot/o9a/V/mMeH8KvOAiQ\nbyinkNndn+Bgk5sSV5DFgF0DffVqmVMblt5p3jPtImzBIH0QQrXJq39AT8cRwP5H\nafuVeLHcDsRp6hol4P+ZFIhu8mmbI1u0hH3W/0C2BuYXB5PC+5izFFh/nP0lc2Lf\n6rELO9LZdnOhpL1ExFOq9H/B8tPQ84T3Sgb4nAifDabNt/zu6MmCGo5U8lwEFtGM\nRoOaX4AS+909x00lYnmtwsDVWv9vBiJCXRsCAwEAAaOByTCBxjBgBgNVHR8EWTBX\nMFWgU6BRhk9odHRwOi8vdHJ1c3RlZHNlcnZpY2VzLmludGVsLmNvbS9jb250ZW50\nL0NSTC9TR1gvQXR0ZXN0YXRpb25SZXBvcnRTaWduaW5nQ0EuY3JsMB0GA1UdDgQW\nBBR4Q3t2pn680K9+QjfrNXw7hwFRPDAfBgNVHSMEGDAWgBR4Q3t2pn680K9+Qjfr\nNXw7hwFRPDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkq\nhkiG9w0BAQsFAAOCAYEAeF8tYMXICvQqeXYQITkV2oLJsp6J4JAqJabHWxYJHGir\nIEqucRiJSSx+HjIJEUVaj8E0QjEud6Y5lNmXlcjqRXaCPOqK0eGRz6hi+ripMtPZ\nsFNaBwLQVV905SDjAzDzNIDnrcnXyB4gcDFCvwDFKKgLRjOB/WAqgscDUoGq5ZVi\nzLUzTqiQPmULAQaB9c6Oti6snEFJiCQ67JLyW/E83/frzCmO5Ru6WjU4tmsmy8Ra\nUd4APK0wZTGtfPXU7w+IBdG5Ez0kE1qzxGQaL4gINJ1zMyleDnbuS8UicjJijvqA\n152Sq049ESDz+1rRGc2NVEqh1KaGXmtXvqxXcTB+Ljy5Bw2ke0v8iGngFBPqCTVB\n3op5KBG3RjbF6RRSzwzuWfL7QErNC8WEy5yDVARzTA5+xmBc388v9Dm21HGfcC8O\nDD+gT9sSpssq0ascmvH49MOgjt1yoysLtdCtJW/9FZpoOypaHx0R+mJTLwPXVMrv\nDaVzWh5aiEx+idkSGMnX\n-----END CERTIFICATE-----\n",
"directoryV2Url": "https://cdsh.staging.signal.org",
"directoryV2PublicKey": "2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74",
"directoryV2CodeHash": "ec58c0d7561de8d5657f3a4b22a635eaa305204e9359dcc80a99dfd0c5f1cbf2",
"directoryV2CodeHash": "8c8025d787b4e7da35047c342a96c24a7119fd23ed9a3a774454a06315b4852a",
"cdn": {
"0": "https://cdn-staging.signal.org",
"2": "https://cdn2-staging.signal.org"

View File

@ -0,0 +1,40 @@
// Copyright 2021-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice;
message CDSClientRequest {
// From Signal /v2/directory/auth
optional bytes username = 1;
optional bytes password = 2;
// Each e164 is a big-endian uint64 (8 bytes).
repeated bytes e164 = 3;
// Each ACI/UAK pair is a 32-byte buffer, containing the 16-byte ACI followed
// by its 16-byte UAK.
repeated bytes aci_uak_pair = 4;
}
message CDSClientResponse {
// Each triple is an 8-byte e164, a 16-byte PNI, and a 16-byte ACI.
// If the e164 was not found, PNI and ACI are all zeros. If the PNI
// was found but the ACI was not, the PNI will be non-zero and the ACI
// will be all zeros. ACI will be returned if one of the returned
// PNIs has an ACI/UAK pair that matches.
//
// Should the request be successful (IE: a successful status returned),
// |e164_pni_aci_triple| will always equal |e164| of the request,
// so the entire marshalled size of the response will be (2+32)*|e164|,
// where the additional 2 bytes are the id/type/length additions of the
// protobuf marshaling added to each byte array. This avoids any data
// leakage based on the size of the encrypted output.
repeated bytes e164_pni_aci_triple = 1;
// If the user has run out of quota for lookups, they will receive
// a response with just the following field set, followed by a websocket
// closure of type 4008 (RESOURCE_EXHAUSTED). Should they retry exactly
// the same request after the provided number of seconds has passed,
// we expect it should work.
optional int32 retry_after_secs = 2;
}

View File

@ -12,7 +12,7 @@ import { calculateAgreement, generateKeyPair } from './Curve';
import * as log from './logging/log';
import { HashType, CipherType } from './types/Crypto';
import { ProfileDecryptError } from './types/errors';
import { UUID } from './types/UUID';
import { UUID, UUID_BYTE_SIZE } from './types/UUID';
import type { UUIDStringType } from './types/UUID';
export { HashType, CipherType };
@ -458,7 +458,7 @@ export function uuidToBytes(uuid: string): Uint8Array {
}
export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType {
if (bytes.byteLength !== 16) {
if (bytes.byteLength !== UUID_BYTE_SIZE) {
log.warn(
'bytesToUuid: received an Uint8Array of invalid length. ' +
'Returning undefined'
@ -475,8 +475,8 @@ export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType {
export function splitUuids(buffer: Uint8Array): Array<UUIDStringType | null> {
const uuids = [];
for (let i = 0; i < buffer.byteLength; i += 16) {
const bytes = getBytes(buffer, i, 16);
for (let i = 0; i < buffer.byteLength; i += UUID_BYTE_SIZE) {
const bytes = getBytes(buffer, i, UUID_BYTE_SIZE);
const hex = Bytes.toHex(bytes);
const chunks = [
hex.substring(0, 8),

View File

@ -7,12 +7,15 @@ import type { connection as WebSocket } from 'websocket';
import Long from 'long';
import { strictAssert } from '../util/assert';
import { dropNull } from '../util/dropNull';
import { explodePromise } from '../util/explodePromise';
import * as durations from '../util/durations';
import type { UUIDStringType } from '../types/UUID';
import { UUID_BYTE_SIZE } from '../types/UUID';
import * as Bytes from '../Bytes';
import * as Timers from '../Timers';
import { splitUuids } from '../Crypto';
import { uuidToBytes, bytesToUuid } from '../Crypto';
import { SignalService as Proto } from '../protobuf';
enum State {
Handshake,
@ -22,6 +25,8 @@ enum State {
export type CDSRequestOptionsType = Readonly<{
e164s: ReadonlyArray<string>;
acis: ReadonlyArray<UUIDStringType>;
accessKeys: ReadonlyArray<string>;
auth: CDSAuthType;
timeout?: number;
}>;
@ -31,11 +36,26 @@ export type CDSAuthType = Readonly<{
password: string;
}>;
export type CDSSocketDictionaryEntryType = Readonly<{
aci: UUIDStringType | undefined;
pni: UUIDStringType | undefined;
}>;
export type CDSSocketDictionaryType = Readonly<
Record<string, CDSSocketDictionaryEntryType>
>;
export type CDSSocketResponseType = Readonly<{
dictionary: CDSSocketDictionaryType;
retryAfterSecs?: number;
}>;
const HANDSHAKE_TIMEOUT = 10 * durations.SECOND;
const REQUEST_TIMEOUT = 10 * durations.SECOND;
const VERSION = new Uint8Array([0x01]);
const VERSION = new Uint8Array([0x02]);
const USERNAME_LENGTH = 32;
const PASSWORD_LENGTH = 31;
const E164_BYTE_SIZE = 8;
export class CDSSocket extends EventEmitter {
private state = State.Handshake;
@ -96,9 +116,11 @@ export class CDSSocket extends EventEmitter {
public async request({
e164s,
acis,
accessKeys,
auth,
timeout = REQUEST_TIMEOUT,
}: CDSRequestOptionsType): Promise<ReadonlyArray<UUIDStringType | null>> {
}: CDSRequestOptionsType): Promise<CDSSocketResponseType> {
await this.finishedHandshake;
strictAssert(
this.state === State.Established,
@ -116,15 +138,30 @@ export class CDSSocket extends EventEmitter {
'Invalid password length'
);
const request = Bytes.concatenate([
VERSION,
strictAssert(
acis.length === accessKeys.length,
`Number of ACIs ${acis.length} is different ` +
`from number of access keys ${accessKeys.length}`
);
const aciUakPair = new Array<Uint8Array>();
for (let i = 0; i < acis.length; i += 1) {
aciUakPair.push(
Bytes.concatenate([
uuidToBytes(acis[i]),
Bytes.fromBase64(accessKeys[i]),
])
);
}
const request = Proto.CDSClientRequest.encode({
username,
password,
...e164s.map(e164 => {
e164: e164s.map(e164 => {
// Long.fromString handles numbers with or without a leading '+'
return new Uint8Array(Long.fromString(e164).toBytesBE());
}),
]);
aciUakPair,
}).finish();
const { promise, resolve, reject } = explodePromise<Buffer>();
@ -133,7 +170,7 @@ export class CDSSocket extends EventEmitter {
}, timeout);
this.socket.sendBytes(
this.enclaveClient.establishedSend(Buffer.from(request))
this.enclaveClient.establishedSend(Buffer.concat([VERSION, request]))
);
this.requestQueue.push(resolve);
@ -141,11 +178,41 @@ export class CDSSocket extends EventEmitter {
this.requestQueue.length === 1,
'Concurrent use of CDS shold not happen'
);
const uuids = await promise;
const responseBytes = await promise;
Timers.clearTimeout(timer);
return splitUuids(uuids);
const response = Proto.CDSClientResponse.decode(responseBytes);
const dictionary: Record<string, CDSSocketDictionaryEntryType> =
Object.create(null);
for (const tripleBytes of response.e164PniAciTriple ?? []) {
strictAssert(
tripleBytes.length === UUID_BYTE_SIZE * 2 + E164_BYTE_SIZE,
'Invalid size of CDS response triple'
);
let offset = 0;
const e164Bytes = tripleBytes.slice(offset, offset + E164_BYTE_SIZE);
offset += E164_BYTE_SIZE;
const pniBytes = tripleBytes.slice(offset, offset + UUID_BYTE_SIZE);
offset += UUID_BYTE_SIZE;
const aciBytes = tripleBytes.slice(offset, offset + UUID_BYTE_SIZE);
offset += UUID_BYTE_SIZE;
const e164 = `+${Long.fromBytesBE(Array.from(e164Bytes)).toString()}`;
const pni = bytesToUuid(pniBytes);
const aci = bytesToUuid(aciBytes);
dictionary[e164] = { pni, aci };
}
return {
dictionary,
retryAfterSecs: dropNull(response.retryAfterSecs),
};
}
// EventEmitter types

View File

@ -8,10 +8,14 @@ import type { connection as WebSocket } from 'websocket';
import * as Bytes from '../Bytes';
import { prefixPublicKey } from '../Curve';
import type { AbortableProcess } from '../util/AbortableProcess';
import * as durations from '../util/durations';
import { sleep } from '../util/sleep';
import * as log from '../logging/log';
import type { UUIDStringType } from '../types/UUID';
import { CDSSocket } from './CDSSocket';
import type { CDSRequestOptionsType } from './CDSSocket';
import type {
CDSRequestOptionsType,
CDSSocketDictionaryType,
} from './CDSSocket';
import { connect as connectWebSocket } from './WebSocket';
export type CDSSocketManagerOptionsType = Readonly<{
@ -23,6 +27,8 @@ export type CDSSocketManagerOptionsType = Readonly<{
version: string;
}>;
export type CDSResponseType = CDSSocketDictionaryType;
export class CDSSocketManager {
private readonly publicKey: PublicKey;
@ -30,6 +36,8 @@ export class CDSSocketManager {
private readonly proxyAgent?: ReturnType<typeof ProxyAgent>;
private retryAfter?: number;
constructor(private readonly options: CDSSocketManagerOptionsType) {
this.publicKey = PublicKey.deserialize(
Buffer.from(prefixPublicKey(Bytes.fromHex(options.publicKey)))
@ -42,13 +50,29 @@ export class CDSSocketManager {
public async request(
options: CDSRequestOptionsType
): Promise<ReadonlyArray<UUIDStringType | null>> {
): 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);
}
log.info('CDSSocketManager: connecting socket');
const socket = await this.connect().getResult();
log.info('CDSSocketManager: connected socket');
try {
return await socket.request(options);
const { dictionary, retryAfterSecs = 0 } = await socket.request(options);
if (retryAfterSecs > 0) {
this.retryAfter = Math.max(
this.retryAfter ?? Date.now(),
Date.now() + retryAfterSecs * durations.SECOND
);
}
return dictionary;
} finally {
log.info('CDSSocketManager: closing socket');
socket.close(3000, 'Normal');

View File

@ -41,6 +41,7 @@ import type {
SendLogCallbackType,
} from './OutgoingMessage';
import OutgoingMessage from './OutgoingMessage';
import type { CDSResponseType } from './CDSSocketManager';
import * as Bytes from '../Bytes';
import { getRandomBytes, getZeroes, encryptAttachment } from '../Crypto';
import type {
@ -2074,9 +2075,15 @@ export default class MessageSender {
}
async getUuidsForE164sV2(
numbers: ReadonlyArray<string>
): Promise<Dictionary<UUIDStringType | null>> {
return this.server.getUuidsForE164sV2(numbers);
e164s: ReadonlyArray<string>,
acis: ReadonlyArray<UUIDStringType>,
accessKeys: ReadonlyArray<string>
): Promise<CDSResponseType> {
return this.server.getUuidsForE164sV2({
e164s,
acis,
accessKeys,
});
}
async getAvatar(path: string): Promise<ReturnType<WebAPIType['getAvatar']>> {

View File

@ -55,6 +55,7 @@ import type {
StorageServiceCredentials,
} from '../textsecure.d';
import { SocketManager } from './SocketManager';
import type { CDSResponseType } from './CDSSocketManager';
import { CDSSocketManager } from './CDSSocketManager';
import type WebSocketResource from './WebsocketResources';
import { SignalService as Proto } from '../protobuf';
@ -758,6 +759,12 @@ export type ConfirmCodeResultType = Readonly<{
deviceId?: number;
}>;
export type GetUuidsForE164sV2OptionsType = Readonly<{
e164s: ReadonlyArray<string>;
acis: ReadonlyArray<UUIDStringType>;
accessKeys: ReadonlyArray<string>;
}>;
export type WebAPIType = {
confirmCode: (
number: string,
@ -840,8 +847,8 @@ export type WebAPIType = {
e164s: ReadonlyArray<string>
) => Promise<Dictionary<UUIDStringType | null>>;
getUuidsForE164sV2: (
e164s: ReadonlyArray<string>
) => Promise<Dictionary<UUIDStringType | null>>;
options: GetUuidsForE164sV2OptionsType
) => Promise<CDSResponseType>;
fetchLinkPreviewMetadata: (
href: string,
abortSignal: AbortSignal
@ -3005,17 +3012,19 @@ export function initialize({
return zipObject(e164s, uuids);
}
async function getUuidsForE164sV2(
e164s: ReadonlyArray<string>
): Promise<Dictionary<UUIDStringType | null>> {
async function getUuidsForE164sV2({
e164s,
acis,
accessKeys,
}: GetUuidsForE164sV2OptionsType): Promise<CDSResponseType> {
const auth = await getDirectoryAuthV2();
const uuids = await cdsSocketManager.request({
return cdsSocketManager.request({
auth,
e164s,
acis,
accessKeys,
});
return zipObject(e164s, uuids);
}
}
}

View File

@ -14,6 +14,8 @@ export enum UUIDKind {
Unknown = 'Unknown',
}
export const UUID_BYTE_SIZE = 16;
export const isValidUuid = (value: unknown): value is UUIDStringType =>
typeof value === 'string' &&
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(