blob: 078d30ec5c4ef9dcda98839ddf332474a0afbd92 [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 primitives for table encryption."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from enum import IntEnum
from typing import TYPE_CHECKING
from pyiceberg.utils.lazy_import import try_import
if TYPE_CHECKING:
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class AesKeySize(IntEnum):
"""The supported AES key sizes, in bits."""
BITS_128 = 128
BITS_192 = 192
BITS_256 = 256
@property
def key_length(self) -> int:
"""Return the key length in bytes."""
return self.value // 8
@classmethod
def from_key_length(cls, key_length: int) -> AesKeySize:
"""Return the key size for a key of `key_length` bytes."""
try:
return cls(key_length * 8)
except ValueError as e:
raise ValueError(f"Unsupported key length: {key_length} (must be 16, 24 or 32)") from e
@dataclass(frozen=True)
class SecureKey:
"""An AES key of a length the spec allows, kept out of reprs and tracebacks."""
key: bytes = field(repr=False)
def __post_init__(self) -> None:
"""Reject keys that are not a supported AES key length."""
AesKeySize.from_key_length(len(self.key))
@property
def key_size(self) -> AesKeySize:
"""Return the size of this key."""
return AesKeySize.from_key_length(len(self.key))
@classmethod
def generate(cls, key_size: AesKeySize = AesKeySize.BITS_128) -> SecureKey:
"""Generate a new key of `key_size`."""
return cls(os.urandom(key_size.key_length))
class AesGcmCipher:
"""Encrypts and decrypts using AES-GCM.
Ciphertext is laid out as `nonce || ciphertext || tag`, matching Java and iceberg-rust.
"""
NONCE_LENGTH = 12
TAG_LENGTH = 16
def __init__(self, key: SecureKey) -> None:
aead = try_import("cryptography.hazmat.primitives.ciphers.aead", extras_name="encryption")
exceptions = try_import("cryptography.exceptions", extras_name="encryption")
self._aes_gcm: AESGCM = aead.AESGCM(key.key)
self._invalid_tag: type[InvalidTag] = exceptions.InvalidTag
def encrypt(self, plaintext: bytes, aad: bytes | None = None) -> bytes:
"""Encrypt `plaintext`, authenticating `aad` alongside it.
Args:
plaintext (bytes): The data to encrypt.
aad (bytes | None): Additional data to authenticate but not encrypt.
"""
nonce = os.urandom(self.NONCE_LENGTH)
return nonce + self._aes_gcm.encrypt(nonce, plaintext, aad)
def decrypt(self, ciphertext: bytes, aad: bytes | None = None) -> bytes:
"""Decrypt `ciphertext`, verifying `aad` alongside it.
Args:
ciphertext (bytes): The data to decrypt, as returned by `encrypt`.
aad (bytes | None): The additional data that was authenticated on encryption.
"""
if len(ciphertext) < self.NONCE_LENGTH + self.TAG_LENGTH:
raise ValueError(
f"Ciphertext too short: expected at least {self.NONCE_LENGTH + self.TAG_LENGTH} bytes, got {len(ciphertext)}"
)
nonce, encrypted = ciphertext[: self.NONCE_LENGTH], ciphertext[self.NONCE_LENGTH :]
try:
return self._aes_gcm.decrypt(nonce, encrypted, aad)
except self._invalid_tag as e:
raise ValueError("GCM tag check failed. Possible reasons: wrong decryption key; or corrupt/tampered data") from e