Skip to content

feat(encryption) [4/N] AES-GCM encryption implementation - #3963

Merged
kevinjqliu merged 7 commits into
apache:mainfrom
xanderbailey:encryption-ciphers
Sep 14, 2026
Merged

kevinjqliu merged 7 commits into
apache:mainfrom
xanderbailey:encryption-ciphers

Conversation

@xanderbailey

Copy link
Copy Markdown
Contributor

Working towards: #3222

Rationale for this change

Direct port of iceberg-rust AEC-GCM encryption apache/iceberg-rust#2026

Are these changes tested?

Are there any user-facing changes?

Comment thread mkdocs/docs/index.md
| hf | Support for Hugging Face Hub |
| gcp-auth | Support for Google Cloud authentication |
| entra-auth | Support for Azure Entra authentication |
| encryption | Support for table encryption |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed this in the first PR

@rambleraptor rambleraptor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple questions, but this looks great. Thanks so much for doing this!

Comment thread pyiceberg/utils/lazy_import.py Outdated
from pyiceberg.exceptions import NotInstalledError


def not_installed(module_name: str, extras_name: str | None = None) -> NotInstalledError:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this? try_import should be able to take in the full name (try_import("cryptography.hazmat.primitives.ciphers.aead")).

Are you doing this for the sake of keeping the types? The way we've handled that in the past is with a if TYPE_CHECKING block. This works great for this particular use case in an init function, but otherwise just adds some confusion for which import function we should use.


AES128_KEY = b"0123456789012345"
PLAINTEXT = b"the quick brown fox"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we able to add a fixture that was written by a different implementation (java or rust?). That would go a long way to making sure that our decryption logic is correct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did test it locally against rust which I tested at the time against Java, I’ll track a cross client test as a follow up if that’s works for you?

Comment thread pyiceberg/encryption/ciphers.py Outdated
try:
return self._aes_gcm.decrypt(nonce, encrypted, aad)
except self._invalid_tag as e:
raise ValueError("AES-GCM decryption failed") from e

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like Java's error message is ""wrong decryption key; or corrupt/tampered data".

What would you think about changing this error message? This error message makes it unclear if the system failed (and should just be retried?) or if the user inputs are incorrect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to align with Java errr message here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that'll be a nice touch

@kevinjqliu kevinjqliu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

a few nits

Comment thread pyiceberg/encryption/ciphers.py Outdated
Comment on lines +89 to +92
raise not_installed("cryptography", extras_name="encryption") from None

self._aes_gcm: AESGCM = AESGCM(key.key)
self._invalid_tag: type[InvalidTag] = InvalidTag

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: codex suggested this strange logic instead of not_installed

        self._aes_gcm: AESGCM = try_import("cryptography.hazmat.primitives.ciphers.aead", extras_name="encryption").AESGCM(
            key.key
        )
        self._invalid_tag: type[InvalidTag] = try_import("cryptography.exceptions", extras_name="encryption").InvalidTag

assert ciphertext != PLAINTEXT
assert cipher.decrypt(ciphertext, aad) == PLAINTEXT


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something like this would be good. just as a regression test

def test_aes128_gcm_known_answer(mocker: MockFixture) -> None:
    # NIST CAVS gcmEncryptExtIV128.rsp vector from RustCrypto's aes-gcm tests.
    # https://github.com/RustCrypto/AEADs/blob/aes-gcm-v0.10.3/aes-gcm/tests/aes128gcm.rs#L737-L744
    key = bytes.fromhex("c939cc13397c1d37de6ae0e1cb7c423c")
    nonce = bytes.fromhex("b3d8cc017cbb89b39e0f67e2")
    plaintext = bytes.fromhex("c3b3c41f113a31b73d9a5cd432103069")
    aad = bytes.fromhex("24825602bd12a984e0092d3e448eda5f")
    ciphertext = bytes.fromhex("93fe7d9e9bfd10348a5606e5cafa7354")
    tag = bytes.fromhex("0032a1dc85f1c9786925a2e71d8272dd")
    expected = nonce + ciphertext + tag
    cipher = AesGcmCipher(SecureKey(key))
    mocker.patch("pyiceberg.encryption.ciphers.os.urandom", return_value=nonce)
    assert cipher.encrypt(plaintext, aad) == expected
    assert cipher.decrypt(expected, aad) == plaintext

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test cases I added are verified against iceberg-rust

Comment thread pyiceberg/encryption/ciphers.py Outdated
try:
return self._aes_gcm.decrypt(nonce, encrypted, aad)
except self._invalid_tag as e:
raise ValueError("AES-GCM decryption failed") from e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that'll be a nice touch

@kevinjqliu

Copy link
Copy Markdown
Contributor

When we eventually use the rust bindings, we can still keep AesGcmCipher and its API:

Today: Python AesGcmCipher → cryptography
Later: Python AesGcmCipher → pyiceberg_core → Iceberg Rust cipher

The existing cipher tests all round-trip through the same code, so a
symmetric change to the nonce || ciphertext || tag layout would pass them
while breaking interoperability with the Java and iceberg-rust clients.
Pin encryption and decryption against fixed GCM-spec vectors covering
AES-128 and AES-256, with and without AAD.
Report the same cause as the Java client when the GCM tag check fails, so
the message reads consistently across implementations and makes clear the
failure is bad input rather than a retryable system error.
Import the cryptography modules with try_import rather than a spelled-out
try/except, and drop not_installed now that nothing calls it. The
TYPE_CHECKING block already keeps AESGCM and InvalidTag statically typed,
so lazy_import is back to a single entry point.
@xanderbailey

Copy link
Copy Markdown
Contributor Author

Thanks both for the reviews, should be good now

@kevinjqliu
kevinjqliu added this pull request to the merge queue Sep 14, 2026
Merged via the queue into apache:main with commit 400d324 Sep 14, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants