diff --git a/keyserver/src/cron/cron.js b/keyserver/src/cron/cron.js index 6540ed0af..3af3c450e 100644 --- a/keyserver/src/cron/cron.js +++ b/keyserver/src/cron/cron.js @@ -1,121 +1,129 @@ // @flow +import type { Account as OlmAccount } from '@commapp/olm'; import cluster from 'cluster'; import schedule from 'node-schedule'; import { backupDB } from './backups.js'; import { createDailyUpdatesThread } from './daily-updates.js'; import { updateAndReloadGeoipDB } from './update-geoip-db.js'; import { updateIdentityReservedUsernames } from './update-identity-reserved-usernames.js'; import { deleteOrphanedActivity } from '../deleters/activity-deleters.js'; import { deleteExpiredCookies } from '../deleters/cookie-deleters.js'; import { deleteOrphanedDays } from '../deleters/day-deleters.js'; import { deleteOrphanedEntries } from '../deleters/entry-deleters.js'; import { deleteOrphanedMemberships } from '../deleters/membership-deleters.js'; import { deleteOrphanedMessages } from '../deleters/message-deleters.js'; import { deleteOrphanedNotifs } from '../deleters/notif-deleters.js'; import { deleteOrphanedRevisions } from '../deleters/revision-deleters.js'; import { deleteOrphanedRoles } from '../deleters/role-deleters.js'; import { deleteOrphanedSessions, deleteOldWebSessions, } from '../deleters/session-deleters.js'; import { deleteStaleSIWENonceEntries } from '../deleters/siwe-nonce-deleters.js'; import { deleteInaccessibleThreads } from '../deleters/thread-deleters.js'; import { deleteExpiredUpdates } from '../deleters/update-deleters.js'; import { deleteUnassignedUploads } from '../deleters/upload-deleters.js'; import { fetchCallUpdateOlmAccount } from '../updaters/olm-account-updater.js'; -import { validateAccountPrekey } from '../utils/olm-utils.js'; +import { validateAndUploadAccountPrekeys } from '../utils/olm-utils.js'; if (cluster.isMaster) { schedule.scheduleJob( '30 3 * * *', // every day at 3:30 AM in the keyserver's timezone async () => { try { // Do everything one at a time to reduce load since we're in no hurry, // and since some queries depend on previous ones. await deleteExpiredCookies(); await deleteInaccessibleThreads(); await deleteOrphanedMemberships(); await deleteOrphanedDays(); await deleteOrphanedEntries(); await deleteOrphanedRevisions(); await deleteOrphanedRoles(); await deleteOrphanedMessages(); await deleteOrphanedActivity(); await deleteOrphanedNotifs(); await deleteOrphanedSessions(); await deleteOldWebSessions(); await deleteExpiredUpdates(); await deleteUnassignedUploads(); await deleteStaleSIWENonceEntries(); } catch (e) { console.warn('encountered error while trying to clean database', e); } }, ); schedule.scheduleJob( '0 */4 * * *', // every four hours async () => { try { await backupDB(); } catch (e) { console.warn('encountered error while trying to backup database', e); } }, ); schedule.scheduleJob( '0 3 ? * 0', // every Sunday at 3:00 AM in the keyserver's timezone async () => { try { await updateAndReloadGeoipDB(); } catch (e) { console.warn( 'encountered error while trying to update GeoIP database', e, ); } }, ); schedule.scheduleJob( '0 0 * * *', // every day at midnight in the keyserver's timezone async () => { try { if (process.env.RUN_COMM_TEAM_DEV_SCRIPTS) { // This is a job that the Comm internal team uses await createDailyUpdatesThread(); } } catch (e) { console.warn( 'encountered error while trying to create daily updates thread', e, ); } }, ); schedule.scheduleJob( '0 5 * * *', // every day at 5:00 AM in the keyserver's timezone async () => { try { await updateIdentityReservedUsernames(); } catch (e) { console.warn( 'encountered error while trying to update reserved usernames on ' + 'identity service', e, ); } }, ); schedule.scheduleJob( '0 0 * * *', // every day at midnight in the keyserver's timezone async () => { try { - await fetchCallUpdateOlmAccount('content', validateAccountPrekey); - await fetchCallUpdateOlmAccount('notifications', validateAccountPrekey); + await fetchCallUpdateOlmAccount( + 'content', + (contentAccount: OlmAccount) => + fetchCallUpdateOlmAccount( + 'notifications', + (notifAccount: OlmAccount) => + validateAndUploadAccountPrekeys(contentAccount, notifAccount), + ), + ); } catch (e) { console.warn('encountered error while trying to validate prekeys', e); } }, ); } diff --git a/keyserver/src/utils/olm-utils.js b/keyserver/src/utils/olm-utils.js index 48cdef9d8..855d69863 100644 --- a/keyserver/src/utils/olm-utils.js +++ b/keyserver/src/utils/olm-utils.js @@ -1,210 +1,273 @@ // @flow import olm from '@commapp/olm'; import type { Account as OlmAccount, Utility as OlmUtility, Session as OlmSession, } from '@commapp/olm'; 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 { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { fetchCallUpdateOlmAccount, fetchOlmAccount, } from '../updaters/olm-account-updater.js'; import { fetchIdentityInfo } from '../user/identity.js'; type PickledOlmAccount = { +picklingKey: string, +pickledAccount: string, }; const maxPublishedPrekeyAge = 30 * 24 * 60 * 60 * 1000; const maxOldPrekeyAge = 24 * 60 * 60 * 1000; function getLastPrekeyPublishTime(account: OlmAccount): Date { const olmLastPrekeyPublishTime = account.last_prekey_publish_time(); // Olm uses seconds, while in Node we need milliseconds. return new Date(olmLastPrekeyPublishTime * 1000); } function shouldRotatePrekey(account: OlmAccount): boolean { // Our fork of Olm only remembers two prekeys at a time. // If the new one hasn't been published, then the old one is still active. // In that scenario, we need to avoid rotating the prekey because it will // result in the old active prekey being discarded. if (account.unpublished_prekey()) { return false; } const currentDate = new Date(); const lastPrekeyPublishDate = getLastPrekeyPublishTime(account); return currentDate - lastPrekeyPublishDate >= maxPublishedPrekeyAge; } function shouldForgetPrekey(account: OlmAccount): boolean { // Our fork of Olm only remembers two prekeys at a time. // We have to hold onto the old one until the new one is published. if (account.unpublished_prekey()) { return false; } const currentDate = new Date(); const lastPrekeyPublishDate = getLastPrekeyPublishTime(account); return currentDate - lastPrekeyPublishDate >= maxOldPrekeyAge; } 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; } function validateAccountPrekey(account: OlmAccount) { if (shouldRotatePrekey(account)) { account.generate_prekey(); } if (shouldForgetPrekey(account)) { account.forget_old_prekey(); } } async function uploadNewOneTimeKeys(numberOfKeys: number) { const [rustAPI, identityInfo, deviceID] = await Promise.all([ getRustAPI(), fetchIdentityInfo(), getContentSigningKey(), ]); if (!identityInfo) { throw new ServerError('missing_identity_info'); } await fetchCallUpdateOlmAccount('content', (contentAccount: OlmAccount) => { contentAccount.generate_one_time_keys(numberOfKeys); const contentOneTimeKeys = getOneTimeKeyValuesFromBlob( contentAccount.one_time_keys(), ); return fetchCallUpdateOlmAccount( 'notifications', async (notifAccount: OlmAccount) => { notifAccount.generate_one_time_keys(numberOfKeys); const notifOneTimeKeys = getOneTimeKeyValuesFromBlob( notifAccount.one_time_keys(), ); await rustAPI.uploadOneTimeKeys( identityInfo.userId, deviceID, identityInfo.accessToken, contentOneTimeKeys, notifOneTimeKeys, ); notifAccount.mark_keys_as_published(); contentAccount.mark_keys_as_published(); }, ); }); } async function getContentSigningKey(): Promise { const accountInfo = await fetchOlmAccount('content'); return JSON.parse(accountInfo.account.identity_keys()).ed25519; } function getAccountPrekeysSet(account: OlmAccount): { +prekey: string, +prekeySignature: ?string, } { const prekeyMap = JSON.parse(account.prekey()).curve25519; const [prekey] = values(prekeyMap); const prekeySignature = account.prekey_signature(); return { prekey, prekeySignature }; } +async function validateAndUploadAccountPrekeys( + contentAccount: OlmAccount, + notifAccount: OlmAccount, +): Promise { + // Since keys are rotated synchronously, only check validity of one + if (shouldRotatePrekey(contentAccount)) { + await publishNewPrekeys(contentAccount, notifAccount); + } + if (shouldForgetPrekey(contentAccount)) { + contentAccount.forget_old_prekey(); + notifAccount.forget_old_prekey(); + } +} + +async function publishNewPrekeys( + contentAccount: OlmAccount, + notifAccount: OlmAccount, +): Promise { + const rustAPIPromise = getRustAPI(); + const fetchIdentityInfoPromise = fetchIdentityInfo(); + + const deviceID = JSON.parse(contentAccount.identity_keys()).ed25519; + + contentAccount.generate_prekey(); + const { prekey: contentPrekey, prekeySignature: contentPrekeySignature } = + getAccountPrekeysSet(contentAccount); + + notifAccount.generate_prekey(); + 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, + fetchIdentityInfoPromise, + ]); + + 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, validateAccountPrekey, uploadNewOneTimeKeys, getContentSigningKey, getAccountPrekeysSet, + validateAndUploadAccountPrekeys, };