UNPKG

@signalapp/mock-server

Version:
600 lines (599 loc) 26.7 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 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;