@signalapp/mock-server
Version:
Mock Signal Server for writing tests
600 lines (599 loc) • 26.7 kB
JavaScript
"use strict";
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Server = void 0;
const assert_1 = __importDefault(require("assert"));
const fs_1 = __importDefault(require("fs"));
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const http2_1 = __importDefault(require("http2"));
const url_1 = require("url");
const libsignal_client_1 = require("@signalapp/libsignal-client");
const zkgroup_1 = require("@signalapp/libsignal-client/zkgroup");
const debug_1 = __importDefault(require("debug"));
const ws_1 = __importDefault(require("ws"));
const micro_1 = require("micro");
const attachment_1 = require("../data/attachment");
const constants_1 = require("../constants");
const types_1 = require("../types");
const contacts_1 = require("../data/contacts");
const crypto_1 = require("../crypto");
const compiled_1 = require("../../protos/compiled");
const base_1 = require("../server/base");
const util_1 = require("../util");
const http_1 = require("../server/http");
const grpc_1 = require("../server/grpc");
const ws_2 = require("../server/ws");
const primary_device_1 = require("./primary-device");
const debug = (0, debug_1.default)('mock:server:mock');
const libsignalDebug = (0, debug_1.default)('mock:server:libsignal');
const CERTS_DIR = path_1.default.join(__dirname, '..', '..', 'certs');
const CERT = fs_1.default.readFileSync(path_1.default.join(CERTS_DIR, 'full-cert.pem'));
const KEY = fs_1.default.readFileSync(path_1.default.join(CERTS_DIR, 'key.pem'));
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const TRUST_ROOT = JSON.parse(fs_1.default.readFileSync(path_1.default.join(CERTS_DIR, 'trust-root.json')).toString());
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const ZK_PARAMS = JSON.parse(fs_1.default.readFileSync(path_1.default.join(CERTS_DIR, 'zk-params.json')).toString());
const DEFAULT_API_TIMEOUT = 60000;
(0, libsignal_client_1.initLogger)(libsignal_client_1.LogLevel.Info, (level, target, file, line, message) => {
let fileString = '';
if (file && line) {
fileString = ` ${file}:${line}`;
}
else if (file) {
fileString = ` ${file}`;
}
const logString = `${libsignal_client_1.LogLevel[level]} ${message} ${target}${fileString}`;
libsignalDebug(logString);
});
class Server extends base_1.Server {
config;
trustRoot;
primaryDevices = new Map();
knownNumbers = new Set();
emptyAttachment;
provisionQueue;
provisionResultQueueByCode = new Map();
provisionResultQueueByKey = new Map();
manifestQueueByAci = new Map();
groupQueueById = new Map();
transferArchiveByDevice = new Map();
transferCallbacksByDevice = new Map();
rateLimitCountByPair = new Map();
responseForChallenges;
unregisteredServiceIds = new Set();
wsUpgradeResponseHeaders = {};
constructor(config = {}) {
super();
this.config = {
timeout: DEFAULT_API_TIMEOUT,
trustRoot: TRUST_ROOT,
zkParams: ZK_PARAMS,
...config,
https: {
key: KEY,
cert: CERT,
allowHTTP1: true,
...(config.https ?? {}),
settings: {
...(config.https?.settings ?? {}),
enableConnectProtocol: true,
},
},
};
const trustPrivate = Buffer.from(this.config.trustRoot.privateKey, 'base64');
this.trustRoot = libsignal_client_1.PrivateKey.deserialize(trustPrivate);
const zkSecret = Buffer.from(this.config.zkParams.secretParams, 'base64');
this.zkSecret = new zkgroup_1.ServerSecretParams(zkSecret);
const genericSecret = Buffer.from(this.config.zkParams.genericSecretParams, 'base64');
this.genericServerSecret = new zkgroup_1.GenericServerSecretParams(genericSecret);
const backupSecret = Buffer.from(this.config.zkParams.backupSecretParams, 'base64');
this.backupServerSecret = new zkgroup_1.GenericServerSecretParams(backupSecret);
this.certificate = (0, crypto_1.generateServerCertificate)(this.trustRoot);
this.provisionQueue = this.createQueue('api/Server/provisionQueue');
}
async listen(port, host) {
if (this.https) {
throw new Error('Already listening');
}
const emptyData = (0, crypto_1.encryptAttachment)(Buffer.alloc(0));
const emptyCDNKey = await this.storeAttachment(emptyData.blob);
this.emptyAttachment = (0, attachment_1.attachmentToPointer)(emptyCDNKey, emptyData);
const httpHandler = (0, http_1.createHandler)(this, {
cdn3Path: this.config.cdn3Path,
updates2Path: this.config.updates2Path,
});
const grpcHandler = (0, grpc_1.createHandler)(this);
const server = http2_1.default
.createSecureServer(this.config.https, (req, res) => {
let handler;
if (req.headers['content-type'] === 'application/grpc') {
handler = grpcHandler;
}
else {
handler = httpHandler;
}
// micro is actually compatible with http2 requests, but the types are
// not.
void (0, micro_1.run)(req, res, handler);
})
.on('connect', (req, res) => {
// WebSocket
if (req.method === 'CONNECT') {
res.writeHead(200, this.wsUpgradeResponseHeaders);
const websocket = new ws_1.default(null, undefined, {});
websocket.setSocket(req.stream, Buffer.alloc(0), {});
const conn = new ws_2.Connection(req, websocket, this);
conn.start(websocket).catch((error) => {
websocket.close();
debug('Websocket handling error', error);
});
return;
}
});
this.https = server;
return new Promise((resolve) => {
server.listen(port, host, () => resolve());
});
}
async close() {
const https = this.https;
if (!https) {
throw new Error('Not listening');
}
debug('closing server');
await new Promise((resolve) => https.close(resolve));
}
//
// Various queues
//
async waitForProvision() {
return this.provisionQueue.shift();
}
async waitForStorageManifest(device, afterVersion) {
let queue = this.manifestQueueByAci.get(device.aci);
if (!queue) {
queue = this.createQueue('api/Server/waitForStorageManifest');
this.manifestQueueByAci.set(device.aci, queue);
}
let version;
do {
version = await queue.shift();
} while (afterVersion !== undefined && version <= afterVersion);
}
async waitForGroupUpdate(group) {
let queue = this.groupQueueById.get(group.id);
if (!queue) {
queue = this.createQueue('api/Server/waitForGroupUpdate');
this.groupQueueById.set(group.id, queue);
}
let version;
do {
version = await queue.shift();
} while (version <= group.revision);
}
//
// Helper methods
//
async createPrimaryDevice({ profileName, contacts = [], contactsWithoutProfileKey = [], password, }) {
const number = await this.generateNumber();
const registrationId = (0, util_1.generateRegistrationId)();
const pniRegistrationId = (0, util_1.generateRegistrationId)();
const devicePassword = password ?? (0, util_1.generateDevicePassword)();
const device = await this.registerDevice({
number,
registrationId,
pniRegistrationId,
password: devicePassword,
});
const { aci } = device;
debug('creating primary device with aci=%s registrationId=%d', aci, registrationId);
if (!this.emptyAttachment) {
throw new Error('Mock#init must be called before starting the server');
}
const contactsAttachment = (0, crypto_1.encryptAttachment)((0, contacts_1.serializeContacts)([
...contacts.map((device) => device.toContact()),
...contactsWithoutProfileKey.map((device) => device.toContact()),
]));
const contactsCDNKey = await this.storeAttachment(contactsAttachment.blob);
debug('contacts cdn key', contactsCDNKey);
if (this.emptyAttachment.attachmentIdentifier?.cdnKey != null) {
debug('groups cdn key', this.emptyAttachment.attachmentIdentifier.cdnKey);
}
const primary = new primary_device_1.PrimaryDevice(device, {
profileName: profileName,
contacts: (0, attachment_1.attachmentToPointer)(contactsCDNKey, contactsAttachment),
trustRoot: this.trustRoot.getPublicKey(),
serverPublicParams: this.zkSecret.getPublicParams(),
generateNumber: this.generateNumber.bind(this),
generatePni: this.generatePni.bind(this),
changeDeviceNumber: this.changeDeviceNumber.bind(this),
send: this.send.bind(this),
getSenderCertificate: this.getSenderCertificate.bind(this, device),
getDeviceByServiceId: this.getDeviceByServiceId.bind(this),
issueExpiringProfileKeyCredential: this.issueExpiringProfileKeyCredential.bind(this),
getGroup: this.getGroup.bind(this),
createGroup: this.createGroup.bind(this),
modifyGroup: this.modifyGroup.bind(this),
waitForGroupUpdate: this.waitForGroupUpdate.bind(this),
getStorageManifest: this.getStorageManifest.bind(this, device),
getStorageItem: this.getStorageItem.bind(this, device),
getAllStorageKeys: this.getAllStorageKeys.bind(this, device),
waitForStorageManifest: this.waitForStorageManifest.bind(this, device),
applyStorageWrite: this.applyStorageWrite.bind(this, device),
});
await primary.init();
this.primaryDevices.set(primary.device.number, primary);
this.primaryDevices.set(primary.device.aci, primary);
debug('created primary device number=%s aci=%s', primary.device.number, primary.device.aci);
return primary;
}
async createSecondaryDevice(primary) {
const registrationId = (0, util_1.generateRegistrationId)();
const pniRegistrationId = (0, util_1.generateRegistrationId)();
const device = await this.registerDevice({
primary: primary.device,
registrationId,
pniRegistrationId,
});
for (const serviceIdKind of [types_1.ServiceIdKind.ACI, types_1.ServiceIdKind.PNI]) {
await this.updateDeviceKeys(device, serviceIdKind, await primary.generateKeys(device, serviceIdKind));
}
primary.addSecondaryDevice(device);
return device;
}
unregister(primary, serviceIdKind = types_1.ServiceIdKind.ACI) {
this.unregisteredServiceIds.add(primary.device.getServiceIdByKind(serviceIdKind));
}
register(primary, serviceIdKind = types_1.ServiceIdKind.ACI) {
this.unregisteredServiceIds.delete(primary.device.getServiceIdByKind(serviceIdKind));
}
respondToChallengesWith(code = 413, data) {
this.responseForChallenges = {
code,
data,
};
}
stopRespondingToChallenges() {
this.responseForChallenges = undefined;
}
getResponseForChallenges() {
return this.responseForChallenges;
}
rateLimit({ source, target }) {
this.rateLimitCountByPair.set(`${source}:${target}`, 0);
}
stopRateLimiting({ source, target, }) {
const key = `${source}:${target}`;
const existing = this.rateLimitCountByPair.get(key);
this.rateLimitCountByPair.delete(key);
return existing;
}
async removeAllCDNAttachments() {
const { cdn3Path } = this.config;
(0, assert_1.default)(cdn3Path, 'cdn3Path must be provided to store attachments');
const dir = path_1.default.join(cdn3Path, 'attachments');
await promises_1.default.rm(dir, {
recursive: true,
});
}
async storeAttachmentOnCdn(cdnNumber, cdnKey, data) {
assert_1.default.strictEqual(cdnNumber, 3, 'Only cdn 3 currently supported');
const { cdn3Path } = this.config;
(0, assert_1.default)(cdn3Path, 'cdn3Path must be provided to store attachments');
const dir = path_1.default.join(cdn3Path, 'attachments');
await promises_1.default.mkdir(dir, {
recursive: true,
});
await promises_1.default.writeFile(path_1.default.join(dir, cdnKey), data);
}
setWebsocketUpgradeResponseHeaders(headers) {
this.wsUpgradeResponseHeaders = headers;
}
async storeBackupOnCdn(backupId, data) {
const { cdn3Path } = this.config;
(0, assert_1.default)(cdn3Path, 'cdn3Path must be provided to store attachments');
const dir = path_1.default.join(cdn3Path, 'backups', Buffer.from(backupId).toString('base64url'));
await promises_1.default.mkdir(dir, {
recursive: true,
});
await promises_1.default.writeFile(path_1.default.join(dir, 'backup'), data);
}
//
// Implement Server's abstract methods
//
async getProvisioningResponse(id, abortSignal) {
const responseQueue = this.createQueue('api/server/responseQueue');
const resultQueue = this.createQueue('api/server/resultQueue');
const { promise, cancel } = this.provisionQueue.pushAndWait({
complete: async (response) => {
const { promise } = responseQueue.pushAndWait(response);
await promise;
return resultQueue.shift();
},
});
const abortListener = () => {
cancel();
};
abortSignal?.addEventListener('abort', abortListener);
await promise;
abortSignal?.removeEventListener('abort', abortListener);
const {
// tsdevice:/?uuid=<uuid>&pub_key=<base64>&capabilities=<...>
provisionURL, primaryDevice, } = await responseQueue.shift();
const { query } = (0, url_1.parse)(provisionURL, true);
assert_1.default.strictEqual(query.uuid, id, 'id mismatch');
if (query.pub_key == null || Array.isArray(query.pub_key)) {
throw new Error('Expected `pub_key` in provision URL');
}
const publicKey = libsignal_client_1.PublicKey.deserialize(Buffer.from(query.pub_key, 'base64'));
const aciIdentityKey = await primaryDevice.getIdentityKey(types_1.ServiceIdKind.ACI);
const pniIdentityKey = await primaryDevice.getIdentityKey(types_1.ServiceIdKind.PNI);
const provisioningCode = await this.getProvisioningCode(id, primaryDevice.device.number);
this.provisionResultQueueByCode.set(provisioningCode, {
seenServiceIdKinds: new Set(),
promiseQueue: resultQueue,
});
const envelopeData = compiled_1.signalservice.ProvisionMessage.encode({
aciIdentityKeyPrivate: aciIdentityKey.serialize(),
aciIdentityKeyPublic: aciIdentityKey.getPublicKey().serialize(),
pniIdentityKeyPrivate: pniIdentityKey.serialize(),
pniIdentityKeyPublic: pniIdentityKey.getPublicKey().serialize(),
number: primaryDevice.device.number,
aciBinary: primaryDevice.device.aciRawUuid,
pniBinary: primaryDevice.device.pniRawUuid,
provisioningCode,
profileKey: primaryDevice.profileKey.serialize(),
userAgent: primaryDevice.userAgent,
readReceipts: true,
provisioningVersion: compiled_1.signalservice.ProvisioningVersion.CURRENT,
masterKey: primaryDevice.masterKey,
ephemeralBackupKey: primaryDevice.ephemeralBackupKey ?? null,
mediaRootBackupKey: primaryDevice.mediaRootBackupKey,
accountEntropyPool: primaryDevice.accountEntropyPool,
});
const { body, ephemeralKey } = (0, crypto_1.encryptProvisionMessage)(Buffer.from(envelopeData), publicKey);
const envelope = compiled_1.signalservice.ProvisionEnvelope.encode({
publicKey: ephemeralKey,
body,
});
return { envelope: Buffer.from(envelope) };
}
async handleMessage(source, serviceIdKind, envelopeType, target, encrypted, timestamp) {
if (envelopeType !== base_1.EnvelopeType.SealedSender) {
(0, assert_1.default)(source, 'No source for non-sealed sender envelope');
}
debug('got message for %s.%d', target.aci, target.deviceId);
if (target.deviceId !== constants_1.PRIMARY_DEVICE_ID) {
if (target.isProvisioned) {
let type;
switch (envelopeType) {
case base_1.EnvelopeType.CipherText:
type = compiled_1.signalservice.Envelope.Type.DOUBLE_RATCHET;
break;
case base_1.EnvelopeType.PreKey:
type = compiled_1.signalservice.Envelope.Type.PREKEY_MESSAGE;
break;
case base_1.EnvelopeType.SealedSender:
type = compiled_1.signalservice.Envelope.Type.UNIDENTIFIED_SENDER;
break;
case base_1.EnvelopeType.Plaintext:
type = compiled_1.signalservice.Envelope.Type.PLAINTEXT_CONTENT;
break;
default:
throw new Error(`Unsupported envelope type: ${envelopeType}`);
}
void this.send(target, Buffer.from(compiled_1.signalservice.Envelope.encode({
type,
sourceServiceIdBinary: source?.aciBinary ?? null,
sourceDeviceId: source?.deviceId ?? null,
destinationServiceIdBinary: target.getServiceIdBinaryByKind(serviceIdKind),
serverTimestamp: timestamp,
clientTimestamp: timestamp,
content: encrypted,
urgent: null,
serverGuid: null,
ephemeral: null,
story: null,
reportSpamToken: null,
serverGuidBinary: null,
updatedPniBinary: null,
// Deprecated string fields
sourceServiceId: null,
destinationServiceId: null,
updatedPni: null,
})));
}
return;
}
const primary = this.primaryDevices.get(target.aci);
if (!primary) {
debug('ignoring message, primary device not found');
return;
}
await primary.handleEnvelope(source, serviceIdKind, envelopeType, encrypted);
}
isUnregistered(serviceId) {
return this.unregisteredServiceIds.has(serviceId);
}
isSendRateLimited({ source, target, }) {
const key = `${source}:${target}`;
const existing = this.rateLimitCountByPair.get(key);
if (existing === undefined) {
return false;
}
const newValue = existing + 1;
debug('isSendRateLimited: source=%j target=%j count=%d', source, target, newValue);
this.rateLimitCountByPair.set(key, newValue);
return true;
}
//
// Override `Server`'s methods to automatically pass keys to primary
// devices.
//
// TODO(indutny): use popSingleUseKey() perhaps?
//
async updateDeviceKeys(device, serviceIdKind, keys) {
await super.updateDeviceKeys(device, serviceIdKind, keys);
// Atomic linking updates only signed pre keys, and we should ignore it.
if (!keys.preKeys?.length && !keys.kyberPreKeys?.length) {
return;
}
const key = `${device.aci}.${device.getRegistrationId(serviceIdKind)}`;
// Device is marked as provisioned only once we have its keys
const resultQueue = this.provisionResultQueueByKey.get(key);
if (!resultQueue) {
return;
}
debug('updateDeviceKeys: got keys for', device.debugId, serviceIdKind);
const { seenServiceIdKinds, promiseQueue } = resultQueue;
(0, assert_1.default)(!seenServiceIdKinds.has(serviceIdKind), `Duplicate service id kind ${serviceIdKind} ` +
`for device: ${device.debugId}`);
seenServiceIdKinds.add(serviceIdKind);
if (!seenServiceIdKinds.has(types_1.ServiceIdKind.ACI) ||
!seenServiceIdKinds.has(types_1.ServiceIdKind.PNI)) {
return;
}
this.provisionResultQueueByKey.delete(key);
const { promise } = promiseQueue.pushAndWait(device);
await promise;
}
async provisionDevice(options) {
const { provisioningCode } = options;
const queue = this.provisionResultQueueByCode.get(provisioningCode);
(0, assert_1.default)(queue !== undefined, `Missing provision result queue for code: ${provisioningCode}`);
this.provisionResultQueueByCode.delete(provisioningCode);
const device = await super.provisionDevice(options);
for (const serviceIdKind of [types_1.ServiceIdKind.ACI, types_1.ServiceIdKind.PNI]) {
const key = `${device.aci}.${device.getRegistrationId(serviceIdKind)}`;
this.provisionResultQueueByKey.set(key, queue);
}
const primary = this.primaryDevices.get(device.aci);
primary?.addSecondaryDevice(device);
return device;
}
// Override `getStorageItems` to provide configurable limit for maximum
// storage read keys.
async getStorageItems(device, keys) {
if (this.config.maxStorageReadKeys !== undefined &&
keys.length > this.config.maxStorageReadKeys) {
debug('getStorageItems: requested more than max keys', device.debugId);
return undefined;
}
return super.getStorageItems(device, keys);
}
// Override updateGroup to notify about group modifications
async modifyGroup(options) {
const { group } = options;
debug('modifyGroup', group.id);
const result = await super.modifyGroup(options);
let queue = this.groupQueueById.get(group.id);
if (!queue) {
queue = this.createQueue('api/Server/modifyGroup');
this.groupQueueById.set(group.id, queue);
}
queue.push(group.revision);
return result;
}
async onStorageManifestUpdate(device, version) {
debug('onStorageManifestUpdate', device.debugId);
let queue = this.manifestQueueByAci.get(device.aci);
if (!queue) {
queue = this.createQueue('api/Server/onStorageManifestUpdate');
this.manifestQueueByAci.set(device.aci, queue);
}
queue.push(version);
}
async backupTransitAttachments(backupId, batch) {
const { cdn3Path } = this.config;
(0, assert_1.default)(cdn3Path, 'cdn3Path must be provided to store attachments');
const dir = path_1.default.join(cdn3Path, 'attachments');
const mediaDir = path_1.default.join(cdn3Path, 'backups', backupId, 'media');
await promises_1.default.mkdir(mediaDir, {
recursive: true,
});
return Promise.all(batch.items.map(async (item) => {
assert_1.default.strictEqual(item.sourceAttachment.cdn, 3, 'Invalid object CDN');
const transitPath = path_1.default.join(dir, item.sourceAttachment.key);
const finalPath = path_1.default.join(mediaDir, item.mediaId);
// TODO(indutny): streams
let data;
try {
data = await promises_1.default.readFile(transitPath);
}
catch (error) {
(0, assert_1.default)(error instanceof Error);
if ('code' in error && error.code === 'ENOENT') {
return {
cdn: 3,
status: 410,
mediaId: item.mediaId,
};
}
throw error;
}
assert_1.default.strictEqual(data.byteLength, item.objectLength, 'Invalid objectLength');
const reencrypted = (0, crypto_1.encryptAttachment)(data, {
aesKey: item.encryptionKey,
macKey: item.hmacKey,
// Deterministic value
iv: Buffer.alloc(16),
});
await promises_1.default.writeFile(finalPath, reencrypted.blob);
void this.onNewBackupMediaObject(backupId, {
cdn: 3,
mediaId: item.mediaId,
objectLength: reencrypted.blob.length,
});
return {
cdn: 3,
status: 200,
mediaId: item.mediaId,
};
}));
}
async provideTransferArchive(device, archive) {
const callbacks = this.transferCallbacksByDevice.get(device) ?? [];
this.transferCallbacksByDevice.delete(device);
this.transferArchiveByDevice.set(device, archive);
for (const callback of callbacks) {
callback(archive);
}
}
async getTransferArchive(device) {
const existing = this.transferArchiveByDevice.get(device);
if (existing !== undefined) {
return existing;
}
return new Promise((resolve) => {
let list = this.transferCallbacksByDevice.get(device);
if (list === undefined) {
list = [];
this.transferCallbacksByDevice.set(device, list);
}
list.push(resolve);
});
}
//
// Private
//
createQueue(name) {
return new util_1.PromiseQueue({
timeout: this.config.timeout,
name,
});
}
async generateNumber() {
let number;
do {
number = (0, util_1.generateRandomE164)();
} while (this.knownNumbers.has(number));
this.knownNumbers.add(number);
return number;
}
}
exports.Server = Server;