UNPKG

@signalapp/mock-server

Version:
546 lines (545 loc) 24.3 kB
"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;