@signalapp/mock-server
Version:
Mock Signal Server for writing tests
1,029 lines (1,028 loc) • 39 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 = exports.EnvelopeType = void 0;
const libsignal_client_1 = require("@signalapp/libsignal-client");
const zkgroup_1 = require("@signalapp/libsignal-client/zkgroup");
const assert_1 = __importDefault(require("assert"));
const crypto_1 = __importDefault(require("crypto"));
const debug_1 = __importDefault(require("debug"));
const uuid_1 = require("uuid");
const compiled_1 = require("../../protos/compiled");
const constants_1 = require("../constants");
const crypto_2 = require("../crypto");
const device_1 = require("../data/device");
const types_1 = require("../types");
const util_1 = require("../util");
const group_1 = require("./group");
var EnvelopeType;
(function (EnvelopeType) {
EnvelopeType["CipherText"] = "CipherText";
EnvelopeType["Plaintext"] = "Plaintext";
EnvelopeType["PreKey"] = "PreKey";
EnvelopeType["SealedSender"] = "SealedSender";
EnvelopeType["SenderKey"] = "SenderKey";
})(EnvelopeType || (exports.EnvelopeType = EnvelopeType = {}));
const debug = (0, debug_1.default)('mock:server:base');
// NOTE: This class is currently extended only by src/api/server.ts
class Server {
devices = new Map();
devicesByServiceId = new Map();
devicesByAuth = new Map();
usedServiceIds = new Set();
usedProvisionIds = new Set();
storageAuthByUsername = new Map();
storageAuthByDevice = new Map();
storageManifestByAci = new Map();
storageItemsByAci = new Map();
provisioningCodes = new Map();
attachments = new Map();
stickerPacks = new Map();
webSockets = new Map();
messageQueue = new WeakMap();
groups = new Map();
aciByUsername = new Map();
aciByReservedUsername = new Map();
usernameByAci = new Map();
reservedUsernameByAci = new Map();
usernameLinkIdByServiceId = new Map();
usernameLinkById = new Map();
callLinksByRoomId = new Map();
backupAuthReqByAci = new Map();
backupKeyById = new Map();
backupCDNPasswordById = new Map();
backupMediaById = new Map();
backupMediaCursorById = new Map();
remoteConfig = new Map();
privCertificate;
privZKSecret;
privGenericServerSecret;
privBackupServerSecret;
https;
address() {
if (!this.https) {
throw new Error('Not listening');
}
const result = this.https.address();
if (!result || typeof result !== 'object') {
throw new Error('Invalid .address() result');
}
return result;
}
//
// Service Ids
//
async generateAci() {
let result;
do {
result = (0, uuid_1.v4)();
} while (this.usedServiceIds.has(result));
this.usedServiceIds.add(result);
return result;
}
async generatePni() {
let result;
do {
result = `PNI:${(0, uuid_1.v4)()}`;
} while (this.usedServiceIds.has(result));
this.usedServiceIds.add(result);
return result;
}
//
// Provisioning
//
async generateProvisionId() {
let result;
do {
result = (0, uuid_1.v4)();
} while (this.usedProvisionIds.has(result));
this.usedProvisionIds.add(result);
return result;
}
async releaseProvisionId(id) {
this.usedProvisionIds.delete(id);
}
async registerDevice({ primary, provisionId, number: maybeNumber, registrationId, pniRegistrationId, password, }) {
if (provisionId && !this.usedProvisionIds.has(provisionId)) {
throw new Error('Use generateProvisionId() to create new provision id');
}
let aci;
let pni;
let number;
if (primary) {
({ aci, pni, number } = primary);
}
else {
[aci, pni] = await Promise.all([this.generateAci(), this.generatePni()]);
number = maybeNumber;
}
let list = this.devices.get(number);
if (!list) {
list = [];
this.devices.set(number, list);
}
const deviceId = (list.length + 1);
const isPrimary = deviceId === constants_1.PRIMARY_DEVICE_ID;
const device = new device_1.Device({
aci,
pni,
number,
deviceId,
registrationId,
pniRegistrationId,
});
if (isPrimary) {
(0, assert_1.default)(!this.devicesByServiceId.has(aci), 'Duplicate primary device');
this.devicesByServiceId.set(aci, device);
this.devicesByServiceId.set(pni, device);
}
if (password) {
this.setDeviceAuthPassword(number, device, password);
}
list.push(device);
debug('registered device number=%j aci=%s pni=%s', number, aci, pni);
return device;
}
// Called from primary device
async getProvisioningCode(id, number) {
let entry = this.provisioningCodes.get(number);
if (!entry) {
entry = new Map();
this.provisioningCodes.set(number, entry);
}
let code;
do {
code = crypto_1.default.randomBytes(8).toString('hex');
} while (entry.has(code));
entry.set(code, id);
return code;
}
// Called from secondary device
async provisionDevice({ number, password, provisioningCode, registrationId, pniRegistrationId, }) {
const entry = this.provisioningCodes.get(number);
if (!entry) {
throw new Error('Invalid number for provisioning');
}
const provisionIdString = entry.get(provisioningCode);
if (!provisionIdString) {
throw new Error('Invalid provisioning code');
}
entry.delete(provisioningCode);
const [primary] = this.devices.get(number) || [];
(0, assert_1.default)(primary !== undefined, 'Missing primary device when provisioning');
const device = await this.registerDevice({
primary,
registrationId,
pniRegistrationId,
password,
});
debug('provisioned device id=%j number=%j aci=%j', device.deviceId, number, device.aci);
return device;
}
setDeviceAuthPassword(number, device, password) {
const username = `${number}.${device.deviceId}`;
// This is awkward, but WebSockets use it.
const secondUsername = `${device.aci}.${device.deviceId}`;
// Add auth only after successfully registering the device
(0, assert_1.default)(!this.devicesByAuth.has(username) &&
!this.devicesByAuth.has(secondUsername), 'Duplicate username in `provisionDevice`');
const authEntry = {
password,
device,
};
this.devicesByAuth.set(username, authEntry);
this.devicesByAuth.set(secondUsername, authEntry);
}
async updateDeviceKeys(device, serviceIdKind, keys) {
debug('setting device=%s keys', device.debugId);
const primary = this.devicesByServiceId.get(device.aci);
(0, assert_1.default)(primary, 'must have primary device');
await device.setKeys(serviceIdKind, {
...keys,
identityKey: await primary.getIdentityKey(serviceIdKind),
});
}
async changeDeviceNumber(device, options) {
const oldNumber = device.number;
const oldPni = device.pni;
await device.changeNumber(options);
const oldDevices = this.devices.get(oldNumber) ?? [];
const oldDeviceIndex = oldDevices.indexOf(device);
if (oldDeviceIndex !== -1) {
oldDevices.splice(oldDeviceIndex, 1);
if (oldDevices.length === 0) {
this.devices.delete(oldNumber);
}
}
let newDevices = this.devices.get(options.number);
if (!newDevices) {
newDevices = [];
this.devices.set(options.number, newDevices);
}
newDevices.push(device);
const oldPrimary = this.devicesByServiceId.get(oldPni);
if (oldPrimary === device) {
this.devicesByServiceId.delete(oldPni);
this.devicesByServiceId.set(options.pni, device);
}
}
//
// Auth
//
async auth(username, password) {
const entry = this.devicesByAuth.get(username);
if (!entry) {
debug('auth failed, username=%j is unknown', username);
return;
}
if (entry.password !== password) {
debug('auth failed, invalid login/password %j:%j', username, password);
return;
}
return entry.device;
}
//
// Remote config
//
setRemoteConfig(key, value) {
this.remoteConfig.set(key, value);
}
getRemoteConfig() {
return this.remoteConfig;
}
//
// CDN
//
async storeAttachment(attachment) {
const id = crypto_1.default
.createHash('sha256')
.update(attachment)
.digest('hex');
this.attachments.set(id, attachment);
return id;
}
async fetchAttachment(id) {
return this.attachments.get(id);
}
async fetchStickerPack(packId) {
return this.stickerPacks.get(packId)?.manifest;
}
async fetchSticker(packId, stickerId) {
return this.stickerPacks.get(packId)?.stickers[stickerId];
}
async storeStickerPack(pack) {
this.stickerPacks.set(pack.id.toString('hex'), pack);
}
async getAttachmentUploadForm(folder, key) {
const { port, family } = this.address();
// These are the only two in the TLS certificate
const host = family === 'IPv6' ? '[::1]' : '127.0.0.1';
const signedUploadLocation = `https://${host}:${port}/cdn3/${folder}/${key}`;
return {
cdn: 3,
key,
headers: {
// TODO(indutny): verify on request
expectedHeaders: crypto_1.default.randomBytes(16).toString('hex'),
},
signedUploadLocation,
};
}
//
// Messages
//
async prepareMultiDeviceMessage(source, targetServiceId, messages) {
if (this.isUnregistered(targetServiceId)) {
return { status: 'unknown' };
}
const devices = await this.getAllDevicesByServiceId(targetServiceId);
if (devices.length === 0) {
return { status: 'unknown' };
}
const deviceById = new Map();
for (const device of devices) {
deviceById.set(device.deviceId, device);
}
const result = new Array();
const extraDevices = new Set();
const staleDevices = new Set();
for (const message of messages) {
const { destinationDeviceId, destinationRegistrationId } = message;
const target = deviceById.get(destinationDeviceId);
if (!target) {
extraDevices.add(destinationDeviceId);
continue;
}
const serviceIdKind = target.getServiceIdKind(targetServiceId);
deviceById.delete(destinationDeviceId);
if (target.getRegistrationId(serviceIdKind) !== destinationRegistrationId) {
staleDevices.add(destinationDeviceId);
continue;
}
result.push([target, message]);
}
if (source && source.aci === targetServiceId) {
deviceById.delete(source.deviceId);
}
if (staleDevices.size !== 0) {
return { status: 'stale', staleDevices: Array.from(staleDevices) };
}
if (extraDevices.size !== 0 || deviceById.size !== 0) {
return {
status: 'incomplete',
missingDevices: Array.from(deviceById.keys()),
extraDevices: Array.from(extraDevices),
};
}
return { status: 'ok', targetServiceId, result };
}
async handlePreparedMultiDeviceMessage(source, targetServiceId, prepared) {
for (const [target, message] of prepared) {
let envelopeType;
if (message.type === compiled_1.signalservice.Envelope.Type.CIPHERTEXT) {
envelopeType = EnvelopeType.CipherText;
}
else if (message.type === compiled_1.signalservice.Envelope.Type.PREKEY_BUNDLE) {
envelopeType = EnvelopeType.PreKey;
}
else if (message.type === compiled_1.signalservice.Envelope.Type.UNIDENTIFIED_SENDER) {
envelopeType = EnvelopeType.SealedSender;
}
else if (message.type === compiled_1.signalservice.Envelope.Type.PLAINTEXT_CONTENT) {
envelopeType = EnvelopeType.Plaintext;
}
else {
throw new Error(`Unsupported envelope type: ${message.type}`);
}
const serviceIdKind = target.getServiceIdKind(targetServiceId);
await this.handleMessage(source, serviceIdKind, envelopeType, target, Buffer.from(message.content, 'base64'));
}
}
async addWebSocket(device, socket) {
debug('adding websocket for device=%s', device.debugId);
let sockets = this.webSockets.get(device);
if (!sockets) {
sockets = new Set();
this.webSockets.set(device, sockets);
}
sockets.add(socket);
await this.sendQueue(device, socket);
}
removeWebSocket(device, socket) {
debug('removing websocket for device=%s', device.debugId);
const sockets = this.webSockets.get(device);
if (!sockets) {
return;
}
sockets.delete(socket);
if (sockets.size === 0) {
this.webSockets.delete(device);
}
}
// TODO(indutny): timeout
async send(target, message) {
const sockets = this.webSockets.get(target);
if (sockets) {
debug('sending message to %d sockets of %s', sockets.size, target.debugId);
let success = false;
await Promise.all(Array.from(sockets).map(async (socket) => {
try {
await socket.sendMessage(message);
success = true;
}
catch (error) {
(0, assert_1.default)(error instanceof Error);
debug('failed to send message to socket of %s, error %s', target.debugId, error.message);
}
}));
// At least one send should succeed, if not - queue
if (success) {
return;
}
debug("message couldn't be sent to %s", sockets.size, target.debugId);
}
debug('queueing message for device=%s', target.debugId);
await new Promise((resolve, reject) => {
// NOTE: set and push have to happen in the same tick, otherwise a race
// condition is possible in `removeWebSocket`.
let queue = this.messageQueue.get(target);
if (!queue) {
queue = [];
this.messageQueue.set(target, queue);
}
queue.push({
message,
resolve,
reject,
});
});
debug('queued message sent to device=%s', target.debugId);
}
//
// Groups
//
async createGroup(group) {
const result = new group_1.ServerGroup({
zkSecret: this.zkSecret,
profileOps: new zkgroup_1.ServerZkProfileOperations(this.zkSecret),
state: group,
});
const key = result.publicParams.serialize().toString('base64');
if (this.groups.get(key)) {
throw new Error('Duplicate group');
}
this.groups.set(key, result);
return result;
}
async modifyGroup({ group, actions, aciCiphertext, pniCiphertext, }) {
return group.modify(new zkgroup_1.UuidCiphertext(Buffer.from(aciCiphertext)), new zkgroup_1.UuidCiphertext(Buffer.from(pniCiphertext)), actions);
}
async getGroup(publicParams) {
return this.groups.get(publicParams.toString('base64'));
}
//
// Storage
//
async getStorageAuth(device) {
let auth = this.storageAuthByDevice.get(device);
if (!auth) {
do {
auth = {
username: crypto_1.default.randomBytes(8).toString('hex'),
password: crypto_1.default.randomBytes(8).toString('hex'),
device,
};
} while (this.storageAuthByUsername.has(auth.username));
this.storageAuthByDevice.set(device, auth);
this.storageAuthByUsername.set(auth.username, auth);
debug('register new storage username=%j', auth.username);
}
return {
username: auth.username,
password: auth.password,
};
}
async storageAuth(username, password) {
const auth = this.storageAuthByUsername.get(username);
if (!auth) {
debug('auth failed, username=%j is unknown', username);
return;
}
if (auth.password !== password) {
debug('auth failed, invalid login/password %j:%j', username, password);
}
return auth.device;
}
async getStorageManifest(device) {
return this.storageManifestByAci.get(device.aci);
}
async applyStorageWrite(device, { manifest, clearAll, insertItem, deleteKey }, shouldNotify = true) {
if (!manifest) {
return { error: 'missing `writeOperation.manifest`' };
}
if (!manifest.version) {
return {
error: 'not updating storage manifest, ' +
'missing `writeOperation.manifest.version`',
};
}
const existing = await this.getStorageManifest(device);
if (existing) {
// Atomicity
(0, assert_1.default)(existing.version, 'consistency check');
if (!manifest.version.eq(existing.version.add(1))) {
debug('not updating storage manifest, current version=%j new version=%j', existing.version.toNumber(), manifest.version.toNumber());
return { updated: false, manifest: existing };
}
}
if (clearAll) {
debug('clearing storage items for=%j', device.debugId);
await this.clearStorageItems(device);
}
const inserts = (insertItem || []).map(async (item) => {
(0, assert_1.default)(item.key instanceof Uint8Array, 'insertItem.key must be a Buffer');
(0, assert_1.default)(item.value instanceof Uint8Array, 'insertItem.value must be a Buffer');
return this.setStorageItem(device, Buffer.from(item.key), Buffer.from(item.value));
});
await Promise.all(inserts);
const deletes = (deleteKey || []).map(async (key) => {
return this.deleteStorageItem(device, Buffer.from(key));
});
await Promise.all(deletes);
debug('updating storage manifest to version=%j for=%j', manifest.version.toNumber(), device.debugId);
this.storageManifestByAci.set(device.aci, manifest);
if (shouldNotify) {
await this.onStorageManifestUpdate(device, manifest.version);
}
return { updated: true };
}
async clearStorageItems(device) {
this.storageItemsByAci.get(device.aci)?.clear();
}
async setStorageItem(device, key, value) {
let map = this.storageItemsByAci.get(device.aci);
if (!map) {
map = new Map();
this.storageItemsByAci.set(device.aci, map);
}
map.set(key.toString('hex'), value);
}
async getStorageItem(device, key) {
const map = this.storageItemsByAci.get(device.aci);
if (!map) {
return undefined;
}
return map.get(key.toString('hex'));
}
async getAllStorageKeys(device) {
const map = this.storageItemsByAci.get(device.aci);
if (!map) {
return [];
}
return Array.from(map.keys()).map((hex) => Buffer.from(hex, 'hex'));
}
async getStorageItems(device, keys) {
const result = new Array();
await Promise.all(keys.map(async (key) => {
const value = await this.getStorageItem(device, key);
if (value !== undefined) {
result.push({ key, value });
}
}));
return result;
}
async deleteStorageItem(device, key) {
const map = this.storageItemsByAci.get(device.aci);
if (!map) {
return;
}
map.delete(key.toString('hex'));
}
//
// Usernames
//
async reserveUsername(aci, { usernameHashes }) {
// Clear previously reserved usernames
const reserved = this.reservedUsernameByAci.get(aci);
if (reserved !== undefined) {
this.reservedUsernameByAci.delete(aci);
this.aciByReservedUsername.delete(reserved);
}
for (const hash of usernameHashes) {
const hashHex = hash.toString('hex');
if (this.aciByReservedUsername.has(hashHex)) {
continue;
}
if (this.aciByUsername.has(hashHex)) {
continue;
}
this.reservedUsernameByAci.set(aci, hashHex);
this.aciByReservedUsername.set(hashHex, aci);
return hash;
}
return undefined;
}
async confirmUsername(aci, { usernameHash, zkProof, encryptedUsername }) {
// Clear previously reserved usernames
const reserved = this.reservedUsernameByAci.get(aci);
if (reserved !== usernameHash.toString('hex')) {
return undefined;
}
try {
libsignal_client_1.usernames.verifyProof(zkProof, usernameHash);
}
catch (error) {
debug('failed to verify username proof of %s: %O', aci, error);
return undefined;
}
this.reservedUsernameByAci.delete(aci);
this.aciByReservedUsername.delete(reserved);
this.aciByUsername.set(reserved, aci);
this.usernameByAci.set(aci, reserved);
let usernameLinkHandle;
if (encryptedUsername) {
usernameLinkHandle = await this.replaceUsernameLink(aci, encryptedUsername);
}
return { usernameLinkHandle };
}
async deleteUsername(aci) {
const hash = this.usernameByAci.get(aci);
if (!hash) {
return;
}
this.aciByUsername.delete(hash);
this.usernameByAci.delete(aci);
const previousId = this.usernameLinkIdByServiceId.get(aci);
if (previousId !== undefined) {
this.usernameLinkById.delete(previousId);
}
this.usernameLinkIdByServiceId.delete(aci);
}
async lookupByUsernameHash(usernameHash) {
return this.aciByUsername.get(usernameHash.toString('hex'));
}
async replaceUsernameLink(aci, encryptedValue) {
const lookupId = (0, uuid_1.v4)();
const previousId = this.usernameLinkIdByServiceId.get(aci);
if (previousId !== undefined) {
this.usernameLinkById.delete(previousId);
}
this.usernameLinkIdByServiceId.set(aci, lookupId);
this.usernameLinkById.set(lookupId, encryptedValue);
return lookupId;
}
async lookupByUsernameLink(lookupId) {
return this.usernameLinkById.get(lookupId);
}
// For easier testing
async lookupByUsername(username) {
return this.aciByUsername.get(libsignal_client_1.usernames.hash(username).toString('hex'));
}
// For easier testing
async setUsername(aci, username) {
const hash = libsignal_client_1.usernames.hash(username).toString('hex');
this.usernameByAci.set(aci, hash);
this.aciByUsername.set(hash, aci);
}
// For easier testing
async setUsernameLink(aci, username) {
const { entropy, encryptedUsername } = libsignal_client_1.usernames.createUsernameLink(username);
const serverId = await this.replaceUsernameLink(aci, encryptedUsername);
return {
entropy,
serverId,
};
}
//
// Call Links
//
async createCallLinkAuth(device, request) {
return request.issueCredential(libsignal_client_1.Aci.parseFromServiceIdString(device.aci), (0, util_1.getTodayInSeconds)(), this.genericServerSecret);
}
hasCallLink(roomId) {
return this.callLinksByRoomId.has(roomId);
}
async createCallLink(roomId, { adminPasskey }) {
const callLink = {
adminPasskey,
encryptedName: '',
restrictions: 'none',
revoked: false,
expiration: new Date('2101-01-01').getTime(),
};
this.callLinksByRoomId.set(roomId, callLink);
return callLink;
}
async getCallLink(roomId) {
return this.callLinksByRoomId.get(roomId);
}
async updateCallLink(roomId, { adminPasskey, name, restrictions, revoked }) {
const callLink = this.callLinksByRoomId.get(roomId);
if (!callLink) {
throw new Error('Call link not found');
}
if (!callLink.adminPasskey.equals(adminPasskey)) {
throw new Error('Invalid admin passkey');
}
const newCallLink = {
adminPasskey,
encryptedName: name ?? callLink.encryptedName,
restrictions: restrictions ?? callLink.restrictions,
revoked: revoked ?? callLink.revoked,
expiration: callLink.expiration,
};
this.callLinksByRoomId.set(roomId, newCallLink);
return newCallLink;
}
async deleteCallLink(roomId, { adminPasskey }) {
const callLink = this.callLinksByRoomId.get(roomId);
if (!callLink) {
throw new Error('Call link not found');
}
if (!callLink.adminPasskey.equals(adminPasskey)) {
throw new Error('Invalid admin passkey');
}
this.callLinksByRoomId.delete(roomId);
}
//
// Utils
//
async getDevice(number, deviceId) {
const list = this.devices.get(number);
if (!list) {
return;
}
if (deviceId < 1 || deviceId > list.length) {
return;
}
return list[deviceId - 1];
}
async removeDevice(number, deviceId) {
if (deviceId === constants_1.PRIMARY_DEVICE_ID) {
throw new Error('You cannot remove a primary device; unregister account instead');
}
const list = this.devices.get(number);
if (!list) {
throw new Error(`No devices found for number ${number}`);
}
if (deviceId < 1 || deviceId > list.length) {
throw new Error(`Device ${deviceId} is out of range for number ${number}`);
}
const device = list[deviceId - 1];
debug('removeDevice %j.%j (%j)', device.aci, deviceId, number);
const copy = [...list];
copy.splice(deviceId - 1, 1);
this.devices.set(number, copy);
const idByNumber = `${number}.${deviceId}`;
this.devicesByAuth.delete(idByNumber);
const idByAci = `${device.aci}.${deviceId}`;
this.devicesByAuth.delete(idByAci);
}
async getDeviceByServiceId(serviceId, deviceId) {
const primary = this.devicesByServiceId.get(serviceId);
if (deviceId === undefined || !primary || primary.deviceId === deviceId) {
return primary;
}
if (primary.deviceId !== constants_1.PRIMARY_DEVICE_ID) {
return undefined;
}
return await this.getDevice(primary.number, deviceId);
}
async getAllDevicesByServiceId(serviceId) {
const primary = this.devicesByServiceId.get(serviceId);
if (!primary) {
return [];
}
return this.devices.get(primary.number) || [];
}
async getSenderCertificate(device) {
return (0, crypto_2.generateSenderCertificate)(this.certificate, {
number: device.number,
aci: device.aci,
deviceId: device.deviceId,
identityKey: await device.getIdentityKey(types_1.ServiceIdKind.ACI),
});
}
async getGroupCredentials({ aci, pni }, range) {
const auth = new zkgroup_1.ServerZkAuthOperations(this.zkSecret);
return this.issueCredentials(range, (redemptionTime) => {
return auth.issueAuthCredentialWithPniZkc(libsignal_client_1.Aci.parseFromServiceIdString(aci), libsignal_client_1.Pni.parseFromServiceIdString(pni), redemptionTime);
});
}
async verifyGroupCredentials(publicParams, credential) {
const auth = new zkgroup_1.ServerZkAuthOperations(this.zkSecret);
const groupParams = new zkgroup_1.GroupPublicParams(publicParams);
const presentation = new zkgroup_1.AuthCredentialPresentation(credential);
auth.verifyAuthCredentialPresentation(groupParams, presentation);
// TODO(indutny): verify credential timestamp
return presentation;
}
async getCallLinkAuthCredentials({ aci }, range) {
return this.issueCredentials(range, (redemptionTime) => {
return zkgroup_1.CallLinkAuthCredentialResponse.issueCredential(libsignal_client_1.Aci.parseFromServiceIdString(aci), redemptionTime, this.genericServerSecret);
});
}
async issueExpiringProfileKeyCredential({ aci, profileKeyCommitment }, request) {
if (!profileKeyCommitment) {
return undefined;
}
const today = (0, util_1.getTodayInSeconds)();
const profile = new zkgroup_1.ServerZkProfileOperations(this.zkSecret);
return profile
.issueExpiringProfileKeyCredential(request, libsignal_client_1.Aci.parseFromServiceIdString(aci), profileKeyCommitment, today + constants_1.PROFILE_KEY_CREDENTIAL_EXPIRATION)
.serialize();
}
async setBackupId({ aci }, { messagesBackupAuthCredentialRequest, mediaBackupAuthCredentialRequest, }) {
this.backupAuthReqByAci.set(aci, {
messages: new zkgroup_1.BackupAuthCredentialRequest(messagesBackupAuthCredentialRequest),
media: new zkgroup_1.BackupAuthCredentialRequest(mediaBackupAuthCredentialRequest),
});
}
async setBackupKey(headers, { backupIdPublicKey }) {
const publicKey = libsignal_client_1.PublicKey.deserialize(backupIdPublicKey);
const backupId = this.authenticateBackup(headers, publicKey);
this.backupKeyById.set(backupId, publicKey);
if (!this.backupCDNPasswordById.get(backupId)) {
const password = crypto_1.default.randomBytes(16).toString('hex');
this.backupCDNPasswordById.set(backupId, password);
}
}
async refreshBackup(headers) {
this.authenticateBackup(headers);
// No-op for tests
}
async getBackupInfo(headers) {
const backupId = this.authenticateBackup(headers);
return {
cdn: 3,
backupDir: backupId,
mediaDir: 'media',
backupName: 'backup',
};
}
async listBackupMedia(headers, { cursor, limit }) {
const backupId = this.authenticateBackup(headers);
let cursorData;
let newCursor;
if (cursor !== undefined) {
cursorData = this.backupMediaCursorById.get(cursor);
}
if (cursorData === undefined) {
newCursor = crypto_1.default.randomBytes(8).toString('hex');
cursorData = {
backupId,
remainingMedia: this.backupMediaById.get(backupId)?.slice() ?? [],
};
this.backupMediaCursorById.set(newCursor, cursorData);
}
else {
assert_1.default.strictEqual(cursorData.backupId, backupId);
}
const storedMediaObjects = cursorData.remainingMedia.slice(0, limit);
// End of list
if (storedMediaObjects.length < limit) {
(0, assert_1.default)(newCursor !== undefined);
this.backupMediaCursorById.delete(newCursor);
newCursor = undefined;
}
else {
cursorData.remainingMedia = cursorData.remainingMedia.slice(limit);
}
return {
storedMediaObjects,
backupDir: backupId,
mediaDir: 'media',
cursor: newCursor,
};
}
async getBackupMediaUploadForm(headers) {
this.authenticateBackup(headers);
const form = await this.getAttachmentUploadForm('attachments', (0, uuid_1.v4)());
return form;
}
async getBackupUploadForm(headers) {
const backupId = this.authenticateBackup(headers);
const form = await this.getAttachmentUploadForm('backups', `${backupId}/backup`);
return form;
}
async backupMediaBatch(headers, batch) {
const backupId = this.authenticateBackup(headers);
const responses = await this.backupTransitAttachments(backupId, batch);
return { responses };
}
async getBackupCDNAuth(headers) {
const backupId = this.authenticateBackup(headers);
const password = this.backupCDNPasswordById.get(backupId);
(0, assert_1.default)(password !== undefined);
const basic = Buffer.from(`${backupId}:${password}`);
const authorization = `Basic ${basic.toString('base64')}`;
return {
authorization,
};
}
async authorizeBackupCDN(backupId, password) {
const expected = this.backupCDNPasswordById.get(backupId);
if (expected === undefined) {
return false;
}
if (!crypto_1.default.timingSafeEqual(Buffer.from(expected), Buffer.from(password))) {
return false;
}
return true;
}
async getBackupCredentials({ aci, backupLevel }, range) {
const req = this.backupAuthReqByAci.get(aci);
if (req === undefined) {
return undefined;
}
const messages = this.issueCredentials(range, (redemptionTime) => {
return req.messages.issueCredential(redemptionTime, zkgroup_1.BackupLevel.Free, zkgroup_1.BackupCredentialType.Messages, this.backupServerSecret);
});
const media = this.issueCredentials(range, (redemptionTime) => {
return req.media.issueCredential(redemptionTime, backupLevel, zkgroup_1.BackupCredentialType.Media, this.backupServerSecret);
});
return {
messages,
media,
};
}
async onNewBackupMediaObject(backupId, media) {
let list = this.backupMediaById.get(backupId);
if (list === undefined) {
list = [];
this.backupMediaById.set(backupId, list);
}
list.push(media);
}
//
// Private
//
set certificate(value) {
if (this.privCertificate) {
throw new Error('Certificate already set');
}
this.privCertificate = value;
}
get certificate() {
if (!this.privCertificate) {
throw new Error('Certificate not set');
}
return this.privCertificate;
}
set genericServerSecret(value) {
if (this.privGenericServerSecret) {
throw new Error('zkgroup generic secret already set');
}
this.privGenericServerSecret = value;
}
get genericServerSecret() {
if (!this.privGenericServerSecret) {
throw new Error('zkgroup generic secret not set');
}
return this.privGenericServerSecret;
}
set backupServerSecret(value) {
if (this.privBackupServerSecret) {
throw new Error('zkgroup backup secret already set');
}
this.privBackupServerSecret = value;
}
get backupServerSecret() {
if (!this.privBackupServerSecret) {
throw new Error('zkgroup backup secret not set');
}
return this.privBackupServerSecret;
}
set zkSecret(value) {
if (this.privZKSecret) {
throw new Error('zkgroup secret already set');
}
this.privZKSecret = value;
}
get zkSecret() {
if (!this.privZKSecret) {
throw new Error('zkgroup secret not set');
}
return this.privZKSecret;
}
async sendQueue(device, socket) {
let queue = this.messageQueue.get(device);
if (queue) {
this.messageQueue.delete(device);
}
else {
queue = [];
}
debug('sending queued %d messages to %s', queue.length, device.debugId);
await Promise.all(queue.map(async (entry) => {
const { message, resolve, reject } = entry;
try {
await socket.sendMessage(message);
}
catch (error) {
(0, assert_1.default)(error instanceof Error);
reject(error);
return;
}
resolve();
}));
debug('queue for %s is empty', device.debugId);
await socket.sendMessage('empty');
}
issueCredentials({ from, to }, issueOne) {
const today = (0, util_1.getTodayInSeconds)();
if (from > to ||
from < today ||
to > today + constants_1.DAY_IN_SECONDS * constants_1.MAX_GROUP_CREDENTIALS_DAYS) {
throw new Error('Invalid redemption range');
}
const result = [];
for (let redemptionTime = from; redemptionTime <= to; redemptionTime += constants_1.DAY_IN_SECONDS) {
result.push({
credential: issueOne(redemptionTime).serialize().toString('base64'),
redemptionTime,
});
}
return result;
}
authenticateBackup(headers, newPublicKey) {
const presentation = new zkgroup_1.BackupAuthCredentialPresentation(headers['x-signal-zk-auth']);
presentation.verify(this.backupServerSecret);
// Backup id is used in urls, so encode it properly
const backupId = presentation.getBackupId().toString('base64url');
const validatingKey = this.backupKeyById.get(backupId) || newPublicKey;
if (!validatingKey) {
throw new Error('No backup public key to validate against');
}
const isValid = validatingKey.verify(headers['x-signal-zk-auth'], headers['x-signal-zk-auth-signature']);
if (!isValid) {
throw new Error('Invalid signature');
}
return backupId;
}
}
exports.Server = Server;