From 3f8e105035f125afa7122e1c1060d77f594ffe79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Tue, 3 Mar 2026 17:45:56 +0100 Subject: [PATCH] wip encrypt with attachments --- src/backend/core/api/serializers.py | 8 + src/backend/core/api/viewsets.py | 41 +- .../doc-editor/components/BlockNoteEditor.tsx | 2 +- .../components/custom-blocks/PdfBlock.tsx | 2 +- .../docs/doc-management/api/useEncryptDoc.tsx | 2 + .../components/ModalEncryptDoc.tsx | 382 +++++++++++++----- 6 files changed, 330 insertions(+), 107 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 0e857090..739d21a2 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -976,6 +976,14 @@ class EncryptDocumentSerializer(serializers.Serializer): content = serializers.CharField(required=True) encryptedSymmetricKeyPerUser = serializers.DictField(child=serializers.CharField(), required=True) + attachmentKeyMapping = serializers.DictField( + child=serializers.CharField(), + required=False, + default=dict, + help_text="Mapping of original attachment key to new encrypted attachment key. " + "During encryption, existing attachments are uploaded encrypted under new keys. " + "This mapping tells the backend to copy each new key over the original and clean up.", + ) class RemoveEncryptionSerializer(serializers.Serializer): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 08e3a8b4..1749eb17 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1480,19 +1480,10 @@ class DocumentViewSet( serializer = serializers.FileUploadSerializer(data=request.data) serializer.is_valid(raise_exception=True) + # Normally encrypted attachments would be only allowed on encrypted documents and vice-versa + # but since during encryption/decryption we upload all attachments before the switch, we cannot enforce this rule is_file_encrypted = serializer.validated_data.get("is_encrypted", False) - # Encrypted attachments are only allowed on encrypted documents - if is_file_encrypted and not document.is_encrypted: - raise drf.exceptions.ValidationError({ - "is_encrypted": - "Cannot upload encrypted attachments to a non-encrypted document." - }) - - # Generate a generic yet unique filename to store the image in object storage - file_id = uuid.uuid4() - ext = serializer.validated_data["expected_extension"] - # For encrypted files, set status to READY immediately since the server # cannot inspect ciphertext for malware scanning. initial_status = ( @@ -1513,6 +1504,10 @@ class DocumentViewSet( if is_file_encrypted: extra_args["Metadata"]["is_encrypted"] = "true" + # Generate a generic yet unique filename to store the image in object storage + file_id = uuid.uuid4() + ext = serializer.validated_data["expected_extension"] + file_unsafe = "" if serializer.validated_data["is_unsafe"]: extra_args["Metadata"]["is_unsafe"] = "true" @@ -2058,6 +2053,7 @@ class DocumentViewSet( content = serializer.validated_data["content"] encryptedSymmetricKeyPerUser = serializer.validated_data["encryptedSymmetricKeyPerUser"] + attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {}) # Prevent encryption if there are pending invitations if document.invitations.exists(): @@ -2092,11 +2088,34 @@ class DocumentViewSet( f'Only users with access should have encrypted symmetric keys.' }) + # Remove old unencrypted attachment keys from the allowed list. + # The frontend uploaded encrypted copies under new keys and updated the + # Yjs content to reference them. + if attachment_key_mapping: + old_keys = set(attachment_key_mapping.keys()) + document.attachments = [ + k for k in (document.attachments or []) if k not in old_keys + ] + # Update the document content and encryption status document.content = content # This will be cached and saved to object storage document.is_encrypted = True document.save() + # Clean up old S3 objects only after the DB transaction has committed, + # so a deletion failure can never affect the encrypt operation. + if attachment_key_mapping: + def _cleanup_old_attachments(): + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + for old_key in attachment_key_mapping: + try: + s3_client.delete_object(Bucket=bucket_name, Key=old_key) + except ClientError: + logger.warning("Failed to delete old attachment %s", old_key) + + transaction.on_commit(_cleanup_old_attachments) + # Store the encrypted symmetric keys in DocumentAccess for each user for user_id, encrypted_key in encryptedSymmetricKeyPerUser.items(): try: diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 48a65d94..8184285a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -154,7 +154,7 @@ export const BlockNoteEditor = ({ }; const mime = mimeMap[ext] || 'application/octet-stream'; - const blob = new Blob([decryptedBytes], { type: mime }); + const blob = new Blob([decryptedBytes as BlobPart], { type: mime }); const blobUrl = URL.createObjectURL(blob); blobUrlCacheRef.current.set(url, blobUrl); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx index 74523c4a..905202f8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx @@ -17,7 +17,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createGlobalStyle, css } from 'styled-components'; -import { Box, Button, Icon, Loading } from '@/components'; +import { Box, Icon, Loading } from '@/components'; import { ANALYZE_URL } from '../../conf'; import { DocsBlockNoteEditor } from '../../types'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useEncryptDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useEncryptDoc.tsx index 067704ac..31640fbd 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useEncryptDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useEncryptDoc.tsx @@ -11,6 +11,7 @@ interface EncryptDocProps { docId: string; content: Uint8Array; encryptedSymmetricKeyPerUser: Record; + attachmentKeyMapping?: Record; } export const encryptDoc = async ({ @@ -33,6 +34,7 @@ export const encryptDoc = async ({ ...params, content: toBase64(params.content), encryptedSymmetricKeyPerUser: base64EncryptedSymmetricKeyPerUser, + attachmentKeyMapping: params.attachmentKeyMapping || {}, }), }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx index 2f1e39af..d2bf0bb1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx @@ -1,5 +1,6 @@ import { Button, + Loader, Modal, ModalSize, VariantType, @@ -8,6 +9,9 @@ import { import { useTranslation } from 'react-i18next'; import * as Y from 'yjs'; +import { backendUrl } from '@/api'; +import { useState } from 'react'; + import { Box, ButtonCloseModal, Text, TextErrors } from '@/components'; import { useUserUpdate } from '@/core/api/useUserUpdate'; import { @@ -17,6 +21,7 @@ import { getEncryptionDB, prepareEncryptedSymmetricKeysForUsers, } from '@/docs/doc-collaboration'; +import { createDocAttachment } from '@/docs/doc-editor/api'; import { toBase64 } from '@/docs/doc-editor'; import { useAuth } from '@/features/auth'; import { @@ -27,6 +32,158 @@ import { useProviderStore, } from '@/features/docs/doc-management'; import { useKeyboardAction } from '@/hooks'; +import { Spinner } from '@gouvfr-lasuite/ui-kit'; + +function traverseYDoc( + node: Y.XmlElement | Y.XmlFragment, + callback: (el: Y.XmlElement) => void, +) { + if (node instanceof Y.XmlElement) { + callback(node); + } + + node.toArray().forEach((child) => { + if (child instanceof Y.XmlElement || child instanceof Y.XmlFragment) { + traverseYDoc(child, callback); + } + }); +} + +const UUID = + '[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}'; +const ATTACHMENT_KEY_REGEX = new RegExp( + `^/media/(${UUID}/attachments/${UUID}(?:-unsafe)?\\.[a-zA-Z0-9]{1,10})$`, +); + +type ExtractedKeyMetadata = { + mediaUrl: string; + name?: string; + nodes: Y.XmlElement[]; +}; + +// extract unique attachment keys from the Yjs document +const extractAttachmentKeysAndMetadata = ( + yDoc: Y.Doc, +): Map => { + const fragment = yDoc.getXmlFragment('document-store'); + + // for each key keep track of nodes + const keysAndMetadata = new Map(); + + yDoc.transact(() => { + traverseYDoc(fragment, (node) => { + const urlAttributeValue = node.getAttribute('url'); + + if (urlAttributeValue) { + // url should always be valid + const url = new URL(urlAttributeValue); + + // applying the test only on the pathname since hostname can vary + const match = ATTACHMENT_KEY_REGEX.exec(url.pathname); + + if (match) { + const key = match[1]; + const keyMetadata = keysAndMetadata.get(key); + + if (keyMetadata) { + keyMetadata.nodes.push(node); + } else { + // avoid any unexpected parts + url.search = ''; + url.hash = ''; + + keysAndMetadata.set(key, { + mediaUrl: url.toString(), + name: node.getAttribute('name'), + nodes: [node], + }); + } + } + } + }); + }); + + return keysAndMetadata; +}; + +/** + * encrypt existing unencrypted attachments and return: + * - a modified Yjs state with URLs pointing to new encrypted files + * - a mapping of old S3 keys to new ones (for backend cleanup) + * + * originals are never modified so if the process fails midway the document + * still works with its original unencrypted attachments. + */ +const encryptRemoteAttachments = async ( + yDoc: Y.Doc, + docId: string, + symmetricKey: CryptoKey, +): Promise> => { + const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc); + + // if no attachment it's straightforward + if (attachmentKeysAndMetadata.size === 0) { + return {}; + } + + // otherwise upload encrypted copies as new attachments and collect the mapping + const attachmentKeyMapping: Record = {}; + + for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from( + attachmentKeysAndMetadata.entries(), + )) { + const response = await fetch(oldAttachmentMetadata.mediaUrl, { + credentials: 'include', + }); + if (!response.ok) { + throw new Error('attachment cannot be fetch'); + } + + const fileBytes = new Uint8Array(await response.arrayBuffer()); + const encryptedBytes = await encryptContent(fileBytes, symmetricKey); + + const fileName = oldAttachmentMetadata.name ?? 'file'; // since encrypted we could not reuse the file name that can be stored as clear text + const encryptedFile = new File([encryptedBytes], fileName, { + type: 'application/octet-stream', + }); + + const body = new FormData(); + body.append('file', encryptedFile); + body.append('is_encrypted', 'true'); + + const result = await createDocAttachment({ docId, body }); + + // result.file is like "/api/v1.0/documents/{id}/media-check/?key={newKey}" + const newKey = new URL( + result.file, + window.location.origin, + ).searchParams.get('key'); + + if (!newKey) { + throw new Error('file key must be provided once uploaded'); + } + + attachmentKeyMapping[oldAttachmentKey] = newKey; + } + + // once uploaded, we can update all nodes referencing attachments with their new key + yDoc.transact(() => { + for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from( + attachmentKeysAndMetadata.entries(), + )) { + const newMediaUrl = oldAttachmentMetadata.mediaUrl.replace( + oldAttachmentKey, + attachmentKeyMapping[oldAttachmentKey], + ); + + for (const node of oldAttachmentMetadata.nodes) { + node.setAttribute('url', newMediaUrl); + } + } + }); + + return attachmentKeyMapping; +}; interface ModalEncryptDocProps { doc: Doc; @@ -51,129 +208,156 @@ export const ModalEncryptDoc = ({ const { user } = useAuth(); const { mutateAsync: updateUser } = useUserUpdate(); + const [isPending, setIsPending] = useState(false); + const { - mutate: encryptDoc, + mutateAsync: encryptDoc, isError, error, } = useEncryptDoc({ listInvalidQueries: [KEY_DOC, KEY_LIST_DOC], - options: { - onSuccess: () => { - onSuccess && onSuccess(doc); - onClose(); - - toast(t('The document has been encrypted.'), VariantType.SUCCESS, { - duration: 4000, - }); - }, - }, }); const keyboardAction = useKeyboardAction(); const handleClose = () => { + if (isPending) { + return; + } onClose(); }; const handleEncrypt = async () => { - if (!provider || !user) { + if (!provider || !user || isPending) { return; } - let currentUserPublicKeyFromThisOnboardingSession: ArrayBuffer | null = - null; + setIsPending(true); - // Perform the onboarding if that's the first time using encryption on this device - if (!encryptionSettings) { - // TODO: trigger the onboarding, either by creating or retrieving a key from another device - // TODO: probably the logic should be at a device key level, not user one? + try { + let currentUserPublicKeyFromThisOnboardingSession: ArrayBuffer | null = + null; - const userKeyPair = await generateUserKeyPair(); + // Perform the onboarding if that's the first time using encryption on this device + if (!encryptionSettings) { + // TODO: trigger the onboarding, either by creating or retrieving a key from another device + // TODO: probably the logic should be at a device key level, not user one? - const encryptionDatabase = await getEncryptionDB(); + const userKeyPair = await generateUserKeyPair(); - // TODO: it should use transaction - // encryptionDatabase.transaction - await encryptionDatabase.put( - 'privateKey', - userKeyPair.privateKey, - `user:${user.id}`, - ); - await encryptionDatabase.put( - 'publicKey', - userKeyPair.publicKey, - `user:${user.id}`, + const encryptionDatabase = await getEncryptionDB(); + + // TODO: it should use transaction + // encryptionDatabase.transaction + await encryptionDatabase.put( + 'privateKey', + userKeyPair.privateKey, + `user:${user.id}`, + ); + await encryptionDatabase.put( + 'publicKey', + userKeyPair.publicKey, + `user:${user.id}`, + ); + + const rawPublicKey = await crypto.subtle.exportKey( + 'spki', + userKeyPair.publicKey, + ); + + // TODO: it should throw if the backend has already a public key (so the user can with concious forget the old one (but here he did the onboarding already so... it was probably a new device)) + await updateUser({ + id: user.id, + encryption_public_key: toBase64(new Uint8Array(rawPublicKey)), + }); + + currentUserPublicKeyFromThisOnboardingSession = rawPublicKey; + + // TODO: should check encryptionSettings will update, otherwise hard refresh is needed + window.location.reload(); + + return; + } + + const documentSymmetricKey = await generateSymmetricKey(); + + // Their public key are base64 encoded, decoding the whole + const usersPublicKeys: Record = {}; + + if (doc.accesses_public_keys_per_user) { + // TODO: + // TODO: should throw if missing public keys according to current accesses + // TODO: + + for (const [userId, publicKey] of Object.entries( + doc.accesses_public_keys_per_user, + )) { + usersPublicKeys[userId] = Buffer.from(publicKey, 'base64').buffer; + } + + // if the onboarding has been done directly in this encryption flow, the backend has not yet told the frontend + // about the current user key, so just patching the mapping with this new public key + if (currentUserPublicKeyFromThisOnboardingSession) { + usersPublicKeys[user.id] = + currentUserPublicKeyFromThisOnboardingSession; + } + } else { + // if it has been not provided it's weird because it should only happen for people not authenticated + throw new Error(`"accesses_public_keys_per_user" should be provided`); + } + + // Prepare encrypted symmetric keys for all users with access + const encryptedSymmetricKeyPerUser = + await prepareEncryptedSymmetricKeysForUsers( + documentSymmetricKey, + usersPublicKeys, + ); + + // clone the Yjs document since performing changes during encryption that require backend confirmation + // once successfully done it can be used locally + const ongoingDoc = new Y.Doc(); + Y.applyUpdate(ongoingDoc, Y.encodeStateAsUpdate(provider.document)); + + // encrypt existing attachments + const attachmentKeyMapping = await encryptRemoteAttachments( + ongoingDoc, + doc.id, + documentSymmetricKey, ); - const rawPublicKey = await crypto.subtle.exportKey( - 'spki', - userKeyPair.publicKey, + const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc); + + const encryptedContent = await encryptContent( + new Uint8Array(ongoingDocState), + documentSymmetricKey, ); - // TODO: it should throw if the backend has already a public key (so the user can with concious forget the old one (but here he did the onboarding already so... it was probably a new device)) - await updateUser({ - id: user.id, - encryption_public_key: toBase64(new Uint8Array(rawPublicKey)), + // TODO: + // TODO: if none it should at least make it for the current user + // TODO: so it makes sense `accesses_public_keys_per_user` is always passed? + // TODO: + + await encryptDoc({ + docId: doc.id, + content: encryptedContent, + encryptedSymmetricKeyPerUser, + attachmentKeyMapping, }); - currentUserPublicKeyFromThisOnboardingSession = rawPublicKey; + // 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)); - // TODO: should check encryptionSettings will update, otherwise hard refresh is needed - window.location.reload(); + onSuccess?.(doc); + onClose(); - return; + toast(t('The document has been encrypted.'), VariantType.SUCCESS, { + duration: 4000, + }); + + ongoingDoc.destroy(); + } finally { + setIsPending(false); } - - const documentSymmetricKey = await generateSymmetricKey(); - - const state = Y.encodeStateAsUpdate(provider.document); - const encryptedContent = await encryptContent( - new Uint8Array(state), - documentSymmetricKey, - ); - - // Their public key are base64 encoded, decoding the whole - const usersPublicKeys: Record = {}; - - if (doc.accesses_public_keys_per_user) { - // TODO: - // TODO: should throw if missing public keys according to current accesses - // TODO: - - for (const [userId, publicKey] of Object.entries( - doc.accesses_public_keys_per_user, - )) { - usersPublicKeys[userId] = Buffer.from(publicKey, 'base64').buffer; - } - - // if the onboarding has been done directly in this encryption flow, the backend has not yet told the frontend - // about the current user key, so just patching the mapping with this new public key - if (currentUserPublicKeyFromThisOnboardingSession) { - usersPublicKeys[user.id] = - currentUserPublicKeyFromThisOnboardingSession; - } - } else { - // if it has been not provided it's weird because it should only happen for people not authenticated - throw new Error(`"accesses_public_keys_per_user" should be provided`); - } - - // Prepare encrypted symmetric keys for all users with access - const encryptedSymmetricKeyPerUser = - await prepareEncryptedSymmetricKeysForUsers( - documentSymmetricKey, - usersPublicKeys, - ); - - // TODO: - // TODO: if none it should at least make it for the current user - // TODO: so it makes sense `accesses_public_keys_per_user` is always passed? - // TODO: - - encryptDoc({ - docId: doc.id, - content: encryptedContent, - encryptedSymmetricKeyPerUser, - }); }; const handleCloseKeyDown = keyboardAction(handleClose); @@ -182,7 +366,7 @@ export const ModalEncryptDoc = ({ return ( {t('Cancel')} @@ -201,6 +386,14 @@ export const ModalEncryptDoc = ({ fullWidth onClick={handleEncrypt} onKeyDown={handleEncryptKeyDown} + disabled={isPending} + icon={ + isPending ? ( +
+ +
+ ) : undefined + } > {t('Confirm')} @@ -227,6 +420,7 @@ export const ModalEncryptDoc = ({ aria-label={t('Close the encrypt modal')} onClick={handleClose} onKeyDown={handleCloseKeyDown} + disabled={isPending} /> }