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
+120 -13
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,17 +501,12 @@ 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):
fields.pop("encrypted_document_symmetric_key_for_user", None)
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.
+184 -10
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,