mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-25 17:15:01 +02:00
wip
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
44
src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/global.d.ts
vendored
Normal file
44
src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/global.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VaultClientProvider, useVaultClient } from './VaultClientProvider';
|
||||
export type { VaultClientContextValue } from './VaultClientProvider';
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 || {},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user