diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -8,6 +8,7 @@ import { olmEncryptedMessageTypes, type NotificationsOlmDataType, + type PickledOLMAccount, } from 'lib/types/crypto-types.js'; import type { PlainTextWebNotification, @@ -21,6 +22,8 @@ decryptData, encryptData, importJWKKey, + exportKeyToJWK, + generateCryptoKey, } from '../crypto/aes-gcm-crypto-utils.js'; import { initOlm } from '../olm/olm-utils.js'; import { @@ -40,6 +43,11 @@ +staffCanSee: boolean, }; +export type NotificationAccountWithPicklingKey = { + +notificationAccount: olm.Account, + +picklingKey: string, +}; + type DecryptionResult = { +newPendingSessionUpdate: string, +newUpdateCreationTimestamp: number, @@ -63,6 +71,9 @@ '256'; const INDEXED_DB_UNREAD_COUNT_SUFFIX = 'unreadCount'; +const INDEXED_DB_NOTIFS_ACCOUNT_KEY = 'notificationAccount'; +const INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL = + 'notificationAccountEncryptionKey'; async function decryptWebNotification( encryptedNotification: EncryptedWebNotification, @@ -392,6 +403,86 @@ return encryptedNotification; } +// notifications account manipulation + +async function isNotifsCryptoAccountInitialized(): Promise { + const [encryptedNotifsAccount, notifsAccountEncryptionKey] = + await Promise.all([ + localforage.getItem(INDEXED_DB_NOTIFS_ACCOUNT_KEY), + retrieveEncryptionKey(INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL), + ]); + + return !!encryptedNotifsAccount && !!notifsAccountEncryptionKey; +} + +async function getNotifsCryptoAccount(): Promise { + const [encryptedNotifsAccount, notifsAccountEncryptionKey] = + await Promise.all([ + localforage.getItem(INDEXED_DB_NOTIFS_ACCOUNT_KEY), + retrieveEncryptionKey(INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL), + ]); + + if (!encryptedNotifsAccount || !notifsAccountEncryptionKey) { + throw new Error( + 'Attempt to retrieve notifs olm account but account not created.', + ); + } + + const pickledOLMAccount: PickledOLMAccount = JSON.parse( + new TextDecoder().decode( + await decryptData(encryptedNotifsAccount, notifsAccountEncryptionKey), + ), + ); + const { pickledAccount, picklingKey } = pickledOLMAccount; + + const notificationAccount = new olm.Account(); + notificationAccount.unpickle(picklingKey, pickledAccount); + + return { notificationAccount, picklingKey }; +} + +async function persistNotifsCryptoAccount( + notificationAccountWithPicklingKey: NotificationAccountWithPicklingKey, + createEncryptionKeyIfNotExist?: boolean = false, +): Promise { + let encryptionKey = await retrieveEncryptionKey( + INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL, + ); + + const persistencePromises: Array> = []; + if (!encryptionKey && !createEncryptionKeyIfNotExist) { + throw new Error( + 'Attempt to persist notification olm account before it was initialized', + ); + } else if (!encryptionKey) { + encryptionKey = await generateCryptoKey({ extractable: isDesktopSafari }); + persistencePromises.push( + persistEncryptionKey( + INDEXED_DB_NOTIFS_ACCOUNT_ENCRYPTION_KEY_DB_LABEL, + encryptionKey, + ), + ); + } + + const { notificationAccount, picklingKey } = + notificationAccountWithPicklingKey; + const pickledOLMAccount: PickledOLMAccount = { + pickledAccount: notificationAccount.pickle(picklingKey), + picklingKey, + }; + + const encryptedData = await encryptData( + new TextEncoder().encode(JSON.stringify(pickledOLMAccount)), + encryptionKey, + ); + persistencePromises.push( + (async () => { + await localforage.setItem(INDEXED_DB_NOTIFS_ACCOUNT_KEY, encryptedData); + })(), + ); + await Promise.all(persistencePromises); +} + async function retrieveEncryptionKey( encryptionKeyDBLabel: string, ): Promise { @@ -408,6 +499,22 @@ return await importJWKKey(persistedCryptoKey); } +async function persistEncryptionKey( + encryptionKeyDBLabel: string, + encryptionKey: CryptoKey, +): Promise { + let cryptoKeyPersistentForm; + if (isDesktopSafari) { + // Safari doesn't support structured clone algorithm in service + // worker context so we have to store CryptoKey as JSON + cryptoKeyPersistentForm = await exportKeyToJWK(encryptionKey); + } else { + cryptoKeyPersistentForm = encryptionKey; + } + + await localforage.setItem(encryptionKeyDBLabel, cryptoKeyPersistentForm); +} + async function getNotifsOlmSessionDBKeys(keyserverID?: string): Promise<{ +olmDataContentKey: string, +encryptionKeyDBKey: string, @@ -648,4 +755,8 @@ migrateLegacyOlmNotificationsSessions, updateNotifsUnreadCountStorage, queryNotifsUnreadCountStorage, + isNotifsCryptoAccountInitialized, + getNotifsCryptoAccount, + persistNotifsCryptoAccount, + persistEncryptionKey, }; diff --git a/web/shared-worker/worker/worker-crypto.js b/web/shared-worker/worker/worker-crypto.js --- a/web/shared-worker/worker/worker-crypto.js +++ b/web/shared-worker/worker/worker-crypto.js @@ -46,7 +46,6 @@ } from './worker-database.js'; import { encryptData, - exportKeyToJWK, generateCryptoKey, } from '../../crypto/aes-gcm-crypto-utils.js'; import { @@ -54,6 +53,11 @@ getOlmEncryptionKeyDBLabelForCookie, getOlmDataContentKeyForDeviceID, getOlmEncryptionKeyDBLabelForDeviceID, + persistEncryptionKey, + persistNotifsCryptoAccount, + isNotifsCryptoAccountInitialized, + type NotificationAccountWithPicklingKey, + getNotifsCryptoAccount, } from '../../push-notif/notif-crypto-utils.js'; import { type WorkerRequestMessage, @@ -74,8 +78,6 @@ +contentAccountPickleKey: string, +contentAccount: olm.Account, +contentSessions: OlmSessions, - +notificationAccountPickleKey: string, - +notificationAccount: olm.Account, }; let cryptoStore: ?WorkerCryptoStore = null; @@ -85,7 +87,10 @@ cryptoStore = null; } -function persistCryptoStore(withoutTransaction: boolean = false) { +async function persistCryptoStore( + notifsCryptoAccount?: NotificationAccountWithPicklingKey, + withoutTransaction: boolean = false, +) { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { @@ -97,13 +102,8 @@ throw new Error("Couldn't persist crypto store because it doesn't exist"); } - const { - contentAccountPickleKey, - contentAccount, - contentSessions, - notificationAccountPickleKey, - notificationAccount, - } = cryptoStore; + const { contentAccountPickleKey, contentAccount, contentSessions } = + cryptoStore; const pickledContentAccount: PickledOLMAccount = { picklingKey: contentAccountPickleKey, @@ -118,11 +118,6 @@ version: sessionData.version, })); - const pickledNotificationAccount: PickledOLMAccount = { - picklingKey: notificationAccountPickleKey, - pickledAccount: notificationAccount.pickle(notificationAccountPickleKey), - }; - try { if (!withoutTransaction) { sqliteQueryExecutor.beginTransaction(); @@ -134,10 +129,9 @@ for (const pickledSession of pickledContentSessions) { sqliteQueryExecutor.storeOlmPersistSession(pickledSession); } - sqliteQueryExecutor.storeOlmPersistAccount( - sqliteQueryExecutor.getNotifsAccountID(), - JSON.stringify(pickledNotificationAccount), - ); + if (notifsCryptoAccount) { + await persistNotifsCryptoAccount(notifsCryptoAccount, true); + } if (!withoutTransaction) { sqliteQueryExecutor.commitTransaction(); } @@ -159,16 +153,20 @@ throw new Error('Crypto account not initialized'); } - const { notificationAccountPickleKey, notificationAccount } = cryptoStore; - const encryptionKey = await generateCryptoKey({ - extractable: isDesktopSafari, - }); + const [encryptionKey, notificationAccountWithPicklingKey] = await Promise.all( + [ + generateCryptoKey({ + extractable: isDesktopSafari, + }), + getNotifsCryptoAccount(), + ], + ); const notificationsPrekey = notificationsInitializationInfo.prekey; const session = new olm.Session(); if (notificationsInitializationInfo.oneTimeKey) { session.create_outbound( - notificationAccount, + notificationAccountWithPicklingKey.notificationAccount, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, @@ -177,7 +175,7 @@ ); } else { session.create_outbound_without_otk( - notificationAccount, + notificationAccountWithPicklingKey.notificationAccount, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, @@ -188,46 +186,33 @@ JSON.stringify(initialEncryptedMessageContent), ); - const mainSession = session.pickle(notificationAccountPickleKey); + const mainSession = session.pickle( + notificationAccountWithPicklingKey.picklingKey, + ); const notificationsOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: mainSession, updateCreationTimestamp: Date.now(), - picklingKey: notificationAccountPickleKey, + picklingKey: notificationAccountWithPicklingKey.picklingKey, }; const encryptedOlmData = await encryptData( new TextEncoder().encode(JSON.stringify(notificationsOlmData)), encryptionKey, ); - const persistEncryptionKeyPromise = (async () => { - let cryptoKeyPersistentForm; - if (isDesktopSafari) { - // Safari doesn't support structured clone algorithm in service - // worker context so we have to store CryptoKey as JSON - cryptoKeyPersistentForm = await exportKeyToJWK(encryptionKey); - } else { - cryptoKeyPersistentForm = encryptionKey; - } - - await localforage.setItem( - dataEncryptionKeyDBLabel, - cryptoKeyPersistentForm, - ); - })(); - await Promise.all([ localforage.setItem(dataPersistenceKey, encryptedOlmData), - persistEncryptionKeyPromise, + persistEncryptionKey(dataEncryptionKeyDBLabel, encryptionKey), + persistCryptoStore(notificationAccountWithPicklingKey), ]); return { message, messageType }; } -function getOrCreateOlmAccount(accountIDInDB: number): { +async function getOrCreateOlmAccount(accountIDInDB: number): Promise<{ +picklingKey: string, +account: olm.Account, -} { +}> { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { @@ -245,6 +230,22 @@ throw new Error(getProcessingStoreOpsExceptionMessage(err, dbModule)); } + const notifsCryptoModuleInitialized = await (async () => { + if (accountIDInDB !== sqliteQueryExecutor.getNotifsAccountID()) { + return false; + } + return await isNotifsCryptoAccountInitialized(); + })(); + + if (notifsCryptoModuleInitialized) { + const { notificationAccount, picklingKey: notificationAccountPicklingKey } = + await getNotifsCryptoAccount(); + return { + account: notificationAccount, + picklingKey: notificationAccountPicklingKey, + }; + } + if (accountDBString.isNull) { picklingKey = uuid.v4(); account.create(); @@ -315,13 +316,14 @@ initialCryptoStore.primaryAccount, ), contentSessions: {}, - notificationAccountPickleKey: - initialCryptoStore.notificationAccount.picklingKey, + }; + const notifsCryptoAccount = { + picklingKey: initialCryptoStore.notificationAccount.picklingKey, notificationAccount: unpickleInitialCryptoStoreAccount( initialCryptoStore.notificationAccount, ), }; - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); return; } @@ -351,12 +353,13 @@ return undefined; } -function getSignedIdentityKeysBlob(): SignedIdentityKeysBlob { +async function getSignedIdentityKeysBlob(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; + const { contentAccount } = cryptoStore; + const { notificationAccount } = await getNotifsCryptoAccount(); const identityKeysBlob: IdentityKeysBlob = { notificationIdentityPublicKeys: JSON.parse( @@ -374,22 +377,25 @@ return signedIdentityKeysBlob; } -function getNewDeviceKeyUpload(): IdentityNewDeviceKeyUpload { +async function getNewDeviceKeyUpload(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; - - const signedIdentityKeysBlob = getSignedIdentityKeysBlob(); + const { contentAccount } = cryptoStore; + const [notifsCryptoAccount, signedIdentityKeysBlob] = await Promise.all([ + getNotifsCryptoAccount(), + getSignedIdentityKeysBlob(), + ]); const primaryAccountKeysSet = retrieveAccountKeysSet(contentAccount); - const notificationAccountKeysSet = - retrieveAccountKeysSet(notificationAccount); + const notificationAccountKeysSet = retrieveAccountKeysSet( + notifsCryptoAccount.notificationAccount, + ); contentAccount.mark_keys_as_published(); - notificationAccount.mark_keys_as_published(); + notifsCryptoAccount.notificationAccount.mark_keys_as_published(); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); return { keyPayload: signedIdentityKeysBlob.payload, @@ -403,20 +409,22 @@ }; } -function getExistingDeviceKeyUpload(): IdentityExistingDeviceKeyUpload { +async function getExistingDeviceKeyUpload(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; - - const signedIdentityKeysBlob = getSignedIdentityKeysBlob(); + const { contentAccount } = cryptoStore; + const [notifsCryptoAccount, signedIdentityKeysBlob] = await Promise.all([ + getNotifsCryptoAccount(), + getSignedIdentityKeysBlob(), + ]); const { prekey: contentPrekey, prekeySignature: contentPrekeySignature } = retrieveIdentityKeysAndPrekeys(contentAccount); const { prekey: notifPrekey, prekeySignature: notifPrekeySignature } = - retrieveIdentityKeysAndPrekeys(notificationAccount); + retrieveIdentityKeysAndPrekeys(notifsCryptoAccount.notificationAccount); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); return { keyPayload: signedIdentityKeysBlob.payload, @@ -472,30 +480,35 @@ throw new Error('Database not initialized'); } - const contentAccountResult = getOrCreateOlmAccount( - sqliteQueryExecutor.getContentAccountID(), - ); - const notificationAccountResult = getOrCreateOlmAccount( - sqliteQueryExecutor.getNotifsAccountID(), + const [contentAccountResult, notificationAccountResult] = await Promise.all( + [ + getOrCreateOlmAccount(sqliteQueryExecutor.getContentAccountID()), + getOrCreateOlmAccount(sqliteQueryExecutor.getNotifsAccountID()), + ], ); + const contentSessions = getOlmSessions(contentAccountResult.picklingKey); cryptoStore = { contentAccountPickleKey: contentAccountResult.picklingKey, contentAccount: contentAccountResult.account, contentSessions, - notificationAccountPickleKey: notificationAccountResult.picklingKey, + }; + const notifsCryptoAccount = { + picklingKey: notificationAccountResult.picklingKey, notificationAccount: notificationAccountResult.account, }; - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); }, async getUserPublicKey(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; - const { payload, signature } = getSignedIdentityKeysBlob(); + const { contentAccount } = cryptoStore; + const [{ notificationAccount }, { payload, signature }] = await Promise.all( + [getNotifsCryptoAccount(), getSignedIdentityKeysBlob()], + ); return { primaryIdentityPublicKeys: JSON.parse(contentAccount.identity_keys()), @@ -516,7 +529,7 @@ } const encryptedContent = olmSession.session.encrypt(content); - persistCryptoStore(); + await persistCryptoStore(); return { message: encryptedContent.body, @@ -558,7 +571,7 @@ deviceID, JSON.stringify(result), ); - persistCryptoStore(true); + await persistCryptoStore(undefined, true); sqliteQueryExecutor.commitTransaction(); } catch (e) { sqliteQueryExecutor.rollbackTransaction(); @@ -585,7 +598,7 @@ encryptedData.message, ); - persistCryptoStore(); + await persistCryptoStore(); return result; }, @@ -626,7 +639,7 @@ sqliteQueryExecutor.beginTransaction(); try { sqliteQueryExecutor.addInboundP2PMessage(receivedMessage); - persistCryptoStore(true); + await persistCryptoStore(undefined, true); sqliteQueryExecutor.commitTransaction(); } catch (e) { sqliteQueryExecutor.rollbackTransaction(); @@ -672,7 +685,7 @@ session, version: sessionVersion, }; - persistCryptoStore(); + await persistCryptoStore(); return initialEncryptedMessage; }, @@ -714,7 +727,7 @@ session, version: newSessionVersion, }; - persistCryptoStore(); + await persistCryptoStore(); const encryptedData: EncryptedData = { message: initialEncryptedData.body, @@ -819,7 +832,8 @@ if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; + const { contentAccount } = cryptoStore; + const notifsCryptoAccount = await getNotifsCryptoAccount(); const contentOneTimeKeys = getAccountOneTimeKeys( contentAccount, @@ -828,12 +842,12 @@ contentAccount.mark_keys_as_published(); const notificationsOneTimeKeys = getAccountOneTimeKeys( - notificationAccount, + notifsCryptoAccount.notificationAccount, numberOfKeys, ); - notificationAccount.mark_keys_as_published(); + notifsCryptoAccount.notificationAccount.mark_keys_as_published(); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); return { contentOneTimeKeys, notificationsOneTimeKeys }; }, @@ -851,26 +865,27 @@ if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; + const { contentAccount } = cryptoStore; + const notifsCryptoAccount = await getNotifsCryptoAccount(); // Content and notification accounts' keys are always rotated at the same // time so we only need to check one of them. if (shouldRotatePrekey(contentAccount)) { contentAccount.generate_prekey(); - notificationAccount.generate_prekey(); + notifsCryptoAccount.notificationAccount.generate_prekey(); } if (shouldForgetPrekey(contentAccount)) { contentAccount.forget_old_prekey(); - notificationAccount.forget_old_prekey(); + notifsCryptoAccount.notificationAccount.forget_old_prekey(); } - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); if (!contentAccount.unpublished_prekey()) { return; } const { prekey: notifPrekey, prekeySignature: notifPrekeySignature } = - getAccountPrekeysSet(notificationAccount); + getAccountPrekeysSet(notifsCryptoAccount.notificationAccount); const { prekey: contentPrekey, prekeySignature: contentPrekeySignature } = getAccountPrekeysSet(contentAccount); @@ -885,9 +900,9 @@ notifPrekeySignature, }); contentAccount.mark_prekey_as_published(); - notificationAccount.mark_prekey_as_published(); + notifsCryptoAccount.notificationAccount.mark_prekey_as_published(); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); }, async signMessage(message: string): Promise { if (!cryptoStore) { @@ -920,12 +935,13 @@ if (!cryptoStore) { throw new Error('Crypto account not initialized'); } - const { contentAccount, notificationAccount } = cryptoStore; + const { contentAccount } = cryptoStore; + const notifsCryptoAccount = await getNotifsCryptoAccount(); contentAccount.mark_prekey_as_published(); - notificationAccount.mark_prekey_as_published(); + notifsCryptoAccount.notificationAccount.mark_prekey_as_published(); - persistCryptoStore(); + await persistCryptoStore(notifsCryptoAccount); }, };