diff --git a/keyserver/src/session/cookies.js b/keyserver/src/session/cookies.js --- a/keyserver/src/session/cookies.js +++ b/keyserver/src/session/cookies.js @@ -5,6 +5,11 @@ import invariant from 'invariant'; import url from 'url'; +import { isStaff } from 'lib/shared/staff-utils.js'; +import { + hasMinCodeVersion, + NEXT_CODE_VERSION, +} from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import type { SignedIdentityKeysBlob } from 'lib/types/crypto-types.js'; import { isWebPlatform } from 'lib/types/device-types.js'; @@ -22,6 +27,7 @@ import type { SIWESocialProof } from 'lib/types/siwe-types.js'; import type { InitialClientSocketMessage } from 'lib/types/socket-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; +import { isDev } from 'lib/utils/dev-utils.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; @@ -822,12 +828,16 @@ async function isCookieMissingOlmNotificationsSession( viewer: Viewer, ): Promise { + const isStaffOrDev = isStaff(viewer.userID) || isDev; if ( !viewer.platformDetails || (viewer.platformDetails.platform !== 'ios' && - viewer.platformDetails.platform !== 'android') || - !viewer.platformDetails.codeVersion || - viewer.platformDetails.codeVersion <= 222 + viewer.platformDetails.platform !== 'android' && + !(viewer.platformDetails.platform === 'web' && isStaffOrDev)) || + !hasMinCodeVersion(viewer.platformDetails, { + native: 222, + web: NEXT_CODE_VERSION, + }) ) { return false; } diff --git a/lib/shared/crypto-utils.js b/lib/shared/crypto-utils.js --- a/lib/shared/crypto-utils.js +++ b/lib/shared/crypto-utils.js @@ -6,7 +6,11 @@ getOlmSessionInitializationData, getOlmSessionInitializationDataActionTypes, } from '../actions/user-actions.js'; -import type { OLMIdentityKeys, OLMOneTimeKeys } from '../types/crypto-types'; +import type { + OLMIdentityKeys, + OLMOneTimeKeys, + OLMPrekey, +} from '../types/crypto-types'; import type { OlmSessionInitializationInfo } from '../types/request-types'; import { useServerCall, @@ -23,6 +27,10 @@ +callServerEndpointOptions?: ?CallServerEndpointOptions, }; +const initialEncryptedMessageContent = { + type: 'init', +}; + function useInitialNotificationsEncryptedMessage( platformSpecificSessionCreator: ( notificationsIdentityKeys: OLMIdentityKeys, @@ -75,13 +83,26 @@ return values(oneTimeKeys.curve25519); } +function getPrekeyValue(prekey: OLMPrekey): string { + const [prekeyValue] = values(prekey.curve25519); + return prekeyValue; +} + function getOneTimeKeyValuesFromBlob(keyBlob: string): $ReadOnlyArray { const oneTimeKeys: OLMOneTimeKeys = JSON.parse(keyBlob); return getOneTimeKeyValues(oneTimeKeys); } +function getPrekeyValueFromBlob(prekeyBlob: string): string { + const prekey: OLMPrekey = JSON.parse(prekeyBlob); + return getPrekeyValue(prekey); +} + export { getOneTimeKeyValues, + getPrekeyValue, getOneTimeKeyValuesFromBlob, + getPrekeyValueFromBlob, + initialEncryptedMessageContent, useInitialNotificationsEncryptedMessage, }; diff --git a/lib/types/crypto-types.js b/lib/types/crypto-types.js --- a/lib/types/crypto-types.js +++ b/lib/types/crypto-types.js @@ -2,6 +2,7 @@ import t, { type TInterface } from 'tcomb'; +import type { OlmSessionInitializationInfo } from '../types/request-types.js'; import { tShape } from '../utils/validation-utils.js'; export type OLMIdentityKeys = { @@ -36,6 +37,20 @@ +getInitializedCryptoStore: () => Promise, }; +export type NotificationsSessionCreatorContextType = { + +notificationsSessionCreator: ( + notificationsIdentityKeys: OLMIdentityKeys, + notificationsInitializationInfo: OlmSessionInitializationInfo, + ) => Promise, +}; + +export type NotificationsOlmDataType = { + +mainSession: string, + +picklingKey: string, + +pendingSessionUpdate: string, + +updateCreationTimestamp: number, +}; + export type IdentityKeysBlob = { +primaryIdentityPublicKeys: OLMIdentityKeys, +notificationIdentityPublicKeys: OLMIdentityKeys, diff --git a/web/account/account-hooks.js b/web/account/account-hooks.js --- a/web/account/account-hooks.js +++ b/web/account/account-hooks.js @@ -2,17 +2,37 @@ import olm from '@commapp/olm'; import invariant from 'invariant'; +import localforage from 'localforage'; import * as React from 'react'; import { useDispatch } from 'react-redux'; 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 { + 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'; @@ -20,6 +40,9 @@ const CryptoStoreContext: React.Context = React.createContext(null); +const WebNotificationsSessionCreatorContext: React.Context = + React.createContext(null); + type Props = { +children: React.Node, }; @@ -154,8 +177,144 @@ }, [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; + 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); + } + + 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/database/utils/constants.js b/web/database/utils/constants.js --- a/web/database/utils/constants.js +++ b/web/database/utils/constants.js @@ -14,6 +14,11 @@ export const COMM_SQLITE_DATABASE_PATH = 'comm.sqlite'; +export const NOTIFICATIONS_OLM_DATA_CONTENT = 'notificationsOlmDataContent'; + +export const NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY = + 'notificationsOlmDataEncryptionKey'; + export const DB_SUPPORTED_OS: $ReadOnlyArray = [ 'Windows 10', 'Linux', diff --git a/web/root.js b/web/root.js --- a/web/root.js +++ b/web/root.js @@ -12,7 +12,10 @@ import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; -import { GetOrCreateCryptoStoreProvider } from './account/account-hooks.js'; +import { + GetOrCreateCryptoStoreProvider, + WebNotificationsSessionCreatorProvider, +} from './account/account-hooks.js'; import App from './app.react.js'; import { SQLiteDataHandler } from './database/sqlite-data-handler.js'; import { localforageConfig } from './database/utils/constants.js'; @@ -39,12 +42,14 @@ - - - - - - + + + + + + + + diff --git a/web/selectors/socket-selectors.js b/web/selectors/socket-selectors.js --- a/web/selectors/socket-selectors.js +++ b/web/selectors/socket-selectors.js @@ -61,6 +61,7 @@ type WebGetClientResponsesSelectorInputType = { +state: AppState, +getSignedIdentityKeysBlob: () => Promise, + +getInitialNotificationsEncryptedMessage: () => Promise, }; const webGetClientResponsesSelector: ( @@ -74,23 +75,26 @@ input.getSignedIdentityKeysBlob, (input: WebGetClientResponsesSelectorInputType) => input.state.navInfo.tab === 'calendar', + (input: WebGetClientResponsesSelectorInputType) => + input.getInitialNotificationsEncryptedMessage, ( getClientResponsesFunc: ( calendarActive: boolean, oneTimeKeyGenerator: ?OneTimeKeyGenerator, getSignedIdentityKeysBlob: () => Promise, - getInitialNotificationsEncryptedMessage: ?() => Promise, + getInitialNotificationsEncryptedMessage: () => Promise, serverRequests: $ReadOnlyArray, ) => Promise<$ReadOnlyArray>, getSignedIdentityKeysBlob: () => Promise, calendarActive: boolean, + getInitialNotificationsEncryptedMessage: () => Promise, ) => (serverRequests: $ReadOnlyArray) => getClientResponsesFunc( calendarActive, null, getSignedIdentityKeysBlob, - null, + getInitialNotificationsEncryptedMessage, serverRequests, ), ); diff --git a/web/socket.react.js b/web/socket.react.js --- a/web/socket.react.js +++ b/web/socket.react.js @@ -12,11 +12,15 @@ connectionSelector, lastCommunicatedPlatformDetailsSelector, } from 'lib/selectors/keyserver-selectors.js'; +import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import Socket, { type BaseSocketProps } from 'lib/socket/socket.react.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; -import { useGetSignedIdentityKeysBlob } from './account/account-hooks.js'; +import { + useGetSignedIdentityKeysBlob, + useWebNotificationsSessionCreator, +} from './account/account-hooks.js'; import { useSelector } from './redux/redux-utils.js'; import { activeThreadSelector, @@ -53,8 +57,15 @@ preRequestUserStateForSingleKeyserverSelector(ashoatKeyserverID), ); const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob(); + const webNotificationsSessionCreator = useWebNotificationsSessionCreator(); + const getInitialNotificationsEncryptedMessage = + useInitialNotificationsEncryptedMessage(webNotificationsSessionCreator); const getClientResponses = useSelector(state => - webGetClientResponsesSelector({ state, getSignedIdentityKeysBlob }), + webGetClientResponsesSelector({ + state, + getSignedIdentityKeysBlob, + getInitialNotificationsEncryptedMessage, + }), ); const sessionStateFunc = useSelector( webSessionStateFuncSelector(ashoatKeyserverID),