diff --git a/lib/flow-typed/web-crypto-common.js b/lib/flow-typed/web-crypto-common.js index 571e479ba..70dd21d90 100644 --- a/lib/flow-typed/web-crypto-common.js +++ b/lib/flow-typed/web-crypto-common.js @@ -1,272 +1,281 @@ // @flow declare interface RandomSource { getRandomValues(typedArray: T): T; randomUUID(): string; } declare interface Crypto extends RandomSource { +subtle: SubtleCrypto; } type CryptoKey$Type = 'secret' | 'public' | 'private'; type CryptoKey$Usages = | 'encrypt' | 'decrypt' | 'sign' | 'verify' | 'deriveKey' | 'deriveBits' | 'wrapKey' | 'unwrapKey'; -declare type CryptoKey = SubtleCrypto$JsonWebKey; +declare type CryptoKey = { + +algorithm: + | SubtleCrypto$AesKeyGenParams + | SubtleCrypto$RsaHashedKeyGenParams + | SubtleCrypto$EcKeyGenParams + | SubtleCrypto$HmacKeyGenParams, + +extractable: boolean, + +type: CryptoKey$Type, + +usages: $ReadOnlyArray, +}; type SubtleCrypto$KeyFormatWithoutJwk = 'pkcs8' | 'raw' | 'spki'; type SubtleCrypto$KeyFormat = 'jwk' | SubtleCrypto$KeyFormatWithoutJwk; type SubtleCrypto$HashAlgo = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'; type SubtleCrypto$AesAlgo = 'AES-CBC' | 'AES-CTR' | 'AES-GCM' | 'AES-KW'; type SubtleCrypto$RsaOaepParams = { +name: 'RSA-OAEP', +label?: BufferSource, }; type SubtleCrypto$AesCtrParams = { +name: 'AES-CTR', +counter: BufferSource, +length: number, }; type SubtleCrypto$AesCbcParams = { +name: 'AES-CBC', +iv: BufferSource, }; type SubtleCrypto$AesGcmParams = { +name: 'AES-GCM', +iv: BufferSource, +additionalData?: BufferSource, +tagLength?: number, }; type SubtleCrypto$EcdhKeyDeriveParams = { +name: 'ECDH', +public: CryptoKey, }; type SubtleCrypto$HkdfParams = { +name: 'HKDF', +hash: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512', +info: BufferSource, +salt: BufferSource, }; type SubtleCrypto$Pbkdf2Params = { +name: 'PBKDF2', +hash: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512', +iterations: number, +salt: BufferSource, }; type SubtleCrypto$HmacImportParams = { +name: 'HMAC', +hash: 'SHA-256' | 'SHA-384' | 'SHA-512', +length?: number, }; type SubtleCrypto$RsaHashedKeyGenParams = { +name: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'RSA-OAEP', +modulusLength: number, +publicExponent: Uint8Array, +hash: 'SHA-256' | 'SHA-384' | 'SHA-512', }; type SubtleCrypto$HmacKeyGenParams = { +name: 'HMAC', +hash: SubtleCrypto$HashAlgo, +length?: number, }; type SubtleCrypto$RsaHashedImportParams = { +name: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'RSA-OAEP', +hash: 'SHA-256' | 'SHA-384' | 'SHA-512', }; type SubtleCrypto$EcKeyImportParams = { +name: 'ECDSA' | 'ECDH', +namedCurve: 'P-256' | 'P-384' | 'P-521', }; type SubtleCrypto$EcKeyGenParams = { +name: 'ECDSA' | 'ECDH', +namedCurve: 'P-256' | 'P-384' | 'P-521', }; type SubtleCrypto$RsaPssParams = { +name: 'RSA-PSS', +saltLength: number, }; type SubtleCrypto$EcdsaParams = { +name: 'ECDSA', +hash: 'SHA-256' | 'SHA-384' | 'SHA-512', }; type SubtleCrypto$AesKeyGenParams = { +name: 'AES-CBC' | 'AES-CTR' | 'AES-GCM' | 'AES-KW', +length: 128 | 192 | 256, }; type SubtleCrypto$ImportKeyAlgo = | SubtleCrypto$RsaHashedImportParams | SubtleCrypto$EcKeyImportParams | SubtleCrypto$HmacImportParams | SubtleCrypto$AesAlgo | 'PBKDF2' | 'HKDF'; type SubtleCrypto$RsaOtherPrimesInfo = { +d?: string, +r?: string, +t?: string, }; type SubtleCrypto$JsonWebKey = { +alg?: string, +crv?: string, +d?: string, +dp?: string, +dq?: string, +e?: string, +ext?: boolean, +k?: string, +key_ops?: $ReadOnlyArray, +kty?: string, +n?: string, +oth?: $ReadOnlyArray, +p?: string, +q?: string, +qi?: string, +use?: string, +x?: string, +y?: string, }; declare interface SubtleCrypto { decrypt( algorithm: | SubtleCrypto$RsaOaepParams | SubtleCrypto$AesCtrParams | SubtleCrypto$AesCbcParams | SubtleCrypto$AesGcmParams, key: CryptoKey, data: BufferSource, ): Promise; deriveBits( algorithm: | SubtleCrypto$EcdhKeyDeriveParams | SubtleCrypto$HkdfParams | SubtleCrypto$Pbkdf2Params, baseKey: CryptoKey, length: number, ): Promise; deriveKey( algorithm: | SubtleCrypto$EcdhKeyDeriveParams | SubtleCrypto$HkdfParams | SubtleCrypto$Pbkdf2Params, baseKey: CryptoKey, derivedKeyType: | SubtleCrypto$HmacKeyGenParams | SubtleCrypto$AesKeyGenParams, extractable: boolean, keyUsages: $ReadOnlyArray, ): Promise; digest( algorithm: SubtleCrypto$HashAlgo | { +name: SubtleCrypto$HashAlgo }, data: BufferSource, ): Promise; encrypt( algorithm: | SubtleCrypto$RsaOaepParams | SubtleCrypto$AesCtrParams | SubtleCrypto$AesCbcParams | SubtleCrypto$AesGcmParams, key: CryptoKey, data: BufferSource, ): Promise; exportKey(format: 'jwk', key: CryptoKey): Promise; exportKey( format: SubtleCrypto$KeyFormatWithoutJwk, key: CryptoKey, ): Promise; generateKey( algorithm: | SubtleCrypto$RsaHashedKeyGenParams | SubtleCrypto$EcKeyGenParams | SubtleCrypto$HmacKeyGenParams | SubtleCrypto$AesKeyGenParams, extractable: boolean, keyUsages: $ReadOnlyArray, ): Promise; importKey( format: SubtleCrypto$KeyFormatWithoutJwk, keyData: BufferSource, algorithm: SubtleCrypto$ImportKeyAlgo, extractable: boolean, keyUsages: $ReadOnlyArray, ): Promise; importKey( format: 'jwk', keyData: SubtleCrypto$JsonWebKey, algorithm: SubtleCrypto$ImportKeyAlgo, extractable: boolean, keyUsages: $ReadOnlyArray, ): Promise; sign( algorithm: | 'RSASSA-PKCS1-v1_5' | 'HMAC' | SubtleCrypto$RsaPssParams | SubtleCrypto$EcdsaParams, key: CryptoKey, data: BufferSource, ): Promise; unwrapKey( format: SubtleCrypto$KeyFormat, wrappedKey: ArrayBuffer, unwrappingKey: CryptoKey, unwrapAlgorithm: | SubtleCrypto$RsaOaepParams | SubtleCrypto$AesCtrParams | SubtleCrypto$AesCbcParams | SubtleCrypto$AesGcmParams | 'AES-KW', unwrappedKeyAlgorithm: SubtleCrypto$ImportKeyAlgo, extractable: boolean, keyUsages: $ReadOnlyArray, ): Promise; verify( algorithm: | SubtleCrypto$RsaPssParams | SubtleCrypto$EcdsaParams | 'RSASSA-PKCS1-v1_5' | 'HMAC', key: CryptoKey, signature: ArrayBuffer, data: ArrayBuffer, ): Promise; wrapKey( format: SubtleCrypto$KeyFormat, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: | SubtleCrypto$RsaOaepParams | SubtleCrypto$AesCtrParams | SubtleCrypto$AesCbcParams | SubtleCrypto$AesGcmParams | 'AES-KW', ): Promise; } diff --git a/web/account/account-hooks.js b/web/account/account-hooks.js index 15902a83e..60c6740ba 100644 --- a/web/account/account-hooks.js +++ b/web/account/account-hooks.js @@ -1,320 +1,322 @@ // @flow import olm from '@commapp/olm'; import invariant from 'invariant'; import localforage from 'localforage'; import * as React from 'react'; import uuid from 'uuid'; import { initialEncryptedMessageContent, getOneTimeKeyValuesFromBlob, getPrekeyValueFromBlob, } from 'lib/shared/crypto-utils.js'; import type { SignedIdentityKeysBlob, CryptoStore, IdentityKeysBlob, CryptoStoreContextType, OLMIdentityKeys, NotificationsSessionCreatorContextType, NotificationsOlmDataType, } from 'lib/types/crypto-types.js'; import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateCryptoKey, encryptData, exportKeyToJWK, } from '../crypto/aes-gcm-crypto-utils.js'; import { NOTIFICATIONS_OLM_DATA_CONTENT, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, } from '../database/utils/constants.js'; import { isDesktopSafari } from '../database/utils/db-utils.js'; import { initOlm } from '../olm/olm-utils.js'; import { setCryptoStore } from '../redux/crypto-store-reducer.js'; import { useSelector } from '../redux/redux-utils.js'; const CryptoStoreContext: React.Context = React.createContext(null); const WebNotificationsSessionCreatorContext: React.Context = React.createContext(null); type Props = { +children: React.Node, }; function GetOrCreateCryptoStoreProvider(props: Props): React.Node { const dispatch = useDispatch(); const createCryptoStore = React.useCallback(async () => { await initOlm(); const identityAccount = new olm.Account(); identityAccount.create(); const { ed25519: identityED25519, curve25519: identityCurve25519 } = JSON.parse(identityAccount.identity_keys()); const identityAccountPicklingKey = uuid.v4(); const pickledIdentityAccount = identityAccount.pickle( identityAccountPicklingKey, ); const notificationAccount = new olm.Account(); notificationAccount.create(); const { ed25519: notificationED25519, curve25519: notificationCurve25519 } = JSON.parse(notificationAccount.identity_keys()); const notificationAccountPicklingKey = uuid.v4(); const pickledNotificationAccount = notificationAccount.pickle( notificationAccountPicklingKey, ); const newCryptoStore = { primaryAccount: { picklingKey: identityAccountPicklingKey, pickledAccount: pickledIdentityAccount, }, primaryIdentityKeys: { ed25519: identityED25519, curve25519: identityCurve25519, }, notificationAccount: { picklingKey: notificationAccountPicklingKey, pickledAccount: pickledNotificationAccount, }, notificationIdentityKeys: { ed25519: notificationED25519, curve25519: notificationCurve25519, }, }; dispatch({ type: setCryptoStore, payload: newCryptoStore }); return newCryptoStore; }, [dispatch]); const currentCryptoStore = useSelector(state => state.cryptoStore); const createCryptoStorePromiseRef = React.useRef>(null); const getCryptoStorePromise = React.useCallback(() => { if (currentCryptoStore) { return Promise.resolve(currentCryptoStore); } const currentCreateCryptoStorePromiseRef = createCryptoStorePromiseRef.current; if (currentCreateCryptoStorePromiseRef) { return currentCreateCryptoStorePromiseRef; } const newCreateCryptoStorePromise = (async () => { try { return await createCryptoStore(); } catch (e) { createCryptoStorePromiseRef.current = undefined; throw e; } })(); createCryptoStorePromiseRef.current = newCreateCryptoStorePromise; return newCreateCryptoStorePromise; }, [createCryptoStore, currentCryptoStore]); const isCryptoStoreSet = !!currentCryptoStore; React.useEffect(() => { if (!isCryptoStoreSet) { createCryptoStorePromiseRef.current = undefined; } }, [isCryptoStoreSet]); const contextValue = React.useMemo( () => ({ getInitializedCryptoStore: getCryptoStorePromise, }), [getCryptoStorePromise], ); return ( {props.children} ); } function useGetOrCreateCryptoStore(): () => Promise { const context = React.useContext(CryptoStoreContext); invariant(context, 'CryptoStoreContext not found'); return context.getInitializedCryptoStore; } function useGetSignedIdentityKeysBlob(): () => Promise { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); return React.useCallback(async () => { const { primaryAccount, primaryIdentityKeys, notificationIdentityKeys } = await getOrCreateCryptoStore(); await initOlm(); const primaryOLMAccount = new olm.Account(); primaryOLMAccount.unpickle( primaryAccount.picklingKey, primaryAccount.pickledAccount, ); const identityKeysBlob: IdentityKeysBlob = { primaryIdentityPublicKeys: primaryIdentityKeys, notificationIdentityPublicKeys: notificationIdentityKeys, }; const payloadToBeSigned: string = JSON.stringify(identityKeysBlob); const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: payloadToBeSigned, signature: primaryOLMAccount.sign(payloadToBeSigned), }; return signedIdentityKeysBlob; }, [getOrCreateCryptoStore]); } function WebNotificationsSessionCreatorProvider(props: Props): React.Node { const getOrCreateCryptoStore = useGetOrCreateCryptoStore(); const currentCryptoStore = useSelector(state => state.cryptoStore); const createNewNotificationsSession = React.useCallback( async ( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => { const [{ notificationAccount }, encryptionKey] = await Promise.all([ getOrCreateCryptoStore(), generateCryptoKey({ extractable: isDesktopSafari }), initOlm(), ]); const account = new olm.Account(); const { picklingKey, pickledAccount } = notificationAccount; account.unpickle(picklingKey, pickledAccount); const notificationsPrekey = getPrekeyValueFromBlob( notificationsInitializationInfo.prekey, ); const [notificationsOneTimeKey] = getOneTimeKeyValuesFromBlob( notificationsInitializationInfo.oneTimeKey, ); const session = new olm.Session(); session.create_outbound( account, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, notificationsInitializationInfo.prekeySignature, notificationsOneTimeKey, ); const { body: initialNotificationsEncryptedMessage } = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); const mainSession = session.pickle(picklingKey); const notificationsOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: mainSession, updateCreationTimestamp: Date.now(), picklingKey, }; const encryptedOlmData = await encryptData( new TextEncoder().encode(JSON.stringify(notificationsOlmData)), encryptionKey, ); const persistEncryptionKeyPromise = (async () => { - let cryptoKeyPersistentForm = encryptionKey; + 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( NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, cryptoKeyPersistentForm, ); })(); await Promise.all([ localforage.setItem(NOTIFICATIONS_OLM_DATA_CONTENT, encryptedOlmData), persistEncryptionKeyPromise, ]); return initialNotificationsEncryptedMessage; }, [getOrCreateCryptoStore], ); const notificationsSessionPromise = React.useRef>(null); const createNotificationsSession = React.useCallback( async ( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => { if (notificationsSessionPromise.current) { return notificationsSessionPromise.current; } const newNotificationsSessionPromise = (async () => { try { return await createNewNotificationsSession( notificationsIdentityKeys, notificationsInitializationInfo, ); } catch (e) { notificationsSessionPromise.current = undefined; throw e; } })(); notificationsSessionPromise.current = newNotificationsSessionPromise; return newNotificationsSessionPromise; }, [createNewNotificationsSession], ); const isCryptoStoreSet = !!currentCryptoStore; React.useEffect(() => { if (!isCryptoStoreSet) { notificationsSessionPromise.current = undefined; } }, [isCryptoStoreSet]); const contextValue = React.useMemo( () => ({ notificationsSessionCreator: createNotificationsSession, }), [createNotificationsSession], ); return ( {props.children} ); } function useWebNotificationsSessionCreator(): ( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => Promise { const context = React.useContext(WebNotificationsSessionCreatorContext); invariant(context, 'WebNotificationsSessionCreator not found.'); return context.notificationsSessionCreator; } export { useGetSignedIdentityKeysBlob, useGetOrCreateCryptoStore, WebNotificationsSessionCreatorProvider, useWebNotificationsSessionCreator, GetOrCreateCryptoStoreProvider, }; diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js index dff71c9e8..e14df9676 100644 --- a/web/push-notif/notif-crypto-utils.js +++ b/web/push-notif/notif-crypto-utils.js @@ -1,208 +1,214 @@ // @flow import olm from '@commapp/olm'; import localforage from 'localforage'; import { olmEncryptedMessageTypes, type NotificationsOlmDataType, } from 'lib/types/crypto-types.js'; import type { PlainTextWebNotification, PlainTextWebNotificationPayload, EncryptedWebNotification, } from 'lib/types/notif-types.js'; import { decryptData, encryptData, importJWKKey, type EncryptedData, } from '../crypto/aes-gcm-crypto-utils.js'; import { NOTIFICATIONS_OLM_DATA_CONTENT, NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, } from '../database/utils/constants.js'; import { isDesktopSafari } from '../database/utils/db-utils.js'; export type WebNotifDecryptionError = { +id: string, +error: string, +displayErrorMessage?: boolean, }; export type WebNotifsServiceUtilsData = { +olmWasmPath: string, +staffCanSee: boolean, }; type DecryptionResult = { +newPendingSessionUpdate: string, +newUpdateCreationTimestamp: number, +decryptedNotification: PlainTextWebNotificationPayload, }; export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; const SESSION_UPDATE_MAX_PENDING_TIME = 10 * 1000; async function decryptWebNotification( encryptedNotification: EncryptedWebNotification, ): Promise { const { id, encryptedPayload } = encryptedNotification; const retrieveEncryptionKeyPromise: Promise = (async () => { - const persistedCryptoKey = await localforage.getItem( - NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, - ); - if (isDesktopSafari && persistedCryptoKey) { - // Safari doesn't support structured clone algorithm in service - // worker context so we have to store CryptoKey as JSON - return await importJWKKey(persistedCryptoKey); + if (!isDesktopSafari) { + return await localforage.getItem( + NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, + ); + } + // Safari doesn't support structured clone algorithm in service + // worker context so we have to store CryptoKey as JSON + const persistedCryptoKey = + await localforage.getItem( + NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY, + ); + if (!persistedCryptoKey) { + return null; } - return persistedCryptoKey; + return await importJWKKey(persistedCryptoKey); })(); const [encryptedOlmData, encryptionKey, utilsData] = await Promise.all([ localforage.getItem(NOTIFICATIONS_OLM_DATA_CONTENT), retrieveEncryptionKeyPromise, localforage.getItem( WEB_NOTIFS_SERVICE_UTILS_KEY, ), ]); if (!utilsData) { return { id, error: 'Necessary data not found in IndexedDB' }; } const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData); if (!encryptionKey || !encryptedOlmData) { return { id, error: 'Received encrypted notification but olm session was not created', displayErrorMessage: staffCanSee, }; } try { await olm.init({ locateFile: () => olmWasmPath }); const serializedOlmData = await decryptData( encryptedOlmData, encryptionKey, ); const { mainSession, picklingKey, pendingSessionUpdate, updateCreationTimestamp, }: NotificationsOlmDataType = JSON.parse( new TextDecoder().decode(serializedOlmData), ); let updatedOlmData: NotificationsOlmDataType; let decryptedNotification: PlainTextWebNotificationPayload; const shouldUpdateMainSession = Date.now() - updateCreationTimestamp > SESSION_UPDATE_MAX_PENDING_TIME; const decryptionWithPendingSessionResult = decryptWithPendingSession( pendingSessionUpdate, picklingKey, encryptedPayload, ); if (decryptionWithPendingSessionResult.decryptedNotification) { const { decryptedNotification: notifDecryptedWithPendingSession, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptionWithPendingSessionResult; decryptedNotification = notifDecryptedWithPendingSession; updatedOlmData = { mainSession: shouldUpdateMainSession ? pendingSessionUpdate : mainSession, pendingSessionUpdate: newPendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } else { const { newUpdateCreationTimestamp, decryptedNotification: notifDecryptedWithMainSession, } = decryptWithSession(mainSession, picklingKey, encryptedPayload); decryptedNotification = notifDecryptedWithMainSession; updatedOlmData = { mainSession: mainSession, pendingSessionUpdate, updateCreationTimestamp: newUpdateCreationTimestamp, picklingKey, }; } const updatedEncryptedSession = await encryptData( new TextEncoder().encode(JSON.stringify(updatedOlmData)), encryptionKey, ); await localforage.setItem( NOTIFICATIONS_OLM_DATA_CONTENT, updatedEncryptedSession, ); return { id, ...decryptedNotification }; } catch (e) { return { id, error: e.message, displayErrorMessage: staffCanSee, }; } } function decryptWithSession( pickledSession: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult { const session = new olm.Session(); session.unpickle(picklingKey, pickledSession); const decryptedNotification: PlainTextWebNotificationPayload = JSON.parse( session.decrypt(olmEncryptedMessageTypes.TEXT, encryptedPayload), ); const newPendingSessionUpdate = session.pickle(picklingKey); const newUpdateCreationTimestamp = Date.now(); return { decryptedNotification, newUpdateCreationTimestamp, newPendingSessionUpdate, }; } function decryptWithPendingSession( pendingSessionUpdate: string, picklingKey: string, encryptedPayload: string, ): DecryptionResult | { +error: string } { try { const { decryptedNotification, newPendingSessionUpdate, newUpdateCreationTimestamp, } = decryptWithSession(pendingSessionUpdate, picklingKey, encryptedPayload); return { newPendingSessionUpdate, newUpdateCreationTimestamp, decryptedNotification, }; } catch (e) { return { error: e.message }; } } export { decryptWebNotification };