Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>?` | 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<Record<string, string>>?` | 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`

Expand Down Expand Up @@ -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'),
'<TOCK_BOT_API_URL>',
Expand All @@ -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 */,
// }
}
},
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
170 changes: 170 additions & 0 deletions src/historySerialization.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
decrypt: (history: string | null) => Promise<Message[] | null>;
}

/**
* 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<string> {
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<Message[] | null> {
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),
};
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/settings/TockSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HistorySerialization } from '../historySerialization';
import { RendererSettings } from './RendererSettings';
import linkifyHtml from 'linkify-html';
import { PartialDeep } from 'type-fest';
Expand All @@ -7,6 +8,7 @@ export interface LocalStorageSettings {
enableMessageHistory: boolean;
maxMessageCount: number;
historyMaxAge: number;
historySerialization?: HistorySerialization;
}

export interface NetworkSettings {
Expand Down
Loading