diff --git a/keyserver/src/keyserver.js b/keyserver/src/keyserver.js index 57a1baeea..02dac6daf 100644 --- a/keyserver/src/keyserver.js +++ b/keyserver/src/keyserver.js @@ -1,184 +1,193 @@ // @flow import olm from '@commapp/olm'; import cluster from 'cluster'; import cookieParser from 'cookie-parser'; import express from 'express'; import expressWs from 'express-ws'; import os from 'os'; import './cron/cron.js'; import { migrate } from './database/migrations.js'; import { jsonEndpoints } from './endpoints.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 { multerProcessor, multimediaUploadResponder, uploadDownloadResponder, } from './uploads/uploads.js'; +import { verifyUserLoggedIn } from './user/login.js'; import { initENSCache } from './utils/ens-cache.js'; import { prefetchAllURLFacts, getSquadCalURLFacts, getLandingURLFacts, getCommAppURLFacts, } from './utils/urls.js'; (async () => { await Promise.all([olm.init(), prefetchAllURLFacts(), initENSCache()]); const squadCalBaseRoutePath = getSquadCalURLFacts()?.baseRoutePath; const landingBaseRoutePath = getLandingURLFacts()?.baseRoutePath; const commAppBaseRoutePath = getCommAppURLFacts()?.baseRoutePath; const compiledFolderOptions = process.env.NODE_ENV === 'development' ? undefined : { maxAge: '1y', immutable: true }; 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 { + await verifyUserLoggedIn(); + } catch (e) { + console.warn('failed_identity_login'); + } + const cpuCount = os.cpus().length; for (let i = 0; i < cpuCount; i++) { cluster.fork(); } cluster.on('exit', () => cluster.fork()); } else { const server = express(); expressWs(server); server.use(express.json({ limit: '250mb' })); server.use(cookieParser()); const setupAppRouter = router => { router.use('/images', express.static('images')); router.use('/fonts', express.static('fonts')); router.use('/misc', express.static('misc')); router.use( '/.well-known', express.static( '.well-known', // Necessary for apple-app-site-association file { setHeaders: res => res.setHeader('Content-Type', 'application/json'), }, ), ); router.use( '/compiled', express.static('app_compiled', compiledFolderOptions), ); router.use('/', express.static('icons')); for (const endpoint in jsonEndpoints) { // $FlowFixMe Flow thinks endpoint is string const responder = jsonEndpoints[endpoint]; const expectCookieInvalidation = endpoint === 'log_out'; router.post( `/${endpoint}`, jsonHandler(responder, expectCookieInvalidation), ); } router.get( '/download_error_report/:reportID', downloadHandler(errorReportDownloadResponder), ); router.get( '/upload/:uploadID/:secret', downloadHandler(uploadDownloadResponder), ); router.get('/invite/:secret', inviteResponder); // $FlowFixMe express-ws has side effects that can't be typed router.ws('/ws', onConnection); router.get('/worker/:worker', webWorkerResponder); router.get('*', htmlHandler(websiteResponder)); router.post( '/upload_multimedia', multerProcessor, uploadHandler(multimediaUploadResponder), ); }; // Note - the order of router declarations matters. On prod we have // squadCalBaseRoutePath configured to '/', which means it's a catch-all. If // we call server.use on squadCalRouter first, it will catch all requests // and prevent commAppRouter and landingRouter from working correctly. So we // make sure that squadCalRouter goes last server.get('/invite/:secret', inviteResponder); if (landingBaseRoutePath) { const landingRouter = express.Router(); 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 (commAppBaseRoutePath) { const commAppRouter = express.Router(); setupAppRouter(commAppRouter); server.use(commAppBaseRoutePath, commAppRouter); } if (squadCalBaseRoutePath) { const squadCalRouter = express.Router(); setupAppRouter(squadCalRouter); server.use(squadCalBaseRoutePath, squadCalRouter); } 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/updaters/olm-account-updater.js b/keyserver/src/updaters/olm-account-updater.js index 6fe744c55..5a918e925 100644 --- a/keyserver/src/updaters/olm-account-updater.js +++ b/keyserver/src/updaters/olm-account-updater.js @@ -1,112 +1,112 @@ // @flow import type { Account as OlmAccount } from '@commapp/olm'; import { ServerError } from 'lib/utils/errors.js'; import sleep from 'lib/utils/sleep.js'; import { SQL, dbQuery } from '../database/database.js'; import { unpickleOlmAccount } from '../utils/olm-utils.js'; const maxOlmAccountUpdateRetriesCount = 5; const olmAccountUpdateRetryDelay = 200; async function fetchCallUpdateOlmAccount( olmAccountType: 'content' | 'notifications', - callback: (account: OlmAccount, picklingKey: string) => Promise, + callback: (account: OlmAccount, picklingKey: string) => Promise | T, ): Promise { const isContent = olmAccountType === 'content'; let retriesLeft = maxOlmAccountUpdateRetriesCount; while (retriesLeft > 0) { const [olmAccountResult] = await dbQuery( SQL` SELECT version, pickling_key, pickled_olm_account FROM olm_accounts WHERE is_content = ${isContent} `, ); if (olmAccountResult.length === 0) { throw new ServerError('missing_olm_account'); } const [ { version, pickling_key: picklingKey, pickled_olm_account: pickledAccount, }, ] = olmAccountResult; const account = await unpickleOlmAccount({ picklingKey, pickledAccount, }); const result = await callback(account, picklingKey); const updatedAccount = account.pickle(picklingKey); const [transactionResult] = await dbQuery( SQL` START TRANSACTION; SELECT version INTO @currentVersion FROM olm_accounts WHERE is_content = ${isContent} FOR UPDATE; UPDATE olm_accounts SET pickled_olm_account = ${updatedAccount}, version = ${version} + 1 WHERE version = ${version} AND is_content = ${isContent}; COMMIT; SELECT @currentVersion AS versionOnUpdateAttempt; `, { multipleStatements: true }, ); const selectResult = transactionResult.pop(); const [{ versionOnUpdateAttempt }] = selectResult; if (version === versionOnUpdateAttempt) { return result; } retriesLeft = retriesLeft - 1; await sleep(olmAccountUpdateRetryDelay); } throw new ServerError('max_olm_account_update_retry_exceeded'); } async function fetchOlmAccount( olmAccountType: 'content' | 'notifications', ): Promise<{ account: OlmAccount, picklingKey: string, }> { const isContent = olmAccountType === 'content'; const [olmAccountResult] = await dbQuery( SQL` SELECT pickling_key, pickled_olm_account FROM olm_accounts WHERE is_content = ${isContent} `, ); if (olmAccountResult.length === 0) { throw new ServerError('missing_olm_account'); } const picklingKey = olmAccountResult[0].pickling_key; const pickledAccount = olmAccountResult[0].pickled_olm_account; const account = await unpickleOlmAccount({ picklingKey, pickledAccount, }); return { account, picklingKey }; } export { fetchCallUpdateOlmAccount, fetchOlmAccount }; diff --git a/keyserver/src/user/login.js b/keyserver/src/user/login.js new file mode 100644 index 000000000..74640efb7 --- /dev/null +++ b/keyserver/src/user/login.js @@ -0,0 +1,199 @@ +// @flow + +import type { Account as OlmAccount } from '@commapp/olm'; +import type { QueryResults } from 'mysql'; +import { getRustAPI } from 'rust-node-addon'; + +import type { OLMOneTimeKeys } from 'lib/types/crypto-types'; +import { getCommConfig } from 'lib/utils/comm-config.js'; +import { ServerError } from 'lib/utils/errors.js'; +import { values } from 'lib/utils/objects.js'; + +import { SQL, dbQuery } from '../database/database.js'; +import { getMessageForException } from '../responders/utils.js'; +import { fetchCallUpdateOlmAccount } from '../updaters/olm-account-updater.js'; +import { validateAccountPrekey } from '../utils/olm-utils.js'; + +type UserCredentials = { +username: string, +password: string }; +type IdentityInfo = { +userId: string, +accessToken: string }; + +const userIDMetadataKey = 'user_id'; +const accessTokenMetadataKey = 'access_token'; + +export type AccountKeysSet = { + +identityKeys: string, + +prekey: string, + +prekeySignature: string, + +oneTimeKey: $ReadOnlyArray, +}; + +function getOneTimeKeyValues(keyBlob: string): $ReadOnlyArray { + const content: OLMOneTimeKeys = JSON.parse(keyBlob); + const keys: $ReadOnlyArray = values(content.curve25519); + return keys; +} + +function retrieveAccountKeysSet(account: OlmAccount): AccountKeysSet { + const identityKeys = account.identity_keys(); + + validateAccountPrekey(account); + const prekeyMap = JSON.parse(account.prekey()).curve25519; + const [prekey] = values(prekeyMap); + const prekeySignature = account.prekey_signature(); + + if (!prekeySignature || !prekey) { + throw new ServerError('invalid_prekey'); + } + + if (getOneTimeKeyValues(account.one_time_keys()).length < 10) { + account.generate_one_time_keys(10); + } + + const oneTimeKey = getOneTimeKeyValues(account.one_time_keys()); + + return { identityKeys, oneTimeKey, prekey, prekeySignature }; +} + +// After register or login is successful +function markKeysAsPublished(account: OlmAccount) { + account.mark_prekey_as_published(); + account.mark_keys_as_published(); +} + +async function fetchIdentityInfo(): Promise { + const versionQuery = SQL` + SELECT data + FROM metadata + WHERE name IN (${userIDMetadataKey}, ${accessTokenMetadataKey}) + `; + + const [[userId, accessToken]] = await dbQuery(versionQuery); + if (!userId || !accessToken) { + return null; + } + return { userId, accessToken }; +} + +function saveIdentityInfo(userInfo: IdentityInfo): Promise { + const updateQuery = SQL` + REPLACE INTO metadata (name, data) + VALUES (${userIDMetadataKey}, ${userInfo.userId}), + (${accessTokenMetadataKey}, ${userInfo.accessToken}) + `; + + return dbQuery(updateQuery); +} + +async function verifyUserLoggedIn(): Promise { + const result = await fetchIdentityInfo(); + + if (result) { + return result; + } + + const identityInfo = await registerOrLogin(); + await saveIdentityInfo(identityInfo); + return identityInfo; +} + +async function registerOrLogin(): Promise { + const rustAPIPromise = getRustAPI(); + + const userInfo = await getCommConfig({ + folder: 'secrets', + name: 'user_credentials', + }); + + if (!userInfo) { + throw new ServerError('missing_user_credentials'); + } + + const { + identityKeys: notificationsIdentityKeys, + prekey: notificationsPrekey, + prekeySignature: notificationsPrekeySignature, + oneTimeKey: notificationsOneTimeKey, + } = await fetchCallUpdateOlmAccount('notifications', retrieveAccountKeysSet); + + const contentAccountCallback = async (account: OlmAccount) => { + const { + identityKeys: contentIdentityKeys, + oneTimeKey, + prekey, + prekeySignature, + } = await retrieveAccountKeysSet(account); + + const identityKeysBlob = { + primaryIdentityPublicKeys: JSON.parse(contentIdentityKeys), + notificationIdentityPublicKeys: JSON.parse(notificationsIdentityKeys), + }; + const identityKeysBlobPayload = JSON.stringify(identityKeysBlob); + const signedIdentityKeysBlob = { + payload: identityKeysBlobPayload, + signature: account.sign(identityKeysBlobPayload), + }; + + return { + signedIdentityKeysBlob, + oneTimeKey, + prekey, + prekeySignature, + }; + }; + + const [ + rustAPI, + { + signedIdentityKeysBlob, + prekey: contentPrekey, + prekeySignature: contentPrekeySignature, + oneTimeKey: contentOneTimeKey, + }, + ] = await Promise.all([ + rustAPIPromise, + fetchCallUpdateOlmAccount('content', contentAccountCallback), + ]); + + try { + const identity_info = await rustAPI.loginUser( + userInfo.username, + userInfo.password, + signedIdentityKeysBlob, + contentPrekey, + contentPrekeySignature, + notificationsPrekey, + notificationsPrekeySignature, + contentOneTimeKey, + notificationsOneTimeKey, + ); + await Promise.all([ + fetchCallUpdateOlmAccount('content', markKeysAsPublished), + fetchCallUpdateOlmAccount('notifications', markKeysAsPublished), + ]); + return identity_info; + } catch (e) { + try { + const identity_info = await rustAPI.registerUser( + userInfo.username, + userInfo.password, + signedIdentityKeysBlob, + contentPrekey, + contentPrekeySignature, + notificationsPrekey, + notificationsPrekeySignature, + contentOneTimeKey, + notificationsOneTimeKey, + ); + await Promise.all([ + fetchCallUpdateOlmAccount('content', markKeysAsPublished), + fetchCallUpdateOlmAccount('notifications', markKeysAsPublished), + ]); + return identity_info; + } catch (err) { + console.warn('Failed to register user: ' + getMessageForException(err)); + throw new ServerError('identity_auth_failed'); + } + } +} + +export { verifyUserLoggedIn }; diff --git a/keyserver/src/utils/olm-utils.js b/keyserver/src/utils/olm-utils.js index 14f6a573d..e525089b3 100644 --- a/keyserver/src/utils/olm-utils.js +++ b/keyserver/src/utils/olm-utils.js @@ -1,109 +1,109 @@ // @flow import olm from '@commapp/olm'; import type { Account as OlmAccount, Utility as OlmUtility, Session as OlmSession, } from '@commapp/olm'; import uuid from 'uuid'; import { olmEncryptedMessageTypes } from 'lib/types/crypto-types.js'; type PickledOlmAccount = { +picklingKey: string, +pickledAccount: string, }; const maxPublishedPrekeyAge = 30 * 24 * 60 * 60 * 1000; const maxOldPrekeyAge = 24 * 60 * 60 * 1000; 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, ): Promise { await olm.init(); const session = new olm.Session(); 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 validateAccountPrekey(account: OlmAccount): Promise { +function validateAccountPrekey(account: OlmAccount) { const currentDate = new Date(); const lastPrekeyPublishDate = new Date(account.last_prekey_publish_time()); const prekeyPublished = !account.unpublished_prekey(); if ( prekeyPublished && currentDate - lastPrekeyPublishDate > maxPublishedPrekeyAge ) { // If there is no prekey or the current prekey is older than month // we need to generate new one. account.generate_prekey(); } if ( prekeyPublished && currentDate - lastPrekeyPublishDate >= maxOldPrekeyAge ) { account.forget_old_prekey(); } } export { createPickledOlmAccount, createPickledOlmSession, getOlmUtility, unpickleOlmAccount, unpickleOlmSession, validateAccountPrekey, };