diff --git a/lib/types/crypto-types.js b/lib/types/crypto-types.js index 836b4b32d..fe0d84450 100644 --- a/lib/types/crypto-types.js +++ b/lib/types/crypto-types.js @@ -1,135 +1,140 @@ // @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 CryptoStore = { +primaryAccount: PickledOLMAccount, +primaryIdentityKeys: OLMIdentityKeys, +notificationAccount: PickledOLMAccount, +notificationIdentityKeys: OLMIdentityKeys, }; export type CryptoStoreContextType = { +getInitializedCryptoStore: () => Promise, }; 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 OlmAPI = { +initializeCryptoAccount: () => Promise, +encrypt: (content: string, deviceID: string) => Promise, +decrypt: (encryptedContent: string, deviceID: string) => Promise, +contentInboundSessionCreator: ( contentIdentityKeys: OLMIdentityKeys, initialEncryptedContent: string, ) => Promise, + +contentOutboundSessionCreator: ( + contentIdentityKeys: OLMIdentityKeys, + contentInitializationInfo: OlmSessionInitializationInfo, + ) => Promise, +getOneTimeKeys: (numberOfKeys: number) => Promise, +validateAndUploadPrekeys: (authMetadata: AuthMetadata) => Promise, }; diff --git a/lib/utils/__mocks__/config.js b/lib/utils/__mocks__/config.js index 94c5a0d4e..c19d71253 100644 --- a/lib/utils/__mocks__/config.js +++ b/lib/utils/__mocks__/config.js @@ -1,27 +1,28 @@ // @flow import { type Config } from '../config.js'; const getConfig = (): Config => ({ resolveKeyserverSessionInvalidationUsingNativeCredentials: null, setSessionIDOnRequest: true, calendarRangeInactivityLimit: null, platformDetails: { platform: 'web', codeVersion: 70, stateVersion: 50, }, authoritativeKeyserverID: '123', olmAPI: { initializeCryptoAccount: jest.fn(), encrypt: jest.fn(), decrypt: jest.fn(), contentInboundSessionCreator: jest.fn(), + contentOutboundSessionCreator: jest.fn(), getOneTimeKeys: jest.fn(), validateAndUploadPrekeys: jest.fn(), }, }); const hasConfig = (): boolean => true; export { getConfig, hasConfig }; diff --git a/native/crypto/olm-api.js b/native/crypto/olm-api.js index efba59b71..c988dc6ad 100644 --- a/native/crypto/olm-api.js +++ b/native/crypto/olm-api.js @@ -1,54 +1,73 @@ // @flow import { getOneTimeKeyValues } from 'lib/shared/crypto-utils.js'; import { type AuthMetadata } from 'lib/shared/identity-client-context.js'; import type { OneTimeKeysResultValues, OlmAPI, OLMIdentityKeys, } from 'lib/types/crypto-types'; +import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js'; import { commCoreModule } from '../native-modules.js'; const olmAPI: OlmAPI = { async initializeCryptoAccount(): Promise { await commCoreModule.initializeCryptoAccount(); }, encrypt: commCoreModule.encrypt, decrypt: commCoreModule.decrypt, async contentInboundSessionCreator( contentIdentityKeys: OLMIdentityKeys, initialEncryptedContent: string, ): Promise { const identityKeys = JSON.stringify({ curve25519: contentIdentityKeys.curve25519, ed25519: contentIdentityKeys.ed25519, }); return commCoreModule.initializeContentInboundSession( identityKeys, initialEncryptedContent, contentIdentityKeys.ed25519, ); }, + async contentOutboundSessionCreator( + contentIdentityKeys: OLMIdentityKeys, + contentInitializationInfo: OlmSessionInitializationInfo, + ): 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, + ); + }, 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, ); }, }; export { olmAPI }; diff --git a/web/crypto/olm-api.js b/web/crypto/olm-api.js index cdff26c1c..32cb324fa 100644 --- a/web/crypto/olm-api.js +++ b/web/crypto/olm-api.js @@ -1,61 +1,62 @@ // @flow import olm from '@commapp/olm'; import { type OlmAPI } from 'lib/types/crypto-types.js'; import { getCommSharedWorker } from '../shared-worker/shared-worker-provider.js'; import { getOlmWasmPath } from '../shared-worker/utils/constants.js'; import { workerRequestMessageTypes, workerResponseMessageTypes, } from '../types/worker-types.js'; const usingSharedWorker = false; function proxyToWorker( method: $Keys, ): (...args: $ReadOnlyArray) => Promise { return async (...args: $ReadOnlyArray) => { const sharedWorker = await getCommSharedWorker(); const result = await sharedWorker.schedule({ type: workerRequestMessageTypes.CALL_OLM_API_METHOD, method, args, }); if (!result) { throw new Error(`Worker OlmAPI call didn't return expected message`); } else if (result.type !== workerResponseMessageTypes.CALL_OLM_API_METHOD) { throw new Error( `Worker OlmAPI call didn't return expected message. Instead got: ${JSON.stringify( result, )}`, ); } // Worker should return a message with the corresponding return type return (result.result: any); }; } const olmAPI: OlmAPI = { async initializeCryptoAccount(): Promise { if (usingSharedWorker) { const sharedWorker = await getCommSharedWorker(); await sharedWorker.schedule({ type: workerRequestMessageTypes.INITIALIZE_CRYPTO_ACCOUNT, olmWasmPath: getOlmWasmPath(), }); } else { await olm.init(); } }, encrypt: proxyToWorker('encrypt'), decrypt: proxyToWorker('decrypt'), contentInboundSessionCreator: proxyToWorker('contentInboundSessionCreator'), + contentOutboundSessionCreator: proxyToWorker('contentOutboundSessionCreator'), getOneTimeKeys: proxyToWorker('getOneTimeKeys'), validateAndUploadPrekeys: proxyToWorker('validateAndUploadPrekeys'), }; export { olmAPI, usingSharedWorker }; diff --git a/web/shared-worker/worker/worker-crypto.js b/web/shared-worker/worker/worker-crypto.js index 5a5ed0f79..cbe2a0a5a 100644 --- a/web/shared-worker/worker/worker-crypto.js +++ b/web/shared-worker/worker/worker-crypto.js @@ -1,474 +1,503 @@ // @flow import olm from '@commapp/olm'; import uuid from 'uuid'; +import { initialEncryptedMessageContent } from 'lib/shared/crypto-utils.js'; import { olmEncryptedMessageTypes, type OLMIdentityKeys, type CryptoStore, type PickledOLMAccount, type IdentityKeysBlob, type SignedIdentityKeysBlob, type OlmAPI, type OneTimeKeysResultValues, } 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 } from './worker-database.js'; import { type WorkerRequestMessage, type WorkerResponseMessage, workerRequestMessageTypes, workerResponseMessageTypes, } from '../../types/worker-types.js'; import type { OlmPersistSession } from '../types/sqlite-query-executor.js'; type WorkerCryptoStore = { +contentAccountPickleKey: string, +contentAccount: olm.Account, +contentSessions: { [deviceID: string]: olm.Session }, +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(([deviceID, session]) => ({ targetUserID: deviceID, sessionData: session.pickle(contentAccountPickleKey), })); 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): { [deviceID: string]: olm.Session, } { const sqliteQueryExecutor = getSQLiteQueryExecutor(); const dbModule = getDBModule(); if (!sqliteQueryExecutor || !dbModule) { throw new Error( "Couldn't get olm sessions because database is not initialized", ); } let sessionsData; try { sessionsData = sqliteQueryExecutor.getOlmPersistSessionsData(); } catch (err) { throw new Error(getProcessingStoreOpsExceptionMessage(err, dbModule)); } const sessions: { [deviceID: string]: olm.Session } = {}; for (const sessionData of sessionsData) { const session = new olm.Session(); session.unpickle(picklingKey, sessionData.sessionData); sessions[sessionData.targetUserID] = session; } return sessions; } 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: ?CryptoStore, ) { 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 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 { body } = session.encrypt(content); persistCryptoStore(); return body; }, async decrypt(encryptedContent: 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 result = session.decrypt( olmEncryptedMessageTypes.TEXT, encryptedContent, ); persistCryptoStore(); return result; }, async contentInboundSessionCreator( contentIdentityKeys: OLMIdentityKeys, initialEncryptedContent: string, ): Promise { if (!cryptoStore) { throw new Error('Crypto account not initialized'); } const { contentAccount, contentSessions } = cryptoStore; const session = new olm.Session(); session.create_inbound_from( contentAccount, contentIdentityKeys.curve25519, initialEncryptedContent, ); contentAccount.remove_one_time_keys(session); const initialEncryptedMessage = session.decrypt( olmEncryptedMessageTypes.PREKEY, initialEncryptedContent, ); contentSessions[contentIdentityKeys.ed25519] = session; persistCryptoStore(); return initialEncryptedMessage; }, + async contentOutboundSessionCreator( + contentIdentityKeys: OLMIdentityKeys, + contentInitializationInfo: OlmSessionInitializationInfo, + ): Promise { + if (!cryptoStore) { + throw new Error('Crypto account not initialized'); + } + const { contentAccount, contentSessions } = cryptoStore; + + const session = new olm.Session(); + session.create_outbound( + contentAccount, + contentIdentityKeys.curve25519, + contentIdentityKeys.ed25519, + contentInitializationInfo.prekey, + contentInitializationInfo.prekeySignature, + contentInitializationInfo.oneTimeKey, + ); + const { body: initialContentEncryptedMessage } = session.encrypt( + JSON.stringify(initialEncryptedMessageContent), + ); + + contentSessions[contentIdentityKeys.ed25519] = session; + persistCryptoStore(); + + return initialContentEncryptedMessage; + }, 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(); }, }; export { clearCryptoStore, processAppOlmApiRequest, getSignedIdentityKeysBlob, getNewDeviceKeyUpload, getExistingDeviceKeyUpload, };