blob: 73c57dfd5b33acac3089acb15ae7eeb9b6045bae [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.
"""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")