diff --git a/lib/facts/identity-service.js b/lib/facts/identity-service.js index 09516334b..c9ca61a80 100644 --- a/lib/facts/identity-service.js +++ b/lib/facts/identity-service.js @@ -1,11 +1,35 @@ // @flow import { isDev } from '../utils/dev-utils.js'; -const config: { defaultURL: string } = { +type IdentityServicePath = '/device_inbound_keys?device_id='; + +type IdentityServiceEndpoint = { + +path: IdentityServicePath, + +method: 'PUT' | 'GET' | 'POST' | 'DELETE', +}; + +const httpEndpoints = Object.freeze({ + GET_INBOUND_KEYS: { + path: '/device_inbound_keys?device_id=', + method: 'GET', + }, +}); + +type IdentityServiceConfig = { + +defaultURL: string, + +defaultHttpURL: string, + +httpEndpoints: { +[endpoint: string]: IdentityServiceEndpoint }, +}; + +const config: IdentityServiceConfig = { defaultURL: isDev ? 'https://identity.staging.commtechnologies.org:50054' : 'https://identity.commtechnologies.org:50054', + defaultHttpURL: isDev + ? 'https://identity.staging.commtechnologies.org:51004' + : 'https://identity.commtechnologies.org:51004', + httpEndpoints, }; export default config; diff --git a/lib/utils/identity-service.js b/lib/utils/identity-service.js new file mode 100644 index 000000000..23840df4e --- /dev/null +++ b/lib/utils/identity-service.js @@ -0,0 +1,48 @@ +// @flow + +import type { TInterface } from 'tcomb'; +import t from 'tcomb'; + +import identityServiceConfig from '../facts/identity-service.js'; +import { tShape } from '../utils/validation-utils.js'; + +export type InboundKeysForDeviceResponse = { + identityKeyInfo: { + keyPayload: string, + keyPayloadSignature: string, + }, + contentPrekey: { + prekey: string, + prekeySignature: string, + }, + notifPrekey: { + prekey: string, + prekeySignature: string, + }, +}; + +export const inboundKeysForDeviceResponseValidator: TInterface = + tShape({ + identityKeyInfo: tShape({ + keyPayload: t.String, + keyPayloadSignature: t.String, + }), + contentPrekey: tShape({ + prekey: t.String, + prekeySignature: t.String, + }), + notifPrekey: tShape({ + prekey: t.String, + prekeySignature: t.String, + }), + }); + +function getInboundKeysForDeviceURL(deviceID: string): string { + const urlSafeDeviceID = deviceID.replaceAll('+', '-').replaceAll('/', '_'); + const endpointBasePath = + identityServiceConfig.httpEndpoints.GET_INBOUND_KEYS.path; + const path = `${endpointBasePath}${urlSafeDeviceID}`; + return `${identityServiceConfig.defaultHttpURL}${path}`; +} + +export { getInboundKeysForDeviceURL }; diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js index 5c0847dba..d366d932d 100644 --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -1,311 +1,329 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { recordAlertActionType } from 'lib/actions/alert-actions.js'; 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 { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { alertTypes, type RecordAlertActionPayload, } from 'lib/types/alert-types.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 } 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, 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: 12 }) ) { return; } void (async () => { await migrateLegacyOlmNotificationsSessions(); setNotifsSessionsMigrated(true); })(); }, [platformDetails]); React.useEffect( () => electron?.onDeviceTokenRegistered?.((token: ?string) => { void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), undefined, { type: 'device_token', deviceToken: token }, ); }), [callSetDeviceToken, dispatchActionPromise], ); React.useEffect(() => { electron?.fetchDeviceToken?.(); }, []); React.useEffect(() => { if ( hasMinCodeVersion(platformDetails, { majorDesktop: 12 }) && !notifsOlmSessionMigrated ) { return undefined; } return electron?.onEncryptedNotification?.( async ({ encryptedPayload, keyserverID, }: { encryptedPayload: string, keyserverID?: string, }) => { const decryptedPayload = await decryptDesktopNotification( encryptedPayload, staffCanSee, keyserverID, ); 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], ); // Handle invalid device token const localToken = useSelector( state => state.tunnelbrokerDeviceToken.localToken, ); const prevLocalToken = React.useRef(localToken); React.useEffect(() => { if (prevLocalToken.current && !localToken) { electron?.fetchDeviceToken?.(); } prevLocalToken.current = localToken; }, [localToken]); } function useCreatePushSubscription(): () => Promise { const publicKey = useSelector(state => state.pushApiPublicKey); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); const staffCanSee = useStaffCanSee(); + const identityContext = React.useContext(IdentityClientContext); + invariant(identityContext, 'Identity context should be set'); + const { getAuthMetadata } = identityContext; + return React.useCallback(async () => { if (!publicKey) { return; } const workerRegistration = await navigator.serviceWorker?.ready; - if (!workerRegistration || !workerRegistration.pushManager) { + const authMetadata = await getAuthMetadata(); + if ( + !workerRegistration || + !workerRegistration.pushManager || + !authMetadata + ) { return; } workerRegistration.active?.postMessage({ olmWasmPath: getOlmWasmPath(), staffCanSee, + authMetadata, }); const subscription = await workerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }); const token = JSON.stringify(subscription); void dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), undefined, { type: 'device_token', deviceToken: token }, ); - }, [callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee]); + }, [ + callSetDeviceToken, + dispatchActionPromise, + publicKey, + staffCanSee, + getAuthMetadata, + ]); } function PushNotificationsHandler(): React.Node { useCreateDesktopPushSubscription(); const createPushSubscription = useCreatePushSubscription(); const notifPermissionAlertInfo = useSelector( state => state.alertStore.alertInfos[alertTypes.NOTIF_PERMISSION], ); 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(); const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; dispatch({ type: recordAlertActionType, payload, }); } })(); // 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(); const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; dispatch({ type: recordAlertActionType, payload, }); } } 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]); // Handle invalid device token const localToken = useSelector( state => state.tunnelbrokerDeviceToken.localToken, ); const prevLocalToken = React.useRef(localToken); React.useEffect(() => { if ( !navigator.serviceWorker || !supported || Notification.permission !== 'granted' ) { return; } if (prevLocalToken.current && !localToken) { void createPushSubscription(); } prevLocalToken.current = localToken; }, [createPushSubscription, localToken, supported]); return null; } export { PushNotificationsHandler, useCreatePushSubscription }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js index 6acefa352..2767298a3 100644 --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -1,179 +1,190 @@ // @flow import localforage from 'localforage'; +import type { AuthMetadata } from 'lib/shared/identity-client-context.js'; 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 { persistAuthMetadata } from './services-client.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 }; + +data: { + +olmWasmPath?: string, + +staffCanSee?: boolean, + +authMetadata?: AuthMetadata, + }; } 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) { + const { olmWasmPath, staffCanSee, authMetadata } = event.data; + + if (!olmWasmPath || staffCanSee === undefined || !authMetadata) { return; } const webNotifsServiceUtils: WebNotifsServiceUtilsData = { - olmWasmPath: event.data.olmWasmPath, - staffCanSee: event.data.staffCanSee, + olmWasmPath: olmWasmPath, + staffCanSee: staffCanSee, }; - await localforage.setItem( - WEB_NOTIFS_SERVICE_UTILS_KEY, - webNotifsServiceUtils, - ); + await Promise.all([ + localforage.setItem( + WEB_NOTIFS_SERVICE_UTILS_KEY, + webNotifsServiceUtils, + ), + persistAuthMetadata(authMetadata), + ]); 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); } })(), ); }); diff --git a/web/push-notif/services-client.js b/web/push-notif/services-client.js new file mode 100644 index 000000000..063b0b9fc --- /dev/null +++ b/web/push-notif/services-client.js @@ -0,0 +1,120 @@ +// @flow + +import localforage from 'localforage'; + +import identityServiceConfig from 'lib/facts/identity-service.js'; +import type { AuthMetadata } from 'lib/shared/identity-client-context.js'; +import { + identityKeysBlobValidator, + type OLMIdentityKeys, +} from 'lib/types/crypto-types.js'; +import { getMessageForException } from 'lib/utils/errors.js'; +import { + getInboundKeysForDeviceURL, + inboundKeysForDeviceResponseValidator, +} from 'lib/utils/identity-service.js'; +import { createHTTPAuthorizationHeader } from 'lib/utils/services-utils.js'; +import { assertWithValidator } from 'lib/utils/validation-utils.js'; + +import { + persistEncryptionKey, + retrieveEncryptionKey, +} from './notif-crypto-utils.js'; +import { + type EncryptedData, + decryptData, + encryptData, + generateCryptoKey, +} from '../crypto/aes-gcm-crypto-utils.js'; +import { isDesktopSafari } from '../shared-worker/utils/db-utils.js'; + +export const WEB_NOTIFS_SERVICE_CSAT_ENCRYPTION_KEY = 'notifsCSATEncryptionKey'; +export const WEB_NOTIFS_SERVICE_CSAT = 'notifsCSAT'; + +async function persistAuthMetadata(authMetadata: AuthMetadata): Promise { + const encryptionKey = await generateCryptoKey({ + extractable: isDesktopSafari, + }); + + const encryptedAuthMetadata = await encryptData( + new TextEncoder().encode(JSON.stringify(authMetadata)), + encryptionKey, + ); + + await Promise.all([ + localforage.setItem(WEB_NOTIFS_SERVICE_CSAT, encryptedAuthMetadata), + persistEncryptionKey(WEB_NOTIFS_SERVICE_CSAT_ENCRYPTION_KEY, encryptionKey), + ]); +} + +async function fetchAuthMetadata(): Promise { + const [encryptionKey, encryptedAuthMetadata] = await Promise.all([ + retrieveEncryptionKey(WEB_NOTIFS_SERVICE_CSAT_ENCRYPTION_KEY), + localforage.getItem(WEB_NOTIFS_SERVICE_CSAT), + ]); + + if (!encryptionKey || !encryptedAuthMetadata) { + throw new Error('CSAT unavailable in push notifs service worker'); + } + + const authMetadata: AuthMetadata = JSON.parse( + new TextDecoder().decode( + await decryptData(encryptedAuthMetadata, encryptionKey), + ), + ); + + return authMetadata; +} + +async function getNotifsInboundKeysForDeviceID( + deviceID: string, + authMetadata: AuthMetadata, +): Promise { + const authorization = createHTTPAuthorizationHeader(authMetadata); + const headers = { + Authorization: authorization, + Accept: 'application/json', + }; + try { + const getInboundKeysResponse = await fetch( + getInboundKeysForDeviceURL(deviceID), + { + method: identityServiceConfig.httpEndpoints.GET_INBOUND_KEYS.method, + headers, + }, + ); + + if (!getInboundKeysResponse.ok) { + const { statusText, status } = getInboundKeysResponse; + return { + error: + `Failed to fetch inbound keys for ${deviceID} with code: ${status}. ` + + `Details: ${statusText}`, + }; + } + + const inboundKeysForDeviceBlob = await getInboundKeysResponse.json(); + const inboundKeysForDevice = assertWithValidator( + inboundKeysForDeviceBlob, + inboundKeysForDeviceResponseValidator, + ); + const identityKeysBlob = inboundKeysForDevice.identityKeyInfo.keyPayload; + const identityKeys = assertWithValidator( + JSON.parse(identityKeysBlob), + identityKeysBlobValidator, + ); + return identityKeys.notificationIdentityPublicKeys; + } catch (e) { + return { + error: `Failed to fetch inbound keys for ${deviceID}. Details: ${ + getMessageForException(e) ?? '' + }`, + }; + } +} + +export { + persistAuthMetadata, + fetchAuthMetadata, + getNotifsInboundKeysForDeviceID, +};