mirror of
https://github.com/suitenumerique/drive.git
synced 2026-09-28 20:45:13 +02:00
wip move working
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
+389
-6
@@ -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.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user