diff --git a/lib/components/keyserver-connection-handler.js b/lib/components/keyserver-connection-handler.js index d58936339..b30aaf043 100644 --- a/lib/components/keyserver-connection-handler.js +++ b/lib/components/keyserver-connection-handler.js @@ -1,412 +1,413 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { keyserverAuthActionTypes, logOutActionTypes, keyserverAuthRawAction, useLogOut, } from '../actions/user-actions.js'; import { useCallKeyserverEndpointContext } from '../keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { CANCELLED_ERROR, type CallKeyserverEndpoint, } from '../keyserver-conn/keyserver-conn-types.js'; import { useKeyserverRecoveryLogIn } from '../keyserver-conn/recovery-utils.js'; import { filterThreadIDsInFilterList } from '../reducers/calendar-filters-reducer.js'; import { connectionSelector, cookieSelector, deviceTokenSelector, } from '../selectors/keyserver-selectors.js'; import { isLoggedInToKeyserver } from '../selectors/user-selectors.js'; import { useInitialNotificationsEncryptedMessage } from '../shared/crypto-utils.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import type { BaseSocketProps } from '../socket/socket.react.js'; import { logInActionSources, type RecoveryActionSource, type AuthActionSource, } from '../types/account-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import type { CallSingleKeyserverEndpoint } from '../utils/call-single-keyserver-endpoint.js'; import { getConfig } from '../utils/config.js'; import { getMessageForException } from '../utils/errors.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken, relyingOnAuthoritativeKeyserver, } from '../utils/services-utils.js'; import sleep from '../utils/sleep.js'; type Props = { ...BaseSocketProps, +socketComponent: React.ComponentType, }; const AUTH_RETRY_DELAY_MS = 60000; function KeyserverConnectionHandler(props: Props) { const { socketComponent: Socket, ...socketProps } = props; const { keyserverID } = props; const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useLogOut(); const hasConnectionIssue = useSelector( state => !!connectionSelector(keyserverID)(state)?.connectionIssue, ); const cookie = useSelector(cookieSelector(keyserverID)); const dataLoaded = useSelector(state => state.dataLoaded); const keyserverDeviceToken = useSelector(deviceTokenSelector(keyserverID)); // We have an assumption that we should be always connected to Ashoat's // keyserver. It is possible that a token which it has is correct, so we can // try to use it. In worst case it is invalid and our push-handler will try // to fix it. const ashoatKeyserverDeviceToken = useSelector( deviceTokenSelector(authoritativeKeyserverID()), ); const deviceToken = keyserverDeviceToken ?? ashoatKeyserverDeviceToken; const navInfo = useSelector(state => state.navInfo); const calendarFilters = useSelector(state => state.calendarFilters); const calendarQuery = React.useMemo(() => { const filters = filterThreadIDsInFilterList( calendarFilters, (threadID: string) => extractKeyserverIDFromID(threadID) === keyserverID, ); return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters, }; }, [calendarFilters, keyserverID, navInfo.endDate, navInfo.startDate]); React.useEffect(() => { if ( hasConnectionIssue && keyserverID === authoritativeKeyserverID() && relyingOnAuthoritativeKeyserver ) { void dispatchActionPromise(logOutActionTypes, callLogOut()); } }, [callLogOut, hasConnectionIssue, dispatchActionPromise, keyserverID]); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { identityClient, getAuthMetadata } = identityContext; const { olmAPI } = getConfig(); const preRequestUserInfo = useSelector(state => state.currentUserInfo); const innerPerformAuth = React.useCallback( ( authActionSource: AuthActionSource, setInProgress: boolean => mixed, hasBeenCancelled: () => boolean, doNotRegister: boolean, ) => async (innerCallKeyserverEndpoint: CallKeyserverEndpoint) => { try { const [keyserverKeys] = await Promise.all([ identityClient.getKeyserverKeys(keyserverID), olmAPI.initializeCryptoAccount(), ]); if (hasBeenCancelled()) { throw new Error(CANCELLED_ERROR); } const [notifsSession, contentSession, { userID, deviceID }] = await Promise.all([ olmAPI.notificationsSessionCreator( cookie, keyserverKeys.identityKeysBlob.notificationIdentityPublicKeys, keyserverKeys.notifInitializationInfo, keyserverID, ), olmAPI.contentOutboundSessionCreator( keyserverKeys.identityKeysBlob.primaryIdentityPublicKeys, keyserverKeys.contentInitializationInfo, ), getAuthMetadata(), ]); invariant(userID, 'userID should be set'); invariant(deviceID, 'deviceID should be set'); const deviceTokenUpdateInput = deviceToken ? { [keyserverID]: { deviceToken } } : {}; if (hasBeenCancelled()) { throw new Error(CANCELLED_ERROR); } const authPromise = keyserverAuthRawAction( innerCallKeyserverEndpoint, )({ userID, deviceID, doNotRegister, calendarQuery, deviceTokenUpdateInput, authActionSource, keyserverData: { [keyserverID]: { - initialContentEncryptedMessage: contentSession.message, + initialContentEncryptedMessage: + contentSession.encryptedData.message, initialNotificationsEncryptedMessage: notifsSession, }, }, preRequestUserInfo, }); await dispatchActionPromise(keyserverAuthActionTypes, authPromise); } catch (e) { if (hasBeenCancelled()) { return; } console.log( `Error while authenticating to keyserver with id ${keyserverID}`, e, ); throw e; } finally { if (!hasBeenCancelled()) { void (async () => { await sleep(AUTH_RETRY_DELAY_MS); setInProgress(false); })(); } } }, [ calendarQuery, cookie, deviceToken, dispatchActionPromise, getAuthMetadata, identityClient, keyserverID, olmAPI, preRequestUserInfo, ], ); const [authInProgress, setAuthInProgress] = React.useState(false); const { callKeyserverEndpoint } = useCallKeyserverEndpointContext(); const performAuth = React.useCallback(() => { setAuthInProgress(true); let cancelled = false; const cancel = () => { cancelled = true; setAuthInProgress(false); }; const hasBeenCancelled = () => cancelled; const promise = (async () => { try { await innerPerformAuth( process.env.BROWSER ? logInActionSources.keyserverAuthFromWeb : logInActionSources.keyserverAuthFromNative, setAuthInProgress, hasBeenCancelled, false, )(callKeyserverEndpoint); } catch (e) { if ( !dataLoaded && keyserverID === authoritativeKeyserverID() && relyingOnAuthoritativeKeyserver ) { await dispatchActionPromise(logOutActionTypes, callLogOut()); } } })(); return [promise, cancel]; }, [ innerPerformAuth, callKeyserverEndpoint, dataLoaded, keyserverID, dispatchActionPromise, callLogOut, ]); const activeSessionRecovery = useSelector( state => state.keyserverStore.keyserverInfos[keyserverID]?.connection .activeSessionRecovery, ); // This async function asks the keyserver for its keys, whereas performAuth // above gets the keyserver's keys from the identity service const getInitialNotificationsEncryptedMessageForRecovery = useInitialNotificationsEncryptedMessage(keyserverID); const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); const innerPerformRecovery = React.useCallback( ( recoveryActionSource: RecoveryActionSource, setInProgress: boolean => mixed, hasBeenCancelled: () => boolean, ) => async ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, innerCallKeyserverEndpoint: CallKeyserverEndpoint, ) => { if (usingCommServicesAccessToken) { try { await innerPerformAuth( recoveryActionSource, setInProgress, hasBeenCancelled, true, )(innerCallKeyserverEndpoint); } catch (e) { console.log( `Tried to recover session with keyserver ${keyserverID} but got ` + `error. Exception: ` + (getMessageForException(e) ?? '{no exception message}'), ); } return; } if (!resolveKeyserverSessionInvalidationUsingNativeCredentials) { return; } await resolveKeyserverSessionInvalidationUsingNativeCredentials( callSingleKeyserverEndpoint, innerCallKeyserverEndpoint, dispatchActionPromise, recoveryActionSource, keyserverID, getInitialNotificationsEncryptedMessageForRecovery, hasBeenCancelled, ); }, [ innerPerformAuth, resolveKeyserverSessionInvalidationUsingNativeCredentials, dispatchActionPromise, keyserverID, getInitialNotificationsEncryptedMessageForRecovery, ], ); const keyserverRecoveryLogIn = useKeyserverRecoveryLogIn(keyserverID); const performRecovery = React.useCallback( (recoveryActionSource: RecoveryActionSource) => { setAuthInProgress(true); let cancelled = false; const cancel = () => { cancelled = true; setAuthInProgress(false); }; const hasBeenCancelled = () => cancelled; const promise = (async () => { try { await keyserverRecoveryLogIn( recoveryActionSource, innerPerformRecovery( recoveryActionSource, setAuthInProgress, hasBeenCancelled, ), hasBeenCancelled, ); } finally { if (!cancelled) { setAuthInProgress(false); } } })(); return [promise, cancel]; }, [keyserverRecoveryLogIn, innerPerformRecovery], ); const cancelPendingAuth = React.useRef void>(null); const prevPerformAuth = React.useRef(performAuth); const isUserAuthenticated = useSelector(isLoggedInToKeyserver(keyserverID)); const hasAccessToken = useSelector(state => !!state.commServicesAccessToken); const cancelPendingRecovery = React.useRef void>(null); const prevPerformRecovery = React.useRef(performRecovery); React.useEffect(() => { if (activeSessionRecovery && isUserAuthenticated) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; if (prevPerformRecovery.current !== performRecovery) { cancelPendingRecovery.current?.(); cancelPendingRecovery.current = null; prevPerformRecovery.current = performRecovery; } if (!authInProgress) { const [, cancel] = performRecovery(activeSessionRecovery); cancelPendingRecovery.current = cancel; } return; } cancelPendingRecovery.current?.(); cancelPendingRecovery.current = null; if (!hasAccessToken) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; } if ( !usingCommServicesAccessToken || isUserAuthenticated || !hasAccessToken ) { return; } if (prevPerformAuth.current !== performAuth) { cancelPendingAuth.current?.(); cancelPendingAuth.current = null; } prevPerformAuth.current = performAuth; if (authInProgress) { return; } const [, cancel] = performAuth(); cancelPendingAuth.current = cancel; }, [ activeSessionRecovery, authInProgress, performRecovery, hasAccessToken, isUserAuthenticated, performAuth, ]); return ; } const Handler: React.ComponentType = React.memo( KeyserverConnectionHandler, ); export default Handler; diff --git a/lib/handlers/peer-to-peer-message-handler.js b/lib/handlers/peer-to-peer-message-handler.js index 08916c038..6bc0d1a7c 100644 --- a/lib/handlers/peer-to-peer-message-handler.js +++ b/lib/handlers/peer-to-peer-message-handler.js @@ -1,76 +1,78 @@ // @flow import type { IdentityServiceClient, DeviceOlmInboundKeys, } from '../types/identity-service-types.js'; import { peerToPeerMessageTypes, type PeerToPeerMessage, } from '../types/tunnelbroker/peer-to-peer-message-types.js'; import { getConfig } from '../utils/config.js'; async function peerToPeerMessageHandler( message: PeerToPeerMessage, identityClient: IdentityServiceClient, ): Promise { const { olmAPI } = getConfig(); if (message.type === peerToPeerMessageTypes.OUTBOUND_SESSION_CREATION) { try { - const { senderInfo, encryptedData } = message; + const { senderInfo, encryptedData, sessionVersion } = message; const { keys } = await identityClient.getInboundKeysForUser( senderInfo.userID, ); const deviceKeys: ?DeviceOlmInboundKeys = keys[senderInfo.deviceID]; if (!deviceKeys) { throw new Error( 'No keys for the device that requested creating a session, ' + `deviceID: ${senderInfo.deviceID}`, ); } await olmAPI.initializeCryptoAccount(); const result = await olmAPI.contentInboundSessionCreator( deviceKeys.identityKeysBlob.primaryIdentityPublicKeys, encryptedData, + sessionVersion, ); console.log( 'Created inbound session with device ' + - `${message.senderInfo.deviceID}: ${result}`, + `${message.senderInfo.deviceID}: ${result}, ` + + `session version: ${sessionVersion}`, ); } catch (e) { console.log( 'Error creating inbound session with device ' + `${message.senderInfo.deviceID}: ${e.message}`, ); } } else if (message.type === peerToPeerMessageTypes.ENCRYPTED_MESSAGE) { try { await olmAPI.initializeCryptoAccount(); const decrypted = await olmAPI.decrypt( message.encryptedData, message.senderInfo.deviceID, ); console.log( 'Decrypted message from device ' + `${message.senderInfo.deviceID}: ${decrypted}`, ); } catch (e) { console.log( 'Error decrypting message from device ' + `${message.senderInfo.deviceID}: ${e.message}`, ); } } else if (message.type === peerToPeerMessageTypes.REFRESH_KEY_REQUEST) { try { await olmAPI.initializeCryptoAccount(); const oneTimeKeys = await olmAPI.getOneTimeKeys(message.numberOfKeys); await identityClient.uploadOneTimeKeys(oneTimeKeys); } catch (e) { console.log(`Error uploading one-time keys: ${e.message}`); } } } export { peerToPeerMessageHandler }; diff --git a/lib/types/crypto-types.js b/lib/types/crypto-types.js index 2e0276a0c..e7627a59b 100644 --- a/lib/types/crypto-types.js +++ b/lib/types/crypto-types.js @@ -1,162 +1,168 @@ // @flow import t, { type TInterface } from 'tcomb'; import type { OlmSessionInitializationInfo } from './request-types.js'; import { type AuthMetadata } from '../shared/identity-client-context.js'; import { tShape } from '../utils/validation-utils.js'; export type OLMIdentityKeys = { +ed25519: string, +curve25519: string, }; const olmIdentityKeysValidator: TInterface = tShape({ ed25519: t.String, curve25519: t.String, }); export type OLMPrekey = { +curve25519: { +[key: string]: string, }, }; export type SignedPrekeys = { +contentPrekey: string, +contentPrekeySignature: string, +notifPrekey: string, +notifPrekeySignature: string, }; export const signedPrekeysValidator: TInterface = tShape({ contentPrekey: t.String, contentPrekeySignature: t.String, notifPrekey: t.String, notifPrekeySignature: t.String, }); export type OLMOneTimeKeys = { +curve25519: { +[string]: string }, }; export type OneTimeKeysResult = { +contentOneTimeKeys: OLMOneTimeKeys, +notificationsOneTimeKeys: OLMOneTimeKeys, }; export type OneTimeKeysResultValues = { +contentOneTimeKeys: $ReadOnlyArray, +notificationsOneTimeKeys: $ReadOnlyArray, }; export type PickledOLMAccount = { +picklingKey: string, +pickledAccount: string, }; export type NotificationsOlmDataType = { +mainSession: string, +picklingKey: string, +pendingSessionUpdate: string, +updateCreationTimestamp: number, }; export type IdentityKeysBlob = { +primaryIdentityPublicKeys: OLMIdentityKeys, +notificationIdentityPublicKeys: OLMIdentityKeys, }; export const identityKeysBlobValidator: TInterface = tShape({ primaryIdentityPublicKeys: olmIdentityKeysValidator, notificationIdentityPublicKeys: olmIdentityKeysValidator, }); export type SignedIdentityKeysBlob = { +payload: string, +signature: string, }; export const signedIdentityKeysBlobValidator: TInterface = tShape({ payload: t.String, signature: t.String, }); export type UserDetail = { +username: string, +userID: 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: UserDetail, +issuedAt: string, }; export const olmEncryptedMessageTypes = Object.freeze({ PREKEY: 0, TEXT: 1, }); export type OlmEncryptedMessageTypes = $Values; export type EncryptedData = { +message: string, +messageType: OlmEncryptedMessageTypes, }; export const encryptedDataValidator: TInterface = tShape({ message: t.String, messageType: t.Number, }); export type ClientPublicKeys = { +primaryIdentityPublicKeys: { +ed25519: string, +curve25519: string, }, +notificationIdentityPublicKeys: { +ed25519: string, +curve25519: string, }, +blobPayload: string, +signature: string, }; +export type OutboundSessionCreationResult = { + +encryptedData: EncryptedData, + +sessionVersion: number, +}; + export type OlmAPI = { +initializeCryptoAccount: () => Promise, +getUserPublicKey: () => Promise, +encrypt: (content: string, deviceID: string) => Promise, +decrypt: (encryptedData: EncryptedData, deviceID: string) => Promise, +contentInboundSessionCreator: ( contentIdentityKeys: OLMIdentityKeys, initialEncryptedData: EncryptedData, + sessionVersion: number, ) => Promise, +contentOutboundSessionCreator: ( contentIdentityKeys: OLMIdentityKeys, contentInitializationInfo: OlmSessionInitializationInfo, - ) => Promise, + ) => Promise, +notificationsSessionCreator: ( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ) => Promise, +getOneTimeKeys: (numberOfKeys: number) => Promise, +validateAndUploadPrekeys: (authMetadata: AuthMetadata) => Promise, +signMessage: (message: string) => Promise, }; diff --git a/lib/types/tunnelbroker/peer-to-peer-message-types.js b/lib/types/tunnelbroker/peer-to-peer-message-types.js index 1129efacc..abe44dd90 100644 --- a/lib/types/tunnelbroker/peer-to-peer-message-types.js +++ b/lib/types/tunnelbroker/peer-to-peer-message-types.js @@ -1,101 +1,103 @@ // @flow import type { TInterface, TUnion } from 'tcomb'; import t from 'tcomb'; import { tShape, tString } from '../../utils/validation-utils.js'; import { type EncryptedData, encryptedDataValidator } from '../crypto-types.js'; import { signedDeviceListValidator, type SignedDeviceList, } from '../identity-service-types.js'; export type SenderInfo = { +userID: string, +deviceID: string, }; const senderInfoValidator: TInterface = tShape({ userID: t.String, deviceID: t.String, }); export const peerToPeerMessageTypes = Object.freeze({ OUTBOUND_SESSION_CREATION: 'OutboundSessionCreation', ENCRYPTED_MESSAGE: 'EncryptedMessage', REFRESH_KEY_REQUEST: 'RefreshKeyRequest', QR_CODE_AUTH_MESSAGE: 'QRCodeAuthMessage', DEVICE_LIST_UPDATED: 'DeviceListUpdated', }); export type OutboundSessionCreation = { +type: 'OutboundSessionCreation', +senderInfo: SenderInfo, +encryptedData: EncryptedData, + +sessionVersion: number, }; export const outboundSessionCreationValidator: TInterface = tShape({ type: tString(peerToPeerMessageTypes.OUTBOUND_SESSION_CREATION), senderInfo: senderInfoValidator, encryptedData: encryptedDataValidator, + sessionVersion: t.Number, }); export type EncryptedMessage = { +type: 'EncryptedMessage', +senderInfo: SenderInfo, +encryptedData: EncryptedData, }; export const encryptedMessageValidator: TInterface = tShape({ type: tString(peerToPeerMessageTypes.ENCRYPTED_MESSAGE), senderInfo: senderInfoValidator, encryptedData: encryptedDataValidator, }); export type RefreshKeyRequest = { +type: 'RefreshKeyRequest', +deviceID: string, +numberOfKeys: number, }; export const refreshKeysRequestValidator: TInterface = tShape({ type: tString(peerToPeerMessageTypes.REFRESH_KEY_REQUEST), deviceID: t.String, numberOfKeys: t.Number, }); export type QRCodeAuthMessage = { +type: 'QRCodeAuthMessage', +encryptedContent: string, }; export const qrCodeAuthMessageValidator: TInterface = tShape({ type: tString(peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE), encryptedContent: t.String, }); export type DeviceListUpdated = { +type: 'DeviceListUpdated', +userID: string, +signedDeviceList: SignedDeviceList, }; export const deviceListUpdatedValidator: TInterface = tShape({ type: tString(peerToPeerMessageTypes.DEVICE_LIST_UPDATED), userID: t.String, signedDeviceList: signedDeviceListValidator, }); export type PeerToPeerMessage = | OutboundSessionCreation | EncryptedMessage | RefreshKeyRequest | QRCodeAuthMessage | DeviceListUpdated; export const peerToPeerMessageValidator: TUnion = t.union([ outboundSessionCreationValidator, encryptedMessageValidator, refreshKeysRequestValidator, qrCodeAuthMessageValidator, deviceListUpdatedValidator, ]); diff --git a/lib/utils/crypto-utils.js b/lib/utils/crypto-utils.js index f041495d5..f53973248 100644 --- a/lib/utils/crypto-utils.js +++ b/lib/utils/crypto-utils.js @@ -1,116 +1,118 @@ // @flow import t from 'tcomb'; import { type TInterface } from 'tcomb'; import { getConfig } from './config.js'; import { primaryIdentityPublicKeyRegex } from './siwe-utils.js'; import { tRegex, tShape } from './validation-utils.js'; import type { AuthMetadata } from '../shared/identity-client-context.js'; import type { ClientMessageToDevice } from '../tunnelbroker/tunnelbroker-context.js'; import type { IdentityKeysBlob, OLMIdentityKeys, SignedIdentityKeysBlob, } from '../types/crypto-types'; import type { IdentityServiceClient } from '../types/identity-service-types'; import { type OutboundSessionCreation, peerToPeerMessageTypes, } from '../types/tunnelbroker/peer-to-peer-message-types.js'; const signedIdentityKeysBlobValidator: TInterface = tShape({ payload: t.String, signature: t.String, }); const olmIdentityKeysValidator: TInterface = tShape({ ed25519: tRegex(primaryIdentityPublicKeyRegex), curve25519: tRegex(primaryIdentityPublicKeyRegex), }); const identityKeysBlobValidator: TInterface = tShape({ primaryIdentityPublicKeys: olmIdentityKeysValidator, notificationIdentityPublicKeys: olmIdentityKeysValidator, }); async function getContentSigningKey(): Promise { const { olmAPI } = getConfig(); await olmAPI.initializeCryptoAccount(); const { primaryIdentityPublicKeys: { ed25519 }, } = await olmAPI.getUserPublicKey(); return ed25519; } async function createOlmSessionsWithOwnDevices( authMetadata: AuthMetadata, identityClient: IdentityServiceClient, sendMessage: (message: ClientMessageToDevice) => Promise, ): Promise { const { olmAPI } = getConfig(); const { userID, deviceID, accessToken } = authMetadata; await olmAPI.initializeCryptoAccount(); if (!userID || !deviceID || !accessToken) { throw new Error('CommServicesAuthMetadata is missing'); } const keysResponse = await identityClient.getOutboundKeysForUser(userID); for (const deviceKeys of keysResponse) { const { keys } = deviceKeys; if (!keys) { console.log(`Keys missing for device ${deviceKeys.deviceID}`); continue; } const { primaryIdentityPublicKeys } = keys.identityKeysBlob; if (primaryIdentityPublicKeys.ed25519 === deviceID) { continue; } const recipientDeviceID = primaryIdentityPublicKeys.ed25519; if (!keys.contentInitializationInfo.oneTimeKey) { console.log(`One-time key is missing for device ${recipientDeviceID}`); continue; } try { - const encryptedData = await olmAPI.contentOutboundSessionCreator( - primaryIdentityPublicKeys, - keys.contentInitializationInfo, - ); + const { sessionVersion, encryptedData } = + await olmAPI.contentOutboundSessionCreator( + primaryIdentityPublicKeys, + keys.contentInitializationInfo, + ); const sessionCreationMessage: OutboundSessionCreation = { type: peerToPeerMessageTypes.OUTBOUND_SESSION_CREATION, senderInfo: { userID, deviceID, }, encryptedData, + sessionVersion, }; await sendMessage({ deviceID: recipientDeviceID, payload: JSON.stringify(sessionCreationMessage), }); console.log( `Request to create a session with device ${recipientDeviceID} sent.`, ); } catch (e) { console.log( 'Error creating outbound session with ' + `device ${recipientDeviceID}: ${e.message}`, ); } } } export { signedIdentityKeysBlobValidator, identityKeysBlobValidator, getContentSigningKey, createOlmSessionsWithOwnDevices, }; diff --git a/native/crypto/olm-api.js b/native/crypto/olm-api.js index 6344e2144..5a497788c 100644 --- a/native/crypto/olm-api.js +++ b/native/crypto/olm-api.js @@ -1,92 +1,94 @@ // @flow import { getOneTimeKeyValues } from 'lib/shared/crypto-utils.js'; import { type AuthMetadata } from 'lib/shared/identity-client-context.js'; import { type OneTimeKeysResultValues, type OlmAPI, type OLMIdentityKeys, type EncryptedData, } from 'lib/types/crypto-types.js'; import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js'; import { commCoreModule } from '../native-modules.js'; const olmAPI: OlmAPI = { async initializeCryptoAccount(): Promise { await commCoreModule.initializeCryptoAccount(); }, getUserPublicKey: commCoreModule.getUserPublicKey, encrypt: commCoreModule.encrypt, decrypt: commCoreModule.decrypt, + // $FlowFixMe async contentInboundSessionCreator( contentIdentityKeys: OLMIdentityKeys, initialEncryptedData: EncryptedData, ): Promise { const identityKeys = JSON.stringify({ curve25519: contentIdentityKeys.curve25519, ed25519: contentIdentityKeys.ed25519, }); return commCoreModule.initializeContentInboundSession( identityKeys, initialEncryptedData, contentIdentityKeys.ed25519, ); }, async contentOutboundSessionCreator( contentIdentityKeys: OLMIdentityKeys, contentInitializationInfo: OlmSessionInitializationInfo, + // $FlowFixMe ): Promise { const { prekey, prekeySignature, oneTimeKey } = contentInitializationInfo; const identityKeys = JSON.stringify({ curve25519: contentIdentityKeys.curve25519, ed25519: contentIdentityKeys.ed25519, }); return commCoreModule.initializeContentOutboundSession( identityKeys, prekey, prekeySignature, oneTimeKey, contentIdentityKeys.ed25519, ); }, notificationsSessionCreator( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ): Promise { const { prekey, prekeySignature, oneTimeKey } = notificationsInitializationInfo; return commCoreModule.initializeNotificationsSession( JSON.stringify(notificationsIdentityKeys), prekey, prekeySignature, oneTimeKey, keyserverID, ); }, async getOneTimeKeys(numberOfKeys: number): Promise { const { contentOneTimeKeys, notificationsOneTimeKeys } = await commCoreModule.getOneTimeKeys(numberOfKeys); return { contentOneTimeKeys: getOneTimeKeyValues(contentOneTimeKeys), notificationsOneTimeKeys: getOneTimeKeyValues(notificationsOneTimeKeys), }; }, async validateAndUploadPrekeys(authMetadata: AuthMetadata): Promise { const { userID, deviceID, accessToken } = authMetadata; if (!userID || !deviceID || !accessToken) { return; } await commCoreModule.validateAndUploadPrekeys( userID, deviceID, accessToken, ); }, signMessage: commCoreModule.signMessage, }; export { olmAPI }; diff --git a/web/shared-worker/worker/worker-crypto.js b/web/shared-worker/worker/worker-crypto.js index d7a3eda66..dadb52d09 100644 --- a/web/shared-worker/worker/worker-crypto.js +++ b/web/shared-worker/worker/worker-crypto.js @@ -1,651 +1,667 @@ // @flow import olm from '@commapp/olm'; import localforage from 'localforage'; import uuid from 'uuid'; import { initialEncryptedMessageContent } from 'lib/shared/crypto-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { type OLMIdentityKeys, type PickledOLMAccount, type IdentityKeysBlob, type SignedIdentityKeysBlob, type OlmAPI, type OneTimeKeysResultValues, type ClientPublicKeys, type NotificationsOlmDataType, type EncryptedData, + type OutboundSessionCreationResult, } from 'lib/types/crypto-types.js'; import type { IdentityNewDeviceKeyUpload, IdentityExistingDeviceKeyUpload, } from 'lib/types/identity-service-types.js'; import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js'; import { entries } from 'lib/utils/objects.js'; import { retrieveAccountKeysSet, getAccountOneTimeKeys, getAccountPrekeysSet, shouldForgetPrekey, shouldRotatePrekey, retrieveIdentityKeysAndPrekeys, } from 'lib/utils/olm-utils.js'; import { getIdentityClient } from './identity-client.js'; import { getProcessingStoreOpsExceptionMessage } from './process-operations.js'; import { getDBModule, getSQLiteQueryExecutor, getPlatformDetails, } from './worker-database.js'; import { encryptData, exportKeyToJWK, generateCryptoKey, } from '../../crypto/aes-gcm-crypto-utils.js'; import { getOlmDataContentKeyForCookie, getOlmEncryptionKeyDBLabelForCookie, } from '../../push-notif/notif-crypto-utils.js'; import { type WorkerRequestMessage, type WorkerResponseMessage, workerRequestMessageTypes, workerResponseMessageTypes, type LegacyCryptoStore, } from '../../types/worker-types.js'; import type { OlmPersistSession } from '../types/sqlite-query-executor.js'; import { isDesktopSafari } from '../utils/db-utils.js'; type OlmSession = { +session: olm.Session, +version: number }; type OlmSessions = { [deviceID: string]: OlmSession, }; type WorkerCryptoStore = { +contentAccountPickleKey: string, +contentAccount: olm.Account, +contentSessions: OlmSessions, +notificationAccountPickleKey: string, +notificationAccount: olm.Account, }; let cryptoStore: ?WorkerCryptoStore = null; function clearCryptoStore() { cryptoStore = null; } function persistCryptoStore() { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error( "Couldn't persist crypto store because database is not initialized", ); } if (!cryptoStore) { throw new Error("Couldn't persist crypto store because it doesn't exist"); } const { contentAccountPickleKey, contentAccount, contentSessions, notificationAccountPickleKey, notificationAccount, } = cryptoStore; const pickledContentAccount: PickledOLMAccount = { picklingKey: contentAccountPickleKey, pickledAccount: contentAccount.pickle(contentAccountPickleKey), }; const pickledContentSessions: OlmPersistSession[] = entries( contentSessions, ).map(([targetDeviceID, sessionData]) => ({ targetDeviceID, sessionData: sessionData.session.pickle(contentAccountPickleKey), version: sessionData.version, })); const pickledNotificationAccount: PickledOLMAccount = { picklingKey: notificationAccountPickleKey, pickledAccount: notificationAccount.pickle(notificationAccountPickleKey), }; try { sqliteQueryExecutor.storeOlmPersistAccount( sqliteQueryExecutor.getContentAccountID(), JSON.stringify(pickledContentAccount), ); for (const pickledSession of pickledContentSessions) { sqliteQueryExecutor.storeOlmPersistSession(pickledSession); } sqliteQueryExecutor.storeOlmPersistAccount( sqliteQueryExecutor.getNotifsAccountID(), JSON.stringify(pickledNotificationAccount), ); } catch (err) { throw new Error(getProcessingStoreOpsExceptionMessage(err, dbModule)); } } function getOrCreateOlmAccount(accountIDInDB: number): { +picklingKey: string, +account: olm.Account, } { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error('Database not initialized'); } const account = new olm.Account(); let picklingKey; let accountDBString; try { accountDBString = sqliteQueryExecutor.getOlmPersistAccountDataWeb(accountIDInDB); } catch (err) { throw new Error(getProcessingStoreOpsExceptionMessage(err, dbModule)); } if (accountDBString.isNull) { picklingKey = uuid.v4(); account.create(); } else { const dbAccount: PickledOLMAccount = JSON.parse(accountDBString.value); picklingKey = dbAccount.picklingKey; account.unpickle(picklingKey, dbAccount.pickledAccount); } return { picklingKey, account }; } function getOlmSessions(picklingKey: string): OlmSessions { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error( "Couldn't get olm sessions because database is not initialized", ); } let dbSessionsData; try { dbSessionsData = sqliteQueryExecutor.getOlmPersistSessionsData(); } catch (err) { throw new Error(getProcessingStoreOpsExceptionMessage(err, dbModule)); } const sessionsData: OlmSessions = {}; for (const persistedSession: OlmPersistSession of dbSessionsData) { const { sessionData, version } = persistedSession; const session = new olm.Session(); session.unpickle(picklingKey, sessionData); sessionsData[persistedSession.targetDeviceID] = { session, version, }; } return sessionsData; } function unpickleInitialCryptoStoreAccount( account: PickledOLMAccount, ): olm.Account { const { picklingKey, pickledAccount } = account; const olmAccount = new olm.Account(); olmAccount.unpickle(picklingKey, pickledAccount); return olmAccount; } async function initializeCryptoAccount( olmWasmPath: string, initialCryptoStore: ?LegacyCryptoStore, ) { const sqliteQueryExecutor = getSQLiteQueryExecutor(); if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } await olm.init({ locateFile: () => olmWasmPath }); if (initialCryptoStore) { cryptoStore = { contentAccountPickleKey: initialCryptoStore.primaryAccount.picklingKey, contentAccount: unpickleInitialCryptoStoreAccount( initialCryptoStore.primaryAccount, ), contentSessions: {}, notificationAccountPickleKey: initialCryptoStore.notificationAccount.picklingKey, notificationAccount: unpickleInitialCryptoStoreAccount( initialCryptoStore.notificationAccount, ), }; persistCryptoStore(); return; } await olmAPI.initializeCryptoAccount(); } async function processAppOlmApiRequest( message: WorkerRequestMessage, ): Promise { if (message.type === workerRequestMessageTypes.INITIALIZE_CRYPTO_ACCOUNT) { await initializeCryptoAccount( message.olmWasmPath, message.initialCryptoStore, ); } else if (message.type === workerRequestMessageTypes.CALL_OLM_API_METHOD) { const method: (...$ReadOnlyArray) => mixed = (olmAPI[ message.method ]: any); // Flow doesn't allow us to bind the (stringified) method name with // the argument types so we need to pass the args as mixed. const result = await method(...message.args); return { type: workerResponseMessageTypes.CALL_OLM_API_METHOD, result, }; } return undefined; } function getSignedIdentityKeysBlob(): SignedIdentityKeysBlob { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, notificationAccount } = cryptoStore; const identityKeysBlob: IdentityKeysBlob = { notificationIdentityPublicKeys: JSON.parse( notificationAccount.identity_keys(), ), primaryIdentityPublicKeys: JSON.parse(contentAccount.identity_keys()), }; const payloadToBeSigned: string = JSON.stringify(identityKeysBlob); const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: payloadToBeSigned, signature: contentAccount.sign(payloadToBeSigned), }; return signedIdentityKeysBlob; } function getNewDeviceKeyUpload(): IdentityNewDeviceKeyUpload { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, notificationAccount } = cryptoStore; const signedIdentityKeysBlob = getSignedIdentityKeysBlob(); const primaryAccountKeysSet = retrieveAccountKeysSet(contentAccount); const notificationAccountKeysSet = retrieveAccountKeysSet(notificationAccount); persistCryptoStore(); return { keyPayload: signedIdentityKeysBlob.payload, keyPayloadSignature: signedIdentityKeysBlob.signature, contentPrekey: primaryAccountKeysSet.prekey, contentPrekeySignature: primaryAccountKeysSet.prekeySignature, notifPrekey: notificationAccountKeysSet.prekey, notifPrekeySignature: notificationAccountKeysSet.prekeySignature, contentOneTimeKeys: primaryAccountKeysSet.oneTimeKeys, notifOneTimeKeys: notificationAccountKeysSet.oneTimeKeys, }; } function getExistingDeviceKeyUpload(): IdentityExistingDeviceKeyUpload { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, notificationAccount } = cryptoStore; const signedIdentityKeysBlob = getSignedIdentityKeysBlob(); const { prekey: contentPrekey, prekeySignature: contentPrekeySignature } = retrieveIdentityKeysAndPrekeys(contentAccount); const { prekey: notifPrekey, prekeySignature: notifPrekeySignature } = retrieveIdentityKeysAndPrekeys(notificationAccount); persistCryptoStore(); return { keyPayload: signedIdentityKeysBlob.payload, keyPayloadSignature: signedIdentityKeysBlob.signature, contentPrekey, contentPrekeySignature, notifPrekey, notifPrekeySignature, }; } const olmAPI: OlmAPI = { async initializeCryptoAccount(): Promise { const sqliteQueryExecutor = getSQLiteQueryExecutor(); if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } const contentAccountResult = getOrCreateOlmAccount( sqliteQueryExecutor.getContentAccountID(), ); const notificationAccountResult = getOrCreateOlmAccount( sqliteQueryExecutor.getNotifsAccountID(), ); const contentSessions = getOlmSessions(contentAccountResult.picklingKey); cryptoStore = { contentAccountPickleKey: contentAccountResult.picklingKey, contentAccount: contentAccountResult.account, contentSessions, notificationAccountPickleKey: notificationAccountResult.picklingKey, notificationAccount: notificationAccountResult.account, }; persistCryptoStore(); }, async getUserPublicKey(): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, notificationAccount } = cryptoStore; const { payload, signature } = getSignedIdentityKeysBlob(); return { primaryIdentityPublicKeys: JSON.parse(contentAccount.identity_keys()), notificationIdentityPublicKeys: JSON.parse( notificationAccount.identity_keys(), ), blobPayload: payload, signature, }; }, async encrypt(content: string, deviceID: string): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { session } = cryptoStore.contentSessions[deviceID]; if (!session) { throw new Error(`No session for deviceID: ${deviceID}`); } const encryptedContent = session.encrypt(content); persistCryptoStore(); return { message: encryptedContent.body, messageType: encryptedContent.type, }; }, async decrypt( encryptedData: EncryptedData, deviceID: string, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { session } = cryptoStore.contentSessions[deviceID]; if (!session) { throw new Error(`No session for deviceID: ${deviceID}`); } const result = session.decrypt( encryptedData.messageType, encryptedData.message, ); persistCryptoStore(); return result; }, async contentInboundSessionCreator( contentIdentityKeys: OLMIdentityKeys, initialEncryptedData: EncryptedData, + sessionVersion: number, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, contentSessions } = cryptoStore; + const existingSession = contentSessions[contentIdentityKeys.ed25519]; + if (existingSession && existingSession.version > sessionVersion) { + throw new Error('OLM_SESSION_ALREADY_CREATED'); + } else if (existingSession && existingSession.version === sessionVersion) { + throw new Error('OLM_SESSION_CREATION_RACE_CONDITION'); + } + const session = new olm.Session(); session.create_inbound_from( contentAccount, contentIdentityKeys.curve25519, initialEncryptedData.message, ); contentAccount.remove_one_time_keys(session); const initialEncryptedMessage = session.decrypt( initialEncryptedData.messageType, initialEncryptedData.message, ); contentSessions[contentIdentityKeys.ed25519] = { session, - version: 1, + version: sessionVersion, }; persistCryptoStore(); return initialEncryptedMessage; }, async contentOutboundSessionCreator( contentIdentityKeys: OLMIdentityKeys, contentInitializationInfo: OlmSessionInitializationInfo, - ): Promise { + ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, contentSessions } = cryptoStore; + const existingSession = contentSessions[contentIdentityKeys.ed25519]; const session = new olm.Session(); session.create_outbound( contentAccount, contentIdentityKeys.curve25519, contentIdentityKeys.ed25519, contentInitializationInfo.prekey, contentInitializationInfo.prekeySignature, contentInitializationInfo.oneTimeKey, ); const initialEncryptedData = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); - contentSessions[contentIdentityKeys.ed25519] = { session, version: 1 }; + const newSessionVersion = existingSession ? existingSession.version + 1 : 1; + contentSessions[contentIdentityKeys.ed25519] = { + session, + version: newSessionVersion, + }; persistCryptoStore(); - return { + const encryptedData: EncryptedData = { message: initialEncryptedData.body, messageType: initialEncryptedData.type, }; + + return { encryptedData, sessionVersion: newSessionVersion }; }, async notificationsSessionCreator( cookie: ?string, notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, keyserverID: string, ): Promise { const platformDetails = getPlatformDetails(); if (!platformDetails) { throw new Error('Worker not initialized'); } if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { notificationAccountPickleKey, notificationAccount } = cryptoStore; const encryptionKey = await generateCryptoKey({ extractable: isDesktopSafari, }); const notificationsPrekey = notificationsInitializationInfo.prekey; const session = new olm.Session(); session.create_outbound( notificationAccount, notificationsIdentityKeys.curve25519, notificationsIdentityKeys.ed25519, notificationsPrekey, notificationsInitializationInfo.prekeySignature, notificationsInitializationInfo.oneTimeKey, ); const { body: initialNotificationsEncryptedMessage } = session.encrypt( JSON.stringify(initialEncryptedMessageContent), ); const mainSession = session.pickle(notificationAccountPickleKey); const notificationsOlmData: NotificationsOlmDataType = { mainSession, pendingSessionUpdate: mainSession, updateCreationTimestamp: Date.now(), picklingKey: notificationAccountPickleKey, }; const encryptedOlmData = await encryptData( new TextEncoder().encode(JSON.stringify(notificationsOlmData)), encryptionKey, ); let notifsOlmDataContentKey; let notifsOlmDataEncryptionKeyDBLabel; if (hasMinCodeVersion(platformDetails, { majorDesktop: 12 })) { notifsOlmDataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForCookie( cookie, keyserverID, ); notifsOlmDataContentKey = getOlmDataContentKeyForCookie( cookie, keyserverID, ); } else { notifsOlmDataEncryptionKeyDBLabel = getOlmEncryptionKeyDBLabelForCookie(cookie); notifsOlmDataContentKey = getOlmDataContentKeyForCookie(cookie); } 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( notifsOlmDataEncryptionKeyDBLabel, cryptoKeyPersistentForm, ); })(); await Promise.all([ localforage.setItem(notifsOlmDataContentKey, encryptedOlmData), persistEncryptionKeyPromise, ]); return initialNotificationsEncryptedMessage; }, async getOneTimeKeys(numberOfKeys: number): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, notificationAccount } = cryptoStore; const contentOneTimeKeys = getAccountOneTimeKeys( contentAccount, numberOfKeys, ); contentAccount.mark_keys_as_published(); const notificationsOneTimeKeys = getAccountOneTimeKeys( notificationAccount, numberOfKeys, ); notificationAccount.mark_keys_as_published(); persistCryptoStore(); return { contentOneTimeKeys, notificationsOneTimeKeys }; }, async validateAndUploadPrekeys(authMetadata): Promise { const { userID, deviceID, accessToken } = authMetadata; if (!userID || !deviceID || !accessToken) { return; } const identityClient = getIdentityClient(); if (!identityClient) { throw new Error('Identity client not initialized'); } if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, notificationAccount } = cryptoStore; // 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(); } if (shouldForgetPrekey(contentAccount)) { contentAccount.forget_old_prekey(); notificationAccount.forget_old_prekey(); } persistCryptoStore(); if (!contentAccount.unpublished_prekey()) { return; } const { prekey: notifPrekey, prekeySignature: notifPrekeySignature } = getAccountPrekeysSet(notificationAccount); const { prekey: contentPrekey, prekeySignature: contentPrekeySignature } = getAccountPrekeysSet(contentAccount); if (!notifPrekeySignature || !contentPrekeySignature) { throw new Error('Prekey signature is missing'); } await identityClient.publishWebPrekeys({ contentPrekey, contentPrekeySignature, notifPrekey, notifPrekeySignature, }); contentAccount.mark_prekey_as_published(); notificationAccount.mark_prekey_as_published(); persistCryptoStore(); }, async signMessage(message: string): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount } = cryptoStore; return contentAccount.sign(message); }, }; export { clearCryptoStore, processAppOlmApiRequest, getSignedIdentityKeysBlob, getNewDeviceKeyUpload, getExistingDeviceKeyUpload, };