From 75fa9a34a5b4a62e86303474cab3b6c192bba5f6 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 24 Sep 2025 14:21:23 -0700 Subject: [PATCH 1/8] Install Dilithium --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 4ff53ed..74a79b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,9 +4,11 @@ cbor2==5.6.5 cffi==1.17.1 click==8.1.7 cryptography==44.0.2 +dilithium-py==1.3.0 mccabe==0.7.0 mypy==1.11.2 mypy-extensions==1.0.0 +packaging==25.0 pathspec==0.12.1 platformdirs==4.3.6 pycodestyle==2.12.1 From 2fa9571919751f8947e1a6a948866c45cc6e93e2 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 24 Sep 2025 16:10:41 -0700 Subject: [PATCH 2/8] Tiny bit of cleanup --- webauthn/helpers/decode_credential_public_key.py | 2 -- webauthn/registration/generate_registration_options.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/webauthn/helpers/decode_credential_public_key.py b/webauthn/helpers/decode_credential_public_key.py index 030c68e..9c91947 100644 --- a/webauthn/helpers/decode_credential_public_key.py +++ b/webauthn/helpers/decode_credential_public_key.py @@ -1,8 +1,6 @@ from typing import Union from dataclasses import dataclass -import cbor2 - from .cose import COSECRV, COSEKTY, COSEAlgorithmIdentifier, COSEKey from .exceptions import InvalidPublicKeyStructure, UnsupportedPublicKeyType from .parse_cbor import parse_cbor diff --git a/webauthn/registration/generate_registration_options.py b/webauthn/registration/generate_registration_options.py index 254d978..325cd12 100644 --- a/webauthn/registration/generate_registration_options.py +++ b/webauthn/registration/generate_registration_options.py @@ -1,6 +1,6 @@ from typing import List, Optional -from webauthn.helpers import generate_challenge, generate_user_handle, byteslike_to_bytes +from webauthn.helpers import generate_challenge, generate_user_handle from webauthn.helpers.cose import COSEAlgorithmIdentifier from webauthn.helpers.structs import ( AttestationConveyancePreference, From d598dc67a76a7a07960cbaf6d46f6b5135693376 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 29 Sep 2025 08:50:20 -0700 Subject: [PATCH 3/8] Support registration responses with ML-DSA pubkeys --- webauthn/helpers/cose.py | 9 +++++++++ .../helpers/decode_credential_public_key.py | 20 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/webauthn/helpers/cose.py b/webauthn/helpers/cose.py index 7c4e6b4..19fab8c 100644 --- a/webauthn/helpers/cose.py +++ b/webauthn/helpers/cose.py @@ -15,6 +15,9 @@ class COSEAlgorithmIdentifier(int, Enum): `RSASSA_PKCS1_v1_5_SHA_384` `RSASSA_PKCS1_v1_5_SHA_512` `RSASSA_PKCS1_v1_5_SHA_1` + `ML_DSA_44` + `ML_DSA_65` + `ML_DSA_87` https://www.w3.org/TR/webauthn-2/#sctn-alg-identifier https://www.iana.org/assignments/cose/cose.xhtml#algorithms @@ -30,6 +33,9 @@ class COSEAlgorithmIdentifier(int, Enum): RSASSA_PKCS1_v1_5_SHA_384 = -258 RSASSA_PKCS1_v1_5_SHA_512 = -259 RSASSA_PKCS1_v1_5_SHA_1 = -65535 # Deprecated; here for legacy support + ML_DSA_44 = -48 + ML_DSA_65 = -49 + ML_DSA_87 = -50 class COSEKTY(int, Enum): @@ -43,6 +49,7 @@ class COSEKTY(int, Enum): OKP = 1 EC2 = 2 RSA = 3 + AKP = 7 class COSECRV(int, Enum): @@ -77,3 +84,5 @@ class COSEKey(int, Enum): # RSA N = -1 E = -2 + # ML-DSA + PUB = -1 diff --git a/webauthn/helpers/decode_credential_public_key.py b/webauthn/helpers/decode_credential_public_key.py index 9c91947..1de391e 100644 --- a/webauthn/helpers/decode_credential_public_key.py +++ b/webauthn/helpers/decode_credential_public_key.py @@ -31,9 +31,16 @@ class DecodedRSAPublicKey: e: bytes +@dataclass +class DecodedAKPPublicKey: + kty: COSEKTY + alg: COSEAlgorithmIdentifier + pub: bytes + + def decode_credential_public_key( key: bytes, -) -> Union[DecodedOKPPublicKey, DecodedEC2PublicKey, DecodedRSAPublicKey]: +) -> Union[DecodedOKPPublicKey, DecodedEC2PublicKey, DecodedRSAPublicKey, DecodedAKPPublicKey]: """ Decode a CBOR-encoded public key and turn it into a data structure. @@ -114,5 +121,16 @@ def decode_credential_public_key( n=n, e=e, ) + elif kty == COSEKTY.AKP: + pub = decoded_key[COSEKey.PUB] + + if not pub: + raise InvalidPublicKeyStructure("AKP credential public key missing pub") + + return DecodedAKPPublicKey( + kty=kty, + alg=alg, + pub=pub, + ) raise UnsupportedPublicKeyType(f'Unsupported credential public key type "{kty}"') From c8157bb4379b53ac934187d0e2703d0043bf9bfb Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 29 Sep 2025 08:50:41 -0700 Subject: [PATCH 4/8] Support ML-DSA sigs in authentication responses --- mypy.ini | 3 ++ .../decoded_public_key_to_cryptography.py | 13 +++++- webauthn/helpers/exceptions.py | 4 ++ webauthn/helpers/pqc.py | 44 +++++++++++++++++++ webauthn/helpers/verify_signature.py | 6 +++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 webauthn/helpers/pqc.py diff --git a/mypy.ini b/mypy.ini index 2938b45..adaaf99 100644 --- a/mypy.ini +++ b/mypy.ini @@ -9,3 +9,6 @@ ignore_missing_imports = True [mypy-OpenSSL.*] ignore_missing_imports = True + +[mypy-dilithium_py.*] +ignore_missing_imports = True diff --git a/webauthn/helpers/decoded_public_key_to_cryptography.py b/webauthn/helpers/decoded_public_key_to_cryptography.py index 23adf2f..9b1d1f3 100644 --- a/webauthn/helpers/decoded_public_key_to_cryptography.py +++ b/webauthn/helpers/decoded_public_key_to_cryptography.py @@ -14,13 +14,20 @@ DecodedEC2PublicKey, DecodedOKPPublicKey, DecodedRSAPublicKey, + DecodedAKPPublicKey, ) from .exceptions import UnsupportedPublicKey +from .pqc import AlgorithmKeyPairPublicKey def decoded_public_key_to_cryptography( - public_key: Union[DecodedOKPPublicKey, DecodedEC2PublicKey, DecodedRSAPublicKey] -) -> Union[Ed25519PublicKey, EllipticCurvePublicKey, RSAPublicKey]: + public_key: Union[ + DecodedOKPPublicKey, + DecodedEC2PublicKey, + DecodedRSAPublicKey, + DecodedAKPPublicKey, + ], +) -> Union[Ed25519PublicKey, EllipticCurvePublicKey, RSAPublicKey, AlgorithmKeyPairPublicKey]: """Convert raw decoded public key parameters (crv, x, y, n, e, etc...) into public keys using primitives from the cryptography.io library """ @@ -61,5 +68,7 @@ def decoded_public_key_to_cryptography( okp_pub_key = Ed25519PublicKey.from_public_bytes(public_key.x) return okp_pub_key + elif isinstance(public_key, DecodedAKPPublicKey): + return AlgorithmKeyPairPublicKey(public_key) else: raise UnsupportedPublicKey(f"Unrecognized decoded public key: {public_key}") diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py index b1ed255..1e69fa2 100644 --- a/webauthn/helpers/exceptions.py +++ b/webauthn/helpers/exceptions.py @@ -68,3 +68,7 @@ class InvalidBackupFlags(WebAuthnException): class InvalidCBORData(WebAuthnException): pass + + +class PQCNotSupported(WebAuthnException): + pass diff --git a/webauthn/helpers/pqc.py b/webauthn/helpers/pqc.py new file mode 100644 index 0000000..3d7a026 --- /dev/null +++ b/webauthn/helpers/pqc.py @@ -0,0 +1,44 @@ +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +from .cose import COSEAlgorithmIdentifier +from .exceptions import PQCNotSupported +from .decode_credential_public_key import DecodedAKPPublicKey + + +class AlgorithmKeyPairPublicKey(DecodedAKPPublicKey): + """ + Something vaguely shaped like other PublicKey classes in cryptography. Going with something + like this till the cryptography library itself supports PQC directly. + """ + + def __init__(self, decoded_public_key: DecodedAKPPublicKey) -> None: + try: + import dilithium_py + except Exception: + raise PQCNotSupported() + + super().__init__( + kty=decoded_public_key.kty, + alg=decoded_public_key.alg, + pub=decoded_public_key.pub, + ) + + def verify(self, signature: bytes, data: bytes) -> None: + """ + Verify the ML-DSA signature. Raises `cryptography.exceptions.InvalidSignature` to blend in. + """ + from dilithium_py.ml_dsa import ML_DSA_44, ML_DSA_65, ML_DSA_87 + + if self.alg == COSEAlgorithmIdentifier.ML_DSA_44: + verified = ML_DSA_44.verify(self.pub, data, signature) + elif self.alg == COSEAlgorithmIdentifier.ML_DSA_65: + verified = ML_DSA_65.verify(self.pub, data, signature) + elif self.alg == COSEAlgorithmIdentifier.ML_DSA_87: + verified = ML_DSA_87.verify(self.pub, data, signature) + + if not verified: + raise InvalidSignature() + + def public_bytes(self, encoding: Encoding, format: PublicFormat) -> bytes: + return self.pub diff --git a/webauthn/helpers/verify_signature.py b/webauthn/helpers/verify_signature.py index ba67e21..5ddd295 100644 --- a/webauthn/helpers/verify_signature.py +++ b/webauthn/helpers/verify_signature.py @@ -18,6 +18,8 @@ ) from .cose import COSEAlgorithmIdentifier from .exceptions import UnsupportedAlgorithm, UnsupportedPublicKey +from .decode_credential_public_key import DecodedAKPPublicKey +from .pqc import AlgorithmKeyPairPublicKey def verify_signature( @@ -30,6 +32,7 @@ def verify_signature( Ed448PublicKey, X25519PublicKey, X448PublicKey, + DecodedAKPPublicKey, ], signature_alg: COSEAlgorithmIdentifier, signature: bytes, @@ -66,6 +69,9 @@ def verify_signature( raise UnsupportedAlgorithm(f"Unrecognized RSA signature alg {signature_alg}") elif isinstance(public_key, Ed25519PublicKey): public_key.verify(signature, data) + elif isinstance(public_key, DecodedAKPPublicKey): + _public_key = AlgorithmKeyPairPublicKey(public_key) + _public_key.verify(signature, data) else: raise UnsupportedPublicKey( f"Unsupported public key for signature verification: {public_key}" From e3254233f3649ea9e3a30a0f44e86143fbe1ac8e Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 29 Sep 2025 08:47:24 -0700 Subject: [PATCH 5/8] Rename "AKP" to "ML-DSA" --- webauthn/helpers/cose.py | 2 +- webauthn/helpers/decode_credential_public_key.py | 10 +++++----- .../helpers/decoded_public_key_to_cryptography.py | 12 ++++++------ webauthn/helpers/pqc.py | 6 +++--- webauthn/helpers/verify_signature.py | 10 ++++------ 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/webauthn/helpers/cose.py b/webauthn/helpers/cose.py index 19fab8c..5dde45f 100644 --- a/webauthn/helpers/cose.py +++ b/webauthn/helpers/cose.py @@ -49,7 +49,7 @@ class COSEKTY(int, Enum): OKP = 1 EC2 = 2 RSA = 3 - AKP = 7 + ML_DSA = 7 class COSECRV(int, Enum): diff --git a/webauthn/helpers/decode_credential_public_key.py b/webauthn/helpers/decode_credential_public_key.py index 1de391e..357444e 100644 --- a/webauthn/helpers/decode_credential_public_key.py +++ b/webauthn/helpers/decode_credential_public_key.py @@ -32,7 +32,7 @@ class DecodedRSAPublicKey: @dataclass -class DecodedAKPPublicKey: +class DecodedMLDSAPublicKey: kty: COSEKTY alg: COSEAlgorithmIdentifier pub: bytes @@ -40,7 +40,7 @@ class DecodedAKPPublicKey: def decode_credential_public_key( key: bytes, -) -> Union[DecodedOKPPublicKey, DecodedEC2PublicKey, DecodedRSAPublicKey, DecodedAKPPublicKey]: +) -> Union[DecodedOKPPublicKey, DecodedEC2PublicKey, DecodedRSAPublicKey, DecodedMLDSAPublicKey]: """ Decode a CBOR-encoded public key and turn it into a data structure. @@ -121,13 +121,13 @@ def decode_credential_public_key( n=n, e=e, ) - elif kty == COSEKTY.AKP: + elif kty == COSEKTY.ML_DSA: pub = decoded_key[COSEKey.PUB] if not pub: - raise InvalidPublicKeyStructure("AKP credential public key missing pub") + raise InvalidPublicKeyStructure("ML-DSA credential public key missing pub") - return DecodedAKPPublicKey( + return DecodedMLDSAPublicKey( kty=kty, alg=alg, pub=pub, diff --git a/webauthn/helpers/decoded_public_key_to_cryptography.py b/webauthn/helpers/decoded_public_key_to_cryptography.py index 9b1d1f3..641e9e2 100644 --- a/webauthn/helpers/decoded_public_key_to_cryptography.py +++ b/webauthn/helpers/decoded_public_key_to_cryptography.py @@ -14,10 +14,10 @@ DecodedEC2PublicKey, DecodedOKPPublicKey, DecodedRSAPublicKey, - DecodedAKPPublicKey, + DecodedMLDSAPublicKey, ) from .exceptions import UnsupportedPublicKey -from .pqc import AlgorithmKeyPairPublicKey +from .pqc import MLDSAPublicKey def decoded_public_key_to_cryptography( @@ -25,9 +25,9 @@ def decoded_public_key_to_cryptography( DecodedOKPPublicKey, DecodedEC2PublicKey, DecodedRSAPublicKey, - DecodedAKPPublicKey, + DecodedMLDSAPublicKey, ], -) -> Union[Ed25519PublicKey, EllipticCurvePublicKey, RSAPublicKey, AlgorithmKeyPairPublicKey]: +) -> Union[Ed25519PublicKey, EllipticCurvePublicKey, RSAPublicKey, MLDSAPublicKey]: """Convert raw decoded public key parameters (crv, x, y, n, e, etc...) into public keys using primitives from the cryptography.io library """ @@ -68,7 +68,7 @@ def decoded_public_key_to_cryptography( okp_pub_key = Ed25519PublicKey.from_public_bytes(public_key.x) return okp_pub_key - elif isinstance(public_key, DecodedAKPPublicKey): - return AlgorithmKeyPairPublicKey(public_key) + elif isinstance(public_key, DecodedMLDSAPublicKey): + return MLDSAPublicKey(public_key) else: raise UnsupportedPublicKey(f"Unrecognized decoded public key: {public_key}") diff --git a/webauthn/helpers/pqc.py b/webauthn/helpers/pqc.py index 3d7a026..df7c589 100644 --- a/webauthn/helpers/pqc.py +++ b/webauthn/helpers/pqc.py @@ -3,16 +3,16 @@ from .cose import COSEAlgorithmIdentifier from .exceptions import PQCNotSupported -from .decode_credential_public_key import DecodedAKPPublicKey +from .decode_credential_public_key import DecodedMLDSAPublicKey -class AlgorithmKeyPairPublicKey(DecodedAKPPublicKey): +class MLDSAPublicKey(DecodedMLDSAPublicKey): """ Something vaguely shaped like other PublicKey classes in cryptography. Going with something like this till the cryptography library itself supports PQC directly. """ - def __init__(self, decoded_public_key: DecodedAKPPublicKey) -> None: + def __init__(self, decoded_public_key: DecodedMLDSAPublicKey) -> None: try: import dilithium_py except Exception: diff --git a/webauthn/helpers/verify_signature.py b/webauthn/helpers/verify_signature.py index 5ddd295..853e004 100644 --- a/webauthn/helpers/verify_signature.py +++ b/webauthn/helpers/verify_signature.py @@ -18,8 +18,7 @@ ) from .cose import COSEAlgorithmIdentifier from .exceptions import UnsupportedAlgorithm, UnsupportedPublicKey -from .decode_credential_public_key import DecodedAKPPublicKey -from .pqc import AlgorithmKeyPairPublicKey +from .pqc import MLDSAPublicKey def verify_signature( @@ -32,7 +31,7 @@ def verify_signature( Ed448PublicKey, X25519PublicKey, X448PublicKey, - DecodedAKPPublicKey, + MLDSAPublicKey, ], signature_alg: COSEAlgorithmIdentifier, signature: bytes, @@ -69,9 +68,8 @@ def verify_signature( raise UnsupportedAlgorithm(f"Unrecognized RSA signature alg {signature_alg}") elif isinstance(public_key, Ed25519PublicKey): public_key.verify(signature, data) - elif isinstance(public_key, DecodedAKPPublicKey): - _public_key = AlgorithmKeyPairPublicKey(public_key) - _public_key.verify(signature, data) + elif isinstance(public_key, MLDSAPublicKey): + public_key.verify(signature, data) else: raise UnsupportedPublicKey( f"Unsupported public key for signature verification: {public_key}" From 7ec196aaf2d286928089026632bb550babcfe8cd Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 29 Sep 2025 08:58:53 -0700 Subject: [PATCH 6/8] Add comment to MLDSAPublicKey.public_bytes() --- webauthn/helpers/pqc.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/webauthn/helpers/pqc.py b/webauthn/helpers/pqc.py index df7c589..5a0d5a9 100644 --- a/webauthn/helpers/pqc.py +++ b/webauthn/helpers/pqc.py @@ -41,4 +41,14 @@ def verify(self, signature: bytes, data: bytes) -> None: raise InvalidSignature() def public_bytes(self, encoding: Encoding, format: PublicFormat) -> bytes: + """ + From https://datatracker.ietf.org/doc/draft-ietf-cose-dilithium/09/: + + "The "pub" parameter is the ML-DSA public key, as described in + Section 5.3 of FIPS-204." + + This method simply returns the bytes, with no support for other encodings or formats. + Nothing that A) provides attestation, and B) uses PQC for public keys will use this + method right now. + """ return self.pub From 64867d9884479c8946d71bb6eaac4d1a0ecb942c Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 29 Sep 2025 09:40:02 -0700 Subject: [PATCH 7/8] Be more specific about it being ML-DSA support --- webauthn/helpers/decoded_public_key_to_cryptography.py | 2 +- webauthn/helpers/exceptions.py | 2 +- webauthn/helpers/{pqc.py => ml_dsa.py} | 6 ++++-- webauthn/helpers/verify_signature.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) rename webauthn/helpers/{pqc.py => ml_dsa.py} (90%) diff --git a/webauthn/helpers/decoded_public_key_to_cryptography.py b/webauthn/helpers/decoded_public_key_to_cryptography.py index 641e9e2..37b2011 100644 --- a/webauthn/helpers/decoded_public_key_to_cryptography.py +++ b/webauthn/helpers/decoded_public_key_to_cryptography.py @@ -17,7 +17,7 @@ DecodedMLDSAPublicKey, ) from .exceptions import UnsupportedPublicKey -from .pqc import MLDSAPublicKey +from .ml_dsa import MLDSAPublicKey def decoded_public_key_to_cryptography( diff --git a/webauthn/helpers/exceptions.py b/webauthn/helpers/exceptions.py index 1e69fa2..f3f5835 100644 --- a/webauthn/helpers/exceptions.py +++ b/webauthn/helpers/exceptions.py @@ -70,5 +70,5 @@ class InvalidCBORData(WebAuthnException): pass -class PQCNotSupported(WebAuthnException): +class MLDSANotSupported(WebAuthnException): pass diff --git a/webauthn/helpers/pqc.py b/webauthn/helpers/ml_dsa.py similarity index 90% rename from webauthn/helpers/pqc.py rename to webauthn/helpers/ml_dsa.py index 5a0d5a9..d6e9098 100644 --- a/webauthn/helpers/pqc.py +++ b/webauthn/helpers/ml_dsa.py @@ -2,7 +2,7 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from .cose import COSEAlgorithmIdentifier -from .exceptions import PQCNotSupported +from .exceptions import MLDSANotSupported from .decode_credential_public_key import DecodedMLDSAPublicKey @@ -16,7 +16,9 @@ def __init__(self, decoded_public_key: DecodedMLDSAPublicKey) -> None: try: import dilithium_py except Exception: - raise PQCNotSupported() + raise MLDSANotSupported( + "Please install https://pypi.org/project/dilithium-py to verify ML-DSA responses with py_webauthn" + ) super().__init__( kty=decoded_public_key.kty, diff --git a/webauthn/helpers/verify_signature.py b/webauthn/helpers/verify_signature.py index 853e004..19aad79 100644 --- a/webauthn/helpers/verify_signature.py +++ b/webauthn/helpers/verify_signature.py @@ -18,7 +18,7 @@ ) from .cose import COSEAlgorithmIdentifier from .exceptions import UnsupportedAlgorithm, UnsupportedPublicKey -from .pqc import MLDSAPublicKey +from .ml_dsa import MLDSAPublicKey def verify_signature( From fb77fe61bf9de150faea9fe9bb43a643aae3c6de Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Mon, 29 Sep 2025 09:44:46 -0700 Subject: [PATCH 8/8] Pull out dependency check --- webauthn/helpers/ml_dsa.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/webauthn/helpers/ml_dsa.py b/webauthn/helpers/ml_dsa.py index d6e9098..9ffeedb 100644 --- a/webauthn/helpers/ml_dsa.py +++ b/webauthn/helpers/ml_dsa.py @@ -13,12 +13,7 @@ class MLDSAPublicKey(DecodedMLDSAPublicKey): """ def __init__(self, decoded_public_key: DecodedMLDSAPublicKey) -> None: - try: - import dilithium_py - except Exception: - raise MLDSANotSupported( - "Please install https://pypi.org/project/dilithium-py to verify ML-DSA responses with py_webauthn" - ) + assert_ml_dsa_dependencies() super().__init__( kty=decoded_public_key.kty, @@ -54,3 +49,19 @@ def public_bytes(self, encoding: Encoding, format: PublicFormat) -> bytes: method right now. """ return self.pub + + +def assert_ml_dsa_dependencies() -> None: + """ + Check that necessary dependencies are present for handling responses containing ML-DSA public + keys. + + Raises: + `webauthn.helpers.exceptions.MLDSANotSupported` if those dependencies are missing + """ + try: + import dilithium_py + except Exception: + raise MLDSANotSupported( + "Please install https://pypi.org/project/dilithium-py to verify ML-DSA responses with py_webauthn" + )