diff --git a/keyserver/src/session/cookies.js b/keyserver/src/session/cookies.js index d744e666c..9bc7f7910 100644 --- a/keyserver/src/session/cookies.js +++ b/keyserver/src/session/cookies.js @@ -1,888 +1,898 @@ // @flow import crypto from 'crypto'; import type { $Response, $Request } from 'express'; 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'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { type ServerSessionChange, cookieLifetime, cookieSources, type CookieSource, cookieTypes, sessionIdentifierTypes, type SessionIdentifierType, } from 'lib/types/session-types.js'; 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'; import { isBcryptHash, getCookieHash, verifyCookieHash, } from './cookie-hash.js'; import { Viewer } from './viewer.js'; import type { AnonymousViewerData, UserViewerData } from './viewer.js'; import createIDs from '../creators/id-creator.js'; import { createSession } from '../creators/session-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { clearDeviceToken } from '../updaters/device-token-updaters.js'; import { assertSecureRequest } from '../utils/security-utils.js'; import { type AppURLFacts, getAppURLFactsFromRequestURL, } from '../utils/urls.js'; function cookieIsExpired(lastUsed: number) { return lastUsed + cookieLifetime <= Date.now(); } type SessionParameterInfo = { isSocket: boolean, sessionID: ?string, sessionIdentifierType: SessionIdentifierType, ipAddress: string, userAgent: ?string, }; type FetchViewerResult = | { type: 'valid', viewer: Viewer } | InvalidFetchViewerResult; type InvalidFetchViewerResult = | { type: 'nonexistant', cookieName: ?string, cookieSource: ?CookieSource, sessionParameterInfo: SessionParameterInfo, } | { type: 'invalidated', cookieName: string, cookieID: string, cookieSource: CookieSource, sessionParameterInfo: SessionParameterInfo, platformDetails: ?PlatformDetails, deviceToken: ?string, }; async function fetchUserViewer( cookie: string, cookieSource: CookieSource, sessionParameterInfo: SessionParameterInfo, ): Promise { const [cookieID, cookiePassword] = cookie.split(':'); if (!cookieID || !cookiePassword) { return { type: 'nonexistant', cookieName: cookieTypes.USER, cookieSource, sessionParameterInfo, }; } const query = SQL` SELECT hash, user, last_used, platform, device_token, versions FROM cookies WHERE id = ${cookieID} AND user IS NOT NULL `; const [[result], allSessionInfo] = await Promise.all([ dbQuery(query), fetchSessionInfo(sessionParameterInfo, cookieID), ]); if (result.length === 0) { return { type: 'nonexistant', cookieName: cookieTypes.USER, cookieSource, sessionParameterInfo, }; } let sessionID = null, sessionInfo = null; if (allSessionInfo) { ({ sessionID, ...sessionInfo } = allSessionInfo); } const cookieRow = result[0]; let platformDetails = null; if (cookieRow.versions) { const versions = JSON.parse(cookieRow.versions); platformDetails = { platform: cookieRow.platform, codeVersion: versions.codeVersion, stateVersion: versions.stateVersion, }; } else { platformDetails = { platform: cookieRow.platform }; } const deviceToken = cookieRow.device_token; const cookieHash = cookieRow.hash; if ( !verifyCookieHash(cookiePassword, cookieHash) || cookieIsExpired(cookieRow.last_used) ) { return { type: 'invalidated', cookieName: cookieTypes.USER, cookieID, cookieSource, sessionParameterInfo, platformDetails, deviceToken, }; } const userID = cookieRow.user.toString(); const viewer = new Viewer({ isSocket: sessionParameterInfo.isSocket, loggedIn: true, id: userID, platformDetails, deviceToken, userID, cookieSource, cookieID, cookiePassword, cookieHash, sessionIdentifierType: sessionParameterInfo.sessionIdentifierType, sessionID, sessionInfo, isScriptViewer: false, ipAddress: sessionParameterInfo.ipAddress, userAgent: sessionParameterInfo.userAgent, }); return { type: 'valid', viewer }; } async function fetchAnonymousViewer( cookie: string, cookieSource: CookieSource, sessionParameterInfo: SessionParameterInfo, ): Promise { const [cookieID, cookiePassword] = cookie.split(':'); if (!cookieID || !cookiePassword) { return { type: 'nonexistant', cookieName: cookieTypes.ANONYMOUS, cookieSource, sessionParameterInfo, }; } const query = SQL` SELECT last_used, hash, platform, device_token, versions FROM cookies WHERE id = ${cookieID} AND user IS NULL `; const [[result], allSessionInfo] = await Promise.all([ dbQuery(query), fetchSessionInfo(sessionParameterInfo, cookieID), ]); if (result.length === 0) { return { type: 'nonexistant', cookieName: cookieTypes.ANONYMOUS, cookieSource, sessionParameterInfo, }; } let sessionID = null, sessionInfo = null; if (allSessionInfo) { ({ sessionID, ...sessionInfo } = allSessionInfo); } const cookieRow = result[0]; let platformDetails = null; if (cookieRow.platform && cookieRow.versions) { const versions = JSON.parse(cookieRow.versions); platformDetails = { platform: cookieRow.platform, codeVersion: versions.codeVersion, stateVersion: versions.stateVersion, }; } else if (cookieRow.platform) { platformDetails = { platform: cookieRow.platform }; } const deviceToken = cookieRow.device_token; const cookieHash = cookieRow.hash; if ( !verifyCookieHash(cookiePassword, cookieHash) || cookieIsExpired(cookieRow.last_used) ) { return { type: 'invalidated', cookieName: cookieTypes.ANONYMOUS, cookieID, cookieSource, sessionParameterInfo, platformDetails, deviceToken, }; } const viewer = new Viewer({ isSocket: sessionParameterInfo.isSocket, loggedIn: false, id: cookieID, platformDetails, deviceToken, cookieSource, cookieID, cookiePassword, cookieHash, sessionIdentifierType: sessionParameterInfo.sessionIdentifierType, sessionID, sessionInfo, isScriptViewer: false, ipAddress: sessionParameterInfo.ipAddress, userAgent: sessionParameterInfo.userAgent, }); return { type: 'valid', viewer }; } type SessionInfo = { +sessionID: ?string, +lastValidated: number, +lastUpdate: number, +calendarQuery: CalendarQuery, }; async function fetchSessionInfo( sessionParameterInfo: SessionParameterInfo, cookieID: string, ): Promise { const { sessionID } = sessionParameterInfo; const session = sessionID !== undefined ? sessionID : cookieID; if (!session) { return null; } const query = SQL` SELECT query, last_validated, last_update FROM sessions WHERE id = ${session} AND cookie = ${cookieID} `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return { sessionID, lastValidated: result[0].last_validated, lastUpdate: result[0].last_update, calendarQuery: JSON.parse(result[0].query), }; } // This function is meant to consume a cookie that has already been processed. // That means it doesn't have any logic to handle an invalid cookie, and it // doesn't update the cookie's last_used timestamp. async function fetchViewerFromCookieData( req: $Request, sessionParameterInfo: SessionParameterInfo, ): Promise { let viewerResult; const { user, anonymous } = req.cookies; if (user) { viewerResult = await fetchUserViewer( user, cookieSources.HEADER, sessionParameterInfo, ); } else if (anonymous) { viewerResult = await fetchAnonymousViewer( anonymous, cookieSources.HEADER, sessionParameterInfo, ); } else { return { type: 'nonexistant', cookieName: null, cookieSource: null, sessionParameterInfo, }; } // We protect against CSRF attacks by making sure that on web, // non-GET requests cannot use a bare cookie for session identification if (viewerResult.type === 'valid') { const { viewer } = viewerResult; invariant( req.method === 'GET' || viewer.sessionIdentifierType !== sessionIdentifierTypes.COOKIE_ID || !isWebPlatform(viewer.platform), 'non-GET request from web using sessionIdentifierTypes.COOKIE_ID', ); } return viewerResult; } async function fetchViewerFromRequestBody( body: mixed, sessionParameterInfo: SessionParameterInfo, ): Promise { if (!body || typeof body !== 'object') { return { type: 'nonexistant', cookieName: null, cookieSource: null, sessionParameterInfo, }; } const cookiePair = body.cookie; if (cookiePair === null || cookiePair === '') { return { type: 'nonexistant', cookieName: null, cookieSource: cookieSources.BODY, sessionParameterInfo, }; } if (!cookiePair || typeof cookiePair !== 'string') { return { type: 'nonexistant', cookieName: null, cookieSource: null, sessionParameterInfo, }; } const [type, cookie] = cookiePair.split('='); if (type === cookieTypes.USER && cookie) { return await fetchUserViewer( cookie, cookieSources.BODY, sessionParameterInfo, ); } else if (type === cookieTypes.ANONYMOUS && cookie) { return await fetchAnonymousViewer( cookie, cookieSources.BODY, sessionParameterInfo, ); } return { type: 'nonexistant', cookieName: null, cookieSource: null, sessionParameterInfo, }; } function getRequestIPAddress(req: $Request) { const { proxy } = getAppURLFactsFromRequestURL(req.originalUrl); let ipAddress; if (proxy === 'none') { ipAddress = req.socket.remoteAddress; } else if (proxy === 'apache') { ipAddress = req.get('X-Forwarded-For'); } invariant(ipAddress, 'could not determine requesting IP address'); return ipAddress; } function getSessionParameterInfoFromRequestBody( req: $Request, ): SessionParameterInfo { const body = (req.body: any); let sessionID = body.sessionID !== undefined || req.method !== 'GET' ? body.sessionID : null; if (sessionID === '') { sessionID = null; } const sessionIdentifierType = req.method === 'GET' || sessionID !== undefined ? sessionIdentifierTypes.BODY_SESSION_ID : sessionIdentifierTypes.COOKIE_ID; return { isSocket: false, sessionID, sessionIdentifierType, ipAddress: getRequestIPAddress(req), userAgent: req.get('User-Agent'), }; } async function fetchViewerForJSONRequest(req: $Request): Promise { assertSecureRequest(req); const sessionParameterInfo = getSessionParameterInfoFromRequestBody(req); let result = await fetchViewerFromRequestBody(req.body, sessionParameterInfo); if ( result.type === 'nonexistant' && (result.cookieSource === null || result.cookieSource === undefined) ) { result = await fetchViewerFromCookieData(req, sessionParameterInfo); } return await handleFetchViewerResult(result); } const webPlatformDetails = { platform: 'web' }; async function fetchViewerForHomeRequest(req: $Request): Promise { assertSecureRequest(req); const sessionParameterInfo = getSessionParameterInfoFromRequestBody(req); const result = await fetchViewerFromCookieData(req, sessionParameterInfo); return await handleFetchViewerResult(result, webPlatformDetails); } async function fetchViewerForSocket( req: $Request, clientMessage: InitialClientSocketMessage, ): Promise { assertSecureRequest(req); const { sessionIdentification } = clientMessage.payload; const { sessionID } = sessionIdentification; const sessionParameterInfo = { isSocket: true, sessionID, sessionIdentifierType: sessionID !== undefined ? sessionIdentifierTypes.BODY_SESSION_ID : sessionIdentifierTypes.COOKIE_ID, ipAddress: getRequestIPAddress(req), userAgent: req.get('User-Agent'), }; let result = await fetchViewerFromRequestBody( clientMessage.payload.sessionIdentification, sessionParameterInfo, ); if ( result.type === 'nonexistant' && (result.cookieSource === null || result.cookieSource === undefined) ) { result = await fetchViewerFromCookieData(req, sessionParameterInfo); } if (result.type === 'valid') { return result.viewer; } const promises = {}; if (result.cookieSource === cookieSources.BODY) { // We initialize a socket's Viewer after the WebSocket handshake, since to // properly initialize the Viewer we need a bunch of data, but that data // can't be sent until after the handshake. Consequently, by the time we // know that a cookie may be invalid, we are no longer communicating via // HTTP, and have no way to set a new cookie for HEADER (web) clients. const platformDetails = result.type === 'invalidated' ? result.platformDetails : null; const deviceToken = result.type === 'invalidated' ? result.deviceToken : null; promises.anonymousViewerData = createNewAnonymousCookie({ platformDetails, deviceToken, }); } if (result.type === 'invalidated') { promises.deleteCookie = deleteCookie(result.cookieID); } const { anonymousViewerData } = await promiseAll(promises); if (!anonymousViewerData) { return null; } return createViewerForInvalidFetchViewerResult(result, anonymousViewerData); } async function handleFetchViewerResult( result: FetchViewerResult, inputPlatformDetails?: PlatformDetails, ) { if (result.type === 'valid') { return result.viewer; } let platformDetails = inputPlatformDetails; if (!platformDetails && result.type === 'invalidated') { platformDetails = result.platformDetails; } const deviceToken = result.type === 'invalidated' ? result.deviceToken : null; const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails, deviceToken }), result.type === 'invalidated' ? deleteCookie(result.cookieID) : null, ]); return createViewerForInvalidFetchViewerResult(result, anonymousViewerData); } function createViewerForInvalidFetchViewerResult( result: InvalidFetchViewerResult, anonymousViewerData: AnonymousViewerData, ): Viewer { // If a null cookie was specified in the request body, result.cookieSource // will still be BODY here. The only way it would be null or undefined here // is if there was no cookie specified in either the body or the header, in // which case we default to returning the new cookie in the response header. const cookieSource = result.cookieSource !== null && result.cookieSource !== undefined ? result.cookieSource : cookieSources.HEADER; const viewer = new Viewer({ ...anonymousViewerData, cookieSource, sessionIdentifierType: result.sessionParameterInfo.sessionIdentifierType, isSocket: result.sessionParameterInfo.isSocket, ipAddress: result.sessionParameterInfo.ipAddress, userAgent: result.sessionParameterInfo.userAgent, }); viewer.sessionChanged = true; // If cookieName is falsey, that tells us that there was no cookie specified // in the request, which means we can't be invalidating anything. if (result.cookieName) { viewer.cookieInvalidated = true; viewer.initialCookieName = result.cookieName; } return viewer; } function addSessionChangeInfoToResult( viewer: Viewer, res: $Response, result: Object, ) { let threadInfos = {}, userInfos = {}; if (result.cookieChange) { ({ threadInfos, userInfos } = result.cookieChange); } let sessionChange; if (viewer.cookieInvalidated) { sessionChange = ({ cookieInvalidated: true, threadInfos, userInfos: (values(userInfos).map(a => a): UserInfo[]), currentUserInfo: { anonymous: true, }, }: ServerSessionChange); } else { sessionChange = ({ cookieInvalidated: false, threadInfos, userInfos: (values(userInfos).map(a => a): UserInfo[]), }: ServerSessionChange); } if (viewer.cookieSource === cookieSources.BODY) { sessionChange.cookie = viewer.cookiePairString; } if (viewer.sessionIdentifierType === sessionIdentifierTypes.BODY_SESSION_ID) { sessionChange.sessionID = viewer.sessionID ? viewer.sessionID : null; } result.cookieChange = sessionChange; } type AnonymousCookieCreationParams = Shape<{ +platformDetails: ?PlatformDetails, +deviceToken: ?string, }>; const defaultPlatformDetails = {}; // The result of this function should not be passed directly to the Viewer // constructor. Instead, it should be passed to viewer.setNewCookie. There are // several fields on AnonymousViewerData that are not set by this function: // sessionIdentifierType, cookieSource, ipAddress, and userAgent. These // parameters all depend on the initial request. If the result of this function // is passed to the Viewer constructor directly, the resultant Viewer object // will throw whenever anybody attempts to access the relevant properties. async function createNewAnonymousCookie( params: AnonymousCookieCreationParams, ): Promise { const { platformDetails, deviceToken } = params; const { platform, ...versions } = platformDetails || defaultPlatformDetails; const versionsString = Object.keys(versions).length > 0 ? JSON.stringify(versions) : null; const time = Date.now(); const cookiePassword = crypto.randomBytes(32).toString('hex'); const cookieHash = getCookieHash(cookiePassword); const [[id]] = await Promise.all([ createIDs('cookies', 1), deviceToken ? clearDeviceToken(deviceToken) : undefined, ]); const cookieRow = [ id, cookieHash, null, platform, time, time, deviceToken, versionsString, ]; const query = SQL` INSERT INTO cookies(id, hash, user, platform, creation_time, last_used, device_token, versions) VALUES ${[cookieRow]} `; await dbQuery(query); return { loggedIn: false, id, platformDetails, deviceToken, cookieID: id, cookiePassword, cookieHash, sessionID: undefined, sessionInfo: null, cookieInsertedThisRequest: true, isScriptViewer: false, }; } type UserCookieCreationParams = { +platformDetails: PlatformDetails, +deviceToken?: ?string, +socialProof?: ?SIWESocialProof, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, }; // The result of this function should never be passed directly to the Viewer // constructor. Instead, it should be passed to viewer.setNewCookie. There are // several fields on UserViewerData that are not set by this function: // sessionID, sessionIdentifierType, cookieSource, and ipAddress. These // parameters all depend on the initial request. If the result of this function // is passed to the Viewer constructor directly, the resultant Viewer object // will throw whenever anybody attempts to access the relevant properties. async function createNewUserCookie( userID: string, params: UserCookieCreationParams, ): Promise { const { platformDetails, deviceToken, socialProof, signedIdentityKeysBlob } = params; const { platform, ...versions } = platformDetails || defaultPlatformDetails; const versionsString = Object.keys(versions).length > 0 ? JSON.stringify(versions) : null; const time = Date.now(); const cookiePassword = crypto.randomBytes(32).toString('hex'); const cookieHash = getCookieHash(cookiePassword); const [[cookieID]] = await Promise.all([ createIDs('cookies', 1), deviceToken ? clearDeviceToken(deviceToken) : undefined, ]); const cookieRow = [ cookieID, cookieHash, userID, platform, time, time, deviceToken, versionsString, JSON.stringify(socialProof), signedIdentityKeysBlob ? JSON.stringify(signedIdentityKeysBlob) : null, ]; const query = SQL` INSERT INTO cookies(id, hash, user, platform, creation_time, last_used, device_token, versions, social_proof, signed_identity_keys) VALUES ${[cookieRow]} `; await dbQuery(query); return { loggedIn: true, id: userID, platformDetails, deviceToken, userID, cookieID, sessionID: undefined, sessionInfo: null, cookiePassword, cookieHash, cookieInsertedThisRequest: true, isScriptViewer: false, }; } // This gets called after createNewUserCookie and from websiteResponder. If the // Viewer's sessionIdentifierType is COOKIE_ID then the cookieID is used as the // session identifier; otherwise, a new ID is created for the session. async function setNewSession( viewer: Viewer, calendarQuery: CalendarQuery, initialLastUpdate: number, ): Promise { if (viewer.sessionIdentifierType !== sessionIdentifierTypes.COOKIE_ID) { const [sessionID] = await createIDs('sessions', 1); viewer.setSessionID(sessionID); } await createSession(viewer, calendarQuery, initialLastUpdate); } async function updateCookie(viewer: Viewer) { const time = Date.now(); const { cookieID, cookieHash, cookiePassword } = viewer; const updateObj = {}; updateObj.last_used = time; if (isBcryptHash(cookieHash)) { updateObj.hash = getCookieHash(cookiePassword); } const query = SQL` UPDATE cookies SET ${updateObj} WHERE id = ${cookieID} `; await dbQuery(query); } function addCookieToJSONResponse( viewer: Viewer, res: $Response, result: Object, expectCookieInvalidation: boolean, ) { if (expectCookieInvalidation) { viewer.cookieInvalidated = false; } if (!viewer.getData().cookieInsertedThisRequest) { handleAsyncPromise(updateCookie(viewer)); } if (viewer.sessionChanged) { addSessionChangeInfoToResult(viewer, res, result); } } function addCookieToHomeResponse( req: $Request, res: $Response, appURLFacts: AppURLFacts, ) { const { user, anonymous } = req.cookies; if (user) { res.cookie(cookieTypes.USER, user, getCookieOptions(appURLFacts)); } if (anonymous) { res.cookie(cookieTypes.ANONYMOUS, anonymous, getCookieOptions(appURLFacts)); } } function getCookieOptions(appURLFacts: AppURLFacts) { const { baseDomain, basePath, https } = appURLFacts; const domainAsURL = new url.URL(baseDomain); return { domain: domainAsURL.hostname, path: basePath, httpOnly: false, secure: https, maxAge: cookieLifetime, sameSite: 'Strict', }; } async function setCookieSignedIdentityKeysBlob( cookieID: string, signedIdentityKeysBlob: SignedIdentityKeysBlob, ) { const signedIdentityKeysStr = JSON.stringify(signedIdentityKeysBlob); const query = SQL` UPDATE cookies SET signed_identity_keys = ${signedIdentityKeysStr} WHERE id = ${cookieID} `; await dbQuery(query); } // Returns `true` if row with `id = cookieID` exists AND // `signed_identity_keys` is `NULL`. Otherwise, returns `false`. async function isCookieMissingSignedIdentityKeysBlob( cookieID: string, ): Promise { const query = SQL` SELECT signed_identity_keys FROM cookies WHERE id = ${cookieID} `; const [queryResult] = await dbQuery(query); return ( queryResult.length === 1 && queryResult[0].signed_identity_keys === null ); } 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; } const query = SQL` SELECT COUNT(*) AS count FROM olm_sessions WHERE cookie_id = ${viewer.cookieID} AND is_content = FALSE `; const [queryResult] = await dbQuery(query); return queryResult[0].count === 0; } async function setCookiePlatform( viewer: Viewer, platform: Platform, ): Promise { const newPlatformDetails = { ...viewer.platformDetails, platform }; viewer.setPlatformDetails(newPlatformDetails); const query = SQL` UPDATE cookies SET platform = ${platform} WHERE id = ${viewer.cookieID} `; await dbQuery(query); } async function setCookiePlatformDetails( viewer: Viewer, platformDetails: PlatformDetails, ): Promise { viewer.setPlatformDetails(platformDetails); const { platform, ...versions } = platformDetails; const versionsString = Object.keys(versions).length > 0 ? JSON.stringify(versions) : null; const query = SQL` UPDATE cookies SET platform = ${platform}, versions = ${versionsString} WHERE id = ${viewer.cookieID} `; await dbQuery(query); } export { fetchViewerForJSONRequest, fetchViewerForHomeRequest, fetchViewerForSocket, createNewAnonymousCookie, createNewUserCookie, setNewSession, updateCookie, addCookieToJSONResponse, addCookieToHomeResponse, setCookieSignedIdentityKeysBlob, isCookieMissingSignedIdentityKeysBlob, setCookiePlatform, setCookiePlatformDetails, isCookieMissingOlmNotificationsSession, }; diff --git a/lib/shared/crypto-utils.js b/lib/shared/crypto-utils.js index feb64d16d..9ac40c226 100644 --- a/lib/shared/crypto-utils.js +++ b/lib/shared/crypto-utils.js @@ -1,87 +1,108 @@ //@flow import * as React from 'react'; import { 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, useDispatchActionPromise, } from '../utils/action-utils.js'; import type { CallServerEndpointOptions, CallServerEndpoint, } from '../utils/call-server-endpoint.js'; import { values } from '../utils/objects.js'; export type InitialNotifMessageOptions = { +callServerEndpoint?: ?CallServerEndpoint, +callServerEndpointOptions?: ?CallServerEndpointOptions, }; +const initialEncryptedMessageContent = { + type: 'init', +}; + function useInitialNotificationsEncryptedMessage( platformSpecificSessionCreator: ( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => Promise, ): (options?: ?InitialNotifMessageOptions) => Promise { const callGetOlmSessionInitializationData = useServerCall( getOlmSessionInitializationData, ); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( async options => { const callServerEndpoint = options?.callServerEndpoint; const callServerEndpointOptions = options?.callServerEndpointOptions; const initDataAction = callServerEndpoint ? getOlmSessionInitializationData(callServerEndpoint) : callGetOlmSessionInitializationData; const olmSessionDataPromise = initDataAction(callServerEndpointOptions); dispatchActionPromise( getOlmSessionInitializationDataActionTypes, olmSessionDataPromise, ); const { signedIdentityKeysBlob, notifInitializationInfo } = await olmSessionDataPromise; const { notificationIdentityPublicKeys } = JSON.parse( signedIdentityKeysBlob.payload, ); return await platformSpecificSessionCreator( notificationIdentityPublicKeys, notifInitializationInfo, ); }, [ callGetOlmSessionInitializationData, dispatchActionPromise, platformSpecificSessionCreator, ], ); } function getOneTimeKeyValues( oneTimeKeys: OLMOneTimeKeys, ): $ReadOnlyArray { 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 index 87fe7af07..6299c3727 100644 --- a/lib/types/crypto-types.js +++ b/lib/types/crypto-types.js @@ -1,79 +1,94 @@ // @flow import t, { type TInterface } from 'tcomb'; +import type { OlmSessionInitializationInfo } from '../types/request-types.js'; import { tShape } from '../utils/validation-utils.js'; export type OLMIdentityKeys = { +ed25519: string, +curve25519: string, }; export type OLMPrekey = { +curve25519: { +id: string, +key: string, }, }; export type OLMOneTimeKeys = { +curve25519: { +[string]: string }, }; export type PickledOLMAccount = { +picklingKey: string, +pickledAccount: string, }; export type CryptoStore = { +primaryAccount: PickledOLMAccount, +primaryIdentityKeys: OLMIdentityKeys, +notificationAccount: PickledOLMAccount, +notificationIdentityKeys: OLMIdentityKeys, }; export type CryptoStoreContextType = { +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, }; export type SignedIdentityKeysBlob = { +payload: string, +signature: string, }; export const signedIdentityKeysBlobValidator: TInterface = tShape({ payload: t.String, signature: t.String, }); // This type should not be changed without making equivalent changes to // `Message` in Identity service's `reserved_users` module export type ReservedUsernameMessage = | { +statement: 'Add the following usernames to reserved list', +payload: $ReadOnlyArray, +issuedAt: string, } | { +statement: 'Remove the following username from reserved list', +payload: string, +issuedAt: string, } | { +statement: 'This user is the owner of the following username and user ID', +payload: { +username: string, +userID: string, }, +issuedAt: string, }; export const olmEncryptedMessageTypes = Object.freeze({ PREKEY: 0, TEXT: 1, }); diff --git a/web/account/account-hooks.js b/web/account/account-hooks.js index f6edff8a3..4271dba66 100644 --- a/web/account/account-hooks.js +++ b/web/account/account-hooks.js @@ -1,161 +1,320 @@ // @flow 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'; 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; + 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 index a6de4b24d..ddb0e20df 100644 --- a/web/database/utils/constants.js +++ b/web/database/utils/constants.js @@ -1,38 +1,43 @@ // @flow import localforage from 'localforage'; export const SQLITE_CONTENT = 'sqliteFileContent'; export const SQLITE_ENCRYPTION_KEY = 'encryptionKey'; export const CURRENT_USER_ID_KEY = 'current_user_id'; export const DATABASE_WORKER_PATH = 'worker/database'; export const DATABASE_MODULE_FILE_PATH = '/compiled/webworkers'; export const DEFAULT_COMM_QUERY_EXECUTOR_FILENAME = 'comm_query_executor.wasm'; 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', 'Mac OS', ]; export const DB_SUPPORTED_BROWSERS: $ReadOnlyArray = [ 'edge', 'edge-chromium', 'chrome', 'firefox', 'opera', 'safari', ]; export const localforageConfig: PartialConfig = { driver: localforage.INDEXEDDB, name: 'comm', storeName: 'commStorage', description: 'Comm encrypted database storage', version: '1.0', }; diff --git a/web/root.js b/web/root.js index a7a075e0e..25b5ca43c 100644 --- a/web/root.js +++ b/web/root.js @@ -1,54 +1,59 @@ // @flow import localforage from 'localforage'; import * as React from 'react'; import { Provider } from 'react-redux'; import { Router, Route } from 'react-router'; import { createStore, applyMiddleware, type Store } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction.js'; import { persistReducer, persistStore } from 'redux-persist'; import thunk from 'redux-thunk'; 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'; import ErrorBoundary from './error-boundary.react.js'; import { defaultWebState } from './redux/default-state.js'; import InitialReduxStateGate from './redux/initial-state-gate.js'; import { persistConfig } from './redux/persist.js'; import { type AppState, type Action, reducer } from './redux/redux-setup.js'; import history from './router-history.js'; import Socket from './socket.react.js'; localforage.config(localforageConfig); const persistedReducer = persistReducer(persistConfig, reducer); const store: Store = createStore( persistedReducer, defaultWebState, composeWithDevTools({})(applyMiddleware(thunk, reduxLoggerMiddleware)), ); const persistor = persistStore(store); const RootProvider = (): React.Node => ( - - - - - - + + + + + + + + ); export default RootProvider; diff --git a/web/selectors/socket-selectors.js b/web/selectors/socket-selectors.js index 61480e823..c0aef6898 100644 --- a/web/selectors/socket-selectors.js +++ b/web/selectors/socket-selectors.js @@ -1,123 +1,127 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { sessionIDSelector, urlPrefixSelector, cookieSelector, } from 'lib/selectors/keyserver-selectors.js'; import { getClientResponsesSelector, sessionStateFuncSelector, } from 'lib/selectors/socket-selectors.js'; import { createOpenSocketFunction } from 'lib/shared/socket-utils.js'; import type { SignedIdentityKeysBlob } from 'lib/types/crypto-types.js'; import type { ClientServerRequest, ClientClientResponse, } from 'lib/types/request-types.js'; import type { SessionIdentification, SessionState, } from 'lib/types/session-types.js'; import type { OneTimeKeyGenerator } from 'lib/types/socket-types.js'; import type { AppState } from '../redux/redux-setup.js'; const baseOpenSocketSelector: ( keyserverID: string, ) => (state: AppState) => ?() => WebSocket = keyserverID => createSelector(urlPrefixSelector(keyserverID), (urlPrefix: ?string) => { if (!urlPrefix) { return null; } return createOpenSocketFunction(urlPrefix); }); const openSocketSelector: ( keyserverID: string, ) => (state: AppState) => ?() => WebSocket = _memoize(baseOpenSocketSelector); const baseSessionIdentificationSelector: ( keyserverID: string, ) => (state: AppState) => SessionIdentification = keyserverID => createSelector( cookieSelector(keyserverID), sessionIDSelector(keyserverID), (cookie: ?string, sessionID: ?string): SessionIdentification => ({ cookie, sessionID, }), ); const sessionIdentificationSelector: ( keyserverID: string, ) => (state: AppState) => SessionIdentification = _memoize( baseSessionIdentificationSelector, ); type WebGetClientResponsesSelectorInputType = { +state: AppState, +getSignedIdentityKeysBlob: () => Promise, + +getInitialNotificationsEncryptedMessage: () => Promise, }; const webGetClientResponsesSelector: ( input: WebGetClientResponsesSelectorInputType, ) => ( serverRequests: $ReadOnlyArray, ) => Promise<$ReadOnlyArray> = createSelector( (input: WebGetClientResponsesSelectorInputType) => getClientResponsesSelector(input.state), (input: WebGetClientResponsesSelectorInputType) => 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, ), ); const baseWebSessionStateFuncSelector: ( keyserverID: string, ) => (state: AppState) => () => SessionState = keyserverID => createSelector( sessionStateFuncSelector(keyserverID), (state: AppState) => state.navInfo.tab === 'calendar', ( sessionStateFunc: (calendarActive: boolean) => SessionState, calendarActive: boolean, ) => () => sessionStateFunc(calendarActive), ); const webSessionStateFuncSelector: ( keyserverID: string, ) => (state: AppState) => () => SessionState = _memoize( baseWebSessionStateFuncSelector, ); export { openSocketSelector, sessionIdentificationSelector, webGetClientResponsesSelector, webSessionStateFuncSelector, }; diff --git a/web/socket.react.js b/web/socket.react.js index 69766b6c8..cf37ec93e 100644 --- a/web/socket.react.js +++ b/web/socket.react.js @@ -1,106 +1,117 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { useLogOut } from 'lib/actions/user-actions.js'; import { preRequestUserStateForSingleKeyserverSelector } from 'lib/selectors/account-selectors.js'; import { cookieSelector, urlPrefixSelector, 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, webCalendarQuery, } from './selectors/nav-selectors.js'; import { openSocketSelector, sessionIdentificationSelector, webGetClientResponsesSelector, webSessionStateFuncSelector, } from './selectors/socket-selectors.js'; import { decompressMessage } from './utils/decompress.js'; const WebSocket: React.ComponentType = React.memo(function WebSocket(props) { const cookie = useSelector(cookieSelector(ashoatKeyserverID)); const urlPrefix = useSelector(urlPrefixSelector(ashoatKeyserverID)); invariant(urlPrefix, 'missing urlPrefix for given keyserver id'); const connection = useSelector(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const active = useSelector( state => !!state.currentUserInfo && !state.currentUserInfo.anonymous && state.lifecycleState !== 'background', ); const openSocket = useSelector(openSocketSelector(ashoatKeyserverID)); invariant(openSocket, 'openSocket failed to be created'); const sessionIdentification = useSelector( sessionIdentificationSelector(ashoatKeyserverID), ); const preRequestUserState = useSelector( 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), ); const currentCalendarQuery = useSelector(webCalendarQuery); const reduxActiveThread = useSelector(activeThreadSelector); const windowActive = useSelector(state => state.windowActive); const activeThread = React.useMemo(() => { if (!active || !windowActive) { return null; } return reduxActiveThread; }, [active, windowActive, reduxActiveThread]); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useLogOut(); const lastCommunicatedPlatformDetails = useSelector( lastCommunicatedPlatformDetailsSelector(ashoatKeyserverID), ); return ( ); }); export default WebSocket;