UNPKG

@signalapp/mock-server

Version:
1,029 lines (1,028 loc) 39 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 = 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;