Skip to content

Commit f6e138f

Browse files
committed
in-memory kms
1 parent 464a4b4 commit f6e138f

2 files changed

Lines changed: 189 additions & 1 deletion

File tree

‎pyiceberg/encryption/kms.py‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
# KIND, either express or implied. See the License for the
1515
# specific language governing permissions and limitations
1616
# under the License.
17-
"""Key management client interface for table encryption."""
17+
"""Key management client interface for table encryption, and an in-memory implementation."""
1818

1919
from __future__ import annotations
2020

2121
from abc import ABC, abstractmethod
2222
from dataclasses import dataclass, field
2323

24+
from pyiceberg.encryption.ciphers import AesGcmCipher, AesKeySize, SecureKey
25+
2426

2527
@dataclass(frozen=True)
2628
class GeneratedKey:
@@ -65,3 +67,48 @@ def generate_key(self, wrapping_key_id: str) -> GeneratedKey:
6567
wrapping_key_id (str): Identifies the master key held by the service.
6668
"""
6769
raise NotImplementedError(f"{type(self).__name__} does not support key generation")
70+
71+
72+
class MemoryKeyManagementClient(KeyManagementClient):
73+
"""A key management service that holds its master keys in memory, for testing and demonstration.
74+
75+
Master keys live only in this process, with no durability or access control, so this is
76+
not for production use. Mirrors Java's `MemoryMockKMS` and iceberg-rust's
77+
`MemoryKeyManagementClient`.
78+
"""
79+
80+
def __init__(self, master_key_size: AesKeySize = AesKeySize.BITS_128) -> None:
81+
self._master_key_size = master_key_size
82+
self._master_keys: dict[str, SecureKey] = {}
83+
84+
def __repr__(self) -> str:
85+
"""Return a representation that counts the master keys without exposing them."""
86+
return f"MemoryKeyManagementClient(master_key_size={self._master_key_size!r}, key_count={len(self._master_keys)})"
87+
88+
def add_master_key(self, wrapping_key_id: str, key: SecureKey | None = None) -> SecureKey:
89+
"""Register a master key under `wrapping_key_id`, generating one when `key` is omitted.
90+
91+
Args:
92+
wrapping_key_id (str): The id to register the master key under.
93+
key (SecureKey | None): Known key material, for tests that share it with another client.
94+
"""
95+
if wrapping_key_id in self._master_keys:
96+
raise ValueError(f"Master key already exists: {wrapping_key_id}")
97+
98+
master_key = SecureKey.generate(self._master_key_size) if key is None else key
99+
self._master_keys[wrapping_key_id] = master_key
100+
return master_key
101+
102+
def _cipher(self, wrapping_key_id: str) -> AesGcmCipher:
103+
if (master_key := self._master_keys.get(wrapping_key_id)) is None:
104+
raise ValueError(f"Master key not found: {wrapping_key_id}")
105+
106+
return AesGcmCipher(master_key)
107+
108+
def wrap_key(self, key: bytes, wrapping_key_id: str) -> bytes:
109+
"""Wrap a key with the registered master key, without AAD, as Java and iceberg-rust do."""
110+
return self._cipher(wrapping_key_id).encrypt(key)
111+
112+
def unwrap_key(self, wrapped_key: bytes, wrapping_key_id: str) -> bytes:
113+
"""Unwrap a key wrapped by `wrap_key`."""
114+
return self._cipher(wrapping_key_id).decrypt(wrapped_key)

‎tests/encryption/test_kms.py‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import pytest
19+
20+
from pyiceberg.encryption.ciphers import AesGcmCipher, AesKeySize, SecureKey
21+
from pyiceberg.encryption.kms import GeneratedKey, KeyManagementClient, MemoryKeyManagementClient
22+
23+
MASTER_KEY_ID = "master-key"
24+
MASTER_KEY = SecureKey(b"0123456789012345")
25+
DEK = b"6543210987654321"
26+
27+
28+
@pytest.fixture
29+
def kms() -> MemoryKeyManagementClient:
30+
client = MemoryKeyManagementClient()
31+
client.add_master_key(MASTER_KEY_ID)
32+
return client
33+
34+
35+
def test_key_management_client_cannot_be_instantiated() -> None:
36+
with pytest.raises(TypeError, match="abstract"):
37+
KeyManagementClient() # type: ignore[abstract]
38+
39+
40+
def test_generated_key_repr_redacts_key() -> None:
41+
generated = GeneratedKey(key=DEK, wrapped_key=b"wrapped")
42+
43+
assert repr(generated) == "GeneratedKey(wrapped_key=b'wrapped')"
44+
assert repr(DEK) not in repr(generated)
45+
46+
47+
def test_key_generation_is_unsupported_by_default(kms: MemoryKeyManagementClient) -> None:
48+
assert kms.supports_key_generation() is False
49+
50+
with pytest.raises(NotImplementedError, match="MemoryKeyManagementClient does not support key generation"):
51+
kms.generate_key(MASTER_KEY_ID)
52+
53+
54+
def test_wrap_unwrap_round_trip(kms: MemoryKeyManagementClient) -> None:
55+
wrapped = kms.wrap_key(DEK, MASTER_KEY_ID)
56+
57+
assert wrapped != DEK
58+
assert kms.unwrap_key(wrapped, MASTER_KEY_ID) == DEK
59+
60+
61+
@pytest.mark.parametrize("key_size", list(AesKeySize))
62+
def test_wrap_unwrap_round_trip_for_each_master_key_size(key_size: AesKeySize) -> None:
63+
kms = MemoryKeyManagementClient(key_size)
64+
master_key = kms.add_master_key(MASTER_KEY_ID)
65+
66+
assert master_key.key_size == key_size
67+
assert kms.unwrap_key(kms.wrap_key(DEK, MASTER_KEY_ID), MASTER_KEY_ID) == DEK
68+
69+
70+
def test_wrap_key_does_not_reuse_nonce(kms: MemoryKeyManagementClient) -> None:
71+
first, second = kms.wrap_key(DEK, MASTER_KEY_ID), kms.wrap_key(DEK, MASTER_KEY_ID)
72+
73+
assert first != second
74+
assert kms.unwrap_key(first, MASTER_KEY_ID) == kms.unwrap_key(second, MASTER_KEY_ID) == DEK
75+
76+
77+
def test_wrap_key_is_not_bound_to_the_wrapping_key_id(kms: MemoryKeyManagementClient) -> None:
78+
"""No AAD is used when wrapping, matching Java's `MemoryMockKMS` and iceberg-rust."""
79+
kms.add_master_key("other-key", MASTER_KEY)
80+
kms.add_master_key("same-key-different-id", MASTER_KEY)
81+
82+
wrapped = kms.wrap_key(DEK, "other-key")
83+
84+
assert kms.unwrap_key(wrapped, "same-key-different-id") == DEK
85+
86+
87+
def test_generated_master_keys_are_unique() -> None:
88+
kms = MemoryKeyManagementClient()
89+
90+
assert kms.add_master_key("first") != kms.add_master_key("second")
91+
92+
93+
def test_add_master_key_with_known_key_material() -> None:
94+
kms = MemoryKeyManagementClient()
95+
96+
assert kms.add_master_key(MASTER_KEY_ID, MASTER_KEY) == MASTER_KEY
97+
assert AesGcmCipher(MASTER_KEY).decrypt(kms.wrap_key(DEK, MASTER_KEY_ID)) == DEK
98+
99+
100+
def test_add_master_key_rejects_a_duplicate_id(kms: MemoryKeyManagementClient) -> None:
101+
with pytest.raises(ValueError, match=f"Master key already exists: {MASTER_KEY_ID}"):
102+
kms.add_master_key(MASTER_KEY_ID)
103+
104+
105+
@pytest.mark.parametrize("key_length", [0, 15, 33])
106+
def test_add_master_key_rejects_an_invalid_key_length(key_length: int) -> None:
107+
with pytest.raises(ValueError, match="Unsupported key length"):
108+
MemoryKeyManagementClient().add_master_key(MASTER_KEY_ID, SecureKey(bytes(key_length)))
109+
110+
111+
def test_wrap_key_with_an_unknown_master_key_id(kms: MemoryKeyManagementClient) -> None:
112+
with pytest.raises(ValueError, match="Master key not found: missing-key"):
113+
kms.wrap_key(DEK, "missing-key")
114+
115+
116+
def test_unwrap_key_with_an_unknown_master_key_id(kms: MemoryKeyManagementClient) -> None:
117+
with pytest.raises(ValueError, match="Master key not found: missing-key"):
118+
kms.unwrap_key(kms.wrap_key(DEK, MASTER_KEY_ID), "missing-key")
119+
120+
121+
def test_unwrap_key_with_the_wrong_master_key(kms: MemoryKeyManagementClient) -> None:
122+
wrapped = kms.wrap_key(DEK, MASTER_KEY_ID)
123+
kms.add_master_key("other-key")
124+
125+
with pytest.raises(ValueError, match="wrong decryption key; or corrupt/tampered data"):
126+
kms.unwrap_key(wrapped, "other-key")
127+
128+
129+
def test_unwrap_tampered_key(kms: MemoryKeyManagementClient) -> None:
130+
wrapped = bytearray(kms.wrap_key(DEK, MASTER_KEY_ID))
131+
wrapped[-1] ^= 0xFF
132+
133+
with pytest.raises(ValueError, match="wrong decryption key; or corrupt/tampered data"):
134+
kms.unwrap_key(bytes(wrapped), MASTER_KEY_ID)
135+
136+
137+
def test_repr_redacts_master_keys(kms: MemoryKeyManagementClient) -> None:
138+
kms.add_master_key("other-key", MASTER_KEY)
139+
140+
assert repr(kms) == "MemoryKeyManagementClient(master_key_size=<AesKeySize.BITS_128: 128>, key_count=2)"
141+
assert repr(MASTER_KEY.key) not in repr(kms)

0 commit comments

Comments
 (0)