diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index ff12f042..366e155f 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -105,8 +105,8 @@ class ItemAccessSerializer(serializers.ModelSerializer): encrypted_item_symmetric_key_for_user = serializers.CharField( required=False, allow_blank=True, write_only=True ) - encryption_public_key_fingerprint = serializers.CharField( - required=False, allow_blank=True, max_length=16 + encryption_public_key_version = serializers.IntegerField( + required=False, allow_null=True, min_value=1 ) is_pending_encryption = serializers.SerializerMethodField(read_only=True) @@ -126,7 +126,7 @@ class ItemAccessSerializer(serializers.ModelSerializer): "item", "is_explicit", "encrypted_item_symmetric_key_for_user", - "encryption_public_key_fingerprint", + "encryption_public_key_version", "is_pending_encryption", ] read_only_fields = [ @@ -253,18 +253,18 @@ class ListItemSerializer(serializers.ModelSerializer): is_pending_encryption_for_user = serializers.SerializerMethodField( read_only=True ) - encryption_public_key_fingerprint_for_user = serializers.SerializerMethodField( + encryption_public_key_version_for_user = serializers.SerializerMethodField( read_only=True ) - def get_encryption_public_key_fingerprint_for_user(self, item): - """Fingerprint of the user's public key AT THE TIME they were - granted access. Stored on the ItemAccess row that holds their - wrapped symmetric key. + def get_encryption_public_key_version_for_user(self, item): + """Version of the user's encryption public key AT THE TIME they + were granted access. Stored on the ItemAccess row that holds + their wrapped symmetric key. Clients use this to tell the user which key the document was encrypted for if decryption fails with "wrong secret key" — - the user can compare it to their current key's fingerprint and + the user can compare it to their current key's version and understand that a collaborator needs to re-add them so the key gets wrapped against their current public key. @@ -283,7 +283,7 @@ class ListItemSerializer(serializers.ModelSerializer): user=request.user, encrypted_item_symmetric_key_for_user__isnull=False, ) - .values_list("encryption_public_key_fingerprint", flat=True) + .values_list("encryption_public_key_version", flat=True) .first() ) @@ -390,7 +390,7 @@ class ListItemSerializer(serializers.ModelSerializer): "is_encryption_root", "is_inside_encrypted_subtree", "is_pending_encryption_for_user", - "encryption_public_key_fingerprint_for_user", + "encryption_public_key_version_for_user", "is_favorite", "link_role", "link_reach", @@ -430,7 +430,7 @@ class ListItemSerializer(serializers.ModelSerializer): "is_encryption_root", "is_inside_encrypted_subtree", "is_pending_encryption_for_user", - "encryption_public_key_fingerprint_for_user", + "encryption_public_key_version_for_user", "is_favorite", "link_role", "link_reach", @@ -665,7 +665,7 @@ class ItemSerializer(ListItemSerializer): "is_wopi_supported", "encrypted_item_symmetric_key_for_user", "is_pending_encryption_for_user", - "encryption_public_key_fingerprint_for_user", + "encryption_public_key_version_for_user", "accesses_user_ids", ] read_only_fields = [ @@ -702,7 +702,7 @@ class ItemSerializer(ListItemSerializer): "is_wopi_supported", "encrypted_item_symmetric_key_for_user", "is_pending_encryption_for_user", - "encryption_public_key_fingerprint_for_user", + "encryption_public_key_version_for_user", "accesses_user_ids", ] @@ -1069,7 +1069,7 @@ class MoveItemSerializer(serializers.Serializer): {"target_item_id": "..." | null, "is_encryption_root": true, "per_user_encrypted_keys": {"": "" | null, ...}, - "encryption_public_key_fingerprints": {"": "" | null, ...}} + "encryption_public_key_versions": {"": | null, ...}} - The caller's own sub MUST map to a non-null wrapped key. Other users may be `null` (access row stored as pending until they finish encryption onboarding — symmetric to /encrypt/). @@ -1107,8 +1107,8 @@ class MoveItemSerializer(serializers.Serializer): child=serializers.CharField(allow_null=True), required=False, ) - encryption_public_key_fingerprints = serializers.DictField( - child=serializers.CharField(allow_null=True, allow_blank=True, max_length=16), + encryption_public_key_versions = serializers.DictField( + child=serializers.IntegerField(allow_null=True, min_value=1), required=False, ) # Encrypt-on-move payload (plaintext → encrypted). Snake_case here @@ -1167,10 +1167,10 @@ class AcceptEncryptionAccessSerializer(serializers.Serializer): "→ validated. To revert, delete the access row instead." ), ) - encryption_public_key_fingerprint = serializers.CharField( + encryption_public_key_version = serializers.IntegerField( required=True, - allow_blank=False, - max_length=16, + allow_null=False, + min_value=1, ) @@ -1198,29 +1198,27 @@ class EncryptItemSerializer(serializers.Serializer): "never null." ), ) - encryption_public_key_fingerprint_per_user = serializers.DictField( - # Required: the client must send a fingerprint entry for every + encryption_public_key_version_per_user = serializers.DictField( + # Required: the client must send a version entry for every # user it sent a wrapped-key entry for. Symmetric keys and - # fingerprints travel as matched pairs — keeping them coupled + # versions travel as matched pairs — keeping them coupled # means an encrypted access row always has the "what key was - # this wrapped for" display hint stored alongside. + # this wrapped for" staleness marker stored alongside. # # Not security-sensitive in the crypto sense — the actual wrap - # is the wrapped key itself. The fingerprint is a display hint + # is the wrapped key itself. The version is a staleness marker # surfaced in the client's key-mismatch panel. Since it comes # from the encrypting client, we trust it as we trust any # other client-provided metadata; a malicious client could # send wrong values, but the worst it achieves is confusing # the very user whose client was sending the lie. - child=serializers.CharField( - allow_null=True, allow_blank=True, max_length=16 - ), + child=serializers.IntegerField(allow_null=True, min_value=1), required=True, help_text=( - "Mapping of user OIDC sub → fingerprint of their public key " - "at encryption time. Must cover the same set of users as " + "Mapping of user OIDC sub → version of their encryption public " + "key at encryption time. Must cover the same set of users as " "`encrypted_symmetric_key_per_user`; null is valid for " - "pending users (no public key to fingerprint yet)." + "pending users (no public key to version yet)." ), ) encrypted_keys_for_descendants = serializers.DictField( diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 19994951..0ec7e31e 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1050,8 +1050,8 @@ class ItemViewSet( encrypted_symmetric_key = validated_data.get("encrypted_symmetric_key") is_encryption_root_flag = validated_data.get("is_encryption_root") per_user_encrypted_keys = validated_data.get("per_user_encrypted_keys") - fingerprint_per_user = validated_data.get( - "encryption_public_key_fingerprints", {} + version_per_user = validated_data.get( + "encryption_public_key_versions", {} ) # Encrypt-on-move payload: the presence of either field is the # signal we're looking at the plaintext-into-chain shape (an @@ -1268,25 +1268,25 @@ class ItemViewSet( code="item_move_caller_wrap_required", ) - # Per-user fingerprints must cover the same set as the wraps. + # Per-user versions must cover the same set as the wraps. provided_user_subs = set(per_user_encrypted_keys.keys()) - fingerprint_subs = set(fingerprint_per_user.keys()) - if fingerprint_subs != provided_user_subs: - fp_missing = provided_user_subs - fingerprint_subs - fp_extra = fingerprint_subs - provided_user_subs + version_subs = set(version_per_user.keys()) + if version_subs != provided_user_subs: + version_missing = provided_user_subs - version_subs + version_extra = version_subs - provided_user_subs errors = {} - if fp_missing: - errors["missing_users"] = sorted(fp_missing) - if fp_extra: - errors["extra_users"] = sorted(fp_extra) + if version_missing: + errors["missing_users"] = sorted(version_missing) + if version_extra: + errors["extra_users"] = sorted(version_extra) raise drf.exceptions.ValidationError( { - "encryption_public_key_fingerprints": _( - "Fingerprint set must match per-user key set." + "encryption_public_key_versions": _( + "Version set must match per-user key set." ), **errors, }, - code="item_move_fingerprints_mismatch", + code="item_move_versions_mismatch", ) elif is_encryption_root_flag is False: @@ -1395,13 +1395,13 @@ class ItemViewSet( access.encrypted_item_symmetric_key_for_user = ( per_user_encrypted_keys[user_sub] ) - access.encryption_public_key_fingerprint = ( - fingerprint_per_user.get(user_sub) or None + access.encryption_public_key_version = ( + version_per_user.get(user_sub) or None ) access.save( update_fields=[ "encrypted_item_symmetric_key_for_user", - "encryption_public_key_fingerprint", + "encryption_public_key_version", ] ) remaining_user_subs.discard(user_sub) @@ -1428,8 +1428,8 @@ class ItemViewSet( encrypted_item_symmetric_key_for_user=per_user_encrypted_keys[ user_sub ], - encryption_public_key_fingerprint=( - fingerprint_per_user.get(user_sub) or None + encryption_public_key_version=( + version_per_user.get(user_sub) or None ), ) @@ -2162,27 +2162,27 @@ class ItemViewSet( # over the role they currently hold via inheritance so permissions # don't change — the new ItemAccess exists purely to hold the # encrypted key material. - # Per-user fingerprint map — required, must cover the same set + # Per-user version map — required, must cover the same set # of user subs as the wrapped-key map. Stored alongside the # wrapped key so clients can later tell which key the file was # encrypted for (surfaced in the "key mismatch" panel when # decrypt fails on a rotated key). - fingerprint_per_user = serializer.validated_data[ - "encryption_public_key_fingerprint_per_user" + version_per_user = serializer.validated_data[ + "encryption_public_key_version_per_user" ] - fingerprint_subs = set(fingerprint_per_user.keys()) - if fingerprint_subs != provided_user_subs: - fp_missing = provided_user_subs - fingerprint_subs - fp_extra = fingerprint_subs - provided_user_subs + version_subs = set(version_per_user.keys()) + if version_subs != provided_user_subs: + version_missing = provided_user_subs - version_subs + version_extra = version_subs - provided_user_subs errors = {} - if fp_missing: - errors["missing_users"] = list(fp_missing) - if fp_extra: - errors["extra_users"] = list(fp_extra) + if version_missing: + errors["missing_users"] = list(version_missing) + if version_extra: + errors["extra_users"] = list(version_extra) return drf.response.Response( { "detail": _( - "Provided fingerprints do not match the users in " + "Provided versions do not match the users in " "encrypted_symmetric_key_per_user." ), **errors, @@ -2200,11 +2200,11 @@ class ItemViewSet( user_sub ] update_fields = ["encrypted_item_symmetric_key_for_user"] - if user_sub in fingerprint_per_user: - access.encryption_public_key_fingerprint = ( - fingerprint_per_user[user_sub] or None + if user_sub in version_per_user: + access.encryption_public_key_version = ( + version_per_user[user_sub] or None ) - update_fields.append("encryption_public_key_fingerprint") + update_fields.append("encryption_public_key_version") access.save(update_fields=update_fields) remaining_user_subs.discard(user_sub) @@ -2228,8 +2228,8 @@ class ItemViewSet( encrypted_item_symmetric_key_for_user=encrypted_key_per_user[ user_sub ], - encryption_public_key_fingerprint=( - fingerprint_per_user.get(user_sub) or None + encryption_public_key_version=( + version_per_user.get(user_sub) or None ), ) @@ -2368,7 +2368,7 @@ class ItemViewSet( # Clear all per-user encrypted keys on this item's accesses models.ItemAccess.objects.filter(item=item).update( encrypted_item_symmetric_key_for_user=None, - encryption_public_key_fingerprint=None, + encryption_public_key_version=None, ) # Collect file items in the effective scope for post-commit @@ -3157,13 +3157,13 @@ class ItemAccessViewSet( access.encrypted_item_symmetric_key_for_user = ( serializer.validated_data["encrypted_item_symmetric_key_for_user"] ) - access.encryption_public_key_fingerprint = ( - serializer.validated_data["encryption_public_key_fingerprint"] + access.encryption_public_key_version = ( + serializer.validated_data["encryption_public_key_version"] ) access.save( update_fields=[ "encrypted_item_symmetric_key_for_user", - "encryption_public_key_fingerprint", + "encryption_public_key_version", ] ) diff --git a/src/backend/core/migrations/0022_itemaccess_encryption_public_key_version.py b/src/backend/core/migrations/0022_itemaccess_encryption_public_key_version.py new file mode 100644 index 00000000..20694cca --- /dev/null +++ b/src/backend/core/migrations/0022_itemaccess_encryption_public_key_version.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0021_item_encryption_fields"), + ] + + operations = [ + migrations.RemoveField( + model_name="itemaccess", + name="encryption_public_key_fingerprint", + ), + migrations.AddField( + model_name="itemaccess", + name="encryption_public_key_version", + field=models.PositiveIntegerField( + blank=True, + help_text=( + "Version of the user's encryption public key at the time of sharing. " + "Used to detect key changes — if the user's current public key version " + "differs from this value, the access needs re-encryption." + ), + null=True, + verbose_name="encryption public key version", + ), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index b72103f5..21190d2e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1223,13 +1223,13 @@ class ItemAccess(BaseModel): "This is the user's entry point into the key chain." ), ) - encryption_public_key_fingerprint = models.CharField( - max_length=16, + encryption_public_key_version = models.PositiveIntegerField( + _("encryption public key version"), null=True, blank=True, help_text=_( - "Fingerprint of the user's public key at the time of sharing. " - "Used to detect key changes — if the user's current public key fingerprint " + "Version of the user's encryption public key at the time of sharing. " + "Used to detect key changes — if the user's current public key version " "differs from this value, the access needs re-encryption." ), ) diff --git a/src/backend/core/tests/items/test_api_items_encrypt.py b/src/backend/core/tests/items/test_api_items_encrypt.py index 61b1ce82..7ce4284d 100644 --- a/src/backend/core/tests/items/test_api_items_encrypt.py +++ b/src/backend/core/tests/items/test_api_items_encrypt.py @@ -79,7 +79,7 @@ def test_api_items_encrypt_standalone_file(): f"/api/v1.0/items/{item.id!s}/encrypt/", { "encrypted_symmetric_key_per_user": {user.sub: "encrypted_key_for_user"}, - "encryption_public_key_fingerprint_per_user": {user.sub: "fp"}, + "encryption_public_key_version_per_user": {user.sub: 1}, "encrypted_keys_for_descendants": {}, }, format="json", @@ -118,7 +118,7 @@ def test_api_items_encrypt_folder_with_children(): f"/api/v1.0/items/{folder.id!s}/encrypt/", { "encrypted_symmetric_key_per_user": {user.sub: "root_key_for_user"}, - "encryption_public_key_fingerprint_per_user": {user.sub: "fp"}, + "encryption_public_key_version_per_user": {user.sub: 1}, "encrypted_keys_for_descendants": { str(subfolder.pk): "subfolder_wrapped_key", str(file_item.pk): "file_wrapped_key", @@ -202,7 +202,7 @@ def test_api_items_encrypt_missing_user_keys(): f"/api/v1.0/items/{item.id!s}/encrypt/", { "encrypted_symmetric_key_per_user": {user1.sub: "key1"}, - "encryption_public_key_fingerprint_per_user": {user1.sub: "fp1"}, + "encryption_public_key_version_per_user": {user1.sub: 1}, }, format="json", ) @@ -388,7 +388,7 @@ def test_api_items_key_chain_prefers_root_over_hybrid_wrap(): user=user, role="owner", encrypted_item_symmetric_key_for_user="hybrid_side_door_wrap", - encryption_public_key_fingerprint="fp", + encryption_public_key_version=1, ) client = APIClient() @@ -439,7 +439,7 @@ def test_api_items_key_chain_falls_back_to_hybrid_for_outsiders(): user=outsider, role="reader", encrypted_item_symmetric_key_for_user="outsider_wrap", - encryption_public_key_fingerprint="fp-outsider", + encryption_public_key_version=4, ) client = APIClient() diff --git a/src/backend/core/tests/items/test_api_items_move.py b/src/backend/core/tests/items/test_api_items_move.py index 9b79e40a..025b3d74 100644 --- a/src/backend/core/tests/items/test_api_items_move.py +++ b/src/backend/core/tests/items/test_api_items_move.py @@ -859,7 +859,7 @@ def _encrypted_root(user, role="owner"): user=user, role=role, encrypted_item_symmetric_key_for_user="WRAP-USER-ROOT", - encryption_public_key_fingerprint="fp-root", + encryption_public_key_version=1, ) return item @@ -953,7 +953,7 @@ def test_api_items_move_re_anchor_to_plaintext(): user=other, role="reader", encrypted_item_symmetric_key_for_user="WRAP-OTHER-ROOT", - encryption_public_key_fingerprint="fp-other", + encryption_public_key_version=2, ) file_item = _encrypted_child(root, item_type=models.ItemTypeChoices.FILE) @@ -971,9 +971,9 @@ def test_api_items_move_re_anchor_to_plaintext(): user.sub: "WRAP-USER-NEW-ROOT", other.sub: "WRAP-OTHER-NEW-ROOT", }, - "encryption_public_key_fingerprints": { - user.sub: "fp-user", - other.sub: "fp-other", + "encryption_public_key_versions": { + user.sub: 3, + other.sub: 2, }, }, format="json", @@ -988,7 +988,7 @@ def test_api_items_move_re_anchor_to_plaintext(): # item with the wrap. user_access = models.ItemAccess.objects.get(item=file_item, user=user) assert user_access.encrypted_item_symmetric_key_for_user == "WRAP-USER-NEW-ROOT" - assert user_access.encryption_public_key_fingerprint == "fp-user" + assert user_access.encryption_public_key_version == 3 other_access = models.ItemAccess.objects.get(item=file_item, user=other) assert other_access.encrypted_item_symmetric_key_for_user == "WRAP-OTHER-NEW-ROOT" @@ -1008,7 +1008,7 @@ def test_api_items_move_re_anchor_with_pending_collaborator(): factories.UserItemAccessFactory( item=root, user=pending, role="reader", encrypted_item_symmetric_key_for_user=None, # pending on root too - encryption_public_key_fingerprint=None, + encryption_public_key_version=None, ) file_item = _encrypted_child(root, item_type=models.ItemTypeChoices.FILE) @@ -1025,8 +1025,8 @@ def test_api_items_move_re_anchor_with_pending_collaborator(): user.sub: "WRAP-USER-NEW-ROOT", pending.sub: None, }, - "encryption_public_key_fingerprints": { - user.sub: "fp-user", + "encryption_public_key_versions": { + user.sub: 3, pending.sub: None, }, }, @@ -1036,7 +1036,7 @@ def test_api_items_move_re_anchor_with_pending_collaborator(): assert response.status_code == 200, response.json() pending_access = models.ItemAccess.objects.get(item=file_item, user=pending) assert pending_access.encrypted_item_symmetric_key_for_user is None - assert pending_access.encryption_public_key_fingerprint is None + assert pending_access.encryption_public_key_version is None def test_api_items_move_re_anchor_caller_must_have_wrap(): @@ -1062,7 +1062,7 @@ def test_api_items_move_re_anchor_caller_must_have_wrap(): "target_item_id": str(plain_target.id), "is_encryption_root": True, "per_user_encrypted_keys": {user.sub: None}, - "encryption_public_key_fingerprints": {user.sub: None}, + "encryption_public_key_versions": {user.sub: None}, }, format="json", ) @@ -1087,7 +1087,7 @@ def test_api_items_move_re_anchor_into_encrypted_rejected(): "target_item_id": str(root_b.id), "is_encryption_root": True, "per_user_encrypted_keys": {user.sub: "WRAP"}, - "encryption_public_key_fingerprints": {user.sub: "fp"}, + "encryption_public_key_versions": {user.sub: 1}, }, format="json", ) @@ -1116,7 +1116,7 @@ def test_api_items_move_demote_self_rooted_into_chain(): user=user, role="owner", encrypted_item_symmetric_key_for_user="WRAP-USER-FILE-ROOT", - encryption_public_key_fingerprint="fp-user", + encryption_public_key_version=3, ) dest_root = _encrypted_root(user) @@ -1142,7 +1142,7 @@ def test_api_items_move_demote_self_rooted_into_chain(): # — chain users use the chain, originals use their per-user wrap. user_access = models.ItemAccess.objects.get(item=file_item, user=user) assert user_access.encrypted_item_symmetric_key_for_user == "WRAP-USER-FILE-ROOT" - assert user_access.encryption_public_key_fingerprint == "fp-user" + assert user_access.encryption_public_key_version == 3 def test_api_items_move_demote_preserves_outsider_wrap(): @@ -1167,12 +1167,12 @@ def test_api_items_move_demote_preserves_outsider_wrap(): factories.UserItemAccessFactory( item=file_item, user=user, role="owner", encrypted_item_symmetric_key_for_user="WRAP-USER-FILE", - encryption_public_key_fingerprint="fp-user", + encryption_public_key_version=3, ) factories.UserItemAccessFactory( item=file_item, user=outsider, role="reader", encrypted_item_symmetric_key_for_user="WRAP-OUTSIDER-FILE", - encryption_public_key_fingerprint="fp-outsider", + encryption_public_key_version=4, ) # Destination tree only `user` has access to. @@ -1194,7 +1194,7 @@ def test_api_items_move_demote_preserves_outsider_wrap(): # their wrap on this item directly. outsider_access = models.ItemAccess.objects.get(item=file_item, user=outsider) assert outsider_access.encrypted_item_symmetric_key_for_user == "WRAP-OUTSIDER-FILE" - assert outsider_access.encryption_public_key_fingerprint == "fp-outsider" + assert outsider_access.encryption_public_key_version == 4 def test_api_items_move_demote_requires_chain_wrap(): @@ -1210,7 +1210,7 @@ def test_api_items_move_demote_requires_chain_wrap(): ) factories.UserItemAccessFactory( item=file_item, user=user, role="owner", - encrypted_item_symmetric_key_for_user="WRAP", encryption_public_key_fingerprint="fp", + encrypted_item_symmetric_key_for_user="WRAP", encryption_public_key_version=1, ) dest_root = _encrypted_root(user) @@ -1240,7 +1240,7 @@ def test_api_items_move_demote_to_plaintext_rejected(): ) factories.UserItemAccessFactory( item=file_item, user=user, role="owner", - encrypted_item_symmetric_key_for_user="WRAP", encryption_public_key_fingerprint="fp", + encrypted_item_symmetric_key_for_user="WRAP", encryption_public_key_version=1, ) plain_target = factories.ItemFactory( type=models.ItemTypeChoices.FOLDER, users=[(user, "owner")], diff --git a/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts b/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts index 16925298..9d52c151 100644 --- a/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts +++ b/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts @@ -5,7 +5,7 @@ export type DTOCreateAccess = { userId: string; role: Role; encrypted_item_symmetric_key_for_user?: string; - encryption_public_key_fingerprint?: string; + encryption_public_key_version?: number; }; export type DTOUpdateAccess = { diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index 9264ee7c..a4d05898 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -202,7 +202,7 @@ export abstract class Driver { itemId: string, data: { encryptedSymmetricKeyPerUser: Record; - encryptionPublicKeyFingerprintPerUser: Record; + encryptionPublicKeyVersionPerUser: Record; encryptedKeysForDescendants: Record; fileKeyMapping?: Record; } @@ -241,7 +241,7 @@ export abstract class Driver { accessId: string, data: { encrypted_item_symmetric_key_for_user: string; - encryption_public_key_fingerprint: string; + encryption_public_key_version: number; } ): Promise; } diff --git a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts index 4d49d202..062ca413 100644 --- a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts +++ b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts @@ -1,5 +1,6 @@ import { fetchAPI } from "@/features/api/fetchApi"; import { fromBase64, toBase64 } from "@/features/encryption/recursive/binary"; +import { fetchRegisteredKeys } from "@/features/encryption/fetchRegisteredKeys"; import { Driver, Entitlements, @@ -213,10 +214,10 @@ export class StandardDriver extends Driver { // via the share UI once the user has onboarded. let perUserEncryptedKeys: Record | undefined; // Matched pair with `perUserEncryptedKeys`: same key set, value is - // the fingerprint of the user's pubkey at wrap time (or null when - // the user is pending). Backend stores it on the access row so + // the version of the user's encryption pubkey at wrap time (or null + // when the user is pending). Backend stores it on the access row so // clients can later detect a key-rotation mismatch. - let perUserFingerprints: Record | undefined; + let perUserVersions: Record | undefined; let isEncryptionRoot: boolean | undefined; // present iff the move toggles the flag if (sourceEncrypted && targetEncrypted && sourceIsRoot) { @@ -327,7 +328,8 @@ export class StandardDriver extends Driver { 'No users with access — cannot re-anchor item as its own encryption root.', ); } - const { publicKeys } = await vaultClient.fetchPublicKeys(userSubs); + const { publicKeys, versions } = + await fetchRegisteredKeys(userSubs); const usersWithKeys = new Set(Object.keys(publicKeys)); // The only catastrophic case is the OPERATOR (the user doing @@ -352,25 +354,35 @@ export class StandardDriver extends Driver { } // Wrap K_item only for users who have a pubkey; the rest get a - // `null` placeholder that the backend will treat as pending. + // `null` placeholder that the backend will treat as pending. Pass a + // labeled recipient map (sub → {email, name}) built from the same + // access rows — the vault resolves + trust-checks each key (binding + + // TOFU); the labels are display-only, shown if the trust modal opens. + const recipients: Record = {}; + for (const a of accesses) { + if (a.user.sub && publicKeys[a.user.sub]) { + recipients[a.user.sub] = { + email: a.user.email, + name: a.user.full_name, + }; + } + } const { encryptedKeys } = await vaultClient.shareKeys( entryKey, - publicKeys, + recipients, chainToItem.length > 0 ? chainToItem : undefined, ); perUserEncryptedKeys = {}; - // Fingerprints travel as matched pairs with the per-user wraps — + // Versions travel as matched pairs with the per-user wraps — // the backend writes `(encrypted_item_symmetric_key_for_user, - // encryption_public_key_fingerprint)` together on each access + // encryption_public_key_version)` together on each access // row, identical to /encrypt/. - perUserFingerprints = {}; + perUserVersions = {}; for (const userSub of userSubs) { const wrap = encryptedKeys[userSub]; perUserEncryptedKeys[userSub] = wrap ? toBase64(wrap) : null; const pubKey = publicKeys[userSub]; - perUserFingerprints[userSub] = pubKey - ? await vaultClient.computeKeyFingerprint(pubKey) - : null; + perUserVersions[userSub] = pubKey ? versions[userSub] : null; } const pendingCount = userSubs.length - usersWithKeys.size; if (pendingCount > 0) { @@ -394,8 +406,8 @@ export class StandardDriver extends Driver { ...(perUserEncryptedKeys ? { per_user_encrypted_keys: perUserEncryptedKeys } : {}), - ...(perUserFingerprints - ? { encryption_public_key_fingerprints: perUserFingerprints } + ...(perUserVersions + ? { encryption_public_key_versions: perUserVersions } : {}), }; await fetchAPI(`items/${id}/move/`, { @@ -419,9 +431,9 @@ export class StandardDriver extends Driver { body.encrypted_item_symmetric_key_for_user = data.encrypted_item_symmetric_key_for_user; } - if (data.encryption_public_key_fingerprint) { - body.encryption_public_key_fingerprint = - data.encryption_public_key_fingerprint; + if (data.encryption_public_key_version) { + body.encryption_public_key_version = + data.encryption_public_key_version; } await fetchAPI(`items/${data.itemId}/accesses/`, { method: "POST", @@ -781,7 +793,7 @@ export class StandardDriver extends Driver { itemId: string, data: { encryptedSymmetricKeyPerUser: Record; - encryptionPublicKeyFingerprintPerUser: Record; + encryptionPublicKeyVersionPerUser: Record; encryptedKeysForDescendants: Record; fileKeyMapping?: Record; }, @@ -792,8 +804,8 @@ export class StandardDriver extends Driver { method: "PATCH", body: JSON.stringify({ encrypted_symmetric_key_per_user: data.encryptedSymmetricKeyPerUser, - encryption_public_key_fingerprint_per_user: - data.encryptionPublicKeyFingerprintPerUser, + encryption_public_key_version_per_user: + data.encryptionPublicKeyVersionPerUser, encrypted_keys_for_descendants: data.encryptedKeysForDescendants, file_key_mapping: data.fileKeyMapping ?? {}, }), @@ -860,7 +872,7 @@ export class StandardDriver extends Driver { accessId: string, data: { encrypted_item_symmetric_key_for_user: string; - encryption_public_key_fingerprint: string; + encryption_public_key_version: number; }, ): Promise { await fetchAPI( diff --git a/src/frontend/apps/drive/src/features/drivers/types.ts b/src/frontend/apps/drive/src/features/drivers/types.ts index 8bec2df7..fbc7fdf3 100644 --- a/src/frontend/apps/drive/src/features/drivers/types.ts +++ b/src/frontend/apps/drive/src/features/drivers/types.ts @@ -58,7 +58,7 @@ export type Item = { is_encryption_root?: boolean; is_inside_encrypted_subtree?: boolean; is_pending_encryption_for_user?: boolean; - encryption_public_key_fingerprint_for_user?: string | null; + encryption_public_key_version_for_user?: number | null; encrypted_item_symmetric_key_for_user?: string; accesses_user_ids?: string[]; is_favorite?: boolean; diff --git a/src/frontend/apps/drive/src/features/encryption/KeyMismatchPanel.tsx b/src/frontend/apps/drive/src/features/encryption/KeyMismatchPanel.tsx index 07e60919..748772dd 100644 --- a/src/frontend/apps/drive/src/features/encryption/KeyMismatchPanel.tsx +++ b/src/frontend/apps/drive/src/features/encryption/KeyMismatchPanel.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { fetchRegisteredKeys } from '@/features/encryption/fetchRegisteredKeys'; + /** * True when the SDK threw a `VaultError` carrying the * `WRONG_SECRET_KEY` code. In drive this almost always means the file @@ -21,29 +23,30 @@ export const isWrongSecretKeyError = ( interface KeyMismatchPanelProps { /** - * Fingerprint stored on the user's access row at share time (i.e. - * the fingerprint of the key the file was actually encrypted for). - * Comes from `item.encryption_public_key_fingerprint_for_user` on - * Drive or `doc.accesses_fingerprints_per_user[currentUser.sub]` on - * Docs. Optional — if absent, only the current key is shown. + * Encryption public key VERSION stored on the user's access row at + * share time (i.e. the version of the key the file was actually + * encrypted for). Comes from + * `item.encryption_public_key_version_for_user` on Drive. Optional — + * if absent, only the current version is shown. This is the per-access + * staleness marker: when it lags behind the user's current version the + * access needs re-wrapping. */ - shareTimeFingerprint?: string | null; + shareTimeVersion?: number | null; } /** * Friendly panel shown when `isWrongSecretKeyError` is true. Explains - * the situation and surfaces BOTH the fingerprint the file was - * encrypted for (stored at share time) and the user's current key's - * fingerprint, so they can see the mismatch concretely and give the - * new fingerprint to whoever re-adds them. + * the situation and surfaces BOTH the key version the file was + * encrypted for (stored at share time) and the user's CURRENT key + * version, so they can see the staleness concretely: the access was + * wrapped for version N of their key, their current version is M, so a + * re-encryption is needed. */ export const KeyMismatchPanel = ({ - shareTimeFingerprint, + shareTimeVersion, }: KeyMismatchPanelProps = {}) => { const { t } = useTranslation(); - const [currentFingerprint, setCurrentFingerprint] = useState( - null - ); + const [currentVersion, setCurrentVersion] = useState(null); useEffect(() => { const vault = window.__driveVaultClient; @@ -51,12 +54,12 @@ export const KeyMismatchPanel = ({ let cancelled = false; (async () => { try { - const { publicKey } = await vault.getPublicKey(); - const raw = await vault.computeKeyFingerprint(publicKey); - const formatted = vault.formatFingerprint(raw); - if (!cancelled) setCurrentFingerprint(formatted); + const sub = vault.getAuthContext()?.suiteUserId; + if (!sub) return; + const { versions } = await fetchRegisteredKeys([sub]); + if (!cancelled) setCurrentVersion(versions[sub] ?? null); } catch { - // Ignore — we just won't show the fingerprint row. + // Ignore — we just won't show the current version row. } })(); return () => { @@ -64,16 +67,9 @@ export const KeyMismatchPanel = ({ }; }, []); - const formatShareTime = (() => { - const vault = window.__driveVaultClient; - if (!shareTimeFingerprint) return null; - if (!vault) return shareTimeFingerprint; - try { - return vault.formatFingerprint(shareTimeFingerprint); - } catch { - return shareTimeFingerprint; - } - })(); + const hasShareTimeVersion = + shareTimeVersion !== undefined && shareTimeVersion !== null; + const hasCurrentVersion = currentVersion !== null; return (
- {(formatShareTime || currentFingerprint) && ( + {(hasShareTimeVersion || hasCurrentVersion) && (
- {formatShareTime && ( + {hasShareTimeVersion && (
{t( - 'explorer.encrypted.key_mismatch.share_time_fingerprint_label', - 'Fingerprint at the time it was shared with you:' + 'explorer.encrypted.key_mismatch.share_time_version_label', + 'Key version at the time it was shared with you:' )}{' '} - {formatShareTime} + {shareTimeVersion}
)} - {currentFingerprint && ( + {hasCurrentVersion && (
{t( - 'explorer.encrypted.key_mismatch.fingerprint_label', - 'Your current key fingerprint:' + 'explorer.encrypted.key_mismatch.current_version_label', + 'Your current key version:' )}{' '} - {currentFingerprint} + {currentVersion}
)} diff --git a/src/frontend/apps/drive/src/features/encryption/VaultClientProvider.tsx b/src/frontend/apps/drive/src/features/encryption/VaultClientProvider.tsx index 965eef8e..be62e47e 100644 --- a/src/frontend/apps/drive/src/features/encryption/VaultClientProvider.tsx +++ b/src/frontend/apps/drive/src/features/encryption/VaultClientProvider.tsx @@ -14,6 +14,16 @@ import { } from './MissingEncryptionKeysModal'; import { ModalEncryptionOnboarding } from './ModalEncryptionOnboarding'; +// Drive-owned handle to the live VaultClient, stashed on window so non-React +// call sites (drivers, deep helpers) can reach it without threading it through. +// Not part of the SDK surface, so it lives here rather than in the vendored +// declaration or its shim. +declare global { + interface Window { + __driveVaultClient: VaultClient | null; + } +} + const VAULT_URL = process.env.NEXT_PUBLIC_VAULT_URL ?? 'http://localhost:7201'; const INTERFACE_URL = process.env.NEXT_PUBLIC_INTERFACE_URL ?? 'http://localhost:7202'; diff --git a/src/frontend/apps/drive/src/features/encryption/client-sdk.d.ts b/src/frontend/apps/drive/src/features/encryption/client-sdk.d.ts new file mode 100644 index 00000000..0f1ffbb3 --- /dev/null +++ b/src/frontend/apps/drive/src/features/encryption/client-sdk.d.ts @@ -0,0 +1,582 @@ +export declare interface AuthContext { + /** + * The identity provider's `sub` claim for the logged-in user. Products only + * ever deal in subs — their own APIs know users by sub, and every SDK + * operation (recipients, fingerprints, profiles) takes subs. The vault + * resolves them to its internal, migration-stable user ids at its boundary + * (local alias map, then public registry); that internal id never has to be + * handled, stored, or even seen by a product. + */ + suiteUserId: string; +} + +export declare interface EncryptionClientEventMap { + /** Fired when the hidden vault iframe is ready for encrypt/decrypt operations */ + [MSG_VAULT_READY]: void; + /** Fired when the user completes onboarding (key generation + backup) */ + 'onboarding:complete': { + publicKey: string; + }; + /** Fired when the user cancels or closes the interface */ + [MSG_INTERFACE_CLOSED]: void; + /** Fired on errors from the vault or the interface */ + error: Error; + /** Fired when keys changed from another tab/product (via BroadcastChannel) */ + 'keys-changed': void; + /** Fired when keys were destroyed from another tab/product (via BroadcastChannel) */ + 'keys-destroyed': void; + /** Fired when a fingerprint is accepted or refused in the local registry */ + 'fingerprint-changed': void; +} + +export declare interface EncryptionClientOptions { + /** URL of the vault domain (data.encryption), e.g. "https://data.encryption.numerique.gouv.fr" */ + vaultUrl: string; + /** URL of the interface domain (encryption), e.g. "https://encryption.numerique.gouv.fr" */ + interfaceUrl: string; + /** Timeout in ms for vault operations (default: 30000) */ + timeout?: number; + /** + * Cunningham theme name for the interface iframe. + * Standard names: "default", "dark", "dsfr", "dsfr-dark", "anct", "anct-dark". + * Default: "default" + */ + theme?: string; + /** Language code for the interface iframe: "fr", "en", etc. (default: browser language) */ + lang?: string; +} + +export declare const isVaultError: (err: unknown) => err is VaultError; + +declare type Listener = (data: EncryptionClientEventMap[K]) => void; + +declare const MSG_INTERFACE_CLOSED = "interface:closed"; + +declare const MSG_VAULT_READY = "vault:ready"; + +export declare type RecipientLabel = { + email: string; + name?: string; +}; + +/** + * Union on `verified` on purpose: `encryptionPublicKey` is a usable + * `ArrayBuffer` only in the verified branch (`null` otherwise), so a caller + * cannot wrap a key for a forged / incoherent directory entry without the + * compiler stopping them. Identity and encryption key live in one per-user + * entry so the two can never disagree for a user. + */ +export declare type RegisteredUser = { + verified: true; + signaturePublicKey: ArrayBuffer; + identityFingerprint: string; + version: number; + createdAtMillis: number; + encryptionPublicKey: ArrayBuffer; +} | { + verified: false; + signaturePublicKey: ArrayBuffer; + identityFingerprint: string; + version: number; + createdAtMillis: number; + encryptionPublicKey: null; +}; + +export declare class VaultClient { + private vaultIframe; + private interfaceIframe; + private pending; + private listeners; + private vaultReadyResolve; + private initTimeoutId; + private messageHandler; + private vaultUrl; + private interfaceUrl; + private vaultOrigin; + private interfaceOrigin; + private timeout; + private theme; + private lang; + private authContext; + private verifyOverlay; + private verifyResolve; + private emergencyOverlay; + private emergencySurfaced; + private emergencyWatchdog; + private pendingContext; + constructor(options: EncryptionClientOptions); + /** + * Update the Cunningham theme. If the interface iframe is open, sends it a + * theme message so it re-themes in place. + * @param theme - Cunningham theme name: "default", "dark", "dsfr", "dsfr-dark", "anct", "anct-dark", etc. + */ + setTheme(theme: string): void; + /** + * Set the authentication context. Must be called before opening the interface. + * The suiteUserId is passed to the interface iframe so it can register + * public keys on the server and perform device transfers. + */ + setAuthContext(context: AuthContext): void; + /** + * Read back the auth context that was set via {@link setAuthContext}. + * Returns `null` until `setAuthContext` has been called. Useful when + * the host app needs to identify the currently-bound suite user from + * code that doesn't otherwise have access to the auth state — e.g. + * the Drive driver's encryption-aware move handler, which has to + * verify the operator (mover) keeps a per-user wrap during a + * re-anchor and would otherwise need to thread the user identifier + * through every call site. + */ + getAuthContext(): AuthContext | null; + /** + * Initialize the encryption client. + * Creates a hidden vault iframe (data.encryption) and waits for it to be ready. + */ + init(): Promise; + /** + * Clean up all iframes and event listeners. + */ + destroy(): void; + /** Check if the user has encryption keys on this device. */ + hasKeys(): Promise<{ + hasKeys: boolean; + }>; + /** Get the user's public key as ArrayBuffer. */ + getPublicKey(): Promise<{ + publicKey: ArrayBuffer; + }>; + /** + * Create a new ROOT encrypted resource. Mints a fresh symmetric key, + * encrypts `data` with it, and wraps the new key once per recipient. + * + * Pass recipients as a labeled map (OIDC sub → {email, name?}), not public + * keys — the subs your product already holds for its users. The vault + * resolves + trust-checks each recipient (binding + TOFU 'trusted' with + * matching fingerprint, keyed internally so trust survives an OIDC provider + * migration) before wrapping, and throws UNTRUSTED_RECIPIENT if any is + * unverified or untrusted. Call checkFingerprints first to resolve any + * 'unknown' contacts. The labels are display-only and used only if the trust + * modal opens; the vault request itself sees just the subs. + * + * Use this for standalone encrypted files (no parent) and for the root + * folder of an encrypted subtree. + * + * @returns encryptedContent and encryptedKeys (userId → wrappedKey) + */ + encryptWithoutKey(data: ArrayBuffer, recipients: Record, options?: { + optimizeMemory?: boolean; + }): Promise<{ + encryptedContent: ArrayBuffer; + encryptedKeys: Record; + }>; + private encryptWithoutKeyRequest; + /** + * Create a new NESTED encrypted resource inside an existing encrypted + * subtree. Resolves `entry + chain` to the parent folder's key, mints a + * fresh symmetric key, encrypts `data` with it, and wraps the new key + * under the parent's key. + * + * Use this for creating a file inside an already-encrypted folder. + * Persist the returned `wrappedKey` on the new item's DB row. + * + * @param data - ArrayBuffer content to encrypt + * @param encryptedSymmetricKey - caller's entry key (asymmetric bootstrap + * into the subtree, same value used for decryptWithKey) + * @param encryptedKeyChain - optional symmetric wrappings from entry down + * to the parent folder (exclusive of the new resource, since it doesn't + * exist yet). Empty/omitted means the entry key IS the parent's key. + */ + encryptNestedWithoutKey(data: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer, encryptedKeyChain?: ArrayBuffer[], options?: { + optimizeMemory?: boolean; + }): Promise<{ + encryptedContent: ArrayBuffer; + wrappedKey: ArrayBuffer; + }>; + /** + * Encrypt content with an EXISTING symmetric key — pure symmetric mirror + * of `decryptWithKey`. No new key is minted. + * + * Without `encryptedKeyChain`: resolves `encryptedSymmetricKey` (user's + * entry key) and encrypts with it. Used by the flat model (Docs). + * + * With `encryptedKeyChain`: resolves entry + chain to the terminal + * symmetric key and encrypts with it. Used by the collaborative relay + * to encrypt messages tied to an existing file inside an encrypted + * hierarchy, so that sender and receiver converge on the same K_file. + */ + encryptWithKey(data: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer, encryptedKeyChain?: ArrayBuffer[], options?: { + optimizeMemory?: boolean; + }): Promise<{ + encryptedData: ArrayBuffer; + }>; + /** + * Decrypt content using a separately provided encrypted symmetric key. + * The symmetric key decryption is cached per session for performance. + * + * @param encryptedData - ArrayBuffer ciphertext to decrypt + * @param encryptedSymmetricKey - user's encrypted copy of the symmetric key + * @param keyVersion - the recipient's encryption-key VERSION this wrap was + * produced against, as stored by the product on the access row. The vault + * unwraps with exactly that retained key (a version this device no longer + * holds throws WRONG_SECRET_KEY). For Drive chains it is the version of the + * ENTRY-point key; the chain links themselves are symmetric. + * @param encryptedKeyChain - optional chain of wrapped keys for Drive's key hierarchy. + * When provided, resolves the chain from entry point to target before decrypting. + */ + decryptWithKey(encryptedData: ArrayBuffer, encryptedSymmetricKey: ArrayBuffer, keyVersion: number, encryptedKeyChain?: ArrayBuffer[], options?: { + optimizeMemory?: boolean; + }): Promise<{ + data: ArrayBuffer; + }>; + /** + * Re-wrap a nested resource's symmetric key from one parent chain onto + * another. Used when MOVING an encrypted file/folder between positions + * inside the same encrypted subtree: the file's content is left + * untouched (still encrypted with K_file), but K_file's wrapping + * follows its new parent. + * + * @param encryptedSymmetricKey - the user's entry-point key (root key + * wrapped under the user's pubkey). Same value for both old and new + * chains since this operation stays within a single encrypted root. + * @param oldEncryptedKey - the resource's K_file as currently stored, + * wrapped under its OLD parent's key. + * @param oldEncryptedKeyChain - chain of wrapped folder keys from the + * entry point down to (and including) the OLD parent's key. Omit / + * pass `undefined` when the OLD parent IS the encryption root. + * @param newEncryptedKeyChain - chain entry → NEW parent. Omit when + * the NEW parent is the encryption root. + * @returns the resource's K_file re-wrapped under the NEW parent's + * key — caller persists this on the resource's DB row, replacing + * the old wrapping. + */ + rewrapNestedKey(encryptedSymmetricKey: ArrayBuffer, oldEncryptedKey: ArrayBuffer, oldEncryptedKeyChain?: ArrayBuffer[], newEncryptedKeyChain?: ArrayBuffer[]): Promise<{ + newEncryptedKey: ArrayBuffer; + }>; + /** + * Wrap an existing per-user-anchored symmetric key under a parent + * chain. Symmetric reverse of {@link shareKeys} (which goes 1→N + * chain→per-user); this goes 1→1 per-user→chain. + * + * Used when MOVING a self-rooted encrypted resource (per-user + * wraps on its access rows) INTO an encrypted subtree: K_item is + * recovered from the user's per-user wrap, then wrapped under the + * destination parent's chain so the resource stops being a root + * and joins the destination tree. + * + * @param userEncryptedKey - the resource's per-user wrap from the + * caller's access row (`encrypted_item_symmetric_key_for_user`). + * @param newEntryEncryptedSymmetricKey - user's entry-point key + * for the destination tree (the tree's root key wrapped under + * their pubkey — same value `getKeyChain` returns as + * `encrypted_key_for_user` for any item under that tree). + * @param newEncryptedKeyChain - chain entry → NEW parent. Omit + * when the new parent IS the destination tree's root. + * @returns the resource's K_item wrapped under the new parent's + * key — caller persists this on the resource's row. + */ + wrapNestedKey(userEncryptedKey: ArrayBuffer, newEntryEncryptedSymmetricKey: ArrayBuffer, newEncryptedKeyChain?: ArrayBuffer[]): Promise<{ + newEncryptedKey: ArrayBuffer; + }>; + /** + * Share an existing document's or item's symmetric key with additional users. + * + * Pass recipients as a labeled map (OIDC sub → {email, name?}), not public + * keys: the vault resolves each recipient's encryption key from the directory + * itself and wraps ONLY for identities whose binding verifies AND that you have marked + * 'trusted' (TOFU) with a matching fingerprint. If any recipient is unverified + * or untrusted it throws UNTRUSTED_RECIPIENT and wraps for none — call + * checkFingerprints (and resolve any 'unknown') first. Recipients are resolved + * in one batched request. The labels are display-only and used only if the + * trust modal opens; the vault request itself sees just the userIds. + * + * @param encryptedSymmetricKey - current user's encrypted copy of the key + * @param recipients - map of userId → display label to share with + * @param encryptedKeyChain - optional chain of wrapped keys for Drive's key hierarchy. + * When provided, resolves the chain from entry point to the target item's key + * before re-encrypting for the target users. + * @returns encryptedKeys - Record of userId → ArrayBuffer encrypted symmetric key for each user + */ + shareKeys(encryptedSymmetricKey: ArrayBuffer, recipients: Record, encryptedKeyChain?: ArrayBuffer[]): Promise<{ + encryptedKeys: Record; + }>; + private shareKeysRequest; + /** + * Fetch registered users for a list of OIDC subs (the ids your product + * already holds). The vault calls the encryption server itself (products + * never touch it) and verifies each record's binding signature before + * returning. The map is keyed by the subs you queried; subs with no active + * registration are absent (that person never onboarded encryption). Use it + * when building a sharing UI: it tells you who has keys, their fingerprint, + * and whether the directory record is coherent (`verified`). + */ + fetchPublicKeys(subs: string[]): Promise>; + /** + * Check fingerprints provided by the product against the vault's local registry. + * The product sends the fingerprints it stored at share time, keyed by OIDC + * sub; results echo the same subs back (the vault translates to its internal + * trust keys on its side). + * + * Returns results with status: "trusted", "refused", or "unknown" (needs user decision). + */ + checkFingerprints(userFingerprints: Record): Promise<{ + results: Array<{ + userId: string; + knownFingerprint: string | null; + providedFingerprint: string; + status: 'trusted' | 'refused' | 'unknown' | 'mismatch'; + }>; + }>; + /** + * Get all known fingerprints with their status from the local registry. + */ + getKnownFingerprints(): Promise<{ + fingerprints: Record; + }>; + /** + * Compute a 128-bit DECIMAL fingerprint of a public key: the first 16 bytes of + * its SHA-256, read big-endian as a fixed-width 40-digit decimal. Matches the + * device-pairing fingerprint so every surface shows the same value. + * This is a pure client-side operation — no vault iframe needed. + * + * @param publicKey - The public key as ArrayBuffer (from fetchPublicKeys or getPublicKey) + */ + computeKeyFingerprint(publicKey: ArrayBuffer): Promise; + /** + * Format a raw decimal fingerprint for display, grouped in blocks of five. + */ + formatFingerprint(fingerprint: string): string; + /** + * Open the encryption interface for onboarding (key generation + backup). + * The product provides a container element where the interface iframe will be mounted. + * The product is responsible for showing/hiding this container (e.g. in a modal). + * + * Listen to 'onboarding:complete' and 'interface:closed' events for results. + */ + openOnboarding(container: HTMLElement): void; + /** + * Open the encryption interface for key backup/export. + */ + openBackup(container: HTMLElement): void; + /** + * Open the encryption interface for key restoration from backup. + */ + openRestore(container: HTMLElement): void; + /** + * Open the encryption settings (view fingerprint, delete keys). + */ + openSettings(container: HTMLElement): void; + /** + * Open device approval: enroll this device from another, or approve a new one. + */ + openDeviceApproval(container: HTMLElement): void; + /** + * Open the emergency-access (trusted contacts) management screen: designate + * contacts, accept a designation, follow or refuse a running recovery. + */ + openEmergencyAccess(container: HTMLElement): void; + /** + * Open the per-recipient profile: the recipient's current trust decision, their + * identity fingerprint (for out-of-band comparison), and Trust / Refuse actions. + * Opened explicitly by the product (e.g. clicking a person in its share UI), so + * it mounts in a product-provided container like the other open* methods. + * `userId` is the recipient's OIDC sub, like every id a product passes. + */ + openRecipientProfile(container: HTMLElement, userId: string, label: RecipientLabel): void; + /** + * Close the interface iframe if it is open. + */ + closeInterface(): void; + on(event: K, listener: Listener): void; + off(event: K, listener: Listener): void; + private openInterface; + /** + * Construct and configure an interface iframe for `path` (sandbox, allow, + * theme/lang hash, context handshake). Mounting is left to the caller so the + * same setup serves both the product-provided container (openInterface) and + * the SDK-created full-screen overlay (openVerifyRecipients). + * + * `overlay` travels in the HASH, not in the postMessage context, precisely + * because the context arrives asynchronously: a screen that renders as a page + * when embedded and as a modal when overlaid would otherwise paint the page + * variant first (full-width and opaque, over the product) and swap to the modal + * only once the handshake lands, which reads as a flash. + */ + private buildInterfaceIframe; + /** The context message currently owed to the interface, or null before auth. */ + private sendContext; + /** + * Open the SDK-owned "verify recipients" overlay on top of the product's own + * share dialog, and resolve with the user's outcome. The SDK deliberately does + * NOT draw any chrome here: the container is a minimal, transparent, full- + * viewport layer, and the interface (a Cunningham Modal) draws the whole modal + * (its own backdrop + card) inside the transparent iframe, so it matches the + * rest of the interface UI. Tears the overlay down once the outcome is in. + */ + private openVerifyRecipients; + /** + * Auto-open the interface over the product when the vault reports actionable + * emergency-access state: a running recovery request against the user's vault + * (which they must be able to refuse without hunting for a menu) or a pending + * trusted-contact designation to accept. Same transparent-overlay technique + * as the verify-recipients flow; at most once per page load, and never while + * another interface flow is already open (the settings screen shows the same + * state anyway). + * + * This one is the ONLY flow the SDK opens on its own initiative, so it is held + * to a stricter rule than the flows a product asked for: it stays invisible + * until the interface says it is up, and if it never says so it is removed + * rather than left covering the page. Both halves hang off the same signal: + * + * - the interface asks for its context (MSG_INTERFACE_REQUEST_CONTEXT) as soon + * as its React app mounts, so that is "the remote page is ready"; + * - until then `visibility: hidden` keeps the blank document, then the app's + * first frames, off the screen; + * - if it never arrives (bundle blocked, offline, a redirect leaving an empty + * document, a crash before mount) the watchdog removes the whole overlay, so + * a broken interface never sits on top of the product swallowing clicks. The + * user loses nothing: the same state is in the settings screen, and the + * load-bearing channel for all of this is email. + */ + private surfaceEmergencyPending; + /** + * The interface app mounted. Reveal the overlay we kept hidden and stand the + * watchdog down. No-op for every other flow (the product owns their container). + */ + private revealEmergencyOverlay; + private teardownEmergencyOverlay; + private completeVerify; + private teardownVerifyOverlay; + /** + * Run a recipient-bearing operation, and on UNTRUSTED_RECIPIENT open the shared + * verify modal for the ORIGINAL recipients (full labeled map; the interface + * surfaces only the blocked ones). If the user trusts them all, retry the + * operation exactly once; otherwise rethrow the original error so the product + * sees the share failed (all-or-nothing). Any other error rethrows unchanged. + * This is always on: whether to prompt for trust is not a product choice, so + * there is no opt-out. + */ + private withRecipientVerification; + private vaultRequest; + private handleMessage; + private handleVaultMessage; + private handleInterfaceMessage; + private removeIframe; + private emit; +} + +/** + * Error subclass tagged with a stable {@link VaultErrorCode}. Throw this + * (instead of `new Error(...)`) anywhere inside the SDK so the boundary + * marshaller can pass the code along to consumers. + * + * Note: `VaultError` instances DO NOT survive `structuredClone` — the + * postMessage layer marshals `{ message, code }` explicitly and + * reconstructs the class on the receiving side. + */ +export declare class VaultError extends Error { + readonly code: VaultErrorCode; + constructor(code: VaultErrorCode, message: string); +} + +/** + * Stable error codes that travel across the vault iframe ↔ client + * postMessage boundary. Consumers (drive / docs / meet) match on these + * instead of regexing error messages — message text is for logs / humans + * and may change, the codes are part of the SDK contract. + * + * Why a string-keyed const (not a TS enum): keeps the runtime values + * tree-shakeable, and the strings double as wire identifiers when + * marshalled over postMessage. + */ +export declare const VaultErrorCode: { + /** No key pair stored locally on this device — user must onboard. */ + readonly MISSING_KEYS: "MISSING_KEYS"; + /** + * AEAD verification failed. Either the ciphertext is for a different + * recipient (their wrapped symmetric key was encrypted against another + * pubkey) or the underlying KEM secret didn't match. Bubbles up from + * libsodium's "wrong secret key for the given ciphertext". + */ + readonly WRONG_SECRET_KEY: "WRONG_SECRET_KEY"; + /** Backup payload is corrupted, truncated, or from an unsupported version. */ + readonly INVALID_BACKUP: "INVALID_BACKUP"; + /** BIP-39-style mnemonic input that doesn't checksum. */ + readonly INVALID_MNEMONIC: "INVALID_MNEMONIC"; + /** Caller hit a vault method without first calling `init()`. */ + readonly NOT_INITIALIZED: "NOT_INITIALIZED"; + /** `setAuthContext({ suiteUserId })` was never called. */ + readonly AUTH_REQUIRED: "AUTH_REQUIRED"; + /** + * The declared sub could not be resolved to an internal encryption user id: + * no local alias, and no directory row (either the user never onboarded, or + * the registry was unreachable with nothing cached). Thrown by every vault + * operation EXCEPT `has-keys`, which responds `{ hasKeys: false }` instead + * of throwing: for that probe, "unresolvable" and "never onboarded" are the + * same answer, and products use it to decide whether to offer onboarding. + */ + readonly UNRESOLVED_USER: "UNRESOLVED_USER"; + /** Privileged operation attempted from a non-encryption-origin caller. */ + readonly PRIVILEGED_ORIGIN_REQUIRED: "PRIVILEGED_ORIGIN_REQUIRED"; + /** A vault request didn't get an answer within the configured timeout. */ + readonly TIMEOUT: "TIMEOUT"; + /** Vault module loaded outside an iframe (origin-isolation invariant). */ + readonly IFRAME_REQUIRED: "IFRAME_REQUIRED"; + /** Ciphertext / encrypted-key payload too short to be valid (truncated). */ + readonly CIPHERTEXT_TOO_SHORT: "CIPHERTEXT_TOO_SHORT"; + /** Blob's leading version byte doesn't match a format this build can decode. */ + readonly UNSUPPORTED_CRYPTO_VERSION: "UNSUPPORTED_CRYPTO_VERSION"; + /** A signature public key didn't have the expected Ed25519 length. */ + readonly INVALID_SIGNATURE_KEY: "INVALID_SIGNATURE_KEY"; + /** + * A registry entry's binding signature did not verify against its claimed + * identity (signature) key — the directory record is forged, tampered, or + * incoherent. Consumers MUST refuse to trust / share with such an entry. + */ + readonly INVALID_KEY_BINDING: "INVALID_KEY_BINDING"; + /** + * A pulled vault failed its integrity check: the identity-signed manifest did + * not verify, an item's ciphertext hash or coverage did not match, or the + * revision rolled back. Distinct from a wrong recovery phrase (which fails the + * unlock, not the integrity check) — it means the SERVER served tampered or + * incoherent vault data, so the user must be warned, not told to re-type. + */ + readonly VAULT_INTEGRITY_FAILED: "VAULT_INTEGRITY_FAILED"; + /** + * A wrap was attempted for a recipient whose identity is not TOFU-'trusted' + * with a matching fingerprint (refused, never verified, or a fingerprint + * mismatch that may be a MITM-substituted key). The vault refuses to wrap the + * symmetric key until the recipient's identity is verified. The offending + * userIds are in the error message. + */ + readonly UNTRUSTED_RECIPIENT: "UNTRUSTED_RECIPIENT"; + /** + * A write-through change (e.g. a TOFU decision) could not be pushed to the + * server, so it was NOT kept locally either — the caller should surface a + * "couldn't save, retry" and the local state is unchanged. Distinct from a + * network throw (which also aborts before persisting). + */ + readonly SYNC_FAILED: "SYNC_FAILED"; + /** + * The request reached a handler but its payload does not satisfy that + * operation's contract (a missing required field, or none of a set of mutually + * exclusive ones). A CALLER bug, not a user condition: it is surfaced rather + * than defaulted so a malformed call fails loud instead of acting on a + * half-specified target. + */ + readonly INVALID_REQUEST: "INVALID_REQUEST"; + /** + * Catch-all for situations the SDK couldn't classify into a more + * specific code — present so consumers always have something to switch + * on rather than falling back to message regex. + */ + readonly UNKNOWN: "UNKNOWN"; +}; + +export declare type VaultErrorCode = (typeof VaultErrorCode)[keyof typeof VaultErrorCode]; + +export { } + +export as namespace EncryptionClient; diff --git a/src/frontend/apps/drive/src/features/encryption/global.d.ts b/src/frontend/apps/drive/src/features/encryption/global.d.ts index d48d463b..af6aa0f7 100644 --- a/src/frontend/apps/drive/src/features/encryption/global.d.ts +++ b/src/frontend/apps/drive/src/features/encryption/global.d.ts @@ -1,157 +1,21 @@ +// Ambient bindings for the VaultClient SDK. +// +// The full method/type surface lives in ./client-sdk.d.ts, a VERBATIM copy of +// the encryption service's generated declaration (served at +// /public-assets/client.d.ts). Regenerate it with: +// curl http://encryption.localhost:7200/public-assets/client.d.ts -o client-sdk.d.ts +// This file only re-exposes those types as the globals this codebase uses; never +// hand-edit signatures here — fix them at the source and re-vendor. export {}; declare global { - interface VaultClient { - init(): Promise; - destroy(): void; - setTheme(theme: string): void; - setAuthContext(context: { suiteUserId: string }): void; - getAuthContext(): { suiteUserId: string } | null; - hasKeys(): Promise<{ hasKeys: boolean }>; - getPublicKey(): Promise<{ publicKey: ArrayBuffer }>; - // Root creation: mint a new key and wrap it under each user's pubkey. - // - // Note on `optimizeMemory` (present on all four data-handling methods - // below): defaults to `false` — safe behaviour, the SDK clones the - // input buffer on its way to the vault and the vault clones the - // result on its way back. Caller's input buffer stays valid. Set to - // `true` ONLY on hot paths where the caller is happy to lose - // ownership of the input buffer (transferred + detached) in - // exchange for skipping two large-buffer copies. Keys / chain / - // userId are never transferred regardless of this flag. - encryptWithoutKey( - data: ArrayBuffer, - userPublicKeys: Record, - options?: { optimizeMemory?: boolean } - ): Promise<{ - encryptedContent: ArrayBuffer; - encryptedKeys: Record; - }>; - // Nested creation: mint a new key and wrap it under the parent folder's - // key (resolved from entry + chain). - encryptNestedWithoutKey( - data: ArrayBuffer, - encryptedSymmetricKey: ArrayBuffer, - encryptedKeyChain?: ArrayBuffer[], - options?: { optimizeMemory?: boolean } - ): Promise<{ encryptedContent: ArrayBuffer; wrappedKey: ArrayBuffer }>; - // Encrypt with an existing key — purely symmetric. No mint, no wrap. - encryptWithKey( - data: ArrayBuffer, - encryptedSymmetricKey: ArrayBuffer, - encryptedKeyChain?: ArrayBuffer[], - options?: { optimizeMemory?: boolean } - ): Promise<{ encryptedData: ArrayBuffer }>; - decryptWithKey( - encryptedData: ArrayBuffer, - encryptedSymmetricKey: ArrayBuffer, - encryptedKeyChain?: ArrayBuffer[], - options?: { optimizeMemory?: boolean } - ): Promise<{ data: ArrayBuffer }>; - /** - * Re-wrap an encrypted resource's K_file from its OLD parent chain - * to a NEW one. Called when moving a file/folder between parents - * inside the same encrypted subtree — content stays untouched, only - * the wrapping of K_file follows the new position. `oldEncryptedKey` - * is the file's current wrapped K_file from its access row; the - * returned `newEncryptedKey` replaces it. - */ - rewrapNestedKey( - encryptedSymmetricKey: ArrayBuffer, - oldEncryptedKey: ArrayBuffer, - oldEncryptedKeyChain?: ArrayBuffer[], - newEncryptedKeyChain?: ArrayBuffer[] - ): Promise<{ newEncryptedKey: ArrayBuffer }>; - /** - * Wrap a per-user-anchored K_item (the value stored in - * `encrypted_item_symmetric_key_for_user` on the caller's access - * row of a self-rooted encrypted resource) under a destination - * parent's chain. Used when moving a self-rooted file/folder - * INTO an encrypted subtree — symmetric reverse of `shareKeys`. - */ - wrapNestedKey( - userEncryptedKey: ArrayBuffer, - newEntryEncryptedSymmetricKey: ArrayBuffer, - newEncryptedKeyChain?: ArrayBuffer[] - ): Promise<{ newEncryptedKey: ArrayBuffer }>; - shareKeys( - encryptedSymmetricKey: ArrayBuffer, - userPublicKeys: Record, - encryptedKeyChain?: ArrayBuffer[] - ): Promise<{ encryptedKeys: Record }>; - computeKeyFingerprint(publicKey: ArrayBuffer): Promise; - formatFingerprint(fingerprint: string): string; - fetchPublicKeys( - userIds: string[] - ): Promise<{ publicKeys: Record }>; - checkFingerprints( - userFingerprints: Record, - currentUserId?: string - ): Promise<{ - results: Array<{ - userId: string; - knownFingerprint: string | null; - providedFingerprint: string; - status: 'trusted' | 'refused' | 'unknown'; - }>; - }>; - acceptFingerprint(userId: string, fingerprint: string): Promise; - refuseFingerprint(userId: string, fingerprint: string): Promise; - getKnownFingerprints(): Promise<{ - fingerprints: Record< - string, - { - fingerprint: string; - status: 'trusted' | 'refused' | 'unknown'; - } - >; - }>; - openOnboarding(container: HTMLElement): void; - openBackup(container: HTMLElement): void; - openRestore(container: HTMLElement): void; - openDeviceTransfer(container: HTMLElement): void; - openSettings(container: HTMLElement): void; - closeInterface(): void; - on(event: K, listener: (data: unknown) => void): void; - off(event: K, listener: (data: unknown) => void): void; - } - - /** - * Stable error codes carried by `VaultError`. Sourced from the - * encryption SDK (re-exported on `window.EncryptionClient.VaultErrorCode`) - * — drive consumers match on these via `(err as VaultError).code` rather - * than regexing message text. Keep in sync with the SDK definition. - */ - type VaultErrorCode = - | 'MISSING_KEYS' - | 'WRONG_SECRET_KEY' - | 'INVALID_BACKUP' - | 'INVALID_MNEMONIC' - | 'NOT_INITIALIZED' - | 'AUTH_REQUIRED' - | 'PRIVILEGED_ORIGIN_REQUIRED' - | 'TIMEOUT' - | 'IFRAME_REQUIRED' - | 'CIPHERTEXT_TOO_SHORT' - | 'UNKNOWN'; - - interface VaultError extends Error { - readonly code: VaultErrorCode; - } + type VaultClient = import('./client-sdk').VaultClient; + type VaultError = import('./client-sdk').VaultError; + type VaultErrorCode = import('./client-sdk').VaultErrorCode; + type RegisteredUser = import('./client-sdk').RegisteredUser; + type RecipientLabel = import('./client-sdk').RecipientLabel; interface Window { - EncryptionClient: { - VaultClient: new (options: { - vaultUrl: string; - interfaceUrl: string; - timeout?: number; - theme?: string; - lang?: string; - }) => VaultClient; - VaultError: new (code: VaultErrorCode, message: string) => VaultError; - VaultErrorCode: { readonly [K in VaultErrorCode]: K }; - isVaultError: (err: unknown) => err is VaultError; - }; - __driveVaultClient: VaultClient | null; + EncryptionClient: typeof import('./client-sdk'); } } diff --git a/src/frontend/apps/drive/src/features/encryption/oo-bridge/checkpointing.ts b/src/frontend/apps/drive/src/features/encryption/oo-bridge/checkpointing.ts index 82d56a22..97e0fc47 100644 --- a/src/frontend/apps/drive/src/features/encryption/oo-bridge/checkpointing.ts +++ b/src/frontend/apps/drive/src/features/encryption/oo-bridge/checkpointing.ts @@ -8,7 +8,7 @@ import { convertFromInternal } from './x2tConverter'; import { getPatchIndex } from './changesPipeline'; -import { acquireSaveLock, releaseSaveLock, isSaveLocked } from './locks'; +import { acquireSaveLock, releaseSaveLock } from './locks'; import { pauseIncomingOT, resumeIncomingOT } from './incomingOtGate'; const CHECKPOINT_CHANGES_THRESHOLD = 50; @@ -22,6 +22,7 @@ const CHECKPOINT_CHANGES_THRESHOLD = 50; const CHECKPOINT_TIME_INTERVAL_MS = 120_000; /** Reference to the OnlyOffice editor instance */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice editor internals let editorInstance: any = null; /** Original file format (e.g. "docx") */ @@ -75,6 +76,7 @@ let saveResultCallback: let isSaveLeader: (() => boolean) | null = null; export function initCheckpointing(opts: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice editor internals editor: any; format: string; type: string; @@ -213,6 +215,7 @@ async function saveCheckpoint(): Promise { // asc_nativeGetFile() is on the INNER editor object inside the OO iframe, // not on the DocsAPI.DocEditor wrapper. Access it via the iframe's window. const ooIframe = document.querySelector('iframe[name="frameEditor"]') as HTMLIFrameElement | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice editor inner window const innerWindow = ooIframe?.contentWindow as any; const innerEditor = innerWindow?.editor || innerWindow?.editorCell; @@ -346,7 +349,7 @@ async function saveCheckpoint(): Promise { // `converted.buffer` is detached after the await — read the // byte count from the local capture above. try { - await uploadCallback(converted.buffer, originalFormat); + await uploadCallback(converted.buffer as ArrayBuffer, originalFormat); } catch (error) { transientFailure = error instanceof Error ? error.message : String(error); diff --git a/src/frontend/apps/drive/src/features/encryption/oo-bridge/encryptedRelay.ts b/src/frontend/apps/drive/src/features/encryption/oo-bridge/encryptedRelay.ts index 857a58f6..b76e8a78 100644 --- a/src/frontend/apps/drive/src/features/encryption/oo-bridge/encryptedRelay.ts +++ b/src/frontend/apps/drive/src/features/encryption/oo-bridge/encryptedRelay.ts @@ -373,9 +373,10 @@ export class EncryptedRelay { private roomId: string; private userId: string; private userName: string; - private vaultClient: any; + private vaultClient: VaultClient; private encryptedSymmetricKey: ArrayBuffer; private encryptedKeyChain: ArrayBuffer[]; + private keyVersion: number; private callbacks: RelayCallbacks; private reconnectTimer: ReturnType | null = null; private reconnectAttempts = 0; @@ -423,9 +424,10 @@ export class EncryptedRelay { roomId: string; userId: string; userName: string; - vaultClient: any; + vaultClient: VaultClient; encryptedSymmetricKey: ArrayBuffer; encryptedKeyChain: ArrayBuffer[]; + keyVersion: number; callbacks: RelayCallbacks; }) { this.roomId = opts.roomId; @@ -434,6 +436,7 @@ export class EncryptedRelay { this.vaultClient = opts.vaultClient; this.encryptedSymmetricKey = opts.encryptedSymmetricKey; this.encryptedKeyChain = opts.encryptedKeyChain; + this.keyVersion = opts.keyVersion; this.callbacks = opts.callbacks; } @@ -470,7 +473,7 @@ export class EncryptedRelay { this.processing = this.processing.then(() => this.handleMessage(data)); }; - this.ws.onclose = event => { + this.ws.onclose = () => { this.callbacks.onConnectionChange(false); // Pending settlement promises become unresolvable once the socket // is gone — the relay on the other side of a future reconnect @@ -819,6 +822,7 @@ export class EncryptedRelay { const { data: plaintext } = await this.vaultClient.decryptWithKey( ciphertext, this.cloneKey(), + this.keyVersion, this.cloneKeyChain() ); diff --git a/src/frontend/apps/drive/src/features/encryption/oo-bridge/mockServer.ts b/src/frontend/apps/drive/src/features/encryption/oo-bridge/mockServer.ts index e8009ee4..48bdb118 100644 --- a/src/frontend/apps/drive/src/features/encryption/oo-bridge/mockServer.ts +++ b/src/frontend/apps/drive/src/features/encryption/oo-bridge/mockServer.ts @@ -39,6 +39,7 @@ export interface MockServerOptions { userId?: string; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice editor internals let editorInstance: any = null; /** * Diagnostic counter — when > 0, every `sendToEditor` call is logged @@ -48,6 +49,7 @@ let editorInstance: any = null; */ let verboseSends = 0; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice editor internals export function setEditorInstance(editor: any): void { editorInstance = editor; } @@ -258,6 +260,7 @@ export function createMockServerCallbacks( peerLocksShownToEditor.add(key); } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice message protocol sendToEditor({ type: 'getLock', locks } as any); }) .catch(err => { @@ -267,6 +270,7 @@ export function createMockServerCallbacks( // either have a fresh connection or have surfaced the // disconnect to the user. console.warn('[lockArbitrator] claim failed:', err); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice message protocol sendToEditor({ type: 'getLock', locks: {} } as any); }); break; @@ -284,6 +288,7 @@ export function createMockServerCallbacks( block, }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice message protocol sendToEditor({ type: 'getLock', locks } as any); break; } @@ -383,10 +388,12 @@ export function createMockServerCallbacks( break; case 'message': { - const payload = (msg as any).messages - ? ((msg as any).messages as unknown[]) - : (msg as any).message !== undefined - ? [(msg as any).message] + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice message protocol + const anyMsg = msg as any; + const payload = anyMsg.messages + ? (anyMsg.messages as unknown[]) + : anyMsg.message !== undefined + ? [anyMsg.message] : []; if (payload.length > 0) { options.onMessageBroadcast?.(payload); @@ -396,6 +403,7 @@ export function createMockServerCallbacks( case 'meta': { const payload = + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OnlyOffice message protocol ((msg as any).messages as unknown[] | undefined) ?? []; if (payload.length > 0) { options.onMetaBroadcast?.(payload); diff --git a/src/frontend/apps/drive/src/features/encryption/oo-bridge/x2tConverter.ts b/src/frontend/apps/drive/src/features/encryption/oo-bridge/x2tConverter.ts index dc0f47f8..545dcbda 100644 --- a/src/frontend/apps/drive/src/features/encryption/oo-bridge/x2tConverter.ts +++ b/src/frontend/apps/drive/src/features/encryption/oo-bridge/x2tConverter.ts @@ -28,6 +28,7 @@ interface X2TModule { } // Store on window to survive HMR module reloads +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- x2t WASM module global const getWindow = (): any => (typeof window !== 'undefined' ? window : {}); const getX2TModule = (): X2TModule | null => getWindow().__x2tModule ?? null; const setX2TModule = (m: X2TModule) => { getWindow().__x2tModule = m; }; @@ -48,6 +49,7 @@ function getX2T(): Promise { if (pending) return pending; // Check if Module was loaded by a previous render + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- x2t WASM module global const existing = (window as any).Module; if (existing?.FS?.readdir) { setX2TModule(existing as X2TModule); @@ -59,6 +61,7 @@ function getX2T(): Promise { if (document.querySelector(`script[src="${x2tUrl}"]`)) { const poll = setInterval(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- x2t WASM module global const mod = (window as any).Module; if (mod?.FS?.readdir) { clearInterval(poll); @@ -74,10 +77,12 @@ function getX2T(): Promise { return; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- x2t WASM module global (window as any).Module = { locateFile: (path: string) => new URL(`/onlyoffice/x2t/${path}`, window.location.origin).href, onRuntimeInitialized: () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- x2t WASM module global const mod = (window as any).Module as X2TModule; try { mod.FS.mkdir('/working'); } catch {} try { mod.FS.mkdir('/working/media'); } catch {} diff --git a/src/frontend/apps/drive/src/features/encryption/recursive/useRecursiveEncryptionJob.ts b/src/frontend/apps/drive/src/features/encryption/recursive/useRecursiveEncryptionJob.ts index 4f39393c..a3b76300 100644 --- a/src/frontend/apps/drive/src/features/encryption/recursive/useRecursiveEncryptionJob.ts +++ b/src/frontend/apps/drive/src/features/encryption/recursive/useRecursiveEncryptionJob.ts @@ -5,11 +5,11 @@ import { Item, ItemType, LinkReach } from '@/features/drivers/types'; import { getDriver } from '@/features/config/Config'; import { APIError, errorToString } from '@/features/api/APIError'; import { useAuth } from '@/features/auth/Auth'; +import { fetchRegisteredKeys } from '@/features/encryption/fetchRegisteredKeys'; import { useVaultClient } from '../VaultClientProvider'; import { chainForNode, fetchSubtree, - filesOnly, foldersOnly, innerEncryptionRoots, isInsideInnerRoot, @@ -170,6 +170,29 @@ export type UseRecursiveEncryptionJob = { * PATCH commits the whole fileKeyMapping + encryptedKeysForDescendants in a * DB transaction. Failure pre-commit ⇒ DB untouched; rollback is `return;`. */ + +type RecipientLabel = { email: string; name?: string }; + +/** + * Build the SDK's labeled recipient map (sub → {email, name}) for a set of + * subs, reading each label from the sourced access-row labels. The vault only + * uses the subs (it resolves + trust-checks keys itself); the labels are shown + * if the verify-recipients trust modal opens. The fallback placeholder guards + * only a sub that has a key but no resolvable access row (should not happen for + * individual accesses) and is never shown for a trusted recipient — the modal + * surfaces only the blocked ones. + */ +function toRecipients( + subs: string[], + labels: Record, +): Record { + const recipients: Record = {}; + for (const sub of subs) { + recipients[sub] = labels[sub] ?? { email: 'unknown@unknown.invalid' }; + } + return recipients; +} + export function useRecursiveEncryptionJob({ mode, item, @@ -188,7 +211,9 @@ export function useRecursiveEncryptionJob({ const flatRef = useRef([]); const processableIdsRef = useRef([]); const publicKeysRef = useRef>({}); + const versionsRef = useRef>({}); const pendingUserIdsRef = useRef([]); + const recipientLabelsRef = useRef>({}); // For encrypt mode, the root's per-user wrapped key map (committed). const rootEncryptedKeysRef = useRef>({}); // Current user's wrapped copy of the root key — entry key passed to the @@ -218,7 +243,9 @@ export function useRecursiveEncryptionJob({ flatRef.current = []; processableIdsRef.current = []; publicKeysRef.current = {}; + versionsRef.current = {}; pendingUserIdsRef.current = []; + recipientLabelsRef.current = {}; rootEncryptedKeysRef.current = {}; currentUserRootWrappedRef.current = null; folderWrappedKeysRef.current = new Map(); @@ -343,9 +370,27 @@ export function useRecursiveEncryptionJob({ } if (errors.length === 0 && userIds.length > 0) { - const { publicKeys } = await vaultClient.fetchPublicKeys(userIds); + const { publicKeys, versions } = + await fetchRegisteredKeys(userIds); if (cancelled) return; + // Source display labels (email/name) for the recipients so the + // SDK's verify-recipients trust modal can identify them — the job + // otherwise carries only subs (accesses_user_ids) + pubkeys, and + // emails live on the item's access rows. + const accessRows = await driver.getItemAccesses(item.id); + if (cancelled) return; + const labels: Record = {}; + for (const a of accessRows) { + if (a.user.sub) { + labels[a.user.sub] = { + email: a.user.email, + name: a.user.full_name, + }; + } + } + recipientLabelsRef.current = labels; + const missing = userIds.filter(uid => !publicKeys[uid]); const callerMissing = user?.sub && missing.includes(user.sub); @@ -372,6 +417,7 @@ export function useRecursiveEncryptionJob({ // pending on the backend and accepted later from the share // dialog. Informational, not blocking. publicKeysRef.current = publicKeys; + versionsRef.current = versions; pendingUserIdsRef.current = missing; dispatch({ type: 'SET_PENDING_USER_COUNT', @@ -498,6 +544,7 @@ export function useRecursiveEncryptionJob({ flat: flatRef.current, processableIds: processableIdsRef.current, publicKeys: publicKeysRef.current, + recipientLabels: recipientLabelsRef.current, rootEncryptedKeysRef, currentUserRootWrappedRef, folderWrappedKeysRef, @@ -573,38 +620,29 @@ export function useRecursiveEncryptionJob({ encryptedSymmetricKeyPerUser[uid] = null; } - // Compute a fingerprint for each user whose public key we have - // and null for pending users (no public key → no fingerprint). - // Backend stores the map verbatim on the ItemAccess rows so - // clients can later tell which key each user's wrapped key was - // produced for — the "Fingerprint at the time it was shared - // with you" line in the key-mismatch panel reads directly from - // here. Mirrors the symmetric-key payload: every user on the - // access list appears in the map exactly once. - const encryptionPublicKeyFingerprintPerUser: Record< + // Record the encryption public key version for each user whose + // public key we have and null for pending users (no public key → + // no version). Backend stores the map verbatim on the ItemAccess + // rows so clients can later tell which key version each user's + // wrapped key was produced for — the key-staleness check in the + // key-mismatch panel reads directly from here. Mirrors the + // symmetric-key payload: every user on the access list appears + // in the map exactly once. + const encryptionPublicKeyVersionPerUser: Record< string, - string | null + number | null > = {}; - for (const [uid, publicKey] of Object.entries(publicKeysRef.current)) { - try { - encryptionPublicKeyFingerprintPerUser[uid] = - await vaultClient.computeKeyFingerprint(publicKey); - } catch (err) { - console.warn( - '[encrypt] computeKeyFingerprint failed for', - uid, - err, - ); - encryptionPublicKeyFingerprintPerUser[uid] = null; - } + for (const uid of Object.keys(publicKeysRef.current)) { + encryptionPublicKeyVersionPerUser[uid] = + versionsRef.current[uid] ?? null; } for (const uid of pendingUserIdsRef.current) { - encryptionPublicKeyFingerprintPerUser[uid] = null; + encryptionPublicKeyVersionPerUser[uid] = null; } await driver.encryptItem(item.id, { encryptedSymmetricKeyPerUser, - encryptionPublicKeyFingerprintPerUser, + encryptionPublicKeyVersionPerUser, encryptedKeysForDescendants, fileKeyMapping, }); @@ -739,6 +777,7 @@ type EncryptPipelineArgs = { flat: FlatNode[]; processableIds: string[]; publicKeys: Record; + recipientLabels: Record; rootEncryptedKeysRef: React.MutableRefObject>; currentUserRootWrappedRef: React.MutableRefObject; folderWrappedKeysRef: React.MutableRefObject>; @@ -754,6 +793,7 @@ async function encryptPipeline({ flat, processableIds, publicKeys, + recipientLabels, rootEncryptedKeysRef, currentUserRootWrappedRef, folderWrappedKeysRef, @@ -766,13 +806,12 @@ async function encryptPipeline({ // and encryptWithoutKey is called with the actual file content below. if (rootItem.type === ItemType.FOLDER) { dispatch({ type: 'UPDATE_ROW', id: rootItem.id, state: 'running' }); - // Vault SDK is safe-by-default: public-key buffers in - // `userPublicKeys` are structured-cloned by postMessage (never in - // the transferList), so the same map stays valid for the - // `computeKeyFingerprint` calls that follow at submit time. + // Pass a labeled recipient map; the vault resolves + trust-checks each key + // itself (binding + TOFU) and uses only the subs. `publicKeys` is keyed by + // userId; labels come from the sourced access rows. const { encryptedKeys } = await vaultClient.encryptWithoutKey( new ArrayBuffer(0), - publicKeys + toRecipients(Object.keys(publicKeys), recipientLabels) ); if (signal.aborted) throw abortError(); rootEncryptedKeysRef.current = encryptedKeys; @@ -827,6 +866,7 @@ async function encryptPipeline({ flat, targetId: id, publicKeys, + recipientLabels, rootEncryptedKeysRef, currentUserRootWrappedRef, folderWrappedKeysRef, @@ -846,6 +886,7 @@ type StageOneEncryptArgs = { flat: FlatNode[]; targetId: string; publicKeys: Record; + recipientLabels: Record; rootEncryptedKeysRef: React.MutableRefObject>; currentUserRootWrappedRef: React.MutableRefObject; folderWrappedKeysRef: React.MutableRefObject>; @@ -860,6 +901,7 @@ async function stageOneEncryption({ flat, targetId, publicKeys, + recipientLabels, rootEncryptedKeysRef, currentUserRootWrappedRef, folderWrappedKeysRef, @@ -889,12 +931,17 @@ async function stageOneEncryption({ if (rootItem.type === ItemType.FILE && targetId === rootItem.id) { // optimizeMemory: hot path — `plaintext` is the full file body - // (often several MB) and is discarded after this call. publicKeys - // is never in the transferList regardless of the flag. + // (often several MB) and is discarded after this call. Pass a labeled + // recipient map; the vault resolves + trust-checks each key (binding + + // TOFU) and uses only the subs. const { encryptedContent: ct, encryptedKeys } = - await vaultClient.encryptWithoutKey(plaintext, publicKeys, { - optimizeMemory: true, - }); + await vaultClient.encryptWithoutKey( + plaintext, + toRecipients(Object.keys(publicKeys), recipientLabels), + { + optimizeMemory: true, + } + ); encryptedContent = ct; rootEncryptedKeysRef.current = encryptedKeys; wrappedKey = new ArrayBuffer(0); @@ -1244,11 +1291,14 @@ async function decryptPipeline({ } const ciphertext = await resp.arrayBuffer(); + const keyVersion = node.item.encryption_public_key_version_for_user ?? 1; + // optimizeMemory: hot path — file body, ciphertext discarded // after this call and plaintext goes straight to S3. const { data: plaintext } = await vaultClient.decryptWithKey( ciphertext, entryKey, + keyVersion, chain.length > 0 ? chain : undefined, { optimizeMemory: true } ); diff --git a/src/frontend/apps/drive/src/features/encryption/sharing/PendingEncryptionSection.tsx b/src/frontend/apps/drive/src/features/encryption/sharing/PendingEncryptionSection.tsx index c3df2649..333c98be 100644 --- a/src/frontend/apps/drive/src/features/encryption/sharing/PendingEncryptionSection.tsx +++ b/src/frontend/apps/drive/src/features/encryption/sharing/PendingEncryptionSection.tsx @@ -8,6 +8,7 @@ import { fetchSubtreeEntryKey, wrapSubtreeKeyForUser, } from './wrapKeyForUser'; +import { fetchRegisteredKeys } from '@/features/encryption/fetchRegisteredKeys'; interface Props { itemId: string; @@ -82,7 +83,7 @@ export const PendingEncryptionSection = ({ itemId, accesses }: Props) => { return; } try { - const { publicKeys } = await vaultClient.fetchPublicKeys(subs); + const { publicKeys } = await fetchRegisteredKeys(subs); if (cancelled) return; const next: Record = {}; for (const sub of subs) { @@ -102,7 +103,7 @@ export const PendingEncryptionSection = ({ itemId, accesses }: Props) => { }; // pendingSubsSignature intentionally used instead of `pending` itself // to avoid re-firing on unrelated Access array identity changes. - }, [pendingSubsSignature]); // eslint-disable-line react-hooks/exhaustive-deps + }, [pendingSubsSignature]); if (pending.length === 0) { return null; @@ -117,7 +118,11 @@ export const PendingEncryptionSection = ({ itemId, accesses }: Props) => { }); try { const entryKey = await fetchSubtreeEntryKey(itemId); - const wrapped = await wrapSubtreeKeyForUser(entryKey, access.user.sub); + const wrapped = await wrapSubtreeKeyForUser(entryKey, { + sub: access.user.sub, + email: access.user.email, + name: access.user.full_name, + }); if (!wrapped) { // Race: the key probe said they had one but fetching just now // returned nothing. Surface a concrete message and remove the @@ -142,7 +147,7 @@ export const PendingEncryptionSection = ({ itemId, accesses }: Props) => { itemId: access.item.id, accessId: access.id, encrypted_item_symmetric_key_for_user: wrapped.wrappedKeyBase64, - encryption_public_key_fingerprint: wrapped.fingerprint, + encryption_public_key_version: wrapped.version, }); // Invalidate the currently-viewed item's access cache too when // it differs from where the access lives — the modal is looking diff --git a/src/frontend/apps/drive/src/features/encryption/sharing/wrapKeyForUser.ts b/src/frontend/apps/drive/src/features/encryption/sharing/wrapKeyForUser.ts index 1b2df503..c35a0eed 100644 --- a/src/frontend/apps/drive/src/features/encryption/sharing/wrapKeyForUser.ts +++ b/src/frontend/apps/drive/src/features/encryption/sharing/wrapKeyForUser.ts @@ -11,15 +11,28 @@ * * Both paths need the same primitives: fetch the subtree's key chain, * unwrap the caller's own entry key, wrap it for the invitee, and - * compute the fingerprint of the invitee's public key so the backend - * can track key-change events later. + * record the version of the invitee's public key so the backend can + * track key-change events later. */ import { getDriver } from '@/features/config/Config'; +import { fetchRegisteredKeys } from '@/features/encryption/fetchRegisteredKeys'; export interface WrappedKeyForUser { wrappedKeyBase64: string; - fingerprint: string; + version: number; +} + +/** + * A share recipient: the OIDC sub plus a display-only label (email required, + * name optional). The vault wraps for the sub after its own binding + TOFU + * trust check; the label is surfaced only if the "verify recipients" trust + * modal opens, so callers pass the user's real email/name. + */ +export interface ShareRecipient { + sub: string; + email: string; + name?: string; } function arrayBufferToBase64(buffer: ArrayBuffer): string { @@ -59,28 +72,31 @@ export async function fetchSubtreeEntryKey( */ export async function wrapSubtreeKeyForUser( encryptedSymmetricKey: ArrayBuffer, - userSub: string, + recipient: ShareRecipient, ): Promise { const vaultClient = window.__driveVaultClient; if (!vaultClient) { throw new Error('Vault client not available'); } - const { publicKeys } = await vaultClient.fetchPublicKeys([userSub]); - const userPublicKey = publicKeys[userSub]; + const { sub, email, name } = recipient; + const { publicKeys, versions } = await fetchRegisteredKeys([sub]); + const userPublicKey = publicKeys[sub]; if (!userPublicKey) { return null; } - const { encryptedKeys } = await vaultClient.shareKeys( - encryptedSymmetricKey, - { [userSub]: userPublicKey }, - ); - const wrappedKey = encryptedKeys[userSub]; + // Pass a labeled recipient map (sub → {email, name}): the vault resolves + + // trust-checks the key itself (binding + TOFU). The fetched publicKey above + // only gates on the user being registered; the label is display-only, shown + // if the trust modal opens. + const { encryptedKeys } = await vaultClient.shareKeys(encryptedSymmetricKey, { + [sub]: { email, name }, + }); + const wrappedKey = encryptedKeys[sub]; if (!wrappedKey) { return null; } - const fingerprint = await vaultClient.computeKeyFingerprint(userPublicKey); return { wrappedKeyBase64: arrayBufferToBase64(wrappedKey), - fingerprint, + version: versions[sub], }; } diff --git a/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx b/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx index f7c613b3..0e387747 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/modals/share/ItemShareModal.tsx @@ -128,13 +128,17 @@ export const ItemShareModal = ({ const promises = inviteByUsername.map(async (user) => { let memberEncryptedSymmetricKey: string | undefined; - let memberKeyFingerprint: string | undefined; + let memberKeyVersion: number | undefined; if (entryKey && user.sub) { - const wrapped = await wrapSubtreeKeyForUser(entryKey, user.sub); + const wrapped = await wrapSubtreeKeyForUser(entryKey, { + sub: user.sub, + email: user.email, + name: user.full_name, + }); if (wrapped) { memberEncryptedSymmetricKey = wrapped.wrappedKeyBase64; - memberKeyFingerprint = wrapped.fingerprint; + memberKeyVersion = wrapped.version; } // wrapped === null → invitee has no public key yet; omit the // key fields → backend creates the row pending. @@ -145,7 +149,7 @@ export const ItemShareModal = ({ userId: user.id, role: role as Role, encrypted_item_symmetric_key_for_user: memberEncryptedSymmetricKey, - encryption_public_key_fingerprint: memberKeyFingerprint, + encryption_public_key_version: memberKeyVersion, }); }); diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts index 84755215..f4834507 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts @@ -82,7 +82,7 @@ export const useMutationAcceptEncryptionAccess = () => { itemId: string; accessId: string; encrypted_item_symmetric_key_for_user: string; - encryption_public_key_fingerprint: string; + encryption_public_key_version: number; }, ) => { return driver.acceptEncryptionAccess( @@ -91,8 +91,8 @@ export const useMutationAcceptEncryptionAccess = () => { { encrypted_item_symmetric_key_for_user: payload.encrypted_item_symmetric_key_for_user, - encryption_public_key_fingerprint: - payload.encryption_public_key_fingerprint, + encryption_public_key_version: + payload.encryption_public_key_version, }, ); }, diff --git a/src/frontend/apps/drive/src/features/explorer/utils/utils.ts b/src/frontend/apps/drive/src/features/explorer/utils/utils.ts index d0100974..631fe5d4 100644 --- a/src/frontend/apps/drive/src/features/explorer/utils/utils.ts +++ b/src/frontend/apps/drive/src/features/explorer/utils/utils.ts @@ -201,8 +201,8 @@ export const itemToPreviewFile = (item: Item) => { is_wopi_supported: item.is_wopi_supported && !item.is_encrypted, is_encrypted: item.is_encrypted, is_pending_encryption_for_user: item.is_pending_encryption_for_user, - encryption_public_key_fingerprint_for_user: - item.encryption_public_key_fingerprint_for_user, + encryption_public_key_version_for_user: + item.encryption_public_key_version_for_user, size: item.size, abilities: item.abilities, } as FilePreviewType; diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json index 532734a7..e1da315b 100644 --- a/src/frontend/apps/drive/src/features/i18n/translations.json +++ b/src/frontend/apps/drive/src/features/i18n/translations.json @@ -769,8 +769,8 @@ "key_mismatch": { "title": "Ce fichier a été chiffré avec une autre clé", "body": "Le fichier a été chiffré pour vous à un moment où vous utilisiez une autre clé de chiffrement — probablement avant une réinitialisation de vos clés ou un changement d'appareil sans restauration de sauvegarde. Votre clé actuelle ne peut plus le déchiffrer. Demandez à un propriétaire ou administrateur de ce fichier de vous retirer de la liste d'accès puis de vous ajouter à nouveau afin qu'il soit rechiffré avec votre clé actuelle.", - "share_time_fingerprint_label": "Empreinte au moment du partage avec vous :", - "fingerprint_label": "Empreinte de votre clé actuelle :" + "share_time_version_label": "Version de la clé au moment du partage avec vous :", + "current_version_label": "Version actuelle de votre clé :" }, "pending_self": { "title": "Terminez votre configuration du chiffrement pour ouvrir ce fichier", diff --git a/src/frontend/apps/drive/src/features/items/hooks/useDecryptedContent.tsx b/src/frontend/apps/drive/src/features/items/hooks/useDecryptedContent.tsx index d9ca3f64..d82f7888 100644 --- a/src/frontend/apps/drive/src/features/items/hooks/useDecryptedContent.tsx +++ b/src/frontend/apps/drive/src/features/items/hooks/useDecryptedContent.tsx @@ -54,7 +54,7 @@ export const useDecryptedContent = (item?: Item) => { // 3. Decrypt via vault with key chain // The vault client must be available on window (loaded by VaultClientProvider) - const vaultClient = (window as any).__driveVaultClient; + const vaultClient = window.__driveVaultClient; if (!vaultClient) { throw new Error( "Vault client not initialized. Encryption keys are required.", @@ -79,9 +79,15 @@ export const useDecryptedContent = (item?: Item) => { } const encryptedSymmetricKey = entryKeyBytes.buffer; + // Version of the user's encryption key this wrap was produced against, + // stored on the access row. Default to 1 for the current single-version + // reality when the field is absent. + const keyVersion = item.encryption_public_key_version_for_user ?? 1; + const { data: decryptedBuffer } = await vaultClient.decryptWithKey( encryptedBuffer, encryptedSymmetricKey, + keyVersion, encryptedKeyChain.length > 0 ? encryptedKeyChain : undefined, ); @@ -139,7 +145,7 @@ export const downloadDecryptedFile = async ( const encryptedBuffer = await response.arrayBuffer(); // 3. Decrypt via vault - const vaultClient = (window as any).__driveVaultClient; + const vaultClient = window.__driveVaultClient; if (!vaultClient) { throw new Error("Vault client not initialized."); } @@ -159,9 +165,15 @@ export const downloadDecryptedFile = async ( entryKeyBytes[i] = entryKeyBinary.charCodeAt(i); } + // Version of the user's encryption key this wrap was produced against, + // stored on the access row. Default to 1 for the current single-version + // reality when the field is absent. + const keyVersion = item.encryption_public_key_version_for_user ?? 1; + const { data: decryptedBuffer } = await vaultClient.decryptWithKey( encryptedBuffer, entryKeyBytes.buffer, + keyVersion, encryptedKeyChain.length > 0 ? encryptedKeyChain : undefined, ); diff --git a/src/frontend/apps/drive/src/features/ui/preview/encrypted/EncryptedFileViewer.tsx b/src/frontend/apps/drive/src/features/ui/preview/encrypted/EncryptedFileViewer.tsx index c2281115..19c3c1e5 100644 --- a/src/frontend/apps/drive/src/features/ui/preview/encrypted/EncryptedFileViewer.tsx +++ b/src/frontend/apps/drive/src/features/ui/preview/encrypted/EncryptedFileViewer.tsx @@ -4,6 +4,7 @@ import { MimeCategory, } from "@/features/explorer/utils/mimeTypes"; import { useDecryptedContent } from "@/features/items/hooks/useDecryptedContent"; +import { Item } from "@/features/drivers/types"; import { useTranslation } from "react-i18next"; import { ImageViewer } from "../image-viewer/ImageViewer"; import { VideoPlayer } from "../video-player/VideoPlayer"; @@ -116,7 +117,7 @@ export const EncryptedFileViewer = ({ title: file.title, is_encrypted: true, }; - return ; + return ; } return ( @@ -151,7 +152,9 @@ const NonOfficeEncryptedViewer = ({ is_encrypted: true, mimetype: file.mimetype, }; - const { blobUrl, isDecrypting, error } = useDecryptedContent(item as any); + const { blobUrl, isDecrypting, error } = useDecryptedContent( + item as unknown as Item + ); if (isDecrypting) { return ( @@ -187,7 +190,7 @@ const NonOfficeEncryptedViewer = ({ if (isWrongSecretKeyError(error)) { return ( ); } diff --git a/src/frontend/apps/drive/src/features/ui/preview/files-preview/FilesPreview.tsx b/src/frontend/apps/drive/src/features/ui/preview/files-preview/FilesPreview.tsx index 0aca13b6..621db817 100644 --- a/src/frontend/apps/drive/src/features/ui/preview/files-preview/FilesPreview.tsx +++ b/src/frontend/apps/drive/src/features/ui/preview/files-preview/FilesPreview.tsx @@ -28,7 +28,7 @@ export type FilePreviewType = { is_wopi_supported?: boolean; is_encrypted?: boolean; is_pending_encryption_for_user?: boolean; - encryption_public_key_fingerprint_for_user?: string | null; + encryption_public_key_version_for_user?: number | null; url_preview: string; url: string; abilities?: {