diff --git a/lib/facts/identity-service.js b/lib/facts/identity-service.js --- a/lib/facts/identity-service.js +++ b/lib/facts/identity-service.js @@ -2,10 +2,34 @@ 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 --- /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 --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -1,5 +1,6 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { recordAlertActionType } from 'lib/actions/alert-actions.js'; @@ -9,6 +10,7 @@ } 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, @@ -128,19 +130,29 @@ 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({ @@ -155,7 +167,13 @@ undefined, { type: 'device_token', deviceToken: token }, ); - }, [callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee]); + }, [ + callSetDeviceToken, + dispatchActionPromise, + publicKey, + staffCanSee, + getAuthMetadata, + ]); } function PushNotificationsHandler(): React.Node { diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -2,6 +2,7 @@ import localforage from 'localforage'; +import type { AuthMetadata } from 'lib/shared/identity-client-context.js'; import type { PlainTextWebNotification, WebNotification, @@ -15,6 +16,7 @@ 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'; @@ -26,7 +28,11 @@ } declare class CommAppMessage extends ExtendableEvent { - +data: { +olmWasmPath?: string, +staffCanSee?: boolean }; + +data: { + +olmWasmPath?: string, + +staffCanSee?: boolean, + +authMetadata?: AuthMetadata, + }; } declare var clients: Clients; @@ -66,19 +72,24 @@ 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(); })(), diff --git a/web/push-notif/services-client.js b/web/push-notif/services-client.js new file mode 100644 --- /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, +};