@signalapp/mock-server
Version:
Mock Signal Server for writing tests
546 lines (545 loc) • 24.3 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 https_1 = __importDefault(require("https"));
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 ws_2 = require("../server/ws");
const primary_device_1 = require("./primary-device");
const debug = (0, debug_1.default)('mock:server:mock');
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'));
const TRUST_ROOT = JSON.parse(fs_1.default.readFileSync(path_1.default.join(CERTS_DIR, 'trust-root.json')).toString());
const ZK_PARAMS = JSON.parse(fs_1.default.readFileSync(path_1.default.join(CERTS_DIR, 'zk-params.json')).toString());
const DEFAULT_API_TIMEOUT = 60000;
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,
...(config.https || {}),
},
};
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();
}
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 server = https_1.default.createServer(this.config.https || {}, (req, res) => {
(0, micro_1.run)(req, res, httpHandler);
});
const wss = new ws_1.default.Server({
server,
verifyClient: async (info, callback) => {
const { url } = info.req;
(0, assert_1.default)(url, 'verifyClient: expected a URL on incoming request');
const query = (0, url_1.parse)(url, true).query || {};
if (!query.login && !query.password) {
debug('verifyClient: Allowing connection with no credentials');
callback(true);
return;
}
// Note: when a device has been unlinked, it will use '' as its password
if (!query.login ||
Array.isArray(query.login) ||
typeof query.password !== 'string' ||
Array.isArray(query.password)) {
debug('verifyClient: Malformed credentials @ %s: %j', url, query);
callback(false, 403);
return;
}
const device = await this.auth(query.login, query.password);
if (!device) {
debug('verifyClient: Invalid credentials @ %s: %j', url, query);
callback(false, 403);
return;
}
callback(true);
},
});
wss.on('connection', (ws, request) => {
const conn = new ws_2.Connection(request, ws, this);
conn.start().catch((error) => {
ws.close();
debug('Websocket handling error', error.stack);
});
});
wss.on('headers', (headers) => {
Object.entries(this.wsUpgradeResponseHeaders).forEach(([header, value]) => {
headers.push(`${header}: ${value}`);
});
});
this.https = server;
return await 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 await this.provisionQueue.shift();
}
async waitForStorageManifest(device, afterVersion) {
let queue = this.manifestQueueByAci.get(device.aci);
if (!queue) {
queue = this.createQueue();
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();
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 = await (0, util_1.generateRegistrationId)();
const pniRegistrationId = await (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);
debug('groups cdn key', this.emptyAttachment.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 = await (0, util_1.generateRegistrationId)();
const pniRegistrationId = await (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) {
const responseQueue = this.createQueue();
const resultQueue = this.createQueue();
await this.provisionQueue.pushAndWait({
complete: async (response) => {
await responseQueue.pushAndWait(response);
return await resultQueue.shift();
},
});
const {
// tsdevice:/?uuid=<uuid>&pub_key=<base64>&capabilities=<...>
provisionURL, primaryDevice, } = await responseQueue.shift();
const query = (0, url_1.parse)(provisionURL, true).query || {};
assert_1.default.strictEqual(query.uuid, id, 'id mismatch');
if (!query.pub_key || 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,
aci: primaryDevice.device.aci,
pni: (0, types_1.untagPni)(primaryDevice.device.pni),
provisioningCode,
profileKey: primaryDevice.profileKey.serialize(),
userAgent: primaryDevice.userAgent,
readReceipts: true,
// TODO(indutny): is it correct?
ProvisioningVersion: compiled_1.signalservice.ProvisioningVersion.CURRENT,
masterKey: primaryDevice.masterKey,
ephemeralBackupKey: primaryDevice.ephemeralBackupKey,
mediaRootBackupKey: primaryDevice.mediaRootBackupKey,
accountEntropyPool: primaryDevice.accountEntropyPool,
}).finish();
const { body, ephemeralKey } = (0, crypto_1.encryptProvisionMessage)(Buffer.from(envelopeData), publicKey);
const envelope = compiled_1.signalservice.ProvisionEnvelope.encode({
publicKey: ephemeralKey,
body,
}).finish();
return { envelope: Buffer.from(envelope) };
}
async handleMessage(source, serviceIdKind, envelopeType, target, encrypted) {
(0, assert_1.default)(source || envelopeType === base_1.EnvelopeType.SealedSender, '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) {
debug('ignoring message, not primary');
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);
await promiseQueue.pushAndWait(device);
}
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();
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();
this.manifestQueueByAci.set(device.aci, queue);
}
queue.push(version.toNumber());
}
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);
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() {
return new util_1.PromiseQueue({
timeout: this.config.timeout,
});
}
async generateNumber() {
let number;
do {
number = (0, util_1.generateRandomE164)();
} while (this.knownNumbers.has(number));
this.knownNumbers.add(number);
return number;
}
}
exports.Server = Server;