blob: dc3dc7193df229940a04c27d1f507ef43dd21f9c [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 pytest
from pytest_mock import MockFixture
from pyiceberg.encryption.ciphers import AesGcmCipher, AesKeySize, SecureKey
from pyiceberg.exceptions import NotInstalledError
AES128_KEY = b"0123456789012345"
PLAINTEXT = b"the quick brown fox"
# Known-answer vectors from McGrew & Viega, "The Galois/Counter Mode of Operation
# (GCM)", shared with the NIST GCM validation suite and the Java and iceberg-rust
# test suites. They pin the `nonce || ciphertext || tag` layout against changes
# that stay self-consistent on round trip but break cross-client interoperability.
GCM_TEST_VECTORS = [
pytest.param(
"feffe9928665731c6d6a8f9467308308",
"cafebabefacedbaddecaf888",
"d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b391aafd255",
"",
"42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e091473f5985",
"4d5c2af327cd64a62cf35abd2ba6fab4",
id="aes128-no-aad",
),
pytest.param(
"feffe9928665731c6d6a8f9467308308",
"cafebabefacedbaddecaf888",
"d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39",
"feedfacedeadbeeffeedfacedeadbeefabaddad2",
"42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e091",
"5bc94fbc3221a5db94fae95ae7121a47",
id="aes128-with-aad",
),
pytest.param(
"feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308",
"cafebabefacedbaddecaf888",
"d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39",
"feedfacedeadbeeffeedfacedeadbeefabaddad2",
"522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662",
"76fc6ece0f4e1768cddf8853bb2d551b",
id="aes256-with-aad",
),
]
@pytest.mark.parametrize(
"key_length, key_size",
[(16, AesKeySize.BITS_128), (24, AesKeySize.BITS_192), (32, AesKeySize.BITS_256)],
)
def test_key_size_from_key_length(key_length: int, key_size: AesKeySize) -> None:
assert AesKeySize.from_key_length(key_length) == key_size
assert key_size.key_length == key_length
@pytest.mark.parametrize("key_length", [0, 4, 15, 20, 33])
def test_key_size_rejects_invalid_key_length(key_length: int) -> None:
with pytest.raises(ValueError, match=f"Unsupported key length: {key_length}"):
AesKeySize.from_key_length(key_length)
@pytest.mark.parametrize("key_length", [0, 4, 15, 20, 33])
def test_secure_key_rejects_invalid_key_length(key_length: int) -> None:
with pytest.raises(ValueError, match="Unsupported key length"):
SecureKey(bytes(key_length))
@pytest.mark.parametrize("key_size", list(AesKeySize))
def test_secure_key_generate(key_size: AesKeySize) -> None:
key = SecureKey.generate(key_size)
assert len(key.key) == key_size.key_length
assert key.key_size == key_size
assert SecureKey.generate(key_size) != key
def test_secure_key_repr_redacts_key() -> None:
key = SecureKey(AES128_KEY)
assert repr(key) == "SecureKey()"
assert repr(AES128_KEY) not in repr(key)
@pytest.mark.parametrize("key_size", list(AesKeySize))
@pytest.mark.parametrize("aad", [None, b"", b"aad"])
def test_encrypt_decrypt_round_trip(key_size: AesKeySize, aad: bytes | None) -> None:
cipher = AesGcmCipher(SecureKey.generate(key_size))
ciphertext = cipher.encrypt(PLAINTEXT, aad)
assert ciphertext != PLAINTEXT
assert cipher.decrypt(ciphertext, aad) == PLAINTEXT
@pytest.mark.parametrize("key, nonce, plaintext, aad, ciphertext, tag", GCM_TEST_VECTORS)
def test_decrypt_known_answer(key: str, nonce: str, plaintext: str, aad: str, ciphertext: str, tag: str) -> None:
cipher = AesGcmCipher(SecureKey(bytes.fromhex(key)))
stored = bytes.fromhex(nonce + ciphertext + tag)
assert cipher.decrypt(stored, bytes.fromhex(aad) or None) == bytes.fromhex(plaintext)
@pytest.mark.parametrize("key, nonce, plaintext, aad, ciphertext, tag", GCM_TEST_VECTORS)
def test_encrypt_known_answer(
monkeypatch: pytest.MonkeyPatch, key: str, nonce: str, plaintext: str, aad: str, ciphertext: str, tag: str
) -> None:
monkeypatch.setattr("pyiceberg.encryption.ciphers.os.urandom", lambda _: bytes.fromhex(nonce))
cipher = AesGcmCipher(SecureKey(bytes.fromhex(key)))
assert cipher.encrypt(bytes.fromhex(plaintext), bytes.fromhex(aad) or None) == bytes.fromhex(nonce + ciphertext + tag)
def test_encrypt_empty_plaintext() -> None:
cipher = AesGcmCipher(SecureKey(AES128_KEY))
assert cipher.decrypt(cipher.encrypt(b"")) == b""
def test_ciphertext_layout() -> None:
cipher = AesGcmCipher(SecureKey(AES128_KEY))
ciphertext = cipher.encrypt(PLAINTEXT)
assert len(ciphertext) == AesGcmCipher.NONCE_LENGTH + len(PLAINTEXT) + AesGcmCipher.TAG_LENGTH
def test_nonce_is_not_reused() -> None:
cipher = AesGcmCipher(SecureKey(AES128_KEY))
first, second = cipher.encrypt(PLAINTEXT), cipher.encrypt(PLAINTEXT)
assert first[: AesGcmCipher.NONCE_LENGTH] != second[: AesGcmCipher.NONCE_LENGTH]
assert first != second
def test_decrypt_with_wrong_key() -> None:
ciphertext = AesGcmCipher(SecureKey(AES128_KEY)).encrypt(PLAINTEXT)
with pytest.raises(ValueError, match="wrong decryption key; or corrupt/tampered data"):
AesGcmCipher(SecureKey(b"5432109876543210")).decrypt(ciphertext)
def test_decrypt_with_mismatched_aad() -> None:
cipher = AesGcmCipher(SecureKey(AES128_KEY))
ciphertext = cipher.encrypt(PLAINTEXT, b"aad")
with pytest.raises(ValueError, match="wrong decryption key; or corrupt/tampered data"):
cipher.decrypt(ciphertext, b"other aad")
def test_decrypt_tampered_ciphertext() -> None:
cipher = AesGcmCipher(SecureKey(AES128_KEY))
ciphertext = bytearray(cipher.encrypt(PLAINTEXT))
ciphertext[-1] ^= 0xFF
with pytest.raises(ValueError, match="wrong decryption key; or corrupt/tampered data"):
cipher.decrypt(bytes(ciphertext))
@pytest.mark.parametrize("length", [0, 1, 27])
def test_decrypt_ciphertext_too_short(length: int) -> None:
cipher = AesGcmCipher(SecureKey(AES128_KEY))
with pytest.raises(ValueError, match=f"Ciphertext too short: expected at least 28 bytes, got {length}"):
cipher.decrypt(bytes(length))
def test_cipher_without_cryptography_installed_raises_not_installed_error(mocker: MockFixture) -> None:
mocker.patch.dict("sys.modules", {"cryptography.hazmat.primitives.ciphers.aead": None})
with pytest.raises(NotInstalledError, match=r"pyiceberg\[encryption\]"):
AesGcmCipher(SecureKey(AES128_KEY))