diff --git a/README.md b/README.md index 5b688af..0194c78 100644 --- a/README.md +++ b/README.md @@ -369,20 +369,21 @@ Objects implementing this interface can be passed to `renderChat` or to `TockCon #### `LocalStorageSettings` -| Property name | Type | Description | -|------------------------|------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `enableMessageHistory` | `boolean?` | If set to `true`, the most recent messages of a conversation will be persisted in the local storage. Defaults to `false`. | -| `historyMaxAge` | `number?` | If set to a positive value, represents the number of seconds before the message history is cleared (the timeout is reset after each message received). | -| `maxMessageCount` | `number?` | When message history is enabled, sets the max number of messages to store. Defaults to 10. | -| `prefix` | `string?` | Prefix for local storage keys allowing communication with different bots from the same domain (used for both `userId` and message history). | +| Property name | Type | Description | +|------------------------|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `enableMessageHistory` | `boolean?` | If set to `true`, the most recent messages of a conversation will be persisted in the local storage. Defaults to `false`. | +| `historyMaxAge` | `number?` | If set to a positive value, represents the number of seconds before the message history is cleared (the timeout is reset after each message received). | +| `maxMessageCount` | `number?` | When message history is enabled, sets the max number of messages to store. Defaults to 10. | +| `prefix` | `string?` | Prefix for local storage keys allowing communication with different bots from the same domain (used for both `userId` and message history). | +| `historySerialization` | `HistorySerialization?` | Replaces the default JSON serialization of the persisted message history with a custom `{ encrypt, decrypt }` implementation. Use `createEncryptedHistorySerialization(encryptionKey)` to opt into the built-in AES-GCM encryption. | #### `NetworkSettings` -| Property name | type | Description | -|------------------------|------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `disableSse` | `boolean?` | If `true`, disables any SSE connection attempt | -| `extraHeadersProvider` | `() => Promise>?` | Provides extra HTTP headers for outgoing requests | -| `retryOnPingTimeoutMs` | `number?` | If SSE is enabled, when this duration in milliseconds elapses without receiving ping events from the backend, the SSE connection is considered to be in an error state and gets restarted | +| Property name | type | Description | +|------------------------|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `disableSse` | `boolean?` | If `true`, disables any SSE connection attempt | +| `extraHeadersProvider` | `() => Promise>?` | Provides extra HTTP headers for outgoing requests | +| `retryOnPingTimeoutMs` | `number?` | If SSE is enabled, when this duration in milliseconds elapses without receiving ping events from the backend, the SSE connection is considered to be in an error state and gets restarted | #### `RendererSettings` @@ -530,9 +531,18 @@ The optional `localStorage.enableMessageHistory` setting (disabled by default) m This history loads at the creation of the chat and is stored in the local storage of the browser. The number of persisted messages can be configured with the `localStorage.maxMessageCount` setting. +By default, the persisted history is serialized as plain JSON by +`createDefaultHistorySerialization()`. To use the built-in AES-GCM encryption, explicitly configure +`localStorage.historySerialization` with +`createEncryptedHistorySerialization(encryptionKey)`. Its decrypt operation remains compatible with existing +plain-JSON and `v1`-encrypted histories. Both factories are publicly exported. You may also provide a custom +`{ encrypt, decrypt }` implementation. + Example: ```js +import { createEncryptedHistorySerialization } from 'tock-react-kit'; + renderChat( document.getElementById('chat'), '', @@ -541,6 +551,14 @@ renderChat( { localStorage: { enableMessageHistory: true, maxMessageCount: 15, // default is 10 messages max + historySerialization: createEncryptedHistorySerialization( + () => 'my-secret-key', + ), + // or provide a custom serialization implementation: + // historySerialization: { + // encrypt: async (history) => /* custom serialization/encryption */, + // decrypt: async (history) => /* custom deserialization/decryption */, + // } } }, ); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 923ed63..fed600c 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -65,7 +65,7 @@ const Chat: (props: ChatProps) => JSX.Element = ({ useEffect(() => { // When the chat gets initialized for the first time, process optional referral|opening message sseInitPromise.then(async () => { - const history = loadHistory(); + const history = await loadHistory(); if (afterInit) { await afterInit({ diff --git a/src/historySerialization.ts b/src/historySerialization.ts new file mode 100644 index 0000000..9bd5abe --- /dev/null +++ b/src/historySerialization.ts @@ -0,0 +1,170 @@ +import { arrayBufferToBase64, base64ToUint8Array } from './utils'; +import { Message } from './model/messages'; + +/** + * Pluggable strategy for serializing/encrypting the message history + * persisted to `localStorage`, and for reversing that operation when + * reloading the history. + * + * `useTock` uses {@link createDefaultHistorySerialization} by default. A + * consumer can provide a fully custom implementation via + * `LocalStorageSettings.historySerialization` to replace it (e.g. to use + * encryption, a different cipher, or delegate to a server-side/native API). + */ +export interface HistorySerialization { + encrypt: (history: Message[]) => Promise; + decrypt: (history: string | null) => Promise; +} + +/** + * Encrypts the given message history using AES-GCM, deriving the encryption + * key from the provided `encryptionKey` factory (SHA-256 hash of the key + * string). If no `encryptionKey` is provided, the history is returned as + * plain (unencrypted) JSON. + * @param history - the message history to encrypt + * @param encryptionKey - optional function returning the raw encryption key + * @returns the serialized (and possibly encrypted) history, ready for storage + */ +export async function encrypt( + history: Message[], + encryptionKey?: () => string, +): Promise { + const payload = JSON.stringify(history); + + if (!encryptionKey) { + return payload; + } + + const keyHash = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(encryptionKey()), + ); + const key = await crypto.subtle.importKey( + 'raw', + keyHash, + { name: 'AES-GCM' }, + false, + ['encrypt'], + ); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + }, + key, + new TextEncoder().encode(payload), + ); + const encryptedBytes = new Uint8Array(encrypted); + const authTag = encryptedBytes.slice(-16); + const cipherText = encryptedBytes.slice(0, -16); + + return [ + 'v1', + arrayBufferToBase64(iv), + arrayBufferToBase64(authTag), + arrayBufferToBase64(cipherText), + ].join('.'); +} + +/** + * Decrypts a message history previously produced by {@link encrypt}. + * Falls back to plain JSON parsing for backward compatibility with + * history stored before encryption support was introduced. + * @param history - the serialized (and possibly encrypted) history, or null if absent + * @param encryptionKey - optional function returning the raw encryption key + * @returns the decrypted message history, or null if `history` is null + */ +export async function decrypt( + history: string | null, + encryptionKey?: () => string, +): Promise { + if (history == null) { + return null; + } + + if (!encryptionKey) { + return JSON.parse(history); + } + + if (!history.startsWith('v1.')) { + // Backward compatibility: history was stored unencrypted (plain JSON) + return JSON.parse(history); + } + + const parts = history.split('.'); + if (parts.length !== 4) { + throw new Error('Unsupported encrypted history format'); + } + + const [, ivBase64, authTagBase64, encryptedBase64] = parts; + + if (!ivBase64 || !authTagBase64 || !encryptedBase64) { + throw new Error('Invalid encrypted history format'); + } + + const iv = base64ToUint8Array(ivBase64); + const authTag = base64ToUint8Array(authTagBase64); + const encrypted = base64ToUint8Array(encryptedBase64); + + const keyHash = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(encryptionKey()), + ); + + const key = await crypto.subtle.importKey( + 'raw', + keyHash, + { + name: 'AES-GCM', + }, + false, + ['decrypt'], + ); + + const encryptedWithAuthTag = new Uint8Array( + encrypted.length + authTag.length, + ); + + encryptedWithAuthTag.set(encrypted); + encryptedWithAuthTag.set(authTag, encrypted.length); + + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv, + }, + key, + encryptedWithAuthTag, + ); + + return JSON.parse(new TextDecoder().decode(decrypted)); +} + +/** + * Builds the default {@link HistorySerialization} implementation, which + * serializes the history as plain JSON. + */ +export function createDefaultHistorySerialization(): HistorySerialization { + return { + encrypt: async (history) => JSON.stringify(history), + decrypt: async (history) => (history == null ? null : JSON.parse(history)), + }; +} + +/** + * Builds an AES-GCM {@link HistorySerialization}. The encryption key is + * derived with SHA-256 and the persisted payload keeps the existing `v1` + * format. Its decrypt operation also accepts pre-existing plain JSON history. + * + * @param encryptionKey - function returning the raw encryption key + */ +export function createEncryptedHistorySerialization( + encryptionKey: () => string, +): HistorySerialization { + return { + encrypt: (history) => encrypt(history, encryptionKey), + decrypt: (history) => decrypt(history, encryptionKey), + }; +} diff --git a/src/index.ts b/src/index.ts index 9d802f7..8d92248 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,12 @@ export type { } from './PostInitContext'; export type { default as TockTheme } from './styles/theme'; export type { default as TockOptions } from './TockOptions'; +export type { default as TockLocalStorage } from './TockLocalStorage'; +export type { HistorySerialization } from './historySerialization'; +export { + createDefaultHistorySerialization, + createEncryptedHistorySerialization, +} from './historySerialization'; export type { default as TockSettings, LocalStorageSettings, diff --git a/src/settings/TockSettings.tsx b/src/settings/TockSettings.tsx index c33877e..fb0c56a 100644 --- a/src/settings/TockSettings.tsx +++ b/src/settings/TockSettings.tsx @@ -1,3 +1,4 @@ +import { HistorySerialization } from '../historySerialization'; import { RendererSettings } from './RendererSettings'; import linkifyHtml from 'linkify-html'; import { PartialDeep } from 'type-fest'; @@ -7,6 +8,7 @@ export interface LocalStorageSettings { enableMessageHistory: boolean; maxMessageCount: number; historyMaxAge: number; + historySerialization?: HistorySerialization; } export interface NetworkSettings { diff --git a/src/useTock.ts b/src/useTock.ts index 59546d7..06eb4f3 100644 --- a/src/useTock.ts +++ b/src/useTock.ts @@ -4,6 +4,7 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, } from 'react'; import { @@ -36,6 +37,7 @@ import { } from './model/responses'; import TockSettings from './settings/TockSettings'; import { TockEventSource } from './network/TockEventSource'; +import { createDefaultHistorySerialization } from './historySerialization'; export interface UseTock { messages: Message[]; @@ -64,7 +66,7 @@ export interface UseTock { sendReferralParameter: (referralParameter: string) => Promise; sendOpeningMessage: (msg: string) => Promise; sendPayload: (payload: string) => Promise; - loadHistory: () => TockHistoryData | null; + loadHistory: () => Promise; /** * @deprecated use {@link loadHistory} instead of reimplementing history parsing */ @@ -173,6 +175,13 @@ export const useTock0: ( }), ); const sseSource = useRef(new TockEventSource({ retryOnPingTimeoutMs })); + const historySerialization = useMemo( + () => + localStorageSettings.historySerialization ?? + createDefaultHistorySerialization(), + [localStorageSettings.historySerialization], + ); + const historySavePromise = useRef(Promise.resolve()); const startLoading: () => void = useCallback(() => { dispatch({ @@ -188,36 +197,48 @@ export const useTock0: ( }); }, [dispatch]); - const recordResponseToLocalSession: (message: Message) => void = useCallback( - (message: Message) => { - const messageHistoryLSKeyName = retrievePrefixedLocalStorageKey( - localStoragePrefix, - 'tockMessageHistory', - ); - const messageHistoryLastTime = retrievePrefixedLocalStorageKey( - localStoragePrefix, - 'tockLastMessageTimestamp', - ); + const recordResponseToLocalSession: (messages: Message[]) => void = + useCallback( + (messages: Message[]) => { + historySavePromise.current = historySavePromise.current + .then(async () => { + if (!messages.length) { + return; + } + + const messageHistoryLSKeyName = retrievePrefixedLocalStorageKey( + localStoragePrefix, + 'tockMessageHistory', + ); + const messageHistoryLastTime = retrievePrefixedLocalStorageKey( + localStoragePrefix, + 'tockLastMessageTimestamp', + ); - const savedHistory = window.localStorage.getItem(messageHistoryLSKeyName); - let history: Message[]; - if (!savedHistory) { - history = []; - } else { - history = JSON.parse(savedHistory); - } - if (history.length >= localStorageMaxMessages) { - history.splice(0, history.length - localStorageMaxMessages + 1); - } - history.push(message); - window.localStorage.setItem( - messageHistoryLSKeyName, - JSON.stringify(history), - ); - window.localStorage.setItem(messageHistoryLastTime, '' + Date.now()); - }, - [localStoragePrefix, localStorageMaxMessages], - ); + const savedHistory = window.localStorage.getItem( + messageHistoryLSKeyName, + ); + const history = savedHistory + ? ((await historySerialization.decrypt(savedHistory)) ?? []) + : []; + + history.push(...messages); + if (history.length > localStorageMaxMessages) { + history.splice(0, history.length - localStorageMaxMessages); + } + const payload = await historySerialization.encrypt(history); + window.localStorage.setItem(messageHistoryLSKeyName, payload); + window.localStorage.setItem( + messageHistoryLastTime, + '' + Date.now(), + ); + }) + .catch((error) => { + console.error('Failed to save message history', error); + }); + }, + [localStoragePrefix, localStorageMaxMessages, historySerialization], + ); const handleBotResponse: (botResponse: BotConnectorResponse) => void = useCallback( @@ -248,53 +269,66 @@ export const useTock0: ( ); } + const messages: Message[] = []; + + for (const response of responses) { + const { text, card, carousel, widget, image, buttons } = response; + let message: Message; + + if (widget) { + message = { + widgetData: widget, + type: MessageType.widget, + } as Widget; + } else if (text !== undefined) { + message = { + author: 'bot', + message: text, + type: MessageType.message, + buttons: (buttons || []) + .filter((button) => button.type !== 'quick_reply') + .map(mapButton), + } as TextMessage; + } else if (card) { + message = mapCard(card); + } else if (image) { + message = mapImage(image); + } else if (carousel) { + message = { + cards: carousel.cards.map(mapCard), + type: MessageType.carousel, + } as Carousel; + } else { + console.error('Unsupported bot response', response); + continue; + } + + message.metadata = { + RECEPTION_TIME: new Date().toISOString(), + ...metadata, + }; + messages.push(message); + } + + if (localStorageEnabled) { + recordResponseToLocalSession(messages); + } + dispatch({ type: metadata?.TOCK_STREAM_RESPONSE === 'true' ? 'UPDATE_MESSAGE' : 'ADD_MESSAGE', - messages: responses.flatMap((response) => { - const { text, card, carousel, widget, image, buttons } = response; - let message: Message; - if (widget) { - message = { - widgetData: widget, - type: MessageType.widget, - } as Widget; - } else if (text !== undefined) { - message = { - author: 'bot', - message: text, - type: MessageType.message, - buttons: (buttons || []) - .filter((button) => button.type !== 'quick_reply') - .map(mapButton), - } as TextMessage; - } else if (card) { - message = mapCard(card); - } else if (image) { - message = mapImage(image); - } else if (carousel) { - message = { - cards: carousel.cards.map(mapCard), - type: MessageType.carousel, - } as Carousel; - } else { - console.error('Unsupported bot response', response); - return []; - } - - message.metadata = metadata; - - if (localStorageEnabled) { - recordResponseToLocalSession(message); - } - return [message]; - }), + messages, }); } }, - [dispatch], + [ + dispatch, + localStorageEnabled, + localStoragePrefix, + recordResponseToLocalSession, + ], ); const setProcessedMessageCount = useCallback( @@ -436,7 +470,7 @@ export const useTock0: ( type: MessageType.message, } as TextMessage; if (localStorageEnabled) { - recordResponseToLocalSession(messageToDispatch); + recordResponseToLocalSession([messageToDispatch]); } dispatch({ type: 'ADD_MESSAGE', @@ -503,11 +537,13 @@ export const useTock0: ( } else if (button.payload) { setQuickReplies([]); if (localStorageEnabled) { - recordResponseToLocalSession({ - author: 'user', - message: button.label, - type: MessageType.message, - }); + recordResponseToLocalSession([ + { + author: 'user', + message: button.label, + type: MessageType.message, + }, + ]); } addMessage(button.label, 'user'); startLoading(); @@ -661,7 +697,7 @@ export const useTock0: ( [], ); - const loadHistory: () => TockHistoryData | null = () => { + const loadHistory: () => Promise = async () => { // If not first time, return existing messages if (messages.length) { return { @@ -687,12 +723,17 @@ export const useTock0: ( 'tockLastMessageTimestamp', ); - const serializedHistory = + const storedMessages = storageAvailable('localStorage') && localStorageEnabled - ? window.localStorage.getItem(messageHistoryLSKey) + ? await historySerialization + .decrypt(window.localStorage.getItem(messageHistoryLSKey)) + .catch((error) => { + console.error('Failed to load message history', error); + return null; + }) : undefined; - if (serializedHistory) { + if (storedMessages) { const historyMaxAge = localStorageSettings.historyMaxAge; if (historyMaxAge > 0) { const lastMessageTime = +( @@ -706,7 +747,7 @@ export const useTock0: ( } } - const messages = JSON.parse(serializedHistory); + const messages = storedMessages; const quickReplies = JSON.parse( window.localStorage.getItem(quickReplyHistoryLSKey) || '[]', ); diff --git a/src/utils.ts b/src/utils.ts index 2a4dad8..024ff22 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -88,3 +88,18 @@ export const retrievePrefixedLocalStorageKey: ( } return key; }; + +/** + * Encodes an ArrayBuffer/Uint8Array to a base64 string. + * @param buffer - bytes to encode + */ +export const arrayBufferToBase64: ( + buffer: ArrayBuffer | Uint8Array, +) => string = (buffer) => btoa(String.fromCharCode(...new Uint8Array(buffer))); + +/** + * Decodes a base64 string to a Uint8Array. + * @param value - base64-encoded string + */ +export const base64ToUint8Array: (value: string) => Uint8Array = (value) => + Uint8Array.from(atob(value), (char) => char.charCodeAt(0));