blob: 39a88bb098fa16db36f8a7a091fba34ce6a82815 [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.
"""AES GCM Stream (AGS1) encryption, used to encrypt manifests and manifest lists.
An AGS1 stream is an 8 byte header followed by a sequence of AES-GCM blocks::
"AGS1" || plain_block_size (4 bytes, little endian)
nonce || ciphertext || tag (block 0, up to plain_block_size of plaintext)
nonce || ciphertext || tag (block 1..n, the last of which may be shorter)
Each block authenticates `aad_prefix || block_index` as additional data, so blocks cannot
be reordered or moved between files.
The spec gives the last block a non-zero length, which makes a bare header its encoding of an
empty file. Java, iceberg-rust and PyIceberg all require at least one block instead, so an
empty file is a header followed by a single empty block. apache/iceberg#18219 tracks which of
the two forms writers should produce.
`_encrypt_ags1` and `_decrypt_ags1` work on whole files, matching how PyIceberg reads and writes
Avro, so their implementation can move to pyiceberg-core without changing callers. They stay
private until the manifest reader and writer use them.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pyiceberg.encryption.ciphers import AesGcmCipher, SecureKey
if TYPE_CHECKING:
from pyiceberg.encryption.key_metadata import StandardKeyMetadata
_GCM_STREAM_MAGIC = b"AGS1"
_PLAIN_BLOCK_SIZE = 1024 * 1024
_GCM_STREAM_HEADER = _GCM_STREAM_MAGIC + _PLAIN_BLOCK_SIZE.to_bytes(4, "little")
_GCM_STREAM_HEADER_LENGTH = len(_GCM_STREAM_HEADER)
_BLOCK_OVERHEAD = AesGcmCipher.NONCE_LENGTH + AesGcmCipher.TAG_LENGTH
_CIPHER_BLOCK_SIZE = _PLAIN_BLOCK_SIZE + _BLOCK_OVERHEAD
_MIN_STREAM_LENGTH = _GCM_STREAM_HEADER_LENGTH + _BLOCK_OVERHEAD
def _encrypt_ags1(plaintext: bytes, key_metadata: StandardKeyMetadata) -> bytes:
"""Encrypt `plaintext` into an AGS1 stream.
An empty plaintext is written as the header plus one empty block, as Java and iceberg-rust do.
Args:
plaintext (bytes): The file contents to encrypt.
key_metadata (StandardKeyMetadata): The file's key and AAD prefix. Its `file_length` is ignored, and
callers must record the length of the returned stream as the file's `file_length`.
"""
cipher = AesGcmCipher(SecureKey(key_metadata.encryption_key))
starts = range(0, len(plaintext), _PLAIN_BLOCK_SIZE) or range(1)
return _GCM_STREAM_HEADER + b"".join(
cipher.encrypt(plaintext[start : start + _PLAIN_BLOCK_SIZE], _stream_block_aad(key_metadata.aad_prefix, index))
for index, start in enumerate(starts)
)
def _decrypt_ags1(data: bytes, key_metadata: StandardKeyMetadata) -> bytes:
"""Decrypt an AGS1 stream, authenticating every block.
Args:
data (bytes): The encrypted file contents.
key_metadata (StandardKeyMetadata): The file's key, AAD prefix and `file_length`. The length is required and
must match `data`: dropping trailing blocks leaves every remaining block valid, so only the trusted length
from key metadata exposes a truncated file.
"""
if key_metadata.file_length is None:
raise ValueError("Cannot decrypt AGS1 stream: key metadata has no file_length")
if len(data) != key_metadata.file_length:
raise ValueError(f"Invalid AGS1 stream: key metadata expects {key_metadata.file_length} bytes, got {len(data)}")
if len(data) < _MIN_STREAM_LENGTH:
raise ValueError(f"Invalid AGS1 stream: expected at least {_MIN_STREAM_LENGTH} bytes, got {len(data)}")
_check_stream_header(data)
last_block_length = (len(data) - _GCM_STREAM_HEADER_LENGTH) % _CIPHER_BLOCK_SIZE
if 0 < last_block_length < _BLOCK_OVERHEAD:
raise ValueError(f"Truncated AGS1 stream: last block is {last_block_length} bytes, expected at least {_BLOCK_OVERHEAD}")
cipher = AesGcmCipher(SecureKey(key_metadata.encryption_key))
starts = range(_GCM_STREAM_HEADER_LENGTH, len(data), _CIPHER_BLOCK_SIZE)
return b"".join(
cipher.decrypt(data[start : start + _CIPHER_BLOCK_SIZE], _stream_block_aad(key_metadata.aad_prefix, index))
for index, start in enumerate(starts)
)
def _stream_block_aad(aad_prefix: bytes | None, block_index: int) -> bytes:
"""Return the additional authenticated data for the block at `block_index`."""
return (aad_prefix or b"") + block_index.to_bytes(4, "little")
def _check_stream_header(data: bytes) -> None:
"""Check that `data` starts with an AGS1 header declaring the supported block size."""
if (magic := data[: len(_GCM_STREAM_MAGIC)]) != _GCM_STREAM_MAGIC:
raise ValueError(f"Invalid AGS1 header: magic {magic!r} does not match {_GCM_STREAM_MAGIC!r}")
plain_block_size = int.from_bytes(data[len(_GCM_STREAM_MAGIC) : _GCM_STREAM_HEADER_LENGTH], "little")
if plain_block_size != _PLAIN_BLOCK_SIZE:
raise ValueError(f"Unsupported AGS1 block size: {plain_block_size} (expected {_PLAIN_BLOCK_SIZE})")