This commit is contained in:
Thomas Ramé
2026-03-24 15:01:31 +01:00
parent af1c40995b
commit 3e3ee7e698
34 changed files with 1040 additions and 1470 deletions

View File

@@ -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",

View File

@@ -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",
),
),
]

View File

@@ -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",
),
]

View File

@@ -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):

View File

@@ -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

View File

@@ -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 }) {
<CunninghamProvider theme={theme}>
<ConfigProvider>
<Auth>
<UserEncryptionProvider>{children}</UserEncryptionProvider>
<VaultClientProvider>
<UserEncryptionProvider>{children}</UserEncryptionProvider>
</VaultClientProvider>
</Auth>
</ConfigProvider>
</CunninghamProvider>

View File

@@ -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;
}

View File

@@ -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 ? (
<Icon iconName="warning" $size="20px" $theme="warning" />
) : (
'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;
}
`}
>
<Box
@@ -101,26 +81,27 @@ export const AccountMenu = () => {
$gap="0.5rem"
$align="center"
>
{hasMismatch && (
<Icon iconName="warning" $size="16px" $theme="warning" />
)}
<Icon iconName="person" $color="inherit" $size="xl" />
{t('My account')}
</Box>
</DropdownMenu>
<ModalEncryptionOnboarding
isOpen={isOnboardingOpen}
onClose={() => setIsOnboardingOpen(false)}
/>
<ModalEncryptionSettings
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
onRequestReOnboard={() => {
setIsSettingsOpen(false);
setIsOnboardingOpen(true);
}}
/>
{user && isOnboardingOpen && (
<ModalEncryptionOnboarding
isOpen
onClose={() => setIsOnboardingOpen(false)}
onSuccess={() => setIsOnboardingOpen(false)}
/>
)}
{user && isSettingsOpen && (
<ModalEncryptionSettings
isOpen
onClose={() => setIsSettingsOpen(false)}
onRequestReOnboard={() => {
setIsSettingsOpen(false);
setIsOnboardingOpen(true);
}}
/>
)}
</>
);
};

View File

@@ -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<HTMLDivElement | null>(null);
const hasExistingBackendKey = !!user?.encryption_public_key;
const [step, setStep] = useState<OnboardingStep>('explanation');
const [isPending, setIsPending] = useState(false);
const [backupPassphrase, setBackupPassphrase] = useState<string | null>(null);
const [restoreInput, setRestoreInput] = useState('');
const [restoreError, setRestoreError] = useState<string | null>(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 = () => (
<Box $gap="sm">
<Alert type={VariantType.WARNING}>
<Box $gap="xs">
<Text $size="sm">
{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.',
)}
</Text>
<Text $size="sm">
{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.',
)}
</Text>
</Box>
</Alert>
</Box>
);
const renderExistingKeyChoice = () => (
<Box $gap="sm">
<Alert type={VariantType.WARNING}>
<Box $gap="xs">
<Text $size="sm" $weight="600">
{t('Previous encryption setup detected')}
</Text>
<Text $size="sm">
{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.',
)}
</Text>
</Box>
</Alert>
<Box $gap="sm">
<Box
$gap="xs"
$padding="sm"
$background="var(--c--contextuals--background--semantic--contextual--primary)"
$radius="4px"
>
<Box $direction="row" $align="center" $gap="xs">
<Text $size="sm" $weight="600">
{t('Restore from backup')}
</Text>
<Badge>{t('Recommended')}</Badge>
</Box>
<Text $size="xs" $variation="secondary">
{t(
'If you have a backup of your keys, you can restore them on this device.',
)}
</Text>
<Button
fullWidth
color="brand"
variant="secondary"
onClick={() => setStep('restore')}
icon={<Icon iconName="key" $size="sm" $theme="brand" />}
>
{t('Restore existing keys from backup')}
</Button>
</Box>
<Box
$direction="row"
$align="center"
$gap="sm"
$css="color: var(--c--contextuals--content--secondary);"
>
<Box $css="flex: 1; height: 1px; background: var(--c--contextuals--border--surface--primary);" />
<Text $size="xs" $variation="secondary">
{t('or')}
</Text>
<Box $css="flex: 1; height: 1px; background: var(--c--contextuals--border--surface--primary);" />
</Box>
<Box
$gap="xs"
$padding="sm"
$background="var(--c--contextuals--background--semantic--contextual--primary)"
$radius="4px"
>
<Text $size="sm" $weight="600">
{t('Start fresh')}
</Text>
<Text $size="xs" $variation="secondary">
{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.',
)}
</Text>
<Button
fullWidth
color="error"
variant="secondary"
onClick={generateAndStoreKeys}
disabled={isPending}
icon={
isPending ? (
<div>
<Spinner size="sm" />
</div>
) : (
<Icon iconName="add" $size="sm" $theme="error" />
)
}
>
{t('Create new key pair (invalidates old keys)')}
</Button>
</Box>
</Box>
</Box>
);
const renderRestore = () => (
<Box $gap="sm">
<Text $size="sm">
{t(
'Paste your backup passphrase below to restore your encryption keys on this device.',
)}
</Text>
<Box
as="textarea"
$padding="sm"
$radius="4px"
$width="100%"
$css="min-height: 100px; font-family: monospace; font-size: 12px; border: 1px solid var(--c--contextuals--border--surface--primary); resize: vertical;"
value={restoreInput}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setRestoreInput(e.target.value)
}
placeholder={t('Paste your backup passphrase here...')}
/>
{restoreError && <Alert type={VariantType.ERROR}>{restoreError}</Alert>}
</Box>
);
const [showPassphrase, setShowPassphrase] = useState(false);
const renderBackup = () => (
<Box $gap="sm">
<Alert type={VariantType.SUCCESS}>
<Box $gap="xs">
<Text $size="sm" $weight="600">
{t('Keys generated successfully!')}
</Text>
<Text $size="sm">
{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.',
)}
</Text>
</Box>
</Alert>
<Box
$gap="xs"
$padding="sm"
$background="var(--c--contextuals--background--semantic--contextual--primary)"
$radius="4px"
>
<Box $direction="row" $align="center" $gap="xs">
<Icon iconName="key" $size="sm" $theme="brand" />
<Text $size="sm" $weight="600">
{t('Save passphrase')}
</Text>
<Badge>{t('Recommended')}</Badge>
</Box>
<Text $size="xs" $variation="secondary">
{t(
'Copy this passphrase and store it in a password manager with 2FA enabled, or print it and keep it in a safe place.',
)}
</Text>
{showPassphrase ? (
<Box $gap="xs">
<Box
as="textarea"
readOnly
value={backupPassphrase ?? ''}
rows={4}
$width="100%"
$padding="sm"
$radius="4px"
$css="font-family: monospace; font-size: 11px; word-break: break-all; resize: none; border: 1px solid var(--c--contextuals--border--surface--primary); background: var(--c--contextuals--background--surface--primary); user-select: all;"
/>
<Button variant="secondary" onClick={handleCopyPassphrase}>
{t('Copy to clipboard')}
</Button>
</Box>
) : (
<Button variant="secondary" onClick={() => setShowPassphrase(true)}>
{t('Reveal passphrase')}
</Button>
)}
</Box>
<Box
$gap="xs"
$padding="sm"
$background="var(--c--contextuals--background--semantic--contextual--primary)"
$radius="4px"
>
<Box $direction="row" $align="center" $gap="xs">
<Icon iconName="cloud_upload" $size="sm" $theme="brand" />
<Text $size="sm" $weight="600">
{t('Third-party backup')}
</Text>
</Box>
<Text $size="xs" $variation="secondary">
{t(
'Send your encrypted key to a trusted third-party server for recovery.',
)}
</Text>
<Button variant="secondary" onClick={handleBackupThirdParty}>
{t('Send to server')}
</Button>
</Box>
</Box>
);
const getStepContent = () => {
switch (step) {
case 'explanation':
return renderExplanation();
case 'existing-key-choice':
return renderExistingKeyChoice();
case 'generating':
return (
<Box $align="center" $padding="lg">
<Spinner />
<Text $size="sm">{t('Generating encryption keys...')}</Text>
</Box>
);
case 'restore':
return renderRestore();
case 'backup':
return renderBackup();
}
};
const getRightActions = () => {
switch (step) {
case 'explanation':
return (
<>
<Button variant="secondary" fullWidth onClick={handleClose}>
{t('Cancel')}
</Button>
<Button
color="brand"
fullWidth
onClick={generateAndStoreKeys}
disabled={isPending}
icon={
isPending ? (
<div>
<Spinner size="sm" />
</div>
) : undefined
}
>
{t('Enable encryption')}
</Button>
</>
);
case 'existing-key-choice':
return (
<Button variant="secondary" fullWidth onClick={handleClose}>
{t('Cancel')}
</Button>
);
case 'restore':
return (
<>
<Button
variant="secondary"
fullWidth
onClick={() => {
setRestoreError(null);
setRestoreInput('');
setStep(
hasExistingBackendKey ? 'existing-key-choice' : 'explanation',
);
}}
>
{t('Back')}
</Button>
<Button
color="brand"
fullWidth
onClick={handleRestoreKeys}
disabled={isPending || !restoreInput.trim()}
icon={
isPending ? (
<div>
<Spinner size="sm" />
</div>
) : undefined
}
>
{t('Restore keys')}
</Button>
</>
);
case 'backup':
return (
<Button color="brand" fullWidth onClick={handleBackupDone}>
{t('I have backed up my keys')}
</Button>
);
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 (
<Modal
isOpen={isOpen}
closeOnClickOutside={!isPending && step !== 'backup'}
hideCloseButton
closeOnClickOutside={false}
onClose={handleClose}
aria-describedby="modal-encryption-onboarding-title"
rightActions={getRightActions()}
size={ModalSize.MEDIUM}
title={
<Box
$direction="row"
$justify="space-between"
$align="center"
$width="100%"
>
<Text
$size="h6"
as="h1"
id="modal-encryption-onboarding-title"
$margin="0"
$align="flex-start"
>
{getTitle()}
</Text>
{step !== 'backup' && (
<ButtonCloseModal
aria-label={t('Close')}
onClick={handleClose}
disabled={isPending}
/>
)}
</Box>
}
size={ModalSize.LARGE}
hideCloseButton
>
{getStepContent()}
<Box $minHeight="400px">
<div
ref={setContainerEl}
style={{ width: '100%', minHeight: '400px' }}
/>
</Box>
</Modal>
);
};

View File

@@ -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<HTMLDivElement | null>(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<SettingsView>('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 = () => (
<Box $gap="sm">
{hasMismatch && (
<Alert type={VariantType.ERROR}>
<Box $gap="xs">
<Text $size="sm" $weight="600">
{t('Key mismatch detected')}
</Text>
<Text $size="sm">
{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.',
)}
</Text>
<Text $size="sm">
{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.',
)}
</Text>
<Button
variant="secondary"
color="error"
onClick={handleReOnboard}
style={{ width: 'fit-content' }}
>
{t('Re-setup encryption')}
</Button>
</Box>
</Alert>
)}
<Box $gap="xs">
<Text $size="sm">{t('Your public key fingerprint on the server')}</Text>
<Box
$padding="sm"
$background="var(--c--contextuals--background--semantic--contextual--primary)"
$radius="4px"
$css="font-family: monospace; font-size: 14px; letter-spacing: 2px;"
>
{backendFingerprint || '...'}
</Box>
</Box>
{localFingerprint && (
<Box $gap="xs">
<Text $size="sm">
{t('Your public key fingerprint on this current device')}
</Text>
<Box
$padding="sm"
$background="var(--c--contextuals--background--semantic--contextual--primary)"
$radius="4px"
$css="font-family: monospace; font-size: 14px; letter-spacing: 2px;"
>
{localFingerprint}
</Box>
</Box>
)}
{!encryptionSettings && user?.encryption_public_key && (
<Alert type={VariantType.WARNING}>
<Box $gap="xs">
<Text $size="sm" $weight="600">
{t('No local keys on this device')}
</Text>
<Text $size="sm">
{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.',
)}
</Text>
<Button
variant="secondary"
onClick={handleReOnboard}
style={{ width: 'fit-content' }}
>
{t('Restore keys on this device')}
</Button>
</Box>
</Alert>
)}
</Box>
);
const renderConfirmRemove = () => (
<Box $gap="sm">
<Alert type={VariantType.WARNING}>
<Text $size="sm">
{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.',
)}
</Text>
</Alert>
<Box
$gap="xs"
$padding="sm"
$background="var(--c--contextuals--background--semantic--contextual--primary)"
$radius="4px"
>
<Checkbox
label={t('Also remove my public key from the server')}
checked={alsoRemoveFromServer}
onChange={() => {
setAlsoRemoveFromServer((prev) => !prev);
setConfirmInput('');
}}
/>
<Text $size="xs" $variation="secondary" $margin={{ left: 'lg' }}>
{t(
'If enabled, other users will no longer find this current public key to share new documents with you.',
)}
</Text>
</Box>
<Box $gap="xs">
<Text $size="sm" $direction="row" $align="center" $gap="0.3rem">
<Trans t={t}>
To confirm, type your public key fingerprint:{' '}
<Badge style={{ width: 'fit-content' }}>{backendFingerprint}</Badge>
</Trans>
</Text>
<Input
label={t('Fingerprint')}
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
state={confirmInput && !fingerprintMatches ? 'error' : 'default'}
text={
confirmInput && !fingerprintMatches
? t('Fingerprint does not match')
: undefined
}
/>
</Box>
</Box>
);
const getRightActions = () => {
if (view === 'main') {
return (
<>
<Button variant="secondary" fullWidth onClick={handleClose}>
{t('Close')}
</Button>
<Button
color="error"
fullWidth
onClick={() => setView('confirm-remove')}
>
{t('Remove encryption')}
</Button>
</>
);
}
return (
<>
<Button
variant="secondary"
fullWidth
onClick={() => {
setView('main');
setConfirmInput('');
}}
>
{t('Back')}
</Button>
<Button
color="error"
fullWidth
onClick={handleRemoveEncryption}
disabled={isPending || !canConfirmRemoval}
icon={
isPending ? (
<div>
<Spinner size="sm" />
</div>
) : undefined
}
>
{t('Confirm removal')}
</Button>
</>
);
};
}, [isOpen]);
return (
<Modal
isOpen={isOpen}
closeOnClickOutside={!isPending}
hideCloseButton
closeOnClickOutside={false}
onClose={handleClose}
aria-describedby="modal-encryption-settings-title"
rightActions={getRightActions()}
size={ModalSize.MEDIUM}
title={
<Box
$direction="row"
$justify="space-between"
$align="center"
$width="100%"
>
<Text
$size="h6"
as="h1"
id="modal-encryption-settings-title"
$margin="0"
$align="flex-start"
>
{view === 'main'
? t('Encryption settings')
: t('Remove encryption')}
</Text>
<ButtonCloseModal
aria-label={t('Close')}
onClick={handleClose}
disabled={isPending}
/>
</Box>
}
size={ModalSize.LARGE}
hideCloseButton
>
{view === 'main' ? renderMain() : renderConfirmRemove()}
<Box $minHeight="400px">
<div
ref={setContainerEl}
style={{ width: '100%', minHeight: '400px' }}
/>
</Box>
</Modal>
);
};

View File

@@ -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 (
<UserEncryptionContext.Provider
value={{ ...encryptionValue, refreshEncryption }}
value={{
encryptionLoading: isLoading,
encryptionSettings,
encryptionError,
refreshEncryption,
}}
>
{children}
</UserEncryptionContext.Provider>

View File

@@ -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<ArrayBuffer>(
// 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<ArrayBuffer>) {
// TODO: we use the polyfilled websocket parameter for `y-websocket` to bring our own encryption logic over the network
// that's great but encryption is preferable with async processes, we cannot just switch to async since
// 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;
};
}

View File

@@ -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<DocumentEncryptionError>(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<DocumentEncryptionSettings | null>(() => {
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,
};
}

View File

@@ -0,0 +1,263 @@
/**
* React context provider for the centralized encryption VaultClient SDK.
*
* The client SDK is loaded at runtime via a <script> tag from the vault domain
* (data.encryption). Type declarations are provided by encryption-client.d.ts.
*
* This provider:
* - Loads the client.js script from the vault URL
* - Creates and initializes the VaultClient instance
* - Sets auth context when the user logs in
* - Tracks key state (hasKeys, publicKey)
* - Provides the client to all downstream components
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/features/auth';
// Environment configuration
const VAULT_URL =
process.env.NEXT_PUBLIC_VAULT_URL ?? 'http://localhost:7201';
const INTERFACE_URL =
process.env.NEXT_PUBLIC_INTERFACE_URL ?? 'http://localhost:7202';
export interface VaultClientContextValue {
/** The VaultClient instance, or null if not yet initialized */
client: VaultClient | null;
/** True once the vault iframe is ready AND auth context has been set */
isReady: boolean;
/** True while the vault is initializing */
isLoading: boolean;
/** Error message if initialization failed */
error: string | null;
/** Whether the current user has encryption keys on this device */
hasKeys: boolean | null;
/** The current user's public key, or null */
publicKey: ArrayBuffer | null;
/** Re-check key state (after onboarding, restore, etc.) */
refreshKeyState: () => Promise<void>;
}
const VaultClientContext = createContext<VaultClientContextValue>({
client: null,
isReady: false,
isLoading: true,
error: null,
hasKeys: null,
publicKey: null,
refreshKeyState: async () => {},
});
/** Load the encryption client SDK script from the vault domain */
function loadClientScript(): Promise<void> {
return new Promise((resolve, reject) => {
// Check if already loaded
if (window.EncryptionClient?.VaultClient) {
resolve();
return;
}
// Check if script tag already exists
const existing = document.querySelector(
`script[src="${VAULT_URL}/client.js"]`,
);
if (existing) {
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () =>
reject(new Error('Failed to load encryption client SDK')),
);
return;
}
const script = document.createElement('script');
script.src = `${VAULT_URL}/client.js`;
script.async = true;
script.onload = () => resolve();
script.onerror = () =>
reject(new Error('Failed to load encryption client SDK'));
document.head.appendChild(script);
});
}
export function VaultClientProvider({
children,
}: {
children: React.ReactNode;
}) {
const { user, authenticated } = useAuth();
const { i18n } = useTranslation();
const clientRef = useRef<VaultClient | null>(null);
const [clientInitialized, setClientInitialized] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasKeys, setHasKeys] = useState<boolean | null>(null);
const [publicKey, setPublicKey] = useState<ArrayBuffer | null>(null);
const initRef = useRef(false);
// Load script + initialize VaultClient once
useEffect(() => {
if (initRef.current) return;
initRef.current = true;
let destroyed = false;
async function init() {
try {
await loadClientScript();
if (destroyed) return;
const client = new window.EncryptionClient.VaultClient({
vaultUrl: VAULT_URL,
interfaceUrl: INTERFACE_URL,
theme: 'light',
lang: i18n.language,
});
clientRef.current = client;
client.on('onboarding:complete', () => {
setHasKeys(true);
client
.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {});
});
client.on('keys-changed', () => {
client
.hasKeys()
.then(({ hasKeys: exists }) => {
setHasKeys(exists);
if (exists) {
client
.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {});
}
})
.catch(() => {});
});
client.on('keys-destroyed', () => {
setHasKeys(false);
setPublicKey(null);
});
await client.init();
if (destroyed) {
client.destroy();
} else {
setClientInitialized(true);
}
} catch (err) {
if (!destroyed) {
setError((err as Error).message);
setIsLoading(false);
}
}
}
void init();
return () => {
destroyed = true;
if (clientRef.current) {
clientRef.current.destroy();
clientRef.current = null;
}
};
}, []);
// Set auth context whenever user changes or client finishes initializing
useEffect(() => {
const client = clientRef.current;
if (!client || !clientInitialized || !authenticated || !user?.id) {
return;
}
// In Docs, auth is cookie-based. The vault in dev mode (no VITE_JWKS_URL)
// falls back to the declared userId. In production, pass a real JWT.
// TODO: Pass real ProConnect JWT when available.
client.setAuthContext({
token: 'session-cookie-auth',
userId: user.id,
});
setIsLoading(true);
client
.hasKeys()
.then(async ({ hasKeys: exists }) => {
setHasKeys(exists);
if (exists) {
const { publicKey: pk } = await client.getPublicKey();
setPublicKey(pk);
}
setIsReady(true);
setIsLoading(false);
})
.catch((err) => {
setError((err as Error).message);
setIsLoading(false);
});
}, [clientInitialized, authenticated, user?.id]);
const refreshKeyState = useCallback(async () => {
const client = clientRef.current;
if (!client) return;
try {
const { hasKeys: exists } = await client.hasKeys();
setHasKeys(exists);
if (exists) {
const { publicKey: pk } = await client.getPublicKey();
setPublicKey(pk);
} else {
setPublicKey(null);
}
} catch {
// Vault not available
}
}, []);
return (
<VaultClientContext.Provider
value={{
client: clientRef.current,
isReady,
isLoading,
error,
hasKeys,
publicKey,
refreshKeyState,
}}
>
{children}
</VaultClientContext.Provider>
);
}
export const useVaultClient = (): VaultClientContextValue =>
useContext(VaultClientContext);

View File

@@ -0,0 +1,44 @@
// Re-export types from encryption-client.d.ts for global availability
export {};
declare global {
interface VaultClient {
init(): Promise<void>;
destroy(): void;
setTheme(theme: 'light' | 'dark' | 'system'): void;
setAuthContext(context: { token: string; userId: string }): void;
hasKeys(): Promise<{ hasKeys: boolean }>;
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>;
encrypt(data: ArrayBuffer): Promise<{ encryptedData: ArrayBuffer }>;
decrypt(encryptedData: ArrayBuffer): Promise<{ data: ArrayBuffer }>;
encryptForUsers(data: ArrayBuffer, userPublicKeys: Record<string, ArrayBuffer>): Promise<{ encryptedContent: ArrayBuffer; encryptedKeys: Record<string, ArrayBuffer> }>;
encryptWithKey(data: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer): Promise<{ encryptedData: ArrayBuffer }>;
decryptWithKey(encryptedData: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer): Promise<{ data: ArrayBuffer }>;
rewrapKey(encryptedSymmetricKey: ArrayBuffer, targetUserPublicKey: ArrayBuffer): Promise<{ rewrappedKey: ArrayBuffer }>;
fetchPublicKeys(userIds: string[]): Promise<{ publicKeys: Record<string, ArrayBuffer> }>;
checkFingerprints(userFingerprints: Record<string, string>, currentUserId?: string): Promise<{ results: Array<{ userId: string; knownFingerprint: string | null; providedFingerprint: string; status: 'trusted' | 'refused' | 'unknown' }> }>;
acceptFingerprint(userId: string, fingerprint: string): Promise<void>;
refuseFingerprint(userId: string, fingerprint: string): Promise<void>;
getKnownFingerprints(): Promise<{ fingerprints: Record<string, { fingerprint: string; status: 'trusted' | 'refused' | 'unknown' }> }>;
openOnboarding(container: HTMLElement): void;
openBackup(container: HTMLElement): void;
openRestore(container: HTMLElement): void;
openDeviceTransfer(container: HTMLElement): void;
openSettings(container: HTMLElement): void;
closeInterface(): void;
on<K extends string>(event: K, listener: (data: any) => void): void;
off<K extends string>(event: K, listener: (data: any) => void): void;
}
interface Window {
EncryptionClient: {
VaultClient: new (options: {
vaultUrl: string;
interfaceUrl: string;
timeout?: number;
theme?: 'light' | 'dark' | 'system';
lang?: string;
}) => VaultClient;
};
}
}

View File

@@ -0,0 +1,2 @@
export { VaultClientProvider, useVaultClient } from './VaultClientProvider';
export type { VaultClientContextValue } from './VaultClientProvider';

View File

@@ -19,6 +19,7 @@ import type { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
@@ -87,9 +88,7 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
interface BlockNoteEditorProps {
doc: Doc;
provider: SwitchableProvider;
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null;
documentEncryptionSettings: DocumentEncryptionSettings | null;
}
export const BlockNoteEditor = ({
@@ -120,8 +119,8 @@ export const BlockNoteEditor = ({
lang = 'en';
}
const symmetricKey = documentEncryptionSettings?.documentSymmetricKey;
const { uploadFile, errorAttachment } = useUploadFile(doc.id, symmetricKey);
const encryptedSymmetricKey = documentEncryptionSettings?.encryptedSymmetricKey;
const { uploadFile, errorAttachment } = useUploadFile(doc.id, encryptedSymmetricKey);
const collabName = user?.full_name || user?.email;
const cursorName = collabName || t('Anonymous');
@@ -228,7 +227,7 @@ export const BlockNoteEditor = ({
lang,
provider,
uploadFile,
symmetricKey,
encryptedSymmetricKey,
threadStore,
resolveUsers,
],
@@ -249,7 +248,7 @@ export const BlockNoteEditor = ({
}, [setEditor, editor]);
return (
<EncryptionProvider symmetricKey={symmetricKey}>
<EncryptionProvider encryptedSymmetricKey={encryptedSymmetricKey}>
<EncryptedDocBanner />
<Box
ref={refEditorContainer}

View File

@@ -2,6 +2,7 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Box, Loading } from '@/components';
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
@@ -76,9 +77,7 @@ export const DocEditorContainer = ({
interface DocEditorProps {
doc: Doc;
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null;
documentEncryptionSettings: DocumentEncryptionSettings | null;
}
export const DocEditor = ({

View File

@@ -1,3 +1,7 @@
/**
* Encryption context for document content and file attachments.
* Uses VaultClient SDK with ArrayBuffer for all decrypt operations.
*/
import {
ReactNode,
createContext,
@@ -9,29 +13,25 @@ import {
useState,
} from 'react';
import { decryptContent } from '@/docs/doc-collaboration/encryption';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
const MIME_MAP: Record<string, string> = {
// Images
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
// Audio
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
flac: 'audio/flac',
aac: 'audio/aac',
// Video
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
// PDF
pdf: 'application/pdf',
};
@@ -60,14 +60,15 @@ const DEFAULT_VALUE: EncryptionContextValue = {
const EncryptionContext = createContext<EncryptionContextValue>(DEFAULT_VALUE);
interface EncryptionProviderProps {
symmetricKey: CryptoKey | undefined;
encryptedSymmetricKey: ArrayBuffer | undefined;
children: ReactNode;
}
export const EncryptionProvider = ({
symmetricKey,
encryptedSymmetricKey,
children,
}: EncryptionProviderProps) => {
const { client: vaultClient } = useVaultClient();
const blobUrlCacheRef = useRef<Map<string, string>>(new Map());
const [revealAllCounter, setRevealAllCounter] = useState(0);
const [pendingPlaceholders, setPendingPlaceholders] = useState(0);
@@ -86,35 +87,42 @@ export const EncryptionProvider = ({
const decryptFileUrl = useCallback(
async (url: string): Promise<string> => {
if (!symmetricKey) {
if (!encryptedSymmetricKey || !vaultClient) {
return url;
}
const cached = blobUrlCacheRef.current.get(url);
if (cached) {
return cached;
}
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) {
throw new Error(
`Failed to fetch encrypted attachment: ${response.status}`,
);
}
const fileBytes = new Uint8Array(await response.arrayBuffer());
const decryptedBytes = await decryptContent(fileBytes, symmetricKey);
// Get file as ArrayBuffer directly — no base64 conversion
const encryptedBuffer = await response.arrayBuffer();
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
encryptedBuffer,
encryptedSymmetricKey,
);
const ext = url.split('.').pop()?.toLowerCase() || '';
const mime = MIME_MAP[ext] || 'application/octet-stream';
const blob = new Blob([decryptedBytes as BlobPart], { type: mime });
const blob = new Blob([decryptedBuffer], { type: mime });
const blobUrl = URL.createObjectURL(blob);
blobUrlCacheRef.current.set(url, blobUrl);
return blobUrl;
},
[symmetricKey],
[encryptedSymmetricKey, vaultClient],
);
useEffect(() => {
@@ -124,11 +132,11 @@ export const EncryptionProvider = ({
);
blobUrlCacheRef.current.clear();
};
}, [symmetricKey]);
}, [encryptedSymmetricKey]);
const value = useMemo(
() => ({
isEncrypted: !!symmetricKey,
isEncrypted: !!encryptedSymmetricKey,
decryptFileUrl,
revealAllCounter,
requestRevealAll,
@@ -137,7 +145,7 @@ export const EncryptionProvider = ({
unregisterPlaceholder,
}),
[
symmetricKey,
encryptedSymmetricKey,
decryptFileUrl,
revealAllCounter,
requestRevealAll,

View File

@@ -1,10 +1,11 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import * as Y from 'yjs';
import { encryptContent } from '@/docs/doc-collaboration/encryption';
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import { useUpdateDoc, useProviderStore } from '@/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
import { isFirefox } from '@/utils/userAgent';
import { toBase64 } from '../utils';
@@ -16,11 +17,10 @@ export const useSaveDoc = (
yDoc: Y.Doc,
isConnectedToCollabServer: boolean,
isEncrypted: boolean,
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null,
documentEncryptionSettings: DocumentEncryptionSettings | null,
) => {
const { encryptionTransition } = useProviderStore();
const { client: vaultClient } = useVaultClient();
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
@@ -29,11 +29,6 @@ export const useSaveDoc = (
});
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
/**
* Update initial doc when doc is updated by other users,
* so only the user typing will trigger the save.
* This is to avoid saving the same doc multiple time.
*/
useEffect(() => {
const onUpdate = (
_uintArray: Uint8Array,
@@ -56,31 +51,38 @@ export const useSaveDoc = (
return false;
} else if (encryptionTransition) {
return false;
} else if (isEncrypted && !documentEncryptionSettings) {
// If the symmetric key is not yet ready we just ignore saving (either it needs onboarding or just a few seconds)
} else if (isEncrypted && (!documentEncryptionSettings || !vaultClient)) {
return false;
}
let state = Y.encodeStateAsUpdate(yDoc);
let contentPromise: Promise<typeof state>;
const state = Y.encodeStateAsUpdate(yDoc);
if (isEncrypted) {
contentPromise = encryptContent(
new Uint8Array(state),
documentEncryptionSettings!.documentSymmetricKey,
);
if (isEncrypted && documentEncryptionSettings && vaultClient) {
// Encrypt via vault with ArrayBuffer — zero-copy
vaultClient
.encryptWithKey(
state.buffer as ArrayBuffer,
documentEncryptionSettings.encryptedSymmetricKey,
)
.then(({ encryptedData }) => {
updateDoc({
id: docId,
content: toBase64(new Uint8Array(encryptedData)),
contentEncrypted: true,
websocket: isConnectedToCollabServer,
});
})
.catch((err) => {
console.error('Failed to encrypt document for save:', err);
});
} else {
contentPromise = Promise.resolve(state);
}
contentPromise.then((docState) => {
updateDoc({
id: docId,
content: toBase64(docState),
contentEncrypted: isEncrypted,
content: toBase64(state),
contentEncrypted: false,
websocket: isConnectedToCollabServer,
});
});
}
return true;
}, [
@@ -92,6 +94,7 @@ export const useSaveDoc = (
isConnectedToCollabServer,
isEncrypted,
documentEncryptionSettings,
vaultClient,
]);
const router = useRouter();
@@ -100,13 +103,6 @@ export const useSaveDoc = (
const onSave = (e?: Event) => {
const isSaving = saveDoc();
/**
* Firefox does not trigger the request every time the user leaves the page.
* Plus the request is not intercepted by the service worker.
* So we prevent the default behavior to have the popup asking the user
* if he wants to leave the page, by adding the popup, we let the time to the
* request to be sent, and intercepted by the service worker (for the offline part).
*/
if (
isSaving &&
typeof e !== 'undefined' &&
@@ -117,16 +113,12 @@ export const useSaveDoc = (
}
};
// Save every minute
const timeout = setInterval(onSave, SAVE_INTERVAL);
// Save when the user leaves the page
addEventListener('beforeunload', onSave);
// Save when the user navigates to another page
router.events.on('routeChangeStart', onSave);
return () => {
clearInterval(timeout);
removeEventListener('beforeunload', onSave);
router.events.off('routeChangeStart', onSave);
};

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { backendUrl } from '@/api';
import { encryptContent } from '@/docs/doc-collaboration/encryption';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
import { useCreateDocAttachment } from '../api';
import { ANALYZE_URL } from '../conf';
@@ -12,25 +12,28 @@ import { DocsBlockNoteEditor } from '../types';
export const useUploadFile = (
docId: string,
symmetricKey?: CryptoKey,
encryptedSymmetricKey?: ArrayBuffer,
) => {
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const { client: vaultClient } = useVaultClient();
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
if (symmetricKey) {
const arrayBuffer = await file.arrayBuffer();
const encryptedBytes = await encryptContent(
new Uint8Array(arrayBuffer),
symmetricKey,
if (encryptedSymmetricKey && vaultClient) {
// Encrypt the file via vault — pure ArrayBuffer
const fileBuffer = await file.arrayBuffer();
const { encryptedData } = await vaultClient.encryptWithKey(
fileBuffer,
encryptedSymmetricKey,
);
const encryptedFile = new File([encryptedBytes], file.name, {
const encryptedFile = new File([encryptedData], file.name, {
type: 'application/octet-stream',
});
body.append('file', encryptedFile);
@@ -46,7 +49,7 @@ export const useUploadFile = (
return `${backendUrl()}${ret.file}`;
},
[createDocAttachment, docId, symmetricKey],
[createDocAttachment, docId, encryptedSymmetricKey, vaultClient],
);
return {
@@ -60,16 +63,10 @@ export const useUploadFile = (
* When we upload a file it can takes some time to analyze it (e.g. virus scan).
* This hook listen to upload end and replace the uploaded block by a uploadLoader
* block to show analyzing status.
* The uploadLoader block will then handle the status display until the analysis is done
* then replaced by the final block (e.g. image, pdf, etc.).
* @param editor
*/
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
const { t } = useTranslation();
/**
* Replace the resource block by a uploadLoader block to show analyzing status
*/
const replaceBlockWithUploadLoader = useCallback(
(block: Block) => {
if (
@@ -113,7 +110,6 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
);
useEffect(() => {
// Check if editor and its view are mounted before accessing document
if (!editor?.document) {
return;
}
@@ -128,12 +124,7 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
});
}, [editor, replaceBlockWithUploadLoader]);
/**
* Handle upload end to replace the upload block by a uploadLoader
* block to show analyzing status
*/
useEffect(() => {
// Check if editor and its view are mounted before setting up handlers
if (!editor) {
return;
}

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Box, HorizontalSeparator } from '@/components';
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
@@ -20,9 +21,7 @@ import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
doc: Doc;
documentEncryptionSettings?: {
documentSymmetricKey: CryptoKey;
} | null;
documentEncryptionSettings?: DocumentEncryptionSettings | null;
}
export const DocHeader = ({

View File

@@ -35,6 +35,7 @@ import {
usePublicKeyRegistry,
useUserEncryption,
} from '@/docs/doc-collaboration';
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import { DocShareModal } from '@/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -50,9 +51,7 @@ const ModalExport = Export?.ModalExport;
interface DocToolBoxProps {
doc: Doc;
documentEncryptionSettings?: {
documentSymmetricKey: CryptoKey;
} | null;
documentEncryptionSettings?: DocumentEncryptionSettings | null;
}
export const DocToolBox = ({
@@ -77,7 +76,7 @@ export const DocToolBox = ({
const modalShare = useModal();
const { hasMismatches: hasKeyWarnings } = usePublicKeyRegistry(
doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
undefined,
encryptionSettings?.userId,
);
@@ -338,10 +337,10 @@ export const DocToolBox = ({
/>
)}
{isModalRemoveEncryptionOpen &&
documentEncryptionSettings?.documentSymmetricKey && (
documentEncryptionSettings?.encryptedSymmetricKey && (
<ModalRemoveDocEncryption
doc={doc}
symmetricKey={documentEncryptionSettings.documentSymmetricKey}
encryptedSymmetricKey={documentEncryptionSettings.encryptedSymmetricKey}
onClose={() => setIsModalRemoveEncryptionOpen(false)}
/>
)}

View File

@@ -10,7 +10,8 @@ import { toBase64 } from '@/features/docs/doc-editor';
interface EncryptDocProps {
docId: string;
content: Uint8Array<ArrayBufferLike>;
encryptedSymmetricKeyPerUser: Record<string, ArrayBuffer>;
/** Per-user encrypted symmetric keys as base64 strings (from VaultClient.encryptForUsers) */
encryptedSymmetricKeyPerUser: Record<string, string>;
attachmentKeyMapping?: Record<string, string>;
}
@@ -18,22 +19,11 @@ export const encryptDoc = async ({
docId,
...params
}: EncryptDocProps): Promise<void> => {
const base64EncryptedSymmetricKeyPerUser: Record<string, string> = {};
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,
encryptedSymmetricKeyPerUser: params.encryptedSymmetricKeyPerUser,
attachmentKeyMapping: params.attachmentKeyMapping || {},
}),
});

View File

@@ -6,17 +6,14 @@ import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, ButtonCloseModal, Icon, Text, TextErrors } from '@/components';
import {
encryptContent,
generateSymmetricKey,
prepareEncryptedSymmetricKeysForUsers,
useUserEncryption,
} from '@/docs/doc-collaboration';
import { toBase64 } from '@/features/docs/doc-editor';
import { useUserEncryption } from '@/docs/doc-collaboration';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
import { createDocAttachment } from '@/docs/doc-editor/api';
import { useAuth } from '@/features/auth';
import {
@@ -46,7 +43,8 @@ import { Spinner } from '@gouvfr-lasuite/ui-kit';
const encryptRemoteAttachments = async (
yDoc: Y.Doc,
docId: string,
symmetricKey: CryptoKey,
vaultClient: VaultClient,
encryptedSymmetricKey: ArrayBuffer,
): Promise<Record<string, string>> => {
const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc);
@@ -68,8 +66,13 @@ const encryptRemoteAttachments = async (
throw new Error('attachment cannot be fetch');
}
const fileBytes = new Uint8Array(await response.arrayBuffer());
const encryptedBytes = await encryptContent(fileBytes, symmetricKey);
// Encrypt file via vault — pure ArrayBuffer, no base64 conversion
const fileBuffer = await response.arrayBuffer();
const { encryptedData: encryptedBuffer } = await vaultClient.encryptWithKey(
fileBuffer,
encryptedSymmetricKey,
);
const encryptedBytes = new Uint8Array(encryptedBuffer);
const fileName = oldAttachmentMetadata.name ?? 'file'; // since encrypted we could not reuse the file name that can be stored as clear text
const encryptedFile = new File([encryptedBytes], fileName, {
@@ -126,6 +129,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
useProviderStore();
const { user } = useAuth();
const { encryptionSettings } = useUserEncryption();
const { client: vaultClient } = useVaultClient();
const [isPending, setIsPending] = useState(false);
@@ -150,17 +154,32 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
const isRestricted = effectiveReach === LinkReach.RESTRICTED;
const hasPendingInvitations = !!invitationsData && invitationsData.count > 0;
// Fetch public keys from the encryption service to check who has encryption enabled
const [publicKeysMap, setPublicKeysMap] = useState<Record<string, ArrayBuffer>>({});
useEffect(() => {
if (!accesses || !vaultClient) return;
const userIds = accesses
.filter((a) => a.user)
.map((a) => a.user.id);
if (userIds.length === 0) return;
vaultClient.fetchPublicKeys(userIds)
.then(({ publicKeys }) => setPublicKeysMap(publicKeys))
.catch(() => {});
}, [accesses, vaultClient]);
const membersWithoutKey = useMemo(() => {
if (!accesses || !doc.accesses_public_keys_per_user) {
if (!accesses) {
return [];
}
const publicKeysMap = doc.accesses_public_keys_per_user;
return accesses.filter(
(access) => access.user && !publicKeysMap[access.user.id],
);
}, [accesses, doc.accesses_public_keys_per_user]);
}, [accesses, publicKeysMap]);
const hasEncryptionKeys = !!encryptionSettings;
@@ -178,7 +197,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
};
const handleEncrypt = async () => {
if (!provider || !user || isPending || !canEncrypt || !encryptionSettings) {
if (!provider || !user || isPending || !canEncrypt || !encryptionSettings || !vaultClient) {
return;
}
@@ -187,60 +206,48 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
try {
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED);
const documentSymmetricKey = await generateSymmetricKey();
// Their public key are base64 encoded, decoding the whole
const usersPublicKeys: Record<string, ArrayBuffer> = {};
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;
}
} 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`);
if (Object.keys(publicKeysMap).length === 0) {
throw new Error('No public keys available. All members must have encryption enabled.');
}
// Prepare encrypted symmetric keys for all users with access
const encryptedSymmetricKeyPerUser =
await prepareEncryptedSymmetricKeysForUsers(
documentSymmetricKey,
usersPublicKeys,
);
// clone the Yjs document since performing changes during encryption that require backend confirmation
// once successfully done it can be used locally
// Clone the Yjs document for encryption
const ongoingDoc = new Y.Doc();
Y.applyUpdate(ongoingDoc, Y.encodeStateAsUpdate(provider.document));
// encrypt existing attachments
const attachmentKeyMapping = await encryptRemoteAttachments(
ongoingDoc,
doc.id,
documentSymmetricKey,
);
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
// we have no need of patching back the current Yjs document with modifications
// since an encryption success will refetch data from the backend
// Encrypt document content via vault — pure ArrayBuffer
const { encryptedContent: encryptedContentBuffer, encryptedKeys } =
await vaultClient.encryptForUsers(
ongoingDocState.buffer as ArrayBuffer,
publicKeysMap,
);
// Convert ArrayBuffer encrypted keys to base64 for the backend API
const encryptedSymmetricKeyPerUser: Record<string, string> = {};
for (const [uid, keyBuffer] of Object.entries(encryptedKeys)) {
encryptedSymmetricKeyPerUser[uid] = toBase64(new Uint8Array(keyBuffer));
}
// Get the current user's encrypted key for attachment encryption
const currentUserEncryptedKey = encryptedKeys[user.id];
// Encrypt existing attachments using the same symmetric key via vault
let attachmentKeyMapping: Record<string, string> = {};
if (currentUserEncryptedKey) {
attachmentKeyMapping = await encryptRemoteAttachments(
ongoingDoc,
doc.id,
vaultClient,
currentUserEncryptedKey,
);
}
ongoingDoc.destroy();
const encryptedContent = await encryptContent(
new Uint8Array(ongoingDocState),
documentSymmetricKey,
);
// 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:
const encryptedContent = new Uint8Array(encryptedContentBuffer);
await encryptDoc({
docId: doc.id,

View File

@@ -11,7 +11,6 @@ import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { decryptContent } from '@/docs/doc-collaboration';
import { createDocAttachment } from '@/docs/doc-editor/api';
import {
Doc,
@@ -19,23 +18,20 @@ import {
KEY_DOC,
KEY_LIST_DOC,
extractAttachmentKeysAndMetadata,
useRemoveDocEncryption,
useProviderStore,
useRemoveDocEncryption,
} from '@/features/docs/doc-management';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
import { useKeyboardAction } from '@/hooks';
/**
* Decrypt existing encrypted attachments and return:
* - a mapping of old S3 keys to new ones (for backend cleanup)
*
* The yDoc nodes are updated in place with the new URLs.
* Originals are never modified so if the process fails midway the document
* still works with its original encrypted attachments.
* Decrypt existing encrypted attachments using the vault and upload decrypted copies.
*/
const decryptRemoteAttachments = async (
yDoc: Y.Doc,
docId: string,
symmetricKey: CryptoKey,
vaultClient: VaultClient,
encryptedSymmetricKey: ArrayBuffer,
): Promise<Record<string, string>> => {
const attachmentKeysAndMetadata = extractAttachmentKeysAndMetadata(yDoc);
@@ -51,15 +47,20 @@ const decryptRemoteAttachments = async (
const response = await fetch(oldAttachmentMetadata.mediaUrl, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('attachment cannot be fetched');
}
const encryptedBytes = new Uint8Array(await response.arrayBuffer());
const decryptedBytes = await decryptContent(encryptedBytes, symmetricKey);
// Decrypt via vault — pure ArrayBuffer
const encryptedBuffer = await response.arrayBuffer();
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
encryptedBuffer,
encryptedSymmetricKey,
);
const fileName = oldAttachmentMetadata.name ?? 'file';
const decryptedFile = new File([decryptedBytes as BlobPart], fileName);
const decryptedFile = new File([decryptedBuffer], fileName);
const body = new FormData();
body.append('file', decryptedFile);
@@ -78,7 +79,6 @@ const decryptRemoteAttachments = async (
attachmentKeyMapping[oldAttachmentKey] = newKey;
}
// once uploaded, update all nodes referencing attachments with their new key
yDoc.transact(() => {
for (const [oldAttachmentKey, oldAttachmentMetadata] of Array.from(
attachmentKeysAndMetadata.entries(),
@@ -99,19 +99,20 @@ const decryptRemoteAttachments = async (
interface ModalRemoveDocEncryptionProps {
doc: Doc;
symmetricKey: CryptoKey;
encryptedSymmetricKey: ArrayBuffer;
onClose: () => void;
}
export const ModalRemoveDocEncryption = ({
doc,
symmetricKey,
encryptedSymmetricKey,
onClose,
}: ModalRemoveDocEncryptionProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { provider, notifyOthers, startEncryptionTransition } =
useProviderStore();
const { client: vaultClient } = useVaultClient();
const [isPending, setIsPending] = useState(false);
@@ -133,7 +134,7 @@ export const ModalRemoveDocEncryption = ({
};
const handleRemoveEncryption = async () => {
if (!provider || isPending) {
if (!provider || isPending || !vaultClient) {
return;
}
@@ -142,22 +143,17 @@ export const ModalRemoveDocEncryption = ({
try {
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED);
// clone the Yjs document since performing changes during decryption
// that require backend confirmation
const ongoingDoc = new Y.Doc();
Y.applyUpdate(ongoingDoc, Y.encodeStateAsUpdate(provider.document));
// decrypt existing encrypted attachments
const attachmentKeyMapping = await decryptRemoteAttachments(
ongoingDoc,
doc.id,
symmetricKey,
vaultClient,
encryptedSymmetricKey,
);
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
// we have no need of patching back the current Yjs document with modifications
// since a removing encryption success will refetch data from the backend
ongoingDoc.destroy();
await removeDocEncryption({
@@ -166,103 +162,61 @@ export const ModalRemoveDocEncryption = ({
attachmentKeyMapping,
});
toast(
t('The document encryption has been removed.'),
VariantType.SUCCESS,
{ duration: 4000 },
);
toast(t('Encryption has been removed.'), VariantType.SUCCESS, {
duration: 4000,
});
// notify other users before destroying the provider since websocket connection needed
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED);
// trigger the provider switch (relay → hocuspocus):
startEncryptionTransition('removing-encryption');
onClose();
} catch (error) {
} catch (err) {
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED);
throw error;
console.error(err);
} finally {
setIsPending(false);
}
};
const handleCloseKeyDown = keyboardAction(handleClose);
const handleRemoveEncryptionKeyDown = keyboardAction(handleRemoveEncryption);
return (
<Modal
isOpen
closeOnClickOutside={!isPending}
hideCloseButton
closeOnClickOutside
onClose={handleClose}
aria-describedby="modal-remove-doc-encryption-title"
size={ModalSize.MEDIUM}
rightActions={
<>
<Button
variant="secondary"
fullWidth
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
disabled={isPending}
>
<Button variant="secondary" onClick={handleClose} disabled={isPending}>
{t('Cancel')}
</Button>
<Button
color="error"
fullWidth
onClick={handleRemoveEncryption}
onKeyDown={handleRemoveEncryptionKeyDown}
onClick={() => void handleRemoveEncryption()}
disabled={isPending}
icon={
isPending ? (
<div>
<Spinner size="sm" />
</div>
) : undefined
}
{...keyboardAction(() => void handleRemoveEncryption())}
>
{t('Confirm')}
{isPending ? <Spinner /> : t('Confirm')}
</Button>
</>
}
size={ModalSize.MEDIUM}
title={
<Box
$direction="row"
$justify="space-between"
$align="center"
$width="100%"
>
<Text
$size="h6"
as="h1"
id="modal-remove-doc-encryption-title"
$margin="0"
$align="flex-start"
>
<Box $direction="row" $justify="space-between" $align="center">
<Text as="h1" $size="h6" $align="flex-start" $margin="0">
{t('Remove document encryption')}
</Text>
<ButtonCloseModal
aria-label={t('Close the encryption removal modal')}
aria-label={t('Close the modal')}
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
disabled={isPending}
/>
</Box>
}
hideCloseButton
>
<Box className="--docs--modal-remove-doc-encryption" $gap="sm">
{!isError && (
<Text $size="sm" $variation="secondary">
{t(
'Removing encryption will decrypt the document and make it accessible without encryption keys. The document content will be stored in plain text on the server.',
)}
</Text>
)}
{isError && <TextErrors causes={error.cause} />}
<Box $margin={{ top: 'sm' }} $gap="sm">
<Text $variation="secondary">
{t(
'This will permanently remove encryption from this document. All content will be stored in plain text.',
)}
</Text>
{isError && error && <TextErrors causes={error.cause} />}
</Box>
</Modal>
);

View File

@@ -1,22 +1,22 @@
import { useEffect } from 'react';
import { useCollaborationUrl } from '@/core/config';
import { decryptContent } from '@/docs/doc-collaboration/encryption';
import { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import { Base64, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
import { useBroadcastStore } from '@/stores';
export const useCollaboration = (
room: string | undefined,
initialContent: Base64 | undefined,
isEncrypted: boolean | undefined,
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null,
documentEncryptionSettings: DocumentEncryptionSettings | null,
) => {
const collaborationUrl = useCollaborationUrl(room);
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const { user } = useAuth();
const { client: vaultClient } = useVaultClient();
const { provider, createProvider, destroyProvider, encryptionTransition } =
useProviderStore();
@@ -27,63 +27,55 @@ export const useCollaboration = (
!user ||
isEncrypted === undefined ||
(isEncrypted === true && !documentEncryptionSettings) ||
(isEncrypted === true && !vaultClient) ||
provider ||
encryptionTransition
) {
// TODO: make sure the logout would invalide this provider, also a change of local keys (after import...)
return;
}
// since that's initially binary it has been wrapped as base64 first
let initialDocState = initialContent
const initialDocState = initialContent
? Buffer.from(initialContent, 'base64')
: undefined;
// if the document is marked as encrypted we need an extra decoding to retrieve the Yjs state
// note: we hack a bit due to decryption being async
let contentPromise: Promise<
[typeof initialDocState, CryptoKey | undefined]
>;
if (isEncrypted) {
contentPromise = (async () => {
if (!documentEncryptionSettings) {
throw new Error(
`"documentEncryptionSettings" must be filled since document is encrypted`,
);
}
if (isEncrypted && documentEncryptionSettings && vaultClient) {
(async () => {
let decryptedState = initialDocState;
if (initialDocState) {
return [
Buffer.from(
await decryptContent(
initialDocState,
documentEncryptionSettings.documentSymmetricKey,
),
),
documentEncryptionSettings.documentSymmetricKey,
];
} else {
return [
initialDocState,
documentEncryptionSettings.documentSymmetricKey,
];
}
})();
} else {
contentPromise = Promise.resolve([initialDocState, undefined]);
}
// Decrypt initial document content via vault — pure ArrayBuffer
const { data: decryptedBuffer } = await vaultClient.decryptWithKey(
initialDocState.buffer as ArrayBuffer,
documentEncryptionSettings.encryptedSymmetricKey,
);
contentPromise.then(([initialDocState, symmetricKey]) => {
decryptedState = Buffer.from(decryptedBuffer);
}
const newProvider = createProvider(
collaborationUrl,
room,
decryptedState,
{
vaultClient,
encryptedSymmetricKey:
documentEncryptionSettings.encryptedSymmetricKey,
},
);
setBroadcastProvider(newProvider);
})().catch((err) => {
console.error('Failed to decrypt document content:', err);
});
} else {
const newProvider = createProvider(
collaborationUrl,
room,
initialDocState,
symmetricKey,
);
setBroadcastProvider(newProvider);
});
}
}, [
provider,
collaborationUrl,
@@ -94,12 +86,10 @@ export const useCollaboration = (
user,
isEncrypted,
documentEncryptionSettings,
vaultClient,
encryptionTransition,
]);
/**
* Destroy the provider when the component is unmounted
*/
useEffect(() => {
return () => {
if (room) {

View File

@@ -27,7 +27,10 @@ export interface UseCollaborationStore {
providerUrl: string,
storeId: string,
initialDocState?: Buffer<ArrayBuffer>,
symmetricKey?: CryptoKey,
encryptionOptions?: {
vaultClient: VaultClient;
encryptedSymmetricKey: ArrayBuffer;
},
) => SwitchableProvider;
destroyProvider: () => void;
notifyOthers: (event: EncryptionTransitionEvent) => void;
@@ -78,8 +81,8 @@ function handleEncryptionSystemMessage(
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
...defaultValues,
createProvider: (wsUrl, storeId, initialDocState, encryptionSymmetricKey) => {
const isEncrypted = !!encryptionSymmetricKey;
createProvider: (wsUrl, storeId, initialDocState, encryptionOptions) => {
const isEncrypted = !!encryptionOptions;
const doc = new Y.Doc({
guid: storeId,
@@ -98,8 +101,8 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
//
const AdaptedEncryptedWebSocket = createAdaptedEncryptedWebsocketClass({
encryptionKey: encryptionSymmetricKey,
decryptionKey: encryptionSymmetricKey,
vaultClient: encryptionOptions!.vaultClient,
encryptedSymmetricKey: encryptionOptions!.encryptedSymmetricKey,
onSystemMessage: (message) => {
if (message === 'system:authenticated') {
set({ isReady: true, isConnected: true });

View File

@@ -73,7 +73,8 @@ export interface Doc {
updated_at: string;
user_role: Role;
encrypted_document_symmetric_key_for_user?: string;
accesses_public_keys_per_user?: Record<string, string>;
accesses_user_ids?: string[];
accesses_fingerprints_per_user?: Record<string, string>;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;

View File

@@ -9,10 +9,11 @@ import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Card } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { encryptSymmetricKey } from '@/docs/doc-collaboration';
import { toBase64 } from '@/docs/doc-editor';
import { toBase64 } from '@/features/docs/doc-editor';
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
import { OptionType } from '../types';
@@ -27,9 +28,7 @@ type APIErrorUser = APIError<{
type Props = {
doc: Doc;
documentEncryptionSettings: {
documentSymmetricKey: CryptoKey;
} | null;
documentEncryptionSettings: DocumentEncryptionSettings | null;
selectedUsers: User[];
onRemoveUser?: (user: User) => void;
onSubmit?: (selectedUsers: User[], role: Role) => void;
@@ -44,6 +43,7 @@ export const DocShareAddMemberList = ({
}: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { client: vaultClient } = useVaultClient();
const [isLoading, setIsLoading] = useState(false);
const { spacingsTokens } = useCunninghamTheme();
@@ -90,6 +90,20 @@ export const DocShareAddMemberList = ({
const onInvite = async () => {
setIsLoading(true);
// Fetch all public keys in a single request before processing users
let publicKeysMap: Record<string, ArrayBuffer> = {};
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
const memberUserIds = selectedUsers
.filter((user) => user.id !== user.email)
.map((user) => user.id);
if (memberUserIds.length > 0) {
const { publicKeys } = await vaultClient.fetchPublicKeys(memberUserIds);
publicKeysMap = publicKeys;
}
}
const promises = selectedUsers.map(async (user) => {
const isInvitationMode = user.id === user.email;
@@ -123,33 +137,20 @@ export const DocShareAddMemberList = ({
});
}
// For encrypted docs, encrypt the symmetric key with the user's public key
// For encrypted docs, re-wrap the symmetric key for the new member via vault
let memberEncryptedSymmetricKey: string | null = null;
if (
doc.is_encrypted &&
documentEncryptionSettings &&
user.encryption_public_key
) {
const publicKeyBuffer = Uint8Array.from(
atob(user.encryption_public_key),
(c) => c.charCodeAt(0),
).buffer;
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
const userPublicKey = publicKeysMap[user.id];
const importedPublicKey = await crypto.subtle.importKey(
'spki',
publicKeyBuffer,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true,
['encrypt'],
);
if (userPublicKey) {
const { rewrappedKey } = await vaultClient.rewrapKey(
documentEncryptionSettings.encryptedSymmetricKey,
userPublicKey,
);
const encryptedKey = await encryptSymmetricKey(
documentEncryptionSettings.documentSymmetricKey,
importedPublicKey,
);
memberEncryptedSymmetricKey = toBase64(new Uint8Array(encryptedKey));
memberEncryptedSymmetricKey = toBase64(new Uint8Array(rewrappedKey));
}
}
return createDocAccess({

View File

@@ -34,7 +34,6 @@ export const DocShareInvitationItem = ({
full_name: invitation.email,
email: invitation.email,
short_name: invitation.email,
encryption_public_key: null,
language: 'en-us',
};

View File

@@ -144,8 +144,7 @@ export const QuickSearchGroupMember = ({
group={membersData}
renderElement={(access) => {
const hasMismatch = keyMismatchUserIds?.has(access.user.id);
const hasNoEncryptionKey =
doc.is_encrypted && !access.user.encryption_public_key;
const hasNoEncryptionKey = false;
let suffix: string | undefined;
if (hasMismatch) {
@@ -166,12 +165,7 @@ export const QuickSearchGroupMember = ({
? () => setMismatchUserId(access.user.id)
: undefined
}
fingerprintKey={
doc.is_encrypted
? (doc.accesses_public_keys_per_user?.[access.user.id] ??
access.user.encryption_public_key)
: undefined
}
fingerprintKey={undefined}
/>
);
}}

View File

@@ -23,6 +23,7 @@ import {
usePublicKeyRegistry,
useUserEncryption,
} from '@/docs/doc-collaboration';
import type { DocumentEncryptionSettings } from '@/docs/doc-collaboration/hook/useDocumentEncryption';
import type { PublicKeyMismatch } from '@/docs/doc-collaboration/hook/usePublicKeyRegistry';
import { Doc } from '@/docs/doc-management';
import { User, useAuth } from '@/features/auth';
@@ -63,9 +64,7 @@ const ShareModalStyle = createGlobalStyle`
type Props = {
doc: Doc;
documentEncryptionSettings?: {
documentSymmetricKey: CryptoKey;
} | null;
documentEncryptionSettings?: DocumentEncryptionSettings | null;
isRootDoc?: boolean;
onClose: () => void;
};
@@ -105,7 +104,7 @@ export const DocShareModal = ({
: null;
const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry(
doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
undefined,
user?.id,
);
const keyMismatchUserIds = useMemo(
@@ -468,7 +467,7 @@ const QuickSearchInviteInputSection = ({
const handleSelect = useCallback(
(user: User) => {
if (isEncrypted && !user.encryption_public_key) {
if (false) {
setShowNoKeyModal(true);
return;
}
@@ -489,7 +488,6 @@ const QuickSearchInviteInputSection = ({
full_name: '',
email: userQuery,
short_name: '',
encryption_public_key: null,
language: '',
};
@@ -518,7 +516,7 @@ const QuickSearchInviteInputSection = ({
if (keyMismatchUserIds?.has(user.id)) {
return t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
}
if (isEncrypted && !user.encryption_public_key) {
if (false) {
return t(`(encryption not enabled)`);
}
return undefined;
@@ -538,9 +536,7 @@ const QuickSearchInviteInputSection = ({
<DocShareModalInviteUserRow
user={user}
suffix={getUserSuffix(user)}
fingerprintKey={
isEncrypted ? user.encryption_public_key : undefined
}
fingerprintKey={undefined}
/>
)}
/>

View File

@@ -37,7 +37,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const shareModal = useModal();
const { user } = useAuth();
const { hasMismatches: hasKeyWarning } = usePublicKeyRegistry(
doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
undefined,
user?.id,
);
const isPublic = doc.link_reach === LinkReach.PUBLIC;