From dcab5a45bd35e97cbc3d84ead23feafffb56af17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 29 Apr 2026 15:01:53 +0200 Subject: [PATCH] wip move working --- src/backend/core/api/serializers.py | 44 +- src/backend/core/api/viewsets.py | 347 +++++++++++---- .../tests/items/test_api_items_encrypt.py | 29 +- .../core/tests/items/test_api_items_move.py | 233 +++++++++++ .../apps/drive/src/features/drivers/Driver.ts | 20 + .../drivers/implementations/StandardDriver.ts | 34 +- .../encryption/ModalRecursiveEncrypt.tsx | 43 +- .../encryption/RecursiveEncryptProvider.tsx | 61 ++- .../recursive/useRecursiveEncryptionJob.ts | 395 +++++++++++++++++- .../src/features/explorer/api/useMoveItem.tsx | 57 +-- 10 files changed, 1106 insertions(+), 157 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 2e4e50e8..ff12f042 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1075,10 +1075,25 @@ class MoveItemSerializer(serializers.Serializer): finish encryption onboarding — symmetric to /encrypt/). Self-rooted encrypted → encrypted (DEMOTE — item attaches under a - destination chain, per-user wraps cleared): + destination chain, per-user wraps preserved as side-doors): {"target_item_id": "...", "is_encryption_root": false, "encrypted_symmetric_key": ""} + + Plaintext → encrypted (ENCRYPT-ON-MOVE — item enters the chain + fully chain-wrapped, NO per-user wraps anywhere). Mirrors + /encrypt/'s descendant shape but produces a chain wrap on the + moved item instead of a per-user wrap: + {"target_item_id": "...", + "encrypted_symmetric_key": "", + "encrypted_keys_for_descendants": {"": "", ...}, + "file_key_mapping": {"": "", ...}} + - The presence of `encrypted_keys_for_descendants` (even + empty for a single-file move) is what tells the backend + this is encrypt-on-move, not a chain rewrap. The + default-dict on the field is required so a missing key + from the client maps to "no descendants" rather than + tripping rewrap validation. """ target_item_id = serializers.UUIDField(required=False) @@ -1096,6 +1111,19 @@ class MoveItemSerializer(serializers.Serializer): child=serializers.CharField(allow_null=True, allow_blank=True, max_length=16), required=False, ) + # Encrypt-on-move payload (plaintext → encrypted). Snake_case here + # rather than copying /encrypt/'s camelCase outlier — the rest of + # MoveItemSerializer is snake_case and mixing within one payload + # would be jarring. /encrypt/'s naming is a legacy quirk (see its + # own serializer) we don't want to propagate. + encrypted_keys_for_descendants = serializers.DictField( + child=serializers.CharField(), + required=False, + ) + file_key_mapping = serializers.DictField( + child=serializers.CharField(), + required=False, + ) class SDKRelayEventSerializer(serializers.Serializer): @@ -1156,7 +1184,7 @@ class EncryptItemSerializer(serializers.Serializer): After commit, old S3 objects are cleaned up. """ - encryptedSymmetricKeyPerUser = serializers.DictField( + encrypted_symmetric_key_per_user = serializers.DictField( # 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 @@ -1170,7 +1198,7 @@ class EncryptItemSerializer(serializers.Serializer): "never null." ), ) - encryptionPublicKeyFingerprintPerUser = serializers.DictField( + encryption_public_key_fingerprint_per_user = serializers.DictField( # Required: the client must send a fingerprint entry for every # user it sent a wrapped-key entry for. Symmetric keys and # fingerprints travel as matched pairs — keeping them coupled @@ -1191,11 +1219,11 @@ class EncryptItemSerializer(serializers.Serializer): 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 (no public key to fingerprint yet)." + "`encrypted_symmetric_key_per_user`; null is valid for " + "pending users (no public key to fingerprint yet)." ), ) - encryptedKeysForDescendants = serializers.DictField( + encrypted_keys_for_descendants = serializers.DictField( child=serializers.CharField(), required=False, default=dict, @@ -1204,7 +1232,7 @@ class EncryptItemSerializer(serializers.Serializer): "Empty for standalone file encryption." ), ) - fileKeyMapping = serializers.DictField( + file_key_mapping = serializers.DictField( child=serializers.CharField(), required=False, default=dict, @@ -1220,7 +1248,7 @@ class EncryptItemSerializer(serializers.Serializer): class RemoveEncryptionSerializer(serializers.Serializer): """Serializer for removing encryption from an item or subtree.""" - fileKeyMapping = serializers.DictField( + file_key_mapping = serializers.DictField( child=serializers.CharField(), required=False, default=dict, diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5d0dba93..19994951 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -257,6 +257,113 @@ class ItemMetadata(drf.metadata.SimpleMetadata): return simple_metadata +# ============================================================================= +# Subtree-encryption helpers +# +# Both `/encrypt/` (encrypt a subtree at its root) and `/move/` (move a +# plaintext subtree INTO an encrypted chain) need to: +# 1. Determine the effective scope, excluding inner encrypted subtrees. +# 2. Write `is_encrypted=True` + a wrapped key + (optionally) a new +# filename onto the root and every descendant in scope. +# 3. Schedule best-effort post-commit cleanup of the old S3 keys. +# +# The differences (per-user wraps + RESTRICTED check at /encrypt/, the +# `item.move(target)` + chain wrap at /move/) stay in the views; these +# helpers cover the strictly shared mechanics. +# ============================================================================= + + +def compute_effective_descendants_for_encryption(item): + """Descendants of `item` minus everything inside inner encrypted roots. + + Stacked encryption: a folder may already contain inner encrypted + subtrees. Those keep their existing keys and per-user ItemAccess + rows; we only operate on the "outer" subtree they don't cover. + """ + inner_roots = list( + item.descendants().filter( + is_encrypted=True, + encrypted_symmetric_key__isnull=True, + ) + ) + qs = item.descendants() + for inner in inner_roots: + qs = qs.exclude(path__descendants=inner.path) + return qs + + +def write_subtree_encryption( + *, + item, + root_chain_wrap, + encrypted_keys_for_descendants, + file_key_mapping, + effective_descendants, +): + """Set `is_encrypted=True` + encryption fields on item + descendants. + + `root_chain_wrap`: + - None: the item is itself the encryption root — its + `encrypted_symmetric_key` stays NULL. The caller is responsible + for writing per-user wraps onto access rows. + - str: the item is being attached under a destination chain — + its `encrypted_symmetric_key` carries the chain wrap. + + `encrypted_keys_for_descendants` and `file_key_mapping` are keyed by + str(uuid). Caller is responsible for validating that the descendant + set matches the live one (mutation 409) before calling this — by + the time we get here we trust the inputs. + + Returns the list of old S3 keys (filenames) that the caller should + pass to `schedule_s3_cleanup` after the transaction commits. + """ + old_s3_keys = [] + + item.is_encrypted = True + item.encrypted_symmetric_key = root_chain_wrap + update_fields = ["is_encrypted", "encrypted_symmetric_key"] + if str(item.pk) in file_key_mapping: + old_s3_keys.append(item.file_key) + item.filename = file_key_mapping[str(item.pk)] + update_fields.append("filename") + item.save(update_fields=update_fields) + + for descendant in effective_descendants: + descendant.is_encrypted = True + descendant.encrypted_symmetric_key = encrypted_keys_for_descendants.get( + str(descendant.pk) + ) + desc_fields = ["is_encrypted", "encrypted_symmetric_key"] + if str(descendant.pk) in file_key_mapping: + old_s3_keys.append(descendant.file_key) + descendant.filename = file_key_mapping[str(descendant.pk)] + desc_fields.append("filename") + descendant.save(update_fields=desc_fields) + + return old_s3_keys + + +def schedule_s3_cleanup(old_s3_keys): + """Best-effort post-commit deletion of S3 objects. + + Failures are logged, never raised — by the time we get here the + DB transaction has committed and we can't undo it. + """ + if not old_s3_keys: + return + + def _cleanup(): + s3_client = default_storage.connection.meta.client + bucket = default_storage.bucket_name + for old_key in old_s3_keys: + try: + s3_client.delete_object(Bucket=bucket, Key=old_key) + except ClientError: + logger.warning("Failed to delete old S3 key: %s", old_key) + + transaction.on_commit(_cleanup) + + # pylint: disable=too-many-public-methods class ItemViewSet( SerializerPerActionMixin, @@ -936,7 +1043,7 @@ class ItemViewSet( {"target_item_id": message}, code="item_move_missing_permission" ) - # Encryption-aware move. Four resolved shapes — see + # Encryption-aware move. Five resolved shapes — see # `MoveItemSerializer` for the payload schema. We validate first # (so a malformed payload never moves the item) then apply the # move, then apply the encryption state change atomically. @@ -946,6 +1053,18 @@ class ItemViewSet( fingerprint_per_user = validated_data.get( "encryption_public_key_fingerprints", {} ) + # Encrypt-on-move payload: the presence of either field is the + # signal we're looking at the plaintext-into-chain shape (an + # empty dict still counts — single-file moves have no + # descendants but still go through this path). + encrypt_on_move = ( + "encrypted_keys_for_descendants" in validated_data + or "file_key_mapping" in validated_data + ) + encrypted_keys_for_descendants = validated_data.get( + "encrypted_keys_for_descendants", {} + ) + file_key_mapping = validated_data.get("file_key_mapping", {}) target_is_encrypted = target_item.is_encrypted if target_item else False # Source flags. `source_is_root` means the item currently holds # its key per-user (no chain wrap of its own). @@ -955,9 +1074,126 @@ class ItemViewSet( ) source_in_chain = source_is_encrypted and not source_is_root - # Reject plaintext-into-encrypted: caller must encrypt first - # (the recursive-encryption modal does that ahead of retrying - # the move). + # Encrypt-on-move (plaintext → encrypted): plaintext source + # picks up the chain wrap inline, no /encrypt/ round trip, no + # per-user wraps materialised on inherited-only collaborators. + if encrypt_on_move: + if source_is_encrypted: + raise drf.exceptions.ValidationError( + { + "detail": _( + "Encrypt-on-move payload is only valid for " + "plaintext source items." + ) + }, + code="item_move_encrypt_on_move_source_encrypted", + ) + if not target_is_encrypted: + raise drf.exceptions.ValidationError( + { + "detail": _( + "Encrypt-on-move requires moving INTO an " + "encrypted folder." + ) + }, + code="item_move_encrypt_on_move_plain_target", + ) + if not encrypted_symmetric_key: + raise drf.exceptions.ValidationError( + { + "encrypted_symmetric_key": _( + "Required for encrypt-on-move (chain wrap of " + "the item's symmetric key)." + ) + }, + code="item_move_chain_wrap_required", + ) + if is_encryption_root_flag is not None: + raise drf.exceptions.ValidationError( + { + "is_encryption_root": _( + "Not applicable to encrypt-on-move; the item " + "enters as a chain descendant." + ) + }, + code="item_move_conflicting_flag", + ) + if per_user_encrypted_keys is not None: + raise drf.exceptions.ValidationError( + { + "per_user_encrypted_keys": _( + "Encrypt-on-move uses chain wraps only — no " + "per-user wraps." + ) + }, + code="item_move_conflicting_wrap", + ) + # No pending invitations on item or descendants (mirrors + # /encrypt/'s precondition). + descendant_ids_pre = list(item.descendants().values_list("pk", flat=True)) + all_item_ids = [item.pk] + descendant_ids_pre + if models.Invitation.objects.filter(item_id__in=all_item_ids).exists(): + raise drf.exceptions.ValidationError( + { + "detail": _( + "All pending invitations must be resolved " + "before encrypting on move." + ) + }, + code="item_move_pending_invitations", + ) + # Materialise the descendant set BEFORE the move. The path + # filter on `descendants()` captures `item.path` at filter- + # build time; once `item.move(target)` rewrites paths via + # raw SQL, re-evaluating the queryset returns nothing + # (filter still references the old path value). + effective_descendants = list( + compute_effective_descendants_for_encryption(item) + ) + live_descendant_ids = {str(d.pk) for d in effective_descendants} + provided_descendant_ids = set(encrypted_keys_for_descendants.keys()) + if live_descendant_ids != provided_descendant_ids: + return drf.response.Response( + { + "detail": _( + "Folder contents changed during the operation. " + "Please retry." + ), + "code": "subtree_mutated", + "missing": sorted( + live_descendant_ids - provided_descendant_ids + ), + "extra": sorted( + provided_descendant_ids - live_descendant_ids + ), + }, + status=drf.status.HTTP_409_CONFLICT, + ) + # Apply: move + write subtree encryption (chain-wrapped at + # the root). No per-user wraps anywhere in this subtree — + # decryption flows through the destination chain. + item.move(target_item) + old_s3_keys = write_subtree_encryption( + item=item, + root_chain_wrap=encrypted_symmetric_key, + encrypted_keys_for_descendants=encrypted_keys_for_descendants, + file_key_mapping=file_key_mapping, + effective_descendants=effective_descendants, + ) + # Sync link reach with the new (encrypted) parent — same + # rule as plain moves: a child item's link_reach is None + # so it inherits the parent's RESTRICTED. + item.link_reach = None + item.save(update_fields=["link_reach"]) + schedule_s3_cleanup(old_s3_keys) + posthog_capture("item_moved", user, {}, item=item) + return drf.response.Response( + {"message": "item moved successfully."}, status=status.HTTP_200_OK + ) + + # Reject plaintext-into-encrypted without the encrypt-on-move + # payload: the caller has to either encrypt first, or use the + # encrypt-on-move shape above. if not source_is_encrypted and target_is_encrypted: raise drf.exceptions.ValidationError( { @@ -1818,9 +2054,9 @@ class ItemViewSet( serializer = serializers.EncryptItemSerializer(data=request.data) serializer.is_valid(raise_exception=True) - encrypted_key_per_user = serializer.validated_data["encryptedSymmetricKeyPerUser"] + encrypted_key_per_user = serializer.validated_data["encrypted_symmetric_key_per_user"] encrypted_keys_for_descendants = serializer.validated_data[ - "encryptedKeysForDescendants" + "encrypted_keys_for_descendants" ] # Validate: all users with access (direct OR inherited via an @@ -1868,36 +2104,19 @@ class ItemViewSet( status=drf.status.HTTP_400_BAD_REQUEST, ) - # Find any inner encrypted subtrees already rooted inside this item - # ("stacked encryption"). Those descendants — and everything under - # them — are out of scope: they keep their existing keys and their - # own per-user ItemAccess records. We only encrypt items in the - # effective scope (this item's descendants minus each inner root's - # subtree). - inner_roots = list( - item.descendants().filter( - is_encrypted=True, - encrypted_symmetric_key__isnull=True, - ) + # Effective scope = descendants minus inner encrypted subtrees. + # Materialise into a list so subsequent operations (post-move + # in the encrypt-on-move flow; here just defensive) don't + # re-query against a possibly-stale path predicate. + effective_descendants = list( + compute_effective_descendants_for_encryption(item) ) - effective_descendants_qs = item.descendants() - for inner in inner_roots: - effective_descendants_qs = effective_descendants_qs.exclude( - path__descendants=inner.path, - ) - # Validate: a wrapped key must be provided for every descendant in - # the effective scope (files AND nested folders). The hierarchical - # key model stores each descendant's key wrapped by its direct - # parent folder's key, so /key-chain/ can walk from the user's - # entry point down to any leaf. This also doubles as an integrity - # check: if the subtree mutated between frontend discovery and - # this commit (another user added a file, a folder, etc.), the - # provided id set won't match the live one and we abort the whole - # operation so the user can re-discover + retry. - live_descendant_ids = { - str(pk) for pk in effective_descendants_qs.values_list("pk", flat=True) - } + # Validate descendant set: every effective descendant must be + # covered by a wrapped key entry. Doubles as a mutation check — + # if the subtree changed between frontend discovery and this + # commit, the id sets won't match and we abort the whole op. + live_descendant_ids = {str(d.pk) for d in effective_descendants} provided_descendant_ids = set(encrypted_keys_for_descendants.keys()) if live_descendant_ids != provided_descendant_ids: return drf.response.Response( @@ -1913,36 +2132,18 @@ class ItemViewSet( status=drf.status.HTTP_409_CONFLICT, ) - file_key_mapping = serializer.validated_data["fileKeyMapping"] + file_key_mapping = serializer.validated_data["file_key_mapping"] - # Collect old S3 keys for cleanup after commit - old_s3_keys = [] - - # Apply encryption: mark item as encrypted (it's the encryption root) - item.is_encrypted = True - item.encrypted_symmetric_key = None # root has per-user keys, not parent-wrapped - update_fields = ["is_encrypted", "encrypted_symmetric_key"] - # Swap filename to point to the new S3 key where encrypted content was uploaded - # title stays the same (visible name), only the S3 key changes - if str(item.pk) in file_key_mapping: - old_s3_keys.append(item.file_key) - item.filename = file_key_mapping[str(item.pk)] - update_fields.append("filename") - item.save(update_fields=update_fields) - - # Apply encryption to descendants in the effective scope only — - # inner encrypted subtrees keep their existing state untouched. - for descendant in effective_descendants_qs.iterator(): - descendant.is_encrypted = True - descendant.encrypted_symmetric_key = encrypted_keys_for_descendants.get( - str(descendant.pk) - ) - desc_fields = ["is_encrypted", "encrypted_symmetric_key"] - if str(descendant.pk) in file_key_mapping: - old_s3_keys.append(descendant.file_key) - descendant.filename = file_key_mapping[str(descendant.pk)] - desc_fields.append("filename") - descendant.save(update_fields=desc_fields) + # Apply encryption to root + descendants. `root_chain_wrap=None` + # because /encrypt/ produces a self-rooted item (per-user wraps + # land below); chain mode is for /move/. + old_s3_keys = write_subtree_encryption( + item=item, + root_chain_wrap=None, + encrypted_keys_for_descendants=encrypted_keys_for_descendants, + file_key_mapping=file_key_mapping, + effective_descendants=effective_descendants, + ) # Store per-user encrypted keys on ItemAccess records that live on # THIS item. Keys *must* sit here — not on an ancestor's ItemAccess @@ -1967,7 +2168,7 @@ class ItemViewSet( # encrypted for (surfaced in the "key mismatch" panel when # decrypt fails on a rotated key). fingerprint_per_user = serializer.validated_data[ - "encryptionPublicKeyFingerprintPerUser" + "encryption_public_key_fingerprint_per_user" ] fingerprint_subs = set(fingerprint_per_user.keys()) if fingerprint_subs != provided_user_subs: @@ -1982,7 +2183,7 @@ class ItemViewSet( { "detail": _( "Provided fingerprints do not match the users in " - "encryptedSymmetricKeyPerUser." + "encrypted_symmetric_key_per_user." ), **errors, }, @@ -2032,17 +2233,7 @@ class ItemViewSet( ), ) - # After DB commit: clean up old S3 objects (best-effort) - def _cleanup_old_s3_keys(): - s3_client = default_storage.connection.meta.client - bucket = default_storage.bucket_name - for old_key in old_s3_keys: - try: - s3_client.delete_object(Bucket=bucket, Key=old_key) - except ClientError: - logger.warning("Failed to delete old S3 key: %s", old_key) - - transaction.on_commit(_cleanup_old_s3_keys) + schedule_s3_cleanup(old_s3_keys) return drf.response.Response( self.get_serializer(item).data, @@ -2057,7 +2248,7 @@ class ItemViewSet( """Remove encryption from an item or subtree. The frontend uploads decrypted file content to new S3 keys, then calls - this endpoint with a fileKeyMapping. The backend atomically swaps + this endpoint with a file_key_mapping. The backend atomically swaps file_key_override and clears encryption fields. Old encrypted S3 objects are cleaned up after commit. """ @@ -2100,7 +2291,7 @@ class ItemViewSet( serializer = serializers.RemoveEncryptionSerializer(data=request.data) serializer.is_valid(raise_exception=True) - file_key_mapping = serializer.validated_data["fileKeyMapping"] + file_key_mapping = serializer.validated_data["file_key_mapping"] # Stacked encryption: exclude inner encryption roots and their # subtrees from the removal scope. Those are independent encrypted diff --git a/src/backend/core/tests/items/test_api_items_encrypt.py b/src/backend/core/tests/items/test_api_items_encrypt.py index b41efae3..61b1ce82 100644 --- a/src/backend/core/tests/items/test_api_items_encrypt.py +++ b/src/backend/core/tests/items/test_api_items_encrypt.py @@ -21,7 +21,7 @@ def test_api_items_encrypt_anonymous(): ) response = APIClient().patch( f"/api/v1.0/items/{item.id!s}/encrypt/", - {"encryptedSymmetricKeyPerUser": {}, "encryptedKeysForDescendants": {}}, + {"encrypted_symmetric_key_per_user": {}, "encrypted_keys_for_descendants": {}}, format="json", ) assert response.status_code == 401 @@ -39,7 +39,7 @@ def test_api_items_encrypt_authenticated_unrelated(): client.force_login(user) response = client.patch( f"/api/v1.0/items/{item.id!s}/encrypt/", - {"encryptedSymmetricKeyPerUser": {}, "encryptedKeysForDescendants": {}}, + {"encrypted_symmetric_key_per_user": {}, "encrypted_keys_for_descendants": {}}, format="json", ) assert response.status_code == 403 or response.status_code == 404 @@ -58,7 +58,7 @@ def test_api_items_encrypt_reader_forbidden(): client.force_login(user) response = client.patch( f"/api/v1.0/items/{item.id!s}/encrypt/", - {"encryptedSymmetricKeyPerUser": {user.sub: "fake_key"}}, + {"encrypted_symmetric_key_per_user": {user.sub: "fake_key"}}, format="json", ) assert response.status_code == 403 @@ -78,8 +78,9 @@ def test_api_items_encrypt_standalone_file(): response = client.patch( f"/api/v1.0/items/{item.id!s}/encrypt/", { - "encryptedSymmetricKeyPerUser": {user.sub: "encrypted_key_for_user"}, - "encryptedKeysForDescendants": {}, + "encrypted_symmetric_key_per_user": {user.sub: "encrypted_key_for_user"}, + "encryption_public_key_fingerprint_per_user": {user.sub: "fp"}, + "encrypted_keys_for_descendants": {}, }, format="json", ) @@ -116,8 +117,9 @@ def test_api_items_encrypt_folder_with_children(): response = client.patch( f"/api/v1.0/items/{folder.id!s}/encrypt/", { - "encryptedSymmetricKeyPerUser": {user.sub: "root_key_for_user"}, - "encryptedKeysForDescendants": { + "encrypted_symmetric_key_per_user": {user.sub: "root_key_for_user"}, + "encryption_public_key_fingerprint_per_user": {user.sub: "fp"}, + "encrypted_keys_for_descendants": { str(subfolder.pk): "subfolder_wrapped_key", str(file_item.pk): "file_wrapped_key", }, @@ -151,7 +153,7 @@ def test_api_items_encrypt_not_restricted(): client.force_login(user) response = client.patch( f"/api/v1.0/items/{item.id!s}/encrypt/", - {"encryptedSymmetricKeyPerUser": {user.sub: "key"}}, + {"encrypted_symmetric_key_per_user": {user.sub: "key"}}, format="json", ) assert response.status_code == 400 @@ -173,7 +175,7 @@ def test_api_items_encrypt_already_encrypted(): client.force_login(user) response = client.patch( f"/api/v1.0/items/{item.id!s}/encrypt/", - {"encryptedSymmetricKeyPerUser": {user.sub: "key"}}, + {"encrypted_symmetric_key_per_user": {user.sub: "key"}}, format="json", ) assert response.status_code == 400 @@ -198,7 +200,10 @@ def test_api_items_encrypt_missing_user_keys(): # Only provide key for user1, missing user2 response = client.patch( f"/api/v1.0/items/{item.id!s}/encrypt/", - {"encryptedSymmetricKeyPerUser": {user1.sub: "key1"}}, + { + "encrypted_symmetric_key_per_user": {user1.sub: "key1"}, + "encryption_public_key_fingerprint_per_user": {user1.sub: "fp1"}, + }, format="json", ) assert response.status_code == 400 @@ -229,7 +234,9 @@ def test_api_items_remove_encryption(): client.force_login(user) response = client.patch( f"/api/v1.0/items/{item.id!s}/remove-encryption/", - {}, + # File root needs an entry in `file_key_mapping` for itself + # (the new S3 key the frontend uploaded the plaintext to). + {"file_key_mapping": {str(item.pk): "new_plaintext.txt"}}, format="json", ) assert response.status_code == 200 diff --git a/src/backend/core/tests/items/test_api_items_move.py b/src/backend/core/tests/items/test_api_items_move.py index 32a8a1e4..9b79e40a 100644 --- a/src/backend/core/tests/items/test_api_items_move.py +++ b/src/backend/core/tests/items/test_api_items_move.py @@ -1287,6 +1287,239 @@ def test_api_items_move_plaintext_into_encrypted_rejected(): assert response.json()["errors"][0]["code"] == "item_move_plaintext_into_encrypted" +def test_api_items_move_encrypt_on_move_file(): + """ + Plaintext file → encrypted folder via encrypt-on-move: file becomes + chain-wrapped under the destination, NO ItemAccess rows materialised + for inherited-only collaborators (the whole point of the new shape). + """ + user = factories.UserFactory() + other = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Source is a plaintext folder both `user` and `other` have access + # to. Putting `other` here would normally cause /encrypt/ to + # materialise a per-user wrap row for them — encrypt-on-move must + # not. + source_folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner"), (other, "reader")], + ) + file_item = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, + parent=source_folder, + ) + dest_root = _encrypted_root(user) + + response = client.post( + f"/api/v1.0/items/{file_item.id!s}/move/", + data={ + "target_item_id": str(dest_root.id), + "encrypted_symmetric_key": "CHAIN-WRAP-FILE-UNDER-DEST", + "encrypted_keys_for_descendants": {}, + "file_key_mapping": {}, + }, + format="json", + ) + + assert response.status_code == 200, response.json() + file_item.refresh_from_db() + assert file_item.is_encrypted is True + assert file_item.encrypted_symmetric_key == "CHAIN-WRAP-FILE-UNDER-DEST" + # No per-user ItemAccess wrap created for the inherited `other` + # collaborator. They had inherited access via the source folder; + # they no longer have any access to the file (it moved out of + # source's subtree). That's exactly the cleanup we wanted. + assert ( + models.ItemAccess.objects.filter( + item=file_item, + encrypted_item_symmetric_key_for_user__isnull=False, + ).count() + == 0 + ) + + +def test_api_items_move_encrypt_on_move_folder_with_descendants(): + """Folder with nested files: every effective descendant gets its + chain wrap; the root carries the chain wrap under the destination. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + src_folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + nested_folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, parent=src_folder, + ) + file_a = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, parent=src_folder, + ) + file_b = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, parent=nested_folder, + ) + dest_root = _encrypted_root(user) + + response = client.post( + f"/api/v1.0/items/{src_folder.id!s}/move/", + data={ + "target_item_id": str(dest_root.id), + "encrypted_symmetric_key": "CHAIN-WRAP-SRC-UNDER-DEST", + "encrypted_keys_for_descendants": { + str(nested_folder.id): "wrap-nested-under-src", + str(file_a.id): "wrap-file-a-under-src", + str(file_b.id): "wrap-file-b-under-nested", + }, + "file_key_mapping": { + str(file_a.id): "encrypted_a.bin", + str(file_b.id): "encrypted_b.bin", + }, + }, + format="json", + ) + + assert response.status_code == 200, response.json() + src_folder.refresh_from_db() + nested_folder.refresh_from_db() + file_a.refresh_from_db() + file_b.refresh_from_db() + assert src_folder.is_encrypted is True + assert src_folder.encrypted_symmetric_key == "CHAIN-WRAP-SRC-UNDER-DEST" + assert nested_folder.is_encrypted is True + assert nested_folder.encrypted_symmetric_key == "wrap-nested-under-src" + assert file_a.is_encrypted is True + assert file_a.encrypted_symmetric_key == "wrap-file-a-under-src" + assert file_a.filename == "encrypted_a.bin" + assert file_b.encrypted_symmetric_key == "wrap-file-b-under-nested" + assert file_b.filename == "encrypted_b.bin" + + +def test_api_items_move_encrypt_on_move_subtree_mutation_rejected(): + """If a descendant appears between frontend discovery and the + commit, the mismatched id set must abort with 409 — same contract + as /encrypt/. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + src_folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, users=[(user, "owner")], + ) + file_a = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, parent=src_folder, + ) + # Live extra: the client didn't see this one when it built the + # payload. + factories.ItemFactory(type=models.ItemTypeChoices.FILE, parent=src_folder) + dest_root = _encrypted_root(user) + + response = client.post( + f"/api/v1.0/items/{src_folder.id!s}/move/", + data={ + "target_item_id": str(dest_root.id), + "encrypted_symmetric_key": "wrap", + "encrypted_keys_for_descendants": {str(file_a.id): "wrap-a"}, + "file_key_mapping": {}, + }, + format="json", + ) + + assert response.status_code == 409 + assert response.json()["code"] == "subtree_mutated" + + +def test_api_items_move_encrypt_on_move_requires_chain_wrap(): + """`encrypted_symmetric_key` is mandatory for encrypt-on-move.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file_item = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, users=[(user, "owner")], + ) + dest_root = _encrypted_root(user) + + response = client.post( + f"/api/v1.0/items/{file_item.id!s}/move/", + data={ + "target_item_id": str(dest_root.id), + "encrypted_keys_for_descendants": {}, + "file_key_mapping": {}, + }, + format="json", + ) + + assert response.status_code == 400 + assert response.json()["errors"][0]["code"] == "item_move_chain_wrap_required" + + +def test_api_items_move_encrypt_on_move_rejects_encrypted_source(): + """Encrypt-on-move payload with an already-encrypted source is a + contract violation — the rewrap/demote shapes apply instead. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + root = _encrypted_root(user) + file_item = _encrypted_child(root, item_type=models.ItemTypeChoices.FILE) + dest_root = _encrypted_root(user) + + response = client.post( + f"/api/v1.0/items/{file_item.id!s}/move/", + data={ + "target_item_id": str(dest_root.id), + "encrypted_symmetric_key": "wrap", + "encrypted_keys_for_descendants": {}, + "file_key_mapping": {}, + }, + format="json", + ) + + assert response.status_code == 400 + assert ( + response.json()["errors"][0]["code"] + == "item_move_encrypt_on_move_source_encrypted" + ) + + +def test_api_items_move_encrypt_on_move_rejects_plain_target(): + """Encrypt-on-move with a plaintext destination is meaningless — + if the destination isn't encrypted there's no chain to attach to. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file_item = factories.ItemFactory( + type=models.ItemTypeChoices.FILE, users=[(user, "owner")], + ) + plain_target = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, users=[(user, "owner")], + ) + + response = client.post( + f"/api/v1.0/items/{file_item.id!s}/move/", + data={ + "target_item_id": str(plain_target.id), + "encrypted_symmetric_key": "wrap", + "encrypted_keys_for_descendants": {}, + "file_key_mapping": {}, + }, + format="json", + ) + + assert response.status_code == 400 + assert ( + response.json()["errors"][0]["code"] + == "item_move_encrypt_on_move_plain_target" + ) + + def test_api_items_move_chained_to_plain_without_re_anchor_rejected(): """ Chained-encrypted → plaintext WITHOUT the re-anchor flag is refused diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index d7862ebb..9264ee7c 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -211,6 +211,26 @@ export abstract class Driver { itemId: string, data?: { fileKeyMapping?: Record } ): Promise; + /** + * Encrypt-on-move: ship a plaintext subtree into an encrypted destination + * in one atomic backend call. The frontend has already encrypted file + * contents under the destination's chain and uploaded to fresh S3 keys; + * this commit writes the chain wraps + filename swap + path change in a + * single transaction. + * + * Mirrors the /encrypt/ payload shape (encryptedKeysForDescendants + + * fileKeyMapping) but produces a chain-rooted item — no per-user wraps, + * no ItemAccess rows materialised, no inherited-collaborator bloat. + */ + abstract moveItemEncryptOnMove( + itemId: string, + data: { + targetItemId: string; + encryptedSymmetricKey: string; + encryptedKeysForDescendants: Record; + fileKeyMapping?: Record; + } + ): Promise; abstract getKeyChain(itemId: string): Promise<{ user_access_item_id: string; encrypted_key_for_user: string; diff --git a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts index 4021e2b7..4d49d202 100644 --- a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts +++ b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts @@ -786,21 +786,51 @@ export class StandardDriver extends Driver { fileKeyMapping?: Record; }, ): Promise { + // Wire format is snake_case (consistent with the rest of the + // backend). Caller-side keeps camelCase to stay idiomatic in TS. const response = await fetchAPI(`items/${itemId}/encrypt/`, { method: "PATCH", - body: JSON.stringify(data), + body: JSON.stringify({ + encrypted_symmetric_key_per_user: data.encryptedSymmetricKeyPerUser, + encryption_public_key_fingerprint_per_user: + data.encryptionPublicKeyFingerprintPerUser, + encrypted_keys_for_descendants: data.encryptedKeysForDescendants, + file_key_mapping: data.fileKeyMapping ?? {}, + }), }); const json = await response.json(); return jsonToItem(json); } + async moveItemEncryptOnMove( + itemId: string, + data: { + targetItemId: string; + encryptedSymmetricKey: string; + encryptedKeysForDescendants: Record; + fileKeyMapping?: Record; + }, + ): Promise { + await fetchAPI(`items/${itemId}/move/`, { + method: "POST", + body: JSON.stringify({ + target_item_id: data.targetItemId, + encrypted_symmetric_key: data.encryptedSymmetricKey, + encrypted_keys_for_descendants: data.encryptedKeysForDescendants, + file_key_mapping: data.fileKeyMapping ?? {}, + }), + }); + } + async removeEncryption( itemId: string, data?: { fileKeyMapping?: Record }, ): Promise { const response = await fetchAPI(`items/${itemId}/remove-encryption/`, { method: "PATCH", - body: JSON.stringify(data ?? {}), + body: JSON.stringify({ + file_key_mapping: data?.fileKeyMapping ?? {}, + }), }); const json = await response.json(); return jsonToItem(json); diff --git a/src/frontend/apps/drive/src/features/encryption/ModalRecursiveEncrypt.tsx b/src/frontend/apps/drive/src/features/encryption/ModalRecursiveEncrypt.tsx index 44f7e1d6..4058db5e 100644 --- a/src/frontend/apps/drive/src/features/encryption/ModalRecursiveEncrypt.tsx +++ b/src/frontend/apps/drive/src/features/encryption/ModalRecursiveEncrypt.tsx @@ -16,22 +16,50 @@ interface Props { * completing" (the `onClose` path covers both). */ onSuccess?: () => void; + /** + * When set, the modal switches to encrypt-on-move mode: the item is + * encrypted AND moved into the destination parent in one atomic + * commit. Skips per-user wrap materialisation (no inherited + * collaborator gets a wrap they didn't already have). + */ + intoChainParentId?: string; } -export const ModalRecursiveEncrypt = ({ isOpen, onClose, item, onSuccess }: Props) => { +export const ModalRecursiveEncrypt = ({ + isOpen, + onClose, + item, + onSuccess, + intoChainParentId, +}: Props) => { const { t } = useTranslation(); + const mode = intoChainParentId ? 'encrypt-into-chain' : 'encrypt'; const job = useRecursiveEncryptionJob({ - mode: 'encrypt', + mode, item, isOpen, + intoChainParentId, onSuccess: () => { onSuccess?.(); setTimeout(onClose, 1200); }, }); - const title = - item.type === ItemType.FOLDER + // Title surfaces the destination intent in the encrypt-on-move case + // so the user knows the click will both encrypt and move. + const title = intoChainParentId + ? item.type === ItemType.FOLDER + ? t( + 'encryption.encrypt_modal.title_folder_into_chain', + 'Encrypt and move folder "{{title}}"', + { title: item.title }, + ) + : t( + 'encryption.encrypt_modal.title_file_into_chain', + 'Encrypt and move file "{{title}}"', + { title: item.title }, + ) + : item.type === ItemType.FOLDER ? t('encryption.encrypt_modal.title_folder', 'Encrypt folder "{{title}}"', { title: item.title, }) @@ -72,7 +100,12 @@ export const ModalRecursiveEncrypt = ({ isOpen, onClose, item, onSuccess }: Prop onClick={() => job.confirm()} disabled={!job.canConfirm} > - {t('encryption.encrypt_modal.confirm', 'Encrypt')} + {intoChainParentId + ? t( + 'encryption.encrypt_modal.confirm_into_chain', + 'Encrypt & move', + ) + : t('encryption.encrypt_modal.confirm', 'Encrypt')} )} {job.phase === 'failed' && ( diff --git a/src/frontend/apps/drive/src/features/encryption/RecursiveEncryptProvider.tsx b/src/frontend/apps/drive/src/features/encryption/RecursiveEncryptProvider.tsx index f60e0a37..51222c68 100644 --- a/src/frontend/apps/drive/src/features/encryption/RecursiveEncryptProvider.tsx +++ b/src/frontend/apps/drive/src/features/encryption/RecursiveEncryptProvider.tsx @@ -42,6 +42,14 @@ interface PendingRequest { reject: (e: unknown) => void; /** Set to `true` when the modal's recursive job reports success. */ succeeded: boolean; + /** + * When set, the encrypt request is an encrypt-on-move: the modal + * runs the recursive job in 'encrypt-into-chain' mode, which produces + * a chain-rooted item and commits move + encryption in a single + * /move/ call. Caller doesn't need to follow up with a separate + * `driver.moveItem` — the job handles it. + */ + intoChainParentId?: string; } interface ContextValue { @@ -49,8 +57,17 @@ interface ContextValue { * Open the recursive-encryption modal for `item` and resolve when * the encryption completes. Rejects with `EncryptionRequestCancelled` * if the user closes the modal before success. + * + * Optional `options.intoChainParentId` switches to encrypt-on-move + * mode: the modal performs both the encryption AND the move into the + * given parent atomically. The caller must NOT issue a separate + * `moveItem` afterwards; the resolve already reflects both having + * happened. */ - requestEncryption: (item: Item) => Promise; + requestEncryption: ( + item: Item, + options?: { intoChainParentId?: string }, + ) => Promise; /** * Open the recursive-decryption modal for `item` and resolve when * the decryption completes. Same cancellation contract. @@ -86,26 +103,31 @@ export function RecursiveEncryptProvider({ children }: { children: ReactNode }) setActive(next); }, []); - const enqueue = useCallback((mode: Mode, item: Item) => { - return new Promise((resolve, reject) => { - const req: PendingRequest = { - mode, - item, - resolve, - reject, - succeeded: false, - }; - if (activeRef.current === null) { - activeRef.current = req; - setActive(req); - } else { - queueRef.current.push(req); - } - }); - }, []); + const enqueue = useCallback( + (mode: Mode, item: Item, intoChainParentId?: string) => { + return new Promise((resolve, reject) => { + const req: PendingRequest = { + mode, + item, + resolve, + reject, + succeeded: false, + intoChainParentId, + }; + if (activeRef.current === null) { + activeRef.current = req; + setActive(req); + } else { + queueRef.current.push(req); + } + }); + }, + [], + ); const requestEncryption = useCallback( - (item: Item) => enqueue('encrypt', item), + (item: Item, options?: { intoChainParentId?: string }) => + enqueue('encrypt', item, options?.intoChainParentId), [enqueue], ); const requestDecryption = useCallback( @@ -139,6 +161,7 @@ export function RecursiveEncryptProvider({ children }: { children: ReactNode }) diff --git a/src/frontend/apps/drive/src/features/encryption/recursive/useRecursiveEncryptionJob.ts b/src/frontend/apps/drive/src/features/encryption/recursive/useRecursiveEncryptionJob.ts index dfcca102..4f39393c 100644 --- a/src/frontend/apps/drive/src/features/encryption/recursive/useRecursiveEncryptionJob.ts +++ b/src/frontend/apps/drive/src/features/encryption/recursive/useRecursiveEncryptionJob.ts @@ -25,7 +25,21 @@ import { StagedEncryptFile, } from './types'; -type Mode = 'encrypt' | 'decrypt'; +/** + * Job modes: + * - 'encrypt' : turn an item into a self-rooted encryption tree. + * Mints K_root, wraps per-user, materialises + * ItemAccess rows. Calls PATCH /encrypt/. + * - 'decrypt' : remove encryption from a self-rooted tree. + * Calls PATCH /remove-encryption/. + * - 'encrypt-into-chain': encrypt AND move into an existing encrypted + * subtree atomically. K_root and descendant keys + * are wrapped under the destination's chain only + * — no per-user wraps anywhere. Calls POST /move/ + * with the encrypt-on-move payload (same one-shot + * atomicity contract as /encrypt/). + */ +type Mode = 'encrypt' | 'decrypt' | 'encrypt-into-chain'; type State = { phase: JobPhase; @@ -124,6 +138,13 @@ export type UseRecursiveEncryptionJobArgs = { item: Item; isOpen: boolean; onSuccess?: () => void; + /** + * Required for `mode: 'encrypt-into-chain'`. The destination parent + * the item is moving into — the job fetches its key-chain during + * validation so K_root and descendant keys can be wrapped under the + * destination's chain instead of minted as a fresh root. + */ + intoChainParentId?: string; }; export type UseRecursiveEncryptionJob = { @@ -154,6 +175,7 @@ export function useRecursiveEncryptionJob({ item, isOpen, onSuccess, + intoChainParentId, }: UseRecursiveEncryptionJobArgs): UseRecursiveEncryptionJob { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -176,6 +198,18 @@ export function useRecursiveEncryptionJob({ // Populated during the encrypt folder phase; consumed when building each // file's chain and merged into encryptedKeysForDescendants at commit. const folderWrappedKeysRef = useRef>(new Map()); + // For encrypt-into-chain mode: the destination's key-chain. The + // `parentEntryKey` is K_dest_root wrapped to user (entry into the + // destination's encryption tree); `parentChainToParent` walks from + // K_dest_root down to K_dest_parent, the immediate parent the item + // attaches under. Both are fed to the vault so it can mint K_root + // wrapped under K_dest_parent in one call. + const parentEntryKeyRef = useRef(null); + const parentChainToParentRef = useRef([]); + // Wrap of the moved-subtree's root under the destination chain — the + // top-level chain wrap shipped on `encrypted_symmetric_key` in the + // /move/ payload. + const intoChainRootWrapRef = useRef(null); useEffect(() => { if (!isOpen) { @@ -188,6 +222,9 @@ export function useRecursiveEncryptionJob({ rootEncryptedKeysRef.current = {}; currentUserRootWrappedRef.current = null; folderWrappedKeysRef.current = new Map(); + parentEntryKeyRef.current = null; + parentChainToParentRef.current = []; + intoChainRootWrapRef.current = null; dispatch({ type: 'RESET' }); } }, [isOpen]); @@ -228,8 +265,11 @@ export function useRecursiveEncryptionJob({ // decrypt what's already plaintext). Applies to both files and // folders — a plaintext folder has nothing to clear on decrypt, // an already-encrypted folder has nothing to mint on encrypt. + // 'encrypt-into-chain' shares the encrypt skip semantics — the + // source is plaintext on entry and the target state is + // encrypted. const alreadyInTargetState = - mode === 'encrypt' ? !!n.item.is_encrypted : !n.item.is_encrypted; + mode === 'decrypt' ? !n.item.is_encrypted : !!n.item.is_encrypted; const shouldSkip = insideInner || alreadyInTargetState; return { id: n.item.id, @@ -237,9 +277,9 @@ export function useRecursiveEncryptionJob({ path: n.pathCrumb, state: shouldSkip ? 'skipped' : 'pending', skipReason: shouldSkip - ? mode === 'encrypt' - ? 'already_encrypted' - : 'not_encrypted' + ? mode === 'decrypt' + ? 'not_encrypted' + : 'already_encrypted' : undefined, }; }); @@ -339,6 +379,59 @@ export function useRecursiveEncryptionJob({ }); } } + } else if (mode === 'encrypt-into-chain') { + // Encrypt-on-move: the only chain entry we need is the + // destination's. No per-user pubkey collection — the moved + // subtree decrypts entirely through the destination's chain, + // so collaborators on the SOURCE side that happened to have + // inherited access don't need wraps materialised. The + // RESTRICTED check is also unnecessary: the destination + // subtree is by definition restricted (encrypted), and the + // moved item inherits that on the way in. + if (!intoChainParentId) { + errors.push( + t( + 'encryption.errors.intochain_missing_parent', + 'Destination parent missing for encrypt-on-move.' + ) + ); + } else { + try { + const driver = getDriver(); + const keyChain = await driver.getKeyChain(intoChainParentId); + if (cancelled) return; + parentEntryKeyRef.current = fromBase64( + keyChain.encrypted_key_for_user + ); + parentChainToParentRef.current = keyChain.chain.map(e => + fromBase64(e.encrypted_symmetric_key) + ); + } catch (err) { + errors.push( + t( + 'encryption.errors.intochain_keychain_failed', + 'Could not retrieve the destination key chain ({{err}}).', + { err: (err as Error).message } + ) + ); + } + } + + // Same no-op guard as encrypt mode: a plaintext file root + // must have content to encrypt; a folder root is meaningful + // even with zero processable descendants (root key minted + // alone is fine). + if ( + item.type === ItemType.FILE && + processableIdsRef.current.length === 0 + ) { + errors.push( + t( + 'encryption.errors.nothing_to_encrypt_file', + 'This file is already encrypted.' + ) + ); + } } else { // For a folder root, decrypting is meaningful even with zero // processable files — the root itself needs its ItemAccess @@ -380,7 +473,7 @@ export function useRecursiveEncryptionJob({ return () => { cancelled = true; }; - }, [isOpen, vaultClient, user?.sub, item, mode, t]); + }, [isOpen, vaultClient, user?.sub, item, mode, intoChainParentId, t]); const confirm = useCallback(async () => { if (!vaultClient || !user?.sub) return; @@ -412,6 +505,28 @@ export function useRecursiveEncryptionJob({ dispatch, onFileStaged: (id, staged) => stagedResults.set(id, staged), }); + } else if (mode === 'encrypt-into-chain') { + folderWrappedKeysRef.current = new Map(); + intoChainRootWrapRef.current = null; + const parentEntryKey = parentEntryKeyRef.current; + if (!parentEntryKey) { + throw new Error( + 'Destination key chain not loaded — encrypt-on-move cannot proceed.' + ); + } + await encryptIntoChainPipeline({ + vaultClient, + rootItem: item, + flat: flatRef.current, + processableIds: processableIdsRef.current, + parentEntryKey, + parentChainToParent: parentChainToParentRef.current, + intoChainRootWrapRef, + folderWrappedKeysRef, + signal: controller.signal, + dispatch, + onFileStaged: (id, staged) => stagedResults.set(id, staged), + }); } else { await decryptPipeline({ vaultClient, @@ -493,6 +608,41 @@ export function useRecursiveEncryptionJob({ encryptedKeysForDescendants, fileKeyMapping, }); + } else if (mode === 'encrypt-into-chain') { + const fileKeyMapping: Record = {}; + const encryptedKeysForDescendants: Record = {}; + // Same merge as encrypt mode — folder wraps first, then file + // wraps. The root's chain wrap goes on the top-level field + // `encrypted_symmetric_key`, NOT in the descendants map. + folderWrappedKeysRef.current.forEach((wk, id) => { + if (id === item.id) return; // skip — that's the root wrap + encryptedKeysForDescendants[id] = toBase64(wk); + }); + stagedResults.forEach((v, id) => { + fileKeyMapping[id] = v.newFilename; + if ( + 'wrappedKey' in v && + v.wrappedKey && + v.wrappedKey.byteLength > 0 + ) { + encryptedKeysForDescendants[id] = toBase64(v.wrappedKey); + } + }); + const rootChainWrap = intoChainRootWrapRef.current; + if (!rootChainWrap) { + throw new Error( + 'Root chain wrap missing after pipeline — encrypt-on-move bug.' + ); + } + if (!intoChainParentId) { + throw new Error('Destination parent missing for encrypt-on-move.'); + } + await driver.moveItemEncryptOnMove(item.id, { + targetItemId: intoChainParentId, + encryptedSymmetricKey: toBase64(rootChainWrap), + encryptedKeysForDescendants, + fileKeyMapping, + }); } else { const fileKeyMapping: Record = {}; stagedResults.forEach((v, id) => { @@ -794,6 +944,239 @@ async function stageOneEncryption({ } } +type EncryptIntoChainPipelineArgs = { + vaultClient: VaultClient; + rootItem: Item; + flat: FlatNode[]; + processableIds: string[]; + parentEntryKey: ArrayBuffer; + parentChainToParent: ArrayBuffer[]; + intoChainRootWrapRef: React.MutableRefObject; + folderWrappedKeysRef: React.MutableRefObject>; + signal: AbortSignal; + dispatch: DispatchFn; + onFileStaged: (id: string, staged: StagedEncryptFile) => void; +}; + +/** + * Encrypt-on-move pipeline. Mirrors `encryptPipeline` (mint-then-stage) + * but every wrap targets the destination's chain instead of a per-user + * key map. The conceptual difference vs. encrypt mode: + * + * - encrypt: K_root is wrapped per-user; descendant wraps stack on + * K_root via `currentUserWrapped` as the SDK entry key. + * - encrypt-into-chain: K_root is wrapped under K_dest_parent (chain + * wrap); descendant wraps stack on K_root, but the SDK entry into + * each call is `parentEntryKey` (K_dest_root wrapped to user) and + * the chain prefix walks K_dest_root → K_dest_parent → K_root → ... + * + * The shared trick is that `folderWrappedKeysRef.current.get(rootItem.id)` + * holds K_root's chain wrap, so `chainForNode` naturally picks it up + * for descendants (it returns the chain from the root's direct child to + * the node's direct parent — and we always prepend the destination + * prefix on every call so K_root sits between). + */ +async function encryptIntoChainPipeline({ + vaultClient, + rootItem, + flat, + processableIds, + parentEntryKey, + parentChainToParent, + intoChainRootWrapRef, + folderWrappedKeysRef, + signal, + dispatch, + onFileStaged, +}: EncryptIntoChainPipelineArgs): Promise { + // Mint K_root wrapped under K_dest_parent. For a folder root we mint + // ahead of any per-file work; for a file root we defer to the + // per-file stage where the file's plaintext goes through the same + // encryptNestedWithoutKey call. + if (rootItem.type === ItemType.FOLDER) { + dispatch({ type: 'UPDATE_ROW', id: rootItem.id, state: 'running' }); + const { wrappedKey } = await vaultClient.encryptNestedWithoutKey( + new ArrayBuffer(0), + parentEntryKey, + parentChainToParent.length > 0 ? parentChainToParent : undefined + ); + if (signal.aborted) throw abortError(); + intoChainRootWrapRef.current = wrappedKey; + // Stash under the root's id so chainForNode picks it up for + // descendants below — chainForNode otherwise excludes the root, + // but here OUR root is just an intermediate folder relative to + // K_dest_root. + folderWrappedKeysRef.current.set(rootItem.id, wrappedKey); + dispatch({ type: 'UPDATE_ROW', id: rootItem.id, state: 'staged' }); + + // Mint nested folders top-down so each chain only references + // already-minted ancestors. + const innerRoots = innerEncryptionRoots(flat, rootItem.id); + const nestedFolders = foldersOnly(flat) + .filter( + n => n.item.id !== rootItem.id && !isInsideInnerRoot(n, innerRoots) + ) + .sort((a, b) => a.depth - b.depth); + for (const folder of nestedFolders) { + if (signal.aborted) throw abortError(); + dispatch({ type: 'UPDATE_ROW', id: folder.item.id, state: 'running' }); + const subtreeChain = chainForNode( + folder, + rootItem.id, + folderWrappedKeysRef.current + ); + // Full chain seen by the SDK: [destPrefix..., K_root_wrap, + // intermediates...]. K_root_wrap is the wrapped key we just + // stashed — chainForNode's exclusion of the root means we have + // to prepend it explicitly. + const fullChain = [ + ...parentChainToParent, + folderWrappedKeysRef.current.get(rootItem.id)!, + ...subtreeChain, + ]; + const { wrappedKey: folderWrap } = + await vaultClient.encryptNestedWithoutKey( + new ArrayBuffer(0), + parentEntryKey, + fullChain + ); + folderWrappedKeysRef.current.set(folder.item.id, folderWrap); + dispatch({ type: 'UPDATE_ROW', id: folder.item.id, state: 'staged' }); + } + } + + const queue = [...processableIds]; + const worker = async (): Promise => { + while (queue.length > 0) { + if (signal.aborted) throw abortError(); + const id = queue.shift(); + if (!id) return; + await stageOneEncryptionIntoChain({ + vaultClient, + rootItem, + flat, + targetId: id, + parentEntryKey, + parentChainToParent, + intoChainRootWrapRef, + folderWrappedKeysRef, + signal, + dispatch, + onFileStaged, + }); + } + }; + await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); +} + +type StageOneEncryptIntoChainArgs = { + vaultClient: VaultClient; + rootItem: Item; + flat: FlatNode[]; + targetId: string; + parentEntryKey: ArrayBuffer; + parentChainToParent: ArrayBuffer[]; + intoChainRootWrapRef: React.MutableRefObject; + folderWrappedKeysRef: React.MutableRefObject>; + signal: AbortSignal; + dispatch: DispatchFn; + onFileStaged: (id: string, staged: StagedEncryptFile) => void; +}; + +async function stageOneEncryptionIntoChain({ + vaultClient, + rootItem, + flat, + targetId, + parentEntryKey, + parentChainToParent, + intoChainRootWrapRef, + folderWrappedKeysRef, + signal, + dispatch, + onFileStaged, +}: StageOneEncryptIntoChainArgs): Promise { + const node = flat.find(n => n.item.id === targetId); + if (!node) throw new Error(`Row ${targetId} not found in tree`); + + dispatch({ type: 'UPDATE_ROW', id: targetId, state: 'running' }); + + try { + if (signal.aborted) throw abortError(); + + const resp = await fetch(node.item.url!, { + credentials: 'include', + signal, + }); + if (!resp.ok) { + throw new Error(`Download failed: ${resp.status}`); + } + const plaintext = await resp.arrayBuffer(); + + let encryptedContent: ArrayBuffer; + let wrappedKey: ArrayBuffer; + + if (rootItem.type === ItemType.FILE && targetId === rootItem.id) { + // File root: K_file gets minted directly under K_dest_parent. + // No subtree-root prefix in the chain — there are no + // intermediate folders for a single-file move. + const { encryptedContent: ct, wrappedKey: wk } = + await vaultClient.encryptNestedWithoutKey( + plaintext, + parentEntryKey, + parentChainToParent.length > 0 ? parentChainToParent : undefined, + { optimizeMemory: true } + ); + encryptedContent = ct; + // Top-level chain wrap stored separately; descendants map stays + // empty (none for a file root). Mirrors how encrypt mode treats + // a file root's wrappedKey as 0-byte and pulls per-user wraps + // out of `rootEncryptedKeysRef` instead. + intoChainRootWrapRef.current = wk; + wrappedKey = new ArrayBuffer(0); + } else { + // Descendant file: chain through dest prefix, K_root, and any + // intermediate folder wraps already in folderWrappedKeysRef. + const subtreeChain = chainForNode( + node, + rootItem.id, + folderWrappedKeysRef.current + ); + const rootWrap = folderWrappedKeysRef.current.get(rootItem.id); + if (!rootWrap) { + throw new Error('Subtree root wrap missing — folder ordering bug.'); + } + const fullChain = [...parentChainToParent, rootWrap, ...subtreeChain]; + const { encryptedContent: ct, wrappedKey: wk } = + await vaultClient.encryptNestedWithoutKey(plaintext, parentEntryKey, fullChain, { + optimizeMemory: true, + }); + encryptedContent = ct; + wrappedKey = wk; + } + + const newFilename = stagedFilename(node.item.title); + const uploadUrl = await getEncryptionUploadUrl( + targetId, + newFilename, + signal + ); + await putToS3(uploadUrl, encryptedContent, signal); + + onFileStaged(targetId, { itemId: targetId, newFilename, wrappedKey }); + dispatch({ type: 'UPDATE_ROW', id: targetId, state: 'staged' }); + } catch (err) { + if ((err as Error).name === 'AbortError') throw err; + dispatch({ + type: 'UPDATE_ROW', + id: targetId, + state: 'failed', + error: (err as Error).message, + }); + throw err; + } +} + type DecryptPipelineArgs = { vaultClient: VaultClient; rootItem: Item; diff --git a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx index 7243db61..f8c8b824 100644 --- a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx +++ b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx @@ -27,17 +27,26 @@ export const useMoveItems = () => { /** * Move a single item, intercepting the one encryption-boundary case - * the driver can't handle in-line: plaintext → encrypted folder. The - * driver throws `MoveRequiresEncryption('plaintext-into-encrypted')`, - * we open the recursive-encryption modal for the source first, then - * retry the move on success. + * the driver can't handle in-line: plaintext → encrypted folder. + * The driver throws `MoveRequiresEncryption('plaintext-into-encrypted')`; + * we route the request through the recursive-encryption modal in + * encrypt-on-move mode. That modal does encryption AND the move in a + * single atomic backend call (POST /move/ with the encrypt-on-move + * payload), so there is NO retry afterwards — `requestEncryption` + * resolving means both happened. * - * Other cross-boundary cases are now handled by the driver itself: - * - encrypted → encrypted (same root): in-line rewrap of the - * item's K under the new parent's chain. - * - encrypted → plaintext (or workspace root): in-line re-anchor - * as its own encryption root (per-user wraps via `shareKeys`), - * no modal involved, no decryption. + * The encrypt-on-move route avoids the "encrypt-in-place then demote" + * sequence's main waste: that flow materialised ItemAccess rows for + * every inherited collaborator at the source location, then preserved + * those rows after demoting into the chain (so the chain user the + * file ended up under inherited a pile of stale per-user wraps from + * users that had nothing to do with the destination tree). The + * encrypt-on-move path produces a chain-rooted item with no per-user + * wraps anywhere — clean state on arrival. + * + * Other cross-boundary cases are still handled by the driver itself: + * - encrypted → encrypted (same root): in-line rewrap. + * - encrypted → plaintext / workspace root: in-line re-anchor. * - cross-root remains an error the caller surfaces verbatim. */ const moveOne = async (id: string, parentId?: string): Promise => { @@ -50,32 +59,24 @@ export const useMoveItems = () => { ) { throw e; } + // Encrypt-on-move requires a destination (the chain to attach + // under). Workspace-root has no chain, so a plaintext-into-root + // move shouldn't reach here anyway — defensively throw if it does. + if (!parentId) { + throw new Error( + 'plaintext-into-encrypted reported without a destination — driver bug.', + ); + } const item = await driver.getItem(id); try { - await requestEncryption(item); + await requestEncryption(item, { intoChainParentId: parentId }); } catch (modalErr) { if (modalErr instanceof EncryptionRequestCancelled) { return; // User closed the modal — abort the move silently. } throw modalErr; } - // After encryption the item is self-rooted; the retry routes - // through case 4 (demote into chain) on the driver. If the retry - // throws, surface it loudly — silent failures here let the - // optimistic tree update (DnD already moved the node visually) - // diverge from server reality. - try { - await driver.moveItem(id, parentId); - } catch (retryErr) { - console.error( - '[useMoveItem] retry-after-encrypt failed for item', - id, - '→', - parentId, - retryErr, - ); - throw retryErr; - } + // No retry: encrypt-on-move's commit IS the move. } };