@signalapp/mock-server
Version:
Mock Signal Server for writing tests
702 lines (701 loc) • 30.4 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.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;