mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-28 12:35:10 +02:00
wip pending encryption
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user