From f9bf2a1658da36c84a2f9ecf1a47b535502a74e5 Mon Sep 17 00:00:00 2001 From: Brian Sipos Date: Wed, 27 May 2026 13:46:24 -0400 Subject: [PATCH 1/4] Adding AKP key type and ML-DSA algorithms --- pycose/algorithms.py | 94 +++++++++++++ pycose/keys/__init__.py | 1 + pycose/keys/akp.py | 246 +++++++++++++++++++++++++++++++++ pycose/keys/keyparam.py | 26 ++++ pycose/keys/keytype.py | 6 + pycose/messages/cosemessage.py | 2 + pycose/messages/signcommon.py | 3 + tests/test_akp_keys.py | 202 +++++++++++++++++++++++++++ tests/test_okp_keys.py | 4 +- 9 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 pycose/keys/akp.py create mode 100644 tests/test_akp_keys.py diff --git a/pycose/algorithms.py b/pycose/algorithms.py index 8a2b4c0..cf2191a 100644 --- a/pycose/algorithms.py +++ b/pycose/algorithms.py @@ -9,6 +9,11 @@ from cryptography.hazmat.primitives.asymmetric.ec import ECDH from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey, Ed448PublicKey +from cryptography.hazmat.primitives.asymmetric.mldsa import ( + MLDSA44PrivateKey, MLDSA44PublicKey, + MLDSA65PrivateKey, MLDSA65PublicKey, + MLDSA87PrivateKey, MLDSA87PublicKey +) from cryptography.hazmat.primitives.ciphers import modes, Cipher from cryptography.hazmat.primitives.ciphers.aead import AESGCM, AESCCM from cryptography.hazmat.primitives.ciphers.algorithms import AES @@ -28,6 +33,7 @@ from pycose.keys.symmetric import SK from pycose.keys.ec2 import EC2 from pycose.keys.okp import OKP + from pycose.keys.akp import AKP from pycose.keys.rsa import RSA from pycose.keys.curves import CoseCurve from pycose.messages.context import CoseKDFContext @@ -420,6 +426,94 @@ def get_hash_func(cls): return SHA256 +class _Mldsa(CoseAlgorithm, ABC): + """ Fully-specified ML-DSA family. """ + + private_key_cls = None + ''' Override in derived class ''' + public_key_cls = None + ''' Override in derived class ''' + + @classmethod + def sign(cls, key: 'AKP', data: bytes) -> bytes: + + pkey = cls.private_key_cls.from_seed_bytes(key.priv) + + return pkey.sign(data=data, context=None) + + @classmethod + def verify(cls, key: 'AKP', data: bytes, signature: bytes) -> bool: + + vkey = cls.public_key_cls.from_public_bytes(key.pub) + + try: + vkey.verify(signature=signature, data=data, context=None) + return True + except InvalidSignature: + return False + + +@CoseAlgorithm.register_attribute() +class MlDsa87(_Mldsa): + """ + ML-DSA-87 + + Attributes: + identifier -50 + fullname MLDSA87 + + """ + + identifier = -50 + fullname = "MLDSA87" + + private_key_cls = MLDSA87PrivateKey + """ Key class for this algorithm """ + public_key_cls = MLDSA87PublicKey + """ Key class for this algorithm """ + + +@CoseAlgorithm.register_attribute() +class MlDsa65(_Mldsa): + """ + ML-DSA-65 + + Attributes: + identifier -49 + fullname MLDSA65 + + """ + + identifier = -49 + fullname = "MLDSA65" + + private_key_cls = MLDSA65PrivateKey + """ Key class for this algorithm """ + public_key_cls = MLDSA65PublicKey + """ Key class for this algorithm """ + + +@CoseAlgorithm.register_attribute() +class MlDsa44(_Mldsa): + """ + ML-DSA-44 + + Attributes: + identifier -48 + fullname MLDSA44 + + """ + + identifier = -48 + fullname = "MLDSA44" + + private_key_cls = MLDSA44PrivateKey + """ Key class for this algorithm """ + public_key_cls = MLDSA44PublicKey + """ Key class for this algorithm """ + + + @CoseAlgorithm.register_attribute() class Shake256(_HashAlg): """ diff --git a/pycose/keys/__init__.py b/pycose/keys/__init__.py index a5460a7..3511322 100644 --- a/pycose/keys/__init__.py +++ b/pycose/keys/__init__.py @@ -1,5 +1,6 @@ from .ec2 import EC2Key # noqa: F01 from .okp import OKPKey # noqa: F01 +from .akp import AKPKey # noqa: F01 from .rsa import RSAKey # noqa: F01 from .symmetric import SymmetricKey # noqa: F01 from .cosekey import CoseKey # noqa: F01 diff --git a/pycose/keys/akp.py b/pycose/keys/akp.py new file mode 100644 index 0000000..ec6f190 --- /dev/null +++ b/pycose/keys/akp.py @@ -0,0 +1,246 @@ +from typing import Optional, Type, Union, List, TYPE_CHECKING + +from cryptography.hazmat.primitives.serialization import PrivateFormat, PublicFormat, Encoding, NoEncryption +from cryptography.hazmat.primitives.asymmetric import mldsa + +from pycose import utils +from pycose.exceptions import CoseInvalidKey, CoseIllegalKeyType, CoseIllegalKeyOps +from pycose.keys.cosekey import CoseKey, KpKty, KpAlg +from pycose.keys.keyops import SignOp, VerifyOp, DeriveBitsOp, DeriveKeyOp +from pycose.keys.keyparam import AKPKeyParam, AKPKpPub, AKPKpPriv +from pycose.keys.keytype import KtyAKP +from pycose.algorithms import CoseAlgorithm, MlDsa44, MlDsa65, MlDsa87 + +if TYPE_CHECKING: + from pycose.keys.keyops import KEYOPS + +PYCRYPTO_KEY_ALG = { + mldsa.MLDSA44PrivateKey: MlDsa44, + mldsa.MLDSA44PublicKey: MlDsa44, + mldsa.MLDSA65PrivateKey: MlDsa65, + mldsa.MLDSA65PublicKey: MlDsa65, + mldsa.MLDSA87PrivateKey: MlDsa87, + mldsa.MLDSA87PublicKey: MlDsa87, +} +PYCRYPTO_KEY_TYPES = tuple(PYCRYPTO_KEY_ALG.keys()) + +@CoseKey.record_kty(KtyAKP) +class AKPKey(CoseKey): + + @classmethod + def from_dict(cls, cose_key: dict) -> 'AKPKey': + """ + Returns an initialized COSE Key object of type AKPKey. + + :param cose_key: Dictionary containing COSE Key parameters and there values. + + :return: an initialized AKPKey key + """ + _optional_params = {} + + # extract and remove items from dict, if not found return default value + alg = CoseKey._extract_from_dict(cose_key, KpAlg, None) + pub = CoseKey._extract_from_dict(cose_key, AKPKpPub) + priv = CoseKey._extract_from_dict(cose_key, AKPKpPriv) + + _optional_params.update(cose_key) + CoseKey._remove_from_dict(_optional_params, KpAlg) + CoseKey._remove_from_dict(_optional_params, AKPKpPub) + CoseKey._remove_from_dict(_optional_params, AKPKpPriv) + + return cls(alg=alg, pub=pub, priv=priv, optional_params=_optional_params, allow_unknown_key_attrs=True) + + @staticmethod + def _from_cryptography_key( + ext_key: Union[PYCRYPTO_KEY_TYPES], + optional_params: Optional[dict] = None, + ) -> 'AKPKey': + """ + Returns an initialized COSE Key object of type AKPKey. + :param ext_key: Python cryptography key. + :return: an initialized AKP key + """ + + alg = None + for ext_cls, use_alg in PYCRYPTO_KEY_ALG.items(): + if isinstance(ext_key, ext_cls): + alg = use_alg + break + if alg is None: + raise CoseIllegalKeyType(f'Unsupported key type {type(ext_key)}') + + if hasattr(ext_key, 'private_bytes'): + priv_bytes = ext_key.private_bytes( + encoding=Encoding.Raw, + format=PrivateFormat.Raw, + encryption_algorithm=NoEncryption(), + ) + pub_bytes = ext_key.public_key().public_bytes( + encoding=Encoding.Raw, format=PublicFormat.Raw + ) + else: + priv_bytes = None + pub_bytes = ext_key.public_bytes(encoding=Encoding.Raw, format=PublicFormat.Raw) + + cose_key = { + KpAlg: alg, + AKPKpPub: pub_bytes, + } + if priv_bytes: + cose_key[AKPKpPriv] = priv_bytes + if optional_params: + cose_key.update(optional_params) + return AKPKey.from_dict(cose_key) + + @classmethod + def _supports_cryptography_key_type(cls, ext_key) -> bool: + return isinstance(ext_key, PYCRYPTO_KEY_TYPES) + + @staticmethod + def _key_transform(key: Union[Type['AKPKeyParam'], Type['KeyParam'], str, int], + allow_unknown_attrs: bool = False): + return AKPKeyParam.from_id(key, allow_unknown_attrs) + + def __init__(self, + alg: Union[Type['CoseAlgorithm'], str, int], + pub: bytes = b'', + priv: bytes = b'', + optional_params: Optional[dict] = None, + allow_unknown_key_attrs: bool = True): + """ + Create an COSE AKP key. + + :param alg: An AKP elliptic curve. + :param pub: Public value of the AKP key. + :param priv: Private value of the AKP key. + :param optional_params: A dictionary with optional key parameters. + :param allow_unknown_key_attrs: Allow unknown key attributes (not registered at the IANA registry) + """ + + transformed_dict = {KpKty: KtyAKP} + + if optional_params is None: + optional_params = {} + + for _key_attribute, _value in optional_params.items(): + # translate the key_attribute + kp = AKPKeyParam.from_id(_key_attribute, allow_unknown_key_attrs) + + # parse the value of the key attribute if possible + if hasattr(kp, 'value_parser') and hasattr(kp.value_parser, '__call__'): + _value = kp.value_parser(_value) + + # store in new dict + transformed_dict[kp] = _value + + # final check if key type is correct + if transformed_dict.get(KpKty) != KtyAKP: + raise CoseIllegalKeyType(f"Illegal key type in AKP COSE Key: {transformed_dict.get(KpKty)}") + + super(AKPKey, self).__init__(transformed_dict) + + if len(pub) == 0 and len(priv) == 0: + raise CoseInvalidKey("Either the public values or the private value must be specified") + + if alg is not None: + self.alg = alg + else: + raise CoseInvalidKey("COSE curve cannot be None") + if pub != b'': + self.pub = pub + if priv != b'': + self.priv = priv + + @property + def pub(self) -> bytes: + """ + Returns the mandatory :class:`~pycose.keys.keyparam.AKPKpPub` attribute of the COSE AKP Key object. + """ + + return self.store.get(AKPKpPub, b'') + + @pub.setter + def pub(self, val: bytes): + if type(val) is not bytes: + raise TypeError("Public part must be of type 'bytes'") + self.store[AKPKpPub] = val + + @property + def priv(self) -> bytes: + """ + Returns the mandatory :class:`~pycose.keys.keyparam.AKPKpPriv` attribute of the COSE AKP Key object. + """ + + return self.store.get(AKPKpPriv, b'') + + @priv.setter + def priv(self, val: bytes): + if type(val) is not bytes: + raise TypeError("Private part must be of type 'bytes'") + self.store[AKPKpPriv] = val + + @property + def key_ops(self) -> List[Type['KEYOPS']]: + """ Returns the value of the :class:`~pycose.keys.keyparam.KpKeyOps` key parameter """ + + return CoseKey.key_ops.fget(self) + + @key_ops.setter + def key_ops(self, new_key_ops: List[Union[Type['KEYOPS'], str, int]]) -> None: + supported = {SignOp, VerifyOp, DeriveKeyOp, DeriveBitsOp} + for ops in new_key_ops: + if not self._supported_by_key_type(ops, supported): + raise CoseIllegalKeyOps(f"Invalid COSE key operation {ops} for key type {AKPKey.__name__}") + else: + CoseKey.key_ops.fset(self, new_key_ops) + + @classmethod + def generate_key(cls, alg: Union[Type['CoseAlgorithm'], str, int], optional_params: dict = None) -> 'AKPKey': + """ + Generate a random AKPKey COSE key object. + + :param alg: Specify an algorithm. + :param optional_params: Optional key attributes for the :class:`~pycose.keys.AKP.AKPKey` object, e.g., \ + :class:`~pycose.keys.keyparam.KpKid`. + + :returns: A COSE `AKPKey` key. + """ + + print('alg', alg) + alg = CoseAlgorithm.from_id(alg) + print('alg', alg) + print('done') + + ext_key = alg.private_key_cls.generate() + + return cls._from_cryptography_key(ext_key, optional_params) + + def __delitem__(self, key: Union['KeyParam', str, int]): + if self._key_transform(key) != KpKty and self._key_transform(key) != KpAlg: + if self._key_transform(key) == AKPKpPriv and AKPKpPub not in self.store: + pass + if self._key_transform(key) == AKPKpPub and AKPKpPriv not in self.store: + pass + else: + return super(AKPKey, self).__delitem__(key) + + raise CoseInvalidKey(f"Deleting {key} attribute would lead to an invalid COSE AKP Key") + + def __repr__(self): + _key = self._key_repr() + + if 'AKPKpD' in _key and len(_key['AKPKpD']) > 0: + _key['AKPKpD'] = utils.truncate(_key['AKPKpD']) + if 'AKPKpX' in _key and len(_key['AKPKpX']) > 0: + _key['AKPKpX'] = utils.truncate(_key['AKPKpX']) + if 'AKPKpY' in _key and len(_key['AKPKpY']) > 0: + _key['AKPKpY'] = utils.truncate(_key['AKPKpY']) + + hdr = f'' + return hdr + + +AKP = AKPKey + +if __name__ == '__main__': + print(AKPKeyParam.get_registered_classes()) diff --git a/pycose/keys/keyparam.py b/pycose/keys/keyparam.py index bc4ade5..98cba2c 100644 --- a/pycose/keys/keyparam.py +++ b/pycose/keys/keyparam.py @@ -204,6 +204,31 @@ class RSAKpTi(RSAKeyParam): fullname = "T_I" +######################################### +# AKP Key Parameters +######################################### + +class AKPKeyParam(_CoseAttribute, ABC): + _registered_algorithms = {} + _registered_algorithms.update(KeyParam.get_registered_classes()) + + @classmethod + def get_registered_classes(cls): + return cls._registered_algorithms + + +@AKPKeyParam.register_attribute() +class AKPKpPub(AKPKeyParam): + identifier = -1 + fullname = "PUB" + + +@AKPKeyParam.register_attribute() +class AKPKpPriv(AKPKeyParam): + identifier = -2 + fullname = "PRIV" + + ######################################### # Symmetric Key Parameters ######################################### @@ -234,6 +259,7 @@ class SymKpK(SymmetricKeyParam): OKPKP = TypeVar('OKPKP', bound=OKPKeyParam) SYMKP = TypeVar('SYMKP', bound=SymmetricKeyParam) RSAKP = TypeVar('RSAKP', bound=RSAKeyParam) +AKPKP = TypeVar('AKPKP', bound=AKPKeyParam) KP = Union[Type['KP'], Type['OKPKP'], Type['EC2KP'], Type['SYMKP'], Type['RSAKP']] diff --git a/pycose/keys/keytype.py b/pycose/keys/keytype.py index 52bc8f2..9172d40 100644 --- a/pycose/keys/keytype.py +++ b/pycose/keys/keytype.py @@ -42,6 +42,12 @@ class KtySymmetric(KTY): fullname = 'SYMMETRIC' +@KTY.register_attribute() +class KtyAKP(KTY): + identifier = 7 + fullname = 'AKP' + + KTYPE = TypeVar('KTYPE', bound=KTY) if __name__ == '__main__': diff --git a/pycose/messages/cosemessage.py b/pycose/messages/cosemessage.py index 817d449..627f900 100644 --- a/pycose/messages/cosemessage.py +++ b/pycose/messages/cosemessage.py @@ -7,6 +7,7 @@ from pycose.keys import CoseKey from pycose.keys.ec2 import EC2Key from pycose.keys.okp import OKPKey +from pycose.keys.akp import AKPKey from pycose.keys.rsa import RSAKey from pycose.keys.symmetric import SymmetricKey from pycose.messages.cosebase import CoseBase @@ -120,6 +121,7 @@ def key(self, key: Optional[CoseKey]): if not isinstance(key, SymmetricKey) and \ not isinstance(key, EC2Key) and \ not isinstance(key, OKPKey) and \ + not isinstance(key, AKPKey) and \ not isinstance(key, RSAKey) and \ key is not None: diff --git a/pycose/messages/signcommon.py b/pycose/messages/signcommon.py index 319ed9f..37d3418 100644 --- a/pycose/messages/signcommon.py +++ b/pycose/messages/signcommon.py @@ -3,6 +3,7 @@ from pycose import headers from pycose.keys.okp import OKPKey +from pycose.keys.akp import AKPKey from pycose.exceptions import CoseException from pycose.keys.ec2 import EC2Key from pycose.keys.rsa import RSAKey @@ -31,6 +32,8 @@ def _key_verification(self, alg: Type['CoseAlg'], ops: Type['KEYOPS']): self.key.verify(EC2Key, alg, [ops]) elif isinstance(self.key, OKPKey): self.key.verify(OKPKey, alg, [ops]) + elif isinstance(self.key, AKPKey): + self.key.verify(AKPKey, alg, [ops]) elif isinstance(self.key, RSAKey): self.key.verify(RSAKey, alg, [ops]) else: diff --git a/tests/test_akp_keys.py b/tests/test_akp_keys.py new file mode 100644 index 0000000..71a00bd --- /dev/null +++ b/tests/test_akp_keys.py @@ -0,0 +1,202 @@ +import os +from binascii import unhexlify + +import pytest + +from cryptography.hazmat.primitives.asymmetric import mldsa +from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat + +from pycose.algorithms import MlDsa44, MlDsa65, MlDsa87 +from pycose.exceptions import CoseInvalidKey, CoseIllegalKeyType, CoseIllegalAlgorithm, CoseIllegalKeyOps +from pycose.keys import AKPKey, CoseKey +from pycose.keys.keyops import SignOp, MacVerifyOp +from pycose.keys.keyparam import KpKty, AKPKpPub, AKPKpPriv, KpAlg, KpKeyOps +############################################################### +# AKP key checks +############################################################### +from pycose.keys.keytype import KtyAKP, KtyEC2, KtySymmetric + + +def _is_valid_akp_key(key: AKPKey): + check1 = (KpKty in key and KpAlg in key) and (AKPKpPub in key or AKPKpPriv in key) + check2 = key[KpAlg] in {MlDsa44, MlDsa65, MlDsa87} + + return check2 and check1 + + +@pytest.mark.parametrize('kty_attr, kty_value', + [(KpKty, KtyAKP), ('KTY', 'AKP'), (1, 7), + (KpKty, 'AKP'), (KpKty, 7), + ('KTY', KtyAKP), ('KTY', 7), + (1, KtyAKP), (1, 'AKP')]) +@pytest.mark.parametrize('alg_attr, alg_value', [(KpAlg, MlDsa87), ('ALG', MlDsa87), (3, MlDsa87)]) +@pytest.mark.parametrize('pub_attr, pub_value', [(AKPKpPub, os.urandom(32)), ('PUB', os.urandom(32)), (-1, os.urandom(32))]) +@pytest.mark.parametrize('priv_attr, priv_value', [(AKPKpPriv, os.urandom(32)), ('PRIV', os.urandom(32)), (-2, os.urandom(32))]) +def test_akp_keys_from_dicts(kty_attr, kty_value, alg_attr, alg_value, pub_attr, pub_value, priv_attr, priv_value): + # The public and private values used in this test do not form a valid elliptic curve key, + # but we don't care about that here + + d = {kty_attr: kty_value, alg_attr: alg_value, pub_attr: pub_value, priv_attr: priv_value} + cose_key = CoseKey.from_dict(d) + assert _is_valid_akp_key(cose_key) + + +@pytest.mark.parametrize('kty_attr, kty_value', [(KpKty, KtyAKP), ('KTY', 'AKP'), (1, 7)]) +@pytest.mark.parametrize('alg_attr, alg_value', [(KpAlg, MlDsa87)]) +@pytest.mark.parametrize('priv_attr, priv_value', [(AKPKpPriv, os.urandom(32)), ('PRIV', os.urandom(32)), (-2, os.urandom(32))]) +def test_akp_private_key_from_dicts(kty_attr, kty_value, alg_attr, alg_value, priv_attr, priv_value): + # The public and private values used in this test do not form a valid ML key, + # but we don't care about that here + + d = {kty_attr: kty_value, alg_attr: alg_value, priv_attr: priv_value} + cose_key = CoseKey.from_dict(d) + assert _is_valid_akp_key(cose_key) + + +@pytest.mark.parametrize('kty_attr, kty_value', [(KpKty, KtyAKP), ('KTY', 'AKP'), (1, 7)]) +@pytest.mark.parametrize('alg_attr, alg_value', [(KpAlg, MlDsa87), ('ALG', MlDsa87), (3, MlDsa87)]) +@pytest.mark.parametrize('pub_attr, pub_value', [(AKPKpPub, os.urandom(32)), ('PUB', os.urandom(32)), (-1, os.urandom(32))]) +def test_akp_public_keys_from_dicts(kty_attr, kty_value, alg_attr, alg_value, pub_attr, pub_value): + # The public and private values used in this test do not form a valid ML key, + # but we don't care about that here + + d = {kty_attr: kty_value, alg_attr: alg_value, pub_attr: pub_value} + cose_key = CoseKey.from_dict(d) + assert _is_valid_akp_key(cose_key) + + +@pytest.mark.parametrize('key_class', [mldsa.MLDSA44PrivateKey, mldsa.MLDSA65PrivateKey, mldsa.MLDSA87PrivateKey]) +def test_akp_private_key_from_pem(key_class): + private_key = key_class.generate() + pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode() + cose_key = CoseKey.from_pem_private_key(pem) + assert _is_valid_akp_key(cose_key) + + +@pytest.mark.parametrize('key_class', [mldsa.MLDSA44PrivateKey, mldsa.MLDSA65PrivateKey, mldsa.MLDSA87PrivateKey]) +def test_akp_public_key_from_pem(key_class): + private_key = key_class.generate() + public_key = private_key.public_key() + pem = public_key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode() + cose_key = CoseKey.from_pem_public_key(pem) + assert _is_valid_akp_key(cose_key) + + +@pytest.mark.parametrize('alg', [MlDsa44, MlDsa65, MlDsa87, 'MLDSA87', -50]) +def test_akp_key_generation_encoding_decoding(alg): + trials = 256 + + for _ix in range(trials): + akp_test = AKPKey.generate_key(alg=alg) + akp_encoded = akp_test.encode() + akp_decoded = CoseKey.decode(akp_encoded) + assert _is_valid_akp_key(akp_decoded) + + +@pytest.mark.parametrize('alg', [MlDsa44, MlDsa65, MlDsa87, 'MLDSA87', -50]) +def test_akp_key_generation(alg): + key = AKPKey.generate_key(alg) + + assert _is_valid_akp_key(key) + + +@pytest.mark.parametrize('alg', [MlDsa44, MlDsa65, MlDsa87]) +def test_akp_key_construction(alg): + key = AKPKey(alg=alg, pub=os.urandom(32), priv=os.urandom(32), optional_params={}) + + assert _is_valid_akp_key(key) + + serialized = key.encode() + _ = CoseKey.decode(serialized) + + +@pytest.mark.parametrize('alg', [MlDsa44, MlDsa65, MlDsa87]) +def test_fail_on_missing_key_values(alg): + with pytest.raises(CoseInvalidKey) as excinfo: + _ = AKPKey(alg=alg) + + assert "Either the public values or the private value must be specified" in str(excinfo.value) + + +def test_fail_on_missing_alg_attr(): + cose_key = {KpKty: KtyAKP, AKPKpPub: os.urandom(32), AKPKpPriv: os.urandom(32)} + + with pytest.raises(CoseInvalidKey) as excinfo: + _ = CoseKey.from_dict(cose_key) + + assert "COSE curve cannot be None" in str(excinfo.value) + + +@pytest.mark.parametrize('alg', [MlDsa44, MlDsa65, MlDsa87]) +@pytest.mark.parametrize('kty', [KtyEC2, KtySymmetric, 2, 4]) +def test_fail_on_illegal_kty(alg, kty): + params = {KpKty: kty} + + with pytest.raises(CoseIllegalKeyType) as excinfo: + _ = AKPKey(alg=alg, pub=os.urandom(32), priv=os.urandom(32), optional_params=params) + + assert "Illegal key type in AKP COSE Key" in str(excinfo.value) + + +def test_remove_empty_keyops_list(): + cose_key = {KpKty: KtyAKP, AKPKpPriv: os.urandom(32), KpAlg: MlDsa87, KpKeyOps: []} + + key = CoseKey.from_dict(cose_key) + + assert KpKeyOps not in key + + +def test_existing_non_empty_keyops_list(): + cose_key = {KpKty: KtyAKP, AKPKpPriv: os.urandom(32), KpAlg: MlDsa87, KpKeyOps: [SignOp]} + + key = CoseKey.from_dict(cose_key) + + assert KpKeyOps in key + + +def test_key_ops_setter_getter(): + key = AKPKey.generate_key('MLDSA87') + key.key_ops = [SignOp] + + assert SignOp in key.key_ops + + with pytest.raises(CoseIllegalKeyOps) as excinfo: + key.key_ops = [MacVerifyOp] + + assert "Invalid COSE key operation" in str(excinfo) + + +def test_dict_operations_on_akp_key(): + cose_key = {KpKty: KtyAKP, AKPKpPriv: os.urandom(32), KpAlg: MlDsa87, KpKeyOps: [SignOp]} + + key = CoseKey.from_dict(cose_key) + + assert KpKty in key + assert AKPKpPriv in key + assert AKPKpPub not in key + assert 1 in key + assert -2 in key + assert -1 not in key + assert KpAlg in key + assert 'ALG' in key + + +def test_key_set_alg(): + # Key from https://www.rfc-editor.org/rfc/rfc9964.html#appendix-A.2 + key = 'a5025820b8969ab4b37da9f0684e42647eb8a0be8b5b661ebf5d76f0583bf5b8d3a8059a010703382f20590520ba71f9f64e11baeb58fa9c6fbb6e14e61f18643dab495b47539a9166ca0198131c44f826bbd56e34e55db5e5e2d733485e39ea260fc6000c5ea4ba80d3455cde53b46f34482aedfd5450fc2e1ba4f25d15f9c144242fb39bb52287189030c50498e1717b7c758b190a6748ea9aa3f7acaaf2c7cb526ed717c9f79aeb84214fa5cd8ded92a0c3fa1558810f12c7050a367708d196cd24e5af974904aed8e4ce8872e8696b0b7bca50e452cd7d30ea9a4adac0311d672c6bde8496240b07431463708895cd9bafc31632d7397649388fdafcbf7d305a3de9a495eca7433a8f83ba0f0b25c413c6e39c96eb7d691b34d37ce37f1eead1cf217e25ef34eecf3f7c60f84b8edfdde8405d4f832576c61ef98e0a2f28da187700953924f686b94614705bcf53d33fedd4348edddbdf28b5065e1f20775043e85cf931f829179363a1a7e7404a838ec00086b0976386fe637c98244757e3f769ddd4467471bfad670f9a05f8246ee50a7b1eaf87fc4069c3ae2aa2033258117792f0bcd49e083fd1bc7496abff29cc94e4868b21214ed316525399a610fbdd4a80e7c80715f29578e2a84bb40bdddbd9f47a11b6e7da118a1b658d359e8aef55eb46b5376b5b655979984a922beebfc59bcd600d5309dccd72dbf0787db8ba757b537c1eafd5c0f50ea4bc9583549e2829a42c28cac248c96d78124c47159b18aedd754aba17b19d430fb78f633ea9d26f54a9bd50f8d8f6b73594f828976e7ea09c53bbb9f11a56c9507fb89b9a5ebc037a37267a95f85b8d64ca97192b10a66f417b3f61fe9ca57130a48fd925eae2ab5502d571c8a51903c1d398f4c1f76a7e11743976afdbc697f23094a3cd761ff9685de32e09fb3c28add453490300bc7c89dc01780096071722945775f264e1b0623bcf4619c712c838761205d87691b75ef360196cbb9e9b92a0d4c4ed62326e5024d77510b8ee2c7426cc22eae209dc9f13bde6bf08f5e7181bd3b459450b451a51539a715c21d67dd330eb5970db00d9edbfb2822b036fa13bafeb86d8dc78866e3f8d43e53d78cca5595a6faf886b5dc112f1cf4adcfa875800d90b48883af97316fe1506873fc157e570eacbfd222868d14234101966afb6bf9940829253a953ada89fc756b6a849f70acb9838e69faa50bba75e3e89c2adb57e86d088ab9b04a28e670709172243ec5e0008a5ceaf3f8722f487302596ffd755ad1b82a49c34b3469515b46aa290cd86ee38ea7a9be3f103610335b531cca333ddfe32b14510f4b07ef95fc6684e8c454a92c10dbb5d59c7a7c63fb305fe881967d99e669eb632840582560bb403431d40f75a4954908482278292821f4ea91e42e78fa48caee3c836146dcfd738d117e92e9a15137d28e8e6a4b4622650cb413504cb3a335d44beec5746c1c294b1e8cb99cb608d928f8ce3563632c521f23d13c61a8f61c01df8c96c7360db4f3c68aa5d2fdd342a62ff3459c116389421ab43e8584c45882b50e6e4e96db6f0b8fde890d5dbfadcd88690b449e64240ddb2023747f308363e301aa77757169fc6150628d5920b5aa1ab1c8cbf44cb00e025d7879d72b479e3af5311c785725590da9c89b9fc3b8450769554eb44d203eba2bbaef9cad2237011c2ea44eff00f299a48ffe28ca93ddf85f76608242ef8d6cc24610a1e2078fcac4f9385c314905ecaa82e553916d94d1a7c1ec652aa08897083daa2ebb1775fbc471ae27777d7904ea9f1b92bcac3d8a3158426087b645b1108f0d65fec93789c053743ca14fd63d05e98b652df2b9c2ff9ce05f1940703ffb273f80e0e2732eca9960d981b4cfd3b7bb8045b3c3830546b9dd8db0d2158200000000000000000000000000000000000000000000000000000000000000000' + key = CoseKey.decode(unhexlify(key)) + + assert key.alg == MlDsa44 + + key.alg = MlDsa87 + + assert key.alg == MlDsa87 + + key.alg = MlDsa65.identifier + + assert key.alg == MlDsa65 + + +def test_key_generation_with_optional_parameters(): + key = AKPKey.generate_key(alg='MLDSA87', optional_params={'KpKid': 4}) + assert key is not None diff --git a/tests/test_okp_keys.py b/tests/test_okp_keys.py index 5463d13..6b6a10c 100644 --- a/tests/test_okp_keys.py +++ b/tests/test_okp_keys.py @@ -89,9 +89,9 @@ def test_okp_public_key_from_pem(key_class): @pytest.mark.parametrize('crv', [X25519, X448, Ed25519, Ed448, 4, 'X25519', 'X448']) def test_okp_key_generation_encoding_decoding(crv): - trails = 256 + trials = 256 - for i in range(trails): + for _i in range(trials): okp_test = OKPKey.generate_key(crv=crv) okp_encoded = okp_test.encode() okp_decoded = CoseKey.decode(okp_encoded) From 6de7b39ace228f7600aaa66de8d3429f559b721c Mon Sep 17 00:00:00 2001 From: Brian Sipos Date: Wed, 27 May 2026 13:53:55 -0400 Subject: [PATCH 2/4] Clamp package versions --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5f6db5d..743c009 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -cryptography -cbor2 +cryptography >=47.0 +cbor2 <6.0 ecdsa attrs certvalidator From 1231f37dd70ebd04798e11d9e475f936a40248e7 Mon Sep 17 00:00:00 2001 From: Brian Sipos Date: Wed, 27 May 2026 14:50:10 -0400 Subject: [PATCH 3/4] xfail on older python --- pycose/keys/akp.py | 2 +- pycose/keys/okp.py | 2 +- tests/test_akp_keys.py | 27 ++++++++++++++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/pycose/keys/akp.py b/pycose/keys/akp.py index ec6f190..7535910 100644 --- a/pycose/keys/akp.py +++ b/pycose/keys/akp.py @@ -219,7 +219,7 @@ def __delitem__(self, key: Union['KeyParam', str, int]): if self._key_transform(key) != KpKty and self._key_transform(key) != KpAlg: if self._key_transform(key) == AKPKpPriv and AKPKpPub not in self.store: pass - if self._key_transform(key) == AKPKpPub and AKPKpPriv not in self.store: + elif self._key_transform(key) == AKPKpPub and AKPKpPriv not in self.store: pass else: return super(AKPKey, self).__delitem__(key) diff --git a/pycose/keys/okp.py b/pycose/keys/okp.py index 7950ef0..78d44ef 100644 --- a/pycose/keys/okp.py +++ b/pycose/keys/okp.py @@ -246,7 +246,7 @@ def __delitem__(self, key: Union['KeyParam', str, int]): if self._key_transform(key) != KpKty and self._key_transform(key) != OKPKpCurve: if self._key_transform(key) == OKPKpD and OKPKpX not in self.store: pass - if self._key_transform(key) == OKPKpX and OKPKpD not in self.store: + elif self._key_transform(key) == OKPKpX and OKPKpD not in self.store: pass else: return super(OKPKey, self).__delitem__(key) diff --git a/tests/test_akp_keys.py b/tests/test_akp_keys.py index 71a00bd..f49242a 100644 --- a/tests/test_akp_keys.py +++ b/tests/test_akp_keys.py @@ -1,4 +1,5 @@ import os +import sys from binascii import unhexlify import pytest @@ -7,7 +8,7 @@ from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat from pycose.algorithms import MlDsa44, MlDsa65, MlDsa87 -from pycose.exceptions import CoseInvalidKey, CoseIllegalKeyType, CoseIllegalAlgorithm, CoseIllegalKeyOps +from pycose.exceptions import CoseInvalidKey, CoseIllegalKeyType, CoseIllegalKeyOps from pycose.keys import AKPKey, CoseKey from pycose.keys.keyops import SignOp, MacVerifyOp from pycose.keys.keyparam import KpKty, AKPKpPub, AKPKpPriv, KpAlg, KpKeyOps @@ -66,6 +67,10 @@ def test_akp_public_keys_from_dicts(kty_attr, kty_value, alg_attr, alg_value, pu @pytest.mark.parametrize('key_class', [mldsa.MLDSA44PrivateKey, mldsa.MLDSA65PrivateKey, mldsa.MLDSA87PrivateKey]) +@pytest.mark.xfail( + sys.version_info < (3, 9), + reason="Feature not supported in older cryptography versions" +) def test_akp_private_key_from_pem(key_class): private_key = key_class.generate() pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode() @@ -74,6 +79,10 @@ def test_akp_private_key_from_pem(key_class): @pytest.mark.parametrize('key_class', [mldsa.MLDSA44PrivateKey, mldsa.MLDSA65PrivateKey, mldsa.MLDSA87PrivateKey]) +@pytest.mark.xfail( + sys.version_info < (3, 9), + reason="Feature not supported in older cryptography versions" +) def test_akp_public_key_from_pem(key_class): private_key = key_class.generate() public_key = private_key.public_key() @@ -83,6 +92,10 @@ def test_akp_public_key_from_pem(key_class): @pytest.mark.parametrize('alg', [MlDsa44, MlDsa65, MlDsa87, 'MLDSA87', -50]) +@pytest.mark.xfail( + sys.version_info < (3, 9), + reason="Feature not supported in older cryptography versions" +) def test_akp_key_generation_encoding_decoding(alg): trials = 256 @@ -94,6 +107,10 @@ def test_akp_key_generation_encoding_decoding(alg): @pytest.mark.parametrize('alg', [MlDsa44, MlDsa65, MlDsa87, 'MLDSA87', -50]) +@pytest.mark.xfail( + sys.version_info < (3, 9), + reason="Feature not supported in older cryptography versions" +) def test_akp_key_generation(alg): key = AKPKey.generate_key(alg) @@ -154,6 +171,10 @@ def test_existing_non_empty_keyops_list(): assert KpKeyOps in key +@pytest.mark.xfail( + sys.version_info < (3, 9), + reason="Feature not supported in older cryptography versions" +) def test_key_ops_setter_getter(): key = AKPKey.generate_key('MLDSA87') key.key_ops = [SignOp] @@ -197,6 +218,10 @@ def test_key_set_alg(): assert key.alg == MlDsa65 +@pytest.mark.xfail( + sys.version_info < (3, 9), + reason="Feature not supported in older cryptography versions" +) def test_key_generation_with_optional_parameters(): key = AKPKey.generate_key(alg='MLDSA87', optional_params={'KpKid': 4}) assert key is not None From 8b3d0bba7d54e29908a0ddfe02f6ee7f2c180b5d Mon Sep 17 00:00:00 2001 From: Brian Sipos Date: Wed, 27 May 2026 14:51:08 -0400 Subject: [PATCH 4/4] fix flake8 --- pycose/keys/akp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pycose/keys/akp.py b/pycose/keys/akp.py index 7535910..f992c1d 100644 --- a/pycose/keys/akp.py +++ b/pycose/keys/akp.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from pycose.keys.keyops import KEYOPS + from pycose.keys.keyparam import KeyParam PYCRYPTO_KEY_ALG = { mldsa.MLDSA44PrivateKey: MlDsa44,