wip move working

This commit is contained in:
Thomas Ramé
2026-04-29 15:01:53 +02:00
parent 4a659c1ea7
commit dcab5a45bd
10 changed files with 1106 additions and 157 deletions
+36 -8
View File
@@ -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": "<base64>"}
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": "<base64 chain wrap of K_item>",
"encrypted_keys_for_descendants": {"<uuid>": "<base64>", ...},
"file_key_mapping": {"<uuid>": "<new S3 filename>", ...}}
- 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,
+269 -78
View File
@@ -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
@@ -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
@@ -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
@@ -211,6 +211,26 @@ export abstract class Driver {
itemId: string,
data?: { fileKeyMapping?: Record<string, string> }
): Promise<Item>;
/**
* 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<string, string>;
fileKeyMapping?: Record<string, string>;
}
): Promise<void>;
abstract getKeyChain(itemId: string): Promise<{
user_access_item_id: string;
encrypted_key_for_user: string;
@@ -786,21 +786,51 @@ export class StandardDriver extends Driver {
fileKeyMapping?: Record<string, string>;
},
): Promise<Item> {
// 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<string, string>;
fileKeyMapping?: Record<string, string>;
},
): Promise<void> {
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<string, string> },
): Promise<Item> {
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);
@@ -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')}
</Button>
)}
{job.phase === 'failed' && (
@@ -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<void>;
requestEncryption: (
item: Item,
options?: { intoChainParentId?: string },
) => Promise<void>;
/**
* 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<void>((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<void>((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 })
<ModalRecursiveEncrypt
isOpen
item={active.item}
intoChainParentId={active.intoChainParentId}
onSuccess={handleSuccess}
onClose={handleClose}
/>
@@ -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<Map<string, ArrayBuffer>>(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<ArrayBuffer | null>(null);
const parentChainToParentRef = useRef<ArrayBuffer[]>([]);
// 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<ArrayBuffer | null>(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<string, string> = {};
const encryptedKeysForDescendants: Record<string, string> = {};
// 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<string, string> = {};
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<ArrayBuffer | null>;
folderWrappedKeysRef: React.MutableRefObject<Map<string, ArrayBuffer>>;
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<void> {
// 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<void> => {
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<ArrayBuffer | null>;
folderWrappedKeysRef: React.MutableRefObject<Map<string, ArrayBuffer>>;
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<void> {
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;
@@ -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<void> => {
@@ -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.
}
};