wip full settings flow for onboarding and removing encryption

This commit is contained in:
Thomas Ramé
2026-03-09 17:45:57 +01:00
parent 1da0f6600e
commit af1c40995b
19 changed files with 1288 additions and 148 deletions
@@ -0,0 +1,126 @@
import { useEffect, 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 { useAuth } from '../hooks';
import { gotoLogout } from '../utils';
import { ModalEncryptionOnboarding } from './ModalEncryptionOnboarding';
import { ModalEncryptionSettings } from './ModalEncryptionSettings';
export const AccountMenu = () => {
const { t } = useTranslation();
const { user } = useAuth();
const { encryptionSettings } = useUserEncryption();
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;
const encryptionOption: DropdownMenuOption = useMemo(() => {
if (hasEncryptionSetup) {
return {
label: t('Encryption settings'),
icon: hasMismatch ? (
<Icon iconName="warning" $size="20px" $theme="warning" />
) : (
'lock'
),
callback: () => setIsSettingsOpen(true),
showSeparator: true,
};
}
return {
label: t('Enable encryption'),
icon: 'lock_open',
callback: () => setIsOnboardingOpen(true),
showSeparator: true,
};
}, [hasEncryptionSetup, hasMismatch, t]);
const options: DropdownMenuOption[] = useMemo(
() => [
encryptionOption,
{
label: t('Logout'),
icon: 'logout',
callback: gotoLogout,
},
],
[encryptionOption, t],
);
return (
<>
<DropdownMenu
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;
border-radius: var(--c--globals--spacings--st);
padding: 0.5rem 0.6rem;
& > div {
gap: 0.2rem;
display: flex;
}
`}
>
<Box
$theme="brand"
$variation="tertiary"
$direction="row"
$gap="0.5rem"
$align="center"
>
{hasMismatch && (
<Icon iconName="warning" $size="16px" $theme="warning" />
)}
{t('My account')}
</Box>
</DropdownMenu>
<ModalEncryptionOnboarding
isOpen={isOnboardingOpen}
onClose={() => setIsOnboardingOpen(false)}
/>
<ModalEncryptionSettings
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
onRequestReOnboard={() => {
setIsSettingsOpen(false);
setIsOnboardingOpen(true);
}}
/>
</>
);
};
@@ -2,17 +2,16 @@ import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { BoxButton } from '@/components';
import ProConnectImg from '../assets/button-proconnect.svg';
import { useAuth } from '../hooks';
import { gotoLogin, gotoLogout } from '../utils';
import { gotoLogin } from '../utils';
import { AccountMenu } from './AccountMenu';
export const ButtonLogin = () => {
const { t } = useTranslation();
const { authenticated } = useAuth();
const { colorsTokens } = useCunninghamTheme();
if (!authenticated) {
return (
@@ -28,26 +27,7 @@ export const ButtonLogin = () => {
);
}
return (
<Box
$css={css`
.--docs--button-logout:focus-visible {
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']} !important;
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Button
onClick={gotoLogout}
color="brand"
variant="tertiary"
aria-label={t('Logout')}
className="--docs--button-logout"
>
{t('Logout')}
</Button>
</Box>
);
return <AccountMenu />;
};
export const ProConnectButton = () => {
@@ -0,0 +1,582 @@
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';
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';
interface ModalEncryptionOnboardingProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const ModalEncryptionOnboarding = ({
isOpen,
onClose,
onSuccess,
}: ModalEncryptionOnboardingProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { user } = useAuth();
const { refreshEncryption } = useUserEncryption();
const { mutateAsync: updateUser } = useUserUpdate();
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) {
return;
}
onClose();
};
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));
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 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');
}
};
return (
<Modal
isOpen={isOpen}
closeOnClickOutside={!isPending && step !== 'backup'}
hideCloseButton
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>
}
>
{getStepContent()}
</Modal>
);
};
@@ -0,0 +1,366 @@
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';
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';
interface ModalEncryptionSettingsProps {
isOpen: boolean;
onClose: () => void;
onRequestReOnboard: () => void;
}
export const ModalEncryptionSettings = ({
isOpen,
onClose,
onRequestReOnboard,
}: ModalEncryptionSettingsProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { user } = useAuth();
const { encryptionSettings, refreshEncryption } = useUserEncryption();
const { mutateAsync: updateUser } = useUserUpdate();
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);
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) {
return;
}
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;
}
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>
</>
);
};
return (
<Modal
isOpen={isOpen}
closeOnClickOutside={!isPending}
hideCloseButton
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>
}
>
{view === 'main' ? renderMain() : renderConfirmRemove()}
</Modal>
);
};
@@ -1,3 +1,6 @@
export * from './AccountMenu';
export * from './Auth';
export * from './ButtonLogin';
export * from './ModalEncryptionOnboarding';
export * from './ModalEncryptionSettings';
export * from './UserAvatar';
@@ -1,4 +1,4 @@
import { createContext, useContext } from 'react';
import { createContext, useCallback, useContext, useState } from 'react';
import { useAuth } from '@/features/auth';
@@ -12,12 +12,14 @@ interface UserEncryptionContextValue {
userPublicKey: CryptoKey;
} | null;
encryptionError: EncryptionError;
refreshEncryption: () => void;
}
const UserEncryptionContext = createContext<UserEncryptionContextValue>({
encryptionLoading: true,
encryptionSettings: null,
encryptionError: null,
refreshEncryption: () => {},
});
export const UserEncryptionProvider = ({
@@ -26,10 +28,17 @@ export const UserEncryptionProvider = ({
children: React.ReactNode;
}) => {
const { user } = useAuth();
const value = useEncryption(user?.id);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const encryptionValue = useEncryption(user?.id, refreshTrigger);
const refreshEncryption = useCallback(() => {
setRefreshTrigger((prev) => prev + 1);
}, []);
return (
<UserEncryptionContext.Provider value={value}>
<UserEncryptionContext.Provider
value={{ ...encryptionValue, refreshEncryption }}
>
{children}
</UserEncryptionContext.Provider>
);
@@ -0,0 +1,69 @@
import { userKeyPairAlgorithm } from './encryption';
export async function exportPrivateKeyAsJwk(
privateKey: CryptoKey,
): Promise<JsonWebKey> {
return await crypto.subtle.exportKey('jwk', privateKey);
}
export async function importPrivateKeyFromJwk(
jwk: JsonWebKey,
): Promise<CryptoKey> {
return await crypto.subtle.importKey(
'jwk',
jwk,
{ name: userKeyPairAlgorithm, hash: 'SHA-256' },
true,
['decrypt'],
);
}
export async function importPublicKeyFromJwk(
jwk: JsonWebKey,
): Promise<CryptoKey> {
return await crypto.subtle.importKey(
'jwk',
jwk,
{ name: userKeyPairAlgorithm, hash: 'SHA-256' },
true,
['encrypt'],
);
}
export async function exportPublicKeyAsBase64(
publicKey: CryptoKey,
): Promise<string> {
const rawPublicKey = await crypto.subtle.exportKey('spki', publicKey);
return Buffer.from(new Uint8Array(rawPublicKey)).toString('base64');
}
// Derive a public JWK from a private JWK by removing private fields.
export function derivePublicJwkFromPrivate(privateJwk: JsonWebKey): JsonWebKey {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { d, p, q, dp, dq, qi, ...publicJwk } = privateJwk;
return { ...publicJwk, key_ops: ['encrypt'] };
}
/**
* Serialize a JWK to a compact passphrase-like string.
* This is a base64url encoding of the full JWK JSON - not a mnemonic,
* but compact enough to be stored in a password manager.
*/
export function jwkToPassphrase(jwk: JsonWebKey): string {
const json = JSON.stringify(jwk);
const base64 = Buffer.from(json).toString('base64');
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* Deserialize a passphrase string back to a JWK.
*/
export function passphraseToJwk(passphrase: string): JsonWebKey {
const base64 = passphrase.replace(/-/g, '+').replace(/_/g, '/');
const json = Buffer.from(base64, 'base64').toString('utf-8');
return JSON.parse(json) as JsonWebKey;
}
@@ -1,5 +1,5 @@
const userKeyPairAlgorithm = 'RSA-OAEP';
const documentSymmetricKeyAlgorithm = 'AES-GCM';
export const userKeyPairAlgorithm = 'RSA-OAEP';
export const documentSymmetricKeyAlgorithm = 'AES-GCM';
export async function generateUserKeyPair(): Promise<CryptoKeyPair> {
return await crypto.subtle.generateKey(
@@ -79,6 +79,7 @@ export async function encryptContent(
const result = new Uint8Array(iv.length + ciphertext.byteLength);
result.set(iv);
result.set(new Uint8Array(ciphertext), iv.length);
return result;
}
@@ -113,6 +114,7 @@ export async function computeKeyFingerprint(
const hex = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hex
.slice(0, 16)
.replace(/(.{4})/g, '$1 ')
@@ -73,8 +73,8 @@ export function useDocumentEncryption(
if (!cancelled) {
setSettings({ documentSymmetricKey: symmetricKey });
}
} catch (err) {
console.error(err);
} catch (error) {
console.error(error);
if (!cancelled) {
setError('decryption_failed');
@@ -7,7 +7,10 @@ export type EncryptionError =
| 'missing_public_key'
| null;
export function useEncryption(userId?: string): {
export function useEncryption(
userId?: string,
refreshTrigger?: number,
): {
encryptionLoading: boolean;
encryptionSettings: {
userId: string;
@@ -83,8 +86,8 @@ export function useEncryption(userId?: string): {
userPublicKey: userPublicKey,
});
}
} catch (err) {
console.error(err);
} catch (error) {
console.error(error);
if (!cancelled) {
setSettings(null);
@@ -101,7 +104,7 @@ export function useEncryption(userId?: string): {
return () => {
cancelled = true;
};
}, [userId, enableEncryption]);
}, [userId, enableEncryption, refreshTrigger]);
return {
encryptionLoading: loading,
@@ -1,9 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import {
STORE_KNOWN_PUBLIC_KEYS,
getEncryptionDB,
} from '../encryptionDB';
import { STORE_KNOWN_PUBLIC_KEYS, getEncryptionDB } from '../encryptionDB';
export interface PublicKeyMismatch {
userId: string;
@@ -11,6 +8,13 @@ export interface PublicKeyMismatch {
currentKey: string;
}
// module-level listener set to keep all hook instances in sync
const registryListeners = new Set<() => void>();
function notifyRegistryUpdated() {
registryListeners.forEach((fn) => fn());
}
/**
* TOFU (Trust On First Use) public key registry.
*
@@ -19,6 +23,8 @@ export interface PublicKeyMismatch {
* flagged as a mismatch.
* - The caller can accept a new key via `acceptNewKey(userId)`, which updates
* the locally stored key.
*
* All instances stay in sync via a module-level listener set.
*/
export function usePublicKeyRegistry(
accessesPublicKeysPerUser: Record<string, string> | undefined,
@@ -26,6 +32,18 @@ export function usePublicKeyRegistry(
) {
const [mismatches, setMismatches] = useState<PublicKeyMismatch[]>([]);
const [loading, setLoading] = useState(true);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// listen for updates from other hook instances
useEffect(() => {
const handler = () => setRefreshTrigger((prev) => prev + 1);
registryListeners.add(handler);
return () => {
registryListeners.delete(handler);
};
}, []);
useEffect(() => {
if (!accessesPublicKeysPerUser) {
@@ -67,8 +85,8 @@ export function usePublicKeyRegistry(
if (!cancelled) {
setMismatches(newMismatches);
}
} catch (err) {
console.error('usePublicKeyRegistry: failed to check keys', err);
} catch (error) {
console.error('usePublicKeyRegistry: failed to check keys', error);
} finally {
if (!cancelled) {
setLoading(false);
@@ -82,7 +100,7 @@ export function usePublicKeyRegistry(
return () => {
cancelled = true;
};
}, [accessesPublicKeysPerUser, currentUserId]);
}, [accessesPublicKeysPerUser, currentUserId, refreshTrigger]);
const acceptNewKey = useCallback(
async (userId: string) => {
@@ -99,6 +117,9 @@ export function usePublicKeyRegistry(
);
setMismatches((prev) => prev.filter((m) => m.userId !== userId));
// notify other instances to re-check
notifyRegistryUpdated();
},
[mismatches],
);
@@ -12,13 +12,19 @@ export {
useDocumentEncryption,
type DocumentEncryptionError,
} from './hook/useDocumentEncryption';
export {
useEncryption,
type EncryptionError,
} from './hook/useEncryption';
export { useEncryption, type EncryptionError } from './hook/useEncryption';
export {
UserEncryptionProvider,
useUserEncryption,
} from './UserEncryptionProvider';
export { useKeyFingerprint } from './hook/useKeyFingerprint';
export { usePublicKeyRegistry } from './hook/usePublicKeyRegistry';
export {
exportPrivateKeyAsJwk,
importPrivateKeyFromJwk,
importPublicKeyFromJwk,
exportPublicKeyAsBase64,
derivePublicJwkFromPrivate,
jwkToPassphrase,
passphraseToJwk,
} from './encryption-backup';
@@ -77,7 +77,7 @@ export const DocToolBox = ({
const modalShare = useModal();
const { hasMismatches: hasKeyWarnings } = usePublicKeyRegistry(
doc.accesses_public_keys_per_user,
doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
encryptionSettings?.userId,
);
@@ -1,30 +1,23 @@
import {
Alert,
Button,
Loader,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { backendUrl } from '@/api';
import { useState } from 'react';
import { Box, ButtonCloseModal, Icon, Text, TextErrors } from '@/components';
import { useUserUpdate } from '@/core/api/useUserUpdate';
import {
encryptContent,
generateSymmetricKey,
generateUserKeyPair,
getEncryptionDB,
prepareEncryptedSymmetricKeysForUsers,
useUserEncryption,
} from '@/docs/doc-collaboration';
import { createDocAttachment } from '@/docs/doc-editor/api';
import { toBase64 } from '@/docs/doc-editor';
import { useAuth } from '@/features/auth';
import {
Doc,
@@ -133,7 +126,6 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
useProviderStore();
const { user } = useAuth();
const { encryptionSettings } = useUserEncryption();
const { mutateAsync: updateUser } = useUserUpdate();
const [isPending, setIsPending] = useState(false);
@@ -170,8 +162,13 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
);
}, [accesses, doc.accesses_public_keys_per_user]);
const hasEncryptionKeys = !!encryptionSettings;
const canEncrypt =
isRestricted && !hasPendingInvitations && membersWithoutKey.length === 0;
hasEncryptionKeys &&
isRestricted &&
!hasPendingInvitations &&
membersWithoutKey.length === 0;
const handleClose = () => {
if (isPending) {
@@ -181,57 +178,13 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
};
const handleEncrypt = async () => {
if (!provider || !user || isPending || !canEncrypt) {
if (!provider || !user || isPending || !canEncrypt || !encryptionSettings) {
return;
}
setIsPending(true);
try {
let currentUserPublicKeyFromThisOnboardingSession: ArrayBuffer | null =
null;
// Perform the onboarding if that's the first time using encryption on this device
if (!encryptionSettings) {
// TODO: trigger the onboarding, either by creating or retrieving a key from another device
// TODO: probably the logic should be at a device key level, not user one?
const userKeyPair = await generateUserKeyPair();
const encryptionDatabase = await getEncryptionDB();
// TODO: it should use transaction
// encryptionDatabase.transaction
await encryptionDatabase.put(
'privateKey',
userKeyPair.privateKey,
`user:${user.id}`,
);
await encryptionDatabase.put(
'publicKey',
userKeyPair.publicKey,
`user:${user.id}`,
);
const rawPublicKey = await crypto.subtle.exportKey(
'spki',
userKeyPair.publicKey,
);
// TODO: it should throw if the backend has already a public key (so the user can with concious forget the old one (but here he did the onboarding already so... it was probably a new device))
await updateUser({
id: user.id,
encryption_public_key: toBase64(new Uint8Array(rawPublicKey)),
});
currentUserPublicKeyFromThisOnboardingSession = rawPublicKey;
// TODO: should check encryptionSettings will update, otherwise hard refresh is needed
window.location.reload();
return;
}
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED);
const documentSymmetricKey = await generateSymmetricKey();
@@ -249,13 +202,6 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
)) {
usersPublicKeys[userId] = Buffer.from(publicKey, 'base64').buffer;
}
// if the onboarding has been done directly in this encryption flow, the backend has not yet told the frontend
// about the current user key, so just patching the mapping with this new public key
if (currentUserPublicKeyFromThisOnboardingSession) {
usersPublicKeys[user.id] =
currentUserPublicKeyFromThisOnboardingSession;
}
} else {
// if it has been not provided it's weird because it should only happen for people not authenticated
throw new Error(`"accesses_public_keys_per_user" should be provided`);
@@ -391,23 +337,51 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
<Box className="--docs--modal-encrypt-doc" $gap="sm">
{!isError && (
<Box $gap="sm">
<Text $size="sm" $variation="secondary">
{t(
'Encrypting a document ensures that only authorized members can read its content. Before proceeding, the following conditions must be met:',
)}
</Text>
<Alert type={VariantType.WARNING}>
<Box $gap="xs">
<Text $size="sm">
{t(
'Encrypting a document ensures that only authorized members can read its content. Keep in mind before proceeding any access will then require its user to do the encryption onboarding, with the complication of ensuring keys backups.',
)}
</Text>
</Box>
</Alert>
{/* TODO: warning about encryption */}
{/* TODO: if no public key for current user, provide an onboarding */}
<Text $size="sm" $variation="secondary">
{t('Here the conditions that must be met:')}
</Text>
<Box $gap="xs">
<Box $direction="row" $align="center" $gap="xs">
<Icon
iconName={hasEncryptionKeys ? 'check_circle' : 'cancel'}
$size="sm"
$theme={hasEncryptionKeys ? 'success' : 'error'}
/>
<Text
$size="sm"
$weight={hasEncryptionKeys ? '400' : '600'}
$theme={hasEncryptionKeys ? undefined : 'error'}
>
{hasEncryptionKeys
? t('Encryption is enabled on your account')
: t(
'You must enable encryption from your account menu first',
)}
</Text>
</Box>
<Box $direction="row" $align="center" $gap="xs">
<Icon
iconName={isRestricted ? 'check_circle' : 'cancel'}
$size="sm"
$theme={isRestricted ? 'success' : 'danger'}
$theme={isRestricted ? 'success' : 'error'}
/>
<Text $size="sm" $weight={isRestricted ? '400' : '600'}>
<Text
$size="sm"
$weight={isRestricted ? '400' : '600'}
$theme={isRestricted ? undefined : 'error'}
>
{isRestricted
? t('Document access is private')
: t(
@@ -426,11 +400,12 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
<Icon
iconName={!hasPendingInvitations ? 'check_circle' : 'cancel'}
$size="sm"
$theme={!hasPendingInvitations ? 'success' : 'danger'}
$theme={!hasPendingInvitations ? 'success' : 'error'}
/>
<Text
$size="sm"
$weight={!hasPendingInvitations ? '400' : '600'}
$theme={!hasPendingInvitations ? undefined : 'error'}
>
{!hasPendingInvitations
? t('No pending invitations')
@@ -446,12 +421,15 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
}
$size="sm"
$theme={
membersWithoutKey.length === 0 ? 'success' : 'danger'
membersWithoutKey.length === 0 ? 'success' : 'error'
}
/>
<Text
$size="sm"
$weight={membersWithoutKey.length === 0 ? '400' : '600'}
$theme={
membersWithoutKey.length === 0 ? undefined : 'error'
}
>
{membersWithoutKey.length === 0
? t('All members have encryption enabled')
@@ -472,14 +450,6 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
)}
</Box>
</Box>
{!canEncrypt && (
<Text $size="xs" $variation="secondary">
{t(
'Please resolve the issues above before encrypting the document.',
)}
</Text>
)}
</Box>
)}
@@ -10,7 +10,6 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { backendUrl } from '@/api';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { decryptContent } from '@/docs/doc-collaboration';
import { createDocAttachment } from '@/docs/doc-editor/api';
@@ -254,16 +253,12 @@ export const ModalRemoveDocEncryption = ({
</Box>
}
>
<Box className="--docs--modal-remove-doc-encryption">
<Box className="--docs--modal-remove-doc-encryption" $gap="sm">
{!isError && (
<Text
$size="sm"
$variation="secondary"
$display="inline-block"
as="p"
>
<br />
TODO: warning about removing encryption
<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>
)}
@@ -144,15 +144,23 @@ export const QuickSearchGroupMember = ({
group={membersData}
renderElement={(access) => {
const hasMismatch = keyMismatchUserIds?.has(access.user.id);
const hasNoEncryptionKey =
doc.is_encrypted && !access.user.encryption_public_key;
let suffix: string | undefined;
if (hasMismatch) {
suffix = t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
} else if (hasNoEncryptionKey) {
suffix = t(
'ENCRYPTION DISABLED - consider removing this member since unable to read the document',
);
}
return (
<DocShareMemberItem
doc={doc}
access={access}
suffix={
hasMismatch
? t('DIFFERENT PUBLIC KEY, PLEASE VERIFY')
: undefined
}
suffix={suffix}
onSuffixClick={
hasMismatch
? () => setMismatchUserId(access.user.id)
@@ -105,7 +105,7 @@ export const DocShareModal = ({
: null;
const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry(
doc.accesses_public_keys_per_user,
doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
user?.id,
);
const keyMismatchUserIds = useMemo(
@@ -283,7 +283,7 @@ export const DocShareModal = ({
encryptionError === 'missing_public_key') && (
<Text $variation="secondary" $textAlign="center" $size="sm">
{t(
'This usually happens when you switch to a new device or browser without restoring your encryption backup.',
'This usually happens when you switch to a new device or browser without restoring your encryption backup, please go to your "Encryption Settings" to fix it.',
)}
</Text>
)}
@@ -37,7 +37,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const shareModal = useModal();
const { user } = useAuth();
const { hasMismatches: hasKeyWarning } = usePublicKeyRegistry(
doc.accesses_public_keys_per_user,
doc.is_encrypted ? doc.accesses_public_keys_per_user : undefined,
user?.id,
);
const isPublic = doc.link_reach === LinkReach.PUBLIC;
@@ -306,7 +306,7 @@ const DocPage = ({ id }: DocProps) => {
encryptionError === 'missing_public_key') && (
<Text $variation="secondary" $textAlign="center">
{t(
'This usually happens when you switch to a new device or browser without restoring your encryption backup.',
'This usually happens when you switch to a new device or browser without restoring your encryption backup, please go to your "Encryption Settings" to fix it.',
)}
</Text>
)}