diff --git a/keyserver/src/keyserver.js b/keyserver/src/keyserver.js index f83b4d697..1f01cd6d9 100644 --- a/keyserver/src/keyserver.js +++ b/keyserver/src/keyserver.js @@ -1,299 +1,294 @@ // @flow import olm from '@commapp/olm'; import cluster from 'cluster'; import compression from 'compression'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import crypto from 'crypto'; import express from 'express'; import type { $Request, $Response } from 'express'; import expressWs from 'express-ws'; import os from 'os'; import qrcode from 'qrcode'; import './cron/cron.js'; import { qrCodeLinkURL } from 'lib/facts/links.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { ignorePromiseRejections } from 'lib/utils/promises.js'; import { migrate } from './database/migrations.js'; import { jsonEndpoints } from './endpoints.js'; import { logEndpointMetrics } from './middleware/endpoint-profiling.js'; import { emailSubscriptionResponder } from './responders/comm-landing-responders.js'; import { jsonHandler, downloadHandler, htmlHandler, uploadHandler, } from './responders/handlers.js'; import landingHandler from './responders/landing-handler.js'; import { errorReportDownloadResponder } from './responders/report-responders.js'; import { inviteResponder, websiteResponder, } from './responders/website-responders.js'; import { webWorkerResponder } from './responders/webworker-responders.js'; import { onConnection } from './socket/socket.js'; -import { - createAndMaintainTunnelbrokerWebsocket, - createAndMaintainAnonymousTunnelbrokerWebsocket, -} from './socket/tunnelbroker.js'; +import { createAndMaintainTunnelbrokerWebsocket } from './socket/tunnelbroker.js'; import { multerProcessor, multimediaUploadResponder, uploadDownloadResponder, } from './uploads/uploads.js'; import { createAuthoritativeKeyserverConfigFiles } from './user/create-configs.js'; import { verifyUserLoggedIn } from './user/login.js'; import { initENSCache } from './utils/ens-cache.js'; import { initFCCache } from './utils/fc-cache.js'; import { getContentSigningKey } from './utils/olm-utils.js'; import { prefetchAllURLFacts, getKeyserverURLFacts, getLandingURLFacts, getWebAppURLFacts, getWebAppCorsConfig, } from './utils/urls.js'; const shouldDisplayQRCodeInTerminal = false; void (async () => { const [webAppCorsConfig] = await Promise.all([ getWebAppCorsConfig(), olm.init(), prefetchAllURLFacts(), initENSCache(), initFCCache(), ]); const keyserverURLFacts = getKeyserverURLFacts(); const keyserverBaseRoutePath = keyserverURLFacts?.baseRoutePath; const landingBaseRoutePath = getLandingURLFacts()?.baseRoutePath; const webAppURLFacts = getWebAppURLFacts(); const webAppBaseRoutePath = webAppURLFacts?.baseRoutePath; const compiledFolderOptions = process.env.NODE_ENV === 'development' ? undefined : { maxAge: '1y', immutable: true }; let keyserverCorsOptions = null; if (webAppCorsConfig) { keyserverCorsOptions = { origin: webAppCorsConfig.domain, methods: ['GET', 'POST'], }; } const isCPUProfilingEnabled = process.env.KEYSERVER_CPU_PROFILING_ENABLED; const areEndpointMetricsEnabled = process.env.KEYSERVER_ENDPOINT_METRICS_ENABLED; if (cluster.isMaster) { const didMigrationsSucceed: boolean = await migrate(); if (!didMigrationsSucceed) { // The following line uses exit code 2 to ensure nodemon exits // in a dev environment, instead of restarting. Context provided // in https://github.com/remy/nodemon/issues/751 process.exit(2); } if (shouldDisplayQRCodeInTerminal) { try { const aes256Key = crypto.randomBytes(32).toString('hex'); const ed25519Key = await getContentSigningKey(); - await createAndMaintainAnonymousTunnelbrokerWebsocket(aes256Key); + await createAndMaintainTunnelbrokerWebsocket(aes256Key); console.log( '\nOpen the Comm app on your phone and scan the QR code below\n', ); console.log('How to find the scanner:\n'); console.log('Go to \x1b[1mProfile\x1b[0m'); console.log('Select \x1b[1mLinked devices\x1b[0m'); console.log('Click \x1b[1mAdd\x1b[0m on the top right'); const url = qrCodeLinkURL(aes256Key, ed25519Key); qrcode.toString(url, (error, encodedURL) => console.log(encodedURL)); } catch (e) { console.log('Error generating QR code', e); } } else { // Allow login to be optional until staging environment is available try { // We await here to ensure that the keyserver has been provisioned a // commServicesAccessToken. In the future, this will be necessary for // many keyserver operations. const identityInfo = await verifyUserLoggedIn(); // We don't await here, as Tunnelbroker communication is not needed for // normal keyserver behavior yet. In addition, this doesn't return // information useful for other keyserver functions. - ignorePromiseRejections( - createAndMaintainTunnelbrokerWebsocket(identityInfo), - ); + ignorePromiseRejections(createAndMaintainTunnelbrokerWebsocket(null)); if (process.env.NODE_ENV === 'development') { await createAuthoritativeKeyserverConfigFiles(identityInfo.userId); } } catch (e) { console.warn( 'Failed identity login. Login optional until staging environment is available', ); } } if (!isCPUProfilingEnabled) { const cpuCount = os.cpus().length; for (let i = 0; i < cpuCount; i++) { cluster.fork(); } cluster.on('exit', () => cluster.fork()); } } if (!cluster.isMaster || isCPUProfilingEnabled) { const server = express(); server.use(compression()); expressWs(server); server.use(express.json({ limit: '250mb' })); server.use(cookieParser()); // Note - the order of router declarations matters. On prod we have // keyserverBaseRoutePath configured to '/', which means it's a catch-all. // If we call server.use on keyserverRouter first, it will catch all // requests and prevent webAppRouter and landingRouter from working // correctly. So we make sure that keyserverRouter goes last server.get('/invite/:secret', inviteResponder); if (landingBaseRoutePath) { const landingRouter = express.Router<$Request, $Response>(); landingRouter.get('/invite/:secret', inviteResponder); landingRouter.use( '/.well-known', express.static( '.well-known', // Necessary for apple-app-site-association file { setHeaders: res => res.setHeader('Content-Type', 'application/json'), }, ), ); landingRouter.use('/images', express.static('images')); landingRouter.use('/fonts', express.static('fonts')); landingRouter.use( '/compiled', express.static('landing_compiled', compiledFolderOptions), ); landingRouter.use('/', express.static('landing_icons')); landingRouter.post('/subscribe_email', emailSubscriptionResponder); landingRouter.get('*', landingHandler); server.use(landingBaseRoutePath, landingRouter); } if (webAppBaseRoutePath) { const webAppRouter = express.Router<$Request, $Response>(); webAppRouter.use('/images', express.static('images')); webAppRouter.use('/fonts', express.static('fonts')); webAppRouter.use('/misc', express.static('misc')); webAppRouter.use( '/.well-known', express.static( '.well-known', // Necessary for apple-app-site-association file { setHeaders: res => res.setHeader('Content-Type', 'application/json'), }, ), ); webAppRouter.use( '/compiled', express.static('app_compiled', compiledFolderOptions), ); webAppRouter.use('/', express.static('icons')); webAppRouter.get('/invite/:secret', inviteResponder); webAppRouter.get('/worker/:worker', webWorkerResponder); if (keyserverURLFacts) { webAppRouter.get( '/upload/:uploadID/:secret', (req: $Request, res: $Response) => { const { uploadID, secret } = req.params; const url = `${keyserverURLFacts.baseDomain}${keyserverURLFacts.basePath}upload/${uploadID}/${secret}`; res.redirect(url); }, ); } webAppRouter.get('*', htmlHandler(websiteResponder)); server.use(webAppBaseRoutePath, webAppRouter); } if (keyserverBaseRoutePath) { const keyserverRouter = express.Router<$Request, $Response>(); if (areEndpointMetricsEnabled) { keyserverRouter.use(logEndpointMetrics); } if (keyserverCorsOptions) { keyserverRouter.use(cors(keyserverCorsOptions)); } for (const endpoint in jsonEndpoints) { // $FlowFixMe Flow thinks endpoint is string const responder = jsonEndpoints[endpoint]; const expectCookieInvalidation = endpoint === 'log_out'; keyserverRouter.post( `/${endpoint}`, jsonHandler(responder, expectCookieInvalidation), ); } keyserverRouter.get( '/download_error_report/:reportID', downloadHandler(errorReportDownloadResponder), ); keyserverRouter.get( '/upload/:uploadID/:secret', downloadHandler(uploadDownloadResponder), ); // $FlowFixMe express-ws has side effects that can't be typed keyserverRouter.ws('/ws', onConnection); keyserverRouter.post( '/upload_multimedia', multerProcessor, uploadHandler(multimediaUploadResponder), ); server.use(keyserverBaseRoutePath, keyserverRouter); } if (isDev && webAppURLFacts) { const oldPath = '/comm/'; server.all(`${oldPath}*`, (req: $Request, res: $Response) => { const endpoint = req.url.slice(oldPath.length); const newURL = `${webAppURLFacts.baseDomain}${webAppURLFacts.basePath}${endpoint}`; res.redirect(newURL); }); } const listenAddress = (() => { if (process.env.COMM_LISTEN_ADDR) { return process.env.COMM_LISTEN_ADDR; } else if (process.env.NODE_ENV === 'development') { return undefined; } else { return 'localhost'; } })(); server.listen(parseInt(process.env.PORT, 10) || 3000, listenAddress); } })(); diff --git a/keyserver/src/socket/tunnelbroker-socket.js b/keyserver/src/socket/tunnelbroker-socket.js deleted file mode 100644 index 3d6a5ee16..000000000 --- a/keyserver/src/socket/tunnelbroker-socket.js +++ /dev/null @@ -1,306 +0,0 @@ -// @flow - -import _debounce from 'lodash/debounce.js'; -import { getRustAPI } from 'rust-node-addon'; -import uuid from 'uuid'; -import WebSocket from 'ws'; - -import { hexToUintArray } from 'lib/media/data-utils.js'; -import { tunnelbrokerHeartbeatTimeout } from 'lib/shared/timeouts.js'; -import type { TunnelbrokerClientMessageToDevice } from 'lib/tunnelbroker/tunnelbroker-context.js'; -import type { MessageReceiveConfirmation } from 'lib/types/tunnelbroker/message-receive-confirmation-types.js'; -import type { MessageSentStatus } from 'lib/types/tunnelbroker/message-to-device-request-status-types.js'; -import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-types.js'; -import { - type TunnelbrokerMessage, - tunnelbrokerMessageTypes, - tunnelbrokerMessageValidator, -} from 'lib/types/tunnelbroker/messages.js'; -import { - qrCodeAuthMessageValidator, - type RefreshKeyRequest, - refreshKeysRequestValidator, - type QRCodeAuthMessage, -} from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; -import { - type QRCodeAuthMessagePayload, - qrCodeAuthMessagePayloadValidator, - qrCodeAuthMessageTypes, -} from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; -import type { - ConnectionInitializationMessage, - AnonymousInitializationMessage, -} from 'lib/types/tunnelbroker/session-types.js'; -import type { Heartbeat } from 'lib/types/websocket/heartbeat-types.js'; -import { convertBytesToObj } from 'lib/utils/conversion-utils.js'; - -import { fetchOlmAccount } from '../updaters/olm-account-updater.js'; -import { decrypt } from '../utils/aes-crypto-utils.js'; -import { - uploadNewOneTimeKeys, - getNewDeviceKeyUpload, -} from '../utils/olm-utils.js'; - -type PromiseCallbacks = { - +resolve: () => void, - +reject: (error: string) => void, -}; -type Promises = { [clientMessageID: string]: PromiseCallbacks }; - -class TunnelbrokerSocket { - ws: WebSocket; - connected: boolean = false; - closed: boolean = false; - promises: Promises = {}; - heartbeatTimeoutID: ?TimeoutID; - oneTimeKeysPromise: ?Promise; - anonymous: boolean = false; - qrAuthEncryptionKey: ?string; - - constructor( - socketURL: string, - initMessage: - | ConnectionInitializationMessage - | AnonymousInitializationMessage, - onClose: () => mixed, - qrAuthEncryptionKey?: string, - ) { - const socket = new WebSocket(socketURL); - - socket.on('open', () => { - if (!this.closed) { - socket.send(JSON.stringify(initMessage)); - } - }); - - socket.on('close', async () => { - if (this.closed) { - return; - } - this.closed = true; - this.connected = false; - this.stopHeartbeatTimeout(); - console.error('Connection to Tunnelbroker closed'); - onClose(); - }); - - socket.on('error', (error: Error) => { - console.error('Tunnelbroker socket error:', error.message); - }); - - socket.on('message', this.onMessage); - - this.ws = socket; - this.anonymous = !initMessage.accessToken; - if (qrAuthEncryptionKey) { - this.qrAuthEncryptionKey = qrAuthEncryptionKey; - } - } - - onMessage: (event: ArrayBuffer) => Promise = async ( - event: ArrayBuffer, - ) => { - let rawMessage; - try { - rawMessage = JSON.parse(event.toString()); - } catch (e) { - console.error('error while parsing Tunnelbroker message:', e.message); - return; - } - - if (!tunnelbrokerMessageValidator.is(rawMessage)) { - console.error('invalid TunnelbrokerMessage: ', rawMessage.toString()); - return; - } - const message: TunnelbrokerMessage = rawMessage; - - this.resetHeartbeatTimeout(); - - if ( - message.type === - tunnelbrokerMessageTypes.CONNECTION_INITIALIZATION_RESPONSE - ) { - if (message.status.type === 'Success' && !this.connected) { - this.connected = true; - console.info( - this.anonymous - ? 'anonymous session with Tunnelbroker created' - : 'session with Tunnelbroker created', - ); - } else if (message.status.type === 'Success' && this.connected) { - console.info( - 'received ConnectionInitializationResponse with status: Success for already connected socket', - ); - } else { - this.connected = false; - console.error( - 'creating session with Tunnelbroker error:', - message.status.data, - ); - } - } else if (message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE) { - const confirmation: MessageReceiveConfirmation = { - type: tunnelbrokerMessageTypes.MESSAGE_RECEIVE_CONFIRMATION, - messageIDs: [message.messageID], - }; - this.ws.send(JSON.stringify(confirmation)); - - const { payload } = message; - try { - const messageToKeyserver = JSON.parse(payload); - if (qrCodeAuthMessageValidator.is(messageToKeyserver)) { - const request: QRCodeAuthMessage = messageToKeyserver; - const [qrCodeAuthMessage, rustAPI, accountInfo] = await Promise.all([ - this.parseQRCodeAuthMessage(request), - getRustAPI(), - fetchOlmAccount('content'), - ]); - if ( - !qrCodeAuthMessage || - qrCodeAuthMessage.type !== - qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS - ) { - return; - } - // eslint-disable-next-line no-unused-vars - const { primaryDeviceID, userID } = qrCodeAuthMessage; - const [nonce, deviceKeyUpload] = await Promise.all([ - rustAPI.generateNonce(), - getNewDeviceKeyUpload(), - ]); - const signedIdentityKeysBlob = { - payload: deviceKeyUpload.keyPayload, - signature: deviceKeyUpload.keyPayloadSignature, - }; - const nonceSignature = accountInfo.account.sign(nonce); - - await rustAPI.uploadSecondaryDeviceKeysAndLogIn( - userID, - nonce, - nonceSignature, - signedIdentityKeysBlob, - deviceKeyUpload.contentPrekey, - deviceKeyUpload.contentPrekeySignature, - deviceKeyUpload.notifPrekey, - deviceKeyUpload.notifPrekeySignature, - deviceKeyUpload.contentOneTimeKeys, - deviceKeyUpload.notifOneTimeKeys, - ); - } else if (refreshKeysRequestValidator.is(messageToKeyserver)) { - const request: RefreshKeyRequest = messageToKeyserver; - this.debouncedRefreshOneTimeKeys(request.numberOfKeys); - } - } catch (e) { - console.error( - 'error while processing message to keyserver:', - e.message, - ); - } - } else if ( - message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST_STATUS - ) { - for (const status: MessageSentStatus of message.clientMessageIDs) { - if (status.type === 'Success') { - if (this.promises[status.data]) { - this.promises[status.data].resolve(); - delete this.promises[status.data]; - } else { - console.log( - 'received successful response for a non-existent request', - ); - } - } else if (status.type === 'Error') { - if (this.promises[status.data.id]) { - this.promises[status.data.id].reject(status.data.error); - delete this.promises[status.data.id]; - } else { - console.log('received error response for a non-existent request'); - } - } else if (status.type === 'SerializationError') { - console.error('SerializationError for message: ', status.data); - } else if (status.type === 'InvalidRequest') { - console.log('Tunnelbroker recorded InvalidRequest'); - } - } - } else if (message.type === tunnelbrokerMessageTypes.HEARTBEAT) { - const heartbeat: Heartbeat = { - type: tunnelbrokerMessageTypes.HEARTBEAT, - }; - this.ws.send(JSON.stringify(heartbeat)); - } - }; - - refreshOneTimeKeys: (numberOfKeys: number) => void = numberOfKeys => { - const oldOneTimeKeysPromise = this.oneTimeKeysPromise; - this.oneTimeKeysPromise = (async () => { - await oldOneTimeKeysPromise; - await uploadNewOneTimeKeys(numberOfKeys); - })(); - }; - - debouncedRefreshOneTimeKeys: (numberOfKeys: number) => void = _debounce( - this.refreshOneTimeKeys, - 100, - { leading: true, trailing: true }, - ); - - sendMessage: (message: TunnelbrokerClientMessageToDevice) => Promise = ( - message: TunnelbrokerClientMessageToDevice, - ) => { - if (!this.connected) { - throw new Error('Tunnelbroker not connected'); - } - const clientMessageID = uuid.v4(); - const messageToDevice: MessageToDeviceRequest = { - type: tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST, - clientMessageID, - deviceID: message.deviceID, - payload: message.payload, - }; - - return new Promise((resolve, reject) => { - this.promises[clientMessageID] = { - resolve, - reject, - }; - this.ws.send(JSON.stringify(messageToDevice)); - }); - }; - - stopHeartbeatTimeout() { - if (this.heartbeatTimeoutID) { - clearTimeout(this.heartbeatTimeoutID); - this.heartbeatTimeoutID = null; - } - } - - resetHeartbeatTimeout() { - this.stopHeartbeatTimeout(); - this.heartbeatTimeoutID = setTimeout(() => { - this.ws.close(); - this.connected = false; - }, tunnelbrokerHeartbeatTimeout); - } - - parseQRCodeAuthMessage: ( - message: QRCodeAuthMessage, - ) => Promise = async message => { - const encryptionKey = this.qrAuthEncryptionKey; - if (!encryptionKey) { - return null; - } - const encryptedData = Buffer.from(message.encryptedContent, 'base64'); - const decryptedData = await decrypt( - hexToUintArray(encryptionKey), - new Uint8Array(encryptedData), - ); - const payload = convertBytesToObj(decryptedData); - if (!qrCodeAuthMessagePayloadValidator.is(payload)) { - return null; - } - - return payload; - }; -} - -export default TunnelbrokerSocket; diff --git a/keyserver/src/socket/tunnelbroker.js b/keyserver/src/socket/tunnelbroker.js index 5d92c1ef4..a79c88457 100644 --- a/keyserver/src/socket/tunnelbroker.js +++ b/keyserver/src/socket/tunnelbroker.js @@ -1,97 +1,473 @@ // @flow -import { clientTunnelbrokerSocketReconnectDelay } from 'lib/shared/timeouts.js'; +import invariant from 'invariant'; +import _debounce from 'lodash/debounce.js'; +import { getRustAPI } from 'rust-node-addon'; +import uuid from 'uuid'; +import WebSocket from 'ws'; + +import { hexToUintArray } from 'lib/media/data-utils.js'; +import { + clientTunnelbrokerSocketReconnectDelay, + tunnelbrokerHeartbeatTimeout, +} from 'lib/shared/timeouts.js'; +import type { TunnelbrokerClientMessageToDevice } from 'lib/tunnelbroker/tunnelbroker-context.js'; +import type { MessageReceiveConfirmation } from 'lib/types/tunnelbroker/message-receive-confirmation-types.js'; +import type { MessageSentStatus } from 'lib/types/tunnelbroker/message-to-device-request-status-types.js'; +import type { MessageToDeviceRequest } from 'lib/types/tunnelbroker/message-to-device-request-types.js'; +import { + type TunnelbrokerMessage, + tunnelbrokerMessageTypes, + tunnelbrokerMessageValidator, +} from 'lib/types/tunnelbroker/messages.js'; +import { + qrCodeAuthMessageValidator, + type RefreshKeyRequest, + refreshKeysRequestValidator, + type QRCodeAuthMessage, +} from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; +import { peerToPeerMessageTypes } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; +import { + type QRCodeAuthMessagePayload, + qrCodeAuthMessagePayloadValidator, + qrCodeAuthMessageTypes, +} from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; import type { ConnectionInitializationMessage, AnonymousInitializationMessage, } from 'lib/types/tunnelbroker/session-types.js'; +import type { Heartbeat } from 'lib/types/websocket/heartbeat-types.js'; import { getCommConfig } from 'lib/utils/comm-config.js'; +import { + convertBytesToObj, + convertObjToBytes, +} from 'lib/utils/conversion-utils.js'; +import { getMessageForException } from 'lib/utils/errors.js'; import sleep from 'lib/utils/sleep.js'; -import TunnelbrokerSocket from './tunnelbroker-socket.js'; -import { type IdentityInfo } from '../user/identity.js'; -import { getContentSigningKey } from '../utils/olm-utils.js'; +import { fetchOlmAccount } from '../updaters/olm-account-updater.js'; +import { fetchIdentityInfo, saveIdentityInfo } from '../user/identity.js'; +import type { IdentityInfo } from '../user/identity.js'; +import { encrypt, decrypt } from '../utils/aes-crypto-utils.js'; +import { + getContentSigningKey, + uploadNewOneTimeKeys, + getNewDeviceKeyUpload, + markPrekeysAsPublished, +} from '../utils/olm-utils.js'; type TBConnectionInfo = { +url: string, }; async function getTBConnectionInfo(): Promise { const tbConfig = await getCommConfig({ folder: 'facts', name: 'tunnelbroker', }); if (tbConfig) { return tbConfig; } console.warn('Defaulting to staging Tunnelbroker'); return { url: 'wss://tunnelbroker.staging.commtechnologies.org:51001', }; } -async function createAndMaintainTunnelbrokerWebsocket( - identityInfo: IdentityInfo, -) { +async function createAndMaintainTunnelbrokerWebsocket(encryptionKey: ?string) { const [deviceID, tbConnectionInfo] = await Promise.all([ getContentSigningKey(), getTBConnectionInfo(), ]); + const createNewTunnelbrokerSocket = async ( + shouldNotifyPrimaryAfterReopening: boolean, + primaryDeviceID: ?string, + ) => { + const identityInfo = await fetchIdentityInfo(); + new TunnelbrokerSocket({ + socketURL: tbConnectionInfo.url, + onClose: async (successfullyAuthed: boolean, primaryID: ?string) => { + await sleep(clientTunnelbrokerSocketReconnectDelay); + await createNewTunnelbrokerSocket(successfullyAuthed, primaryID); + }, + identityInfo, + deviceID, + qrAuthEncryptionKey: encryptionKey, + primaryDeviceID, + shouldNotifyPrimaryAfterReopening, + }); + }; + await createNewTunnelbrokerSocket(false, null); +} + +type TunnelbrokerSocketParams = { + +socketURL: string, + +onClose: (boolean, ?string) => mixed, + +identityInfo: ?IdentityInfo, + +deviceID: string, + +qrAuthEncryptionKey: ?string, + +primaryDeviceID: ?string, + +shouldNotifyPrimaryAfterReopening: boolean, +}; + +type PromiseCallbacks = { + +resolve: () => void, + +reject: (error: string) => void, +}; +type Promises = { [clientMessageID: string]: PromiseCallbacks }; + +class TunnelbrokerSocket { + ws: WebSocket; + connected: boolean = false; + closed: boolean = false; + promises: Promises = {}; + heartbeatTimeoutID: ?TimeoutID; + oneTimeKeysPromise: ?Promise; + identityInfo: ?IdentityInfo; + qrAuthEncryptionKey: ?string; + primaryDeviceID: ?string; + shouldNotifyPrimaryAfterReopening: boolean = false; + shouldNotifyPrimary: boolean = false; + + constructor(tunnelbrokerSocketParams: TunnelbrokerSocketParams) { + const { + socketURL, + onClose, + identityInfo, + deviceID, + qrAuthEncryptionKey, + primaryDeviceID, + shouldNotifyPrimaryAfterReopening, + } = tunnelbrokerSocketParams; + + this.identityInfo = identityInfo; + this.qrAuthEncryptionKey = qrAuthEncryptionKey; + this.primaryDeviceID = primaryDeviceID; + + if (shouldNotifyPrimaryAfterReopening) { + this.shouldNotifyPrimary = true; + } - const initMessage: ConnectionInitializationMessage = { - type: 'ConnectionInitializationMessage', - deviceID: deviceID, - accessToken: identityInfo.accessToken, - userID: identityInfo.userId, - deviceType: 'keyserver', + const socket = new WebSocket(socketURL); + + socket.on('open', () => { + this.onOpen(socket, deviceID); + }); + + socket.on('close', async () => { + if (this.closed) { + return; + } + this.closed = true; + this.connected = false; + this.stopHeartbeatTimeout(); + console.error('Connection to Tunnelbroker closed'); + onClose(this.shouldNotifyPrimaryAfterReopening, this.primaryDeviceID); + }); + + socket.on('error', (error: Error) => { + console.error('Tunnelbroker socket error:', error.message); + }); + + socket.on('message', this.onMessage); + + this.ws = socket; + } + + onOpen: (socket: WebSocket, deviceID: string) => void = ( + socket, + deviceID, + ) => { + if (this.closed) { + return; + } + + if (this.identityInfo) { + const initMessage: ConnectionInitializationMessage = { + type: 'ConnectionInitializationMessage', + deviceID, + accessToken: this.identityInfo.accessToken, + userID: this.identityInfo.userId, + deviceType: 'keyserver', + }; + socket.send(JSON.stringify(initMessage)); + } else { + const initMessage: AnonymousInitializationMessage = { + type: 'AnonymousInitializationMessage', + deviceID, + deviceType: 'keyserver', + }; + socket.send(JSON.stringify(initMessage)); + } }; - createAndMaintainTunnelbrokerWebsocketBase(tbConnectionInfo.url, initMessage); -} + onMessage: (event: ArrayBuffer) => Promise = async ( + event: ArrayBuffer, + ) => { + let rawMessage; + try { + rawMessage = JSON.parse(event.toString()); + } catch (e) { + console.error('error while parsing Tunnelbroker message:', e.message); + return; + } -async function createAndMaintainAnonymousTunnelbrokerWebsocket( - encryptionKey: string, -) { - const [deviceID, tbConnectionInfo] = await Promise.all([ - getContentSigningKey(), - getTBConnectionInfo(), - ]); + if (!tunnelbrokerMessageValidator.is(rawMessage)) { + console.error('invalid TunnelbrokerMessage: ', rawMessage.toString()); + return; + } + const message: TunnelbrokerMessage = rawMessage; + + this.resetHeartbeatTimeout(); + + if ( + message.type === + tunnelbrokerMessageTypes.CONNECTION_INITIALIZATION_RESPONSE + ) { + if (message.status.type === 'Success' && !this.connected) { + this.connected = true; + console.info( + this.identityInfo + ? 'session with Tunnelbroker created' + : 'anonymous session with Tunnelbroker created', + ); + if (!this.shouldNotifyPrimary) { + return; + } + const { primaryDeviceID } = this; + invariant( + primaryDeviceID, + 'Primary device ID is not set but should be', + ); + const payload = await this.encodeQRAuthMessage({ + type: qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS, + requestBackupKeys: false, + }); + if (!payload) { + this.closeConnection(); + return; + } + await this.sendMessage({ + deviceID: primaryDeviceID, + payload: JSON.stringify(payload), + }); + } else if (message.status.type === 'Success' && this.connected) { + console.info( + 'received ConnectionInitializationResponse with status: Success for already connected socket', + ); + } else { + this.connected = false; + console.error( + 'creating session with Tunnelbroker error:', + message.status.data, + ); + } + } else if (message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE) { + const confirmation: MessageReceiveConfirmation = { + type: tunnelbrokerMessageTypes.MESSAGE_RECEIVE_CONFIRMATION, + messageIDs: [message.messageID], + }; + this.ws.send(JSON.stringify(confirmation)); + + const { payload } = message; + try { + const messageToKeyserver = JSON.parse(payload); + if (qrCodeAuthMessageValidator.is(messageToKeyserver)) { + const request: QRCodeAuthMessage = messageToKeyserver; + const [qrCodeAuthMessage, rustAPI, accountInfo] = await Promise.all([ + this.parseQRCodeAuthMessage(request), + getRustAPI(), + fetchOlmAccount('content'), + ]); + if ( + !qrCodeAuthMessage || + qrCodeAuthMessage.type !== + qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS + ) { + return; + } + const { primaryDeviceID, userID } = qrCodeAuthMessage; + this.primaryDeviceID = primaryDeviceID; + + const [nonce, deviceKeyUpload] = await Promise.all([ + rustAPI.generateNonce(), + getNewDeviceKeyUpload(), + ]); + const signedIdentityKeysBlob = { + payload: deviceKeyUpload.keyPayload, + signature: deviceKeyUpload.keyPayloadSignature, + }; + const nonceSignature = accountInfo.account.sign(nonce); + + const identityInfo = await rustAPI.uploadSecondaryDeviceKeysAndLogIn( + userID, + nonce, + nonceSignature, + signedIdentityKeysBlob, + deviceKeyUpload.contentPrekey, + deviceKeyUpload.contentPrekeySignature, + deviceKeyUpload.notifPrekey, + deviceKeyUpload.notifPrekeySignature, + deviceKeyUpload.contentOneTimeKeys, + deviceKeyUpload.notifOneTimeKeys, + ); + await Promise.all([ + markPrekeysAsPublished(), + saveIdentityInfo(identityInfo), + ]); + this.shouldNotifyPrimaryAfterReopening = true; + this.closeConnection(); + } else if (refreshKeysRequestValidator.is(messageToKeyserver)) { + const request: RefreshKeyRequest = messageToKeyserver; + this.debouncedRefreshOneTimeKeys(request.numberOfKeys); + } + } catch (e) { + console.error( + 'error while processing message to keyserver:', + e.message, + ); + } + } else if ( + message.type === tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST_STATUS + ) { + for (const status: MessageSentStatus of message.clientMessageIDs) { + if (status.type === 'Success') { + if (this.promises[status.data]) { + this.promises[status.data].resolve(); + delete this.promises[status.data]; + } else { + console.log( + 'received successful response for a non-existent request', + ); + } + } else if (status.type === 'Error') { + if (this.promises[status.data.id]) { + this.promises[status.data.id].reject(status.data.error); + delete this.promises[status.data.id]; + } else { + console.log('received error response for a non-existent request'); + } + } else if (status.type === 'SerializationError') { + console.error('SerializationError for message: ', status.data); + } else if (status.type === 'InvalidRequest') { + console.log('Tunnelbroker recorded InvalidRequest'); + } + } + } else if (message.type === tunnelbrokerMessageTypes.HEARTBEAT) { + const heartbeat: Heartbeat = { + type: tunnelbrokerMessageTypes.HEARTBEAT, + }; + this.ws.send(JSON.stringify(heartbeat)); + } + }; - const initMessage: AnonymousInitializationMessage = { - type: 'AnonymousInitializationMessage', - deviceID: deviceID, - deviceType: 'keyserver', + refreshOneTimeKeys: (numberOfKeys: number) => void = numberOfKeys => { + const oldOneTimeKeysPromise = this.oneTimeKeysPromise; + this.oneTimeKeysPromise = (async () => { + await oldOneTimeKeysPromise; + await uploadNewOneTimeKeys(numberOfKeys); + })(); }; - createAndMaintainTunnelbrokerWebsocketBase( - tbConnectionInfo.url, - initMessage, - encryptionKey, + debouncedRefreshOneTimeKeys: (numberOfKeys: number) => void = _debounce( + this.refreshOneTimeKeys, + 100, + { leading: true, trailing: true }, ); -} -function createAndMaintainTunnelbrokerWebsocketBase( - url: string, - initMessage: ConnectionInitializationMessage | AnonymousInitializationMessage, - encryptionKey?: string, -) { - const createNewTunnelbrokerSocket = () => { - new TunnelbrokerSocket( - url, - initMessage, - async () => { - await sleep(clientTunnelbrokerSocketReconnectDelay); - createNewTunnelbrokerSocket(); - }, - encryptionKey, + sendMessage: (message: TunnelbrokerClientMessageToDevice) => Promise = ( + message: TunnelbrokerClientMessageToDevice, + ) => { + if (!this.connected) { + throw new Error('Tunnelbroker not connected'); + } + const clientMessageID = uuid.v4(); + const messageToDevice: MessageToDeviceRequest = { + type: tunnelbrokerMessageTypes.MESSAGE_TO_DEVICE_REQUEST, + clientMessageID, + deviceID: message.deviceID, + payload: message.payload, + }; + + return new Promise((resolve, reject) => { + this.promises[clientMessageID] = { + resolve, + reject, + }; + this.ws.send(JSON.stringify(messageToDevice)); + }); + }; + + stopHeartbeatTimeout() { + if (this.heartbeatTimeoutID) { + clearTimeout(this.heartbeatTimeoutID); + this.heartbeatTimeoutID = null; + } + } + + resetHeartbeatTimeout() { + this.stopHeartbeatTimeout(); + this.heartbeatTimeoutID = setTimeout(() => { + this.ws.close(); + this.connected = false; + }, tunnelbrokerHeartbeatTimeout); + } + + closeConnection() { + this.ws.close(); + this.connected = false; + } + + parseQRCodeAuthMessage: ( + message: QRCodeAuthMessage, + ) => Promise = async message => { + const encryptionKey = this.qrAuthEncryptionKey; + if (!encryptionKey) { + return null; + } + const encryptedData = Buffer.from(message.encryptedContent, 'base64'); + const decryptedData = await decrypt( + hexToUintArray(encryptionKey), + new Uint8Array(encryptedData), ); + const payload = convertBytesToObj(decryptedData); + if (!qrCodeAuthMessagePayloadValidator.is(payload)) { + return null; + } + + return payload; + }; + + encodeQRAuthMessage: ( + payload: QRCodeAuthMessagePayload, + ) => Promise = async payload => { + const encryptionKey = this.qrAuthEncryptionKey; + if (!encryptionKey) { + console.error('Encryption key missing - cannot send QR auth message.'); + return null; + } + + let encryptedContent; + try { + const payloadBytes = convertObjToBytes(payload); + const keyBytes = hexToUintArray(encryptionKey); + const encryptedBytes = await encrypt(keyBytes, payloadBytes); + encryptedContent = Buffer.from(encryptedBytes).toString('base64'); + } catch (e) { + console.error( + 'Error encoding QRCodeAuthMessagePayload:', + getMessageForException(e), + ); + return null; + } + + return { + type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE, + encryptedContent, + }; }; - createNewTunnelbrokerSocket(); } -export { - createAndMaintainTunnelbrokerWebsocket, - createAndMaintainAnonymousTunnelbrokerWebsocket, -}; +export { createAndMaintainTunnelbrokerWebsocket }; diff --git a/keyserver/src/utils/olm-utils.js b/keyserver/src/utils/olm-utils.js index 206132c8c..eaa560598 100644 --- a/keyserver/src/utils/olm-utils.js +++ b/keyserver/src/utils/olm-utils.js @@ -1,327 +1,339 @@ // @flow import olm from '@commapp/olm'; import type { Account as OlmAccount, Utility as OlmUtility, Session as OlmSession, } from '@commapp/olm'; import invariant from 'invariant'; import { getRustAPI } from 'rust-node-addon'; import uuid from 'uuid'; import { getOneTimeKeyValuesFromBlob } from 'lib/shared/crypto-utils.js'; import { olmEncryptedMessageTypes } from 'lib/types/crypto-types.js'; import type { IdentityNewDeviceKeyUpload } from 'lib/types/identity-service-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { getAccountPrekeysSet, shouldForgetPrekey, shouldRotatePrekey, retrieveAccountKeysSet, } from 'lib/utils/olm-utils.js'; import { fetchCallUpdateOlmAccount, fetchOlmAccount, } from '../updaters/olm-account-updater.js'; import { verifyUserLoggedIn } from '../user/login.js'; export type PickledOlmAccount = { +picklingKey: string, +pickledAccount: string, }; async function createPickledOlmAccount(): Promise { await olm.init(); const account = new olm.Account(); account.create(); const picklingKey = uuid.v4(); const pickledAccount = account.pickle(picklingKey); return { picklingKey: picklingKey, pickledAccount: pickledAccount, }; } async function unpickleOlmAccount( pickledOlmAccount: PickledOlmAccount, ): Promise { await olm.init(); const account = new olm.Account(); account.unpickle( pickledOlmAccount.picklingKey, pickledOlmAccount.pickledAccount, ); return account; } async function createPickledOlmSession( account: OlmAccount, accountPicklingKey: string, initialEncryptedMessage: string, theirCurve25519Key?: string, ): Promise { await olm.init(); const session = new olm.Session(); if (theirCurve25519Key) { session.create_inbound_from( account, theirCurve25519Key, initialEncryptedMessage, ); } else { session.create_inbound(account, initialEncryptedMessage); } account.remove_one_time_keys(session); session.decrypt(olmEncryptedMessageTypes.PREKEY, initialEncryptedMessage); return session.pickle(accountPicklingKey); } async function unpickleOlmSession( pickledSession: string, picklingKey: string, ): Promise { await olm.init(); const session = new olm.Session(); session.unpickle(picklingKey, pickledSession); return session; } let cachedOLMUtility: OlmUtility; function getOlmUtility(): OlmUtility { if (cachedOLMUtility) { return cachedOLMUtility; } cachedOLMUtility = new olm.Utility(); return cachedOLMUtility; } +async function markPrekeysAsPublished(): Promise { + await Promise.all([ + fetchCallUpdateOlmAccount('content', (contentAccount: OlmAccount) => { + contentAccount.mark_prekey_as_published(); + }), + fetchCallUpdateOlmAccount('notifications', (notifAccount: OlmAccount) => { + notifAccount.mark_prekey_as_published(); + }), + ]); +} + async function getNewDeviceKeyUpload(): Promise { let contentIdentityKeys: string; let contentOneTimeKeys: $ReadOnlyArray; let contentPrekey: string; let contentPrekeySignature: string; let notifIdentityKeys: string; let notifOneTimeKeys: $ReadOnlyArray; let notifPrekey: string; let notifPrekeySignature: string; let contentAccountInfo: OlmAccount; await Promise.all([ fetchCallUpdateOlmAccount('content', (contentAccount: OlmAccount) => { const { identityKeys, oneTimeKeys, prekey, prekeySignature } = retrieveAccountKeysSet(contentAccount); contentIdentityKeys = identityKeys; contentOneTimeKeys = oneTimeKeys; contentPrekey = prekey; contentPrekeySignature = prekeySignature; contentAccountInfo = contentAccount; contentAccount.mark_keys_as_published(); }), fetchCallUpdateOlmAccount('notifications', (notifAccount: OlmAccount) => { const { identityKeys, oneTimeKeys, prekey, prekeySignature } = retrieveAccountKeysSet(notifAccount); notifIdentityKeys = identityKeys; notifOneTimeKeys = oneTimeKeys; notifPrekey = prekey; notifPrekeySignature = prekeySignature; notifAccount.mark_keys_as_published(); }), ]); invariant( contentIdentityKeys, 'content identity keys not set after fetchCallUpdateOlmAccount', ); invariant( notifIdentityKeys, 'notif identity keys not set after fetchCallUpdateOlmAccount', ); invariant( contentPrekey, 'content prekey not set after fetchCallUpdateOlmAccount', ); invariant( notifPrekey, 'notif prekey not set after fetchCallUpdateOlmAccount', ); invariant( contentPrekeySignature, 'content prekey signature not set after fetchCallUpdateOlmAccount', ); invariant( notifPrekeySignature, 'notif prekey signature not set after fetchCallUpdateOlmAccount', ); invariant( contentOneTimeKeys, 'content one-time keys not set after fetchCallUpdateOlmAccount', ); invariant( notifOneTimeKeys, 'notif one-time keys not set after fetchCallUpdateOlmAccount', ); invariant( contentAccountInfo, 'content account info not set after fetchCallUpdateOlmAccount', ); const identityKeysBlob = { primaryIdentityPublicKeys: JSON.parse(contentIdentityKeys), notificationIdentityPublicKeys: JSON.parse(notifIdentityKeys), }; const keyPayload = JSON.stringify(identityKeysBlob); const keyPayloadSignature = contentAccountInfo.sign(keyPayload); return { keyPayload, keyPayloadSignature, contentPrekey, contentPrekeySignature, notifPrekey, notifPrekeySignature, contentOneTimeKeys, notifOneTimeKeys, }; } async function uploadNewOneTimeKeys(numberOfKeys: number) { const [rustAPI, identityInfo, deviceID] = await Promise.all([ getRustAPI(), verifyUserLoggedIn(), getContentSigningKey(), ]); if (!identityInfo) { throw new ServerError('missing_identity_info'); } let contentOneTimeKeys: ?$ReadOnlyArray; let notifOneTimeKeys: ?$ReadOnlyArray; await Promise.all([ fetchCallUpdateOlmAccount('content', (contentAccount: OlmAccount) => { contentAccount.generate_one_time_keys(numberOfKeys); contentOneTimeKeys = getOneTimeKeyValuesFromBlob( contentAccount.one_time_keys(), ); contentAccount.mark_keys_as_published(); }), fetchCallUpdateOlmAccount('notifications', (notifAccount: OlmAccount) => { notifAccount.generate_one_time_keys(numberOfKeys); notifOneTimeKeys = getOneTimeKeyValuesFromBlob( notifAccount.one_time_keys(), ); notifAccount.mark_keys_as_published(); }), ]); invariant( contentOneTimeKeys, 'content one-time keys not set after fetchCallUpdateOlmAccount', ); invariant( notifOneTimeKeys, 'notif one-time keys not set after fetchCallUpdateOlmAccount', ); await rustAPI.uploadOneTimeKeys( identityInfo.userId, deviceID, identityInfo.accessToken, contentOneTimeKeys, notifOneTimeKeys, ); } async function getContentSigningKey(): Promise { const accountInfo = await fetchOlmAccount('content'); return JSON.parse(accountInfo.account.identity_keys()).ed25519; } function validateAndUploadAccountPrekeys( contentAccount: OlmAccount, notifAccount: OlmAccount, ): Promise { if (contentAccount.unpublished_prekey()) { return publishPrekeysToIdentity(contentAccount, notifAccount); } // Since keys are rotated synchronously, only check validity of one if (shouldRotatePrekey(contentAccount)) { contentAccount.generate_prekey(); notifAccount.generate_prekey(); return publishPrekeysToIdentity(contentAccount, notifAccount); } if (shouldForgetPrekey(contentAccount)) { contentAccount.forget_old_prekey(); notifAccount.forget_old_prekey(); } return Promise.resolve(); } async function publishPrekeysToIdentity( contentAccount: OlmAccount, notifAccount: OlmAccount, ): Promise { const rustAPIPromise = getRustAPI(); const verifyUserLoggedInPromise = verifyUserLoggedIn(); const deviceID = JSON.parse(contentAccount.identity_keys()).ed25519; const { prekey: contentPrekey, prekeySignature: contentPrekeySignature } = getAccountPrekeysSet(contentAccount); const { prekey: notifPrekey, prekeySignature: notifPrekeySignature } = getAccountPrekeysSet(notifAccount); if (!contentPrekeySignature || !notifPrekeySignature) { console.warn('Unable to create valid signature for a prekey'); return; } const [rustAPI, identityInfo] = await Promise.all([ rustAPIPromise, verifyUserLoggedInPromise, ]); if (!identityInfo) { console.warn( 'Attempted to refresh prekeys before registering with Identity service', ); return; } await rustAPI.publishPrekeys( identityInfo.userId, deviceID, identityInfo.accessToken, contentPrekey, contentPrekeySignature, notifPrekey, notifPrekeySignature, ); contentAccount.mark_prekey_as_published(); notifAccount.mark_prekey_as_published(); } export { createPickledOlmAccount, createPickledOlmSession, getOlmUtility, unpickleOlmAccount, unpickleOlmSession, uploadNewOneTimeKeys, getContentSigningKey, validateAndUploadAccountPrekeys, publishPrekeysToIdentity, getNewDeviceKeyUpload, + markPrekeysAsPublished, };