From 9c438eba068f9fc535f7c89145f3c341d71e27a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 11 Feb 2026 11:13:51 +0100 Subject: [PATCH] wip --- src/backend/core/api/filters.py | 23 +- src/backend/core/api/serializers.py | 117 +++++- src/backend/core/api/viewsets.py | 229 ++++++++++- .../0029_document_is_encrypted_and_more.py | 28 ++ src/backend/core/models.py | 38 ++ .../tests/documents/test_api_documents_all.py | 1 + .../test_api_documents_children_list.py | 14 + .../test_api_documents_descendants.py | 21 + .../test_api_documents_favorite_list.py | 1 + .../documents/test_api_documents_list.py | 1 + .../test_api_documents_list_filters.py | 63 +++ .../documents/test_api_documents_retrieve.py | 9 + .../documents/test_api_documents_tree.py | 35 ++ .../app-impress/doc-grid-dnd.spec.ts | 3 + src/frontend/apps/impress/package.json | 3 +- .../impress/src/features/auth/api/types.ts | 2 + .../doc-collaboration/encryptedWebsocket.ts | 135 ++++++ .../docs/doc-collaboration/encryption.ts | 125 ++++++ .../docs/doc-collaboration/encryptionDB.ts | 35 ++ .../hook/useDocumentEncryption.tsx | 99 +++++ .../doc-collaboration/hook/useEncryption.tsx | 94 +++++ .../hook/usePublicKeyRegistry.tsx | 104 +++++ .../features/docs/doc-collaboration/index.ts | 12 + .../docs/doc-collaboration/relayProvider.ts | 15 + .../doc-editor/__tests__/DocEditor.spec.tsx | 6 +- .../doc-editor/components/BlockNoteEditor.tsx | 32 +- .../docs/doc-editor/components/DocEditor.tsx | 37 +- .../hook/__tests__/useSaveDoc.test.tsx | 8 +- .../docs/doc-editor/hook/useSaveDoc.tsx | 55 ++- .../__tests__/DocToolBoxLicence.spec.tsx | 4 +- .../doc-header/components/BoutonShare.tsx | 36 +- .../docs/doc-header/components/DocHeader.tsx | 22 +- .../docs/doc-header/components/DocToolBox.tsx | 79 +++- .../features/docs/doc-management/api/index.ts | 2 + .../doc-management/api/useCreateChildDoc.tsx | 3 + .../docs/doc-management/api/useCreateDoc.tsx | 6 +- .../docs/doc-management/api/useDocs.tsx | 4 + .../doc-management/api/useDuplicateDoc.tsx | 17 +- .../docs/doc-management/api/useEncryptDoc.tsx | 78 ++++ .../api/useRemoveDocEncryption.tsx | 70 ++++ .../docs/doc-management/api/useSubDocs.tsx | 1 + .../docs/doc-management/api/useUpdateDoc.tsx | 1 + .../assets/encrypted-document.svg | 138 +++++++ .../components/ModalEncryptDoc.tsx | 258 ++++++++++++ .../components/ModalRemoveDocEncryption.tsx | 149 +++++++ .../components/SimpleDocItem.tsx | 9 + .../docs/doc-management/components/index.ts | 2 + .../doc-management/hooks/useCollaboration.tsx | 81 +++- .../hooks/useCreateChildDocTree.tsx | 1 + .../doc-management/stores/IncomingMessage.ts | 63 --- .../stores/useProviderStore.tsx | 386 ++++++------------ .../features/docs/doc-management/types.tsx | 3 + .../docs/doc-share/api/useCreateDocAccess.tsx | 3 + .../components/DocShareAddMemberList.tsx | 59 ++- .../components/DocShareInvitation.tsx | 4 + .../doc-share/components/DocShareMember.tsx | 15 +- .../doc-share/components/DocShareModal.tsx | 39 +- .../doc-share/components/SearchUserRow.tsx | 15 +- .../components/DocTreeItemActions.tsx | 1 + .../components/ModalConfirmationVersion.tsx | 1 + .../docs/docs-grid/api/useImportDoc.tsx | 1 + .../docs/docs-grid/components/DocsGrid.tsx | 10 +- .../docs-grid/components/DocsGridItem.tsx | 6 +- .../service-worker/plugins/ApiPlugin.ts | 1 + .../skeletons/components/Skeleton.tsx | 24 +- .../apps/impress/src/i18n/translations.json | 20 + .../impress/src/pages/docs/[id]/index.tsx | 46 ++- .../impress/src/stores/useBroadcastStore.tsx | 16 +- src/frontend/package.json | 9 +- src/frontend/patches/y-websocket+3.0.0.patch | 69 ++++ src/frontend/servers/y-provider/package.json | 2 - .../src/api/collaborationBackend.ts | 1 + .../collaborationResetConnectionsHandler.ts | 35 +- .../src/handlers/collaborationWSHandler.ts | 108 ++++- .../getDocumentConnectionInfoHandler.ts | 37 +- .../y-provider/src/servers/appServer.ts | 15 +- .../servers/y-provider/src/servers/common.ts | 0 .../src/servers/hocuspocusServer.ts | 82 +--- .../y-provider/src/servers/relayServer.ts | 98 +++++ .../src/servers/standard/callback.js | 87 ---- .../y-provider/src/servers/standard/server.js | 34 -- .../y-provider/src/servers/standard/utils.js | 324 --------------- src/frontend/yarn.lock | 310 +++++--------- 83 files changed, 3033 insertions(+), 1197 deletions(-) create mode 100644 src/backend/core/migrations/0029_document_is_encrypted_and_more.py create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptionDB.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useEncryption.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/usePublicKeyRegistry.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-collaboration/relayProvider.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/api/useEncryptDoc.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/api/useRemoveDocEncryption.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/assets/encrypted-document.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/stores/IncomingMessage.ts create mode 100644 src/frontend/patches/y-websocket+3.0.0.patch create mode 100644 src/frontend/servers/y-provider/src/servers/common.ts create mode 100644 src/frontend/servers/y-provider/src/servers/relayServer.ts delete mode 100644 src/frontend/servers/y-provider/src/servers/standard/callback.js delete mode 100755 src/frontend/servers/y-provider/src/servers/standard/server.js delete mode 100644 src/frontend/servers/y-provider/src/servers/standard/utils.js diff --git a/src/backend/core/api/filters.py b/src/backend/core/api/filters.py index 42cb79cd..1d1d0892 100644 --- a/src/backend/core/api/filters.py +++ b/src/backend/core/api/filters.py @@ -66,10 +66,13 @@ class ListDocumentFilter(DocumentFilter): is_favorite = django_filters.BooleanFilter( method="filter_is_favorite", label=_("Favorite") ) + is_encrypted = django_filters.BooleanFilter( + method="filter_is_encrypted", label=_("Encrypted") + ) class Meta: model = models.Document - fields = ["is_creator_me", "is_favorite", "title"] + fields = ["is_creator_me", "is_favorite", "is_encrypted", "title"] # pylint: disable=unused-argument def filter_is_creator_me(self, queryset, name, value): @@ -110,6 +113,24 @@ class ListDocumentFilter(DocumentFilter): return queryset.filter(is_favorite=bool(value)) + # pylint: disable=unused-argument + def filter_is_encrypted(self, queryset, name, value): + """ + Filter documents based on whether they are encrypted. + + Example: + - /api/v1.0/documents/?is_encrypted=true + → Filters documents encrypted + - /api/v1.0/documents/?is_encrypted=false + → Filters documents not encrypted + """ + user = self.request.user + + if not user.is_authenticated: + return queryset + + return queryset.filter(is_encrypted=bool(value)) + # pylint: disable=unused-argument def filter_is_masked(self, queryset, name, value): """ diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 349e0191..4bd9c3b9 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -32,7 +32,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["id", "email", "full_name", "short_name", "language"] + fields = ["id", "email", "full_name", "short_name", "encryption_public_key", "language"] read_only_fields = ["id", "email", "full_name", "short_name"] def get_full_name(self, instance): @@ -65,17 +65,23 @@ class ListDocumentSerializer(serializers.ModelSerializer): """Serialize documents with limited fields for display in lists.""" is_favorite = serializers.BooleanField(read_only=True) + is_encrypted = serializers.BooleanField(read_only=True) nb_accesses_ancestors = serializers.IntegerField(read_only=True) nb_accesses_direct = serializers.IntegerField(read_only=True) user_role = serializers.SerializerMethodField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True) deleted_at = serializers.SerializerMethodField(read_only=True) + accesses_public_keys_per_user = serializers.SerializerMethodField(read_only=True) + encrypted_document_symmetric_key_for_user = serializers.SerializerMethodField( + read_only=True + ) class Meta: model = models.Document fields = [ "id", "abilities", + "accesses_public_keys_per_user", "ancestors_link_reach", "ancestors_link_role", "computed_link_reach", @@ -84,8 +90,10 @@ class ListDocumentSerializer(serializers.ModelSerializer): "creator", "deleted_at", "depth", + "encrypted_document_symmetric_key_for_user", "excerpt", "is_favorite", + "is_encrypted", "link_role", "link_reach", "nb_accesses_ancestors", @@ -99,6 +107,7 @@ class ListDocumentSerializer(serializers.ModelSerializer): read_only_fields = [ "id", "abilities", + "accesses_public_keys_per_user", "ancestors_link_reach", "ancestors_link_role", "computed_link_reach", @@ -107,8 +116,10 @@ class ListDocumentSerializer(serializers.ModelSerializer): "creator", "deleted_at", "depth", + "encrypted_document_symmetric_key_for_user", "excerpt", "is_favorite", + "is_encrypted", "link_role", "link_reach", "nb_accesses_ancestors", @@ -151,6 +162,28 @@ class ListDocumentSerializer(serializers.ModelSerializer): """Return the deleted_at of the current document.""" return instance.ancestors_deleted_at + def get_accesses_public_keys_per_user(self, instance): + """Return public keys of users with access, only for encrypted documents.""" + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return None + return instance.accesses_public_keys_per_user + + def get_encrypted_document_symmetric_key_for_user(self, instance): + """Return the encrypted symmetric key for the current user.""" + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return None + if not instance.is_encrypted: + return None + try: + access = models.DocumentAccess.objects.get( + document=instance, user=request.user + ) + return access.encrypted_document_symmetric_key_for_user + except models.DocumentAccess.DoesNotExist: + return None + class DocumentLightSerializer(serializers.ModelSerializer): """Minial document serializer for nesting in document accesses.""" @@ -165,6 +198,7 @@ class DocumentSerializer(ListDocumentSerializer): """Serialize documents with all fields for display in detail views.""" content = serializers.CharField(required=False) + contentEncrypted = serializers.BooleanField(required=False, write_only=True) websocket = serializers.BooleanField(required=False, write_only=True) file = serializers.FileField( required=False, write_only=True, allow_null=True, max_length=255 @@ -175,18 +209,22 @@ class DocumentSerializer(ListDocumentSerializer): fields = [ "id", "abilities", + "accesses_public_keys_per_user", "ancestors_link_reach", "ancestors_link_role", "computed_link_reach", "computed_link_role", "content", + "contentEncrypted", "created_at", "creator", "deleted_at", "depth", "excerpt", + "encrypted_document_symmetric_key_for_user", "file", "is_favorite", + "is_encrypted", "link_role", "link_reach", "nb_accesses_ancestors", @@ -209,7 +247,9 @@ class DocumentSerializer(ListDocumentSerializer): "creator", "deleted_at", "depth", + "encrypted_document_symmetric_key_for_user", "is_favorite", + "is_encrypted", "link_role", "link_reach", "nb_accesses_ancestors", @@ -228,6 +268,11 @@ class DocumentSerializer(ListDocumentSerializer): if request and request.method == "POST": fields["id"].read_only = False + # if user is not authenticated remove public keys information since he can still retrieve the document + if request and not request.user.is_authenticated: + fields.pop("accesses_public_keys_per_user", None) + fields.pop("encrypted_document_symmetric_key_for_user", None) + return fields def validate_id(self, value): @@ -343,6 +388,9 @@ class DocumentAccessSerializer(serializers.ModelSerializer): abilities = serializers.SerializerMethodField(read_only=True) max_ancestors_role = serializers.SerializerMethodField(read_only=True) max_role = serializers.SerializerMethodField(read_only=True) + encrypted_document_symmetric_key_for_user = serializers.CharField( + required=False, allow_blank=True, write_only=True + ) class Meta: model = models.DocumentAccess @@ -357,6 +405,7 @@ class DocumentAccessSerializer(serializers.ModelSerializer): "abilities", "max_ancestors_role", "max_role", + "encrypted_document_symmetric_key_for_user", ] read_only_fields = [ "id", @@ -366,6 +415,29 @@ class DocumentAccessSerializer(serializers.ModelSerializer): "max_role", ] + def get_fields(self): + """Dynamically control field availability and requirements based on document encryption status.""" + fields = super().get_fields() + + # Get the document from context (if available) + document = None + if "view" in self.context and hasattr(self.context["view"], "document"): + document = self.context["view"].document + + # Get the encrypted_document_symmetric_key_for_user field + key_field = fields.get("encrypted_document_symmetric_key_for_user") + + if key_field: + # If document is encrypted, make the field required + if document and getattr(document, "is_encrypted", False): + key_field.required = True + key_field.allow_blank = False + # If document is not encrypted, remove the field entirely + elif document and not getattr(document, "is_encrypted", False): + fields.pop("encrypted_document_symmetric_key_for_user", None) + + return fields + def get_abilities(self, instance) -> dict: """Return abilities of the logged-in user on the instance.""" request = self.context.get("request") @@ -856,6 +928,49 @@ class MoveDocumentSerializer(serializers.Serializer): ) +class EncryptDocumentSerializer(serializers.Serializer): + """ + Serializer for encrypting a document. + + Fields: + - content (CharField): The encrypted content of the document. + This field is required. + - encryptedSymmetricKeyPerUser (DictField): Mapping of user IDs to their encrypted symmetric keys. + This field is required. + + Example: + Input payload for encrypting a document: + { + "content": "", + "encryptedSymmetricKeyPerUser": { + "user1_id": "encrypted_key_1", + "user2_id": "encrypted_key_2" + } + } + """ + + content = serializers.CharField(required=True) + encryptedSymmetricKeyPerUser = serializers.DictField(child=serializers.CharField(), required=True) + + +class RemoveEncryptionSerializer(serializers.Serializer): + """ + Serializer for removing encryption from a document. + + Fields: + - content (CharField): The decrypted content of the document. + This field is required. + + Example: + Input payload for removing encryption from a document: + { + "content": "" + } + """ + + content = serializers.CharField(required=True) + + class ReactionSerializer(serializers.ModelSerializer): """Serialize reactions.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 85bc59e3..9896fa6d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -168,6 +168,10 @@ class UserViewSet( ): """User ViewSet""" + # + # TODO: adjust update public key + # + permission_classes = [permissions.IsSelf] queryset = models.User.objects.filter(is_active=True) serializer_class = serializers.UserSerializer @@ -354,6 +358,19 @@ class DocumentViewSet( Returns: JSON response with the translated text. Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. + 12. **Encrypt**: Encrypt a document. + Example: PATCH /documents/{id}/encrypt/ + Expected data: + - content (str): The encrypted content. + - encryptedSymmetricKeyPerUser (dict): Mapping of user IDs to encrypted symmetric keys. + Returns: JSON response with the updated document. + + 13. **Remove Encryption**: Remove encryption from a document. + Example: PATCH /documents/{id}/remove-encryption/ + Expected data: + - content (str): The decrypted content. + Returns: JSON response with the updated document. + ### Ordering: created_at, updated_at, is_favorite, title Example: @@ -365,11 +382,18 @@ class DocumentViewSet( - `is_creator_me=false`: Returns documents created by other users. - `is_favorite=true`: Returns documents marked as favorite by the current user - `is_favorite=false`: Returns documents not marked as favorite by the current user + - `is_encrypted=true`: Returns documents encrypted + - `is_encrypted=false`: Returns documents not encrypted - `title=hello`: Returns documents which title contains the "hello" string Example: - GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true - - GET /api/v1.0/documents/?is_creator_me=false&title=hello + - GET /api/v1.0/documents/?is_creator_me=false&title=hello&is_encrypted=false + + ### Encryption Management: + The encryption status of documents can be managed using the dedicated endpoints: + - PATCH /documents/{id}/encrypt/ - Set is_encrypted to true + - PATCH /documents/{id}/remove-encryption/ - Set is_encrypted to false ### Annotations: 1. **is_favorite**: Indicates whether the document is marked as favorite by the current user. @@ -613,6 +637,20 @@ class DocumentViewSet( def perform_update(self, serializer): """Check rules about collaboration.""" + content_encrypted = serializer.validated_data.pop("contentEncrypted", None) + if ( + content_encrypted is not None + and content_encrypted != serializer.instance.is_encrypted + ): + raise drf.exceptions.ValidationError( + { + "contentEncrypted": ( + "Content encryption status does not match the document's " + "current state. Please refresh and try again." + ) + } + ) + if ( serializer.validated_data.get("websocket", False) or not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY @@ -1937,6 +1975,155 @@ class DocumentViewSet( } ) + def perform_update(self, serializer): + """ + Perform update with safety check for encryption state changes. + + If contentEncrypted parameter is provided, it must match the current + is_encrypted state to prevent accidental content overrides during + encryption state transitions. + """ + document = self.get_object() + + # Prevent direct changes to is_encrypted field via PATCH + # (encryption state should only be changed via /encrypt/ or /remove-encryption/ endpoints) + if 'is_encrypted' in serializer.validated_data: + raise drf.exceptions.ValidationError({ + 'is_encrypted': + 'Cannot modify is_encrypted directly. ' + 'Use the /encrypt/ or /remove-encryption/ endpoints to manage encryption.' + }) + + # Check if contentEncrypted parameter was provided + content_encrypted = serializer.validated_data.get('contentEncrypted') + + if content_encrypted is not None: + # Get the current document instance + document = self.get_object() + + # Safety check: contentEncrypted must match current is_encrypted state + if content_encrypted != document.is_encrypted: + raise drf.exceptions.ValidationError({ + 'contentEncrypted': + f'contentEncrypted must match current encryption state. ' + f'Current: is_encrypted={document.is_encrypted}, ' + f'Provided: contentEncrypted={content_encrypted}' + }) + + # Proceed with normal update + return super().perform_update(serializer) + + @transaction.atomic + @drf.decorators.action( + detail=True, + methods=["patch"], + name="Encrypt a document", + url_path="encrypt", + ) + def encrypt(self, request, *args, **kwargs): + """ + PATCH /api/v1.0/documents//encrypt/ + with expected data: + - content: str (encrypted content) + - encryptedSymmetricKeyPerUser: dict (user_id -> encrypted_key) + Updates the document's content and marks it as encrypted. + """ + document = self.get_object() + + serializer = serializers.EncryptDocumentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + content = serializer.validated_data["content"] + encryptedSymmetricKeyPerUser = serializer.validated_data["encryptedSymmetricKeyPerUser"] + + # Prevent encryption if there are pending invitations + if document.invitations.exists(): + raise drf.exceptions.ValidationError({ + 'non_field_errors': + 'Cannot encrypt a document with pending invitations. ' + 'Please resolve all invitations before encrypting.' + }) + + # Validate that we have keys for all users with access to this document + # Get all user IDs that have access to this document + document_accesses = models.DocumentAccess.objects.filter(document=document, user__isnull=False) + users_with_access = {str(access.user_id) for access in document_accesses} + + # Check that encryptedSymmetricKeyPerUser contains all required users + provided_user_ids = set(encryptedSymmetricKeyPerUser.keys()) + missing_users = users_with_access - provided_user_ids + + if missing_users: + raise drf.exceptions.ValidationError({ + 'encryptedSymmetricKeyPerUser': + f'Missing encrypted keys for users with document access: {missing_users}. ' + f'All users must have encrypted symmetric keys when encrypting a document.' + }) + + # Check for extra users that don't have access + extra_users = provided_user_ids - users_with_access + if extra_users: + raise drf.exceptions.ValidationError({ + 'encryptedSymmetricKeyPerUser': + f'Encrypted keys provided for users without document access: {extra_users}. ' + f'Only users with access should have encrypted symmetric 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() + + # Store the encrypted symmetric keys in DocumentAccess for each user + for user_id, encrypted_key in encryptedSymmetricKeyPerUser.items(): + try: + # Find the DocumentAccess record for this user and document + access = models.DocumentAccess.objects.get(document=document, user_id=user_id) + access.encrypted_document_symmetric_key_for_user = encrypted_key + access.save() + except models.DocumentAccess.DoesNotExist: + # This should not happen due to our validation above, but keep as safety + pass + + # Return the updated document + serializer = self.get_serializer(document) + return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) + + @transaction.atomic + @drf.decorators.action( + detail=True, + methods=["patch"], + name="Remove encryption from a document", + url_path="remove-encryption", + ) + def remove_encryption(self, request, *args, **kwargs): + """ + PATCH /api/v1.0/documents//remove-encryption/ + with expected data: + - content: str (decrypted content) + Updates the document's content and marks it as not encrypted. + """ + document = self.get_object() + + serializer = serializers.RemoveEncryptionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + content = serializer.validated_data["content"] + + # Update the document content and encryption status + document.content = content # This will be cached and saved to object storage + document.is_encrypted = False + document.save() + + # Clean up any stored encrypted keys + models.DocumentAccess.objects.filter(document=document).update( + encrypted_document_symmetric_key_for_user=None + ) + + # Return the updated document + serializer = self.get_serializer(document) + return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) + class DocumentAccessViewSet( ResourceAccessViewsetMixin, @@ -2098,6 +2285,16 @@ class DocumentAccessViewSet( "Only owners of a document can assign other users as owners." ) + # Handle encrypted_document_symmetric_key_for_user during creation + if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data: + if not self.document.is_encrypted: + raise drf.exceptions.ValidationError({ + 'encrypted_document_symmetric_key_for_user': + 'This field can only be provided when the document is encrypted.' + }) + # For encrypted documents, allow the key to be provided + # The key will be stored directly in the DocumentAccess record + access = serializer.save(document_id=self.kwargs["resource_id"]) if access.user: @@ -2112,6 +2309,14 @@ class DocumentAccessViewSet( def perform_update(self, serializer): """Update an access to the document and notify the collaboration server.""" + # Prevent direct modification of encrypted_document_symmetric_key_for_user + # This field should only be managed at access creation or when rotating the document key + if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data: + raise drf.exceptions.ValidationError({ + 'encrypted_document_symmetric_key_for_user': + 'This field cannot be modified directly.' + }) + access = serializer.save() access_user_id = None @@ -2221,6 +2426,15 @@ class InvitationViewset( def perform_create(self, serializer): """Save invitation to a document then send an email to the invited user.""" + # Prevent invitation creation for encrypted documents + document = models.Document.objects.get(pk=self.kwargs["resource_id"]) + if document.is_encrypted: + raise drf.exceptions.ValidationError({ + 'non_field_errors': + 'Cannot create invitations for encrypted documents. ' + 'All invitations must be resolved before encrypting a document.' + }) + invitation = serializer.save() invitation.document.send_invitation_email( @@ -2230,6 +2444,19 @@ class InvitationViewset( self.request.user.language or settings.LANGUAGE_CODE, ) + def perform_update(self, serializer): + """Update an invitation to a document.""" + # Prevent invitation updates for encrypted documents + document = models.Document.objects.get(pk=self.kwargs["resource_id"]) + if document.is_encrypted: + raise drf.exceptions.ValidationError({ + 'non_field_errors': + 'Cannot update invitations for encrypted documents. ' + 'All invitations must be resolved before encrypting a document.' + }) + + return super().perform_update(serializer) + class DocumentAskForAccessViewSet( drf.mixins.ListModelMixin, diff --git a/src/backend/core/migrations/0029_document_is_encrypted_and_more.py b/src/backend/core/migrations/0029_document_is_encrypted_and_more.py new file mode 100644 index 00000000..fb7b62c8 --- /dev/null +++ b/src/backend/core/migrations/0029_document_is_encrypted_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.10 on 2026-02-23 10:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0028_remove_templateaccess_template_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='document', + name='is_encrypted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='documentaccess', + name='encrypted_document_symmetric_key_for_user', + field=models.TextField(blank=True, help_text='Encrypted symmetric key for this document, specific to this user.', null=True, verbose_name='encrypted document symmetric key'), + ), + migrations.AddField( + model_name='user', + name='encryption_public_key', + field=models.TextField(blank=True, help_text='Public key for end-to-end encryption.', null=True, verbose_name='encryption public key'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 71abd253..ca05438f 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -186,6 +186,12 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): default=False, help_text=_("Whether the user can log into this admin site."), ) + encryption_public_key = models.TextField( + _("encryption public key"), + null=True, + blank=True, + help_text=_("Public key for end-to-end encryption."), + ) is_active = models.BooleanField( _("active"), default=True, @@ -279,6 +285,12 @@ class BaseAccess(BaseModel): role = models.CharField( max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER ) + encrypted_document_symmetric_key_for_user = models.TextField( + _("encrypted document symmetric key"), + null=True, + blank=True, + help_text=_("Encrypted symmetric key for this document, specific to this user."), + ) class Meta: abstract = True @@ -361,6 +373,7 @@ class Document(MP_Node, BaseModel): title = models.CharField(_("title"), max_length=255, null=True, blank=True) excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True) + is_encrypted = models.BooleanField(default=False) link_reach = models.CharField( max_length=20, choices=LinkReachChoices.choices, @@ -718,6 +731,29 @@ class Document(MP_Node, BaseModel): """Actual link role on the document.""" return self.computed_link_definition["link_role"] + @property + def accesses_public_keys_per_user(self): + """ + Return public keys of users with access to this document. + Returns a dictionary mapping user IDs to their encryption public keys. + Available for all documents so that encryption can be initiated + on non-encrypted documents too. + """ + # Get all users with direct access to this document + users_with_access = ( + DocumentAccess.objects + .filter(document=self, user__isnull=False) + .select_related('user') + .values_list('user_id', 'user__encryption_public_key') + ) + + # Convert to dictionary: {user_id: public_key} + return { + str(user_id): public_key + for user_id, public_key in users_with_access + if public_key # Only include users with public keys + } + def get_abilities(self, user): """ Compute and return abilities for a given user on the document. @@ -797,12 +833,14 @@ class Document(MP_Node, BaseModel): "descendants": can_get, "destroy": can_destroy, "duplicate": can_get and user.is_authenticated, + "encrypt": is_owner_or_admin, "favorite": can_get and user.is_authenticated, "link_configuration": is_owner_or_admin, "invite_owner": is_owner and not is_deleted, "mask": can_get and user.is_authenticated, "move": is_owner_or_admin and not is_deleted, "partial_update": can_update, + "remove_encryption": is_owner_or_admin, "restore": is_owner, "retrieve": retrieve, "media_auth": can_get, diff --git a/src/backend/core/tests/documents/test_api_documents_all.py b/src/backend/core/tests/documents/test_api_documents_all.py index 051872b8..449413b8 100644 --- a/src/backend/core/tests/documents/test_api_documents_all.py +++ b/src/backend/core/tests/documents/test_api_documents_all.py @@ -351,6 +351,7 @@ def test_api_documents_all_format(): "depth": 1, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "nb_accesses_ancestors": 1, diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py index e9a5cff3..4b45d3b9 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_list.py +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -46,6 +46,7 @@ def test_api_documents_children_list_anonymous_public_standalone( "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -69,6 +70,7 @@ def test_api_documents_children_list_anonymous_public_standalone( "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -122,6 +124,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -145,6 +148,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -217,6 +221,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -240,6 +245,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -298,6 +304,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -321,6 +328,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -406,6 +414,7 @@ def test_api_documents_children_list_authenticated_related_direct( "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -429,6 +438,7 @@ def test_api_documents_children_list_authenticated_related_direct( "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -490,6 +500,7 @@ def test_api_documents_children_list_authenticated_related_parent( "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -513,6 +524,7 @@ def test_api_documents_children_list_authenticated_related_parent( "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -626,6 +638,7 @@ def test_api_documents_children_list_authenticated_related_team_members( "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 0, @@ -649,6 +662,7 @@ def test_api_documents_children_list_authenticated_related_team_members( "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, diff --git a/src/backend/core/tests/documents/test_api_documents_descendants.py b/src/backend/core/tests/documents/test_api_documents_descendants.py index f320b070..f9376068 100644 --- a/src/backend/core/tests/documents/test_api_documents_descendants.py +++ b/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -43,6 +43,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 1, @@ -68,6 +69,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "excerpt": grand_child.excerpt, "id": str(grand_child.id), "is_favorite": False, + "is_encrypted": grand_child.is_encrypted, "link_reach": grand_child.link_reach, "link_role": grand_child.link_role, "numchild": 0, @@ -91,6 +93,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -143,6 +146,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 1, @@ -166,6 +170,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "excerpt": grand_child.excerpt, "id": str(grand_child.id), "is_favorite": False, + "is_encrypted": grand_child.is_encrypted, "link_reach": grand_child.link_reach, "link_role": grand_child.link_role, "numchild": 0, @@ -189,6 +194,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -262,6 +268,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 1, @@ -285,6 +292,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "excerpt": grand_child.excerpt, "id": str(grand_child.id), "is_favorite": False, + "is_encrypted": grand_child.is_encrypted, "link_reach": grand_child.link_reach, "link_role": grand_child.link_role, "numchild": 0, @@ -308,6 +316,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -366,6 +375,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 1, @@ -389,6 +399,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "excerpt": grand_child.excerpt, "id": str(grand_child.id), "is_favorite": False, + "is_encrypted": grand_child.is_encrypted, "link_reach": grand_child.link_reach, "link_role": grand_child.link_role, "numchild": 0, @@ -412,6 +423,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -491,6 +503,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 1, @@ -514,6 +527,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "excerpt": grand_child.excerpt, "id": str(grand_child.id), "is_favorite": False, + "is_encrypted": grand_child.is_encrypted, "link_reach": grand_child.link_reach, "link_role": grand_child.link_role, "numchild": 0, @@ -537,6 +551,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -596,6 +611,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 1, @@ -619,6 +635,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "excerpt": grand_child.excerpt, "id": str(grand_child.id), "is_favorite": False, + "is_encrypted": grand_child.is_encrypted, "link_reach": grand_child.link_reach, "link_role": grand_child.link_role, "numchild": 0, @@ -642,6 +659,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, @@ -747,6 +765,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "excerpt": child1.excerpt, "id": str(child1.id), "is_favorite": False, + "is_encrypted": child1.is_encrypted, "link_reach": child1.link_reach, "link_role": child1.link_role, "numchild": 1, @@ -770,6 +789,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "excerpt": grand_child.excerpt, "id": str(grand_child.id), "is_favorite": False, + "is_encrypted": grand_child.is_encrypted, "link_reach": grand_child.link_reach, "link_role": grand_child.link_role, "numchild": 0, @@ -793,6 +813,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "excerpt": child2.excerpt, "id": str(child2.id), "is_favorite": False, + "is_encrypted": child2.is_encrypted, "link_reach": child2.link_reach, "link_role": child2.link_role, "numchild": 0, diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index d5bfe3c1..b466bef5 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -71,6 +71,7 @@ def test_api_document_favorite_list_authenticated_with_favorite(): "excerpt": document.excerpt, "id": str(document.id), "is_favorite": True, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "nb_accesses_ancestors": 1, diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py index bb422a0c..4619b066 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -73,6 +73,7 @@ def test_api_documents_list_format(): "depth": 1, "excerpt": document.excerpt, "is_favorite": True, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "nb_accesses_ancestors": 3, diff --git a/src/backend/core/tests/documents/test_api_documents_list_filters.py b/src/backend/core/tests/documents/test_api_documents_list_filters.py index 5baa7e30..cabe576d 100644 --- a/src/backend/core/tests/documents/test_api_documents_list_filters.py +++ b/src/backend/core/tests/documents/test_api_documents_list_filters.py @@ -312,6 +312,69 @@ def test_api_documents_list_filter_is_favorite_invalid(): assert len(results) == 5 +# Filters: is_encrypted + + +def test_api_documents_list_filter_is_encrypted_true(): + """ + Authenticated users should be able to filter encrypted documents. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user]) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_encrypted=true") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 3 + + # Ensure all results are encrypted + for result in results: + assert result["is_encrypted"] is True + + +def test_api_documents_list_filter_is_encrypted_false(): + """ + Authenticated users should be able to filter documents not encrypted. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user]) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_encrypted=false") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + + # Ensure all results are not encrypted + for result in results: + assert result["is_encrypted"] is False + + +def test_api_documents_list_filter_is_encrypted_invalid(): + """Filtering with an invalid `is_encrypted` value should do nothing.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory.create_batch(3, users=[user]) + factories.DocumentFactory.create_batch(2, users=[user]) + + response = client.get("/api/v1.0/documents/?is_encrypted=invalid") + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 5 + + # Filters: is_masked diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 59c5e029..1851326f 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -75,6 +75,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "depth": 1, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": "public", "link_role": document.link_role, "nb_accesses_ancestors": 0, @@ -151,6 +152,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "depth": 3, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "nb_accesses_ancestors": 0, @@ -260,6 +262,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "deleted_at": None, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": reach, "link_role": document.link_role, "nb_accesses_ancestors": 0, @@ -343,6 +346,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "deleted_at": None, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "nb_accesses_ancestors": 0, @@ -458,6 +462,7 @@ def test_api_documents_retrieve_authenticated_related_direct(): "depth": 1, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "nb_accesses_ancestors": 2, @@ -541,6 +546,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "deleted_at": None, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": "restricted", "link_role": document.link_role, "nb_accesses_ancestors": 2, @@ -698,6 +704,7 @@ def test_api_documents_retrieve_authenticated_related_team_members( "depth": 1, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": "restricted", "link_role": document.link_role, "nb_accesses_ancestors": 5, @@ -765,6 +772,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators( "depth": 1, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": "restricted", "link_role": document.link_role, "nb_accesses_ancestors": 5, @@ -832,6 +840,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners( "depth": 1, "excerpt": document.excerpt, "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": "restricted", "link_role": document.link_role, "nb_accesses_ancestors": 5, diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py index c86eebc1..05ca0fd5 100644 --- a/src/backend/core/tests/documents/test_api_documents_tree.py +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -54,6 +54,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, + "is_encrypted": child.is_encrypted, "link_reach": child.link_reach, "link_role": child.link_role, "numchild": 0, @@ -78,6 +79,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "numchild": 1, @@ -102,6 +104,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "excerpt": sibling1.excerpt, "id": str(sibling1.id), "is_favorite": False, + "is_encrypted": sibling1.is_encrypted, "link_reach": sibling1.link_reach, "link_role": sibling1.link_role, "numchild": 0, @@ -126,6 +129,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "excerpt": sibling2.excerpt, "id": str(sibling2.id), "is_favorite": False, + "is_encrypted": sibling2.is_encrypted, "link_reach": sibling2.link_reach, "link_role": sibling2.link_role, "numchild": 0, @@ -146,6 +150,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, + "is_encrypted": parent.is_encrypted, "link_reach": parent.link_reach, "link_role": parent.link_role, "numchild": 3, @@ -219,6 +224,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, + "is_encrypted": child.is_encrypted, "link_reach": child.link_reach, "link_role": child.link_role, "numchild": 0, @@ -243,6 +249,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "numchild": 1, @@ -271,6 +278,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "excerpt": document_sibling.excerpt, "id": str(document_sibling.id), "is_favorite": False, + "is_encrypted": document_sibling.is_encrypted, "link_reach": document_sibling.link_reach, "link_role": document_sibling.link_role, "numchild": 0, @@ -293,6 +301,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, + "is_encrypted": parent.is_encrypted, "link_reach": parent.link_reach, "link_role": parent.link_role, "numchild": 2, @@ -319,6 +328,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "excerpt": parent_sibling.excerpt, "id": str(parent_sibling.id), "is_favorite": False, + "is_encrypted": parent_sibling.is_encrypted, "link_reach": parent_sibling.link_reach, "link_role": parent_sibling.link_role, "numchild": 0, @@ -341,6 +351,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "excerpt": grand_parent.excerpt, "id": str(grand_parent.id), "is_favorite": False, + "is_encrypted": grand_parent.is_encrypted, "link_reach": grand_parent.link_reach, "link_role": grand_parent.link_role, "numchild": 2, @@ -421,6 +432,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, + "is_encrypted": child.is_encrypted, "link_reach": child.link_reach, "link_role": child.link_role, "numchild": 0, @@ -443,6 +455,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "numchild": 1, @@ -467,6 +480,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "excerpt": sibling.excerpt, "id": str(sibling.id), "is_favorite": False, + "is_encrypted": sibling.is_encrypted, "link_reach": sibling.link_reach, "link_role": sibling.link_role, "numchild": 0, @@ -487,6 +501,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, + "is_encrypted": parent.is_encrypted, "link_reach": parent.link_reach, "link_role": parent.link_role, "numchild": 2, @@ -565,6 +580,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, + "is_encrypted": child.is_encrypted, "link_reach": child.link_reach, "link_role": child.link_role, "numchild": 0, @@ -589,6 +605,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "numchild": 1, @@ -617,6 +634,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "excerpt": document_sibling.excerpt, "id": str(document_sibling.id), "is_favorite": False, + "is_encrypted": document_sibling.is_encrypted, "link_reach": document_sibling.link_reach, "link_role": document_sibling.link_role, "numchild": 0, @@ -639,6 +657,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, + "is_encrypted": parent.is_encrypted, "link_reach": parent.link_reach, "link_role": parent.link_role, "numchild": 2, @@ -665,6 +684,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "excerpt": parent_sibling.excerpt, "id": str(parent_sibling.id), "is_favorite": False, + "is_encrypted": parent_sibling.is_encrypted, "link_reach": parent_sibling.link_reach, "link_role": parent_sibling.link_role, "numchild": 0, @@ -687,6 +707,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "excerpt": grand_parent.excerpt, "id": str(grand_parent.id), "is_favorite": False, + "is_encrypted": grand_parent.is_encrypted, "link_reach": grand_parent.link_reach, "link_role": grand_parent.link_role, "numchild": 2, @@ -769,6 +790,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, + "is_encrypted": child.is_encrypted, "link_reach": child.link_reach, "link_role": child.link_role, "numchild": 0, @@ -791,6 +813,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "numchild": 1, @@ -815,6 +838,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "excerpt": sibling.excerpt, "id": str(sibling.id), "is_favorite": False, + "is_encrypted": sibling.is_encrypted, "link_reach": sibling.link_reach, "link_role": sibling.link_role, "numchild": 0, @@ -835,6 +859,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, + "is_encrypted": parent.is_encrypted, "link_reach": parent.link_reach, "link_role": parent.link_role, "numchild": 2, @@ -917,6 +942,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, + "is_encrypted": child.is_encrypted, "link_reach": child.link_reach, "link_role": child.link_role, "numchild": 0, @@ -941,6 +967,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "numchild": 1, @@ -969,6 +996,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "excerpt": document_sibling.excerpt, "id": str(document_sibling.id), "is_favorite": False, + "is_encrypted": document_sibling.is_encrypted, "link_reach": document_sibling.link_reach, "link_role": document_sibling.link_role, "numchild": 0, @@ -991,6 +1019,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, + "is_encrypted": parent.is_encrypted, "link_reach": parent.link_reach, "link_role": parent.link_role, "numchild": 2, @@ -1017,6 +1046,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "excerpt": parent_sibling.excerpt, "id": str(parent_sibling.id), "is_favorite": False, + "is_encrypted": parent_sibling.is_encrypted, "link_reach": parent_sibling.link_reach, "link_role": parent_sibling.link_role, "numchild": 0, @@ -1039,6 +1069,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "excerpt": grand_parent.excerpt, "id": str(grand_parent.id), "is_favorite": False, + "is_encrypted": grand_parent.is_encrypted, "link_reach": grand_parent.link_reach, "link_role": grand_parent.link_role, "numchild": 2, @@ -1129,6 +1160,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "excerpt": child.excerpt, "id": str(child.id), "is_favorite": False, + "is_encrypted": child.is_encrypted, "link_reach": child.link_reach, "link_role": child.link_role, "numchild": 0, @@ -1151,6 +1183,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "excerpt": document.excerpt, "id": str(document.id), "is_favorite": False, + "is_encrypted": document.is_encrypted, "link_reach": document.link_reach, "link_role": document.link_role, "numchild": 1, @@ -1175,6 +1208,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "excerpt": sibling.excerpt, "id": str(sibling.id), "is_favorite": False, + "is_encrypted": sibling.is_encrypted, "link_reach": sibling.link_reach, "link_role": sibling.link_role, "numchild": 0, @@ -1195,6 +1229,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "excerpt": parent.excerpt, "id": str(parent.id), "is_favorite": False, + "is_encrypted": parent.is_encrypted, "link_reach": parent.link_reach, "link_role": parent.link_role, "numchild": 2, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts index 17cfa815..e3f249d1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts @@ -232,6 +232,7 @@ const data = [ depth: 1, excerpt: null, is_favorite: false, + is_encrypted: false, link_role: 'reader', link_reach: 'restricted', nb_accesses_ancestors: 1, @@ -281,6 +282,7 @@ const data = [ depth: 1, excerpt: null, is_favorite: false, + is_encrypted: false, link_role: 'reader', link_reach: 'restricted', nb_accesses_ancestors: 1, @@ -329,6 +331,7 @@ const data = [ depth: 1, excerpt: null, is_favorite: false, + is_encrypted: false, link_role: 'reader', link_reach: 'restricted', nb_accesses_ancestors: 14, diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 3f8628d1..56f19af5 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -44,7 +44,7 @@ "@sentry/nextjs": "10.34.0", "@tanstack/react-query": "5.90.18", "@tiptap/extensions": "*", - "@y/websocket-server": "^0.1.1", + "async-mutex": "^0.5.0", "canvg": "4.0.3", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -55,7 +55,6 @@ "i18next": "25.7.4", "i18next-browser-languagedetector": "8.2.0", "idb": "8.0.3", - "js-base64": "^3.7.8", "lodash": "4.17.23", "luxon": "3.7.2", "next": "15.5.9", diff --git a/src/frontend/apps/impress/src/features/auth/api/types.ts b/src/frontend/apps/impress/src/features/auth/api/types.ts index 75a46581..53dbbcee 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -4,6 +4,7 @@ * @property {string} id - The id of the user. * @property {string} email - The email of the user. * @property {string} name - The name of the user. + * @property {string} encryptionPublicKey - The user public key if encryption onboarding has been done. * @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'. */ export interface User { @@ -11,6 +12,7 @@ export interface User { email: string; full_name: string; short_name: string; + encryption_public_key: string | null; language?: string; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts new file mode 100644 index 00000000..0363a96f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts @@ -0,0 +1,135 @@ +import type { MessageEvent } from 'ws'; + +import { + decryptContent, + encryptContent, +} from '@/docs/doc-collaboration/encryption'; + +export class EncryptedWebSocket extends WebSocket { + protected readonly encryptionKey!: CryptoKey; + protected readonly decryptionKey!: CryptoKey; + + constructor(address: string | URL, protocols?: string | string[]) { + super(address, protocols); + + const originalAddEventListener = this.addEventListener.bind(this); + + this.addEventListener = function ( + type: K, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void { + if (type === 'message') { + const wrappedListener: typeof listener = async (event) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messageEvent = event as any; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!(messageEvent.data instanceof ArrayBuffer)) { + throw new Error( + `the data over the wire should always be ArrayBuffer since defined on the websocket property "binaryType"`, + ); + } + + const manageableData = new Uint8Array( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + messageEvent.data as ArrayBuffer, + ); + + const decryptedData = await decryptContent( + manageableData, + this.decryptionKey, + ); + + if (typeof listener === 'function') { + listener.call(this, { ...event, data: decryptedData }); + } else { + listener.handleEvent.call(this, { + ...event, + data: decryptedData, + }); + } + }; + + originalAddEventListener('message', wrappedListener, options); + } else { + originalAddEventListener(type, listener, options); + } + }; + + // In case it's added directly with `onmessage` and since we cannot override the setter of `onmessage` + // tweak a bit to intercept when setting it + // const base = Object.getPrototypeOf(this) as WebSocket; + // const baseDesc = Object.getOwnPropertyDescriptor(base, 'onmessage')!; + + let explicitlySetListener: // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((this: WebSocket, handlerEvent: MessageEvent) => any) | null; + null; + + Object.defineProperty(this, 'onmessage', { + configurable: true, + enumerable: true, + get() { + console.log('GETTING ONMESSAGE'); + + return explicitlySetListener; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars + set(handler: ((handlerEvent: MessageEvent) => any) | null) { + explicitlySetListener = null; + + throw new Error( + `"onmessage" should not be set by "y-websocket", instead it should be patched to use "addEventListener" since we want to extend it to decrypt messages but "defineProperty" is not working on the instance, probably it should be done on the prototype but it would mess with other WebSocket usage. SO PLEASE RUN "yarn run patch-package"!`, + ); + + // if (!handler) { + // explicitlySetListener = null; + // return; + // } + + // explicitlySetListener = function ( + // this: WebSocket, + // event: MessageEvent, + // ) { + // if (!(event.data instanceof ArrayBuffer)) { + // throw new Error( + // `the data over the wire should always be ArrayBuffer since defined on the websocket property "binaryType"`, + // ); + // } + + // const manageableData = new Uint8Array(event.data); + + // return handler.call(this, { ...event, data: decrypt(manageableData) }); + // }; + }, + }); + } + + send(message: Uint8Array) { + // 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 + // it's used into the Yjs websocket provider. + // + // try like this since no return value is expected from here, but it will mess in case of error (unhandled exception...) + // if it does not fit our need, we will have to rewrite the Yjs websocket package to have the best async logic set up + encryptContent(message, this.encryptionKey) + .then((encryptedMessage) => { + super.send(encryptedMessage); + }) + .catch((error) => { + console.error(error); + + return Promise.reject(error); + }); + } +} + +export function createAdaptedEncryptedWebsocketClass(options: { + encryptionKey: CryptoKey; + decryptionKey: CryptoKey; +}) { + return class extends EncryptedWebSocket { + protected readonly encryptionKey = options.encryptionKey; + protected readonly decryptionKey = options.decryptionKey; + }; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption.ts new file mode 100644 index 00000000..aa7b54f8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryption.ts @@ -0,0 +1,125 @@ +const userKeyPairAlgorithm = 'RSA-OAEP'; +const documentSymmetricKeyAlgorithm = 'AES-GCM'; + +export async function generateUserKeyPair(): Promise { + return await crypto.subtle.generateKey( + { + name: userKeyPairAlgorithm, + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + ); +} + +// generate a symmetric key for document encryption +export async function generateSymmetricKey(): Promise { + return await crypto.subtle.generateKey( + { name: documentSymmetricKeyAlgorithm, length: 256 }, + true, + ['encrypt', 'decrypt'], + ); +} + +// Encrypt a symmetric key with a user's public key +export async function encryptSymmetricKey( + symmetricKey: CryptoKey, + publicKey: CryptoKey, +): Promise { + const raw = await crypto.subtle.exportKey('raw', symmetricKey); + + // TODO: + // TODO: should use something better than RSA-OAEP, but maybe WebCrypto is not enough (use downloaded library? "libsodium-wrappers" or so) + // TODO: + return await crypto.subtle.encrypt( + { name: userKeyPairAlgorithm }, + publicKey, + raw, + ); +} + +// decrypt a symmetric key with the local private key +export async function decryptSymmetricKey( + encryptedSymmetricKey: ArrayBuffer, + privateKey: CryptoKey, +): Promise { + const raw = await crypto.subtle.decrypt( + { name: userKeyPairAlgorithm }, + privateKey, + encryptedSymmetricKey, + ); + + return await crypto.subtle.importKey( + 'raw', + raw, + { name: documentSymmetricKeyAlgorithm }, + true, + ['encrypt', 'decrypt'], + ); +} + +// encrypt content with a symmetric key +export async function encryptContent( + content: Uint8Array, + symmetricKey: CryptoKey, +): Promise> { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { + name: documentSymmetricKeyAlgorithm, + iv, + }, + symmetricKey, + content, + ); + + // Prepend IV to ciphertext so the recipient can extract it for decryption + const result = new Uint8Array(iv.length + ciphertext.byteLength); + result.set(iv); + result.set(new Uint8Array(ciphertext), iv.length); + return result; +} + +// decrypt content with a symmetric key +export async function decryptContent( + encryptedContent: Uint8Array, + symmetricKey: CryptoKey, +): Promise> { + const iv = encryptedContent.slice(0, 12); + const ciphertext = encryptedContent.slice(12); + const arrayBuffer = await crypto.subtle.decrypt( + { + name: documentSymmetricKeyAlgorithm, + iv, + }, + symmetricKey, + ciphertext, + ); + + return new Uint8Array(arrayBuffer); +} + +// prepare encrypted symmetric keys for all users with access to a document +export async function prepareEncryptedSymmetricKeysForUsers( + symmetricKey: CryptoKey, + accessesPublicKeysPerUser: Record, +): Promise> { + const result: Record = {}; + + // encrypt the symmetric key for each user's public key + for (const [userId, publicKey] of Object.entries(accessesPublicKeysPerUser)) { + const usablePublicKey = await crypto.subtle.importKey( + 'spki', + publicKey, + { name: userKeyPairAlgorithm, hash: 'SHA-256' }, + true, + ['encrypt'], + ); + + result[userId] = await encryptSymmetricKey(symmetricKey, usablePublicKey); + } + + return result; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptionDB.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptionDB.ts new file mode 100644 index 00000000..728ffe2b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptionDB.ts @@ -0,0 +1,35 @@ +import { IDBPDatabase, openDB } from 'idb'; + +const DB_NAME = 'encryption'; +const DB_VERSION = 1; + +// Store names +export const STORE_PRIVATE_KEY = 'privateKey'; +export const STORE_PUBLIC_KEY = 'publicKey'; +export const STORE_KNOWN_PUBLIC_KEYS = 'knownPublicKeys'; + +let dbPromise: Promise | null = null; + +/** + * Opens (or reuses) the encryption IndexedDB with all required object stores. + * Uses a singleton promise so the upgrade callback only runs once. + */ +export function getEncryptionDB(): Promise { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_PRIVATE_KEY)) { + db.createObjectStore(STORE_PRIVATE_KEY); + } + if (!db.objectStoreNames.contains(STORE_PUBLIC_KEY)) { + db.createObjectStore(STORE_PUBLIC_KEY); + } + if (!db.objectStoreNames.contains(STORE_KNOWN_PUBLIC_KEYS)) { + db.createObjectStore(STORE_KNOWN_PUBLIC_KEYS); + } + }, + }); + } + + return dbPromise; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx new file mode 100644 index 00000000..5ff76dad --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useDocumentEncryption.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react'; + +import { decryptSymmetricKey } from '@/docs/doc-collaboration/encryption'; +import assert from 'assert'; + +export function useDocumentEncryption( + encryptionLoading: boolean, + encryptionSettings: { + userId: string; + userPrivateKey: CryptoKey; + userPublicKey: CryptoKey; + } | null, + isDocumentEncrypted: boolean | undefined, + userEncryptedSymmetricKey: string | undefined, +): { + documentEncryptionLoading: boolean; + documentEncryptionSettings: { + documentSymmetricKey: CryptoKey; + } | null; +} { + const [loading, setLoading] = useState(true); + const [settings, setSettings] = useState<{ + documentSymmetricKey: CryptoKey; + } | null>(null); + + useEffect(() => { + let cancelled = false; + + async function initDocumentEncryption() { + // Waiting for global encryption settings to be ready, or for asynchronous document data to be fetch + if (!encryptionLoading && !encryptionSettings) { + setLoading(false); + setSettings(null); + return; + } else if (encryptionLoading || isDocumentEncrypted === undefined) { + setLoading(true); + setSettings(null); + return; + } else if (isDocumentEncrypted === false) { + setLoading(false); + setSettings(null); + return; + } + + // TODO: + // TODO: if encrypted but there is no encrypted symmetric key for this user, we should display an error + // TODO: (maybe reuse the catch handler below?) + // TODO: + assert( + userEncryptedSymmetricKey, + 'document encrypted symmetric key must exist', + ); + + try { + setLoading(true); + + const userEncryptedSymmetricKeyArrayBuffer = Buffer.from( + userEncryptedSymmetricKey, + 'base64', + ); + + const symmetricKey = await decryptSymmetricKey( + userEncryptedSymmetricKeyArrayBuffer.buffer, + encryptionSettings!.userPrivateKey, + ); + + if (!cancelled) { + setSettings({ documentSymmetricKey: symmetricKey }); + } + } catch (err) { + console.error(err); + + // + // TODO: this should display a global error since if encryption needed it should able + // to retrieve information (except if onboarding needed, but still...) + // + // maybe this should be a return value so the parent knows where to set the CTA + // + + if (!cancelled) { + setSettings(null); + } + } finally { + setLoading(false); + } + } + + initDocumentEncryption(); + + return () => { + cancelled = true; + }; + }, [encryptionLoading, encryptionSettings, userEncryptedSymmetricKey]); + + return { + documentEncryptionLoading: loading, + documentEncryptionSettings: settings, + }; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useEncryption.tsx new file mode 100644 index 00000000..8c3b45a0 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/useEncryption.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react'; + +import { getEncryptionDB } from '../encryptionDB'; + +export function useEncryption(userId?: string): { + encryptionLoading: boolean; + encryptionSettings: { + userId: string; + userPrivateKey: CryptoKey; + userPublicKey: CryptoKey; + } | null; +} { + const [loading, setLoading] = useState(true); + const [settings, setSettings] = useState<{ + userId: string; + userPrivateKey: CryptoKey; + userPublicKey: CryptoKey; + } | null>(null); + + const enableEncryption: boolean = true; // TODO: this could be toggled for instances not needing encryption to save some requests + + useEffect(() => { + let cancelled = false; + + async function initEncryption() { + // Waiting for asynchronous data before initializing encryption stuff + if (!userId) { + setLoading(true); + setSettings(null); + return; + } else if (enableEncryption === false) { + setLoading(false); + setSettings(null); + return; + } + + try { + setLoading(true); + + // We must first retrieve user keys locally + const encryptionDatabase = await getEncryptionDB(); + + const userPrivateKey = await encryptionDatabase.get( + 'privateKey', + `user:${userId}`, + ); + + if (!userPrivateKey) { + throw new Error('user has no local private key (needs onboarding)'); + } + + const userPublicKey = await encryptionDatabase.get( + 'publicKey', + `user:${userId}`, + ); + + if (!userPublicKey) { + throw new Error('user is missing his public key'); + } + + if (!cancelled) { + setSettings({ + userId: userId, + userPrivateKey: userPrivateKey, + userPublicKey: userPublicKey, + }); + } + } catch (err) { + console.error(err); + + // + // TODO: this should display a global error since if encryption needed it should able + // to retrieve information (except if onboarding needed, but still...) + // + // maybe this should be a return value so the parent knows where to set the CTA + // + + if (!cancelled) { + setSettings(null); + } + } finally { + setLoading(false); + } + } + + initEncryption(); + + return () => { + cancelled = true; + }; + }, [userId, enableEncryption]); + + return { encryptionLoading: loading, encryptionSettings: settings }; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/usePublicKeyRegistry.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/usePublicKeyRegistry.tsx new file mode 100644 index 00000000..53355143 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/hook/usePublicKeyRegistry.tsx @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + STORE_KNOWN_PUBLIC_KEYS, + getEncryptionDB, +} from '../encryptionDB'; + +export interface PublicKeyMismatch { + userId: string; + knownKey: string; + currentKey: string; +} + +/** + * TOFU (Trust On First Use) public key registry. + * + * - On first encounter, a user's public key is stored locally in IndexedDB. + * - On subsequent encounters, if the key differs from the stored one, it is + * flagged as a mismatch. + * - The caller can accept a new key via `acceptNewKey(userId)`, which updates + * the locally stored key. + */ +export function usePublicKeyRegistry( + accessesPublicKeysPerUser: Record | undefined, +) { + const [mismatches, setMismatches] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!accessesPublicKeysPerUser) { + setMismatches([]); + setLoading(false); + return; + } + + let cancelled = false; + + async function checkKeys() { + try { + const db = await getEncryptionDB(); + const newMismatches: PublicKeyMismatch[] = []; + + for (const [userId, currentKey] of Object.entries( + accessesPublicKeysPerUser!, + )) { + const knownKey: string | undefined = await db.get( + STORE_KNOWN_PUBLIC_KEYS, + `user:${userId}`, + ); + + if (!knownKey) { + // First time seeing this user's key — trust on first use + await db.put(STORE_KNOWN_PUBLIC_KEYS, currentKey, `user:${userId}`); + } else if (knownKey !== currentKey) { + newMismatches.push({ userId, knownKey, currentKey }); + } + } + + if (!cancelled) { + setMismatches(newMismatches); + } + } catch (err) { + console.error('usePublicKeyRegistry: failed to check keys', err); + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + setLoading(true); + checkKeys(); + + return () => { + cancelled = true; + }; + }, [accessesPublicKeysPerUser]); + + const acceptNewKey = useCallback( + async (userId: string) => { + const mismatch = mismatches.find((m) => m.userId === userId); + if (!mismatch) { + return; + } + + const db = await getEncryptionDB(); + await db.put( + STORE_KNOWN_PUBLIC_KEYS, + mismatch.currentKey, + `user:${userId}`, + ); + + setMismatches((prev) => prev.filter((m) => m.userId !== userId)); + }, + [mismatches], + ); + + return { + mismatches, + hasMismatches: mismatches.length > 0, + loading, + acceptNewKey, + }; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts new file mode 100644 index 00000000..2af8e75f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/index.ts @@ -0,0 +1,12 @@ +export { + decryptContent, + encryptContent, + generateSymmetricKey, + generateUserKeyPair, + prepareEncryptedSymmetricKeysForUsers, + encryptSymmetricKey, +} from './encryption'; +export { getEncryptionDB } from './encryptionDB'; +export { useDocumentEncryption } from './hook/useDocumentEncryption'; +export { useEncryption } from './hook/useEncryption'; +export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/relayProvider.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/relayProvider.ts new file mode 100644 index 00000000..c2cbbaff --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/relayProvider.ts @@ -0,0 +1,15 @@ +import { WebsocketProvider } from 'y-websocket'; + +export class RelayProvider extends WebsocketProvider { + // since the RelayProvider has been added to manage encryption that skips Hocuspocus logic + // we mimic the needed properties for `SwitchableProvider` to be usable and to avoid use extra intermediaries + get document() { + return this.doc; + } + + get configuration() { + return { + name: this.roomname, + }; + } +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx index 9d3b39b2..e70ebbad 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx @@ -78,7 +78,7 @@ describe('DocEditor', () => { }, } as any; - const { rerender } = render(, { + const { rerender } = render(, { wrapper: AppWrapper, }); @@ -90,7 +90,7 @@ describe('DocEditor', () => { // Rerender with same doc to check that event is not tracked again rerender( - , + , ); expect(TrackEventMock).toHaveBeenNthCalledWith(1, { @@ -107,6 +107,8 @@ describe('DocEditor', () => { id: 'test-doc-id-2', computed_link_reach: LinkReach.RESTRICTED, }} + encryptionSettings={null} + documentEncryptionSettings={null} />, ); 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 6c88f99b..08a3dcdd 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 @@ -12,8 +12,6 @@ import * as locales from '@blocknote/core/locales'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; -import { HocuspocusProvider } from '@hocuspocus/provider'; -import { WebsocketProvider } from 'y-websocket'; import { useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -22,7 +20,11 @@ import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, useProviderStore } from '@/docs/doc-management'; +import { + Doc, + SwitchableProvider, + useProviderStore, +} from '@/docs/doc-management'; import { avatarUrlFromName, useAuth } from '@/features/auth'; import { @@ -78,11 +80,17 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) || interface BlockNoteEditorProps { doc: Doc; - // provider: HocuspocusProvider; - provider: WebsocketProvider; + provider: SwitchableProvider; + documentEncryptionSettings: { + documentSymmetricKey: CryptoKey; + } | null; } -export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { +export const BlockNoteEditor = ({ + doc, + provider, + documentEncryptionSettings, +}: BlockNoteEditorProps) => { const { user } = useAuth(); const { setEditor } = useEditorStore(); const { t } = useTranslation(); @@ -93,8 +101,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { // Determine if comments should be visible in the UI const showComments = canSeeComment; - // useSaveDoc(doc.id, provider.document, isConnectedToCollabServer); - useSaveDoc(doc.id, provider.doc, isConnectedToCollabServer); + useSaveDoc( + doc.id, + provider.document, + isConnectedToCollabServer, + doc.is_encrypted, + documentEncryptionSettings, + ); const { i18n } = useTranslation(); let lang = i18n.resolvedLanguage; if (!lang || !(lang in locales)) { @@ -123,8 +136,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { { collaboration: { provider: provider as { awareness?: Awareness | undefined }, - // fragment: provider.document.getXmlFragment('document-store'), - fragment: provider.doc.getXmlFragment('document-store'), + fragment: provider.document.getXmlFragment('document-store'), user: { name: cursorName, color: randomColor(), diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index 171a55a4..87c9ebf6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -76,9 +76,21 @@ export const DocEditorContainer = ({ interface DocEditorProps { doc: Doc; + encryptionSettings: { + userId: string; + userPrivateKey: CryptoKey; + userPublicKey: CryptoKey; + } | null; + documentEncryptionSettings: { + documentSymmetricKey: CryptoKey; + } | null; } -export const DocEditor = ({ doc }: DocEditorProps) => { +export const DocEditor = ({ + doc, + encryptionSettings, + documentEncryptionSettings, +}: DocEditorProps) => { const { isDesktop } = useResponsiveStore(); const { provider, isReady } = useProviderStore(); const { isEditable, isLoading } = useIsCollaborativeEditable(doc); @@ -122,7 +134,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => { }); }, [authenticated, hasTracked, isPublicDoc, trackEvent]); - if (!isProviderReady || provider?.roomname !== doc.id) { + if (!isProviderReady || provider?.configuration.name !== doc.id) { return ; } @@ -130,18 +142,27 @@ export const DocEditor = ({ doc }: DocEditorProps) => { <> {isDesktop && } } + docHeader={ + + } docEditor={ readOnly ? ( ) : ( - + ) } isDeletedDoc={isDeletedDoc} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx index e532c804..880ed98c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx @@ -43,7 +43,7 @@ describe('useSaveDoc', () => { const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, false, null), { wrapper: AppWrapper, }); @@ -75,7 +75,7 @@ describe('useSaveDoc', () => { }), }); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, false, null), { wrapper: AppWrapper, }); @@ -112,7 +112,7 @@ describe('useSaveDoc', () => { }), }); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, false, null), { wrapper: AppWrapper, }); @@ -132,7 +132,7 @@ describe('useSaveDoc', () => { const docId = 'test-doc-id'; const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); - const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), { + const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, false, null), { wrapper: AppWrapper, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index e93f5628..4f5b09a7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -1,7 +1,8 @@ import { useRouter } from 'next/router'; -import { useCallback, useEffect, useState } from 'react'; +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 { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning'; import { isFirefox } from '@/utils/userAgent'; @@ -14,6 +15,10 @@ export const useSaveDoc = ( docId: string, yDoc: Y.Doc, isConnectedToCollabServer: boolean, + isEncrypted: boolean, + documentEncryptionSettings: { + documentSymmetricKey: CryptoKey; + } | null, ) => { const { mutate: updateDoc } = useUpdateDoc({ listInvalidQueries: [KEY_LIST_DOC_VERSIONS], @@ -35,16 +40,6 @@ export const useSaveDoc = ( _updatedDoc: Y.Doc, transaction: Y.Transaction, ) => { - console.log(333333); - if (transaction.local) { - console.log('LOCAL'); - } else { - console.log('REMOTE'); - } - // console.log(transaction); - - // transaction. - setIsLocalChange(transaction.local); }; @@ -58,20 +53,42 @@ export const useSaveDoc = ( const saveDoc = useCallback(() => { if (!isLocalChange) { 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; } - console.log('--------'); - console.log(111111); - console.log(yDoc); + let state = Y.encodeStateAsUpdate(yDoc); + let contentPromise: Promise; - updateDoc({ - id: docId, - content: toBase64(Y.encodeStateAsUpdate(yDoc)), - websocket: isConnectedToCollabServer, + if (isEncrypted) { + contentPromise = encryptContent( + new Uint8Array(state), + documentEncryptionSettings!.documentSymmetricKey, + ); + } else { + contentPromise = Promise.resolve(state); + } + + contentPromise.then((docState) => { + updateDoc({ + id: docId, + content: toBase64(docState), + contentEncrypted: isEncrypted, + websocket: isConnectedToCollabServer, + }); }); return true; - }, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]); + }, [ + isLocalChange, + updateDoc, + docId, + yDoc, + isConnectedToCollabServer, + isEncrypted, + documentEncryptionSettings, + ]); const router = useRouter(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx index 54029d7f..3577e557 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx @@ -37,7 +37,7 @@ describe('DocToolBox - Licence', () => { const { DocToolBox } = await import('../components/DocToolBox'); - render(, { + render(, { wrapper: AppWrapper, }); const optionsButton = await screen.findByLabelText('Export the document'); @@ -55,7 +55,7 @@ describe('DocToolBox - Licence', () => { const { DocToolBox } = await import('../components/DocToolBox'); - render(, { + render(, { wrapper: AppWrapper, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/BoutonShare.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/BoutonShare.tsx index 7b1e421b..61c4d03c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/BoutonShare.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/BoutonShare.tsx @@ -10,6 +10,7 @@ import { Doc } from '@/docs/doc-management'; interface BoutonShareProps { displayNbAccess: boolean; doc: Doc; + hasKeyWarning?: boolean; isDisabled?: boolean; isHidden?: boolean; open: () => void; @@ -18,6 +19,7 @@ interface BoutonShareProps { export const BoutonShare = ({ displayNbAccess, doc, + hasKeyWarning, isDisabled, isHidden, open, @@ -41,9 +43,21 @@ export const BoutonShare = ({ return null; } + const warningIcon = hasKeyWarning ? ( + + ) : null; + if (hasAccesses) { return ( + {warningIcon} + + {warningIcon} + + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index 8e3b0cd3..e3fa778d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -20,9 +20,21 @@ import { DocToolBox } from './DocToolBox'; interface DocHeaderProps { doc: Doc; + encryptionSettings: { + userId: string; + userPrivateKey: CryptoKey; + userPublicKey: CryptoKey; + } | null; + documentEncryptionSettings?: { + documentSymmetricKey: CryptoKey; + } | null; } -export const DocHeader = ({ doc }: DocHeaderProps) => { +export const DocHeader = ({ + doc, + encryptionSettings, + documentEncryptionSettings, +}: DocHeaderProps) => { const { spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); const { t } = useTranslation(); @@ -65,7 +77,13 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { - {!isDeletedDoc && } + {!isDeletedDoc && ( + + )} {isDeletedDoc && ( { +export const DocToolBox = ({ + doc, + encryptionSettings, + documentEncryptionSettings, +}: DocToolBoxProps) => { const { t } = useTranslation(); const treeContext = useTreeContext(); const queryClient = useQueryClient(); @@ -57,9 +72,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalExportOpen, setIsModalExportOpen] = useState(false); + const [isModalEncryptOpen, setIsModalEncryptOpen] = useState(false); + const [isModalRemoveEncryptionOpen, setIsModalRemoveEncryptionOpen] = + useState(false); const selectHistoryModal = useModal(); const modalShare = useModal(); + const { hasMismatches: hasKeyWarnings } = usePublicKeyRegistry( + doc.accesses_public_keys_per_user, + ); + const { isSmallMobile, isMobile } = useResponsiveStore(); const copyDocLink = useCopyDocLink(doc.id); const { mutate: duplicateDoc } = useDuplicateDoc({ @@ -125,6 +147,26 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { show: !isMobile, showSeparator: isTopRoot ? true : false, }, + { + label: t('Encrypt document'), + icon: 'https', + disabled: !doc.abilities.accesses_manage, + callback: () => { + setIsModalEncryptOpen(true); + }, + show: !doc.is_encrypted && doc.abilities.update, + showSeparator: isTopRoot ? true : false, + }, + { + label: t('Remove document encryption'), + icon: 'no_encryption', + disabled: !doc.abilities.accesses_manage, + callback: () => { + setIsModalRemoveEncryptionOpen(true); + }, + show: doc.is_encrypted && doc.abilities.update, + showSeparator: isTopRoot ? true : false, + }, { label: t('Remove emoji'), icon: 'emoji_emotions', @@ -196,11 +238,19 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { $margin={{ left: 'auto' }} $gap={spacingsTokens['2xs']} > + {doc.is_encrypted && ( + <> + [chiffrement activé] + {/* TODO */} + + )} + {!isSmallMobile && ModalExport && ( @@ -217,6 +267,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { aria-label={t('Export the document')} /> )} + { modalShare.close()} doc={doc} + documentEncryptionSettings={documentEncryptionSettings} isRootDoc={treeContext?.root?.id === doc.id} /> )} @@ -265,6 +317,31 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }} /> )} + {isModalEncryptOpen && ( + 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 && ( + 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 && ( selectHistoryModal.close()} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts index 88c4b028..9038bdb3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts @@ -7,6 +7,8 @@ export * from './useDocOptions'; export * from './useDocs'; export * from './useDocsFavorite'; export * from './useDuplicateDoc'; +export * from './useEncryptDoc'; +export * from './useRemoveDocEncryption'; export * from './useRestoreDoc'; export * from './useSubDocs'; export * from './useUpdateDoc'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx index d6ad9aaf..2df9ce9a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx @@ -8,16 +8,19 @@ import { KEY_LIST_DOC } from './useDocs'; export type CreateChildDocParam = Pick & { parentId: string; + isEncrypted: boolean; }; export const createChildDoc = async ({ title, parentId, + isEncrypted = false, }: CreateChildDocParam): Promise => { const response = await fetchAPI(`documents/${parentId}/children/`, { method: 'POST', body: JSON.stringify({ title, + is_encrypted: isEncrypted, }), }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateDoc.tsx index 4587e21e..661aa20a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateDoc.tsx @@ -12,12 +12,16 @@ import { KEY_LIST_DOC } from './useDocs'; type CreateDocParams = { title?: string; + isEncrypted?: boolean; } | void; export const createDoc = async (params: CreateDocParams): Promise => { const response = await fetchAPI(`documents/`, { method: 'POST', - body: JSON.stringify({ title: params?.title }), + body: JSON.stringify({ + title: params?.title, + is_encrypted: params?.isEncrypted ?? false, + }), }); if (!response.ok) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index 90e1461e..48ea3245 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -17,6 +17,7 @@ export type DocsParams = { is_creator_me?: boolean; title?: string; is_favorite?: boolean; + is_encrypted?: boolean; }; export const constructParams = (params: DocsParams): URLSearchParams => { @@ -37,6 +38,9 @@ export const constructParams = (params: DocsParams): URLSearchParams => { if (params.is_favorite !== undefined) { searchParams.set('is_favorite', params.is_favorite.toString()); } + if (params.is_encrypted !== undefined) { + searchParams.set('is_encrypted', params.is_encrypted.toString()); + } return searchParams; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx index b8ca7f50..fa8ed663 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx @@ -72,15 +72,18 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) { const canSave = variables.canSave && provider && - // provider.document.guid === variables.docId; - provider.doc.guid === variables.docId; + provider.document.guid === variables.docId; if (canSave) { - await updateDoc({ - id: variables.docId, - // content: toBase64(Y.encodeStateAsUpdate(provider.document)), - content: toBase64(Y.encodeStateAsUpdate(provider.doc)), - }); + const state = Y.encodeStateAsUpdate(provider.document); + + if (state) { + await updateDoc({ + id: variables.docId, + content: toBase64(state), + contentEncrypted: false, + }); + } } return await duplicateDoc(variables); 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 new file mode 100644 index 00000000..067704ac --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useEncryptDoc.tsx @@ -0,0 +1,78 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { toBase64 } from '@/features/docs/doc-editor'; + +interface EncryptDocProps { + docId: string; + content: Uint8Array; + encryptedSymmetricKeyPerUser: Record; +} + +export const encryptDoc = async ({ + docId, + ...params +}: EncryptDocProps): Promise => { + const base64EncryptedSymmetricKeyPerUser: Record = {}; + + for (const [userId, encryptedSymmetricKey] of Object.entries( + params.encryptedSymmetricKeyPerUser, + )) { + base64EncryptedSymmetricKeyPerUser[userId] = toBase64( + new Uint8Array(encryptedSymmetricKey), + ); + } + + const response = await fetchAPI(`documents/${docId}/encrypt/`, { + method: 'PATCH', + body: JSON.stringify({ + ...params, + content: toBase64(params.content), + encryptedSymmetricKeyPerUser: base64EncryptedSymmetricKeyPerUser, + }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to encrypt the doc', + await errorCauses(response), + ); + } +}; + +type UseEncryptDocOptions = UseMutationOptions; + +export const useEncryptDoc = ({ + listInvalidQueries, + options, +}: { + listInvalidQueries?: string[]; + options?: UseEncryptDocOptions; +}) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: encryptDoc, + ...options, + onSuccess: (data, variables, onMutateResult, context) => { + listInvalidQueries?.forEach((queryKey) => { + void queryClient.invalidateQueries({ + queryKey: [queryKey], + }); + }); + + if (options?.onSuccess) { + void options.onSuccess(data, variables, onMutateResult, context); + } + }, + onError: (error, variables, onMutateResult, context) => { + if (options?.onError) { + void options.onError(error, variables, onMutateResult, context); + } + }, + }); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useRemoveDocEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useRemoveDocEncryption.tsx new file mode 100644 index 00000000..5d9a965c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useRemoveDocEncryption.tsx @@ -0,0 +1,70 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { toBase64 } from '@/features/docs/doc-editor'; + +interface RemoveDocEncryptionProps { + docId: string; + content: Uint8Array; +} + +export const removeDocEncryption = async ({ + docId, + ...params +}: RemoveDocEncryptionProps): Promise => { + const response = await fetchAPI(`documents/${docId}/remove-encryption/`, { + method: 'PATCH', + body: JSON.stringify({ + ...params, + content: toBase64(params.content), + }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to remove encryption from the doc', + await errorCauses(response), + ); + } +}; + +type UseRemoveDocEncryptionOptions = UseMutationOptions< + void, + APIError, + RemoveDocEncryptionProps +>; + +export const useRemoveDocEncryption = ({ + listInvalidQueries, + options, +}: { + listInvalidQueries?: string[]; + options?: UseRemoveDocEncryptionOptions; +}) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: removeDocEncryption, + ...options, + onSuccess: (data, variables, onMutateResult, context) => { + listInvalidQueries?.forEach((queryKey) => { + void queryClient.invalidateQueries({ + queryKey: [queryKey], + }); + }); + + if (options?.onSuccess) { + void options.onSuccess(data, variables, onMutateResult, context); + } + }, + onError: (error, variables, onMutateResult, context) => { + if (options?.onError) { + void options.onError(error, variables, onMutateResult, context); + } + }, + }); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx index e76c8bc4..ec1d3ac4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx @@ -18,6 +18,7 @@ export type SubDocsParams = { is_creator_me?: boolean; title?: string; is_favorite?: boolean; + is_encrypted?: boolean; parent_id: string; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx index aded223d..66bf8401 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx @@ -12,6 +12,7 @@ import { KEY_CAN_EDIT } from './useDocCanEdit'; export type UpdateDocParams = Pick & Partial> & { + contentEncrypted?: boolean; websocket?: boolean; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/assets/encrypted-document.svg b/src/frontend/apps/impress/src/features/docs/doc-management/assets/encrypted-document.svg new file mode 100644 index 00000000..027fb33b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/assets/encrypted-document.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..2f1e39af --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx @@ -0,0 +1,258 @@ +import { + Button, + Modal, + ModalSize, + VariantType, + useToastProvider, +} from '@gouvfr-lasuite/cunningham-react'; +import { useTranslation } from 'react-i18next'; +import * as Y from 'yjs'; + +import { Box, ButtonCloseModal, Text, TextErrors } from '@/components'; +import { useUserUpdate } from '@/core/api/useUserUpdate'; +import { + encryptContent, + generateSymmetricKey, + generateUserKeyPair, + getEncryptionDB, + prepareEncryptedSymmetricKeysForUsers, +} from '@/docs/doc-collaboration'; +import { toBase64 } from '@/docs/doc-editor'; +import { useAuth } from '@/features/auth'; +import { + Doc, + KEY_DOC, + KEY_LIST_DOC, + useEncryptDoc, + useProviderStore, +} from '@/features/docs/doc-management'; +import { useKeyboardAction } from '@/hooks'; + +interface ModalEncryptDocProps { + doc: Doc; + encryptionSettings: { + userId: string; + userPrivateKey: CryptoKey; + userPublicKey: CryptoKey; + } | null; + onClose: () => void; + onSuccess?: (doc: Doc) => void; +} + +export const ModalEncryptDoc = ({ + doc, + encryptionSettings, + onClose, + onSuccess, +}: ModalEncryptDocProps) => { + const { t } = useTranslation(); + const { toast } = useToastProvider(); + const { provider } = useProviderStore(); + const { user } = useAuth(); + const { mutateAsync: updateUser } = useUserUpdate(); + + const { + mutate: 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 = () => { + onClose(); + }; + + const handleEncrypt = async () => { + if (!provider || !user) { + return; + } + + let currentUserPublicKeyFromThisOnboardingSession: ArrayBuffer | null = + null; + + // 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 userKeyPair = await generateUserKeyPair(); + + 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(); + + 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); + const handleEncryptKeyDown = keyboardAction(handleEncrypt); + + return ( + + + + + } + size={ModalSize.MEDIUM} + title={ + + + {t('Encrypt document')} + + + + } + > + + {!isError && ( + +
+ TODO: warning about encryption +
+ TODO: accesses for users without public key will be lost (list them) +
+ TODO: if no public key for current user, provide an onboarding +
+ TODO: if document public, tell it needs first to be private (add + backend check too) +
+ )} + + {isError && } +
+
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx new file mode 100644 index 00000000..0ca44889 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDocEncryption.tsx @@ -0,0 +1,149 @@ +import { + Button, + Modal, + ModalSize, + VariantType, + useToastProvider, +} from '@gouvfr-lasuite/cunningham-react'; +import { useTranslation } from 'react-i18next'; +import * as Y from 'yjs'; + +import { Box, ButtonCloseModal, Text, TextErrors } from '@/components'; +import { + Doc, + KEY_DOC, + KEY_LIST_DOC, + useRemoveDocEncryption, + useProviderStore, +} from '@/features/docs/doc-management'; +import { useKeyboardAction } from '@/hooks'; + +interface ModalRemoveDocEncryptionProps { + doc: Doc; + onClose: () => void; + onSuccess?: (doc: Doc) => void; +} + +export const ModalRemoveDocEncryption = ({ + doc, + onClose, + onSuccess, +}: ModalRemoveDocEncryptionProps) => { + const { t } = useTranslation(); + const { toast } = useToastProvider(); + const { provider } = useProviderStore(); + + const { + mutate: removeDocEncryption, + isError, + error, + } = useRemoveDocEncryption({ + listInvalidQueries: [KEY_DOC, KEY_LIST_DOC], + options: { + onSuccess: () => { + onSuccess && onSuccess(doc); + onClose(); + + toast( + t('The document encryption has been removed.'), + VariantType.SUCCESS, + { + duration: 4000, + }, + ); + }, + }, + }); + + const keyboardAction = useKeyboardAction(); + + const handleClose = () => { + onClose(); + }; + + const handleRemoveEncryption = () => { + if (!provider) { + return; + } + + const state = Y.encodeStateAsUpdate(provider.document); + + removeDocEncryption({ + docId: doc.id, + content: state, + }); + }; + + const handleCloseKeyDown = keyboardAction(handleClose); + const handleRemoveEncryptionKeyDown = keyboardAction(handleRemoveEncryption); + + return ( + + + + + } + size={ModalSize.MEDIUM} + title={ + + + {t('Remove document encryption')} + + + + } + > + + {!isError && ( + +
+ TODO: warning about removing encryption +
+ )} + + {isError && } +
+
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx index 1f8f6b71..e2fda2f7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx @@ -7,6 +7,7 @@ import { useDate } from '@/hooks/useDate'; import { useResponsiveStore } from '@/stores'; import ChildDocument from '../assets/child-document.svg'; +import EncryptedDocumentIcon from '../assets/encrypted-document.svg'; import PinnedDocumentIcon from '../assets/pinned-document.svg'; import SimpleFileIcon from '../assets/simple-document.svg'; import { useDocUtils, useTrans } from '../hooks'; @@ -25,12 +26,14 @@ const ItemTextCss = css` type SimpleDocItemProps = { doc: Doc; isPinned?: boolean; + isEncrypted?: boolean; showAccesses?: boolean; }; export const SimpleDocItem = ({ doc, isPinned = false, + isEncrypted = false, showAccesses = false, }: SimpleDocItemProps) => { const { t } = useTranslation(); @@ -67,6 +70,12 @@ export const SimpleDocItem = ({ data-testid="doc-pinned-icon" color="var(--c--contextuals--content--semantic--info--tertiary)" /> + ) : isEncrypted ? ( +