diff --git a/web/account/account-hooks.js b/web/account/account-hooks.js
index c1063ba5c..ecb3950c1 100644
--- a/web/account/account-hooks.js
+++ b/web/account/account-hooks.js
@@ -1,411 +1,435 @@
// @flow
import olm from '@commapp/olm';
import invariant from 'invariant';
import localforage from 'localforage';
import * as React from 'react';
import uuid from 'uuid';
import { initialEncryptedMessageContent } from 'lib/shared/crypto-utils.js';
import { OlmSessionCreatorContext } from 'lib/shared/olm-session-creator-context.js';
+import {
+ hasMinCodeVersion,
+ NEXT_CODE_VERSION,
+} from 'lib/shared/version-utils.js';
import type {
SignedIdentityKeysBlob,
CryptoStore,
IdentityKeysBlob,
CryptoStoreContextType,
OLMIdentityKeys,
NotificationsOlmDataType,
} from 'lib/types/crypto-types.js';
import { type IdentityDeviceKeyUpload } from 'lib/types/identity-service-types.js';
import type { OlmSessionInitializationInfo } from 'lib/types/request-types.js';
+import { getConfig } from 'lib/utils/config.js';
import { retrieveAccountKeysSet } from 'lib/utils/olm-utils.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import {
generateCryptoKey,
encryptData,
exportKeyToJWK,
} from '../crypto/aes-gcm-crypto-utils.js';
import { initOlm } from '../olm/olm-utils.js';
import {
getOlmDataContentKeyForCookie,
getOlmEncryptionKeyDBLabelForCookie,
} from '../push-notif/notif-crypto-utils.js';
import { setCryptoStore } from '../redux/crypto-store-reducer.js';
import { useSelector } from '../redux/redux-utils.js';
import { isDesktopSafari } from '../shared-worker/utils/db-utils.js';
const CryptoStoreContext: 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 Promise.all([getOrCreateCryptoStore(), 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 useGetDeviceKeyUpload(): () => Promise {
const getOrCreateCryptoStore = useGetOrCreateCryptoStore();
// `getSignedIdentityKeysBlob()` will initialize OLM, so no need to do it
// again
const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob();
const dispatch = useDispatch();
return React.useCallback(async () => {
const [signedIdentityKeysBlob, cryptoStore] = await Promise.all([
getSignedIdentityKeysBlob(),
getOrCreateCryptoStore(),
]);
const primaryOLMAccount = new olm.Account();
const notificationOLMAccount = new olm.Account();
primaryOLMAccount.unpickle(
cryptoStore.primaryAccount.picklingKey,
cryptoStore.primaryAccount.pickledAccount,
);
notificationOLMAccount.unpickle(
cryptoStore.notificationAccount.picklingKey,
cryptoStore.notificationAccount.pickledAccount,
);
const primaryAccountKeysSet = retrieveAccountKeysSet(primaryOLMAccount);
const notificationAccountKeysSet = retrieveAccountKeysSet(
notificationOLMAccount,
);
const pickledPrimaryAccount = primaryOLMAccount.pickle(
cryptoStore.primaryAccount.picklingKey,
);
const pickledNotificationAccount = notificationOLMAccount.pickle(
cryptoStore.notificationAccount.picklingKey,
);
const updatedCryptoStore = {
primaryAccount: {
picklingKey: cryptoStore.primaryAccount.picklingKey,
pickledAccount: pickledPrimaryAccount,
},
primaryIdentityKeys: cryptoStore.primaryIdentityKeys,
notificationAccount: {
picklingKey: cryptoStore.notificationAccount.picklingKey,
pickledAccount: pickledNotificationAccount,
},
notificationIdentityKeys: cryptoStore.notificationIdentityKeys,
};
dispatch({ type: setCryptoStore, payload: updatedCryptoStore });
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,
};
}, [dispatch, getOrCreateCryptoStore, getSignedIdentityKeysBlob]);
}
function OlmSessionCreatorProvider(props: Props): React.Node {
const getOrCreateCryptoStore = useGetOrCreateCryptoStore();
const currentCryptoStore = useSelector(state => state.cryptoStore);
+ const platformDetails = getConfig().platformDetails;
const createNewNotificationsSession = React.useCallback(
async (
cookie: ?string,
notificationsIdentityKeys: OLMIdentityKeys,
notificationsInitializationInfo: OlmSessionInitializationInfo,
keyserverID: string,
) => {
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 = notificationsInitializationInfo.prekey;
const session = new olm.Session();
session.create_outbound(
account,
notificationsIdentityKeys.curve25519,
notificationsIdentityKeys.ed25519,
notificationsPrekey,
notificationsInitializationInfo.prekeySignature,
notificationsInitializationInfo.oneTimeKey,
);
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 notifsOlmDataEncryptionKeyDBLabel =
- getOlmEncryptionKeyDBLabelForCookie(cookie, keyserverID);
- const notifsOlmDataContentKey = getOlmDataContentKeyForCookie(
- cookie,
- keyserverID,
- );
+ let notifsOlmDataContentKey;
+ let notifsOlmDataEncryptionKeyDBLabel;
+
+ if (
+ hasMinCodeVersion(platformDetails, { majorDesktop: NEXT_CODE_VERSION })
+ ) {
+ 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;
},
- [getOrCreateCryptoStore],
+ [getOrCreateCryptoStore, platformDetails],
);
const createNewContentSession = React.useCallback(
async (
contentIdentityKeys: OLMIdentityKeys,
contentInitializationInfo: OlmSessionInitializationInfo,
) => {
const [{ primaryAccount }] = await Promise.all([
getOrCreateCryptoStore(),
initOlm(),
]);
const account = new olm.Account();
const { picklingKey, pickledAccount } = primaryAccount;
account.unpickle(picklingKey, pickledAccount);
const contentPrekey = contentInitializationInfo.prekey;
const session = new olm.Session();
session.create_outbound(
account,
contentIdentityKeys.curve25519,
contentIdentityKeys.ed25519,
contentPrekey,
contentInitializationInfo.prekeySignature,
contentInitializationInfo.oneTimeKey,
);
const { body: initialContentEncryptedMessage } = session.encrypt(
JSON.stringify(initialEncryptedMessageContent),
);
return initialContentEncryptedMessage;
},
[getOrCreateCryptoStore],
);
- const notificationsSessionPromise = React.useRef>(null);
+ const perKeyserverNotificationsSessionPromises = React.useRef<{
+ [keyserverID: string]: ?Promise,
+ }>({});
+
const createNotificationsSession = React.useCallback(
async (
cookie: ?string,
notificationsIdentityKeys: OLMIdentityKeys,
notificationsInitializationInfo: OlmSessionInitializationInfo,
keyserverID: string,
) => {
- if (notificationsSessionPromise.current) {
- return notificationsSessionPromise.current;
+ if (perKeyserverNotificationsSessionPromises.current[keyserverID]) {
+ return perKeyserverNotificationsSessionPromises.current[keyserverID];
}
const newNotificationsSessionPromise = (async () => {
try {
return await createNewNotificationsSession(
cookie,
notificationsIdentityKeys,
notificationsInitializationInfo,
keyserverID,
);
} catch (e) {
- notificationsSessionPromise.current = undefined;
+ perKeyserverNotificationsSessionPromises.current[keyserverID] =
+ undefined;
throw e;
}
})();
- notificationsSessionPromise.current = newNotificationsSessionPromise;
+ perKeyserverNotificationsSessionPromises.current[keyserverID] =
+ newNotificationsSessionPromise;
return newNotificationsSessionPromise;
},
[createNewNotificationsSession],
);
const isCryptoStoreSet = !!currentCryptoStore;
React.useEffect(() => {
if (!isCryptoStoreSet) {
- notificationsSessionPromise.current = undefined;
+ perKeyserverNotificationsSessionPromises.current = {};
}
}, [isCryptoStoreSet]);
const contextValue = React.useMemo(
() => ({
notificationsSessionCreator: createNotificationsSession,
contentSessionCreator: createNewContentSession,
}),
[createNewContentSession, createNotificationsSession],
);
return (
{props.children}
);
}
export {
useGetSignedIdentityKeysBlob,
useGetOrCreateCryptoStore,
OlmSessionCreatorProvider,
GetOrCreateCryptoStoreProvider,
useGetDeviceKeyUpload,
};
diff --git a/web/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js
index 376f9998a..8c6a0cc4b 100644
--- a/web/push-notif/notif-crypto-utils.js
+++ b/web/push-notif/notif-crypto-utils.js
@@ -1,384 +1,472 @@
// @flow
import olm from '@commapp/olm';
import localforage from 'localforage';
import {
olmEncryptedMessageTypes,
type NotificationsOlmDataType,
} from 'lib/types/crypto-types.js';
import type {
PlainTextWebNotification,
EncryptedWebNotification,
} from 'lib/types/notif-types.js';
import { getCookieIDFromCookie } from 'lib/utils/cookie-utils.js';
import {
type EncryptedData,
decryptData,
encryptData,
importJWKKey,
} from '../crypto/aes-gcm-crypto-utils.js';
import { initOlm } from '../olm/olm-utils.js';
import {
NOTIFICATIONS_OLM_DATA_CONTENT,
NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY,
} from '../shared-worker/utils/constants.js';
import { isDesktopSafari } from '../shared-worker/utils/db-utils.js';
export type WebNotifDecryptionError = {
+id: string,
+error: string,
+displayErrorMessage?: boolean,
};
export type WebNotifsServiceUtilsData = {
+olmWasmPath: string,
+staffCanSee: boolean,
};
type DecryptionResult = {
+newPendingSessionUpdate: string,
+newUpdateCreationTimestamp: number,
+decryptedNotification: T,
};
export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils';
const SESSION_UPDATE_MAX_PENDING_TIME = 10 * 1000;
+const INDEXED_DB_KEYSERVER_PREFIX = 'keyserver';
+const INDEXED_DB_KEY_SEPARATOR = ':';
+
+// This constant is only used to migrate the existing notifications
+// session with production keyserver to new IndexedDB key format. This
+// migration will fire when user updates the app. It will also fire
+// on dev env provided old keyserver set up is used. Developers willing
+// to use new keyserver set up must log out before updating the app.
+// Do not introduce new usages of this constant in the code!!!
+const ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE =
+ '256';
async function decryptWebNotification(
encryptedNotification: EncryptedWebNotification,
): Promise {
- const { id, encryptedPayload } = encryptedNotification;
+ const { id, keyserverID, encryptedPayload } = encryptedNotification;
const utilsData = await localforage.getItem(
WEB_NOTIFS_SERVICE_UTILS_KEY,
);
if (!utilsData) {
return { id, error: 'Necessary data not found in IndexedDB' };
}
const { olmWasmPath, staffCanSee } = (utilsData: WebNotifsServiceUtilsData);
let olmDBKeys;
try {
- olmDBKeys = await getNotifsOlmSessionDBKeys();
+ olmDBKeys = await getNotifsOlmSessionDBKeys(keyserverID);
} catch (e) {
return {
id,
error: e.message,
displayErrorMessage: staffCanSee,
};
}
const { olmDataContentKey, encryptionKeyDBKey } = olmDBKeys;
const [encryptedOlmData, encryptionKey] = await Promise.all([
localforage.getItem(olmDataContentKey),
retrieveEncryptionKey(encryptionKeyDBKey),
]);
if (!encryptionKey || !encryptedOlmData) {
return {
id,
error: 'Received encrypted notification but olm session was not created',
displayErrorMessage: staffCanSee,
};
}
try {
await olm.init({ locateFile: () => olmWasmPath });
const decryptedNotification = await commonDecrypt(
encryptedOlmData,
olmDataContentKey,
encryptionKey,
encryptedPayload,
);
return { id, ...decryptedNotification };
} catch (e) {
return {
id,
error: e.message,
displayErrorMessage: staffCanSee,
};
}
}
async function decryptDesktopNotification(
encryptedPayload: string,
staffCanSee: boolean,
- // eslint-disable-next-line no-unused-vars
keyserverID?: string,
): Promise<{ +[string]: mixed }> {
let encryptedOlmData, encryptionKey, olmDataContentKey;
try {
const { olmDataContentKey: olmDataContentKeyValue, encryptionKeyDBKey } =
- await getNotifsOlmSessionDBKeys();
+ await getNotifsOlmSessionDBKeys(keyserverID);
olmDataContentKey = olmDataContentKeyValue;
[encryptedOlmData, encryptionKey] = await Promise.all([
localforage.getItem(olmDataContentKey),
retrieveEncryptionKey(encryptionKeyDBKey),
initOlm(),
]);
} catch (e) {
return {
error: e.message,
displayErrorMessage: staffCanSee,
};
}
if (!encryptionKey || !encryptedOlmData) {
return {
error: 'Received encrypted notification but olm session was not created',
displayErrorMessage: staffCanSee,
};
}
try {
return await commonDecrypt(
encryptedOlmData,
olmDataContentKey,
encryptionKey,
encryptedPayload,
);
} catch (e) {
return {
error: e.message,
staffCanSee,
};
}
}
async function commonDecrypt(
encryptedOlmData: EncryptedData,
olmDataContentKey: string,
encryptionKey: CryptoKey,
encryptedPayload: string,
): Promise {
const serializedOlmData = await decryptData(encryptedOlmData, encryptionKey);
const {
mainSession,
picklingKey,
pendingSessionUpdate,
updateCreationTimestamp,
}: NotificationsOlmDataType = JSON.parse(
new TextDecoder().decode(serializedOlmData),
);
let updatedOlmData: NotificationsOlmDataType;
let decryptedNotification: T;
const shouldUpdateMainSession =
Date.now() - updateCreationTimestamp > SESSION_UPDATE_MAX_PENDING_TIME;
const decryptionWithPendingSessionResult = decryptWithPendingSession(
pendingSessionUpdate,
picklingKey,
encryptedPayload,
);
if (decryptionWithPendingSessionResult.decryptedNotification) {
const {
decryptedNotification: notifDecryptedWithPendingSession,
newPendingSessionUpdate,
newUpdateCreationTimestamp,
} = decryptionWithPendingSessionResult;
decryptedNotification = notifDecryptedWithPendingSession;
updatedOlmData = {
mainSession: shouldUpdateMainSession ? pendingSessionUpdate : mainSession,
pendingSessionUpdate: newPendingSessionUpdate,
updateCreationTimestamp: newUpdateCreationTimestamp,
picklingKey,
};
} else {
const {
newUpdateCreationTimestamp,
decryptedNotification: notifDecryptedWithMainSession,
} = decryptWithSession(mainSession, picklingKey, encryptedPayload);
decryptedNotification = notifDecryptedWithMainSession;
updatedOlmData = {
mainSession: mainSession,
pendingSessionUpdate,
updateCreationTimestamp: newUpdateCreationTimestamp,
picklingKey,
};
}
const updatedEncryptedSession = await encryptData(
new TextEncoder().encode(JSON.stringify(updatedOlmData)),
encryptionKey,
);
await localforage.setItem(olmDataContentKey, updatedEncryptedSession);
return decryptedNotification;
}
function decryptWithSession(
pickledSession: string,
picklingKey: string,
encryptedPayload: string,
): DecryptionResult {
const session = new olm.Session();
session.unpickle(picklingKey, pickledSession);
const decryptedNotification: T = JSON.parse(
session.decrypt(olmEncryptedMessageTypes.TEXT, encryptedPayload),
);
const newPendingSessionUpdate = session.pickle(picklingKey);
const newUpdateCreationTimestamp = Date.now();
return {
decryptedNotification,
newUpdateCreationTimestamp,
newPendingSessionUpdate,
};
}
function decryptWithPendingSession(
pendingSessionUpdate: string,
picklingKey: string,
encryptedPayload: string,
): DecryptionResult | { +error: string } {
try {
const {
decryptedNotification,
newPendingSessionUpdate,
newUpdateCreationTimestamp,
} = decryptWithSession(
pendingSessionUpdate,
picklingKey,
encryptedPayload,
);
return {
newPendingSessionUpdate,
newUpdateCreationTimestamp,
decryptedNotification,
};
} catch (e) {
return { error: e.message };
}
}
async function retrieveEncryptionKey(
encryptionKeyDBLabel: string,
): Promise {
if (!isDesktopSafari) {
return await localforage.getItem(encryptionKeyDBLabel);
}
// Safari doesn't support structured clone algorithm in service
// worker context so we have to store CryptoKey as JSON
const persistedCryptoKey =
await localforage.getItem(encryptionKeyDBLabel);
if (!persistedCryptoKey) {
return null;
}
return await importJWKKey(persistedCryptoKey);
}
-async function getNotifsOlmSessionDBKeys(): Promise<{
+async function getNotifsOlmSessionDBKeys(keyserverID?: string): Promise<{
+olmDataContentKey: string,
+encryptionKeyDBKey: string,
}> {
+ const olmDataContentKeyForKeyserverPrefix = getOlmDataContentKeyForCookie(
+ undefined,
+ keyserverID,
+ );
+
+ const olmEncryptionKeyDBLabelForKeyserverPrefix =
+ getOlmEncryptionKeyDBLabelForCookie(undefined, keyserverID);
+
const dbKeys = await localforage.keys();
const olmDataContentKeys = sortOlmDBKeysArray(
- dbKeys.filter(key => key.startsWith(NOTIFICATIONS_OLM_DATA_CONTENT)),
+ dbKeys.filter(key => key.startsWith(olmDataContentKeyForKeyserverPrefix)),
);
const encryptionKeyDBLabels = sortOlmDBKeysArray(
- dbKeys.filter(key => key.startsWith(NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY)),
+ dbKeys.filter(key =>
+ key.startsWith(olmEncryptionKeyDBLabelForKeyserverPrefix),
+ ),
);
if (olmDataContentKeys.length === 0 || encryptionKeyDBLabels.length === 0) {
throw new Error(
'Received encrypted notification but olm session was not created',
);
}
const latestDataContentKey =
olmDataContentKeys[olmDataContentKeys.length - 1];
const latestEncryptionKeyDBKey =
encryptionKeyDBLabels[encryptionKeyDBLabels.length - 1];
const latestDataContentCookieID =
getCookieIDFromOlmDBKey(latestDataContentKey);
const latestEncryptionKeyCookieID = getCookieIDFromOlmDBKey(
latestEncryptionKeyDBKey,
);
if (latestDataContentCookieID !== latestEncryptionKeyCookieID) {
throw new Error(
'Olm sessions and their encryption keys out of sync. Latest cookie ' +
`id for olm sessions ${latestDataContentCookieID}. Latest cookie ` +
`id for olm session encryption keys ${latestEncryptionKeyCookieID}`,
);
}
const olmDBKeys = {
olmDataContentKey: latestDataContentKey,
encryptionKeyDBKey: latestEncryptionKeyDBKey,
};
const keysToDelete: $ReadOnlyArray = [
...olmDataContentKeys.slice(0, olmDataContentKeys.length - 1),
...encryptionKeyDBLabels.slice(0, encryptionKeyDBLabels.length - 1),
];
await Promise.all(keysToDelete.map(key => localforage.removeItem(key)));
return olmDBKeys;
}
function getOlmDataContentKeyForCookie(
cookie: ?string,
- // eslint-disable-next-line no-unused-vars
- keyserverID: string,
+ keyserverID?: string,
): string {
+ let olmDataContentKeyBase;
+ if (keyserverID) {
+ olmDataContentKeyBase = [
+ INDEXED_DB_KEYSERVER_PREFIX,
+ keyserverID,
+ NOTIFICATIONS_OLM_DATA_CONTENT,
+ ].join(INDEXED_DB_KEY_SEPARATOR);
+ } else {
+ olmDataContentKeyBase = NOTIFICATIONS_OLM_DATA_CONTENT;
+ }
+
if (!cookie) {
- return NOTIFICATIONS_OLM_DATA_CONTENT;
+ return olmDataContentKeyBase;
}
const cookieID = getCookieIDFromCookie(cookie);
- return `${NOTIFICATIONS_OLM_DATA_CONTENT}:${cookieID}`;
+ return [olmDataContentKeyBase, cookieID].join(INDEXED_DB_KEY_SEPARATOR);
}
function getOlmEncryptionKeyDBLabelForCookie(
cookie: ?string,
- // eslint-disable-next-line no-unused-vars
- keyserverID: string,
+ keyserverID?: string,
): string {
+ let olmEncryptionKeyDBLabelBase;
+ if (keyserverID) {
+ olmEncryptionKeyDBLabelBase = [
+ INDEXED_DB_KEYSERVER_PREFIX,
+ keyserverID,
+ NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY,
+ ].join(INDEXED_DB_KEY_SEPARATOR);
+ } else {
+ olmEncryptionKeyDBLabelBase = NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY;
+ }
+
if (!cookie) {
- return NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY;
+ return olmEncryptionKeyDBLabelBase;
}
const cookieID = getCookieIDFromCookie(cookie);
- return `${NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY}:${cookieID}`;
+ return [olmEncryptionKeyDBLabelBase, cookieID].join(INDEXED_DB_KEY_SEPARATOR);
}
function getCookieIDFromOlmDBKey(olmDBKey: string): string | '0' {
- const cookieID = olmDBKey.split(':')[1];
+ // Olm DB keys comply to one of the following formats:
+ // KEYSERVER::(OLM_CONTENT | OLM_ENCRYPTION_KEY):
+ // or legacy (OLM_CONTENT | OLM_ENCRYPTION_KEY):.
+ // Legacy format may be used in case a new version of the web app
+ // is running on a old desktop version that uses legacy key format.
+ const cookieID = olmDBKey.split(INDEXED_DB_KEY_SEPARATOR).slice(-1)[0];
return cookieID ?? '0';
}
function sortOlmDBKeysArray(
olmDBKeysArray: $ReadOnlyArray,
): $ReadOnlyArray {
return olmDBKeysArray
.map(key => ({
cookieID: Number(getCookieIDFromOlmDBKey(key)),
key,
}))
.sort(
({ cookieID: cookieID1 }, { cookieID: cookieID2 }) =>
cookieID1 - cookieID2,
)
.map(({ key }) => key);
}
+async function migrateLegacyOlmNotificationsSessions() {
+ const keyValuePairsToInsert: { [key: string]: EncryptedData | CryptoKey } =
+ {};
+ const keysToDelete = [];
+
+ await localforage.iterate((value: EncryptedData | CryptoKey, key) => {
+ let keyToInsert;
+ if (key.startsWith(NOTIFICATIONS_OLM_DATA_CONTENT)) {
+ const cookieID = getCookieIDFromOlmDBKey(key);
+ keyToInsert = getOlmDataContentKeyForCookie(
+ cookieID,
+ ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE,
+ );
+ } else if (key.startsWith(NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY)) {
+ const cookieID = getCookieIDFromOlmDBKey(key);
+ keyToInsert = getOlmEncryptionKeyDBLabelForCookie(
+ cookieID,
+ ASHOAT_KEYSERVER_ID_USED_ONLY_FOR_MIGRATION_FROM_LEGACY_NOTIF_STORAGE,
+ );
+ } else {
+ return undefined;
+ }
+
+ keyValuePairsToInsert[keyToInsert] = value;
+ keysToDelete.push(key);
+ return undefined;
+ });
+
+ const insertionPromises = Object.entries(keyValuePairsToInsert).map(
+ ([key, value]) =>
+ (async () => {
+ await localforage.setItem(key, value);
+ })(),
+ );
+
+ const deletionPromises = keysToDelete.map(key =>
+ (async () => await localforage.removeItem(key))(),
+ );
+
+ await Promise.all([...insertionPromises, ...deletionPromises]);
+}
+
export {
decryptWebNotification,
decryptDesktopNotification,
getOlmDataContentKeyForCookie,
getOlmEncryptionKeyDBLabelForCookie,
+ migrateLegacyOlmNotificationsSessions,
};
diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js
index fad1305c9..dc37e789a 100644
--- a/web/push-notif/push-notifs-handler.js
+++ b/web/push-notif/push-notifs-handler.js
@@ -1,231 +1,261 @@
// @flow
import * as React from 'react';
import {
useSetDeviceTokenFanout,
setDeviceTokenActionTypes,
} from 'lib/actions/device-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
+import {
+ hasMinCodeVersion,
+ NEXT_CODE_VERSION,
+} from 'lib/shared/version-utils.js';
+import { isDesktopPlatform } from 'lib/types/device-types.js';
+import { getConfig } from 'lib/utils/config.js';
import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js';
import {
shouldSkipPushPermissionAlert,
recordNotifPermissionAlertActionType,
} from 'lib/utils/push-alerts.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
-import { decryptDesktopNotification } from './notif-crypto-utils.js';
+import {
+ decryptDesktopNotification,
+ migrateLegacyOlmNotificationsSessions,
+} from './notif-crypto-utils.js';
import { authoritativeKeyserverID } from '../authoritative-keyserver.js';
import electron from '../electron.js';
import PushNotifModal from '../modals/push-notif-modal.react.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import { getOlmWasmPath } from '../shared-worker/utils/constants.js';
import { useStaffCanSee } from '../utils/staff-utils.js';
function useCreateDesktopPushSubscription() {
const dispatchActionPromise = useDispatchActionPromise();
const callSetDeviceToken = useSetDeviceTokenFanout();
const staffCanSee = useStaffCanSee();
+ const [notifsOlmSessionMigrated, setNotifsSessionsMigrated] =
+ React.useState(false);
+ const platformDetails = getConfig().platformDetails;
+
+ React.useEffect(() => {
+ if (
+ !isDesktopPlatform(platformDetails.platform) ||
+ !hasMinCodeVersion(platformDetails, { majorDesktop: NEXT_CODE_VERSION })
+ ) {
+ return;
+ }
+ void (async () => {
+ await migrateLegacyOlmNotificationsSessions();
+ setNotifsSessionsMigrated(true);
+ })();
+ }, [platformDetails]);
React.useEffect(
() =>
electron?.onDeviceTokenRegistered?.((token: ?string) => {
void dispatchActionPromise(
setDeviceTokenActionTypes,
callSetDeviceToken(token),
);
}),
[callSetDeviceToken, dispatchActionPromise],
);
React.useEffect(() => {
electron?.fetchDeviceToken?.();
}, []);
- React.useEffect(
- () =>
- electron?.onEncryptedNotification?.(
- async ({
+ React.useEffect(() => {
+ if (
+ hasMinCodeVersion(platformDetails, { majorDesktop: NEXT_CODE_VERSION }) &&
+ !notifsOlmSessionMigrated
+ ) {
+ return undefined;
+ }
+
+ return electron?.onEncryptedNotification?.(
+ async ({
+ encryptedPayload,
+ keyserverID,
+ }: {
+ encryptedPayload: string,
+ keyserverID?: string,
+ }) => {
+ const decryptedPayload = await decryptDesktopNotification(
encryptedPayload,
+ staffCanSee,
keyserverID,
- }: {
- encryptedPayload: string,
- keyserverID?: string,
- }) => {
- const decryptedPayload = await decryptDesktopNotification(
- encryptedPayload,
- staffCanSee,
- keyserverID,
- );
- electron?.showDecryptedNotification(decryptedPayload);
- },
- ),
- [staffCanSee],
- );
+ );
+ electron?.showDecryptedNotification(decryptedPayload);
+ },
+ );
+ }, [staffCanSee, notifsOlmSessionMigrated, platformDetails]);
const dispatch = useDispatch();
React.useEffect(
() =>
electron?.onNotificationClicked?.(
({ threadID }: { +threadID: string }) => {
const convertedThreadID = convertNonPendingIDToNewSchema(
threadID,
authoritativeKeyserverID,
);
const payload = {
chatMode: 'view',
activeChatThreadID: convertedThreadID,
tab: 'chat',
};
dispatch({ type: updateNavInfoActionType, payload });
},
),
[dispatch],
);
}
function useCreatePushSubscription(): () => Promise {
const publicKey = useSelector(state => state.pushApiPublicKey);
const dispatchActionPromise = useDispatchActionPromise();
const callSetDeviceToken = useSetDeviceTokenFanout();
const staffCanSee = useStaffCanSee();
return React.useCallback(async () => {
if (!publicKey) {
return;
}
const workerRegistration = await navigator.serviceWorker?.ready;
if (!workerRegistration || !workerRegistration.pushManager) {
return;
}
workerRegistration.active?.postMessage({
olmWasmPath: getOlmWasmPath(),
staffCanSee,
});
const subscription = await workerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
});
void dispatchActionPromise(
setDeviceTokenActionTypes,
callSetDeviceToken(JSON.stringify(subscription)),
);
}, [callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee]);
}
function PushNotificationsHandler(): React.Node {
useCreateDesktopPushSubscription();
const createPushSubscription = useCreatePushSubscription();
const notifPermissionAlertInfo = useSelector(
state => state.notifPermissionAlertInfo,
);
const modalContext = useModalContext();
const loggedIn = useSelector(isLoggedIn);
const dispatch = useDispatch();
const supported = 'Notification' in window && !electron;
React.useEffect(() => {
void (async () => {
if (!navigator.serviceWorker || !supported) {
return;
}
await navigator.serviceWorker.register('worker/notif', { scope: '/' });
if (Notification.permission === 'granted') {
// Make sure the subscription is current if we have the permissions
await createPushSubscription();
} else if (
Notification.permission === 'default' &&
loggedIn &&
!shouldSkipPushPermissionAlert(notifPermissionAlertInfo)
) {
// Ask existing users that are already logged in for permission
modalContext.pushModal();
dispatch({
type: recordNotifPermissionAlertActionType,
payload: { time: Date.now() },
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Ask for permission on login
const prevLoggedIn = React.useRef(loggedIn);
React.useEffect(() => {
if (!navigator.serviceWorker || !supported) {
return;
}
if (!prevLoggedIn.current && loggedIn) {
if (Notification.permission === 'granted') {
void createPushSubscription();
} else if (
Notification.permission === 'default' &&
!shouldSkipPushPermissionAlert(notifPermissionAlertInfo)
) {
modalContext.pushModal();
dispatch({
type: recordNotifPermissionAlertActionType,
payload: { time: Date.now() },
});
}
}
prevLoggedIn.current = loggedIn;
}, [
createPushSubscription,
dispatch,
loggedIn,
modalContext,
notifPermissionAlertInfo,
prevLoggedIn,
supported,
]);
// Redirect to thread on notification click
React.useEffect(() => {
if (!navigator.serviceWorker || !supported) {
return undefined;
}
const callback = (event: MessageEvent) => {
if (typeof event.data !== 'object' || !event.data) {
return;
}
if (event.data.targetThreadID) {
const payload = {
chatMode: 'view',
activeChatThreadID: event.data.targetThreadID,
tab: 'chat',
};
dispatch({ type: updateNavInfoActionType, payload });
}
};
navigator.serviceWorker.addEventListener('message', callback);
return () =>
navigator.serviceWorker?.removeEventListener('message', callback);
}, [dispatch, supported]);
return null;
}
export { PushNotificationsHandler, useCreatePushSubscription };
diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js
index 69597eaaa..6acefa352 100644
--- a/web/push-notif/service-worker.js
+++ b/web/push-notif/service-worker.js
@@ -1,176 +1,179 @@
// @flow
import localforage from 'localforage';
import type {
PlainTextWebNotification,
WebNotification,
} from 'lib/types/notif-types.js';
import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js';
import {
decryptWebNotification,
+ migrateLegacyOlmNotificationsSessions,
WEB_NOTIFS_SERVICE_UTILS_KEY,
type WebNotifsServiceUtilsData,
type WebNotifDecryptionError,
} from './notif-crypto-utils.js';
import { authoritativeKeyserverID } from '../authoritative-keyserver.js';
import { localforageConfig } from '../shared-worker/utils/constants.js';
declare class PushMessageData {
json(): Object;
}
declare class PushEvent extends ExtendableEvent {
+data: PushMessageData;
}
declare class CommAppMessage extends ExtendableEvent {
+data: { +olmWasmPath?: string, +staffCanSee?: boolean };
}
declare var clients: Clients;
declare function skipWaiting(): Promise;
const commIconUrl = 'https://web.comm.app/favicon.ico';
function buildDecryptionErrorNotification(
decryptionError: WebNotifDecryptionError,
) {
const baseErrorPayload = {
badge: commIconUrl,
icon: commIconUrl,
tag: decryptionError.id,
data: {
isError: true,
},
};
if (decryptionError.displayErrorMessage && decryptionError.error) {
return {
body: decryptionError.error,
...baseErrorPayload,
};
}
return baseErrorPayload;
}
self.addEventListener('install', skipWaiting);
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(clients.claim());
});
self.addEventListener('message', (event: CommAppMessage) => {
localforage.config(localforageConfig);
event.waitUntil(
(async () => {
if (!event.data.olmWasmPath || event.data.staffCanSee === undefined) {
return;
}
const webNotifsServiceUtils: WebNotifsServiceUtilsData = {
olmWasmPath: event.data.olmWasmPath,
staffCanSee: event.data.staffCanSee,
};
await localforage.setItem(
WEB_NOTIFS_SERVICE_UTILS_KEY,
webNotifsServiceUtils,
);
+
+ await migrateLegacyOlmNotificationsSessions();
})(),
);
});
self.addEventListener('push', (event: PushEvent) => {
localforage.config(localforageConfig);
const data: WebNotification = event.data.json();
event.waitUntil(
(async () => {
let plainTextData: PlainTextWebNotification;
let decryptionResult: PlainTextWebNotification | WebNotifDecryptionError;
if (data.encryptedPayload) {
decryptionResult = await decryptWebNotification(data);
}
if (decryptionResult && decryptionResult.error) {
const decryptionErrorNotification =
buildDecryptionErrorNotification(decryptionResult);
await self.registration.showNotification(
'Comm notification',
decryptionErrorNotification,
);
return;
} else if (decryptionResult && decryptionResult.body) {
plainTextData = decryptionResult;
} else if (data.body) {
plainTextData = data;
} else {
// We will never enter ths branch. It is
// necessary since flow doesn't differentiate
// between union types out-of-the-box.
return;
}
let body = plainTextData.body;
if (data.prefix) {
body = `${data.prefix} ${body}`;
}
await self.registration.showNotification(plainTextData.title, {
body,
badge: commIconUrl,
icon: commIconUrl,
tag: plainTextData.id,
data: {
unreadCount: plainTextData.unreadCount,
threadID: plainTextData.threadID,
},
});
})(),
);
});
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
event.waitUntil(
(async () => {
const clientList: Array = (await clients.matchAll({
type: 'window',
}): any);
const selectedClient =
clientList.find(client => client.focused) ?? clientList[0];
// Decryption error notifications don't contain threadID
// but we still want them to be interactive in terms of basic
// navigation.
let threadID;
if (!event.notification.data.isError) {
threadID = convertNonPendingIDToNewSchema(
event.notification.data.threadID,
authoritativeKeyserverID,
);
}
if (selectedClient) {
if (!selectedClient.focused) {
await selectedClient.focus();
}
if (threadID) {
selectedClient.postMessage({
targetThreadID: threadID,
});
}
} else {
const baseURL =
process.env.NODE_ENV === 'production'
? 'https://web.comm.app'
: 'http://localhost:3000/webapp';
const url = threadID ? baseURL + `/chat/thread/${threadID}/` : baseURL;
await clients.openWindow(url);
}
})(),
);
});