diff --git a/keyserver/src/keyserver.js b/keyserver/src/keyserver.js index 0725ea7d0..f83b4d697 100644 --- a/keyserver/src/keyserver.js +++ b/keyserver/src/keyserver.js @@ -1,294 +1,299 @@ // @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 } from './socket/tunnelbroker.js'; +import { + createAndMaintainTunnelbrokerWebsocket, + createAndMaintainAnonymousTunnelbrokerWebsocket, +} 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); } - // 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), - ); - 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 (shouldDisplayQRCodeInTerminal) { try { const aes256Key = crypto.randomBytes(32).toString('hex'); const ed25519Key = await getContentSigningKey(); + await createAndMaintainAnonymousTunnelbrokerWebsocket(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), + ); + 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 index bdac75fbd..04dc73359 100644 --- a/keyserver/src/socket/tunnelbroker-socket.js +++ b/keyserver/src/socket/tunnelbroker-socket.js @@ -1,217 +1,273 @@ // @flow import _debounce from 'lodash/debounce.js'; 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 { ConnectionInitializationMessage } from 'lib/types/tunnelbroker/session-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 { decrypt } from '../utils/aes-crypto-utils.js'; import { uploadNewOneTimeKeys } 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, + 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('session with Tunnelbroker created'); + 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 (refreshKeysRequestValidator.is(messageToKeyserver)) { + if (qrCodeAuthMessageValidator.is(messageToKeyserver)) { + const request: QRCodeAuthMessage = messageToKeyserver; + const qrCodeAuthMessage = await this.parseQRCodeAuthMessage(request); + if ( + !qrCodeAuthMessage || + qrCodeAuthMessage.type !== + qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS + ) { + return; + } + } 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 4f9fbb02a..5d92c1ef4 100644 --- a/keyserver/src/socket/tunnelbroker.js +++ b/keyserver/src/socket/tunnelbroker.js @@ -1,57 +1,97 @@ // @flow import { clientTunnelbrokerSocketReconnectDelay } from 'lib/shared/timeouts.js'; -import type { ConnectionInitializationMessage } from 'lib/types/tunnelbroker/session-types.js'; +import type { + ConnectionInitializationMessage, + AnonymousInitializationMessage, +} from 'lib/types/tunnelbroker/session-types.js'; import { getCommConfig } from 'lib/utils/comm-config.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'; 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, ) { const [deviceID, tbConnectionInfo] = await Promise.all([ getContentSigningKey(), getTBConnectionInfo(), ]); const initMessage: ConnectionInitializationMessage = { type: 'ConnectionInitializationMessage', deviceID: deviceID, accessToken: identityInfo.accessToken, userID: identityInfo.userId, deviceType: 'keyserver', }; + createAndMaintainTunnelbrokerWebsocketBase(tbConnectionInfo.url, initMessage); +} + +async function createAndMaintainAnonymousTunnelbrokerWebsocket( + encryptionKey: string, +) { + const [deviceID, tbConnectionInfo] = await Promise.all([ + getContentSigningKey(), + getTBConnectionInfo(), + ]); + + const initMessage: AnonymousInitializationMessage = { + type: 'AnonymousInitializationMessage', + deviceID: deviceID, + deviceType: 'keyserver', + }; + + createAndMaintainTunnelbrokerWebsocketBase( + tbConnectionInfo.url, + initMessage, + encryptionKey, + ); +} + +function createAndMaintainTunnelbrokerWebsocketBase( + url: string, + initMessage: ConnectionInitializationMessage | AnonymousInitializationMessage, + encryptionKey?: string, +) { const createNewTunnelbrokerSocket = () => { - new TunnelbrokerSocket(tbConnectionInfo.url, initMessage, async () => { - await sleep(clientTunnelbrokerSocketReconnectDelay); - createNewTunnelbrokerSocket(); - }); + new TunnelbrokerSocket( + url, + initMessage, + async () => { + await sleep(clientTunnelbrokerSocketReconnectDelay); + createNewTunnelbrokerSocket(); + }, + encryptionKey, + ); }; createNewTunnelbrokerSocket(); } -export { createAndMaintainTunnelbrokerWebsocket }; +export { + createAndMaintainTunnelbrokerWebsocket, + createAndMaintainAnonymousTunnelbrokerWebsocket, +}; diff --git a/lib/utils/conversion-utils.js b/lib/utils/conversion-utils.js index dc24bae69..ed52540fd 100644 --- a/lib/utils/conversion-utils.js +++ b/lib/utils/conversion-utils.js @@ -1,161 +1,177 @@ // @flow import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import type { TInterface, TType } from 'tcomb'; import { convertIDToNewSchema } from './migration-utils.js'; import { assertWithValidator, tID, tUserID } from './validation-utils.js'; import { getPendingThreadID, parsePendingThreadID, } from '../shared/thread-utils.js'; function convertServerIDsToClientIDs( serverPrefixID: string, outputValidator: TType, data: T, ): T { const conversionFunction = (id: string) => { if (id.indexOf('|') !== -1) { console.warn(`Server id '${id}' already has a prefix`); return id; } return convertIDToNewSchema(id, serverPrefixID); }; return convertObject(outputValidator, data, [tID], conversionFunction); } function convertClientIDsToServerIDs( serverPrefixID: string, outputValidator: TType, data: T, ): T { const prefix = serverPrefixID + '|'; const conversionFunction = (id: string) => { if (id.startsWith(prefix)) { return id.substr(prefix.length); } const pendingIDContents = parsePendingThreadID(id); if (!pendingIDContents) { throw new Error('invalid_client_id_prefix'); } if (!pendingIDContents.sourceMessageID) { return id; } return getPendingThreadID( pendingIDContents.threadType, pendingIDContents.memberIDs, pendingIDContents.sourceMessageID.substr(prefix.length), ); }; return convertObject(outputValidator, data, [tID], conversionFunction); } function extractUserIDsFromPayload( outputValidator: TType, data: T, ): $ReadOnlyArray { const result = new Set(); const conversionFunction = (id: string) => { result.add(id); return id; }; convertObject(outputValidator, data, [tUserID], conversionFunction); return [...result]; } function convertObject( validator: TType, input: I, typesToConvert: $ReadOnlyArray>, conversionFunction: T => T, ): I { if (input === null || input === undefined) { return input; } // While they should be the same runtime object, // `TValidator` is `TType` and `validator` is `TType`. // Having them have different types allows us to use `assertWithValidator` // to change `input` flow type const TValidator = typesToConvert[typesToConvert.indexOf(validator)]; if (TValidator && TValidator.is(input)) { const TInput = assertWithValidator(input, TValidator); const converted = conversionFunction(TInput); return assertWithValidator(converted, validator); } if (validator.meta.kind === 'maybe' || validator.meta.kind === 'subtype') { return convertObject( validator.meta.type, input, typesToConvert, conversionFunction, ); } if (validator.meta.kind === 'interface' && typeof input === 'object') { const recastValidator: TInterface = (validator: any); const result: { [string]: mixed } = {}; for (const key in input) { const innerValidator = recastValidator.meta.props[key]; result[key] = convertObject( innerValidator, input[key], typesToConvert, conversionFunction, ); } return assertWithValidator(result, recastValidator); } if (validator.meta.kind === 'union') { for (const innerValidator of validator.meta.types) { if (innerValidator.is(input)) { return convertObject( innerValidator, input, typesToConvert, conversionFunction, ); } } return input; } if (validator.meta.kind === 'list' && Array.isArray(input)) { const innerValidator = validator.meta.type; return (input.map(value => convertObject(innerValidator, value, typesToConvert, conversionFunction), ): any); } if (validator.meta.kind === 'dict' && typeof input === 'object') { const domainValidator = validator.meta.domain; const codomainValidator = validator.meta.codomain; if (typesToConvert.includes(domainValidator)) { input = _mapKeys(key => conversionFunction(key))(input); } return _mapValues(value => convertObject( codomainValidator, value, typesToConvert, conversionFunction, ), )(input); } return input; } +// NOTE: This function should not be called from native. On native, we should +// use `convertObjToBytes` in native/backup/conversion-utils.js instead. +function convertObjToBytes(obj: T): Uint8Array { + const objStr = JSON.stringify(obj); + return new TextEncoder().encode(objStr ?? ''); +} + +// NOTE: This function should not be called from native. On native, we should +// use `convertBytesToObj` in native/backup/conversion-utils.js instead. +function convertBytesToObj(bytes: Uint8Array): T { + const str = new TextDecoder().decode(bytes.buffer); + return JSON.parse(str); +} + export { convertClientIDsToServerIDs, convertServerIDsToClientIDs, extractUserIDsFromPayload, convertObject, + convertObjToBytes, + convertBytesToObj, }; diff --git a/lib/utils/conversion-utils.test.js b/lib/utils/conversion-utils.test.js index 7d9418242..94359d0c0 100644 --- a/lib/utils/conversion-utils.test.js +++ b/lib/utils/conversion-utils.test.js @@ -1,121 +1,134 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import { extractUserIDsFromPayload, convertServerIDsToClientIDs, convertClientIDsToServerIDs, + convertBytesToObj, + convertObjToBytes, } from './conversion-utils.js'; import { tShape, tID, idSchemaRegex } from './validation-utils.js'; import { fetchMessageInfosResponseValidator } from '../types/validators/message-validators.js'; type ComplexType = { +ids: { +[string]: $ReadOnlyArray } }; describe('id conversion', () => { it('should convert string id', () => { const validator = tShape<{ +id: string }>({ id: tID }); const serverData = { id: '1' }; const clientData = { id: '0|1' }; expect( convertServerIDsToClientIDs('0', validator, serverData), ).toStrictEqual(clientData); expect( convertClientIDsToServerIDs('0', validator, clientData), ).toStrictEqual(serverData); }); it('should convert a complex type', () => { const validator = tShape({ ids: t.dict(tID, t.list(tID)) }); const serverData = { ids: { '1': ['11', '12'], '2': [], '3': ['13'] } }; const clientData = { ids: { '0|1': ['0|11', '0|12'], '0|2': [], '0|3': ['0|13'] }, }; expect( convertServerIDsToClientIDs('0', validator, serverData), ).toStrictEqual(clientData); expect( convertClientIDsToServerIDs('0', validator, clientData), ).toStrictEqual(serverData); }); it('should convert a refinement', () => { const validator = t.refinement(tID, () => true); const serverData = '1'; const clientData = '0|1'; expect( convertServerIDsToClientIDs('0', validator, serverData), ).toStrictEqual(clientData); expect( convertClientIDsToServerIDs('0', validator, clientData), ).toStrictEqual(serverData); }); }); describe('idSchemaRegex tests', () => { it('should capture ids', () => { const regex = new RegExp(`^(${idSchemaRegex})$`); const ids = ['123|123', '0|0', '123', '0']; for (const id of ids) { const result = regex.exec(id); expect(result).not.toBeNull(); invariant(result, 'result is not null'); const matches = [...result]; expect(matches).toHaveLength(2); expect(matches[1]).toBe(id); } }); }); describe('Pending ids tests', () => { it('should convert pending ids', () => { const validator = t.list(tID); const serverData = ['pending/sidebar/1', 'pending/type4/1+2+3']; const clientData = ['pending/sidebar/0|1', 'pending/type4/1+2+3']; expect( convertServerIDsToClientIDs('0', validator, serverData), ).toStrictEqual(clientData); expect( convertClientIDsToServerIDs('0', validator, clientData), ).toStrictEqual(serverData); }); }); describe('extractUserIDsFromPayload', () => { it('should extract all user ids from payload', () => { const payload = { rawMessageInfos: [ { type: 0, threadID: '1000', creatorID: '0', time: 0, text: 'test', id: '2000', }, { type: 0, threadID: '1000', creatorID: '1', time: 0, text: 'test', id: '2001', }, ], truncationStatuses: {}, userInfos: { ['100']: { id: '100', username: 'test1' }, ['200']: { id: '200', username: 'test2' }, }, }; expect( extractUserIDsFromPayload(fetchMessageInfosResponseValidator, payload), ).toEqual(['0', '1', '100', '200']); }); }); + +describe('convertObjToBytes and convertBytesToObj', () => { + it('should convert object to byte array and back', () => { + const obj = { hello: 'world', foo: 'bar', a: 2, b: false }; + + const bytes = convertObjToBytes(obj); + const restored = convertBytesToObj(bytes); + + expect(restored).toStrictEqual(obj); + }); +}); diff --git a/web/account/qr-code-login.react.js b/web/account/qr-code-login.react.js index f7a7f90a0..84601bae0 100644 --- a/web/account/qr-code-login.react.js +++ b/web/account/qr-code-login.react.js @@ -1,157 +1,157 @@ // @flow import { QRCodeSVG } from 'qrcode.react'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { qrCodeLinkURL } from 'lib/facts/links.js'; import { useSecondaryDeviceLogIn } from 'lib/hooks/login-hooks.js'; import { useQRAuth } from 'lib/hooks/qr-auth.js'; import { generateKeyCommon } from 'lib/media/aes-crypto-utils-common.js'; import * as AES from 'lib/media/aes-crypto-utils-common.js'; import { hexToUintArray, uintArrayToHexString } from 'lib/media/data-utils.js'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { peerToPeerMessageTypes, type QRCodeAuthMessage, } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js'; import { qrCodeAuthMessagePayloadValidator, type QRCodeAuthMessagePayload, } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js'; +import { + convertBytesToObj, + convertObjToBytes, +} from 'lib/utils/conversion-utils.js'; import { getContentSigningKey } from 'lib/utils/crypto-utils.js'; import { getMessageForException } from 'lib/utils/errors.js'; import css from './qr-code-login.css'; import Alert from '../modals/alert.react.js'; import VersionUnsupportedModal from '../modals/version-unsupported-modal.react.js'; import { base64DecodeBuffer, base64EncodeBuffer, } from '../utils/base64-utils.js'; -import { - convertBytesToObj, - convertObjToBytes, -} from '../utils/conversion-utils.js'; async function composeTunnelbrokerMessage( encryptionKey: string, obj: QRCodeAuthMessagePayload, ): Promise { const objBytes = convertObjToBytes(obj); const keyBytes = hexToUintArray(encryptionKey); const encryptedBytes = await AES.encryptCommon(crypto, keyBytes, objBytes); const encryptedContent = base64EncodeBuffer(encryptedBytes); return { type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE, encryptedContent, }; } async function parseTunnelbrokerMessage( encryptionKey: string, message: QRCodeAuthMessage, ): Promise { const encryptedData = base64DecodeBuffer(message.encryptedContent); const decryptedData = await AES.decryptCommon( crypto, hexToUintArray(encryptionKey), new Uint8Array(encryptedData), ); const payload = convertBytesToObj(decryptedData); if (!qrCodeAuthMessagePayloadValidator.is(payload)) { return null; } return payload; } function QRCodeLogin(): React.Node { const [qrData, setQRData] = React.useState(); const { setUnauthorizedDeviceID } = useTunnelbroker(); const generateQRCode = React.useCallback(async () => { try { const [ed25519, rawAESKey] = await Promise.all([ getContentSigningKey(), generateKeyCommon(crypto), ]); const aesKeyAsHexString: string = uintArrayToHexString(rawAESKey); setUnauthorizedDeviceID(ed25519); setQRData({ deviceID: ed25519, aesKey: aesKeyAsHexString }); } catch (err) { console.error('Failed to generate QR Code:', err); } }, [setUnauthorizedDeviceID]); const { pushModal } = useModalContext(); const logInSecondaryDevice = useSecondaryDeviceLogIn(); const performRegistration = React.useCallback( async (userID: string) => { try { await logInSecondaryDevice(userID); } catch (err) { console.error('Secondary device registration error:', err); const messageForException = getMessageForException(err); if ( messageForException === 'client_version_unsupported' || messageForException === 'Unsupported version' ) { pushModal(); } else { pushModal(Uhh... try again?); } void generateQRCode(); } }, [logInSecondaryDevice, pushModal, generateQRCode], ); React.useEffect(() => { void generateQRCode(); }, [generateQRCode]); const qrCodeURL = React.useMemo( () => (qrData ? qrCodeLinkURL(qrData.aesKey, qrData.deviceID) : undefined), [qrData], ); const qrAuthInput = React.useMemo( () => ({ secondaryDeviceID: qrData?.deviceID, aesKey: qrData?.aesKey, performSecondaryDeviceRegistration: performRegistration, composeMessage: composeTunnelbrokerMessage, processMessage: parseTunnelbrokerMessage, }), [qrData, performRegistration], ); useQRAuth(qrAuthInput); return (
Log in to Comm
Open the Comm app on your phone and scan the QR code below
How to find the scanner:
Go to Profile
Select Linked devices
Click Add on the top right
); } export default QRCodeLogin; diff --git a/web/utils/conversion-utils.js b/web/utils/conversion-utils.js deleted file mode 100644 index 386644486..000000000 --- a/web/utils/conversion-utils.js +++ /dev/null @@ -1,13 +0,0 @@ -// @flow - -function convertObjToBytes(obj: T): Uint8Array { - const objStr = JSON.stringify(obj); - return new TextEncoder().encode(objStr ?? ''); -} - -function convertBytesToObj(bytes: Uint8Array): T { - const str = new TextDecoder().decode(bytes.buffer); - return JSON.parse(str); -} - -export { convertObjToBytes, convertBytesToObj }; diff --git a/web/utils/conversion-utils.test.js b/web/utils/conversion-utils.test.js deleted file mode 100644 index 526255efe..000000000 --- a/web/utils/conversion-utils.test.js +++ /dev/null @@ -1,14 +0,0 @@ -// @flow - -import { convertBytesToObj, convertObjToBytes } from './conversion-utils.js'; - -describe('convertObjToBytes and convertBytesToObj', () => { - it('should convert object to byte array and back', () => { - const obj = { hello: 'world', foo: 'bar', a: 2, b: false }; - - const bytes = convertObjToBytes(obj); - const restored = convertBytesToObj(bytes); - - expect(restored).toStrictEqual(obj); - }); -});