wip pending encryption

This commit is contained in:
Thomas Ramé
2026-04-22 15:45:30 +02:00
parent 21e2658f61
commit 5e651190fd
13 changed files with 833 additions and 38 deletions

View File

@@ -77,6 +77,9 @@ class ListDocumentSerializer(serializers.ModelSerializer):
encrypted_document_symmetric_key_for_user = serializers.SerializerMethodField(
read_only=True
)
is_pending_encryption_for_user = serializers.SerializerMethodField(
read_only=True
)
class Meta:
model = models.Document
@@ -97,6 +100,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"excerpt",
"is_favorite",
"is_encrypted",
"is_pending_encryption_for_user",
"link_role",
"link_reach",
"nb_accesses_ancestors",
@@ -123,6 +127,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"excerpt",
"is_favorite",
"is_encrypted",
"is_pending_encryption_for_user",
"link_role",
"link_reach",
"nb_accesses_ancestors",
@@ -197,6 +202,27 @@ class ListDocumentSerializer(serializers.ModelSerializer):
except models.DocumentAccess.DoesNotExist:
return None
def get_is_pending_encryption_for_user(self, instance):
"""True when the current user has a DocumentAccess row on this
encrypted document with no wrapped key — i.e. they were added
to the access list but haven't completed their encryption
onboarding yet.
Clients use this to avoid attempting to decrypt (which would
fail with a meaningless key error) and render a "waiting for
acceptance" panel directly instead.
"""
if not instance.is_encrypted:
return False
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return False
return models.DocumentAccess.objects.filter(
document=instance,
user=request.user,
encrypted_document_symmetric_key_for_user__isnull=True,
).exists()
class DocumentLightSerializer(serializers.ModelSerializer):
"""Minial document serializer for nesting in document accesses."""
@@ -239,6 +265,7 @@ class DocumentSerializer(ListDocumentSerializer):
"file",
"is_favorite",
"is_encrypted",
"is_pending_encryption_for_user",
"link_role",
"link_reach",
"nb_accesses_ancestors",
@@ -264,6 +291,7 @@ class DocumentSerializer(ListDocumentSerializer):
"encrypted_document_symmetric_key_for_user",
"is_favorite",
"is_encrypted",
"is_pending_encryption_for_user",
"link_role",
"link_reach",
"nb_accesses_ancestors",
@@ -413,9 +441,11 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
encrypted_document_symmetric_key_for_user = serializers.CharField(
required=False, allow_blank=True, write_only=True
)
# TODO: REQUIRED!!!
encryption_public_key_fingerprint = serializers.CharField(
required=False, allow_blank=True, max_length=16
)
is_pending_encryption = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.DocumentAccess
@@ -432,6 +462,7 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
"max_role",
"encrypted_document_symmetric_key_for_user",
"encryption_public_key_fingerprint",
"is_pending_encryption",
]
read_only_fields = [
"id",
@@ -439,10 +470,30 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
"abilities",
"max_ancestors_role",
"max_role",
"is_pending_encryption",
]
def get_is_pending_encryption(self, instance):
"""True when the parent document is encrypted but this access has
no wrapped key — the user was added before completing their
encryption onboarding. A validated collaborator must "accept"
them (re-wrap the key) before they can decrypt.
"""
document = instance.document
return bool(
getattr(document, "is_encrypted", False)
and instance.encrypted_document_symmetric_key_for_user is None
)
def get_fields(self):
"""Dynamically control field availability and requirements based on document encryption status."""
"""Dynamically adjust encryption fields based on document state.
For encrypted documents the key is OPTIONAL at serializer level:
the viewset decides whether omitting it is legitimate (invitee
has no public key yet → access created pending) or a 400 (field
provided against a non-encrypted document). For non-encrypted
documents the field is hidden entirely.
"""
fields = super().get_fields()
# Get the document from context (if available)
@@ -450,16 +501,11 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
if "view" in self.context and hasattr(self.context["view"], "document"):
document = self.context["view"].document
# Get the encrypted_document_symmetric_key_for_user field
key_field = fields.get("encrypted_document_symmetric_key_for_user")
if key_field:
# If document is encrypted, make the field required
if document and getattr(document, "is_encrypted", False):
key_field.required = True
key_field.allow_blank = False
# If document is not encrypted, remove the field entirely
elif document and not getattr(document, "is_encrypted", False):
if (
document
and not getattr(document, "is_encrypted", False)
and "encrypted_document_symmetric_key_for_user" in fields
):
fields.pop("encrypted_document_symmetric_key_for_user", None)
return fields
@@ -993,7 +1039,44 @@ class EncryptDocumentSerializer(serializers.Serializer):
"""
content = serializers.CharField(required=True)
encryptedSymmetricKeyPerUser = serializers.DictField(child=serializers.CharField(), required=True)
# Value is either a base64 wrapped key (validated user) or explicit
# null (user is on the access list but has no public key yet — access
# row is created pending, to be "accepted" later by another validated
# collaborator via PATCH /accesses/{id}/encryption-key/).
encryptedSymmetricKeyPerUser = serializers.DictField(
child=serializers.CharField(allow_null=True),
required=True,
help_text=(
"Mapping of user OIDC sub → wrapped symmetric key (base64), "
"or null to mark the user as pending their encryption "
"onboarding. The caller's own sub must always be a wrapped "
"key, never null."
),
)
# Required: matched to the wrapped-key map. Every user sub present
# in `encryptedSymmetricKeyPerUser` must also appear here with the
# fingerprint of the public key used to wrap their copy (or null
# for pending users with no public key yet). Stored on the access
# row verbatim so clients can later tell which key each user's
# wrapped key was produced for — used by the key-mismatch panel
# to display "Fingerprint at the time it was shared with you".
#
# Not security-sensitive in the crypto sense — the actual wrap is
# the wrapped key itself. The fingerprint is a display hint; a
# malicious client could send wrong values but the worst it
# achieves is confusing the user whose client was lying.
encryptionPublicKeyFingerprintPerUser = serializers.DictField(
child=serializers.CharField(
allow_null=True, allow_blank=True, max_length=16
),
required=True,
help_text=(
"Mapping of user OIDC sub → fingerprint of their public key "
"at encryption time. Must cover the same set of users as "
"`encryptedSymmetricKeyPerUser`; null is valid for pending "
"users."
),
)
attachmentKeyMapping = serializers.DictField(
child=serializers.CharField(),
required=False,
@@ -1004,6 +1087,30 @@ class EncryptDocumentSerializer(serializers.Serializer):
)
# pylint: disable=abstract-method
class AcceptEncryptionAccessSerializer(serializers.Serializer):
"""Payload for PATCH /accesses/{id}/encryption-key/ — "accept" a
pending collaborator by re-wrapping the document's symmetric key
against their (now-available) public key.
"""
encrypted_document_symmetric_key_for_user = serializers.CharField(
required=True,
allow_null=False,
allow_blank=False,
help_text=(
"Wrapped symmetric key for the pending user, base64-encoded. "
"Null / empty is not allowed: this endpoint only flips "
"pending → validated. To revert, delete the access row."
),
)
encryption_public_key_fingerprint = serializers.CharField(
required=True,
allow_blank=False,
max_length=16,
)
class RemoveEncryptionSerializer(serializers.Serializer):
"""
Serializer for removing encryption from a document.

