blob: 5211f39d194bef2d31d670db4107f6b7325dd8ae [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.
from dataclasses import replace
from pathlib import Path
import pytest
from pyiceberg.encryption.io import EncryptedInputFile
from pyiceberg.encryption.key_metadata import StandardKeyMetadata
from pyiceberg.encryption.stream import (
_BLOCK_OVERHEAD,
_CIPHER_BLOCK_SIZE,
_GCM_STREAM_HEADER,
_GCM_STREAM_HEADER_LENGTH,
_MIN_STREAM_LENGTH,
_PLAIN_BLOCK_SIZE,
_decrypt_ags1,
_encrypt_ags1,
_stream_block_aad,
)
from pyiceberg.io.pyarrow import PyArrowFileIO
KEY = b"0123456789012345"
AAD_PREFIX = b"0123456789abcdef"
KEY_METADATA = StandardKeyMetadata(encryption_key=KEY, aad_prefix=AAD_PREFIX)
# The header a Java `AesGcmOutputStream` writes: "AGS1" then 1 MiB as a little-endian int32.
JAVA_HEADER = b"AGS1\x00\x00\x10\x00"
# Streams written by Java's `AesGcmOutputFile` (Apache Iceberg 1.11.0), so AGS1 is checked against another
# implementation's bytes rather than only its own round trip. Multi-block vectors are left out because each block
# adds 1 MiB. #4010 tracks replacing these with vectors from iceberg-verification.
FIXTURE_KEY = bytes(range(16))
FIXTURE_AAD_PREFIX = b"pyiceberg-ags1"
JAVA_STREAMS = {
# An empty file is the header plus one empty block, not a bare header.
"empty": bytes.fromhex("4147533100001000178c6b4fd72e166bc1667356c865baed13878ed51c4340f6426501ec"),
"partial-block": bytes.fromhex(
"4147533100001000c02998e75683447a486f7dd3dd6c9f8a2a10fbfc956b15fdb35611534fe8d1414c81b17a477b914b"
"fa0b515a5060cff94301feb0e85535402a2a2966214e20274635d4c19ae525d4da042ac51ed7db0c6698c731df0ba637"
"4aff8b8d5c05297b8896abbfabe09c3ce1a2389c46d14718c17b0a3e33a4ddebc05a11b18ec9c576"
),
# Written with a null AAD prefix, so the block index alone is the AAD.
"partial-block-no-aad": bytes.fromhex(
"4147533100001000117d00679bfa455f29880fe9dcd8d2ae9b8a7e81d40dbce6061631524e5ec61cbac9914317e7e60f"
"dff52b32a779b3ae5339ea8859a40b3454a986a7f36b62f6e46699b036b6abe2ae043a86aee0ac5873066bab2420802c"
"f970eede664723b455a224b99a5866c862c81640393168355e2acbe90e677a6dbaadab9ffa5cf3dd"
),
}
def decrypt(stream: bytes, key: bytes = KEY, aad_prefix: bytes | None = AAD_PREFIX) -> bytes:
"""Decrypt `stream`, trusting its own length as the key metadata's `file_length`."""
return _decrypt_ags1(stream, StandardKeyMetadata(encryption_key=key, aad_prefix=aad_prefix, file_length=len(stream)))
def patterned_plaintext(length: int) -> bytes:
"""Return a plaintext whose byte `i` is `i % 251`, so a misordered block changes the bytes."""
return (bytes(range(251)) * (length // 251 + 1))[:length]
def test_format_constants() -> None:
assert _GCM_STREAM_HEADER == JAVA_HEADER
assert _PLAIN_BLOCK_SIZE == 1024 * 1024
assert _GCM_STREAM_HEADER_LENGTH == 8
assert _BLOCK_OVERHEAD == 28
assert _CIPHER_BLOCK_SIZE == _PLAIN_BLOCK_SIZE + _BLOCK_OVERHEAD
assert _MIN_STREAM_LENGTH == 36
@pytest.mark.parametrize(
"block_index, expected",
[(0, b"\x00\x00\x00\x00"), (1, b"\x01\x00\x00\x00"), (258, b"\x02\x01\x00\x00"), (2**32 - 1, b"\xff\xff\xff\xff")],
)
def test_stream_block_aad_encodes_the_index_little_endian(block_index: int, expected: bytes) -> None:
assert _stream_block_aad(None, block_index) == expected
assert _stream_block_aad(b"", block_index) == expected
assert _stream_block_aad(AAD_PREFIX, block_index) == AAD_PREFIX + expected
@pytest.mark.parametrize("aad_prefix", [AAD_PREFIX, None])
@pytest.mark.parametrize("plaintext_length", [0, 1, 100, _PLAIN_BLOCK_SIZE, _PLAIN_BLOCK_SIZE + 7, 2 * _PLAIN_BLOCK_SIZE])
def test_round_trip(plaintext_length: int, aad_prefix: bytes | None) -> None:
plaintext = patterned_plaintext(plaintext_length)
stream = _encrypt_ags1(plaintext, StandardKeyMetadata(encryption_key=KEY, aad_prefix=aad_prefix))
assert stream.startswith(JAVA_HEADER)
assert decrypt(stream, aad_prefix=aad_prefix) == plaintext
@pytest.mark.parametrize(
"plaintext_length, encrypted_length",
[
(0, _MIN_STREAM_LENGTH),
(100, _GCM_STREAM_HEADER_LENGTH + _BLOCK_OVERHEAD + 100),
(_PLAIN_BLOCK_SIZE, _GCM_STREAM_HEADER_LENGTH + _CIPHER_BLOCK_SIZE),
(_PLAIN_BLOCK_SIZE + 7, _GCM_STREAM_HEADER_LENGTH + _CIPHER_BLOCK_SIZE + _BLOCK_OVERHEAD + 7),
(2 * _PLAIN_BLOCK_SIZE, _GCM_STREAM_HEADER_LENGTH + 2 * _CIPHER_BLOCK_SIZE),
],
)
def test_encrypted_length(plaintext_length: int, encrypted_length: int) -> None:
"""An empty file gets one empty block, and a block-aligned file gets no trailing empty block, as in Java."""
assert len(_encrypt_ags1(bytes(plaintext_length), KEY_METADATA)) == encrypted_length
@pytest.mark.parametrize(
"name, plaintext_length, aad_prefix",
[
("empty", 0, FIXTURE_AAD_PREFIX),
("partial-block", 100, FIXTURE_AAD_PREFIX),
("partial-block-no-aad", 100, None),
],
)
def test_decrypts_a_java_written_stream(name: str, plaintext_length: int, aad_prefix: bytes | None) -> None:
stream = JAVA_STREAMS[name]
key_metadata = StandardKeyMetadata(encryption_key=FIXTURE_KEY, aad_prefix=aad_prefix, file_length=len(stream))
assert _decrypt_ags1(stream, key_metadata) == patterned_plaintext(plaintext_length)
assert len(_encrypt_ags1(patterned_plaintext(plaintext_length), key_metadata)) == len(stream)
def test_decrypt_requires_file_length() -> None:
stream = _encrypt_ags1(b"data", KEY_METADATA)
with pytest.raises(ValueError, match="key metadata has no file_length"):
_decrypt_ags1(stream, KEY_METADATA)
def test_a_truncated_stream_still_authenticates() -> None:
"""Dropping a trailing block leaves every remaining block valid, so the length must come from key metadata."""
stream = _encrypt_ags1(patterned_plaintext(2 * _PLAIN_BLOCK_SIZE), KEY_METADATA)
truncated = stream[: _GCM_STREAM_HEADER_LENGTH + _CIPHER_BLOCK_SIZE]
assert decrypt(truncated) == patterned_plaintext(_PLAIN_BLOCK_SIZE)
@pytest.mark.parametrize("change", ["truncate", "extend"])
def test_decrypt_rejects_a_length_other_than_the_trusted_length(change: str) -> None:
stream = _encrypt_ags1(patterned_plaintext(2 * _PLAIN_BLOCK_SIZE), KEY_METADATA)
key_metadata = StandardKeyMetadata(encryption_key=KEY, aad_prefix=AAD_PREFIX, file_length=len(stream))
changed = stream[: _GCM_STREAM_HEADER_LENGTH + _CIPHER_BLOCK_SIZE] if change == "truncate" else stream + b"\x00"
with pytest.raises(ValueError, match=f"key metadata expects {len(stream)} bytes, got {len(changed)}"):
_decrypt_ags1(changed, key_metadata)
@pytest.mark.parametrize("length", [0, 7, _GCM_STREAM_HEADER_LENGTH, _MIN_STREAM_LENGTH - 1])
def test_decrypt_rejects_a_stream_shorter_than_one_block(length: int) -> None:
"""A header alone is not a stream, matching Java's `MIN_STREAM_LENGTH` and iceberg-rust."""
with pytest.raises(ValueError, match=f"expected at least {_MIN_STREAM_LENGTH} bytes, got {length}"):
decrypt((JAVA_HEADER + bytes(_BLOCK_OVERHEAD))[:length])
def test_decrypt_rejects_the_wrong_magic() -> None:
with pytest.raises(ValueError, match="magic b'AGS2' does not match b'AGS1'"):
decrypt(b"AGS2" + JAVA_HEADER[4:] + bytes(_BLOCK_OVERHEAD))
def test_decrypt_rejects_an_unsupported_block_size() -> None:
with pytest.raises(ValueError, match=f"Unsupported AGS1 block size: 512 \\(expected {_PLAIN_BLOCK_SIZE}\\)"):
decrypt(b"AGS1" + (512).to_bytes(4, "little") + bytes(_BLOCK_OVERHEAD))
@pytest.mark.parametrize("last_block_length", [1, _BLOCK_OVERHEAD - 1])
def test_decrypt_rejects_a_truncated_last_block(last_block_length: int) -> None:
with pytest.raises(ValueError, match=f"last block is {last_block_length} bytes, expected at least {_BLOCK_OVERHEAD}"):
decrypt(JAVA_HEADER + bytes(_CIPHER_BLOCK_SIZE + last_block_length))
def test_decrypt_rejects_reordered_blocks() -> None:
stream = _encrypt_ags1(bytes(2 * _PLAIN_BLOCK_SIZE), KEY_METADATA)
first, second = (
stream[_GCM_STREAM_HEADER_LENGTH : _GCM_STREAM_HEADER_LENGTH + _CIPHER_BLOCK_SIZE],
stream[_GCM_STREAM_HEADER_LENGTH + _CIPHER_BLOCK_SIZE :],
)
with pytest.raises(ValueError, match="wrong decryption key; or corrupt/tampered data"):
decrypt(JAVA_HEADER + second + first)
def test_decrypt_rejects_another_files_aad_prefix() -> None:
stream = _encrypt_ags1(bytes(100), KEY_METADATA)
with pytest.raises(ValueError, match="wrong decryption key; or corrupt/tampered data"):
decrypt(stream, aad_prefix=b"another file's prefix")
def test_decrypt_rejects_the_wrong_key() -> None:
stream = _encrypt_ags1(bytes(100), KEY_METADATA)
with pytest.raises(ValueError, match="wrong decryption key; or corrupt/tampered data"):
decrypt(stream, key=b"1123456789012345")
def test_encrypted_input_file(tmp_path: Path) -> None:
plaintext = b"manifest list contents"
encrypted = _encrypt_ags1(plaintext, KEY_METADATA)
path = tmp_path / "manifest-list.avro"
path.write_bytes(encrypted)
input_file = EncryptedInputFile(PyArrowFileIO().new_input(str(path)), replace(KEY_METADATA, file_length=len(encrypted)))
assert input_file.location == str(path)
assert input_file.exists()
assert len(input_file) == len(plaintext)
with input_file.open() as stream:
assert stream.read() == plaintext
def test_encrypted_input_file_rejects_a_truncated_file(tmp_path: Path) -> None:
encrypted = _encrypt_ags1(b"manifest list contents", KEY_METADATA)
path = tmp_path / "manifest-list.avro"
path.write_bytes(encrypted[:-1])
input_file = EncryptedInputFile(PyArrowFileIO().new_input(str(path)), replace(KEY_METADATA, file_length=len(encrypted)))
with pytest.raises(ValueError, match="key metadata expects"):
input_file.open()