From d3fe27f0e95d7cce740b98e9fb71a342e6391e82 Mon Sep 17 00:00:00 2001 From: Ryan Berckmans Date: Fri, 20 Oct 2023 13:29:29 -0500 Subject: [PATCH] Support encrypted and signed in CheckoutSettingsProvider --- .../react-app/src/CheckoutSettingsContext.ts | 18 ++++++- .../src/CheckoutSettingsProvider.tsx | 54 +++++++++++++++---- packages/react-app/src/useCheckoutSettings.ts | 9 ++-- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/packages/react-app/src/CheckoutSettingsContext.ts b/packages/react-app/src/CheckoutSettingsContext.ts index baf1c1ad..ddd488f3 100644 --- a/packages/react-app/src/CheckoutSettingsContext.ts +++ b/packages/react-app/src/CheckoutSettingsContext.ts @@ -1,4 +1,20 @@ import React from "react"; import { CheckoutSettings } from "./CheckoutSettings"; +import { hasOwnPropertyOfType } from "./hasOwnProperty"; -export const CheckoutSettingsContext = React.createContext(undefined); // the global contextual CheckoutSettings. See CheckoutSettingsProvider. WARNING this context must only be provided by CheckoutSettingsProvider and used by useCheckoutSettings, and not directly consumed by anything else +// CheckoutSettingsRequiresPassword represents the global contextual +// CheckoutSettings requiring a password to proceed. The client should +// use setPassword to provide the password, upon which the checkout +// settings will be decrypted and provided normally. +export type CheckoutSettingsRequiresPassword = { + requirementType: + 'needToDecrypt' // the global contextual CheckoutSettings is encrypted and needs a decryption password + | 'needToVerifySignature'; // the global contextual CheckoutSettings is decrypted but needs a signature verification password + setPassword: (password: string) => void; +} + +export function isCheckoutSettingsRequiresPassword(c: CheckoutSettings | CheckoutSettingsRequiresPassword): c is CheckoutSettingsRequiresPassword { + return hasOwnPropertyOfType(c, 'requirementType', 'string'); +} + +export const CheckoutSettingsContext = React.createContext(undefined); // the global contextual CheckoutSettings. See CheckoutSettingsProvider. WARNING this context must only be provided by CheckoutSettingsProvider and used by useCheckoutSettings, and not directly consumed by anything else diff --git a/packages/react-app/src/CheckoutSettingsProvider.tsx b/packages/react-app/src/CheckoutSettingsProvider.tsx index f44bede7..c857358d 100644 --- a/packages/react-app/src/CheckoutSettingsProvider.tsx +++ b/packages/react-app/src/CheckoutSettingsProvider.tsx @@ -1,10 +1,12 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Outlet, useMatches, useSearchParams } from "react-router-dom"; import { CheckoutSettings } from "./CheckoutSettings"; -import { CheckoutSettingsContext } from "./CheckoutSettingsContext"; -import { deserializeCheckoutSettings } from "./serialize"; +import { CheckoutSettingsContext, CheckoutSettingsRequiresPassword } from "./CheckoutSettingsContext"; +import { MaybeCheckoutSettings, deserializeCheckoutSettingsUnknownMessageType, deserializeCheckoutSettingsWithEncryption, deserializeCheckoutSettingsWithSignature } from "./serialize"; import { useEffectSkipFirst } from "./useEffectSkipFirst"; +// TODO for 'CheckoutSettingsHasSignatureToVerify', the benefit of signatures vs encryption is that the cleartext CheckoutSettings is included in the CheckoutSettingsSigned. So, we might leverage that cleartext eg. by showing certain unverified payment details before the password is typed in. Or perhaps to bypass the password and see Pay screen with a big red "UNVERIFIED PAY LINK". One way to do this is to have MaybeCheckoutSettings return something like `type CheckoutSettingsUnverified = { checkoutSettings: CheckoutSettings }` instead of `CheckoutSettingsHasSignatureToVerify` and then the downstream CheckoutSettingsRequiresPassword could have something like `requirement: { kind: 'needToDecrypt' } | { kind: 'needToVerifySignature'; skipVerification: () => void; }` and then the client could call skipVerification. And then for Pay to detect skipped verification and show a warning banner, CheckoutSettingsContext could have `CheckoutSettings | CheckoutSettingsRequiresPassword | CheckoutSettingsUnverified` + // Design goals of CheckoutSettingsProvider (which were achieved) // 1. centralize deserialization of CheckoutSettings, as it's a general payload required by various routes. // 2. globally cache deserialized CheckoutSettings. @@ -35,7 +37,9 @@ import { useEffectSkipFirst } from "./useEffectSkipFirst"; // to be fetched before the initial render, so it's not beneficial to // us. -const checkoutSettingsGlobalCache: { [serialized: string]: CheckoutSettings } = {}; // a global cache of (serialized CheckoutSettings -> deserialized CheckoutSettings) to prevent redundant deserializations. This is efficient enough because serialized CheckoutSettings are relatively short (today ranging from ~45 chars to 100s of chars) +const checkoutSettingsGlobalCache: { [serialized: string]: Exclude } = {}; // a global cache of (serialized CheckoutSettings -> deserialized CheckoutSettings OR an indication this serialization is encrypted and requires a password to decrypt) to prevent redundant deserializations. This is efficient enough because serialized CheckoutSettings are relatively short (today ranging from ~30 chars to 100s of chars) + +const checkoutSettingsEncryptedOrSignedGlobalCache: { [checkoutSettingsEncryptedSerialized: string]: CheckoutSettings } = {}; // a global cache of (serialized CheckoutSettingsEncrypted or CheckoutSettingsSigned (ie. the protobuf types) -> decrypted or verified deserialized CheckoutSettings) to prevent redundant decryptions/deserializations/signature verifications. This is efficient enough because serialized CheckoutSettings are relatively short (today ranging from ~30 chars to 100s of chars) type Props = { elementForPathIfCheckoutSettingsNotFound: { [path: string]: React.ReactNode }; // fallback element per react router path to render if CheckoutSettings couldn't be deserialized @@ -53,13 +57,13 @@ export function CheckoutSettingsProvider(props: Props): React.ReactNode { const [searchParams] = useSearchParams(); const serializedCheckoutSettings = searchParams.get(serializedCheckoutSettingsUrlParam); - const doDeserialize = useCallback((): CheckoutSettings | undefined => { + const doDeserialize = useCallback((): MaybeCheckoutSettings => { // console.log("doDeserialize start"); if (serializedCheckoutSettings === null) return undefined; else { if (!checkoutSettingsGlobalCache[serializedCheckoutSettings]) { // console.log("doDeserialize cache miss"); - const cs = deserializeCheckoutSettings(serializedCheckoutSettings); + const cs = deserializeCheckoutSettingsUnknownMessageType(serializedCheckoutSettings); if (cs) checkoutSettingsGlobalCache[serializedCheckoutSettings] = cs; } else { // console.log("doDeserialize cache hit"); @@ -68,17 +72,45 @@ export function CheckoutSettingsProvider(props: Props): React.ReactNode { } }, [serializedCheckoutSettings]); - const [checkoutSettings, setCheckoutSettings] = useState(doDeserialize); + const [checkoutSettings, setCheckoutSettings] = useState(doDeserialize); + + const [password, setPassword] = useState(undefined); useEffectSkipFirst(() => { - // console.log("redo doDeserialize"); + let isMounted = true; + (async () => { // decrypt or verify checkout settings + if (serializedCheckoutSettings !== null && password && (checkoutSettings === 'CheckoutSettingsIsEncrypted' || checkoutSettings === 'CheckoutSettingsHasSignatureToVerify')) { + if (!checkoutSettingsEncryptedOrSignedGlobalCache[serializedCheckoutSettings]) { + // console.log("checkoutSettingsEncryptedOrSignedGlobalCache cache miss", checkoutSettings); + const cs: CheckoutSettings | undefined = checkoutSettings === 'CheckoutSettingsIsEncrypted' ? + await deserializeCheckoutSettingsWithEncryption(serializedCheckoutSettings, password) + : await deserializeCheckoutSettingsWithSignature(serializedCheckoutSettings, password); + if (cs) checkoutSettingsEncryptedOrSignedGlobalCache[serializedCheckoutSettings] = cs; + } else { + // console.log("checkoutSettingsEncryptedOrSignedGlobalCache cache hit"); + } + if (isMounted && checkoutSettingsEncryptedOrSignedGlobalCache[serializedCheckoutSettings]) setCheckoutSettings(checkoutSettingsEncryptedOrSignedGlobalCache[serializedCheckoutSettings]); // here we call setCheckoutSettings iff decryption or verification was successful. This is because if decryption or verification was unsuccessful, we want to maintain the current checkoutSettings value so that the downstream client can detect that the password was incorrect and handle appropriately + } + })(); + return () => { isMounted = false }; + }, [serializedCheckoutSettings, checkoutSettings, setCheckoutSettings, password]); + + useEffectSkipFirst(() => { // redo deserialization iff serializedCheckoutSettings changes after the initial render (ie. serializedCheckoutSettings is a dep of doDeserialize) setCheckoutSettings(doDeserialize()); }, [doDeserialize, setCheckoutSettings]); - if (checkoutSettings) { - return + const providedValue: CheckoutSettings | CheckoutSettingsRequiresPassword | undefined = useMemo(() => { + if ((checkoutSettings === 'CheckoutSettingsIsEncrypted' || checkoutSettings === 'CheckoutSettingsHasSignatureToVerify')) return { + requirementType: checkoutSettings === 'CheckoutSettingsIsEncrypted' ? 'needToDecrypt' : 'needToVerifySignature', + setPassword, + }; else if (checkoutSettings) return checkoutSettings; + else return undefined; + }, [checkoutSettings]) + + if (providedValue) { + return - ; + } else { // checkoutSettings couldn't be deserialized, so we'll render a fallback element. The fallback element is configured per current route matches: let elForPath: React.ReactNode | undefined; diff --git a/packages/react-app/src/useCheckoutSettings.ts b/packages/react-app/src/useCheckoutSettings.ts index 9cfe0042..30f5b716 100644 --- a/packages/react-app/src/useCheckoutSettings.ts +++ b/packages/react-app/src/useCheckoutSettings.ts @@ -1,12 +1,15 @@ import { useContext } from "react"; -import { CheckoutSettingsContext } from "./CheckoutSettingsContext"; +import { CheckoutSettingsContext, CheckoutSettingsRequiresPassword } from "./CheckoutSettingsContext"; import { CheckoutSettings } from "./CheckoutSettings"; // useCheckoutSettings returns the contextual CheckoutSettings that's -// been provided by CheckoutSettingsProvider, or throws an error if +// been provided by CheckoutSettingsProvider, or a +// CheckoutSettingsRequiresPassword indicating that the contextual +// CheckoutSettings requires a password to proceed, upon which the +// checkout settings will be provided normally, or throws an error if // useCheckoutSettings is used in a component that isn't a descendant of // CheckoutSettingsProvider. -export function useCheckoutSettings(): CheckoutSettings { +export function useCheckoutSettings(): CheckoutSettings | CheckoutSettingsRequiresPassword { const cs = useContext(CheckoutSettingsContext); if (!cs) throw new Error("useCheckoutSettings must be used within a descendant of CheckoutSettingsProvider"); else return cs;