View File

@@ -2081,7 +2081,8 @@ class DocumentViewSet(
# Validate that we have encrypted symmetric keys for all users with access.
# Keys in encryptedSymmetricKeyPerUser are keyed by the user's OIDC sub (suite_user_id).
# The frontend already checked via fetchPublicKeys that all members have encryption enabled.
# Values may be a wrapped key (validated) or explicit null (pending —
# user hasn't completed their encryption onboarding yet).
document_accesses = models.DocumentAccess.objects.filter(
document=document, user__isnull=False
).select_related('user')
@@ -2096,7 +2097,7 @@ class DocumentViewSet(
raise drf.exceptions.ValidationError({
'encryptedSymmetricKeyPerUser':
f'Missing encrypted keys for users with document access: {missing_users}. '
f'All users must have encrypted symmetric keys when encrypting a document.'
f'All users must have an entry (either a wrapped key or null) when encrypting.'
})
# Check for extra users that don't have access
@@ -2108,6 +2109,35 @@ class DocumentViewSet(
f'Only users with access should have encrypted symmetric keys.'
})
# The caller is the one performing the encryption — they must
# hold the key. Explicit null for themselves is never legitimate.
caller_sub = str(request.user.sub)
if (
caller_sub in encryptedSymmetricKeyPerUser
and encryptedSymmetricKeyPerUser[caller_sub] is None
):
raise drf.exceptions.ValidationError({
'encryptedSymmetricKeyPerUser':
'You cannot mark yourself as pending encryption onboarding — '
'provide a wrapped key for your own user.'
})
# Per-user fingerprint map — required, keyed on the same user
# subs as the wrapped-key map. Stored verbatim on the access
# row so clients can later tell which key each user's wrapped
# key was produced for.
fingerprint_per_user = serializer.validated_data[
'encryptionPublicKeyFingerprintPerUser'
]
fingerprint_subs = set(fingerprint_per_user.keys())
if fingerprint_subs != provided_user_ids:
raise drf.exceptions.ValidationError({
'encryptionPublicKeyFingerprintPerUser':
'Must cover the same set of users as encryptedSymmetricKeyPerUser. '
f'Missing: {provided_user_ids - fingerprint_subs}. '
f'Extra: {fingerprint_subs - provided_user_ids}.'
})
# Remove old unencrypted attachment keys from the allowed list.
# The frontend uploaded encrypted copies under new keys and updated the
# Yjs content to reference them.
@@ -2136,13 +2166,18 @@ class DocumentViewSet(
transaction.on_commit(_cleanup_old_attachments)
# Store the encrypted symmetric keys in DocumentAccess for each user
# Keys are keyed by the user's OIDC `sub`, so look up by user__sub
# Store the encrypted symmetric keys + fingerprints in
# DocumentAccess for each user. Keys are keyed by the user's
# OIDC `sub`, so look up by user__sub.
for sub, encrypted_key in encryptedSymmetricKeyPerUser.items():
try:
# Find the DocumentAccess record for this user and document
access = models.DocumentAccess.objects.get(document=document, user__sub=sub)
access = models.DocumentAccess.objects.get(
document=document, user__sub=sub,
)
access.encrypted_document_symmetric_key_for_user = encrypted_key
access.encryption_public_key_fingerprint = (
fingerprint_per_user.get(sub) or None
)
access.save()
except models.DocumentAccess.DoesNotExist:
# This should not happen due to our validation above, but keep as safety
@@ -2371,15 +2406,30 @@ class DocumentAccessViewSet(
"Only owners of a document can assign other users as owners."
)
# Handle encrypted_document_symmetric_key_for_user during creation
# Handle encrypted_document_symmetric_key_for_user during
# creation. For encrypted documents the key is OPTIONAL: if the
# invitee has no public key yet (pending onboarding) the caller
# legitimately has nothing to wrap. The access row is then
# created pending (key column NULL) and can be "accepted" later
# via PATCH /accesses/{id}/encryption-key/. Whether the invitee
# actually has a public key is a client-side concern — the
# backend only enforces "key provided ⇒ document must be encrypted".
if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data:
if not self.document.is_encrypted:
key_value = serializer.validated_data[
'encrypted_document_symmetric_key_for_user'
]
if key_value and not self.document.is_encrypted:
raise drf.exceptions.ValidationError({
'encrypted_document_symmetric_key_for_user':
'This field can only be provided when the document is encrypted.'
})
# For encrypted documents, allow the key to be provided
# The key will be stored directly in the DocumentAccess record
# Normalise "" → None so the DB row uses NULL consistently
# and `is_pending_encryption` (which tests IS NULL) is
# reliable downstream.
if not key_value:
serializer.validated_data[
'encrypted_document_symmetric_key_for_user'
] = None
access = serializer.save(document_id=self.kwargs["resource_id"])
@@ -2416,6 +2466,13 @@ class DocumentAccessViewSet(
def perform_destroy(self, instance):
"""Delete an access to the document and notify the collaboration server."""
# Strand-prevention: on an encrypted document, removing the last
# access row that holds a wrapped key while other rows are
# pending (`encrypted_document_symmetric_key_for_user IS NULL`)
# would leave the document undecryptable by anyone — nobody
# could "accept" the pending users afterwards.
self._raise_if_would_strand_pending_users(instance)
instance.delete()
# Notify collaboration server about the access removed
@@ -2423,6 +2480,123 @@ class DocumentAccessViewSet(
str(instance.document.id), str(instance.user.id)
)
def _raise_if_would_strand_pending_users(self, instance):
"""Reject delete if it would leave pending users with nobody
able to accept them. See the docstring in `perform_destroy`.
"""
document = instance.document
if not getattr(document, "is_encrypted", False):
return
# Removing a row that's itself pending never strands anyone.
if not instance.encrypted_document_symmetric_key_for_user:
return
other_accesses = models.DocumentAccess.objects.filter(
document=document
).exclude(pk=instance.pk)
remaining_validated = (
other_accesses.filter(
encrypted_document_symmetric_key_for_user__isnull=False,
)
.exclude(encrypted_document_symmetric_key_for_user="")
.exists()
)
has_pending = other_accesses.filter(
encrypted_document_symmetric_key_for_user__isnull=True,
).exists()
if has_pending and not remaining_validated:
raise drf.exceptions.ValidationError({
"detail": (
"Removing this user would leave pending collaborators "
"unable to decrypt the document. Either wait for them "
"to finish their encryption onboarding, or remove "
"encryption from the document first."
),
"code": "would_strand_pending_users",
})
@drf.decorators.action(
detail=True, methods=["patch"], url_path="encryption-key"
)
def encryption_key(self, request, *args, **kwargs):
"""Accept a pending collaborator by re-wrapping the document's
symmetric key against their public key.
Strictly pending → validated. To revoke a user, delete the access
row instead. The viewset-level permission already enforces that
the caller is a privileged user on the document (admin/owner);
here we additionally require the caller to currently hold a
wrapped key themselves — without that they have no plaintext
subtree key to re-wrap from.
"""
access = self.get_object()
document = access.document
if not getattr(document, "is_encrypted", False):
return drf.response.Response(
{"detail": "Document is not encrypted."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
if access.encrypted_document_symmetric_key_for_user:
return drf.response.Response(
{
"detail": (
"This access is not pending encryption onboarding. "
"Delete the access row instead if you want to "
"revoke it."
),
"code": "access_not_pending",
},
status=drf.status.HTTP_400_BAD_REQUEST,
)
caller_has_key = models.DocumentAccess.objects.filter(
document=document,
user=request.user,
encrypted_document_symmetric_key_for_user__isnull=False,
).exclude(encrypted_document_symmetric_key_for_user="").exists()
if not caller_has_key:
return drf.response.Response(
{
"detail": (
"You do not currently hold a decryption key for "
"this document, so you cannot accept another "
"user on it."
),
},
status=drf.status.HTTP_403_FORBIDDEN,
)
serializer = serializers.AcceptEncryptionAccessSerializer(
data=request.data
)
serializer.is_valid(raise_exception=True)
access.encrypted_document_symmetric_key_for_user = (
serializer.validated_data[
"encrypted_document_symmetric_key_for_user"
]
)
access.encryption_public_key_fingerprint = (
serializer.validated_data["encryption_public_key_fingerprint"]
)
access.save(
update_fields=[
"encrypted_document_symmetric_key_for_user",
"encryption_public_key_fingerprint",
]
)
CollaborationService().reset_connections(
str(document.id),
str(access.user.id) if access.user else None,
)
output = self.get_serializer(access)
return drf.response.Response(output.data)
class InvitationViewset(
drf.mixins.CreateModelMixin,

View File

@@ -1229,12 +1229,21 @@ class DocumentAccess(BaseAccess):
if len(set_role_to) == 1:
set_role_to = []
# "encryption_key" gates the PATCH
# /accesses/{id}/encryption-key/ Accept endpoint. The viewset
# additionally enforces that the caller holds a wrapped key on
# the document (otherwise they have nothing to re-wrap), so at
# this layer the rule just mirrors "can manage accesses on
# this document" — same privileged-role check as update, minus
# the role-change prerequisites which aren't relevant when
# re-wrapping a key.
return {
"destroy": can_delete,
"update": bool(set_role_to) and is_owner_or_admin,
"partial_update": bool(set_role_to) and is_owner_or_admin,
"retrieve": (self.user and self.user.id == user.id) or is_owner_or_admin,
"set_role_to": set_role_to,
"encryption_key": is_owner_or_admin,
}

View File

@@ -12,6 +12,7 @@ export class EncryptedWebSocket extends WebSocket {
protected readonly vaultClient!: VaultClient;
protected readonly encryptedSymmetricKey!: ArrayBuffer;
protected readonly onSystemMessage?: (message: string) => void;
protected readonly onDecryptError?: (err: unknown) => void;
constructor(address: string | URL, protocols?: string | string[]) {
super(address, protocols);
@@ -61,6 +62,7 @@ export class EncryptedWebSocket extends WebSocket {
}
} catch (err) {
console.error('WebSocket decrypt error:', err);
this.onDecryptError?.(err);
}
};
@@ -116,10 +118,12 @@ export function createAdaptedEncryptedWebsocketClass(options: {
vaultClient: VaultClient;
encryptedSymmetricKey: ArrayBuffer;
onSystemMessage?: (message: string) => void;
onDecryptError?: (err: unknown) => void;
}) {
return class extends EncryptedWebSocket {
protected readonly vaultClient = options.vaultClient;
protected readonly encryptedSymmetricKey = options.encryptedSymmetricKey;
protected readonly onSystemMessage = options.onSystemMessage;
protected readonly onDecryptError = options.onDecryptError;
};
}

View File

@@ -10,8 +10,8 @@ import { toBase64 } from '@/features/docs/doc-editor';
interface EncryptDocProps {
docId: string;
content: Uint8Array<ArrayBufferLike>;
/** Per-user encrypted symmetric keys as base64 strings (from VaultClient.encryptWithoutKey) */
encryptedSymmetricKeyPerUser: Record<string, string>;
encryptedSymmetricKeyPerUser: Record<string, string | null>;
encryptionPublicKeyFingerprintPerUser: Record<string, string | null>;
attachmentKeyMapping?: Record<string, string>;
}
@@ -24,6 +24,8 @@ export const encryptDoc = async ({
body: JSON.stringify({
content: toBase64(params.content),
encryptedSymmetricKeyPerUser: params.encryptedSymmetricKeyPerUser,
encryptionPublicKeyFingerprintPerUser:
params.encryptionPublicKeyFingerprintPerUser,
attachmentKeyMapping: params.attachmentKeyMapping || {},
}),
});

View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
import { useAuth } from '@/features/auth';
import type { Doc } from '../types';
/**
* "Wrong secret key for the given ciphertext" from the vault means the
* document was encrypted against a PREVIOUS public key of the current
* user (reset, different device without backup restore, etc.). The
* ciphertext itself is still valid for whoever holds the old key, but
* this user's current key can't unwrap it. The fix is social: someone
* with access has to re-share the doc so the symmetric key gets
* wrapped against their CURRENT public key.
*/
export const isWrongSecretKeyError = (
err: Error | null | undefined,
): boolean => {
if (!err) return false;
const msg = err.message?.toLowerCase() ?? '';
return msg.includes('wrong secret key');
};
interface Props {
/**
* The doc — used to read the share-time fingerprint from
* `doc.accesses_fingerprints_per_user[currentUser.suite_user_id]`.
* Docs exposes this as a per-user map on the document. (We may
* later collapse it to a single `encryption_public_key_fingerprint_for_user`
* scalar once we take the same "current user only" approach Drive
* does, but keeping it as a map for now matches the existing API.)
*/
doc: Doc;
}
/**
* Friendly panel shown when the page / websocket surfaces a "wrong
* secret key" decryption failure. Explains the key rotation, shows the
* share-time fingerprint (from the doc's fingerprint map) AND the
* user's current key fingerprint so whoever re-shares can verify
* they're wrapping against the key the user actually holds now.
*/
export const KeyMismatchPanel = ({ doc }: Props) => {
const { t } = useTranslation();
const { user } = useAuth();
const { client: vaultClient } = useVaultClient();
const [currentFingerprint, setCurrentFingerprint] = useState<string | null>(
null,
);
const shareTimeFingerprint = user?.suite_user_id
? (doc.accesses_fingerprints_per_user?.[user.suite_user_id] ?? null)
: null;
useEffect(() => {
if (!vaultClient) return;
let cancelled = false;
(async () => {
try {
const { publicKey } = await vaultClient.getPublicKey();
const raw = await vaultClient.computeKeyFingerprint(publicKey);
const formatted = vaultClient.formatFingerprint(raw);
if (!cancelled) setCurrentFingerprint(formatted);
} catch {
// Ignore — we just won't render the fingerprint row.
}
})();
return () => {
cancelled = true;
};
}, [vaultClient]);
const formattedShareTime = (() => {
if (!shareTimeFingerprint) return null;
if (!vaultClient) return shareTimeFingerprint;
try {
return vaultClient.formatFingerprint(shareTimeFingerprint);
} catch {
return shareTimeFingerprint;
}
})();
return (
<Box $align="center" $margin="auto" $gap="md" $padding="2rem">
<Icon iconName="key_off" $size="3rem" $theme="warning" />
<Text as="h2" $textAlign="center" $margin="0">
{t('This document was encrypted with a different key')}
</Text>
<Box $maxWidth="500px" $gap="sm">
<Text $variation="secondary" $textAlign="center">
{t(
"The document was encrypted for you at a time when you were using a different encryption key — possibly before you reset your keys or switched device without restoring a backup. Your current key can no longer decrypt it. Ask an owner or administrator of this document to remove you from the access list and add you back so it gets re-encrypted for your current key.",
)}
</Text>
</Box>
{(formattedShareTime || currentFingerprint) && (
<Box $gap="2xs" $maxWidth="500px" $align="center">
{formattedShareTime && (
<Text $variation="secondary" $size="sm" $textAlign="center">
{t('Fingerprint at the time it was shared with you:')}{' '}
<Text
as="span"
$css={`
font-family: monospace;
background: var(--c--theme--colors--greyscale-100, #f4f4f5);
padding: 2px 6px;
border-radius: 3px;
`}
>
{formattedShareTime}
</Text>
</Text>
)}
{currentFingerprint && (
<Text $variation="secondary" $size="sm" $textAlign="center">
{t('Your current key fingerprint:')}{' '}
<Text
as="span"
$css={`
font-family: monospace;
background: var(--c--theme--colors--greyscale-100, #f4f4f5);
padding: 2px 6px;
border-radius: 3px;
`}
>
{currentFingerprint}
</Text>
</Text>
)}
</Box>
)}
</Box>
);
};

View File

@@ -183,11 +183,24 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
const hasEncryptionKeys = !!encryptionSettings;
// Members with no public key will be written to the backend as
// pending (`null` wrapped key). They'll see the document in their
// listings but won't be able to decrypt until a validated collaborator
// accepts them from the share dialog. This no longer blocks
// encryption — only the degenerate case where NOBODY has a key does.
const hasAnyPublicKey =
accesses === undefined || accesses.length === 0
? true
: accesses.some(
(a) =>
a.user?.suite_user_id && !!publicKeysMap[a.user.suite_user_id],
);
const canEncrypt =
hasEncryptionKeys &&
isRestricted &&
!hasPendingInvitations &&
membersWithoutKey.length === 0;
hasAnyPublicKey;
const handleClose = () => {
if (isPending) {
@@ -223,12 +236,44 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
publicKeysMap,
);
// Convert ArrayBuffer encrypted keys to base64 for the backend API
const encryptedSymmetricKeyPerUser: Record<string, string> = {};
// Contract with /encrypt/: every user on the access list must
// appear in the payload exactly once. Validated users get their
// base64 wrapped key; members without a public key yet get
// explicit null (pending onboarding — they'll be accepted later).
const encryptedSymmetricKeyPerUser: Record<string, string | null> = {};
for (const [uid, keyBuffer] of Object.entries(encryptedKeys)) {
encryptedSymmetricKeyPerUser[uid] = toBase64(new Uint8Array(keyBuffer));
}
for (const access of membersWithoutKey) {
const sub = access.user?.suite_user_id;
if (sub && !(sub in encryptedSymmetricKeyPerUser)) {
encryptedSymmetricKeyPerUser[sub] = null;
}
}
// Matched fingerprint map — same set of users, same cardinality.
// Stored on each DocumentAccess row so the key-mismatch panel can
// later show users which historical key the doc was encrypted for.
const encryptionPublicKeyFingerprintPerUser: Record<
string,
string | null
> = {};
for (const [uid, publicKey] of Object.entries(publicKeysMap)) {
try {
encryptionPublicKeyFingerprintPerUser[uid] =
await vaultClient.computeKeyFingerprint(publicKey);
} catch (err) {
console.warn('[encrypt] computeKeyFingerprint failed for', uid, err);
encryptionPublicKeyFingerprintPerUser[uid] = null;
}
}
for (const access of membersWithoutKey) {
const sub = access.user?.suite_user_id;
if (sub && !(sub in encryptionPublicKeyFingerprintPerUser)) {
encryptionPublicKeyFingerprintPerUser[sub] = null;
}
}
// Get the current user's encrypted key for attachment encryption
const currentUserEncryptedKey = user.suite_user_id ? encryptedKeys[user.suite_user_id] : undefined;
@@ -253,6 +298,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
docId: doc.id,
content: encryptedContent,
encryptedSymmetricKeyPerUser,
encryptionPublicKeyFingerprintPerUser,
attachmentKeyMapping,
});
@@ -424,24 +470,20 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
<Box $direction="row" $align="center" $gap="xs">
<Icon
iconName={
membersWithoutKey.length === 0 ? 'check_circle' : 'cancel'
membersWithoutKey.length === 0
? 'check_circle'
: 'hourglass_empty'
}
$size="sm"
$theme={
membersWithoutKey.length === 0 ? 'success' : 'error'
membersWithoutKey.length === 0 ? 'success' : 'warning'
}
/>
<Text
$size="sm"
$weight={membersWithoutKey.length === 0 ? '400' : '600'}
$theme={
membersWithoutKey.length === 0 ? undefined : 'error'
}
>
<Text $size="sm">
{membersWithoutKey.length === 0
? t('All members have encryption enabled')
: t(
'{{count}} member(s) have not enabled encryption yet',
'{{count}} member(s) havent completed encryption onboarding yet. They will be added as pending and wont be able to decrypt the document until another validated collaborator accepts them from the share dialog.',
{ count: membersWithoutKey.length },
)}
</Text>

View File

@@ -42,6 +42,7 @@ export interface UseCollaborationStore {
isSynced: boolean;
hasLostConnection: boolean;
encryptionTransition: EncryptionTransitionType | null;
decryptionFailed: boolean;
resetLostConnection: () => void;
}
@@ -52,6 +53,7 @@ const defaultValues = {
isSynced: false,
hasLostConnection: false,
encryptionTransition: null,
decryptionFailed: false,
};
function handleEncryptionSystemMessage(
@@ -110,6 +112,12 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
handleEncryptionSystemMessage(message, set, get);
}
},
onDecryptError: (err) => {
const msg = err instanceof Error ? err.message : String(err);
if (/wrong secret key/i.test(msg)) {
set({ decryptionFailed: true });
}
},
});
provider = new RelayProvider(wsUrl, storeId, doc, {

View File

@@ -7,6 +7,7 @@ export interface Access {
max_role: Role;
team: string;
user: User;
is_pending_encryption?: boolean;
document: {
id: string;
path: string;
@@ -61,6 +62,7 @@ export interface Doc {
path: string;
is_favorite: boolean;
is_encrypted: boolean;
is_pending_encryption_for_user?: boolean;
link_reach: LinkReach;
link_role?: LinkRole;
nb_accesses_direct: number;

View File

@@ -0,0 +1,66 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
interface AcceptEncryptionAccessParams {
docId: Doc['id'];
accessId: string;
encrypted_document_symmetric_key_for_user: string;
encryption_public_key_fingerprint: string;
}
/**
* PATCH /api/v1.0/documents/{docId}/accesses/{accessId}/encryption-key/
*
* "Accept" a pending collaborator — the caller (who already holds a
* wrapped symmetric key on the document) re-wraps it for a user whose
* access row was created pending (they had no public key at invite
* time). Flips `encrypted_document_symmetric_key_for_user` from NULL
* to the supplied wrapped key and stores the current fingerprint.
*/
export const acceptEncryptionAccess = async ({
docId,
accessId,
encrypted_document_symmetric_key_for_user,
encryption_public_key_fingerprint,
}: AcceptEncryptionAccessParams): Promise<void> => {
const response = await fetchAPI(
`documents/${docId}/accesses/${accessId}/encryption-key/`,
{
method: 'PATCH',
body: JSON.stringify({
encrypted_document_symmetric_key_for_user,
encryption_public_key_fingerprint,
}),
},
);
if (!response.ok) {
throw new APIError(
'Failed to accept the pending collaborator.',
await errorCauses(response),
);
}
};
export function useAcceptEncryptionAccess() {
const queryClient = useQueryClient();
return useMutation<void, APIError, AcceptEncryptionAccessParams>({
mutationFn: acceptEncryptionAccess,
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESSES, { docId: variables.docId }],
});
void queryClient.invalidateQueries({
queryKey: [KEY_DOC, { docId: variables.docId }],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
},
});
}

View File

@@ -45,6 +45,7 @@ import {
QuickSearchGroupAccessRequest,
} from './DocShareAccessRequest';
import { DocShareAddMemberList } from './DocShareAddMemberList';
import { PendingEncryptionSection } from './PendingEncryptionSection';
import {
DocShareModalInviteUserRow,
QuickSearchGroupInvitation,
@@ -345,6 +346,14 @@ export const DocShareModal = ({
)}
</Box>
{doc.is_encrypted && canShare && membersQuery && (
<PendingEncryptionSection
doc={doc}
accesses={membersQuery}
documentEncryptionSettings={effectiveEncryptionSettings}
/>
)}
<Box data-testid="doc-share-quick-search">
{!canViewAccesses && (
<Box

View File

@@ -0,0 +1,229 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useVaultClient } from '@/features/docs/doc-collaboration/vault';
import { toBase64 } from '@/features/docs/doc-editor';
import type { Access, Doc } from '@/features/docs/doc-management';
import type { DocumentEncryptionSettings } from '@/features/docs/doc-collaboration/hook/useDocumentEncryption';
import { useAcceptEncryptionAccess } from '../api/useAcceptEncryptionAccess';
interface Props {
doc: Doc;
accesses: Access[];
documentEncryptionSettings: DocumentEncryptionSettings | null;
}
/**
* Lists users who were added to an encrypted doc before completing
* their encryption onboarding (`is_pending_encryption`).
*
* Two sub-states per row, driven by an upfront public-key probe:
* - invitee HAS a public key → Accept button actionable. One click
* re-wraps the document key against their key and PATCHes the row.
* - invitee has NO public key yet → no button, just a hint saying we're
* waiting for them to complete onboarding. This prevents the
* "click Accept, get a cryptic error" loop.
*/
export const PendingEncryptionSection = ({
doc,
accesses,
documentEncryptionSettings,
}: Props) => {
const { t } = useTranslation();
const { client: vaultClient } = useVaultClient();
const { mutateAsync: acceptMutation } = useAcceptEncryptionAccess();
const [inFlight, setInFlight] = useState<Set<string>>(new Set());
const [errorByAccessId, setErrorByAccessId] = useState<
Record<string, string>
>({});
const [hasPublicKeyBySub, setHasPublicKeyBySub] = useState<
Record<string, boolean>
>({});
const [probing, setProbing] = useState(true);
const pending = useMemo(
() =>
accesses.filter(
(a) => a.is_pending_encryption && a.document.id === doc.id,
),
[accesses, doc.id],
);
const pendingSubsSignature = useMemo(
() =>
pending
.map((a) => a.user?.suite_user_id)
.filter((s): s is string => !!s)
.sort()
.join(','),
[pending],
);
useEffect(() => {
if (pending.length === 0 || !vaultClient) {
setProbing(false);
return;
}
let cancelled = false;
setProbing(true);
const subs = pending
.map((a) => a.user?.suite_user_id)
.filter((s): s is string => !!s);
if (subs.length === 0) {
setProbing(false);
return;
}
vaultClient
.fetchPublicKeys(subs)
.then(({ publicKeys }) => {
if (cancelled) return;
const next: Record<string, boolean> = {};
for (const sub of subs) {
next[sub] = !!publicKeys[sub];
}
setHasPublicKeyBySub(next);
})
.catch(() => {
/* leave empty — fall back to "waiting for their onboarding" */
})
.finally(() => {
if (!cancelled) setProbing(false);
});
return () => {
cancelled = true;
};
// pendingSubsSignature intentionally used to avoid re-probing on
// unrelated access array identity changes.
}, [pendingSubsSignature, vaultClient]); // eslint-disable-line react-hooks/exhaustive-deps
if (pending.length === 0) return null;
const handleAccept = async (access: Access) => {
const sub = access.user?.suite_user_id;
if (!sub || !vaultClient || !documentEncryptionSettings) return;
setInFlight((prev) => new Set(prev).add(access.id));
setErrorByAccessId((prev) => {
const copy = { ...prev };
delete copy[access.id];
return copy;
});
try {
const { publicKeys } = await vaultClient.fetchPublicKeys([sub]);
const userPublicKey = publicKeys[sub];
if (!userPublicKey) {
setHasPublicKeyBySub((m) => ({ ...m, [sub]: false }));
throw new Error(
t("This user still hasn't completed their encryption onboarding."),
);
}
const { encryptedKeys } = await vaultClient.shareKeys(
documentEncryptionSettings.encryptedSymmetricKey,
{ [sub]: userPublicKey },
);
const wrappedKey = encryptedKeys[sub];
if (!wrappedKey) {
throw new Error(t('Failed to wrap the document key for this user.'));
}
const fingerprint = await vaultClient.computeKeyFingerprint(userPublicKey);
await acceptMutation({
docId: doc.id,
accessId: access.id,
encrypted_document_symmetric_key_for_user: toBase64(
new Uint8Array(wrappedKey),
),
encryption_public_key_fingerprint: fingerprint,
});
} catch (err) {
setErrorByAccessId((prev) => ({
...prev,
[access.id]: err instanceof Error ? err.message : String(err),
}));
} finally {
setInFlight((prev) => {
const copy = new Set(prev);
copy.delete(access.id);
return copy;
});
}
};
return (
<Box
$padding="sm"
$margin={{ horizontal: 'base', bottom: 'sm' }}
$gap="xs"
$css={`
background: var(--c--theme--colors--warning-050, #fffbf0);
border: 1px solid var(--c--theme--colors--warning-300, #ffd591);
border-radius: 4px;
`}
>
<Box $direction="row" $align="center" $gap="xs">
<Icon iconName="hourglass_empty" $size="sm" $theme="warning" />
<Text $weight="600" $size="sm">
{t('Users pending encryption access ({{count}})', {
count: pending.length,
})}
</Text>
</Box>
<Box $gap="2xs">
{pending.map((access) => {
const sub = access.user?.suite_user_id;
const isBusy = inFlight.has(access.id);
const error = errorByAccessId[access.id];
const hasPublicKey = sub ? hasPublicKeyBySub[sub] : false;
const canAccept =
!!sub &&
!!documentEncryptionSettings &&
hasPublicKey === true &&
!probing;
return (
<Box
key={access.id}
$direction="row"
$align="center"
$gap="xs"
$wrap="wrap"
$padding={{ vertical: '3xs' }}
>
<Box $flex={1} $minWidth="0">
<Text $weight="500" $size="sm">
{access.user?.full_name || access.user?.email}
</Text>
{access.user?.email && access.user?.full_name && (
<Text $size="xs" $variation="secondary">
{access.user.email}
</Text>
)}
{!canAccept && !probing && (
<Text $size="xs" $variation="secondary">
{t(
"Waiting for this user to complete their encryption onboarding. You'll be able to accept them once they have.",
)}
</Text>
)}
{error && (
<Text $size="xs" $theme="error">
{error}
</Text>
)}
</Box>
{canAccept && (
<Button
size="small"
onClick={() => void handleAccept(access)}
disabled={isBusy}
>
{isBusy ? t('Accepting…') : t('Accept')}
</Button>
)}
</Box>
);
})}
</Box>
</Box>
);
};

View File

@@ -11,6 +11,7 @@ import { Spinner } from '@gouvfr-lasuite/ui-kit';
import { Box, Icon, Loading, StyledLink, Text, TextErrors } from '@/components';
import { DEFAULT_QUERY_RETRY } from '@/core';
import { DocEditor } from '@/docs/doc-editor';
import { KeyMismatchPanel } from '@/features/docs/doc-management/components/KeyMismatchPanel';
import {
Doc,
DocPage403,
@@ -77,6 +78,7 @@ const DocPage = ({ id }: DocProps) => {
encryptionTransition,
clearEncryptionTransition,
provider,
decryptionFailed,
} = useProviderStore();
const { isSkeletonVisible, setIsSkeletonVisible } = useSkeletonStore();
const {
@@ -288,6 +290,10 @@ const DocPage = ({ id }: DocProps) => {
return <Loading />;
}
if (doc.is_encrypted && decryptionFailed) {
return <KeyMismatchPanel doc={doc} />;
}
if (doc.is_encrypted && (encryptionError || documentEncryptionError)) {
return (
<Box $align="center" $margin="auto" $gap="md" $padding="2rem">