From fe7dbe4595c0679471e19bfcff59acb98d78d2b1 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 17 Aug 2026 19:35:27 +0000 Subject: [PATCH 1/2] feat: add post-quantum ML-DSA crypt module (PqcSigner and PqcVerifier) --- .../docs/reference/google.auth.crypt.pqc.rst | 7 + .../docs/reference/google.auth.crypt.rst | 1 + .../google-auth/google/auth/crypt/__init__.py | 14 +- packages/google-auth/google/auth/crypt/pqc.py | 284 ++++++++++++++++++ packages/google-auth/tests/crypt/test_pqc.py | 257 ++++++++++++++++ 5 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 packages/google-auth/docs/reference/google.auth.crypt.pqc.rst create mode 100644 packages/google-auth/google/auth/crypt/pqc.py create mode 100644 packages/google-auth/tests/crypt/test_pqc.py diff --git a/packages/google-auth/docs/reference/google.auth.crypt.pqc.rst b/packages/google-auth/docs/reference/google.auth.crypt.pqc.rst new file mode 100644 index 000000000000..564f3e738b9f --- /dev/null +++ b/packages/google-auth/docs/reference/google.auth.crypt.pqc.rst @@ -0,0 +1,7 @@ +google.auth.crypt.pqc module +============================ + +.. automodule:: google.auth.crypt.pqc + :members: + :inherited-members: + :show-inheritance: diff --git a/packages/google-auth/docs/reference/google.auth.crypt.rst b/packages/google-auth/docs/reference/google.auth.crypt.rst index ff38fa34ea84..5b656e3cc458 100644 --- a/packages/google-auth/docs/reference/google.auth.crypt.rst +++ b/packages/google-auth/docs/reference/google.auth.crypt.rst @@ -14,4 +14,5 @@ Submodules google.auth.crypt.base google.auth.crypt.es256 + google.auth.crypt.pqc google.auth.crypt.rsa diff --git a/packages/google-auth/google/auth/crypt/__init__.py b/packages/google-auth/google/auth/crypt/__init__.py index e56bc7b82df7..ffe5e92bebe8 100644 --- a/packages/google-auth/google/auth/crypt/__init__.py +++ b/packages/google-auth/google/auth/crypt/__init__.py @@ -35,17 +35,24 @@ The code above also works for :class:`ES256Signer` and :class:`ES256Verifier`. Note that these two classes are only available if your `cryptography` dependency version is at least 1.4.0. + +Post-quantum ML-DSA signing and verification is available via :class:`PqcSigner` +and :class:`PqcVerifier` when `cryptography` version is at least 47.0.0. """ from google.auth.crypt import base from google.auth.crypt import es from google.auth.crypt import es256 +from google.auth.crypt import pqc from google.auth.crypt import rsa EsSigner = es.EsSigner EsVerifier = es.EsVerifier ES256Signer = es256.ES256Signer ES256Verifier = es256.ES256Verifier +PqcSigner = pqc.PqcSigner +PqcVerifier = pqc.PqcVerifier +is_mldsa_key = pqc.is_mldsa_key # Aliases to maintain the v1.0.0 interface, as the crypt module was split @@ -57,7 +64,7 @@ def verify_signature(message, signature, certs, verifier_cls=rsa.RSAVerifier): - """Verify an RSA or ECDSA cryptographic signature. + """Verify an RSA, ECDSA, or ML-DSA cryptographic signature. Checks that the provided ``signature`` was generated from ``bytes`` using the private key associated with the ``cert``. @@ -69,7 +76,7 @@ def verify_signature(message, signature, certs, verifier_cls=rsa.RSAVerifier): to use to check the signature. verifier_cls (Optional[~google.auth.crypt.base.Signer]): Which verifier class to use for verification. This can be used to select different - algorithms, such as RSA or ECDSA. Default value is :class:`RSAVerifier`. + algorithms, such as RSA, ECDSA, or ML-DSA. Default value is :class:`RSAVerifier`. Returns: bool: True if the signature is valid, otherwise False. @@ -89,8 +96,11 @@ class to use for verification. This can be used to select different "EsVerifier", "ES256Signer", "ES256Verifier", + "PqcSigner", + "PqcVerifier", "RSASigner", "RSAVerifier", "Signer", "Verifier", + "is_mldsa_key", ] diff --git a/packages/google-auth/google/auth/crypt/pqc.py b/packages/google-auth/google/auth/crypt/pqc.py new file mode 100644 index 000000000000..80778a53288d --- /dev/null +++ b/packages/google-auth/google/auth/crypt/pqc.py @@ -0,0 +1,284 @@ +# Copyright 2026 Google LLC +# +# Licensed 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. + +"""Post-quantum ML-DSA verifier and signer that use the ``cryptography`` library. +""" + +from typing import Any, Dict, Optional, Union + +import cryptography.exceptions +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import serialization +import cryptography.x509 + +from google.auth import _helpers +from google.auth.crypt import base + +# ============================================================================== +# Module-level imports & NIST FIPS 204 OID definitions +# ============================================================================== + +try: + from cryptography.hazmat.primitives.asymmetric import mldsa +except ImportError: + mldsa = None # type: ignore + +_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" +_BACKEND = backends.default_backend() + +_UPGRADE_ERROR = ( + "Post-Quantum ML-DSA Service Account keys require cryptography>=47.0.0. " + "Please upgrade your cryptography library (pip install 'cryptography>=47.0.0')." +) + + +def _get_mldsa_types(*attr_names: str) -> tuple: + if mldsa is None: + return () + types = [] + for name in attr_names: + t = getattr(mldsa, name, None) + if isinstance(t, type): + types.append(t) + return tuple(types) + + +# ============================================================================== +# Key detection helpers +# ============================================================================== + + +def is_mldsa_key(key: Union[str, bytes]) -> bool: + """Determines whether a key is an ML-DSA private or public key.""" + if mldsa is None: + return False + + expected_types = _get_mldsa_types( + "MLDSA44PrivateKey", + "MLDSA65PrivateKey", + "MLDSA87PrivateKey", + "MLDSA44PublicKey", + "MLDSA65PublicKey", + "MLDSA87PublicKey", + ) + if not expected_types: + return False + + try: + key_bytes = _helpers.to_bytes(key) + except (TypeError, ValueError, AttributeError): + return False + + try: + priv_key = serialization.load_pem_private_key( + key_bytes, password=None, backend=_BACKEND + ) + return isinstance(priv_key, expected_types) + except Exception: + pass + + try: + pub_key = serialization.load_pem_public_key(key_bytes, _BACKEND) + return isinstance(pub_key, expected_types) + except Exception: + pass + + return False + + +def _get_mldsa_algorithm_name(key: Any) -> str: + """Determines the ML-DSA algorithm string ("ML-DSA-44", "ML-DSA-65", or "ML-DSA-87") for a key.""" + cls_name = key.__class__.__name__ + t44 = _get_mldsa_types("MLDSA44PrivateKey", "MLDSA44PublicKey") + t65 = _get_mldsa_types("MLDSA65PrivateKey", "MLDSA65PublicKey") + t87 = _get_mldsa_types("MLDSA87PrivateKey", "MLDSA87PublicKey") + + if "87" in cls_name or (t87 and isinstance(key, t87)): + return "ML-DSA-87" + if "65" in cls_name or (t65 and isinstance(key, t65)): + return "ML-DSA-65" + if "44" in cls_name or (t44 and isinstance(key, t44)): + return "ML-DSA-44" + + raise TypeError( + "Expected key of type ML-DSA (e.g. MLDSA44PrivateKey, MLDSA65PrivateKey, or MLDSA87PrivateKey), got: {}".format( + cls_name + ) + ) + + +# ============================================================================== +# PQC Verifier +# ============================================================================== + + +class PqcVerifier(base.Verifier): + """Verifies ML-DSA cryptographic signatures using public keys. + + ML-DSA-65 (3,309-byte signature) is set as the recommended default PQC key + type over ML-DSA-87 (4,627 bytes) to minimize HTTP request header overhead. + + Args: + public_key: The public key used to verify signatures. + """ + + def __init__(self, public_key: Any) -> None: + self._pubkey = public_key + + @_helpers.copy_docstring(base.Verifier) + def verify(self, message: bytes, signature: bytes) -> bool: + message = _helpers.to_bytes(message) + sig_bytes = _helpers.to_bytes(signature) + try: + self._pubkey.verify(sig_bytes, message) + return True + except (ValueError, TypeError, cryptography.exceptions.InvalidSignature): + return False + + @classmethod + def from_string(cls, public_key: Union[str, bytes]) -> "PqcVerifier": + """Construct a Verifier instance from a public key or certificate string. + + Args: + public_key (Union[bytes, str]): Public key or certificate in PEM format. + + Returns: + google.auth.crypt.pqc.PqcVerifier: The constructed verifier. + + Raises: + RuntimeError: If ``cryptography`` is less than version 47.0.0. + TypeError: If ``public_key`` is not an ML-DSA public key. + """ + if mldsa is None: + raise RuntimeError(_UPGRADE_ERROR) + + public_key_data = _helpers.to_bytes(public_key) + + if _CERTIFICATE_MARKER in public_key_data: + cert = cryptography.x509.load_pem_x509_certificate( + public_key_data, _BACKEND + ) + pubkey: Any = cert.public_key() + else: + pubkey = serialization.load_pem_public_key(public_key_data, _BACKEND) + + expected_types = _get_mldsa_types( + "MLDSA44PublicKey", "MLDSA65PublicKey", "MLDSA87PublicKey" + ) + if not expected_types or not isinstance(pubkey, expected_types): + raise TypeError( + "Expected public key of type ML-DSA (e.g. MLDSA44PublicKey, MLDSA65PublicKey, or MLDSA87PublicKey)" + ) + + return cls(pubkey) + + +# ============================================================================== +# PQC Signer +# ============================================================================== + + +class PqcSigner(base.Signer, base.FromServiceAccountMixin): + """Signs messages with a post-quantum ML-DSA (Module-Lattice-Based Digital + Signature Algorithm) private key. + + ML-DSA-65 (3,309-byte signature) is set as the recommended default PQC key + type over ML-DSA-87 (4,627 bytes) to minimize HTTP request header overhead. + + Args: + private_key: The ML-DSA private key to sign with. + key_id (Optional[str]): Optional key ID used to identify this private key. + """ + + RECOMMENDED_DEFAULT_ALGORITHM = "ML-DSA-65" + + def __init__(self, private_key: Any, key_id: Optional[str] = None) -> None: + self._key = private_key + self._key_id = key_id + self._algorithm = _get_mldsa_algorithm_name(private_key) + + @property + def algorithm(self) -> str: + """Name of the algorithm used to sign messages. + + Returns: + str: The algorithm name (e.g., "ML-DSA-65" or "ML-DSA-87"). + """ + return self._algorithm + + @property # type: ignore + @_helpers.copy_docstring(base.Signer) + def key_id(self) -> Optional[str]: + return self._key_id + + @_helpers.copy_docstring(base.Signer) + def sign(self, message: bytes) -> bytes: + message = _helpers.to_bytes(message) + return self._key.sign(message) + + @classmethod + def from_string( + cls, key: Union[bytes, str], key_id: Optional[str] = None + ) -> "PqcSigner": + """Construct a PqcSigner from a private key in PEM format. + + Args: + key (Union[bytes, str]): Private key in PEM format. + key_id (Optional[str]): An optional key id used to identify the private key. + + Returns: + google.auth.crypt.pqc.PqcSigner: The constructed signer. + + Raises: + RuntimeError: If ``cryptography`` is less than version 47.0.0. + ValueError: If ``cryptography`` "Could not deserialize key data." + TypeError: If ``key`` is not an ML-DSA private key. + """ + if mldsa is None: + raise RuntimeError(_UPGRADE_ERROR) + + key_bytes = _helpers.to_bytes(key) + private_key = serialization.load_pem_private_key( + key_bytes, password=None, backend=_BACKEND + ) + + expected_types = _get_mldsa_types( + "MLDSA44PrivateKey", "MLDSA65PrivateKey", "MLDSA87PrivateKey" + ) + if not expected_types or not isinstance(private_key, expected_types): + raise TypeError( + "Expected private key of type ML-DSA (e.g. MLDSA44PrivateKey, MLDSA65PrivateKey, or MLDSA87PrivateKey)" + ) + + return cls(private_key, key_id=key_id) + + def __getstate__(self) -> Dict[str, Any]: + """Pickle helper that serializes the _key attribute.""" + state = self.__dict__.copy() + state["_key"] = self._key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Pickle helper that deserializes the _key attribute.""" + if mldsa is None: + raise RuntimeError(_UPGRADE_ERROR) + state = state.copy() + state["_key"] = serialization.load_pem_private_key( + state["_key"], password=None, backend=_BACKEND + ) + self.__dict__.update(state) diff --git a/packages/google-auth/tests/crypt/test_pqc.py b/packages/google-auth/tests/crypt/test_pqc.py new file mode 100644 index 000000000000..e105620c8642 --- /dev/null +++ b/packages/google-auth/tests/crypt/test_pqc.py @@ -0,0 +1,257 @@ +# Copyright 2026 Google LLC +# +# Licensed 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 unittest import mock + +import pytest + +from google.auth.crypt import pqc + + +PEM_MLDSA_KEY = "-----BEGIN PRIVATE KEY-----\ndGVzdG1sZHNh\n-----END PRIVATE KEY-----" +PEM_MLDSA_44 = PEM_MLDSA_KEY +PEM_MLDSA_65 = PEM_MLDSA_KEY +PEM_MLDSA_87 = PEM_MLDSA_KEY +PEM_NOT_MLDSA = ( + "-----BEGIN PRIVATE KEY-----\ndGVzdGtleW5vdG1sZHNh\n-----END PRIVATE KEY-----" +) + + +class TestIsMldsaKey: + def test_is_mldsa_key_invalid_inputs(self): + assert pqc.is_mldsa_key(None) is False + assert pqc.is_mldsa_key(12345) is False + assert pqc.is_mldsa_key({}) is False + assert pqc.is_mldsa_key("not a key") is False + + def test_is_mldsa_key_without_mldsa(self, monkeypatch): + monkeypatch.setattr(pqc, "mldsa", None) + assert pqc.is_mldsa_key(PEM_MLDSA_65) is False + + def test_is_mldsa_key_with_mldsa_module(self, monkeypatch): + class MockMLDSA65PrivateKey: + pass + + mock_mldsa = mock.Mock() + mock_mldsa.MLDSA65PrivateKey = MockMLDSA65PrivateKey + + monkeypatch.setattr(pqc, "mldsa", mock_mldsa) + monkeypatch.setattr( + pqc.serialization, + "load_pem_private_key", + lambda key, password, backend: MockMLDSA65PrivateKey(), + ) + + assert pqc.is_mldsa_key(PEM_MLDSA_65) is True + + +class TestPqcSignerWithoutCryptography47: + def test_from_string_raises_runtime_error(self, monkeypatch): + monkeypatch.setattr(pqc, "mldsa", None) + with pytest.raises(RuntimeError) as excinfo: + pqc.PqcSigner.from_string(PEM_MLDSA_65) + + assert ( + "Post-Quantum ML-DSA Service Account keys require cryptography>=47.0.0" + in str(excinfo.value) + ) + assert ( + "Please upgrade your cryptography library (pip install 'cryptography>=47.0.0')" + in str(excinfo.value) + ) + + def test_setstate_raises_runtime_error(self, monkeypatch): + monkeypatch.setattr(pqc, "mldsa", None) + signer_state = {"_key": PEM_MLDSA_65.encode("utf-8"), "_key_id": "test_id"} + # Instantiate without __init__ + signer = pqc.PqcSigner.__new__(pqc.PqcSigner) + with pytest.raises(RuntimeError) as excinfo: + signer.__setstate__(signer_state) + + assert ( + "Post-Quantum ML-DSA Service Account keys require cryptography>=47.0.0" + in str(excinfo.value) + ) + + +class TestPqcVerifierWithoutCryptography47: + def test_from_string_raises_runtime_error(self, monkeypatch): + monkeypatch.setattr(pqc, "mldsa", None) + with pytest.raises(RuntimeError) as excinfo: + pqc.PqcVerifier.from_string("pubkey") + + assert ( + "Post-Quantum ML-DSA Service Account keys require cryptography>=47.0.0" + in str(excinfo.value) + ) + + +class TestPqcSignerWithCryptography47: + @pytest.fixture + def mock_mldsa_env(self, monkeypatch): + class MockMLDSA65PrivateKey: + def __init__(self): + self._signed = False + + def sign(self, message): + self._signed = True + return b"sig-65-" + message + + def private_bytes(self, encoding, format, encryption_algorithm): + return b"private-pem-65" + + class MockMLDSA44PrivateKey: + def sign(self, message): + return b"sig-44-" + message + + class MockMLDSA87PrivateKey: + def sign(self, message): + return b"sig-87-" + message + + mock_mldsa = mock.Mock() + mock_mldsa.MLDSA44PrivateKey = MockMLDSA44PrivateKey + mock_mldsa.MLDSA65PrivateKey = MockMLDSA65PrivateKey + mock_mldsa.MLDSA87PrivateKey = MockMLDSA87PrivateKey + + monkeypatch.setattr(pqc, "mldsa", mock_mldsa) + return ( + mock_mldsa, + MockMLDSA44PrivateKey, + MockMLDSA65PrivateKey, + MockMLDSA87PrivateKey, + ) + + def test_from_string_mldsa44(self, mock_mldsa_env, monkeypatch): + _, Mock44, _, _ = mock_mldsa_env + monkeypatch.setattr( + pqc.serialization, + "load_pem_private_key", + lambda key, password, backend: Mock44(), + ) + signer = pqc.PqcSigner.from_string(PEM_MLDSA_44, key_id="key-44") + assert signer.key_id == "key-44" + assert signer.algorithm == "ML-DSA-44" + + sig = signer.sign(b"test") + assert sig == b"sig-44-test" + + def test_from_string_mldsa65(self, mock_mldsa_env, monkeypatch): + _, _, Mock65, _ = mock_mldsa_env + monkeypatch.setattr( + pqc.serialization, + "load_pem_private_key", + lambda key, password, backend: Mock65(), + ) + signer = pqc.PqcSigner.from_string(PEM_MLDSA_65, key_id="key-65") + assert signer.key_id == "key-65" + assert signer.algorithm == "ML-DSA-65" + assert pqc.PqcSigner.RECOMMENDED_DEFAULT_ALGORITHM == "ML-DSA-65" + + sig = signer.sign("hello") + assert sig == b"sig-65-hello" + + def test_from_string_mldsa87(self, mock_mldsa_env, monkeypatch): + _, _, _, Mock87 = mock_mldsa_env + monkeypatch.setattr( + pqc.serialization, + "load_pem_private_key", + lambda key, password, backend: Mock87(), + ) + signer = pqc.PqcSigner.from_string(PEM_MLDSA_87, key_id="key-87") + assert signer.key_id == "key-87" + assert signer.algorithm == "ML-DSA-87" + + sig = signer.sign(b"world") + assert sig == b"sig-87-world" + + def test_from_string_invalid_type(self, mock_mldsa_env, monkeypatch): + monkeypatch.setattr( + pqc.serialization, + "load_pem_private_key", + lambda key, password, backend: "not-an-mldsa-key", + ) + with pytest.raises(TypeError) as excinfo: + pqc.PqcSigner.from_string(PEM_MLDSA_65) + assert "Expected private key of type ML-DSA" in str(excinfo.value) + + def test_pickle_getstate_setstate(self, mock_mldsa_env, monkeypatch): + _, _, Mock65, _ = mock_mldsa_env + key = Mock65() + signer = pqc.PqcSigner(key, key_id="test-id") + + state = signer.__getstate__() + assert state["_key"] == b"private-pem-65" + assert state["_key_id"] == "test-id" + + loaded_key = Mock65() + monkeypatch.setattr( + pqc.serialization, + "load_pem_private_key", + mock.Mock(return_value=loaded_key), + ) + + new_signer = pqc.PqcSigner.__new__(pqc.PqcSigner) + new_signer.__setstate__(state) + assert new_signer.key_id == "test-id" + assert new_signer.algorithm == "ML-DSA-65" + + +class TestPqcVerifierWithCryptography47: + @pytest.fixture + def mock_mldsa_env(self, monkeypatch): + class MockMLDSA65PublicKey: + def verify(self, signature, message): + if signature != b"valid-sig": + raise ValueError("Invalid signature") + + mock_mldsa = mock.Mock() + mock_mldsa.MLDSA65PublicKey = MockMLDSA65PublicKey + monkeypatch.setattr(pqc, "mldsa", mock_mldsa) + return mock_mldsa, MockMLDSA65PublicKey + + def test_from_string_and_verify(self, mock_mldsa_env, monkeypatch): + mock_mldsa, Mock65Pub = mock_mldsa_env + monkeypatch.setattr( + pqc.serialization, + "load_pem_public_key", + lambda pub, backend: Mock65Pub(), + ) + + verifier = pqc.PqcVerifier.from_string("mock-pubkey") + assert verifier.verify(b"msg", b"valid-sig") is True + assert verifier.verify(b"msg", b"invalid-sig") is False + + def test_from_string_x509_cert(self, mock_mldsa_env, monkeypatch): + mock_mldsa, Mock65Pub = mock_mldsa_env + mock_cert = mock.Mock() + mock_cert.public_key.return_value = Mock65Pub() + monkeypatch.setattr( + pqc.cryptography.x509, + "load_pem_x509_certificate", + lambda cert, backend: mock_cert, + ) + + cert_str = "-----BEGIN CERTIFICATE-----\nmockcert\n-----END CERTIFICATE-----" + verifier = pqc.PqcVerifier.from_string(cert_str) + assert verifier.verify(b"msg", b"valid-sig") is True + + def test_from_string_invalid_type(self, mock_mldsa_env, monkeypatch): + monkeypatch.setattr( + pqc.serialization, + "load_pem_public_key", + lambda pub, backend: "not-an-mldsa-pubkey", + ) + with pytest.raises(TypeError) as excinfo: + pqc.PqcVerifier.from_string("mock-pubkey") + assert "Expected public key of type ML-DSA" in str(excinfo.value) From d5eafa9a4aa935157f1238dbd9c6bdb0d432e59c Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 17 Aug 2026 21:50:04 +0000 Subject: [PATCH 2/2] docs: add ValueError to PqcVerifier.from_string docstring --- packages/google-auth/google/auth/crypt/pqc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/google-auth/google/auth/crypt/pqc.py b/packages/google-auth/google/auth/crypt/pqc.py index 80778a53288d..1b72ab05797d 100644 --- a/packages/google-auth/google/auth/crypt/pqc.py +++ b/packages/google-auth/google/auth/crypt/pqc.py @@ -158,6 +158,7 @@ def from_string(cls, public_key: Union[str, bytes]) -> "PqcVerifier": Raises: RuntimeError: If ``cryptography`` is less than version 47.0.0. + ValueError: If ``public_key`` cannot be parsed. TypeError: If ``public_key`` is not an ML-DSA public key. """ if mldsa is None: