This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git


The following commit(s) were added to refs/heads/main by this push:
     new 19abbcf1b feat(encryption): [8/N] encryption manager (#4149)
19abbcf1b is described below

commit 19abbcf1b01d60d9bd6b70a265b07e45811514b3
Author: Xander <[email protected]>
AuthorDate: Sun Oct 25 17:57:08 2026 +0000

    feat(encryption): [8/N] encryption manager (#4149)
    
    * feat(encryption): [8/N] encryption manager
    
    * remove comment
    
    * raise rather than None
    
    * kek guard
    
    * lazy import
---
 pyiceberg/encryption/key_metadata.py  |   9 +
 pyiceberg/encryption/manager.py       | 194 +++++++++++++++++++
 tests/encryption/test_key_metadata.py |  11 ++
 tests/encryption/test_manager.py      | 337 ++++++++++++++++++++++++++++++++++
 4 files changed, 551 insertions(+)

diff --git a/pyiceberg/encryption/key_metadata.py 
b/pyiceberg/encryption/key_metadata.py
index f0c3d814c..e6546be26 100644
--- a/pyiceberg/encryption/key_metadata.py
+++ b/pyiceberg/encryption/key_metadata.py
@@ -19,11 +19,13 @@
 from __future__ import annotations
 
 import io
+import os
 from dataclasses import dataclass, field
 
 from pyiceberg.avro.decoder import new_decoder
 from pyiceberg.avro.encoder import BinaryEncoder
 from pyiceberg.avro.resolver import construct_reader, construct_writer
+from pyiceberg.encryption.ciphers import AesKeySize, SecureKey
 from pyiceberg.schema import Schema
 from pyiceberg.typedef import Record
 from pyiceberg.types import BinaryType, LongType, NestedField
@@ -32,6 +34,8 @@ KEY_METADATA_V1 = 1
 
 AES_KEY_LENGTHS = (16, 24, 32)
 
+AAD_PREFIX_LENGTH = 16
+
 KEY_METADATA_SCHEMA_V1 = Schema(
     NestedField(field_id=0, name="encryption_key", field_type=BinaryType(), 
required=True),
     NestedField(field_id=1, name="aad_prefix", field_type=BinaryType(), 
required=False),
@@ -58,6 +62,11 @@ class StandardKeyMetadata:
                 f"Invalid encryption key in key metadata: expected one of 
{AES_KEY_LENGTHS} bytes, got {len(self.encryption_key)}"
             )
 
+    @classmethod
+    def generate(cls, key_size: AesKeySize = AesKeySize.BITS_128) -> 
StandardKeyMetadata:
+        """Generate key metadata for a new file, with a random key of 
`key_size` and a random AAD prefix."""
+        return cls(encryption_key=SecureKey.generate(key_size).key, 
aad_prefix=os.urandom(AAD_PREFIX_LENGTH))
+
     @classmethod
     def from_bytes(cls, data: bytes) -> StandardKeyMetadata:
         """Decode key metadata from its wire format."""
diff --git a/pyiceberg/encryption/manager.py b/pyiceberg/encryption/manager.py
new file mode 100644
index 000000000..73c57dfd5
--- /dev/null
+++ b/pyiceberg/encryption/manager.py
@@ -0,0 +1,194 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Envelope key management for table encryption.
+
+A master key held by the key management service wraps key encryption keys 
(KEKs), which are
+stored in table metadata. A KEK wraps the key metadata of each snapshot's 
manifest list.
+"""
+
+from __future__ import annotations
+
+import time
+import uuid
+from collections.abc import Iterable
+
+from pyiceberg.encryption.ciphers import AesGcmCipher, AesKeySize, SecureKey
+from pyiceberg.encryption.key_metadata import StandardKeyMetadata
+from pyiceberg.encryption.kms import KeyManagementClient
+from pyiceberg.table.metadata import EncryptedKey, TableMetadata, 
TableMetadataV3
+from pyiceberg.utils.properties import property_as_int
+
+KEK_CREATED_AT_PROPERTY = "KEY_TIMESTAMP"
+
+KEK_LIFESPAN_MS = 730 * 24 * 60 * 60 * 1000
+
+KEK_CACHE_TTL_SECONDS = 3600
+
+
+class EncryptionManager:
+    """Generates file keys and wraps manifest list key metadata for an 
encrypted table."""
+
+    def __init__(
+        self,
+        kms_client: KeyManagementClient,
+        table_key_id: str,
+        encryption_keys: Iterable[EncryptedKey] = (),
+        key_size: AesKeySize = AesKeySize.BITS_128,
+    ) -> None:
+        self._kms_client = kms_client
+        self._table_key_id = table_key_id
+        self._key_size = key_size
+        self._encryption_keys = {key.key_id: key for key in encryption_keys}
+        self._kek_cache: dict[str, tuple[SecureKey, float]] = {}
+
+    @classmethod
+    def from_table_metadata(cls, kms_client: KeyManagementClient | None, 
metadata: TableMetadata) -> EncryptionManager | None:
+        """Create a manager for the table, or return None if the table is not 
encrypted.
+
+        Args:
+            kms_client (KeyManagementClient | None): Wraps and unwraps KEKs 
with the table's master key.
+            metadata (TableMetadata): The table's metadata, holding 
`encryption.key-id` and its encryption keys.
+        """
+        from pyiceberg.table import TableProperties
+
+        if (table_key_id := 
metadata.properties.get(TableProperties.ENCRYPTION_KEY_ID)) is None:
+            return None
+
+        if not isinstance(metadata, TableMetadataV3):
+            raise ValueError(f"Invalid properties for 
v{metadata.format_version}: {TableProperties.ENCRYPTION_KEY_ID}")
+
+        if kms_client is None:
+            raise ValueError(f"Table has {TableProperties.ENCRYPTION_KEY_ID} 
set but no key management client was provided")
+
+        key_length = property_as_int(metadata.properties, 
TableProperties.ENCRYPTION_DATA_KEY_LENGTH)
+        key_size = AesKeySize.from_key_length(
+            TableProperties.ENCRYPTION_DATA_KEY_LENGTH_DEFAULT if key_length 
is None else key_length
+        )
+
+        return cls(kms_client, table_key_id, metadata.encryption_keys, 
key_size)
+
+    @property
+    def encryption_keys(self) -> list[EncryptedKey]:
+        """The table's encryption keys, including KEKs and manifest list keys 
this manager created."""
+        return list(self._encryption_keys.values())
+
+    def generate_key_metadata(self) -> StandardKeyMetadata:
+        """Generate key metadata for a new file, with a fresh key and AAD 
prefix."""
+        return StandardKeyMetadata.generate(self._key_size)
+
+    def encrypt_manifest_list_key_metadata(self, key_metadata: 
StandardKeyMetadata) -> str:
+        """Wrap a manifest list's key metadata with the active KEK, creating a 
KEK if none is active.
+
+        Returns the id of the new encryption key, to record as the snapshot's 
`key-id`.
+
+        Args:
+            key_metadata (StandardKeyMetadata): The manifest list's key 
metadata, including its `file_length`.
+        """
+        kek = self._find_active_kek() or self._create_kek()
+        wrapped = 
AesGcmCipher(self._unwrap_kek(kek)).encrypt(key_metadata.to_bytes(), 
_kek_timestamp_aad(kek))
+
+        key = EncryptedKey(key_id=str(uuid.uuid4()), 
encrypted_key_metadata=wrapped, encrypted_by_id=kek.key_id)
+        self._encryption_keys[key.key_id] = key
+        return key.key_id
+
+    def decrypt_manifest_list_key_metadata(self, key_id: str) -> 
StandardKeyMetadata:
+        """Unwrap the manifest list key metadata stored under `key_id`.
+
+        Args:
+            key_id (str): The snapshot's `key-id`.
+        """
+        if (key := self._encryption_keys.get(key_id)) is None:
+            raise ValueError(f"Encryption key not found: {key_id}")
+
+        if key.encrypted_by_id is None:
+            raise ValueError(f"Encryption key {key_id} has no encrypted-by-id")
+
+        if key.encrypted_by_id == self._table_key_id:
+            raise ValueError(f"{key_id} is a key encryption key, not manifest 
list key metadata")
+
+        if (kek := self._encryption_keys.get(key.encrypted_by_id)) is None:
+            raise ValueError(f"KEK not found in encryption keys: 
{key.encrypted_by_id}")
+
+        aad = _kek_timestamp_aad(kek)
+        cipher = AesGcmCipher(self._unwrap_kek(kek))
+        try:
+            plaintext = cipher.decrypt(key.encrypted_key_metadata, aad)
+        except ValueError as e:
+            raise ValueError(f"Failed to unwrap key metadata with KEK 
{kek.key_id}") from e
+
+        return StandardKeyMetadata.from_bytes(plaintext)
+
+    def _create_kek(self) -> EncryptedKey:
+        if self._kms_client.supports_key_generation():
+            generated = self._kms_client.generate_key(self._table_key_id)
+            plaintext, wrapped = SecureKey(generated.key), 
generated.wrapped_key
+        else:
+            plaintext = SecureKey.generate(self._key_size)
+            wrapped = self._kms_client.wrap_key(plaintext.key, 
self._table_key_id)
+
+        kek = EncryptedKey(
+            key_id=str(uuid.uuid4()),
+            encrypted_key_metadata=wrapped,
+            encrypted_by_id=self._table_key_id,
+            properties={KEK_CREATED_AT_PROPERTY: str(_now_ms())},
+        )
+        self._kek_cache[kek.key_id] = (plaintext, time.monotonic())
+        self._encryption_keys[kek.key_id] = kek
+        return kek
+
+    def _find_active_kek(self) -> EncryptedKey | None:
+        keks = [
+            key
+            for key in self._encryption_keys.values()
+            if key.encrypted_by_id == self._table_key_id and not 
_is_kek_expired(key)
+        ]
+        return max(keks, key=lambda kek: _kek_created_at(kek) or 0, 
default=None)
+
+    def _unwrap_kek(self, kek: EncryptedKey) -> SecureKey:
+        if (cached := self._kek_cache.get(kek.key_id)) is not None and 
time.monotonic() - cached[1] < KEK_CACHE_TTL_SECONDS:
+            return cached[0]
+
+        if kek.encrypted_by_id is None:
+            raise ValueError(f"KEK {kek.key_id} has no encrypted-by-id")
+
+        plaintext = 
SecureKey(self._kms_client.unwrap_key(kek.encrypted_key_metadata, 
kek.encrypted_by_id))
+        self._kek_cache[kek.key_id] = (plaintext, time.monotonic())
+        return plaintext
+
+
+def _now_ms() -> int:
+    return time.time_ns() // 1_000_000
+
+
+def _kek_created_at(kek: EncryptedKey) -> int | None:
+    try:
+        return int(kek.properties[KEK_CREATED_AT_PROPERTY])
+    except (KeyError, ValueError):
+        return None
+
+
+def _is_kek_expired(kek: EncryptedKey) -> bool:
+    created_at = _kek_created_at(kek)
+    return created_at is None or _now_ms() - created_at >= KEK_LIFESPAN_MS
+
+
+def _kek_timestamp_aad(kek: EncryptedKey) -> bytes:
+    """Return the KEK's creation timestamp, which authenticates everything it 
wraps so the timestamp cannot be altered."""
+    if (timestamp := kek.properties.get(KEK_CREATED_AT_PROPERTY)) is None:
+        raise ValueError(f"KEK {kek.key_id} is missing required 
{KEK_CREATED_AT_PROPERTY} property")
+
+    return timestamp.encode("utf-8")
diff --git a/tests/encryption/test_key_metadata.py 
b/tests/encryption/test_key_metadata.py
index 490f793c7..c01dea035 100644
--- a/tests/encryption/test_key_metadata.py
+++ b/tests/encryption/test_key_metadata.py
@@ -20,6 +20,7 @@ import io
 import pytest
 
 from pyiceberg.avro.encoder import BinaryEncoder
+from pyiceberg.encryption.ciphers import AesKeySize
 from pyiceberg.encryption.key_metadata import KEY_METADATA_V1, 
StandardKeyMetadata
 
 AES128_KEY = b"0123456789012345"
@@ -63,6 +64,16 @@ def test_key_metadata_defaults() -> None:
     assert key_metadata.file_length is None
 
 
[email protected]("key_size", list(AesKeySize))
+def test_key_metadata_generate(key_size: AesKeySize) -> None:
+    key_metadata = StandardKeyMetadata.generate(key_size)
+
+    assert len(key_metadata.encryption_key) == key_size.key_length
+    assert key_metadata.aad_prefix is not None and 
len(key_metadata.aad_prefix) == 16
+    assert key_metadata.file_length is None
+    assert StandardKeyMetadata.generate(key_size) != key_metadata
+
+
 def test_key_metadata_repr_redacts_encryption_key() -> None:
     key_metadata = StandardKeyMetadata(encryption_key=AES128_KEY)
 
diff --git a/tests/encryption/test_manager.py b/tests/encryption/test_manager.py
new file mode 100644
index 000000000..0c282cdd9
--- /dev/null
+++ b/tests/encryption/test_manager.py
@@ -0,0 +1,337 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import time
+from typing import Any
+
+import pytest
+from memory_kms import MemoryKeyManagementClient
+
+from pyiceberg.encryption.ciphers import AesGcmCipher, AesKeySize, SecureKey
+from pyiceberg.encryption.key_metadata import StandardKeyMetadata
+from pyiceberg.encryption.kms import GeneratedKey
+from pyiceberg.encryption.manager import KEK_CREATED_AT_PROPERTY, 
EncryptionManager
+from pyiceberg.table.metadata import EncryptedKey, TableMetadataUtil
+
+MASTER_KEY_ID = "master-1"
+KEY_METADATA = StandardKeyMetadata(encryption_key=b"0123456789abcdef", 
aad_prefix=b"test-aad-prefix!", file_length=1024)
+THREE_YEARS_MS = 3 * 365 * 24 * 60 * 60 * 1000
+
+
+class CountingKeyManagementClient(MemoryKeyManagementClient):
+    unwrap_calls = 0
+
+    def unwrap_key(self, wrapped_key: bytes, wrapping_key_id: str) -> bytes:
+        self.unwrap_calls += 1
+        return super().unwrap_key(wrapped_key, wrapping_key_id)
+
+
+class KeyGeneratingKeyManagementClient(MemoryKeyManagementClient):
+    def supports_key_generation(self) -> bool:
+        return True
+
+    def generate_key(self, wrapping_key_id: str) -> GeneratedKey:
+        key = SecureKey.generate().key
+        return GeneratedKey(key=key, wrapped_key=self.wrap_key(key, 
wrapping_key_id))
+
+
[email protected]
+def kms() -> CountingKeyManagementClient:
+    client = CountingKeyManagementClient()
+    client.add_master_key(MASTER_KEY_ID)
+    return client
+
+
[email protected]
+def manager(kms: CountingKeyManagementClient) -> EncryptionManager:
+    return EncryptionManager(kms, MASTER_KEY_ID)
+
+
+def kek_created_at(kms: MemoryKeyManagementClient, key_id: str, created_at_ms: 
int) -> EncryptedKey:
+    return EncryptedKey(
+        key_id=key_id,
+        encrypted_key_metadata=kms.wrap_key(SecureKey.generate().key, 
MASTER_KEY_ID),
+        encrypted_by_id=MASTER_KEY_ID,
+        properties={KEK_CREATED_AT_PROPERTY: str(created_at_ms)},
+    )
+
+
+def now_ms() -> int:
+    return time.time_ns() // 1_000_000
+
+
+def test_first_wrap_creates_a_kek(manager: EncryptionManager) -> None:
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    kek, manifest_list_key = manager.encryption_keys
+    assert kek.encrypted_by_id == MASTER_KEY_ID
+    assert kek.encrypted_key_metadata
+    assert abs(int(kek.properties[KEK_CREATED_AT_PROPERTY]) - now_ms()) < 
60_000
+    assert manifest_list_key.key_id == key_id
+    assert manifest_list_key.encrypted_by_id == kek.key_id
+    assert manifest_list_key.properties == {}
+
+
+def test_wrap_unwrap_round_trip(manager: EncryptionManager) -> None:
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    assert manager.decrypt_manifest_list_key_metadata(key_id) == KEY_METADATA
+
+
+def test_unwrap_with_keys_from_another_manager(kms: 
CountingKeyManagementClient, manager: EncryptionManager) -> None:
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    reader = EncryptionManager(kms, MASTER_KEY_ID, manager.encryption_keys)
+
+    assert reader.decrypt_manifest_list_key_metadata(key_id) == KEY_METADATA
+
+
+def test_unwrap_key_metadata_wrapped_as_java_does(kms: 
CountingKeyManagementClient) -> None:
+    """Java's `EncryptionUtil.encryptManifestListKeyMetadata` wraps with 
AES-GCM, using the KEK timestamp as AAD."""
+    kek_key = SecureKey.generate()
+    kek = EncryptedKey(
+        key_id="kek",
+        encrypted_key_metadata=kms.wrap_key(kek_key.key, MASTER_KEY_ID),
+        encrypted_by_id=MASTER_KEY_ID,
+        properties={KEK_CREATED_AT_PROPERTY: "1700000000000"},
+    )
+    manifest_list_key = EncryptedKey(
+        key_id="manifest-list-key",
+        
encrypted_key_metadata=AesGcmCipher(kek_key).encrypt(KEY_METADATA.to_bytes(), 
b"1700000000000"),
+        encrypted_by_id="kek",
+    )
+
+    manager = EncryptionManager(kms, MASTER_KEY_ID, [kek, manifest_list_key])
+
+    assert manager.decrypt_manifest_list_key_metadata("manifest-list-key") == 
KEY_METADATA
+
+
+def test_kek_is_reused_while_active(manager: EncryptionManager) -> None:
+    first = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+    second = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    kek, first_key, second_key = manager.encryption_keys
+    assert (first_key.key_id, second_key.key_id) == (first, second)
+    assert first_key.encrypted_by_id == second_key.encrypted_by_id == 
kek.key_id
+
+
+def test_kek_is_rotated_once_expired(kms: CountingKeyManagementClient) -> None:
+    expired = kek_created_at(kms, "expired-kek", now_ms() - THREE_YEARS_MS)
+    manager = EncryptionManager(kms, MASTER_KEY_ID, [expired])
+
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    old_kek, new_kek, manifest_list_key = manager.encryption_keys
+    assert old_kek == expired
+    assert new_kek.key_id != expired.key_id
+    assert manifest_list_key.encrypted_by_id == new_kek.key_id
+    assert manager.decrypt_manifest_list_key_metadata(key_id) == KEY_METADATA
+
+
[email protected]("properties", [{}, {KEK_CREATED_AT_PROPERTY: 
"not-a-timestamp"}])
+def test_kek_without_a_valid_timestamp_is_not_reused(kms: 
CountingKeyManagementClient, properties: dict[str, str]) -> None:
+    kek = EncryptedKey(
+        key_id="kek",
+        encrypted_key_metadata=kms.wrap_key(SecureKey.generate().key, 
MASTER_KEY_ID),
+        encrypted_by_id=MASTER_KEY_ID,
+        properties=properties,
+    )
+    manager = EncryptionManager(kms, MASTER_KEY_ID, [kek])
+
+    manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    _, new_kek, manifest_list_key = manager.encryption_keys
+    assert new_kek.encrypted_by_id == MASTER_KEY_ID
+    assert manifest_list_key.encrypted_by_id == new_kek.key_id
+
+
+def test_newest_active_kek_is_used(kms: CountingKeyManagementClient) -> None:
+    newer = kek_created_at(kms, "newer", now_ms() - 1_000)
+    older = kek_created_at(kms, "older", now_ms() - 2_000)
+    manager = EncryptionManager(kms, MASTER_KEY_ID, [newer, older])
+
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    assert len(manager.encryption_keys) == 3
+    assert manager.encryption_keys[-1].key_id == key_id
+    assert manager.encryption_keys[-1].encrypted_by_id == "newer"
+
+
+def test_kek_wrapped_by_another_master_key_is_not_used(kms: 
CountingKeyManagementClient) -> None:
+    kms.add_master_key("master-2")
+    other = EncryptedKey(
+        key_id="other",
+        encrypted_key_metadata=kms.wrap_key(SecureKey.generate().key, 
"master-2"),
+        encrypted_by_id="master-2",
+        properties={KEK_CREATED_AT_PROPERTY: str(now_ms())},
+    )
+    manager = EncryptionManager(kms, MASTER_KEY_ID, [other])
+
+    manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    _, new_kek, manifest_list_key = manager.encryption_keys
+    assert new_kek.encrypted_by_id == MASTER_KEY_ID
+    assert manifest_list_key.encrypted_by_id == new_kek.key_id
+
+
+def test_kek_is_generated_by_the_kms_when_supported() -> None:
+    kms = KeyGeneratingKeyManagementClient()
+    kms.add_master_key(MASTER_KEY_ID)
+    manager = EncryptionManager(kms, MASTER_KEY_ID)
+
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+
+    assert (
+        EncryptionManager(kms, MASTER_KEY_ID, 
manager.encryption_keys).decrypt_manifest_list_key_metadata(key_id) == 
KEY_METADATA
+    )
+
+
+def test_unwrapped_kek_is_cached(kms: CountingKeyManagementClient, manager: 
EncryptionManager) -> None:
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+    manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+    manager.decrypt_manifest_list_key_metadata(key_id)
+    assert kms.unwrap_calls == 0
+
+    reader = EncryptionManager(kms, MASTER_KEY_ID, manager.encryption_keys)
+    reader.decrypt_manifest_list_key_metadata(key_id)
+    reader.decrypt_manifest_list_key_metadata(key_id)
+    assert kms.unwrap_calls == 1
+
+
+def test_unwrap_unknown_key_id(manager: EncryptionManager) -> None:
+    with pytest.raises(ValueError, match="Encryption key not found: missing"):
+        manager.decrypt_manifest_list_key_metadata("missing")
+
+
+def test_unwrap_key_without_encrypted_by_id(kms: CountingKeyManagementClient) 
-> None:
+    manager = EncryptionManager(kms, MASTER_KEY_ID, 
[EncryptedKey(key_id="key", encrypted_key_metadata=b"wrapped")])
+
+    with pytest.raises(ValueError, match="Encryption key key has no 
encrypted-by-id"):
+        manager.decrypt_manifest_list_key_metadata("key")
+
+
+def test_unwrap_key_with_missing_kek(kms: CountingKeyManagementClient) -> None:
+    key = EncryptedKey(key_id="key", encrypted_key_metadata=b"wrapped", 
encrypted_by_id="missing-kek")
+    manager = EncryptionManager(kms, MASTER_KEY_ID, [key])
+
+    with pytest.raises(ValueError, match="KEK not found in encryption keys: 
missing-kek"):
+        manager.decrypt_manifest_list_key_metadata("key")
+
+
+def test_unwrap_kek_id(manager: EncryptionManager) -> None:
+    manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+    kek = manager.encryption_keys[0]
+
+    with pytest.raises(ValueError, match=f"{kek.key_id} is a key encryption 
key, not manifest list key metadata"):
+        manager.decrypt_manifest_list_key_metadata(kek.key_id)
+
+
+def replace_kek_properties(manager: EncryptionManager, key_id: str, 
properties: dict[str, str]) -> list[EncryptedKey]:
+    kek_id = next(key.encrypted_by_id for key in manager.encryption_keys if 
key.key_id == key_id)
+    return [key.model_copy(update={"properties": properties}) if key.key_id == 
kek_id else key for key in manager.encryption_keys]
+
+
+def test_unwrap_fails_when_kek_timestamp_is_missing(kms: 
CountingKeyManagementClient, manager: EncryptionManager) -> None:
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+    reader = EncryptionManager(kms, MASTER_KEY_ID, 
replace_kek_properties(manager, key_id, {}))
+
+    with pytest.raises(ValueError, match=f"is missing required 
{KEK_CREATED_AT_PROPERTY} property"):
+        reader.decrypt_manifest_list_key_metadata(key_id)
+
+
+def test_unwrap_fails_when_kek_timestamp_is_tampered(kms: 
CountingKeyManagementClient, manager: EncryptionManager) -> None:
+    key_id = manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+    reader = EncryptionManager(kms, MASTER_KEY_ID, 
replace_kek_properties(manager, key_id, {KEK_CREATED_AT_PROPERTY: "9999999"}))
+
+    with pytest.raises(ValueError, match="Failed to unwrap key metadata with 
KEK"):
+        reader.decrypt_manifest_list_key_metadata(key_id)
+
+
[email protected]("key_size", list(AesKeySize))
+def test_generate_key_metadata(kms: CountingKeyManagementClient, key_size: 
AesKeySize) -> None:
+    key_metadata = EncryptionManager(kms, MASTER_KEY_ID, 
key_size=key_size).generate_key_metadata()
+
+    assert len(key_metadata.encryption_key) == key_size.key_length
+
+
+def test_from_table_metadata(kms: CountingKeyManagementClient, 
example_table_metadata_v3: dict[str, Any]) -> None:
+    kek = kek_created_at(kms, "kek", now_ms())
+    metadata = TableMetadataUtil.parse_obj(
+        {
+            **example_table_metadata_v3,
+            "properties": {"encryption.key-id": MASTER_KEY_ID, 
"encryption.data-key-length": "32"},
+            "encryption-keys": [kek.model_dump(by_alias=True, mode="json")],
+        }
+    )
+
+    manager = EncryptionManager.from_table_metadata(kms, metadata)
+
+    assert manager is not None
+    assert manager.encryption_keys == [kek]
+    assert len(manager.generate_key_metadata().encryption_key) == 32
+    manager.encrypt_manifest_list_key_metadata(KEY_METADATA)
+    assert manager.encryption_keys[-1].encrypted_by_id == "kek"
+
+
+def test_from_table_metadata_defaults_to_128_bit_keys(
+    kms: CountingKeyManagementClient, example_table_metadata_v3: dict[str, Any]
+) -> None:
+    metadata = TableMetadataUtil.parse_obj({**example_table_metadata_v3, 
"properties": {"encryption.key-id": MASTER_KEY_ID}})
+
+    manager = EncryptionManager.from_table_metadata(kms, metadata)
+
+    assert manager is not None
+    assert len(manager.generate_key_metadata().encryption_key) == 16
+
+
+def test_from_table_metadata_without_key_id(kms: CountingKeyManagementClient, 
example_table_metadata_v3: dict[str, Any]) -> None:
+    metadata = TableMetadataUtil.parse_obj({**example_table_metadata_v3, 
"properties": {}})
+
+    assert EncryptionManager.from_table_metadata(kms, metadata) is None
+    assert EncryptionManager.from_table_metadata(None, metadata) is None
+
+
+def test_from_table_metadata_before_v3(kms: CountingKeyManagementClient, 
example_table_metadata_v2: dict[str, Any]) -> None:
+    metadata = TableMetadataUtil.parse_obj({**example_table_metadata_v2, 
"properties": {"encryption.key-id": MASTER_KEY_ID}})
+
+    with pytest.raises(ValueError, match="Invalid properties for v2: 
encryption.key-id"):
+        EncryptionManager.from_table_metadata(kms, metadata)
+
+
+def test_from_table_metadata_before_v3_without_key_id(
+    kms: CountingKeyManagementClient, example_table_metadata_v2: dict[str, Any]
+) -> None:
+    metadata = TableMetadataUtil.parse_obj({**example_table_metadata_v2, 
"properties": {}})
+
+    assert EncryptionManager.from_table_metadata(kms, metadata) is None
+
+
+def test_from_table_metadata_without_kms_client(example_table_metadata_v3: 
dict[str, Any]) -> None:
+    metadata = TableMetadataUtil.parse_obj({**example_table_metadata_v3, 
"properties": {"encryption.key-id": MASTER_KEY_ID}})
+
+    with pytest.raises(ValueError, match="Table has encryption.key-id set but 
no key management client was provided"):
+        EncryptionManager.from_table_metadata(None, metadata)
+
+
+def test_from_table_metadata_with_invalid_data_key_length(
+    kms: CountingKeyManagementClient, example_table_metadata_v3: dict[str, Any]
+) -> None:
+    metadata = TableMetadataUtil.parse_obj(
+        {**example_table_metadata_v3, "properties": {"encryption.key-id": 
MASTER_KEY_ID, "encryption.data-key-length": "20"}}
+    )
+
+    with pytest.raises(ValueError, match="Unsupported key length: 20"):
+        EncryptionManager.from_table_metadata(kms, metadata)

Reply via email to