diff --git a/keyserver/src/socket/tunnelbroker-socket.js b/keyserver/src/socket/tunnelbroker-socket.js index 04dc73359..3d6a5ee16 100644 --- a/keyserver/src/socket/tunnelbroker-socket.js +++ b/keyserver/src/socket/tunnelbroker-socket.js @@ -1,273 +1,306 @@ // @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 } from '../utils/olm-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 = await this.parseQRCodeAuthMessage(request); + 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/utils/olm-utils.js b/keyserver/src/utils/olm-utils.js index 4f83ad96a..f32e347b7 100644 --- a/keyserver/src/utils/olm-utils.js +++ b/keyserver/src/utils/olm-utils.js @@ -1,227 +1,322 @@ // @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 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; } async function validateAndUploadAccountPrekeys( contentAccount: OlmAccount, notifAccount: OlmAccount, ): Promise { // Since keys are rotated synchronously, only check validity of one if (shouldRotatePrekey(contentAccount)) { contentAccount.generate_prekey(); notifAccount.generate_prekey(); await publishPrekeysToIdentity(contentAccount, notifAccount); contentAccount.mark_prekey_as_published(); notifAccount.mark_prekey_as_published(); } if (shouldForgetPrekey(contentAccount)) { contentAccount.forget_old_prekey(); notifAccount.forget_old_prekey(); } } 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, ); } export { createPickledOlmAccount, createPickledOlmSession, getOlmUtility, unpickleOlmAccount, unpickleOlmSession, uploadNewOneTimeKeys, getContentSigningKey, validateAndUploadAccountPrekeys, publishPrekeysToIdentity, + getNewDeviceKeyUpload, };