diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index ef8a8d7c3..3a8a37d5b 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", "encryption_public_key", "language"] + fields = ["id", "email", "full_name", "short_name", "language"] read_only_fields = ["id", "email", "full_name", "short_name"] def get_full_name(self, instance): @@ -71,7 +71,8 @@ class ListDocumentSerializer(serializers.ModelSerializer): 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) + accesses_user_ids = serializers.SerializerMethodField(read_only=True) + accesses_fingerprints_per_user = serializers.SerializerMethodField(read_only=True) encrypted_document_symmetric_key_for_user = serializers.SerializerMethodField( read_only=True ) @@ -81,7 +82,8 @@ class ListDocumentSerializer(serializers.ModelSerializer): fields = [ "id", "abilities", - "accesses_public_keys_per_user", + "accesses_fingerprints_per_user", + "accesses_user_ids", "ancestors_link_reach", "ancestors_link_role", "computed_link_reach", @@ -107,7 +109,7 @@ class ListDocumentSerializer(serializers.ModelSerializer): read_only_fields = [ "id", "abilities", - "accesses_public_keys_per_user", + "accesses_user_ids", "ancestors_link_reach", "ancestors_link_role", "computed_link_reach", @@ -162,12 +164,22 @@ 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.""" + def get_accesses_user_ids(self, instance): + """Return user IDs of members with access to this document. + The frontend uses these to fetch public keys from the encryption service.""" request = self.context.get("request") if not request or not request.user.is_authenticated: return None - return instance.accesses_public_keys_per_user + return [str(uid) for uid in instance.accesses_user_ids] + + def get_accesses_fingerprints_per_user(self, instance): + """Return fingerprints of users' public keys at share time.""" + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return None + if not instance.is_encrypted: + return None + return instance.accesses_fingerprints_per_user def get_encrypted_document_symmetric_key_for_user(self, instance): """Return the encrypted symmetric key for the current user.""" @@ -209,7 +221,8 @@ class DocumentSerializer(ListDocumentSerializer): fields = [ "id", "abilities", - "accesses_public_keys_per_user", + "accesses_fingerprints_per_user", + "accesses_user_ids", "ancestors_link_reach", "ancestors_link_role", "computed_link_reach", @@ -270,7 +283,7 @@ class DocumentSerializer(ListDocumentSerializer): # 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("accesses_user_ids", None) fields.pop("encrypted_document_symmetric_key_for_user", None) return fields @@ -399,6 +412,9 @@ class DocumentAccessSerializer(serializers.ModelSerializer): encrypted_document_symmetric_key_for_user = serializers.CharField( required=False, allow_blank=True, write_only=True ) + encryption_public_key_fingerprint = serializers.CharField( + required=False, allow_blank=True, max_length=16 + ) class Meta: model = models.DocumentAccess @@ -414,6 +430,7 @@ class DocumentAccessSerializer(serializers.ModelSerializer): "max_ancestors_role", "max_role", "encrypted_document_symmetric_key_for_user", + "encryption_public_key_fingerprint", ] read_only_fields = [ "id", diff --git a/src/backend/core/migrations/0030_baseaccess_encryption_public_key_fingerprint.py b/src/backend/core/migrations/0030_baseaccess_encryption_public_key_fingerprint.py new file mode 100644 index 000000000..74212f0b0 --- /dev/null +++ b/src/backend/core/migrations/0030_baseaccess_encryption_public_key_fingerprint.py @@ -0,0 +1,34 @@ +"""Add encryption_public_key_fingerprint to BaseAccess (DocumentAccess). + +Stores the fingerprint of the user's public key at the time of sharing, +allowing the frontend to detect key changes without relying solely on +client-side TOFU. If the user's current key fingerprint differs from +this stored value, the document access needs re-encryption. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0029_document_is_encrypted_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="documentaccess", + name="encryption_public_key_fingerprint", + field=models.CharField( + blank=True, + help_text=( + "Fingerprint of the user's public key at the time of sharing. " + "Used to detect key changes — if the user's current public key " + "fingerprint differs from this value, the access needs re-encryption." + ), + max_length=16, + null=True, + verbose_name="encryption public key fingerprint", + ), + ), + ] diff --git a/src/backend/core/migrations/0031_remove_user_encryption_public_key.py b/src/backend/core/migrations/0031_remove_user_encryption_public_key.py new file mode 100644 index 000000000..679c795e3 --- /dev/null +++ b/src/backend/core/migrations/0031_remove_user_encryption_public_key.py @@ -0,0 +1,25 @@ +"""Remove encryption_public_key from User model. + +Public keys are now managed by the centralized encryption service. +Products should fetch public keys from the encryption service's API +when needed (e.g. for encrypting a document for multiple users). + +The fingerprint of the public key at share time is stored on +DocumentAccess.encryption_public_key_fingerprint (added in 0030). +""" + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0030_baseaccess_encryption_public_key_fingerprint"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="encryption_public_key", + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index ca05438f7..f930e03e6 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -186,12 +186,6 @@ 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, @@ -291,6 +285,17 @@ class BaseAccess(BaseModel): blank=True, help_text=_("Encrypted symmetric key for this document, specific to this user."), ) + encryption_public_key_fingerprint = models.CharField( + _("encryption public key fingerprint"), + max_length=16, + null=True, + blank=True, + help_text=_( + "Fingerprint of the user's public key at the time of sharing. " + "Used to detect key changes — if the user's current public key " + "fingerprint differs from this value, the access needs re-encryption." + ), + ) class Meta: abstract = True @@ -732,26 +737,36 @@ class Document(MP_Node, BaseModel): return self.computed_link_definition["link_role"] @property - def accesses_public_keys_per_user(self): + def accesses_user_ids(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. + Return the list of user IDs with access to this document. + The frontend uses these IDs to fetch public keys from the + centralized encryption service. """ - # Get all users with direct access to this document - users_with_access = ( + return list( DocumentAccess.objects .filter(document=self, user__isnull=False) - .select_related('user') - .values_list('user_id', 'user__encryption_public_key') + .values_list('user_id', flat=True) + .distinct() + ) + + @property + def accesses_fingerprints_per_user(self): + """ + Return the fingerprint of each user's public key at the time of sharing. + This allows the frontend to detect key changes by comparing the + fingerprint stored at share time with the current public key fingerprint. + """ + accesses = ( + DocumentAccess.objects + .filter(document=self, user__isnull=False, encryption_public_key_fingerprint__isnull=False) + .values_list('user_id', 'encryption_public_key_fingerprint') ) - # 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 + str(user_id): fingerprint + for user_id, fingerprint in accesses + if fingerprint } def get_abilities(self, user): diff --git a/src/frontend/apps/impress/.env.development b/src/frontend/apps/impress/.env.development index 248c72654..03029f9a1 100644 --- a/src/frontend/apps/impress/.env.development +++ b/src/frontend/apps/impress/.env.development @@ -1,3 +1,5 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:8071 NEXT_PUBLIC_PUBLISH_AS_MIT=false NEXT_PUBLIC_SW_DEACTIVATED=true +NEXT_PUBLIC_VAULT_URL=http://localhost:7201 +NEXT_PUBLIC_INTERFACE_URL=http://localhost:7202 diff --git a/src/frontend/apps/impress/src/core/AppProvider.tsx b/src/frontend/apps/impress/src/core/AppProvider.tsx index 23f9c72ac..80774fecb 100644 --- a/src/frontend/apps/impress/src/core/AppProvider.tsx +++ b/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -10,6 +10,7 @@ import { useEffect } from 'react'; import { useCunninghamTheme } from '@/cunningham'; import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth'; import { UserEncryptionProvider } from '@/features/docs/doc-collaboration'; +import { VaultClientProvider } from '@/features/docs/doc-collaboration/vault'; import { useResponsiveStore } from '@/stores/'; import { ConfigProvider } from './config/'; @@ -76,7 +77,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) { - {children} + + {children} + 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 53dbbcee1..933015766 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -3,8 +3,7 @@ * @interface User * @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} full_name - The full name of the user. * @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'. */ export interface User { @@ -12,7 +11,6 @@ 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/auth/components/AccountMenu.tsx b/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx index cdfb70017..74fbcd06f 100644 --- a/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/AccountMenu.tsx @@ -1,12 +1,9 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { Box, DropdownMenu, DropdownMenuOption, Icon } from '@/components'; -import { - exportPublicKeyAsBase64, - useUserEncryption, -} from '@/docs/doc-collaboration'; +import { useVaultClient } from '@/features/docs/doc-collaboration/vault'; import { useAuth } from '../hooks'; import { gotoLogout } from '../utils'; @@ -17,40 +14,19 @@ import { ModalEncryptionSettings } from './ModalEncryptionSettings'; export const AccountMenu = () => { const { t } = useTranslation(); const { user } = useAuth(); - const { encryptionSettings } = useUserEncryption(); + const { hasKeys } = useVaultClient(); const [isOnboardingOpen, setIsOnboardingOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [localPublicKeyBase64, setLocalPublicKeyBase64] = useState< - string | null - >(null); - useEffect(() => { - if (encryptionSettings?.userPublicKey) { - exportPublicKeyAsBase64(encryptionSettings.userPublicKey).then( - setLocalPublicKeyBase64, - ); - } else { - setLocalPublicKeyBase64(null); - } - }, [encryptionSettings]); - - const hasEncryptionSetup = !!user?.encryption_public_key; - - const hasMismatch = - localPublicKeyBase64 !== null && - user?.encryption_public_key !== null && - localPublicKeyBase64 !== user?.encryption_public_key; + // hasKeys comes from the vault — true if the user has encryption keys on this device + const hasEncryptionSetup = hasKeys === true; const encryptionOption: DropdownMenuOption = useMemo(() => { if (hasEncryptionSetup) { return { label: t('Encryption settings'), - icon: hasMismatch ? ( - - ) : ( - 'lock' - ), + icon: 'lock', callback: () => setIsSettingsOpen(true), showSeparator: true, }; @@ -62,7 +38,7 @@ export const AccountMenu = () => { callback: () => setIsOnboardingOpen(true), showSeparator: true, }; - }, [hasEncryptionSetup, hasMismatch, t]); + }, [hasEncryptionSetup, t]); const options: DropdownMenuOption[] = useMemo( () => [ @@ -70,7 +46,7 @@ export const AccountMenu = () => { { label: t('Logout'), icon: 'logout', - callback: gotoLogout, + callback: () => gotoLogout(), }, ], [encryptionOption, t], @@ -82,7 +58,6 @@ export const AccountMenu = () => { options={options} showArrow label={t('My account')} - testId="header-account-menu" buttonCss={css` transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out) !important; @@ -92,6 +67,11 @@ export const AccountMenu = () => { gap: 0.2rem; display: flex; } + & .material-icons { + color: var( + --c--contextuals--content--palette--brand--primary + ) !important; + } `} > { $gap="0.5rem" $align="center" > - {hasMismatch && ( - - )} + {t('My account')} - - setIsOnboardingOpen(false)} - /> - - setIsSettingsOpen(false)} - onRequestReOnboard={() => { - setIsSettingsOpen(false); - setIsOnboardingOpen(true); - }} - /> + {user && isOnboardingOpen && ( + setIsOnboardingOpen(false)} + onSuccess={() => setIsOnboardingOpen(false)} + /> + )} + {user && isSettingsOpen && ( + setIsSettingsOpen(false)} + onRequestReOnboard={() => { + setIsSettingsOpen(false); + setIsOnboardingOpen(true); + }} + /> + )} ); }; diff --git a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx index c2612c524..5053bc76a 100644 --- a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionOnboarding.tsx @@ -1,38 +1,17 @@ -import { - Alert, - Button, - Modal, - ModalSize, - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { Badge, Spinner } from '@gouvfr-lasuite/ui-kit'; -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; +/** + * Encryption onboarding modal — delegates to the centralized encryption service. + * + * Opens the encryption service's interface iframe which handles everything: + * key generation, backup, restore, device transfer, and server registration. + * The product (Docs) doesn't manage public keys — it only stores fingerprints + * on document accesses for UI purposes. + */ +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; -import { Box, ButtonCloseModal, Icon, Text } from '@/components'; -import { useUserUpdate } from '@/core/api/useUserUpdate'; -import { - derivePublicJwkFromPrivate, - exportPrivateKeyAsJwk, - exportPublicKeyAsBase64, - generateUserKeyPair, - getEncryptionDB, - importPrivateKeyFromJwk, - importPublicKeyFromJwk, - jwkToPassphrase, - passphraseToJwk, - useUserEncryption, -} from '@/docs/doc-collaboration'; - -import { useAuth } from '../hooks'; - -type OnboardingStep = - | 'explanation' - | 'existing-key-choice' - | 'generating' - | 'restore' - | 'backup'; +import { Box } from '@/components'; +import { useUserEncryption } from '@/docs/doc-collaboration'; +import { useVaultClient } from '@/features/docs/doc-collaboration/vault'; interface ModalEncryptionOnboardingProps { isOpen: boolean; @@ -45,538 +24,71 @@ export const ModalEncryptionOnboarding = ({ onClose, onSuccess, }: ModalEncryptionOnboardingProps) => { - const { t } = useTranslation(); - const { toast } = useToastProvider(); - const { user } = useAuth(); + const { client: vaultClient, refreshKeyState } = useVaultClient(); const { refreshEncryption } = useUserEncryption(); - const { mutateAsync: updateUser } = useUserUpdate(); + const onboardingOpenedRef = useRef(false); + const [containerEl, setContainerEl] = useState(null); - const hasExistingBackendKey = !!user?.encryption_public_key; - - const [step, setStep] = useState('explanation'); - const [isPending, setIsPending] = useState(false); - const [backupPassphrase, setBackupPassphrase] = useState(null); - const [restoreInput, setRestoreInput] = useState(''); - const [restoreError, setRestoreError] = useState(null); - - const prevIsOpenRef = useRef(isOpen); useEffect(() => { - if (isOpen && !prevIsOpenRef.current) { - setStep(hasExistingBackendKey ? 'existing-key-choice' : 'explanation'); - setBackupPassphrase(null); - setShowPassphrase(false); - setRestoreInput(''); - setRestoreError(null); - } - prevIsOpenRef.current = isOpen; - }, [isOpen, hasExistingBackendKey]); - - const handleClose = () => { - if (isPending) { + if (!isOpen || !vaultClient || !containerEl || onboardingOpenedRef.current) { return; } - onClose(); - }; + onboardingOpenedRef.current = true; + vaultClient.openOnboarding(containerEl); + }, [isOpen, vaultClient, containerEl]); - const generateAndStoreKeys = async () => { - if (!user) { - return; - } - - setIsPending(true); - - try { - 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 publicKeyBase64 = await exportPublicKeyAsBase64( - userKeyPair.publicKey, - ); - - await updateUser({ - id: user.id, - encryption_public_key: publicKeyBase64, - }); - - // Generate backup passphrase - const privateJwk = await exportPrivateKeyAsJwk(userKeyPair.privateKey); - - setBackupPassphrase(jwkToPassphrase(privateJwk)); + useEffect(() => { + if (!vaultClient) return; + const handleComplete = async () => { + // The encryption service registered the public key on its central server. + // Docs doesn't need to store it — just refresh the vault key state. + await refreshKeyState(); refreshEncryption(); - - setStep('backup'); - } catch (error) { - console.error('Key generation failed:', error); - - toast( - t('Failed to generate encryption keys. Please try again.'), - VariantType.ERROR, - ); - } finally { - setIsPending(false); - } - }; - - const handleRestoreKeys = async () => { - if (!user || !restoreInput.trim()) { - return; - } - - setIsPending(true); - setRestoreError(null); - - try { - const privateJwk = passphraseToJwk(restoreInput.trim()); - const privateKey = await importPrivateKeyFromJwk(privateJwk); - - const publicJwk = derivePublicJwkFromPrivate(privateJwk); - const publicKey = await importPublicKeyFromJwk(publicJwk); - - // Verify restored public key matches the backend - const restoredPublicKeyBase64 = await exportPublicKeyAsBase64(publicKey); - - if ( - user.encryption_public_key && - restoredPublicKeyBase64 !== user.encryption_public_key - ) { - setRestoreError( - t( - 'The restored key does not match the one registered on your account. If you want to restore an older key, you must first remove encryption from your account settings (including the server key), then re-enable encryption using this backup.', - ), - ); - setIsPending(false); - - return; - } - - const encryptionDatabase = await getEncryptionDB(); - await encryptionDatabase.put('privateKey', privateKey, `user:${user.id}`); - await encryptionDatabase.put('publicKey', publicKey, `user:${user.id}`); - - refreshEncryption(); - - toast(t('Encryption keys restored successfully.'), VariantType.SUCCESS, { - duration: 4000, - }); - - handleClose(); onSuccess?.(); - } catch (error) { - console.error('Key restoration failed:', error); + }; - setRestoreError( - t('Invalid backup data. Please check your passphrase and try again.'), - ); - } finally { - setIsPending(false); + const handleClosed = () => { + onboardingOpenedRef.current = false; + onClose(); + }; + + vaultClient.on('onboarding:complete', handleComplete); + vaultClient.on('interface:closed', handleClosed); + + return () => { + vaultClient.off('onboarding:complete', handleComplete); + vaultClient.off('interface:closed', handleClosed); + }; + }, [vaultClient, refreshKeyState, refreshEncryption, onSuccess, onClose]); + + const handleClose = useCallback(() => { + vaultClient?.closeInterface(); + onboardingOpenedRef.current = false; + onClose(); + }, [vaultClient, onClose]); + + useEffect(() => { + if (!isOpen) { + onboardingOpenedRef.current = false; } - }; - - const handleBackupThirdParty = () => { - alert(t('Third-party backup is not implemented yet.')); - }; - - const handleCopyPassphrase = async () => { - if (!backupPassphrase) { - return; - } - - try { - await navigator.clipboard.writeText(backupPassphrase); - toast(t('Passphrase copied to clipboard.'), VariantType.SUCCESS, { - duration: 2000, - }); - } catch { - toast(t('Failed to copy to clipboard.'), VariantType.ERROR); - } - }; - - const handleBackupDone = () => { - toast(t('Encryption has been enabled.'), VariantType.SUCCESS, { - duration: 4000, - }); - handleClose(); - onSuccess?.(); - }; - - const renderExplanation = () => ( - - - - - {t( - 'Encryption keys will be stored locally on this device. If these keys are lost (browser data cleared, device lost), you will permanently lose the ability to decrypt your documents.', - )} - - - {t( - 'After enabling encryption, you will be prompted to back up your keys. Please do so carefully using a password manager with two-factor authentication (2FA), or by printing your backup.', - )} - - - - - ); - - const renderExistingKeyChoice = () => ( - - - - - {t('Previous encryption setup detected')} - - - {t( - 'Your account already has an encryption key registered. This could be from a previous setup on this device (with storage cleared) or from another device.', - )} - - - - - - - - - {t('Restore from backup')} - - {t('Recommended')} - - - {t( - 'If you have a backup of your keys, you can restore them on this device.', - )} - - - - - - - - {t('or')} - - - - - - - {t('Start fresh')} - - - {t( - 'Creating new keys will invalidate your old ones. Documents where you are the sole member will become permanently undecryptable. Documents shared with others will require them to unshare and reshare after you have your new key.', - )} - - - - - - ); - - const renderRestore = () => ( - - - {t( - 'Paste your backup passphrase below to restore your encryption keys on this device.', - )} - - - ) => - setRestoreInput(e.target.value) - } - placeholder={t('Paste your backup passphrase here...')} - /> - - {restoreError && {restoreError}} - - ); - - const [showPassphrase, setShowPassphrase] = useState(false); - - const renderBackup = () => ( - - - - - {t('Keys generated successfully!')} - - - {t( - 'Please back up your private key using one of the methods below. Without this backup, you will lose access to your encrypted documents if your browser data is cleared.', - )} - - - - - - - - - {t('Save passphrase')} - - {t('Recommended')} - - - {t( - 'Copy this passphrase and store it in a password manager with 2FA enabled, or print it and keep it in a safe place.', - )} - - {showPassphrase ? ( - - - - - ) : ( - - )} - - - - - - - {t('Third-party backup')} - - - - {t( - 'Send your encrypted key to a trusted third-party server for recovery.', - )} - - - - - ); - - const getStepContent = () => { - switch (step) { - case 'explanation': - return renderExplanation(); - case 'existing-key-choice': - return renderExistingKeyChoice(); - case 'generating': - return ( - - - {t('Generating encryption keys...')} - - ); - case 'restore': - return renderRestore(); - case 'backup': - return renderBackup(); - } - }; - - const getRightActions = () => { - switch (step) { - case 'explanation': - return ( - <> - - - - ); - case 'existing-key-choice': - return ( - - ); - case 'restore': - return ( - <> - - - - ); - case 'backup': - return ( - - ); - default: - return null; - } - }; - - const getTitle = () => { - switch (step) { - case 'backup': - return t('Back up your encryption keys'); - case 'restore': - return t('Restore encryption keys'); - default: - return t('Enable encryption'); - } - }; + }, [isOpen]); return ( - - {getTitle()} - - {step !== 'backup' && ( - - )} - - } + size={ModalSize.LARGE} + hideCloseButton > - {getStepContent()} + +
+ ); }; diff --git a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx index 944790814..660230c7b 100644 --- a/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/ModalEncryptionSettings.tsx @@ -1,29 +1,15 @@ -import { - Alert, - Button, - Checkbox, - Input, - Modal, - ModalSize, - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { useEffect, useRef, useState } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; +/** + * Encryption settings modal — delegates to the centralized encryption service. + * + * Opens the encryption service's settings interface iframe which handles: + * fingerprint display, key deletion, device transfer export, and server key management. + */ +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; +import { useCallback, useEffect, useState } from 'react'; -import { Box, ButtonCloseModal, Icon, Text } from '@/components'; -import { useUserUpdate } from '@/core/api/useUserUpdate'; -import { - exportPublicKeyAsBase64, - getEncryptionDB, - useKeyFingerprint, - useUserEncryption, -} from '@/docs/doc-collaboration'; -import { Badge, Spinner } from '@gouvfr-lasuite/ui-kit'; - -import { useAuth } from '../hooks'; - -type SettingsView = 'main' | 'confirm-remove'; +import { Box } from '@/components'; +import { useUserEncryption } from '@/docs/doc-collaboration'; +import { useVaultClient } from '@/features/docs/doc-collaboration/vault'; interface ModalEncryptionSettingsProps { isOpen: boolean; @@ -36,331 +22,70 @@ export const ModalEncryptionSettings = ({ onClose, onRequestReOnboard, }: ModalEncryptionSettingsProps) => { - const { t } = useTranslation(); - const { toast } = useToastProvider(); - const { user } = useAuth(); - const { encryptionSettings, refreshEncryption } = useUserEncryption(); - const { mutateAsync: updateUser } = useUserUpdate(); + const { client: vaultClient, refreshKeyState } = useVaultClient(); + const { refreshEncryption } = useUserEncryption(); + const [containerEl, setContainerEl] = useState(null); + const [settingsOpened, setSettingsOpened] = useState(false); - const backendFingerprint = useKeyFingerprint(user?.encryption_public_key); - const [localPublicKeyBase64, setLocalPublicKeyBase64] = useState< - string | null - >(null); - const localFingerprint = useKeyFingerprint(localPublicKeyBase64); - - const [view, setView] = useState('main'); - const [confirmInput, setConfirmInput] = useState(''); - const [isPending, setIsPending] = useState(false); - const [alsoRemoveFromServer, setAlsoRemoveFromServer] = useState(false); - - const prevIsOpenRef = useRef(isOpen); + // Open the vault's settings interface when container is mounted useEffect(() => { - if (isOpen && !prevIsOpenRef.current) { - setView('main'); - setConfirmInput(''); - setAlsoRemoveFromServer(false); - } - prevIsOpenRef.current = isOpen; - }, [isOpen]); - - useEffect(() => { - if (encryptionSettings?.userPublicKey) { - exportPublicKeyAsBase64(encryptionSettings.userPublicKey).then( - setLocalPublicKeyBase64, - ); - } else { - setLocalPublicKeyBase64(null); - } - }, [encryptionSettings]); - - const hasMismatch = - localPublicKeyBase64 !== null && - user?.encryption_public_key !== null && - localPublicKeyBase64 !== user?.encryption_public_key; - - const handleClose = () => { - if (isPending) { + if (!isOpen || !vaultClient || !containerEl || settingsOpened) { return; } + + setSettingsOpened(true); + vaultClient.openSettings(containerEl); + }, [isOpen, vaultClient, containerEl, settingsOpened]); + + // Listen for interface close and key changes + useEffect(() => { + if (!vaultClient) return; + + const handleClosed = () => { + setSettingsOpened(false); + refreshKeyState().then(() => refreshEncryption()); + onClose(); + }; + + const handleKeysDestroyed = () => { + refreshKeyState().then(() => refreshEncryption()); + }; + + vaultClient.on('interface:closed', handleClosed); + vaultClient.on('keys-destroyed', handleKeysDestroyed); + + return () => { + vaultClient.off('interface:closed', handleClosed); + vaultClient.off('keys-destroyed', handleKeysDestroyed); + }; + }, [vaultClient, refreshKeyState, refreshEncryption, onClose]); + + const handleClose = useCallback(() => { + vaultClient?.closeInterface(); + setSettingsOpened(false); onClose(); - }; + }, [vaultClient, onClose]); - const normalizedConfirmInput = confirmInput.trim().toUpperCase(); - const normalizedBackendFingerprint = backendFingerprint?.toUpperCase() ?? ''; - const fingerprintMatches = - !!normalizedBackendFingerprint && - normalizedConfirmInput === normalizedBackendFingerprint; - - const canConfirmRemoval = fingerprintMatches; - - const handleRemoveEncryption = async () => { - if (!user || !canConfirmRemoval) { - return; + useEffect(() => { + if (!isOpen) { + setSettingsOpened(false); } - - setIsPending(true); - - try { - if (alsoRemoveFromServer) { - await updateUser({ - id: user.id, - encryption_public_key: null, - }); - } - - const encryptionDatabase = await getEncryptionDB(); - await encryptionDatabase.delete('privateKey', `user:${user.id}`); - await encryptionDatabase.delete('publicKey', `user:${user.id}`); - - refreshEncryption(); - - toast( - alsoRemoveFromServer - ? t('Encryption has been fully removed from your account.') - : t('Local encryption keys have been removed from this device.'), - VariantType.SUCCESS, - { - duration: 4000, - }, - ); - - handleClose(); - } catch (error) { - console.error('Failed to remove encryption:', error); - toast( - t('Failed to remove encryption. Please try again.'), - VariantType.ERROR, - ); - } finally { - setIsPending(false); - } - }; - - const handleReOnboard = () => { - handleClose(); - onRequestReOnboard(); - }; - - const renderMain = () => ( - - {hasMismatch && ( - - - - {t('Key mismatch detected')} - - - {t( - 'The encryption key on this device does not match the one registered on your account. This may happen if you set up encryption on another device or if your local data was modified.', - )} - - - {t( - 'It will lead to unexpected behavior since when other people is sharing a document with you they will use the public key stored on the server, and so according to your current local public key your device will not be able to decrypt the document.', - )} - - - - - )} - - - {t('Your public key fingerprint on the server')} - - {backendFingerprint || '...'} - - - - {localFingerprint && ( - - - {t('Your public key fingerprint on this current device')} - - - {localFingerprint} - - - )} - - {!encryptionSettings && user?.encryption_public_key && ( - - - - {t('No local keys on this device')} - - - {t( - 'Your account has a public key registered on the server, but no encryption keys were found on this device. You will not be able to decrypt documents until you restore your keys from a backup.', - )} - - - - - )} - - ); - - const renderConfirmRemove = () => ( - - - - {t( - 'This will delete your local encryption keys from this device. You will no longer be able to decrypt documents from this browser unless you restore your keys from a backup.', - )} - - - - - { - setAlsoRemoveFromServer((prev) => !prev); - setConfirmInput(''); - }} - /> - - {t( - 'If enabled, other users will no longer find this current public key to share new documents with you.', - )} - - - - - - - To confirm, type your public key fingerprint:{' '} - {backendFingerprint} - - - setConfirmInput(e.target.value)} - state={confirmInput && !fingerprintMatches ? 'error' : 'default'} - text={ - confirmInput && !fingerprintMatches - ? t('Fingerprint does not match') - : undefined - } - /> - - - ); - - const getRightActions = () => { - if (view === 'main') { - return ( - <> - - - - ); - } - - return ( - <> - -
- ) : undefined - } - > - {t('Confirm removal')} - - - ); - }; + }, [isOpen]); return ( - - {view === 'main' - ? t('Encryption settings') - : t('Remove encryption')} - - -
- } + size={ModalSize.LARGE} + hideCloseButton > - {view === 'main' ? renderMain() : renderConfirmRemove()} + +
+ ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx index f2b83c00b..fe1928022 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx @@ -1,15 +1,36 @@ +/** + * User encryption context provider. + * + * MIGRATION NOTE: This provider now bridges between the VaultClient SDK + * and the existing encryption context interface used by downstream components. + * The vault handles all key storage and crypto operations — we no longer + * expose raw CryptoKey objects. Instead, `encryptionSettings` signals that + * encryption is available, and components use the VaultClient directly for + * encrypt/decrypt operations. + */ import { createContext, useCallback, useContext, useState } from 'react'; import { useAuth } from '@/features/auth'; -import { EncryptionError, useEncryption } from './hook/useEncryption'; +import { useVaultClient } from './vault'; + +export type EncryptionError = + | 'missing_private_key' + | 'missing_public_key' + | null; interface UserEncryptionContextValue { encryptionLoading: boolean; + /** + * Non-null when the user has encryption keys available. + * NOTE: userPrivateKey and userPublicKey are no longer raw CryptoKey objects. + * They are kept as null placeholders for type compatibility. Use the VaultClient + * directly for all crypto operations. + */ encryptionSettings: { userId: string; - userPrivateKey: CryptoKey; - userPublicKey: CryptoKey; + userPrivateKey: null; + userPublicKey: null; } | null; encryptionError: EncryptionError; refreshEncryption: () => void; @@ -28,16 +49,42 @@ export const UserEncryptionProvider = ({ children: React.ReactNode; }) => { const { user } = useAuth(); - const [refreshTrigger, setRefreshTrigger] = useState(0); - const encryptionValue = useEncryption(user?.id, refreshTrigger); + const { isReady, isLoading, hasKeys, error, refreshKeyState } = + useVaultClient(); + const [, setRefreshTrigger] = useState(0); const refreshEncryption = useCallback(() => { setRefreshTrigger((prev) => prev + 1); - }, []); + void refreshKeyState(); + }, [refreshKeyState]); + + // Derive the legacy context value from the VaultClient state + let encryptionSettings: UserEncryptionContextValue['encryptionSettings'] = + null; + let encryptionError: EncryptionError = null; + + if (isReady && user?.id) { + if (hasKeys) { + encryptionSettings = { + userId: user.id, + userPrivateKey: null, // Keys are in the vault — use VaultClient for crypto + userPublicKey: null, + }; + } else { + encryptionError = 'missing_private_key'; + } + } else if (!isLoading && error) { + encryptionError = 'missing_private_key'; + } return ( {children} 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 index 3790de0d7..bcbc752bc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/encryptedWebsocket.ts @@ -1,13 +1,16 @@ -import type { MessageEvent } from 'ws'; +/** + * Encrypted WebSocket wrapper for real-time Yjs collaboration. + * + * Uses the VaultClient SDK for all encrypt/decrypt operations via postMessage + * to the vault iframe. All data transfers use ArrayBuffer for zero-copy + * performance. The vault caches the decrypted symmetric key per session + * so only the first message incurs the hybrid decapsulation cost. + */ -import { - decryptContent, - encryptContent, -} from '@/docs/doc-collaboration/encryption'; export class EncryptedWebSocket extends WebSocket { - protected readonly encryptionKey!: CryptoKey; - protected readonly decryptionKey!: CryptoKey; + protected readonly vaultClient!: VaultClient; + protected readonly encryptedSymmetricKey!: ArrayBuffer; protected readonly onSystemMessage?: (message: string) => void; constructor(address: string | URL, protocols?: string | string[]) { @@ -25,39 +28,39 @@ export class EncryptedWebSocket extends WebSocket { // eslint-disable-next-line @typescript-eslint/no-explicit-any const messageEvent = event as any; - // some messages are here to help adjusting the interface or even reloading it - // in case it there is an ongoing decryption, or symmetric key rotation... - // those messages must be parsable so they are not encrypted + // System messages (strings) bypass encryption if (typeof messageEvent.data === 'string') { this.onSystemMessage?.(messageEvent.data as string); return; } - // 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"`, + 'WebSocket data should always be ArrayBuffer (binaryType)', ); } - const manageableData = new Uint8Array( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - messageEvent.data as ArrayBuffer, - ); + try { + // Decrypt directly with ArrayBuffer — no base64 conversion + const { data: decryptedBuffer } = + await this.vaultClient.decryptWithKey( + messageEvent.data as ArrayBuffer, + this.encryptedSymmetricKey, + ); - const decryptedData = await decryptContent( - manageableData, - this.decryptionKey, - ); + const decryptedData = new Uint8Array(decryptedBuffer); - if (typeof listener === 'function') { - listener.call(this, { ...event, data: decryptedData }); - } else { - listener.handleEvent.call(this, { - ...event, - data: decryptedData, - }); + if (typeof listener === 'function') { + listener.call(this, { ...event, data: decryptedData }); + } else { + listener.handleEvent.call(this, { + ...event, + data: decryptedData, + }); + } + } catch (err) { + console.error('WebSocket decrypt error:', err); } }; @@ -67,11 +70,7 @@ export class EncryptedWebSocket extends WebSocket { } }; - // 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')!; - + // Block direct onmessage assignment let explicitlySetListener: // eslint-disable-next-line @typescript-eslint/no-explicit-any ((this: WebSocket, handlerEvent: MessageEvent) => any) | null; null; @@ -80,8 +79,6 @@ export class EncryptedWebSocket extends WebSocket { 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 @@ -89,64 +86,40 @@ export class EncryptedWebSocket extends WebSocket { 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"!`, + '"onmessage" should not be set directly. Use addEventListener instead. 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) }); - // }; }, }); } - // allow sending raw message without encryption so they can be read sendSystemMessage(message: string) { super.send(message); } 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); + // Encrypt directly with ArrayBuffer — no base64 conversion + this.vaultClient + .encryptWithKey( + message.buffer as ArrayBuffer, + this.encryptedSymmetricKey, + ) + .then(({ encryptedData }) => { + super.send(new Uint8Array(encryptedData)); }) .catch((error) => { - console.error(error); - - return Promise.reject(error); + console.error('WebSocket encrypt error:', error); }); } } export function createAdaptedEncryptedWebsocketClass(options: { - encryptionKey: CryptoKey; - decryptionKey: CryptoKey; + vaultClient: VaultClient; + encryptedSymmetricKey: ArrayBuffer; onSystemMessage?: (message: string) => void; }) { return class extends EncryptedWebSocket { - protected readonly encryptionKey = options.encryptionKey; - protected readonly decryptionKey = options.decryptionKey; + protected readonly vaultClient = options.vaultClient; + protected readonly encryptedSymmetricKey = options.encryptedSymmetricKey; protected readonly onSystemMessage = options.onSystemMessage; }; } 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 index 4b951e7e9..1fbeb6fe6 100644 --- 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 @@ -1,6 +1,12 @@ -import { useEffect, useState } from 'react'; - -import { decryptSymmetricKey } from '@/docs/doc-collaboration/encryption'; +/** + * Hook to manage document-level encryption state. + * + * Stores the user's encrypted symmetric key as an ArrayBuffer. + * Components pass this to VaultClient.encryptWithKey() / decryptWithKey() + * for all crypto operations. The vault decrypts the symmetric key internally + * using the user's private key (with session caching for performance). + */ +import { useEffect, useMemo, useState } from 'react'; import { useUserEncryption } from '../UserEncryptionProvider'; @@ -9,94 +15,95 @@ export type DocumentEncryptionError = | 'decryption_failed' | null; +export interface DocumentEncryptionSettings { + /** + * The user's encrypted symmetric key as ArrayBuffer. + * Pass this to VaultClient.encryptWithKey() / decryptWithKey(). + */ + encryptedSymmetricKey: ArrayBuffer; +} + +/** Convert a base64 string to ArrayBuffer */ +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes.buffer as ArrayBuffer; +} + export function useDocumentEncryption( isDocumentEncrypted: boolean | undefined, - userEncryptedSymmetricKey: string | undefined, + userEncryptedSymmetricKeyBase64: string | undefined, ): { documentEncryptionLoading: boolean; - documentEncryptionSettings: { - documentSymmetricKey: CryptoKey; - } | null; + documentEncryptionSettings: DocumentEncryptionSettings | null; documentEncryptionError: DocumentEncryptionError; } { const { encryptionLoading, encryptionSettings } = useUserEncryption(); const [loading, setLoading] = useState(true); - const [settings, setSettings] = useState<{ - documentSymmetricKey: CryptoKey; - } | null>(null); const [error, setError] = useState(null); + // Convert the base64 key from the API to ArrayBuffer (memoized) + const encryptedSymmetricKey = useMemo(() => { + if (!userEncryptedSymmetricKeyBase64) return null; + + try { + return base64ToArrayBuffer(userEncryptedSymmetricKeyBase64); + } catch { + return null; + } + }, [userEncryptedSymmetricKeyBase64]); + + const settings = useMemo(() => { + if (!encryptedSymmetricKey) return null; + + return { encryptedSymmetricKey }; + }, [encryptedSymmetricKey]); + useEffect(() => { - let cancelled = false; + if (!encryptionLoading && !encryptionSettings) { + setLoading(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); - setError(null); - return; - } else if (isDocumentEncrypted === false) { - setLoading(false); - setSettings(null); - setError(null); - return; - } - - if (!userEncryptedSymmetricKey) { - if (!cancelled) { - setError('missing_symmetric_key'); - setSettings(null); - setLoading(false); - } - return; - } - - try { - setLoading(true); - setError(null); - - const userEncryptedSymmetricKeyArrayBuffer = Buffer.from( - userEncryptedSymmetricKey, - 'base64', - ); - - const symmetricKey = await decryptSymmetricKey( - userEncryptedSymmetricKeyArrayBuffer.buffer, - encryptionSettings!.userPrivateKey, - ); - - if (!cancelled) { - setSettings({ documentSymmetricKey: symmetricKey }); - } - } catch (error) { - console.error(error); - - if (!cancelled) { - setError('decryption_failed'); - setSettings(null); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } + return; } - initDocumentEncryption(); + if (encryptionLoading || isDocumentEncrypted === undefined) { + setLoading(true); + setError(null); - return () => { - cancelled = true; - }; - }, [encryptionLoading, encryptionSettings, userEncryptedSymmetricKey]); + return; + } + + if (isDocumentEncrypted === false) { + setLoading(false); + setError(null); + + return; + } + + if (!encryptedSymmetricKey) { + setError('missing_symmetric_key'); + setLoading(false); + + return; + } + + setError(null); + setLoading(false); + }, [ + encryptionLoading, + encryptionSettings, + isDocumentEncrypted, + encryptedSymmetricKey, + ]); return { documentEncryptionLoading: loading, - documentEncryptionSettings: settings, + documentEncryptionSettings: error ? null : settings, documentEncryptionError: error, }; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/VaultClientProvider.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/VaultClientProvider.tsx new file mode 100644 index 000000000..80f6251a5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/VaultClientProvider.tsx @@ -0,0 +1,263 @@ +/** + * React context provider for the centralized encryption VaultClient SDK. + * + * The client SDK is loaded at runtime via a