wip broadcast encryption transition to adapt ui smoothly

This commit is contained in:
Thomas Ramé
2026-03-06 19:06:24 +01:00
parent da4d323144
commit a3fdb206ef
10 changed files with 220 additions and 70 deletions
@@ -115,6 +115,11 @@ export class EncryptedWebSocket extends WebSocket {
});
}
// allow sending raw message without encryption so they can be read
sendSystemMessage(message: string) {
super.send(message);
}
send(message: Uint8Array<ArrayBuffer>) {
// TODO: we use the polyfilled websocket parameter for `y-websocket` to bring our own encryption logic over the network
// that's great but encryption is preferable with async processes, we cannot just switch to async since
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import * as Y from 'yjs';
import { encryptContent } from '@/docs/doc-collaboration/encryption';
import { useUpdateDoc } from '@/docs/doc-management/';
import { useUpdateDoc, useProviderStore } from '@/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { isFirefox } from '@/utils/userAgent';
@@ -20,6 +20,7 @@ export const useSaveDoc = (
documentSymmetricKey: CryptoKey;
} | null,
) => {
const { encryptionTransition } = useProviderStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
@@ -53,6 +54,8 @@ export const useSaveDoc = (
const saveDoc = useCallback(() => {
if (!isLocalChange) {
return false;
} else if (encryptionTransition) {
return false;
} else if (isEncrypted && !documentEncryptionSettings) {
// If the symmetric key is not yet ready we just ignore saving (either it needs onboarding or just a few seconds)
return false;
@@ -82,6 +85,7 @@ export const useSaveDoc = (
return true;
}, [
isLocalChange,
encryptionTransition,
updateDoc,
docId,
yDoc,
@@ -335,12 +335,6 @@ export const DocToolBox = ({
<ModalEncryptDoc
doc={doc}
onClose={() => setIsModalEncryptOpen(false)}
onSuccess={() => {
//
// TODO: probably it should make an hard refresh to get the setup
// but it should before register content in database with accesses, and broadcast the information through websocket
//
}}
/>
)}
{isModalRemoveEncryptionOpen &&
@@ -349,12 +343,6 @@ export const DocToolBox = ({
doc={doc}
symmetricKey={documentEncryptionSettings.documentSymmetricKey}
onClose={() => setIsModalRemoveEncryptionOpen(false)}
onSuccess={() => {
//
// TODO: probably it should make an hard refresh to get the setup
// but it should before register content in database with clean accesses, and broadcast the information through websocket
//
}}
/>
)}
{selectHistoryModal.isOpen && (
@@ -28,6 +28,7 @@ import { toBase64 } from '@/docs/doc-editor';
import { useAuth } from '@/features/auth';
import {
Doc,
EncryptionTransitionEvent,
KEY_DOC,
KEY_LIST_DOC,
LinkReach,
@@ -123,17 +124,13 @@ const encryptRemoteAttachments = async (
interface ModalEncryptDocProps {
doc: Doc;
onClose: () => void;
onSuccess?: (doc: Doc) => void;
}
export const ModalEncryptDoc = ({
doc,
onClose,
onSuccess,
}: ModalEncryptDocProps) => {
export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { provider } = useProviderStore();
const { provider, notifyOthers, startEncryptionTransition } =
useProviderStore();
const { user } = useAuth();
const { encryptionSettings } = useUserEncryption();
const { mutateAsync: updateUser } = useUserUpdate();
@@ -235,6 +232,8 @@ export const ModalEncryptDoc = ({
return;
}
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED);
const documentSymmetricKey = await generateSymmetricKey();
// Their public key are base64 encoded, decoding the whole
@@ -283,6 +282,10 @@ export const ModalEncryptDoc = ({
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
// we have no need of patching back the current Yjs document with modifications
// since an encryption success will refetch data from the backend
ongoingDoc.destroy();
const encryptedContent = await encryptContent(
new Uint8Array(ongoingDocState),
documentSymmetricKey,
@@ -300,17 +303,21 @@ export const ModalEncryptDoc = ({
attachmentKeyMapping,
});
// since the encrypted state has been committed with success with can apply it locally with adjusted encrypted attachments
Y.applyUpdate(provider!.document, Y.encodeStateAsUpdate(ongoingDoc));
onSuccess?.(doc);
onClose();
toast(t('The document has been encrypted.'), VariantType.SUCCESS, {
duration: 4000,
});
ongoingDoc.destroy();
// notify other users before destroying the provider since websocket connection needed
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED);
// trigger the provider switch (hocuspocus → relay)
startEncryptionTransition('encrypting');
onClose();
} catch (error) {
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_CANCELED);
throw error;
} finally {
setIsPending(false);
}
@@ -16,6 +16,7 @@ import { decryptContent } from '@/docs/doc-collaboration';
import { createDocAttachment } from '@/docs/doc-editor/api';
import {
Doc,
EncryptionTransitionEvent,
KEY_DOC,
KEY_LIST_DOC,
extractAttachmentKeysAndMetadata,
@@ -101,18 +102,17 @@ interface ModalRemoveDocEncryptionProps {
doc: Doc;
symmetricKey: CryptoKey;
onClose: () => void;
onSuccess?: (doc: Doc) => void;
}
export const ModalRemoveDocEncryption = ({
doc,
symmetricKey,
onClose,
onSuccess,
}: ModalRemoveDocEncryptionProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { provider } = useProviderStore();
const { provider, notifyOthers, startEncryptionTransition } =
useProviderStore();
const [isPending, setIsPending] = useState(false);
@@ -141,6 +141,8 @@ export const ModalRemoveDocEncryption = ({
setIsPending(true);
try {
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED);
// clone the Yjs document since performing changes during decryption
// that require backend confirmation
const ongoingDoc = new Y.Doc();
@@ -155,36 +157,40 @@ export const ModalRemoveDocEncryption = ({
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
// we have no need of patching back the current Yjs document with modifications
// since a removing encryption success will refetch data from the backend
ongoingDoc.destroy();
await removeDocEncryption({
docId: doc.id,
content: ongoingDocState,
attachmentKeyMapping,
});
// apply the URL changes from the cloned doc to the live document
Y.applyUpdate(provider!.document, Y.encodeStateAsUpdate(ongoingDoc));
onSuccess?.(doc);
onClose();
toast(
t('The document encryption has been removed.'),
VariantType.SUCCESS,
{
duration: 4000,
},
{ duration: 4000 },
);
ongoingDoc.destroy();
// notify other users before destroying the provider since websocket connection needed
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED);
// trigger the provider switch (relay → hocuspocus):
startEncryptionTransition('removing-encryption');
onClose();
} catch (error) {
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED);
throw error;
} finally {
setIsPending(false);
}
};
const handleCloseKeyDown = keyboardAction(handleClose);
const handleRemoveEncryptionKeyDown = keyboardAction(
handleRemoveEncryption,
);
const handleRemoveEncryptionKeyDown = keyboardAction(handleRemoveEncryption);
return (
<Modal
@@ -17,7 +17,8 @@ export const useCollaboration = (
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const { user } = useAuth();
const { provider, createProvider, destroyProvider } = useProviderStore();
const { provider, createProvider, destroyProvider, encryptionTransition } =
useProviderStore();
useEffect(() => {
if (
@@ -26,7 +27,8 @@ export const useCollaboration = (
!user ||
isEncrypted === undefined ||
(isEncrypted === true && !documentEncryptionSettings) ||
provider
provider ||
encryptionTransition
) {
// TODO: make sure the logout would invalide this provider, also a change of local keys (after import...)
return;
@@ -92,6 +94,7 @@ export const useCollaboration = (
user,
isEncrypted,
documentEncryptionSettings,
encryptionTransition,
]);
/**
@@ -3,11 +3,25 @@ import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
import { createAdaptedEncryptedWebsocketClass } from '@/docs/doc-collaboration/encryptedWebsocket';
import {
EncryptedWebSocket,
createAdaptedEncryptedWebsocketClass,
} from '@/docs/doc-collaboration/encryptedWebsocket';
import { RelayProvider } from '@/docs/doc-collaboration/relayProvider';
export enum EncryptionTransitionEvent {
ENCRYPTION_STARTED = 'system:encryption-started',
ENCRYPTION_SUCCEEDED = 'system:encryption-succeeded',
ENCRYPTION_CANCELED = 'system:encryption-canceled',
REMOVE_ENCRYPTION_STARTED = 'system:remove-encryption-started',
REMOVE_ENCRYPTION_SUCCEEDED = 'system:remove-encryption-succeeded',
REMOVE_ENCRYPTION_CANCELED = 'system:remove-encryption-canceled',
}
export type SwitchableProvider = RelayProvider | HocuspocusProvider;
export type EncryptionTransitionType = 'encrypting' | 'removing-encryption';
export interface UseCollaborationStore {
createProvider: (
providerUrl: string,
@@ -16,11 +30,15 @@ export interface UseCollaborationStore {
symmetricKey?: CryptoKey,
) => SwitchableProvider;
destroyProvider: () => void;
notifyOthers: (event: EncryptionTransitionEvent) => void;
startEncryptionTransition: (type: EncryptionTransitionType) => void;
clearEncryptionTransition: () => void;
provider: SwitchableProvider | undefined;
isConnected: boolean;
isReady: boolean;
isSynced: boolean;
hasLostConnection: boolean;
encryptionTransition: EncryptionTransitionType | null;
resetLostConnection: () => void;
}
@@ -30,8 +48,34 @@ const defaultValues = {
isReady: false,
isSynced: false,
hasLostConnection: false,
encryptionTransition: null,
};
function handleEncryptionSystemMessage(
message: string,
set: (partial: Partial<UseCollaborationStore>) => void,
get: () => UseCollaborationStore,
) {
switch (message) {
case EncryptionTransitionEvent.ENCRYPTION_STARTED:
set({ encryptionTransition: 'encrypting' });
break;
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED:
set({ encryptionTransition: 'removing-encryption' });
break;
case EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED:
get().startEncryptionTransition('encrypting');
break;
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED:
get().startEncryptionTransition('removing-encryption');
break;
case EncryptionTransitionEvent.ENCRYPTION_CANCELED:
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED:
set({ encryptionTransition: null });
break;
}
}
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
...defaultValues,
createProvider: (wsUrl, storeId, initialDocState, encryptionSymmetricKey) => {
@@ -59,6 +103,8 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
onSystemMessage: (message) => {
if (message === 'system:authenticated') {
set({ isReady: true, isConnected: true });
} else {
handleEncryptionSystemMessage(message, set, get);
}
},
});
@@ -163,6 +209,9 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
};
});
},
onStateless: ({ payload }) => {
handleEncryptionSystemMessage(payload, set, get);
},
onSynced: ({ state }) => {
set({ isSynced: state, isReady: true });
},
@@ -187,6 +236,44 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
return provider;
},
startEncryptionTransition: (type: EncryptionTransitionType) => {
const provider = get().provider;
// switching between hocuspocus and relay servers, we have to properly close the current one
if (provider) {
provider.destroy();
}
// set the right data so the page component has the indication it needs to fetch again document data
set({
encryptionTransition: type,
provider: undefined,
isConnected: false,
isReady: false,
isSynced: false,
hasLostConnection: false,
});
},
clearEncryptionTransition: () => {
set({ encryptionTransition: null });
},
notifyOthers: (event: EncryptionTransitionEvent) => {
const provider = get().provider;
if (!provider) {
return;
}
if (provider instanceof HocuspocusProvider) {
provider.sendStateless(event);
} else if (provider instanceof RelayProvider) {
const ws = provider.ws as EncryptedWebSocket | null;
if (ws) {
ws.sendSystemMessage(event);
}
}
},
destroyProvider: () => {
const provider = get().provider;
if (provider) {
@@ -6,6 +6,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { Spinner } from '@gouvfr-lasuite/ui-kit';
import { Box, Icon, Loading, StyledLink, Text, TextErrors } from '@/components';
import { DEFAULT_QUERY_RETRY } from '@/core';
@@ -70,7 +71,13 @@ interface DocProps {
}
const DocPage = ({ id }: DocProps) => {
const { hasLostConnection, resetLostConnection } = useProviderStore();
const {
hasLostConnection,
resetLostConnection,
encryptionTransition,
clearEncryptionTransition,
provider,
} = useProviderStore();
const { isSkeletonVisible, setIsSkeletonVisible } = useSkeletonStore();
const {
data: docQuery,
@@ -173,6 +180,39 @@ const DocPage = ({ id }: DocProps) => {
}
}, [hasLostConnection, doc?.id, queryClient, resetLostConnection]);
// when encryption transition destroys the provider, that's the signal to refetch the document
useEffect(() => {
if (encryptionTransition && !provider && doc?.id) {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC, { id: doc.id }],
});
}
}, [encryptionTransition, provider, doc?.id, queryClient]);
// clear transition state once the doc has been refetched with updated state
// and encryption settings are resolved (derived or cleared), unblocking useCollaboration()
useEffect(() => {
if (!encryptionTransition || provider) {
return;
}
// this boolean check ensure the new document data has been properly fetch compared to the old data
const docUpdated =
encryptionTransition === 'encrypting'
? doc?.is_encrypted === true
: doc?.is_encrypted === false;
if (docUpdated && !documentEncryptionLoading) {
clearEncryptionTransition();
}
}, [
encryptionTransition,
provider,
doc?.is_encrypted,
documentEncryptionLoading,
clearEncryptionTransition,
]);
useEffect(() => {
if (!docQuery || isFetching) {
return;
@@ -329,9 +369,7 @@ const DocPage = ({ id }: DocProps) => {
<StyledLink href="/">
<Button
color="neutral"
icon={
<Icon iconName="home" $withThemeInherited />
}
icon={<Icon iconName="home" $withThemeInherited />}
>
{t('Back to home')}
</Button>
@@ -340,6 +378,25 @@ const DocPage = ({ id }: DocProps) => {
);
}
if (encryptionTransition) {
return (
<Box
$align="center"
$justify="center"
$height="100%"
$width="100%"
$gap="md"
>
<Spinner />
<Text $textAlign="center" $variation="secondary">
{encryptionTransition === 'encrypting'
? t('Document encryption in progress, please wait...')
: t('Removing document encryption, please wait...')}
</Text>
</Box>
);
}
return (
<>
<Head>
@@ -43,11 +43,13 @@ export const hocuspocusServer = new Server({
return Promise.resolve();
},
async beforeHandleMessage(data) {
//
// TODO: here or inside an equivalent listener "onMessage" to catch an event "ongoingEncryption"
// so we can close all connections properly and clear data. It needs to check this information from the backend first with "fetchDocument"
// this should be propagated to all subscribers so they can also prepare to refresh their page
//
async onStateless({ payload, document, connection }) {
// some interaction may require notifying other users so they adjust their UI, only let broadcast those events
if (payload.startsWith('system:')) {
// originator should manage it by himself
document.broadcastStateless(payload, (conn) => {
return conn !== connection;
});
}
},
});
@@ -21,10 +21,7 @@ export function getRelayRoom(roomId: string): Set<ws.WebSocket> | undefined {
return rooms.get(roomId);
}
export function closeRelayConnections(
roomId: string,
userId?: string,
): void {
export function closeRelayConnections(roomId: string, userId?: string): void {
const room = rooms.get(roomId);
if (!room) {
@@ -76,16 +73,10 @@ export async function handleRelayServerConnection(
});
ws.on('message', (data) => {
if (data.toString() === 'ongoingDecryption') {
//
// TODO: here or inside an equivalent listener "onMessage" to catch an event "ongoingEncryption"
// so we can close all connections properly and clear data. It needs to check this information from the backend first with "fetchDocument"
// this should be propagated to all subscribers so they can also prepare to refresh their page
//
return;
}
// Relay blindly since this server is a passthrough due to encryption
// relay to all other peers in the room (passthrough due to encryption)
// it will also broadcast events about encryption transition, note that we don't close connections
// for this from the server because the clients could retrieve connecting immediately, it's better letting
// all clients reacting properly so they switch to the right provider
for (const peer of Array.from(room)) {
if (peer !== ws) {
sendMessage(peer, data);