blob: 0c282cdd9847708b9df66c93432b21b72a956838 [file]
# 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))
@pytest.fixture
def kms() -> CountingKeyManagementClient:
client = CountingKeyManagementClient()
client.add_master_key(MASTER_KEY_ID)
return client
@pytest.fixture
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
@pytest.mark.parametrize("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)
@pytest.mark.parametrize("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)