UNPKG

@signalapp/mock-server

Version:
702 lines (701 loc) 30.4 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.Connection = void 0; const assert_1 = __importDefault(require("assert")); const buffer_1 = require("buffer"); const crypto_1 = require("crypto"); const debug_1 = __importDefault(require("debug")); const zkgroup_1 = require("@signalapp/libsignal-client/zkgroup"); const SealedSenderMultiRecipientMessage_1 = __importDefault(require("@signalapp/libsignal-client/dist/SealedSenderMultiRecipientMessage")); const uuid_1 = require("uuid"); const compiled_1 = require("../../../protos/compiled"); const schemas_1 = require("../../data/schemas"); const types_1 = require("../../types"); const crypto_2 = require("../../crypto"); const util_1 = require("../../util"); const service_1 = require("./service"); const router_1 = require("./router"); const debug = (0, debug_1.default)('mock:ws:connection'); class Connection extends service_1.Service { request; server; device; router = new router_1.Router(); constructor(request, ws, server) { super(ws); this.request = request; this.server = server; const getProfile = async (params, _, headers, { credentialType } = {}) => { const serviceId = params.serviceId; const target = await this.server.getDeviceByServiceId(serviceId); if (!target) { return [404, { error: 'Device not found' }]; } if (this.server.isUnregistered(serviceId)) { return [404, { error: 'Unregistered' }]; } const accessError = this.checkAccessKey(target, headers); if (accessError !== undefined) { return [401, { error: accessError }]; } let credential; if (params.request) { const request = new zkgroup_1.ProfileKeyCredentialRequest(buffer_1.Buffer.from(params.request, 'hex')); if (credentialType === 'expiringProfileKey') { credential = await this.server.issueExpiringProfileKeyCredential(target, request); } else { return [400, { error: 'Unsupported credential type' }]; } } const serviceIdKind = target.getServiceIdKind(serviceId); const identityKey = await target.getIdentityKey(serviceIdKind); return [ 200, { name: target.profileName?.toString('base64'), identityKey: identityKey.serialize().toString('base64'), unrestrictedUnidentifiedAccess: false, unidentifiedAccess: target.accessKey ? (0, crypto_2.generateAccessKeyVerifier)(target.accessKey).toString('base64') : undefined, capabilities: target.capabilities, credential: credential?.toString('base64'), }, ]; }; this.router.get('/v1/profile/:serviceId', getProfile); this.router.get('/v1/profile/:serviceId/:version', getProfile); this.router.get('/v1/profile/:serviceId/:version/:request', getProfile); const requireAuth = (handler) => { return async (params, body, headers, query) => { if (!this.device) { return [401, { error: 'Not authorized' }]; } return handler(params, body, headers, query); }; }; this.router.get('/v1/config', requireAuth(async () => { return [ 200, { config: [...this.server.getRemoteConfig().entries()].map(([key, value]) => { return { name: key, ...value }; }), serverEpochTime: Date.now() / 1000, }, ]; })); this.router.put('/v1/messages/multi_recipient', async (_params, body) => { if (!body) { return [400, { error: 'Missing body' }]; } const message = new SealedSenderMultiRecipientMessage_1.default(buffer_1.Buffer.from(body)); const listByServiceId = new Map(); const recipients = message.recipientsByServiceIdString(); for (const [serviceId, recipient] of Object.entries(recipients)) { let list = listByServiceId.get(serviceId); if (!list) { list = []; listByServiceId.set(serviceId, list); } for (const [i, deviceId] of recipient.deviceIds.entries()) { const registrationId = recipient.registrationIds.at(i); list.push({ type: compiled_1.signalservice.Envelope.Type.UNIDENTIFIED_SENDER, destinationDeviceId: deviceId, destinationRegistrationId: registrationId, content: message.messageForRecipient(recipient).toString('base64'), }); } } // TODO(indutny): verify access key xor const results = await Promise.all(Array.from(listByServiceId.entries()).map(async ([serviceId, messages]) => { return { uuid: serviceId, prepared: await this.server.prepareMultiDeviceMessage(undefined, serviceId, messages), }; })); const incomplete = results.filter(({ prepared }) => prepared.status === 'incomplete'); if (incomplete.length !== 0) { return [ 409, incomplete.map(({ uuid, prepared }) => { assert_1.default.ok(prepared.status === 'incomplete'); return { uuid, devices: { missingDevices: prepared.missingDevices, extraDevices: prepared.extraDevices, }, }; }), ]; } const stale = results.filter(({ prepared }) => prepared.status === 'stale'); if (stale.length !== 0) { return [ 410, stale.map(({ uuid, prepared }) => { assert_1.default.ok(prepared.status === 'stale'); return { uuid, devices: { staleDevices: prepared.staleDevices } }; }), ]; } const uuids404 = results .filter(({ prepared }) => prepared.status === 'unknown') .map(({ uuid }) => uuid); const ok = results.filter(({ prepared }) => prepared.status === 'ok'); await Promise.all(ok.map(({ prepared }) => { assert_1.default.ok(prepared.status === 'ok'); return this.server.handlePreparedMultiDeviceMessage(undefined, prepared.targetServiceId, prepared.result); })); return [200, { uuids404 }]; }); this.router.put('/v1/messages/:serviceId', async (params, body, headers, query = {}) => { if (!body) { return [400, { error: 'Missing body' }]; } const { messages } = schemas_1.MessageListSchema.parse(JSON.parse(buffer_1.Buffer.from(body).toString())); const targetServiceId = params.serviceId; const target = await this.server.getDeviceByServiceId(targetServiceId); if (!target) { return [404, { error: 'Device not found' }]; } if (query.story !== 'true') { const accessError = this.checkAccessKey(target, headers); if (accessError !== undefined) { return [401, { error: accessError }]; } } if (this.server.isUnregistered(targetServiceId)) { return [404, { error: 'Unregistered' }]; } if (this.device && this.server.isSendRateLimited({ source: this.device.aci, target: targetServiceId, })) { return [428, { token: 'token', options: ['recaptcha'] }]; } const prepared = await this.server.prepareMultiDeviceMessage(this.device, params.serviceId, messages); switch (prepared.status) { case 'ok': await this.server.handlePreparedMultiDeviceMessage(this.device, prepared.targetServiceId, prepared.result); return [200, { ok: true }]; case 'unknown': return [404, { error: 'Not found' }]; case 'incomplete': return [ 409, { missingDevices: prepared.missingDevices, extraDevices: prepared.extraDevices, }, ]; case 'stale': return [410, { staleDevices: prepared.staleDevices }]; } }); this.router.put('/v1/devices/capabilities', requireAuth(async () => { return [200, { ok: true }]; })); this.router.put('/v1/devices/unauthenticated_delivery', requireAuth(async () => { return [200, { ok: true }]; })); this.router.get('/v1/certificate/delivery', requireAuth(async () => { const certificate = await this.server.getSenderCertificate(this.getDevice()); return [ 200, { certificate: certificate.serialize().toString('base64') }, ]; })); this.router.put('/v2/keys', requireAuth(async (_params, rawBody, _headers, query) => { if (!rawBody) { return [422, { error: 'Missing body' }]; } const serviceIdKind = (0, util_1.serviceIdKindFromQuery)(query); const body = schemas_1.DeviceKeysSchema.parse(JSON.parse(rawBody.toString())); try { await server.updateDeviceKeys(this.getDevice(), serviceIdKind, { preKeys: body.preKeys?.map(crypto_2.decodePreKey), kyberPreKeys: body.pqPreKeys?.map(crypto_2.decodeKyberPreKey), lastResortKey: body.pqLastResortPreKey ? (0, crypto_2.decodeKyberPreKey)(body.pqLastResortPreKey) : undefined, signedPreKey: body.signedPreKey ? (0, crypto_2.decodeSignedPreKey)(body.signedPreKey) : undefined, }); } catch (error) { (0, assert_1.default)(error instanceof Error); debug('updateDeviceKeys error', error.stack); return [400, { error: error.message }]; } return [200, { ok: true }]; })); this.router.get('/v2/keys', requireAuth(async (_params, _rawBody, _headers, query) => { const device = this.getDevice(); const serviceIdKind = (0, util_1.serviceIdKindFromQuery)(query); return [ 200, { count: await device.getPreKeyCount(serviceIdKind), pqCount: await device.getKyberPreKeyCount(serviceIdKind), }, ]; })); this.router.get('/v2/keys/:serviceId/:deviceId', async (params) => { const serviceId = params.serviceId; const deviceId = parseInt(params.deviceId || '', 10); if (!serviceId || deviceId.toString() !== params.deviceId) { return [400, { error: 'Invalid request parameters' }]; } const device = await server.getDeviceByServiceId(serviceId, deviceId); if (!device) { return [404, { error: 'Device not found' }]; } const serviceIdKind = device.getServiceIdKind(serviceId); return [200, await (0, util_1.getDevicesKeysResult)(serviceIdKind, [device])]; }); this.router.get('/v2/keys/:serviceId(/\\*)', async (params) => { const serviceId = params.serviceId; if (!serviceId) { return [400, { error: 'Invalid request parameters' }]; } const devices = await server.getAllDevicesByServiceId(serviceId); if (devices.length === 0) { return [404, { error: 'Account not found' }]; } const serviceIdKind = devices[0].getServiceIdKind(serviceId); return [200, await (0, util_1.getDevicesKeysResult)(serviceIdKind, devices)]; }); this.router.put('/v1/devices/link', async (_params, body, headers) => { const { error, username, password } = (0, util_1.parseAuthHeader)(headers.authorization); if (error) { return [400, { error }]; } if (!username || !password) { return [400, { error: 'Invalid authorization header' }]; } if (!body) { return [400, { error: 'Missing body' }]; } const { verificationCode, accountAttributes, aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, } = schemas_1.AtomicLinkingDataSchema.parse(JSON.parse(buffer_1.Buffer.from(body).toString())); const { registrationId, pniRegistrationId } = accountAttributes; const device = await server.provisionDevice({ number: username, password, provisioningCode: verificationCode, registrationId, pniRegistrationId, }); const primary = await server.getDeviceByServiceId(device.aci); if (!primary) { throw new Error('Primary device not found'); } await server.updateDeviceKeys(device, types_1.ServiceIdKind.ACI, { lastResortKey: (0, crypto_2.decodeKyberPreKey)(aciPqLastResortPreKey), signedPreKey: (0, crypto_2.decodeSignedPreKey)(aciSignedPreKey), }); await server.updateDeviceKeys(device, types_1.ServiceIdKind.PNI, { lastResortKey: (0, crypto_2.decodeKyberPreKey)(pniPqLastResortPreKey), signedPreKey: (0, crypto_2.decodeSignedPreKey)(pniSignedPreKey), }); return [ 200, { deviceId: device.deviceId, uuid: device.aci, pni: (0, types_1.untagPni)(device.pni), }, ]; }); this.router.get('/v1/devices/transfer_archive', requireAuth(async () => { return [200, await server.getTransferArchive(this.getDevice())]; })); // // Groups // this.router.get('/v1/certificate/auth/group', async (_params, _body, _headers, query = {}) => { const device = this.device; if (!device) { debug('/v1/certificate/auth/group: No support for unauthorized delivery'); return [401, { error: 'Not authorized' }]; } const { redemptionStartSeconds: from, redemptionEndSeconds: to } = query; return [ 200, { credentials: await this.server.getGroupCredentials(device, { from: parseInt(from, 10), to: parseInt(to, 10), }), callLinkAuthCredentials: await this.server.getCallLinkAuthCredentials(device, { from: parseInt(from, 10), to: parseInt(to, 10), }), pni: (0, types_1.untagPni)(device.pni), }, ]; }); // // Storage Service // this.router.get('/v1/storage/auth', requireAuth(async () => { return [200, await server.getStorageAuth(this.getDevice())]; })); // // Backups // this.router.put('/v1/archives/backupid', requireAuth(async (_params, body) => { if (!body) { return [400, { error: 'Missing body' }]; } const backupId = schemas_1.SetBackupIdSchema.parse(JSON.parse(body.toString())); await server.setBackupId(this.getDevice(), backupId); return [200, { ok: true }]; })); this.router.get('/v1/archives/auth', requireAuth(async (_params, _body, _headers, query = {}) => { const { redemptionStartSeconds: from, redemptionEndSeconds: to } = query; const credentials = await this.server.getBackupCredentials(this.getDevice(), { from: parseInt(from, 10), to: parseInt(to, 10), }); if (credentials === undefined) { return [404, { error: 'backup id not set' }]; } return [ 200, { credentials, }, ]; })); this.router.put('/v1/archives/keys', async (_params, body, headers) => { if (this.device) { return [400, { error: 'Extraneous authentication' }]; } if (!body) { return [400, { error: 'Missing body' }]; } const backupKey = schemas_1.SetBackupKeySchema.parse(JSON.parse(body.toString())); await server.setBackupKey(schemas_1.BackupHeadersSchema.parse(headers), backupKey); return [200, { ok: true }]; }); this.router.post('/v1/archives', async (_params, _body, headers) => { if (this.device) { return [400, { error: 'Extraneous authentication' }]; } await server.refreshBackup(schemas_1.BackupHeadersSchema.parse(headers)); return [200, { ok: true }]; }); this.router.get('/v1/archives', async (_params, _body, headers) => { if (this.device) { return [400, { error: 'Extraneous authentication' }]; } return [ 200, await server.getBackupInfo(schemas_1.BackupHeadersSchema.parse(headers)), ]; }); this.router.get('/v1/archives/auth/read', async (_params, _body, headers, query = {}) => { if (this.device) { return [400, { error: 'Extraneous authentication' }]; } if (query.cdn !== '3') { return [400, { error: 'Invalid cdn query param' }]; } return [ 200, { headers: await server.getBackupCDNAuth(schemas_1.BackupHeadersSchema.parse(headers)), }, ]; }); this.router.get('/v1/archives/upload/form', async (_params, _body, headers) => { if (this.device) { return [400, { error: 'Extraneous authentication' }]; } return [ 200, await this.server.getBackupUploadForm(schemas_1.BackupHeadersSchema.parse(headers)), ]; }); this.router.get('/v1/archives/media', async (_params, _body, headers, query = {}) => { if (this.device) { return [400, { error: 'Extraneous authentication' }]; } if (typeof query.limit !== 'string') { return [400, { error: 'Missing limit param' }]; } const limit = parseInt(query.limit, 10); if (limit <= 0) { return [400, { error: 'Invalid limit' }]; } const cursor = query.cursor; return [ 200, await this.server.listBackupMedia(schemas_1.BackupHeadersSchema.parse(headers), { cursor: cursor ? String(cursor) : undefined, limit }), ]; }); this.router.get('/v1/archives/media/upload/form', async (_params, _body, headers) => { if (this.device) { return [400, { error: 'Extraneous authentication' }]; } return [ 200, await this.server.getBackupMediaUploadForm(schemas_1.BackupHeadersSchema.parse(headers)), ]; }); this.router.put('/v1/archives/media/batch', async (_params, body, headers) => { if (this.device) { return [400, { error: 'Extraneous authentication' }]; } if (!body) { return [400, { error: 'Missing body' }]; } const batch = schemas_1.BackupMediaBatchSchema.parse(JSON.parse(body.toString())); return [ 200, await this.server.backupMediaBatch(schemas_1.BackupHeadersSchema.parse(headers), batch), ]; }); // // Keepalive // this.router.get('/v1/keepalive', async () => { return [200, { ok: true }]; }); // // Attachment upload forms // this.router.get('/v4/attachments/form/upload', async () => { const key = (0, uuid_1.v4)(); return [ 200, await this.server.getAttachmentUploadForm('attachments', key), ]; }); // // Accounts // this.router.get('/v1/accounts/whoami', requireAuth(async () => { const device = this.getDevice(); return [ 200, { uuid: device.aci, pni: device.pni, number: device.number }, ]; })); this.router.put('/v1/accounts/username_hash/reserve', requireAuth(async (_params, rawBody) => { if (!rawBody) { return [422, { error: 'Missing body' }]; } const body = schemas_1.UsernameReservationSchema.parse(JSON.parse(rawBody.toString())); const usernameHash = await server.reserveUsername(this.getDevice().aci, body); if (!usernameHash) { return [401, { error: 'All username hashes taken' }]; } return [200, { usernameHash: (0, util_1.toURLSafeBase64)(usernameHash) }]; })); this.router.put('/v1/accounts/username_hash/confirm', requireAuth(async (_params, rawBody) => { if (!rawBody) { return [422, { error: 'Missing body' }]; } const body = schemas_1.UsernameConfirmationSchema.parse(JSON.parse(rawBody.toString())); const result = await server.confirmUsername(this.getDevice().aci, body); if (!result) { return [ 409, { error: "Given username hash doesn't match the reserved one or no reservation found.", }, ]; } return [200, result]; })); this.router.del('/v1/accounts/username_hash', requireAuth(async () => { await this.server.deleteUsername(this.getDevice().aci); return [204, { ok: true }]; })); this.router.get('/v1/accounts/username_hash/:hash', async (params) => { const { hash = '' } = params; const uuid = await server.lookupByUsernameHash((0, util_1.fromURLSafeBase64)(hash)); if (!uuid) { return [404, { error: 'Not found' }]; } return [200, { uuid }]; }); this.router.get('/v1/accounts/username_link/:uuid', async (params) => { const { uuid: linkUuid = '' } = params; const encryptedValue = await server.lookupByUsernameLink(linkUuid); if (!encryptedValue) { return [404, { error: 'Not found' }]; } return [ 200, { usernameLinkEncryptedValue: (0, util_1.toURLSafeBase64)(encryptedValue) }, ]; }); this.router.put('/v1/accounts/username_link', requireAuth(async (_params, rawBody) => { if (!rawBody) { return [422, { error: 'Missing body' }]; } const { usernameLinkEncryptedValue } = schemas_1.PutUsernameLinkSchema.parse(JSON.parse(rawBody.toString())); const usernameLinkHandle = await server.replaceUsernameLink(this.getDevice().aci, usernameLinkEncryptedValue); return [200, { usernameLinkHandle }]; })); // // Call links // this.router.post('/v1/call-link/create-auth', requireAuth(async (_params, rawBody) => { if (!rawBody) { return [422, { error: 'Missing body' }]; } const body = schemas_1.CreateCallLinkAuthSchema.parse(JSON.parse(rawBody.toString())); const request = new zkgroup_1.CreateCallLinkCredentialRequest(body.createCallLinkCredentialRequest); const response = await server.createCallLinkAuth(this.getDevice(), request); return [ 200, { redemptionTime: -Date.now(), credential: (0, util_1.toBase64)(response.serialize()), }, ]; })); // // Captcha // this.router.put('/v1/challenge', requireAuth(async () => { const response = server.getResponseForChallenges(); if (response) { return [response.code, response.data ?? {}]; } return [200, { ok: true }]; })); } async start() { debug('Got a websocket connection', this.request.url); const url = this.request.url; if (!url) { throw new Error('Request must have url'); } // Use a fixed string instead of constructing the URL from the HOST header // since we don't actually care about anything but the path. const path = new URL(url, 'http://localhost').pathname; if (path.startsWith('/v1/websocket/provisioning')) { const id = await this.server.generateProvisionId(); try { await this.handleProvision(id); } catch (error) { await this.server.releaseProvisionId(id); throw error; } return; } if (path === '/v1/websocket/') { return await this.handleNormal(this.request); } else { debug('websocket connection has unexpected URL %s', url); } } async sendMessage(message) { let response; if (message === 'empty') { response = await this.send('PUT', '/api/v1/queue/empty', {}); } else { response = await this.send('PUT', '/api/v1/message', { body: message, }); } assert_1.default.strictEqual(response.status, 200, `WebSocket send error ${response.status} ${response.message}`); } // // Service implementation // async handleRequest(request) { return this.router.run(request); } // // Private // async handleProvision(id) { { const { status } = await this.send('PUT', '/v1/address', { body: compiled_1.signalservice.ProvisioningUuid.encode({ uuid: id, }).finish(), }); assert_1.default.strictEqual(status, 200); } { const { envelope } = await this.server.getProvisioningResponse(id); const { status } = await this.send('PUT', '/v1/message', { body: envelope, }); assert_1.default.strictEqual(status, 200); } } async handleNormal(incomingMessage) { const authHeaders = incomingMessage.headers.authorization; if (!authHeaders) { debug('Websocket connection does not include Authorization header'); return; } const { error, username, password } = (0, util_1.parseAuthHeader)(authHeaders, { allowEmptyPassword: true, }); if (error || !username) { debug('Invalid Authorization header for websocket connection @ %s: %s', error, authHeaders); return; } const device = await this.server.auth(username, password); if (!device) { debug('Invalid WebSocket credentials @ %s: %j', incomingMessage.url, { username, password, }); this.ws.close(3000); return; } this.device = device; this.router.setIsAuthenticated(true); this.ws.once('close', () => { this.server.removeWebSocket(device, this); }); await this.server.addWebSocket(device, this); } getDevice() { (0, assert_1.default)(this.device); return this.device; } checkAccessKey(target, headers) { if (this.device) { // Authenticated } else if (headers['group-send-token']) { // Unchecked return undefined; } else if (!target.accessKey || !headers['unidentified-access-key']) { return 'Not authenticated'; } else { const accessKey = buffer_1.Buffer.from(headers['unidentified-access-key'], 'base64'); if (!(0, crypto_1.timingSafeEqual)(accessKey, target.accessKey)) { return 'Invalid access key'; } } return undefined; } } exports.Connection = Connection;