From 4cac74efd0593fd297078bdf672847844a1087bf Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Tue, 9 Jun 2026 14:31:22 +0200 Subject: [PATCH 1/3] feat: Implement JSF (JSON Signature Format) signature support Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/bom.py | 30 +- cyclonedx/model/component.py | 30 +- cyclonedx/model/service.py | 30 +- cyclonedx/model/signature.py | 364 ++++++++++++++++++ tests/_data/models.py | 76 ++++ .../get_bom_with_signatures-1.0.xml.bin | 10 + .../get_bom_with_signatures-1.1.xml.bin | 9 + .../get_bom_with_signatures-1.2.json.bin | 41 ++ .../get_bom_with_signatures-1.2.xml.bin | 26 ++ .../get_bom_with_signatures-1.3.json.bin | 41 ++ .../get_bom_with_signatures-1.3.xml.bin | 26 ++ .../get_bom_with_signatures-1.4.json.bin | 77 ++++ .../get_bom_with_signatures-1.4.xml.bin | 26 ++ .../get_bom_with_signatures-1.5.json.bin | 87 +++++ .../get_bom_with_signatures-1.5.xml.bin | 30 ++ .../get_bom_with_signatures-1.6.json.bin | 87 +++++ .../get_bom_with_signatures-1.6.xml.bin | 30 ++ .../get_bom_with_signatures-1.7.json.bin | 87 +++++ .../get_bom_with_signatures-1.7.xml.bin | 30 ++ tests/test_deserialize_xml.py | 5 + tests/test_model_signature.py | 338 ++++++++++++++++ 21 files changed, 1477 insertions(+), 3 deletions(-) create mode 100644 cyclonedx/model/signature.py create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.0.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.1.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.2.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.2.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.3.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.3.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.4.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.4.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.5.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.5.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.6.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.6.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.7.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_signatures-1.7.xml.bin create mode 100644 tests/test_model_signature.py diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 7cb0081e9..e0cbc701f 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -51,6 +51,7 @@ from .license import License, LicenseExpression, LicenseRepository, _LicenseRepositorySerializationHelper from .lifecycle import Lifecycle, LifecycleRepository, _LifecycleRepositoryHelper from .service import Service +from .signature import JsfSignature, _JsfSignatureSerializationHelper from .tool import Tool, ToolRepository, _ToolRepositoryHelper from .vulnerability import Vulnerability @@ -444,6 +445,7 @@ def __init__( vulnerabilities: Optional[Iterable[Vulnerability]] = None, properties: Optional[Iterable[Property]] = None, definitions: Optional[Definitions] = None, + signature: Optional[JsfSignature] = None, ) -> None: """ Create a new Bom that you can manually/programmatically add data to later. @@ -461,6 +463,7 @@ def __init__( self.dependencies = dependencies or [] self.properties = properties or [] self.definitions = definitions or Definitions() + self.signature = signature @property @serializable.type_mapping(UrnUuidHelper) @@ -694,6 +697,31 @@ def definitions(self) -> Optional[Definitions]: def definitions(self, definitions: Definitions) -> None: self._definitions = definitions + @property + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.type_mapping(_JsfSignatureSerializationHelper) + def signature(self) -> Optional[JsfSignature]: + """ + Enveloped signature in JSON Signature Format (JSF). + + .. note:: + JSON-only. There is no XSD/XML equivalent in any CycloneDX schema version. + + .. note:: + Introduced in CycloneDX v1.4 + + Returns: + `JsfSignature` if set else `None` + """ + return self._signature + + @signature.setter + def signature(self, signature: Optional[JsfSignature]) -> None: + self._signature = signature + def get_component_by_purl(self, purl: Optional['PackageURL']) -> Optional[Component]: """ Get a Component already in the Bom by its PURL @@ -871,7 +899,7 @@ def __comparable_tuple(self) -> _ComparableTuple: self.components), _ComparableTuple(self.services), _ComparableTuple(self.external_references), _ComparableTuple( self.dependencies), _ComparableTuple(self.properties), - _ComparableTuple(self.vulnerabilities), + _ComparableTuple(self.vulnerabilities), self.signature, )) def __eq__(self, other: object) -> bool: diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 09031eef7..8485c0fcb 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -70,6 +70,7 @@ from .issue import IssueType from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper from .release_note import ReleaseNotes +from .signature import JsfSignature, _JsfSignatureSerializationHelper @serializable.serializable_class(ignore_unknown_during_deserialization=True) @@ -1012,6 +1013,7 @@ def __init__( swhids: Optional[Iterable[Swhid]] = None, crypto_properties: Optional[CryptoProperties] = None, tags: Optional[Iterable[str]] = None, + signature: Optional[JsfSignature] = None, # Deprecated in v1.6 author: Optional[str] = None, ) -> None: @@ -1042,6 +1044,7 @@ def __init__( self.release_notes = release_notes self.crypto_properties = crypto_properties self.tags = tags or [] + self.signature = signature # spec-deprecated properties below self.author = author self.modified = modified @@ -1659,6 +1662,31 @@ def tags(self) -> 'SortedSet[str]': def tags(self, tags: Iterable[str]) -> None: self._tags = SortedSet(tags) + @property + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.type_mapping(_JsfSignatureSerializationHelper) + def signature(self) -> Optional[JsfSignature]: + """ + Enveloped signature in JSON Signature Format (JSF). + + .. note:: + JSON-only. There is no XSD/XML equivalent in any CycloneDX schema version. + + .. note:: + Introduced in CycloneDX v1.4 + + Returns: + `JsfSignature` if set else `None` + """ + return self._signature + + @signature.setter + def signature(self, signature: Optional[JsfSignature]) -> None: + self._signature = signature + def get_all_nested_components(self, include_self: bool = False) -> set['Component']: components = set() if include_self: @@ -1689,7 +1717,7 @@ def __comparable_tuple(self) -> _ComparableTuple: _ComparableTuple(self.external_references), _ComparableTuple(self.properties), _ComparableTuple(self.components), self.evidence, self.release_notes, self.modified, _ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids), self.manufacturer, - self.crypto_properties, _ComparableTuple(self.tags), + self.crypto_properties, _ComparableTuple(self.tags), self.signature, )) def __eq__(self, other: object) -> bool: diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index 105973703..3cafeebc3 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -45,6 +45,7 @@ from .dependency import Dependable from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper from .release_note import ReleaseNotes +from .signature import JsfSignature, _JsfSignatureSerializationHelper @serializable.serializable_class(ignore_unknown_during_deserialization=True) @@ -73,6 +74,7 @@ def __init__( properties: Optional[Iterable[Property]] = None, services: Optional[Iterable['Service']] = None, release_notes: Optional[ReleaseNotes] = None, + signature: Optional[JsfSignature] = None, ) -> None: self._bom_ref = _bom_ref_from_str(bom_ref) self.provider = provider @@ -89,6 +91,7 @@ def __init__( self.services = services or [] self.release_notes = release_notes self.properties = properties or [] + self.signature = signature @property @serializable.json_name('bom-ref') @@ -361,6 +364,31 @@ def release_notes(self) -> Optional[ReleaseNotes]: def release_notes(self, release_notes: Optional[ReleaseNotes]) -> None: self._release_notes = release_notes + @property + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @serializable.type_mapping(_JsfSignatureSerializationHelper) + def signature(self) -> Optional[JsfSignature]: + """ + Enveloped signature in JSON Signature Format (JSF). + + .. note:: + JSON-only. There is no XSD/XML equivalent in any CycloneDX schema version. + + .. note:: + Introduced in CycloneDX v1.4 + + Returns: + `JsfSignature` if set else `None` + """ + return self._signature + + @signature.setter + def signature(self, signature: Optional[JsfSignature]) -> None: + self._signature = signature + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.group, self.name, self.version, @@ -369,7 +397,7 @@ def __comparable_tuple(self) -> _ComparableTuple: self.authenticated, _ComparableTuple(self.data), _ComparableTuple(self.endpoints), _ComparableTuple(self.external_references), _ComparableTuple(self.licenses), _ComparableTuple(self.properties), self.release_notes, _ComparableTuple(self.services), - self.x_trust_boundary + self.x_trust_boundary, self.signature )) def __eq__(self, other: object) -> bool: diff --git a/cyclonedx/model/signature.py b/cyclonedx/model/signature.py new file mode 100644 index 000000000..76e5a98b9 --- /dev/null +++ b/cyclonedx/model/signature.py @@ -0,0 +1,364 @@ +# This file is part of CycloneDX Python Library +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + + +""" +JSF (JSON Signature Format) signature-related classes. + +.. note:: + JSON-only. There is no XSD/XML equivalent for JSF signatures in CycloneDX. + +.. note:: + Introduced in CycloneDX v1.4 + +.. note:: + See the JSF specification: https://cyberphone.github.io/doc/security/jsf.html + See the CycloneDX Schema reference: https://cyclonedx.org/docs/1.4/json/#signature +""" + +from enum import Enum +from typing import Any, Optional, Union, cast +from xml.etree.ElementTree import Element as XmlElement # nosec B405 + +import py_serializable as serializable + +from .._internal.compare import ComparableTuple as _ComparableTuple +from ..exception.model import InvalidValueException + + +@serializable.serializable_enum +class JsfAlgorithm(str, Enum): + """ + Recognized JWA [RFC7518] and RFC8037 asymmetric/symmetric key algorithms for JSF signatures. + + Note: Unlike RFC8037, JSF requires explicit Ed* algorithm names instead of "EdDSA". + + For proprietary algorithms, pass a URI string directly — the ``algorithm`` field on + :class:`JsfSignature`, :class:`JsfSignatureSigners`, and :class:`JsfSignatureChain` + accepts both :class:`JsfAlgorithm` enum values and arbitrary strings. + """ + + RS256 = 'RS256' + RS384 = 'RS384' + RS512 = 'RS512' + PS256 = 'PS256' + PS384 = 'PS384' + PS512 = 'PS512' + ES256 = 'ES256' + ES384 = 'ES384' + ES512 = 'ES512' + ED25519 = 'Ed25519' + ED448 = 'Ed448' + HS256 = 'HS256' + HS384 = 'HS384' + HS512 = 'HS512' + + +@serializable.serializable_enum +class JsfKeyType(str, Enum): + """ + Key type indicator for a JSF public key. + """ + + EC = 'EC' + OKP = 'OKP' + RSA = 'RSA' + + +class JsfPublicKey: + """ + Public key object as defined by the JSF standard. + + Supports three key types (determined by ``kty``): + + - **EC**: requires ``crv``, ``x``, ``y`` + - **OKP**: requires ``crv``, ``x`` + - **RSA**: requires ``n``, ``e`` + """ + + def __init__( + self, *, + kty: JsfKeyType, + crv: Optional[str] = None, + x: Optional[str] = None, + y: Optional[str] = None, + n: Optional[str] = None, + e: Optional[str] = None, + ) -> None: + # Validate conditional schema requirements per JSF spec + if kty == JsfKeyType.EC: + if not (crv and x and y): + raise InvalidValueException( + 'EC public key requires crv, x, and y' + ) + elif kty == JsfKeyType.OKP: + if not (crv and x): + raise InvalidValueException( + 'OKP public key requires crv and x' + ) + elif kty == JsfKeyType.RSA: + if not (n and e): + raise InvalidValueException( + 'RSA public key requires n and e' + ) + + self.kty = kty + self.crv = crv + self.x = x + self.y = y + self.n = n + self.e = e + + def _as_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {'kty': self.kty.value} + if self.crv is not None: + d['crv'] = self.crv + if self.x is not None: + d['x'] = self.x + if self.y is not None: + d['y'] = self.y + if self.n is not None: + d['n'] = self.n + if self.e is not None: + d['e'] = self.e + return d + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> 'JsfPublicKey': + return cls( + kty=JsfKeyType(d['kty']), + crv=d.get('crv'), + x=d.get('x'), + y=d.get('y'), + n=d.get('n'), + e=d.get('e'), + ) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.kty, self.crv, self.x, self.y, self.n, self.e)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, JsfPublicKey): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, JsfPublicKey): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +class JsfSignature: + """ + JSF (JSON Signature Format) signature object — abstract base class. + + The JSF specification defines three mutually exclusive signature modes, each represented by + a separate concrete class: + + - **Simple signature** (``signaturecore``): :class:`JsfSimpleSignature`: a single signature + with required ``algorithm`` and ``value``, plus optional ``key_id``, ``public_key``, + ``certificate_path``, and ``excludes`` + - **Multiple signers** (``multisignature``): :class:`JsfSignatureSigners`: contains a + ``signers`` list of :class:`JsfSimpleSignature` + - **Signature chain** (``signaturechain``): :class:`JsfSignatureChain`: contains a + ``chain`` list of :class:`JsfSimpleSignature` + + .. note:: + JSON-only. There is no XSD/XML equivalent in any CycloneDX schema version. + + .. note:: + Introduced in CycloneDX v1.4 + """ + + def __eq__(self, other: object) -> bool: + if isinstance(other, JsfSignature): + return _JsfSignatureSerializationHelper._sort_key(self) == _JsfSignatureSerializationHelper._sort_key(other) + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, JsfSignature): + return _JsfSignatureSerializationHelper._sort_key(self) < _JsfSignatureSerializationHelper._sort_key(other) + return NotImplemented + + def __hash__(self) -> int: + return hash(_JsfSignatureSerializationHelper._sort_key(self)) + + +class JsfSimpleSignature(JsfSignature): + """ + JSF simple signature object: ``signaturecore`` mode. + + Represents a single signature with required ``algorithm`` and ``value``, plus optional + ``key_id``, ``public_key``, ``certificate_path``, and ``excludes``. + """ + + def __init__( + self, *, + algorithm: Union[JsfAlgorithm, str], + value: str, + key_id: Optional[str] = None, + public_key: Optional[JsfPublicKey] = None, + certificate_path: Optional[list[str]] = None, + excludes: Optional[list[str]] = None, + ) -> None: + self.algorithm = algorithm + self.value = value + self.key_id = key_id + self.public_key = public_key + self.certificate_path = list(certificate_path) if certificate_path else [] + self.excludes = list(excludes) if excludes else [] + + def _as_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + 'algorithm': str(self.algorithm), + 'value': self.value, + } + if self.key_id is not None: + d['keyId'] = self.key_id + if self.public_key is not None: + d['publicKey'] = self.public_key._as_dict() + if self.certificate_path: + d['certificatePath'] = list(self.certificate_path) + if self.excludes: + d['excludes'] = list(self.excludes) + return d + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> 'JsfSimpleSignature': + pk = d.get('publicKey') + return cls( + algorithm=d['algorithm'], + value=d['value'], + key_id=d.get('keyId'), + public_key=JsfPublicKey._from_dict(pk) if pk is not None else None, + certificate_path=d.get('certificatePath'), + excludes=d.get('excludes'), + ) + + def __repr__(self) -> str: + return f'' + + +class JsfSignatureSigners(JsfSignature): + """ + Multiple-signers JSF signature: ``multisignature`` in the JSF schema. + + Contains a list of :class:`JsfSimpleSignature` objects serialized under the ``signers`` key. + """ + + def __init__(self, *, signers: list['JsfSimpleSignature']) -> None: + self.signers = list(signers) + + def __repr__(self) -> str: + return f'' + + +class JsfSignatureChain(JsfSignature): + """ + Signature-chain JSF signature: ``signaturechain`` in the JSF schema. + + Contains a list of :class:`JsfSimpleSignature` objects serialized under the ``chain`` key. + """ + + def __init__(self, *, chain: list['JsfSimpleSignature']) -> None: + self.chain = list(chain) + + def __repr__(self) -> str: + return f'' + + +class _JsfSignatureSerializationHelper(serializable.helpers.BaseHelper): + """ THIS CLASS IS NON-PUBLIC API """ + + @staticmethod + def _sort_key(o: JsfSignature) -> _ComparableTuple: + """Generate a comparable tuple key for sorting and equality. + + Handles all three signature modes by delegating to type-specific logic. + """ + if isinstance(o, JsfSignatureChain): + return _ComparableTuple(('chain', _ComparableTuple(o.chain))) + if isinstance(o, JsfSignatureSigners): + return _ComparableTuple(('signers', _ComparableTuple(o.signers))) + # Simple signature (JsfSimpleSignature) + o = cast(JsfSimpleSignature, o) + return _ComparableTuple((str(o.algorithm), o.value, o.key_id, o.public_key, + _ComparableTuple(o.certificate_path), + _ComparableTuple(o.excludes))) + + @classmethod + def json_normalize(cls, o: JsfSignature, *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Any: + if isinstance(o, JsfSignatureSigners): + return {'signers': [s._as_dict() for s in o.signers]} + if isinstance(o, JsfSignatureChain): + return {'chain': [s._as_dict() for s in o.chain]} + # Simple signature + o = cast(JsfSimpleSignature, o) + d: dict[str, Any] = { + 'algorithm': str(o.algorithm), + 'value': o.value, + } + if o.key_id is not None: + d['keyId'] = o.key_id + if o.public_key is not None: + d['publicKey'] = o.public_key._as_dict() + if o.certificate_path: + d['certificatePath'] = list(o.certificate_path) + if o.excludes: + d['excludes'] = list(o.excludes) + return d + + @classmethod + def json_denormalize(cls, o: Any, **__: Any) -> JsfSignature: + if not isinstance(o, dict): + raise TypeError(f'Expected dict, got {type(o)!r}') + if 'signers' in o: + return JsfSignatureSigners(signers=[JsfSimpleSignature._from_dict(s) for s in o['signers']]) + if 'chain' in o: + return JsfSignatureChain(chain=[JsfSimpleSignature._from_dict(s) for s in o['chain']]) + # Simple signature: must have at least 'algorithm' and 'value' + pk = o.get('publicKey') + return JsfSimpleSignature( + algorithm=o['algorithm'], + value=o['value'], + key_id=o.get('keyId'), + public_key=JsfPublicKey._from_dict(pk) if pk is not None else None, + certificate_path=o.get('certificatePath'), + excludes=o.get('excludes'), + ) + + @classmethod + def xml_normalize(cls, o: JsfSignature, *, + element_name: Optional[str], + view: Optional[type[serializable.ViewType]], + xmlns: Optional[str], + **__: Any) -> Optional[XmlElement]: + return None # JSF signatures have no XML representation + + @classmethod + def xml_denormalize(cls, o: XmlElement, *, + default_ns: Optional[str], + **__: Any) -> Optional[JsfSignature]: + return None # JSF signatures have no XML representation diff --git a/tests/_data/models.py b/tests/_data/models.py index 8c6e89342..c8ecedefe 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -107,6 +107,14 @@ from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.service import Service +from cyclonedx.model.signature import ( + JsfAlgorithm, + JsfKeyType, + JsfPublicKey, + JsfSignatureChain, + JsfSignatureSigners, + JsfSimpleSignature, +) from cyclonedx.model.tool import Tool, ToolRepository from cyclonedx.model.vulnerability import ( BomTarget, @@ -1589,6 +1597,67 @@ def get_bom_for_issue540_duplicate_components() -> Bom: bom.register_dependency(component1, [component3]) return bom + +def get_bom_with_signatures() -> Bom: + # Tests JSF signature support on Bom, Component, and Service (JSON-only, CDX >= 1.4) + simple_sig = JsfSimpleSignature( + algorithm=JsfAlgorithm.ES256.value, + value='MEQCIAJ9DECTPNuqwdWHlHO3EB1jYVnjW7HZ0T7x3QIDO4OMfAIgTFz5kl3Zl7nBP4r2TovMnbJo3ij6JTANcFAQQVEBZQ==', + key_id='test-key-1', + ) + multi_sig = JsfSignatureSigners( + signers=[ + JsfSimpleSignature( + algorithm=JsfAlgorithm.RS256.value, + value='AABBCC==', + public_key=JsfPublicKey( + kty=JsfKeyType.RSA, + n='sQ3MDBw==', + e='AQAB', + ), + ), + JsfSimpleSignature( + algorithm=JsfAlgorithm.ES384.value, + value='DDEEFF==', + certificate_path=['MIICpDCCAYwCCQDU'], + excludes=['signature'], + ), + ] + ) + bom = _make_bom( + components=[ + Component( + name='acme-library', + version='1.2.3', + type=ComponentType.LIBRARY, + bom_ref='acme-library', + signature=simple_sig, + ) + ], + services=[ + Service( + name='acme-service', + bom_ref='acme-service', + signature=JsfSignatureChain( + chain=[ + JsfSimpleSignature( + algorithm=JsfAlgorithm.ED25519.value, + value='xyzSig==', + ) + ] + ), + ) + ], + signature=multi_sig, + ) + bom.metadata.component = Component( + name='my-app', + version='0.1.0', + type=ComponentType.APPLICATION, + bom_ref='my-app', + ) + return bom + # --- @@ -1638,4 +1707,11 @@ def get_bom_for_issue540_duplicate_components() -> Bom: get_bom_with_distribution_constraints, get_bom_with_definitions_standards, get_bom_with_definitions_and_detailed_standards, + get_bom_with_signatures, +} + +all_get_bom_funct_no_xml_roundtrip = { + # BOMs that contain JSON-only fields (e.g. JSF signatures) that cannot survive an XML roundtrip. + # These are excluded from test_deserialize_xml's assertBomDeepEqual comparison. + get_bom_with_signatures, } diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_signatures-1.0.xml.bin new file mode 100644 index 000000000..ac13323f4 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.0.xml.bin @@ -0,0 +1,10 @@ + + + + + acme-library + 1.2.3 + false + + + diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_signatures-1.1.xml.bin new file mode 100644 index 000000000..c5309b874 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.1.xml.bin @@ -0,0 +1,9 @@ + + + + + acme-library + 1.2.3 + + + diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.2.json.bin b/tests/_data/snapshots/get_bom_with_signatures-1.2.json.bin new file mode 100644 index 000000000..25c49392b --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.2.json.bin @@ -0,0 +1,41 @@ +{ + "components": [ + { + "bom-ref": "acme-library", + "name": "acme-library", + "type": "library", + "version": "1.2.3" + } + ], + "dependencies": [ + { + "ref": "acme-library" + }, + { + "ref": "acme-service" + }, + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "my-app", + "type": "application", + "version": "0.1.0" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "services": [ + { + "bom-ref": "acme-service", + "name": "acme-service" + } + ], + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_signatures-1.2.xml.bin new file mode 100644 index 000000000..a3ce5ef58 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.2.xml.bin @@ -0,0 +1,26 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + my-app + 0.1.0 + + + + + acme-library + 1.2.3 + + + + + acme-service + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.3.json.bin b/tests/_data/snapshots/get_bom_with_signatures-1.3.json.bin new file mode 100644 index 000000000..244d70de4 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.3.json.bin @@ -0,0 +1,41 @@ +{ + "components": [ + { + "bom-ref": "acme-library", + "name": "acme-library", + "type": "library", + "version": "1.2.3" + } + ], + "dependencies": [ + { + "ref": "acme-library" + }, + { + "ref": "acme-service" + }, + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "my-app", + "type": "application", + "version": "0.1.0" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "services": [ + { + "bom-ref": "acme-service", + "name": "acme-service" + } + ], + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_signatures-1.3.xml.bin new file mode 100644 index 000000000..26d23b438 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.3.xml.bin @@ -0,0 +1,26 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + my-app + 0.1.0 + + + + + acme-library + 1.2.3 + + + + + acme-service + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.4.json.bin b/tests/_data/snapshots/get_bom_with_signatures-1.4.json.bin new file mode 100644 index 000000000..8c862572f --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.4.json.bin @@ -0,0 +1,77 @@ +{ + "components": [ + { + "bom-ref": "acme-library", + "name": "acme-library", + "signature": { + "algorithm": "ES256", + "value": "MEQCIAJ9DECTPNuqwdWHlHO3EB1jYVnjW7HZ0T7x3QIDO4OMfAIgTFz5kl3Zl7nBP4r2TovMnbJo3ij6JTANcFAQQVEBZQ==", + "keyId": "test-key-1" + }, + "type": "library", + "version": "1.2.3" + } + ], + "dependencies": [ + { + "ref": "acme-library" + }, + { + "ref": "acme-service" + }, + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "my-app", + "type": "application", + "version": "0.1.0" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "services": [ + { + "bom-ref": "acme-service", + "name": "acme-service", + "signature": { + "chain": [ + { + "algorithm": "Ed25519", + "value": "xyzSig==" + } + ] + } + } + ], + "signature": { + "signers": [ + { + "algorithm": "RS256", + "value": "AABBCC==", + "publicKey": { + "kty": "RSA", + "n": "sQ3MDBw==", + "e": "AQAB" + } + }, + { + "algorithm": "ES384", + "value": "DDEEFF==", + "certificatePath": [ + "MIICpDCCAYwCCQDU" + ], + "excludes": [ + "signature" + ] + } + ] + }, + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_signatures-1.4.xml.bin new file mode 100644 index 000000000..076ffd528 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.4.xml.bin @@ -0,0 +1,26 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + my-app + 0.1.0 + + + + + acme-library + 1.2.3 + + + + + acme-service + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.5.json.bin b/tests/_data/snapshots/get_bom_with_signatures-1.5.json.bin new file mode 100644 index 000000000..0bc665deb --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.5.json.bin @@ -0,0 +1,87 @@ +{ + "components": [ + { + "bom-ref": "acme-library", + "name": "acme-library", + "signature": { + "algorithm": "ES256", + "value": "MEQCIAJ9DECTPNuqwdWHlHO3EB1jYVnjW7HZ0T7x3QIDO4OMfAIgTFz5kl3Zl7nBP4r2TovMnbJo3ij6JTANcFAQQVEBZQ==", + "keyId": "test-key-1" + }, + "type": "library", + "version": "1.2.3" + } + ], + "dependencies": [ + { + "ref": "acme-library" + }, + { + "ref": "acme-service" + }, + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "my-app", + "type": "application", + "version": "0.1.0" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "services": [ + { + "bom-ref": "acme-service", + "name": "acme-service", + "signature": { + "chain": [ + { + "algorithm": "Ed25519", + "value": "xyzSig==" + } + ] + } + } + ], + "signature": { + "signers": [ + { + "algorithm": "RS256", + "value": "AABBCC==", + "publicKey": { + "kty": "RSA", + "n": "sQ3MDBw==", + "e": "AQAB" + } + }, + { + "algorithm": "ES384", + "value": "DDEEFF==", + "certificatePath": [ + "MIICpDCCAYwCCQDU" + ], + "excludes": [ + "signature" + ] + } + ] + }, + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_signatures-1.5.xml.bin new file mode 100644 index 000000000..eef873bec --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.5.xml.bin @@ -0,0 +1,30 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + my-app + 0.1.0 + + + + + acme-library + 1.2.3 + + + + + acme-service + + + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.6.json.bin b/tests/_data/snapshots/get_bom_with_signatures-1.6.json.bin new file mode 100644 index 000000000..d67f7871f --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.6.json.bin @@ -0,0 +1,87 @@ +{ + "components": [ + { + "bom-ref": "acme-library", + "name": "acme-library", + "signature": { + "algorithm": "ES256", + "value": "MEQCIAJ9DECTPNuqwdWHlHO3EB1jYVnjW7HZ0T7x3QIDO4OMfAIgTFz5kl3Zl7nBP4r2TovMnbJo3ij6JTANcFAQQVEBZQ==", + "keyId": "test-key-1" + }, + "type": "library", + "version": "1.2.3" + } + ], + "dependencies": [ + { + "ref": "acme-library" + }, + { + "ref": "acme-service" + }, + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "my-app", + "type": "application", + "version": "0.1.0" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "services": [ + { + "bom-ref": "acme-service", + "name": "acme-service", + "signature": { + "chain": [ + { + "algorithm": "Ed25519", + "value": "xyzSig==" + } + ] + } + } + ], + "signature": { + "signers": [ + { + "algorithm": "RS256", + "value": "AABBCC==", + "publicKey": { + "kty": "RSA", + "n": "sQ3MDBw==", + "e": "AQAB" + } + }, + { + "algorithm": "ES384", + "value": "DDEEFF==", + "certificatePath": [ + "MIICpDCCAYwCCQDU" + ], + "excludes": [ + "signature" + ] + } + ] + }, + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_signatures-1.6.xml.bin new file mode 100644 index 000000000..6a8e7f3a3 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.6.xml.bin @@ -0,0 +1,30 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + my-app + 0.1.0 + + + + + acme-library + 1.2.3 + + + + + acme-service + + + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.7.json.bin b/tests/_data/snapshots/get_bom_with_signatures-1.7.json.bin new file mode 100644 index 000000000..a0751be58 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.7.json.bin @@ -0,0 +1,87 @@ +{ + "components": [ + { + "bom-ref": "acme-library", + "name": "acme-library", + "signature": { + "algorithm": "ES256", + "value": "MEQCIAJ9DECTPNuqwdWHlHO3EB1jYVnjW7HZ0T7x3QIDO4OMfAIgTFz5kl3Zl7nBP4r2TovMnbJo3ij6JTANcFAQQVEBZQ==", + "keyId": "test-key-1" + }, + "type": "library", + "version": "1.2.3" + } + ], + "dependencies": [ + { + "ref": "acme-library" + }, + { + "ref": "acme-service" + }, + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "my-app", + "type": "application", + "version": "0.1.0" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "services": [ + { + "bom-ref": "acme-service", + "name": "acme-service", + "signature": { + "chain": [ + { + "algorithm": "Ed25519", + "value": "xyzSig==" + } + ] + } + } + ], + "signature": { + "signers": [ + { + "algorithm": "RS256", + "value": "AABBCC==", + "publicKey": { + "kty": "RSA", + "n": "sQ3MDBw==", + "e": "AQAB" + } + }, + { + "algorithm": "ES384", + "value": "DDEEFF==", + "certificatePath": [ + "MIICpDCCAYwCCQDU" + ], + "excludes": [ + "signature" + ] + } + ] + }, + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_signatures-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_signatures-1.7.xml.bin new file mode 100644 index 000000000..1fbcdd14a --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_signatures-1.7.xml.bin @@ -0,0 +1,30 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + my-app + 0.1.0 + + + + + acme-library + 1.2.3 + + + + + acme-service + + + + + + + + + val1 + val2 + + diff --git a/tests/test_deserialize_xml.py b/tests/test_deserialize_xml.py index 8ad0f31cd..9d9f270a8 100644 --- a/tests/test_deserialize_xml.py +++ b/tests/test_deserialize_xml.py @@ -27,6 +27,7 @@ from cyclonedx.schema import OutputFormat, SchemaVersion from tests import OWN_DATA_DIRECTORY, DeepCompareMixin, SnapshotMixin, mksname from tests._data.models import ( + all_get_bom_funct_no_xml_roundtrip, all_get_bom_funct_valid_immut, all_get_bom_funct_valid_reversible_migrate, all_get_bom_funct_with_incomplete_deps, @@ -47,6 +48,10 @@ def test_prepared(self, get_bom: Callable[[], Bom], *_: Any, **__: Any) -> None: expected = get_bom() with open(self.getSnapshotFile(snapshot_name)) as s: bom = Bom.from_xml(s) + if get_bom in all_get_bom_funct_no_xml_roundtrip: + # BOMs with JSON-only fields (e.g. JSF signatures) cannot fully roundtrip through XML. + # Only verify that XML deserialization succeeds without error. + return self.assertBomDeepEqual(expected, bom, fuzzy_deps=get_bom in all_get_bom_funct_with_incomplete_deps) diff --git a/tests/test_model_signature.py b/tests/test_model_signature.py new file mode 100644 index 000000000..0e90d6a5f --- /dev/null +++ b/tests/test_model_signature.py @@ -0,0 +1,338 @@ +# This file is part of CycloneDX Python Library +# +# 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. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +from unittest import TestCase + +from cyclonedx.exception.model import InvalidValueException +from cyclonedx.model.signature import ( + JsfAlgorithm, + JsfKeyType, + JsfPublicKey, + JsfSignature, + JsfSignatureChain, + JsfSignatureSigners, + JsfSimpleSignature, +) + + +class TestJsfPublicKey(TestCase): + + def test_ec_public_key(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + self.assertEqual(pk.kty, JsfKeyType.EC) + self.assertEqual(pk.crv, 'P-256') + self.assertEqual(pk.x, 'abc') + self.assertEqual(pk.y, 'def') + self.assertIsNone(pk.n) + self.assertIsNone(pk.e) + + def test_okp_public_key(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.OKP, crv='Ed25519', x='xyz') + self.assertEqual(pk.kty, JsfKeyType.OKP) + self.assertEqual(pk.crv, 'Ed25519') + self.assertEqual(pk.x, 'xyz') + self.assertIsNone(pk.y) + self.assertIsNone(pk.n) + self.assertIsNone(pk.e) + + def test_rsa_public_key(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') + self.assertEqual(pk.kty, JsfKeyType.RSA) + self.assertEqual(pk.n, 'modulus') + self.assertEqual(pk.e, 'exponent') + self.assertIsNone(pk.crv) + self.assertIsNone(pk.x) + self.assertIsNone(pk.y) + + def test_equality(self) -> None: + pk1 = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + pk2 = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + pk3 = JsfPublicKey(kty=JsfKeyType.RSA, n='n', e='e') + self.assertEqual(pk1, pk2) + self.assertNotEqual(pk1, pk3) + + def test_hash_stable(self) -> None: + pk1 = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + pk2 = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + self.assertEqual(hash(pk1), hash(pk2)) + + def test_sorting(self) -> None: + pks = [ + JsfPublicKey(kty=JsfKeyType.RSA, n='n', e='e'), + JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def'), + JsfPublicKey(kty=JsfKeyType.OKP, crv='Ed25519', x='xyz'), + ] + sorted_pks = sorted(pks) + self.assertEqual(len(sorted_pks), 3) + + def test_ec_validation_missing_crv(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.EC, x='abc', y='def') + self.assertIn('EC', str(cm.exception)) + + def test_ec_validation_missing_x(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', y='def') + self.assertIn('EC', str(cm.exception)) + + def test_ec_validation_missing_y(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc') + self.assertIn('EC', str(cm.exception)) + + def test_okp_validation_missing_crv(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.OKP, x='xyz') + self.assertIn('OKP', str(cm.exception)) + + def test_okp_validation_missing_x(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.OKP, crv='Ed25519') + self.assertIn('OKP', str(cm.exception)) + + def test_rsa_validation_missing_n(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.RSA, e='exponent') + self.assertIn('RSA', str(cm.exception)) + + def test_rsa_validation_missing_e(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.RSA, n='modulus') + self.assertIn('RSA', str(cm.exception)) + + def test_repr(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + self.assertIn('JsfPublicKey', repr(pk)) + self.assertIn('EC', repr(pk)) + + +class TestJsfSimpleSignature(TestCase): + """Tests for JsfSimpleSignature (simple signature mode).""" + + def test_minimal(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='sig-value') + self.assertEqual(sig.algorithm, JsfAlgorithm.ES256) + self.assertEqual(sig.value, 'sig-value') + self.assertIsNone(sig.key_id) + self.assertIsNone(sig.public_key) + self.assertEqual(sig.certificate_path, []) + self.assertEqual(sig.excludes, []) + + def test_full(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + sig = JsfSimpleSignature( + algorithm=JsfAlgorithm.ES256, + value='sig-value', + key_id='my-key', + public_key=pk, + certificate_path=['cert-pem'], + excludes=['field1', 'field2'], + ) + self.assertEqual(sig.public_key, pk) + self.assertEqual(sig.key_id, 'my-key') + self.assertEqual(sig.certificate_path, ['cert-pem']) + self.assertEqual(sig.excludes, ['field1', 'field2']) + + def test_algorithm_as_enum(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + self.assertIsInstance(sig.algorithm, JsfAlgorithm) + + def test_algorithm_as_uri_string(self) -> None: + sig = JsfSimpleSignature(algorithm='https://example.com/algo', value='v') + self.assertIsInstance(sig.algorithm, str) + self.assertNotIsInstance(sig.algorithm, JsfAlgorithm) + + def test_equality(self) -> None: + sig1 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') + sig2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') + sig3 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + self.assertEqual(sig1, sig2) + self.assertNotEqual(sig1, sig3) + + def test_not_equal_to_other_modes(self) -> None: + simple = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') + multi = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v')]) + self.assertNotEqual(simple, multi) + + def test_hash_stable(self) -> None: + sig1 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') + sig2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') + self.assertEqual(hash(sig1), hash(sig2)) + + def test_sorting(self) -> None: + sigs = [ + JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b'), + JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='a'), + ] + sorted_sigs = sorted(sigs) + self.assertEqual(len(sorted_sigs), 2) + + def test_repr(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') + self.assertIn('JsfSimpleSignature', repr(sig)) + self.assertIn('ES256', repr(sig)) + + +class TestJsfSignatureSigners(TestCase): + """Tests for JsfSignatureSigners (multisignature mode).""" + + def test_minimal(self) -> None: + signer = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v1') + sig = JsfSignatureSigners(signers=[signer]) + self.assertEqual(len(sig.signers), 1) + self.assertEqual(sig.signers[0], signer) + + def test_multiple_signers(self) -> None: + s1 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v1') + s2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v2') + sig = JsfSignatureSigners(signers=[s1, s2]) + self.assertEqual(len(sig.signers), 2) + + def test_is_jsfsignature_subclass(self) -> None: + sig = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]) + self.assertIsInstance(sig, JsfSignature) + + def test_equality(self) -> None: + s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + sig1 = JsfSignatureSigners(signers=[s]) + sig2 = JsfSignatureSigners(signers=[s]) + sig3 = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='x')]) + self.assertEqual(sig1, sig2) + self.assertNotEqual(sig1, sig3) + + def test_not_equal_to_chain(self) -> None: + s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + multi = JsfSignatureSigners(signers=[s]) + chain = JsfSignatureChain(chain=[s]) + self.assertNotEqual(multi, chain) + + def test_hash_stable(self) -> None: + s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + sig1 = JsfSignatureSigners(signers=[s]) + sig2 = JsfSignatureSigners(signers=[s]) + self.assertEqual(hash(sig1), hash(sig2)) + + def test_repr(self) -> None: + s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + sig = JsfSignatureSigners(signers=[s]) + self.assertIn('JsfSignatureSigners', repr(sig)) + self.assertIn('1', repr(sig)) + + +class TestJsfSignatureChain(TestCase): + """Tests for JsfSignatureChain (signaturechain mode).""" + + def test_minimal(self) -> None: + signer = JsfSimpleSignature(algorithm=JsfAlgorithm.ED25519, value='xyzSig==') + sig = JsfSignatureChain(chain=[signer]) + self.assertEqual(len(sig.chain), 1) + self.assertEqual(sig.chain[0], signer) + + def test_multiple_in_chain(self) -> None: + s1 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v1') + s2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v2') + sig = JsfSignatureChain(chain=[s1, s2]) + self.assertEqual(len(sig.chain), 2) + + def test_is_jsfsignature_subclass(self) -> None: + sig = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]) + self.assertIsInstance(sig, JsfSignature) + + def test_equality(self) -> None: + s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + sig1 = JsfSignatureChain(chain=[s]) + sig2 = JsfSignatureChain(chain=[s]) + sig3 = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='x')]) + self.assertEqual(sig1, sig2) + self.assertNotEqual(sig1, sig3) + + def test_not_equal_to_multi(self) -> None: + s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + chain = JsfSignatureChain(chain=[s]) + multi = JsfSignatureSigners(signers=[s]) + self.assertNotEqual(chain, multi) + + def test_hash_stable(self) -> None: + s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + sig1 = JsfSignatureChain(chain=[s]) + sig2 = JsfSignatureChain(chain=[s]) + self.assertEqual(hash(sig1), hash(sig2)) + + def test_repr(self) -> None: + s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') + sig = JsfSignatureChain(chain=[s]) + self.assertIn('JsfSignatureChain', repr(sig)) + self.assertIn('1', repr(sig)) + + +class TestJsfSignatureBaseClass(TestCase): + """Tests that JsfSignature acts as a proper abstract base class / type for isinstance checks.""" + + def test_simple_is_jsfsignature(self) -> None: + self.assertIsInstance( + JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v'), + JsfSignature, + ) + + def test_signers_is_jsfsignature(self) -> None: + self.assertIsInstance( + JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]), + JsfSignature, + ) + + def test_chain_is_jsfsignature(self) -> None: + self.assertIsInstance( + JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]), + JsfSignature, + ) + + def test_modes_are_distinct_types(self) -> None: + simple = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') + multi = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]) + chain = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]) + # All are JsfSignature instances + self.assertIsInstance(simple, JsfSignature) + self.assertIsInstance(multi, JsfSignature) + self.assertIsInstance(chain, JsfSignature) + # But they are different types + self.assertEqual(type(simple).__name__, 'JsfSimpleSignature') + self.assertEqual(type(multi).__name__, 'JsfSignatureSigners') + self.assertEqual(type(chain).__name__, 'JsfSignatureChain') + + +class TestJsfAlgorithm(TestCase): + + def test_enum_values(self) -> None: + self.assertEqual(JsfAlgorithm.RS256.value, 'RS256') + self.assertEqual(JsfAlgorithm.ES256.value, 'ES256') + self.assertEqual(JsfAlgorithm.ED25519.value, 'Ed25519') + self.assertEqual(JsfAlgorithm.ED448.value, 'Ed448') + self.assertEqual(JsfAlgorithm.HS512.value, 'HS512') + + def test_all_algorithms_count(self) -> None: + self.assertEqual(len(JsfAlgorithm), 14) + + +class TestJsfKeyType(TestCase): + + def test_enum_values(self) -> None: + self.assertEqual(JsfKeyType.EC.value, 'EC') + self.assertEqual(JsfKeyType.OKP.value, 'OKP') + self.assertEqual(JsfKeyType.RSA.value, 'RSA') + + def test_all_key_types_count(self) -> None: + self.assertEqual(len(JsfKeyType), 3) From e7d1cfd1ba2e55c9ae95c3951575a62fe43da347 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Wed, 17 Jun 2026 17:30:00 +0200 Subject: [PATCH 2/3] fix: refactor JsfKeyType and add enums and enum tests Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/signature.py | 203 +++++++++++++----- tests/_data/models.py | 29 ++- .../snapshots/enum_JsfAlgorithm-1.4.json.bin | 182 ++++++++++++++++ .../snapshots/enum_JsfAlgorithm-1.5.json.bin | 192 +++++++++++++++++ .../snapshots/enum_JsfAlgorithm-1.6.json.bin | 192 +++++++++++++++++ .../snapshots/enum_JsfAlgorithm-1.7.json.bin | 192 +++++++++++++++++ .../snapshots/enum_JsfEcCurve-1.4.json.bin | 68 ++++++ .../snapshots/enum_JsfEcCurve-1.5.json.bin | 78 +++++++ .../snapshots/enum_JsfEcCurve-1.6.json.bin | 78 +++++++ .../snapshots/enum_JsfEcCurve-1.7.json.bin | 78 +++++++ .../snapshots/enum_JsfKeyType-1.4.json.bin | 66 ++++++ .../snapshots/enum_JsfKeyType-1.5.json.bin | 76 +++++++ .../snapshots/enum_JsfKeyType-1.6.json.bin | 76 +++++++ .../snapshots/enum_JsfKeyType-1.7.json.bin | 76 +++++++ .../snapshots/enum_JsfOkpCurve-1.4.json.bin | 48 +++++ .../snapshots/enum_JsfOkpCurve-1.5.json.bin | 58 +++++ .../snapshots/enum_JsfOkpCurve-1.6.json.bin | 58 +++++ .../snapshots/enum_JsfOkpCurve-1.7.json.bin | 58 +++++ tests/test_deserialize_xml.py | 15 +- tests/test_enums.py | 146 ++++++++++++- tests/test_model_signature.py | 186 ++++++++++++++-- 21 files changed, 2070 insertions(+), 85 deletions(-) create mode 100644 tests/_data/snapshots/enum_JsfAlgorithm-1.4.json.bin create mode 100644 tests/_data/snapshots/enum_JsfAlgorithm-1.5.json.bin create mode 100644 tests/_data/snapshots/enum_JsfAlgorithm-1.6.json.bin create mode 100644 tests/_data/snapshots/enum_JsfAlgorithm-1.7.json.bin create mode 100644 tests/_data/snapshots/enum_JsfEcCurve-1.4.json.bin create mode 100644 tests/_data/snapshots/enum_JsfEcCurve-1.5.json.bin create mode 100644 tests/_data/snapshots/enum_JsfEcCurve-1.6.json.bin create mode 100644 tests/_data/snapshots/enum_JsfEcCurve-1.7.json.bin create mode 100644 tests/_data/snapshots/enum_JsfKeyType-1.4.json.bin create mode 100644 tests/_data/snapshots/enum_JsfKeyType-1.5.json.bin create mode 100644 tests/_data/snapshots/enum_JsfKeyType-1.6.json.bin create mode 100644 tests/_data/snapshots/enum_JsfKeyType-1.7.json.bin create mode 100644 tests/_data/snapshots/enum_JsfOkpCurve-1.4.json.bin create mode 100644 tests/_data/snapshots/enum_JsfOkpCurve-1.5.json.bin create mode 100644 tests/_data/snapshots/enum_JsfOkpCurve-1.6.json.bin create mode 100644 tests/_data/snapshots/enum_JsfOkpCurve-1.7.json.bin diff --git a/cyclonedx/model/signature.py b/cyclonedx/model/signature.py index 76e5a98b9..c4d93bcf3 100644 --- a/cyclonedx/model/signature.py +++ b/cyclonedx/model/signature.py @@ -30,8 +30,10 @@ See the CycloneDX Schema reference: https://cyclonedx.org/docs/1.4/json/#signature """ +from abc import ABC, abstractmethod from enum import Enum -from typing import Any, Optional, Union, cast +from typing import Any, Optional, Union +from urllib.parse import urlsplit as _urlsplit from xml.etree.ElementTree import Element as XmlElement # nosec B405 import py_serializable as serializable @@ -79,6 +81,76 @@ class JsfKeyType(str, Enum): RSA = 'RSA' +@serializable.serializable_enum +class JsfEcCurve(str, Enum): + """ + Elliptic curve names for EC public keys in JSF signatures. + + Supported curves per the JSF 0.82 specification. + """ + + P_256 = 'P-256' + P_384 = 'P-384' + P_521 = 'P-521' + + +@serializable.serializable_enum +class JsfOkpCurve(str, Enum): + """ + Curve names for OKP (Octet Key Pair) public keys in JSF signatures. + + Supported curves per the JSF 0.82 specification. + """ + + ED25519 = 'Ed25519' + ED448 = 'Ed448' + + +def _coerce_ec_crv(crv: Union[JsfEcCurve, str]) -> JsfEcCurve: + if isinstance(crv, JsfEcCurve): + return crv + try: + return JsfEcCurve(crv) + except ValueError: + raise InvalidValueException( + f'EC public key crv must be one of {[c.value for c in JsfEcCurve]!r}, got {crv!r}' + ) from None + + +def _coerce_okp_crv(crv: Union[JsfOkpCurve, str]) -> JsfOkpCurve: + if isinstance(crv, JsfOkpCurve): + return crv + try: + return JsfOkpCurve(crv) + except ValueError: + raise InvalidValueException( + f'OKP public key crv must be one of {[c.value for c in JsfOkpCurve]!r}, got {crv!r}' + ) from None + + +def _check_no_rsa_fields(kty: JsfKeyType, n: Optional[str], e: Optional[str]) -> None: + if n is not None or e is not None: + raise InvalidValueException( + f'{kty.value} public key must not include RSA-specific fields (n, e)' + ) + + +def _check_no_y_field(y: Optional[str]) -> None: + if y is not None: + raise InvalidValueException('OKP public key must not include y') + + +def _check_no_ec_okp_fields( + crv: 'Optional[Union[JsfEcCurve, JsfOkpCurve]]', + x: Optional[str], + y: Optional[str], +) -> None: + if crv is not None or x is not None or y is not None: + raise InvalidValueException( + 'RSA public key must not include EC/OKP-specific fields (crv, x, y)' + ) + + class JsfPublicKey: """ Public key object as defined by the JSF standard. @@ -93,7 +165,7 @@ class JsfPublicKey: def __init__( self, *, kty: JsfKeyType, - crv: Optional[str] = None, + crv: Optional[Union[JsfEcCurve, JsfOkpCurve]] = None, x: Optional[str] = None, y: Optional[str] = None, n: Optional[str] = None, @@ -102,22 +174,28 @@ def __init__( # Validate conditional schema requirements per JSF spec if kty == JsfKeyType.EC: if not (crv and x and y): + raise InvalidValueException('EC public key requires crv, x, and y') + if not isinstance(crv, JsfEcCurve): raise InvalidValueException( - 'EC public key requires crv, x, and y' + f'EC public key crv must be a JsfEcCurve instance, got {type(crv).__name__!r}' ) + _check_no_rsa_fields(kty, n, e) elif kty == JsfKeyType.OKP: if not (crv and x): + raise InvalidValueException('OKP public key requires crv and x') + if not isinstance(crv, JsfOkpCurve): raise InvalidValueException( - 'OKP public key requires crv and x' + f'OKP public key crv must be a JsfOkpCurve instance, got {type(crv).__name__!r}' ) + _check_no_y_field(y) + _check_no_rsa_fields(kty, n, e) elif kty == JsfKeyType.RSA: if not (n and e): - raise InvalidValueException( - 'RSA public key requires n and e' - ) + raise InvalidValueException('RSA public key requires n and e') + _check_no_ec_okp_fields(crv, x, y) self.kty = kty - self.crv = crv + self.crv: Optional[Union[JsfEcCurve, JsfOkpCurve]] = crv self.x = x self.y = y self.n = n @@ -126,7 +204,7 @@ def __init__( def _as_dict(self) -> dict[str, Any]: d: dict[str, Any] = {'kty': self.kty.value} if self.crv is not None: - d['crv'] = self.crv + d['crv'] = self.crv.value if self.x is not None: d['x'] = self.x if self.y is not None: @@ -139,14 +217,15 @@ def _as_dict(self) -> dict[str, Any]: @classmethod def _from_dict(cls, d: dict[str, Any]) -> 'JsfPublicKey': - return cls( - kty=JsfKeyType(d['kty']), - crv=d.get('crv'), - x=d.get('x'), - y=d.get('y'), - n=d.get('n'), - e=d.get('e'), - ) + kty = JsfKeyType(d['kty']) + crv_raw = d.get('crv') + crv: Optional[Union[JsfEcCurve, JsfOkpCurve]] = None + if crv_raw is not None: + if kty == JsfKeyType.EC: + crv = _coerce_ec_crv(crv_raw) + elif kty == JsfKeyType.OKP: + crv = _coerce_okp_crv(crv_raw) + return cls(kty=kty, crv=crv, x=d.get('x'), y=d.get('y'), n=d.get('n'), e=d.get('e')) def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple((self.kty, self.crv, self.x, self.y, self.n, self.e)) @@ -168,7 +247,7 @@ def __repr__(self) -> str: return f'' -class JsfSignature: +class JsfSignature(ABC): """ JSF (JSON Signature Format) signature object — abstract base class. @@ -190,6 +269,15 @@ class JsfSignature: Introduced in CycloneDX v1.4 """ + @abstractmethod + def _as_dict(self) -> dict[str, Any]: + ... # pragma: no cover + + @classmethod + @abstractmethod + def _from_dict(cls, d: dict[str, Any]) -> 'JsfSignature': + ... # pragma: no cover + def __eq__(self, other: object) -> bool: if isinstance(other, JsfSignature): return _JsfSignatureSerializationHelper._sort_key(self) == _JsfSignatureSerializationHelper._sort_key(other) @@ -221,6 +309,12 @@ def __init__( certificate_path: Optional[list[str]] = None, excludes: Optional[list[str]] = None, ) -> None: + if not isinstance(algorithm, JsfAlgorithm): + # Proprietary algorithms must be expressed as URIs per JSF spec + if not _urlsplit(str(algorithm)).scheme: + raise InvalidValueException( + f'Proprietary JSF algorithm must be expressed as a URI, got {algorithm!r}' + ) self.algorithm = algorithm self.value = value self.key_id = key_id @@ -230,7 +324,7 @@ def __init__( def _as_dict(self) -> dict[str, Any]: d: dict[str, Any] = { - 'algorithm': str(self.algorithm), + 'algorithm': self.algorithm.value if isinstance(self.algorithm, JsfAlgorithm) else str(self.algorithm), 'value': self.value, } if self.key_id is not None: @@ -245,9 +339,14 @@ def _as_dict(self) -> dict[str, Any]: @classmethod def _from_dict(cls, d: dict[str, Any]) -> 'JsfSimpleSignature': + algorithm: Union[JsfAlgorithm, str] + try: + algorithm = JsfAlgorithm(d['algorithm']) + except ValueError: + algorithm = d['algorithm'] pk = d.get('publicKey') return cls( - algorithm=d['algorithm'], + algorithm=algorithm, value=d['value'], key_id=d.get('keyId'), public_key=JsfPublicKey._from_dict(pk) if pk is not None else None, @@ -267,8 +366,17 @@ class JsfSignatureSigners(JsfSignature): """ def __init__(self, *, signers: list['JsfSimpleSignature']) -> None: + if not signers: + raise InvalidValueException('JsfSignatureSigners requires at least one signer') self.signers = list(signers) + def _as_dict(self) -> dict[str, Any]: + return {'signers': [s._as_dict() for s in self.signers]} + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> 'JsfSignatureSigners': + return cls(signers=[JsfSimpleSignature._from_dict(s) for s in d['signers']]) + def __repr__(self) -> str: return f'' @@ -281,8 +389,17 @@ class JsfSignatureChain(JsfSignature): """ def __init__(self, *, chain: list['JsfSimpleSignature']) -> None: + if not chain: + raise InvalidValueException('JsfSignatureChain requires at least one element') self.chain = list(chain) + def _as_dict(self) -> dict[str, Any]: + return {'chain': [s._as_dict() for s in self.chain]} + + @classmethod + def _from_dict(cls, d: dict[str, Any]) -> 'JsfSignatureChain': + return cls(chain=[JsfSimpleSignature._from_dict(s) for s in d['chain']]) + def __repr__(self) -> str: return f'' @@ -300,54 +417,28 @@ def _sort_key(o: JsfSignature) -> _ComparableTuple: return _ComparableTuple(('chain', _ComparableTuple(o.chain))) if isinstance(o, JsfSignatureSigners): return _ComparableTuple(('signers', _ComparableTuple(o.signers))) - # Simple signature (JsfSimpleSignature) - o = cast(JsfSimpleSignature, o) - return _ComparableTuple((str(o.algorithm), o.value, o.key_id, o.public_key, - _ComparableTuple(o.certificate_path), - _ComparableTuple(o.excludes))) + if isinstance(o, JsfSimpleSignature): + algo_str = o.algorithm.value if isinstance(o.algorithm, JsfAlgorithm) else str(o.algorithm) + return _ComparableTuple((algo_str, o.value, o.key_id, o.public_key, + _ComparableTuple(o.certificate_path), + _ComparableTuple(o.excludes))) + raise TypeError(f'Unknown JsfSignature subtype: {type(o)!r}') # pragma: no cover @classmethod def json_normalize(cls, o: JsfSignature, *, view: Optional[type[serializable.ViewType]], **__: Any) -> Any: - if isinstance(o, JsfSignatureSigners): - return {'signers': [s._as_dict() for s in o.signers]} - if isinstance(o, JsfSignatureChain): - return {'chain': [s._as_dict() for s in o.chain]} - # Simple signature - o = cast(JsfSimpleSignature, o) - d: dict[str, Any] = { - 'algorithm': str(o.algorithm), - 'value': o.value, - } - if o.key_id is not None: - d['keyId'] = o.key_id - if o.public_key is not None: - d['publicKey'] = o.public_key._as_dict() - if o.certificate_path: - d['certificatePath'] = list(o.certificate_path) - if o.excludes: - d['excludes'] = list(o.excludes) - return d + return o._as_dict() @classmethod def json_denormalize(cls, o: Any, **__: Any) -> JsfSignature: if not isinstance(o, dict): raise TypeError(f'Expected dict, got {type(o)!r}') if 'signers' in o: - return JsfSignatureSigners(signers=[JsfSimpleSignature._from_dict(s) for s in o['signers']]) + return JsfSignatureSigners._from_dict(o) if 'chain' in o: - return JsfSignatureChain(chain=[JsfSimpleSignature._from_dict(s) for s in o['chain']]) - # Simple signature: must have at least 'algorithm' and 'value' - pk = o.get('publicKey') - return JsfSimpleSignature( - algorithm=o['algorithm'], - value=o['value'], - key_id=o.get('keyId'), - public_key=JsfPublicKey._from_dict(pk) if pk is not None else None, - certificate_path=o.get('certificatePath'), - excludes=o.get('excludes'), - ) + return JsfSignatureChain._from_dict(o) + return JsfSimpleSignature._from_dict(o) @classmethod def xml_normalize(cls, o: JsfSignature, *, diff --git a/tests/_data/models.py b/tests/_data/models.py index 607a3f19a..5af00bc5f 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -1601,14 +1601,14 @@ def get_bom_for_issue540_duplicate_components() -> Bom: def get_bom_with_signatures() -> Bom: # Tests JSF signature support on Bom, Component, and Service (JSON-only, CDX >= 1.4) simple_sig = JsfSimpleSignature( - algorithm=JsfAlgorithm.ES256.value, + algorithm=JsfAlgorithm.ES256, value='MEQCIAJ9DECTPNuqwdWHlHO3EB1jYVnjW7HZ0T7x3QIDO4OMfAIgTFz5kl3Zl7nBP4r2TovMnbJo3ij6JTANcFAQQVEBZQ==', key_id='test-key-1', ) multi_sig = JsfSignatureSigners( signers=[ JsfSimpleSignature( - algorithm=JsfAlgorithm.RS256.value, + algorithm=JsfAlgorithm.RS256, value='AABBCC==', public_key=JsfPublicKey( kty=JsfKeyType.RSA, @@ -1617,7 +1617,7 @@ def get_bom_with_signatures() -> Bom: ), ), JsfSimpleSignature( - algorithm=JsfAlgorithm.ES384.value, + algorithm=JsfAlgorithm.ES384, value='DDEEFF==', certificate_path=['MIICpDCCAYwCCQDU'], excludes=['signature'], @@ -1641,7 +1641,7 @@ def get_bom_with_signatures() -> Bom: signature=JsfSignatureChain( chain=[ JsfSimpleSignature( - algorithm=JsfAlgorithm.ED25519.value, + algorithm=JsfAlgorithm.ED25519, value='xyzSig==', ) ] @@ -1656,6 +1656,9 @@ def get_bom_with_signatures() -> Bom: type=ComponentType.APPLICATION, bom_ref='my-app', ) + return bom + + def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: bom = _make_bom() bom.metadata.component = root_component = Component( @@ -1735,9 +1738,21 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: if n.startswith('get_bom_') and not n.endswith('_invalid') ) +all_get_bom_funct_no_xml_roundtrip: frozenset = frozenset({ + # BOMs that contain JSON-only fields (e.g. JSF signatures). + # These are excluded from test_deserialize_xml's test_prepared assertBomDeepEqual comparison. + # Use all_get_bom_funct_no_xml_roundtrip_immut for @named_data. + get_bom_with_signatures, +}) + +all_get_bom_funct_no_xml_roundtrip_immut = tuple( + (f.__name__, f) for f in sorted(all_get_bom_funct_no_xml_roundtrip, key=lambda f: f.__name__) +) + all_get_bom_funct_valid_immut = tuple( (n, f) for n, f in getmembers(sys.modules[__name__], isfunction) if n.startswith('get_bom_') and not n.endswith('_invalid') and not n.endswith('_migrate') + and f not in all_get_bom_funct_no_xml_roundtrip ) all_get_bom_funct_valid_reversible_migrate = tuple( @@ -1778,9 +1793,3 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: get_bom_with_definitions_and_detailed_standards, get_bom_with_signatures, } - -all_get_bom_funct_no_xml_roundtrip = { - # BOMs that contain JSON-only fields (e.g. JSF signatures) that cannot survive an XML roundtrip. - # These are excluded from test_deserialize_xml's assertBomDeepEqual comparison. - get_bom_with_signatures, -} diff --git a/tests/_data/snapshots/enum_JsfAlgorithm-1.4.json.bin b/tests/_data/snapshots/enum_JsfAlgorithm-1.4.json.bin new file mode 100644 index 000000000..c6945cdde --- /dev/null +++ b/tests/_data/snapshots/enum_JsfAlgorithm-1.4.json.bin @@ -0,0 +1,182 @@ +{ + "components": [ + { + "bom-ref": "dummy-JA:ED25519", + "name": "JsfAlgorithm: ED25519", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ED448", + "name": "JsfAlgorithm: ED448", + "signature": { + "algorithm": "Ed448", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES256", + "name": "JsfAlgorithm: ES256", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES384", + "name": "JsfAlgorithm: ES384", + "signature": { + "algorithm": "ES384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES512", + "name": "JsfAlgorithm: ES512", + "signature": { + "algorithm": "ES512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS256", + "name": "JsfAlgorithm: HS256", + "signature": { + "algorithm": "HS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS384", + "name": "JsfAlgorithm: HS384", + "signature": { + "algorithm": "HS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS512", + "name": "JsfAlgorithm: HS512", + "signature": { + "algorithm": "HS512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS256", + "name": "JsfAlgorithm: PS256", + "signature": { + "algorithm": "PS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS384", + "name": "JsfAlgorithm: PS384", + "signature": { + "algorithm": "PS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS512", + "name": "JsfAlgorithm: PS512", + "signature": { + "algorithm": "PS512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS256", + "name": "JsfAlgorithm: RS256", + "signature": { + "algorithm": "RS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS384", + "name": "JsfAlgorithm: RS384", + "signature": { + "algorithm": "RS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS512", + "name": "JsfAlgorithm: RS512", + "signature": { + "algorithm": "RS512", + "value": "AABBCC==" + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JA:ED25519" + }, + { + "ref": "dummy-JA:ED448" + }, + { + "ref": "dummy-JA:ES256" + }, + { + "ref": "dummy-JA:ES384" + }, + { + "ref": "dummy-JA:ES512" + }, + { + "ref": "dummy-JA:HS256" + }, + { + "ref": "dummy-JA:HS384" + }, + { + "ref": "dummy-JA:HS512" + }, + { + "ref": "dummy-JA:PS256" + }, + { + "ref": "dummy-JA:PS384" + }, + { + "ref": "dummy-JA:PS512" + }, + { + "ref": "dummy-JA:RS256" + }, + { + "ref": "dummy-JA:RS384" + }, + { + "ref": "dummy-JA:RS512" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfAlgorithm-1.5.json.bin b/tests/_data/snapshots/enum_JsfAlgorithm-1.5.json.bin new file mode 100644 index 000000000..a5df74fb1 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfAlgorithm-1.5.json.bin @@ -0,0 +1,192 @@ +{ + "components": [ + { + "bom-ref": "dummy-JA:ED25519", + "name": "JsfAlgorithm: ED25519", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ED448", + "name": "JsfAlgorithm: ED448", + "signature": { + "algorithm": "Ed448", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES256", + "name": "JsfAlgorithm: ES256", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES384", + "name": "JsfAlgorithm: ES384", + "signature": { + "algorithm": "ES384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES512", + "name": "JsfAlgorithm: ES512", + "signature": { + "algorithm": "ES512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS256", + "name": "JsfAlgorithm: HS256", + "signature": { + "algorithm": "HS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS384", + "name": "JsfAlgorithm: HS384", + "signature": { + "algorithm": "HS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS512", + "name": "JsfAlgorithm: HS512", + "signature": { + "algorithm": "HS512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS256", + "name": "JsfAlgorithm: PS256", + "signature": { + "algorithm": "PS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS384", + "name": "JsfAlgorithm: PS384", + "signature": { + "algorithm": "PS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS512", + "name": "JsfAlgorithm: PS512", + "signature": { + "algorithm": "PS512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS256", + "name": "JsfAlgorithm: RS256", + "signature": { + "algorithm": "RS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS384", + "name": "JsfAlgorithm: RS384", + "signature": { + "algorithm": "RS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS512", + "name": "JsfAlgorithm: RS512", + "signature": { + "algorithm": "RS512", + "value": "AABBCC==" + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JA:ED25519" + }, + { + "ref": "dummy-JA:ED448" + }, + { + "ref": "dummy-JA:ES256" + }, + { + "ref": "dummy-JA:ES384" + }, + { + "ref": "dummy-JA:ES512" + }, + { + "ref": "dummy-JA:HS256" + }, + { + "ref": "dummy-JA:HS384" + }, + { + "ref": "dummy-JA:HS512" + }, + { + "ref": "dummy-JA:PS256" + }, + { + "ref": "dummy-JA:PS384" + }, + { + "ref": "dummy-JA:PS512" + }, + { + "ref": "dummy-JA:RS256" + }, + { + "ref": "dummy-JA:RS384" + }, + { + "ref": "dummy-JA:RS512" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfAlgorithm-1.6.json.bin b/tests/_data/snapshots/enum_JsfAlgorithm-1.6.json.bin new file mode 100644 index 000000000..b586bd9da --- /dev/null +++ b/tests/_data/snapshots/enum_JsfAlgorithm-1.6.json.bin @@ -0,0 +1,192 @@ +{ + "components": [ + { + "bom-ref": "dummy-JA:ED25519", + "name": "JsfAlgorithm: ED25519", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ED448", + "name": "JsfAlgorithm: ED448", + "signature": { + "algorithm": "Ed448", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES256", + "name": "JsfAlgorithm: ES256", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES384", + "name": "JsfAlgorithm: ES384", + "signature": { + "algorithm": "ES384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES512", + "name": "JsfAlgorithm: ES512", + "signature": { + "algorithm": "ES512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS256", + "name": "JsfAlgorithm: HS256", + "signature": { + "algorithm": "HS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS384", + "name": "JsfAlgorithm: HS384", + "signature": { + "algorithm": "HS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS512", + "name": "JsfAlgorithm: HS512", + "signature": { + "algorithm": "HS512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS256", + "name": "JsfAlgorithm: PS256", + "signature": { + "algorithm": "PS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS384", + "name": "JsfAlgorithm: PS384", + "signature": { + "algorithm": "PS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS512", + "name": "JsfAlgorithm: PS512", + "signature": { + "algorithm": "PS512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS256", + "name": "JsfAlgorithm: RS256", + "signature": { + "algorithm": "RS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS384", + "name": "JsfAlgorithm: RS384", + "signature": { + "algorithm": "RS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS512", + "name": "JsfAlgorithm: RS512", + "signature": { + "algorithm": "RS512", + "value": "AABBCC==" + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JA:ED25519" + }, + { + "ref": "dummy-JA:ED448" + }, + { + "ref": "dummy-JA:ES256" + }, + { + "ref": "dummy-JA:ES384" + }, + { + "ref": "dummy-JA:ES512" + }, + { + "ref": "dummy-JA:HS256" + }, + { + "ref": "dummy-JA:HS384" + }, + { + "ref": "dummy-JA:HS512" + }, + { + "ref": "dummy-JA:PS256" + }, + { + "ref": "dummy-JA:PS384" + }, + { + "ref": "dummy-JA:PS512" + }, + { + "ref": "dummy-JA:RS256" + }, + { + "ref": "dummy-JA:RS384" + }, + { + "ref": "dummy-JA:RS512" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfAlgorithm-1.7.json.bin b/tests/_data/snapshots/enum_JsfAlgorithm-1.7.json.bin new file mode 100644 index 000000000..27497bb03 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfAlgorithm-1.7.json.bin @@ -0,0 +1,192 @@ +{ + "components": [ + { + "bom-ref": "dummy-JA:ED25519", + "name": "JsfAlgorithm: ED25519", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ED448", + "name": "JsfAlgorithm: ED448", + "signature": { + "algorithm": "Ed448", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES256", + "name": "JsfAlgorithm: ES256", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES384", + "name": "JsfAlgorithm: ES384", + "signature": { + "algorithm": "ES384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:ES512", + "name": "JsfAlgorithm: ES512", + "signature": { + "algorithm": "ES512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS256", + "name": "JsfAlgorithm: HS256", + "signature": { + "algorithm": "HS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS384", + "name": "JsfAlgorithm: HS384", + "signature": { + "algorithm": "HS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:HS512", + "name": "JsfAlgorithm: HS512", + "signature": { + "algorithm": "HS512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS256", + "name": "JsfAlgorithm: PS256", + "signature": { + "algorithm": "PS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS384", + "name": "JsfAlgorithm: PS384", + "signature": { + "algorithm": "PS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:PS512", + "name": "JsfAlgorithm: PS512", + "signature": { + "algorithm": "PS512", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS256", + "name": "JsfAlgorithm: RS256", + "signature": { + "algorithm": "RS256", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS384", + "name": "JsfAlgorithm: RS384", + "signature": { + "algorithm": "RS384", + "value": "AABBCC==" + }, + "type": "library" + }, + { + "bom-ref": "dummy-JA:RS512", + "name": "JsfAlgorithm: RS512", + "signature": { + "algorithm": "RS512", + "value": "AABBCC==" + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JA:ED25519" + }, + { + "ref": "dummy-JA:ED448" + }, + { + "ref": "dummy-JA:ES256" + }, + { + "ref": "dummy-JA:ES384" + }, + { + "ref": "dummy-JA:ES512" + }, + { + "ref": "dummy-JA:HS256" + }, + { + "ref": "dummy-JA:HS384" + }, + { + "ref": "dummy-JA:HS512" + }, + { + "ref": "dummy-JA:PS256" + }, + { + "ref": "dummy-JA:PS384" + }, + { + "ref": "dummy-JA:PS512" + }, + { + "ref": "dummy-JA:RS256" + }, + { + "ref": "dummy-JA:RS384" + }, + { + "ref": "dummy-JA:RS512" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfEcCurve-1.4.json.bin b/tests/_data/snapshots/enum_JsfEcCurve-1.4.json.bin new file mode 100644 index 000000000..3dd9381c7 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfEcCurve-1.4.json.bin @@ -0,0 +1,68 @@ +{ + "components": [ + { + "bom-ref": "dummy-JEC:P_256", + "name": "JsfEcCurve: P_256", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-256", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JEC:P_384", + "name": "JsfEcCurve: P_384", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-384", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JEC:P_521", + "name": "JsfEcCurve: P_521", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-521", + "x": "abc", + "y": "def" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JEC:P_256" + }, + { + "ref": "dummy-JEC:P_384" + }, + { + "ref": "dummy-JEC:P_521" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfEcCurve-1.5.json.bin b/tests/_data/snapshots/enum_JsfEcCurve-1.5.json.bin new file mode 100644 index 000000000..945f69265 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfEcCurve-1.5.json.bin @@ -0,0 +1,78 @@ +{ + "components": [ + { + "bom-ref": "dummy-JEC:P_256", + "name": "JsfEcCurve: P_256", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-256", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JEC:P_384", + "name": "JsfEcCurve: P_384", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-384", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JEC:P_521", + "name": "JsfEcCurve: P_521", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-521", + "x": "abc", + "y": "def" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JEC:P_256" + }, + { + "ref": "dummy-JEC:P_384" + }, + { + "ref": "dummy-JEC:P_521" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfEcCurve-1.6.json.bin b/tests/_data/snapshots/enum_JsfEcCurve-1.6.json.bin new file mode 100644 index 000000000..8ab8e8fdf --- /dev/null +++ b/tests/_data/snapshots/enum_JsfEcCurve-1.6.json.bin @@ -0,0 +1,78 @@ +{ + "components": [ + { + "bom-ref": "dummy-JEC:P_256", + "name": "JsfEcCurve: P_256", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-256", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JEC:P_384", + "name": "JsfEcCurve: P_384", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-384", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JEC:P_521", + "name": "JsfEcCurve: P_521", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-521", + "x": "abc", + "y": "def" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JEC:P_256" + }, + { + "ref": "dummy-JEC:P_384" + }, + { + "ref": "dummy-JEC:P_521" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfEcCurve-1.7.json.bin b/tests/_data/snapshots/enum_JsfEcCurve-1.7.json.bin new file mode 100644 index 000000000..0c05eac39 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfEcCurve-1.7.json.bin @@ -0,0 +1,78 @@ +{ + "components": [ + { + "bom-ref": "dummy-JEC:P_256", + "name": "JsfEcCurve: P_256", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-256", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JEC:P_384", + "name": "JsfEcCurve: P_384", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-384", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JEC:P_521", + "name": "JsfEcCurve: P_521", + "signature": { + "algorithm": "ES256", + "value": "AABBCC==", + "publicKey": { + "kty": "EC", + "crv": "P-521", + "x": "abc", + "y": "def" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JEC:P_256" + }, + { + "ref": "dummy-JEC:P_384" + }, + { + "ref": "dummy-JEC:P_521" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfKeyType-1.4.json.bin b/tests/_data/snapshots/enum_JsfKeyType-1.4.json.bin new file mode 100644 index 000000000..28f6621ce --- /dev/null +++ b/tests/_data/snapshots/enum_JsfKeyType-1.4.json.bin @@ -0,0 +1,66 @@ +{ + "components": [ + { + "bom-ref": "dummy-JKT:EC", + "name": "JsfKeyType: EC", + "signature": { + "algorithm": "ES256", + "value": "DDEEFF==", + "publicKey": { + "kty": "EC", + "crv": "P-256", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JKT:OKP", + "name": "JsfKeyType: OKP", + "signature": { + "algorithm": "Ed25519", + "value": "GGHHII==", + "publicKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "xyz" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JKT:RSA", + "name": "JsfKeyType: RSA", + "signature": { + "algorithm": "RS256", + "value": "AABBCC==", + "publicKey": { + "kty": "RSA", + "n": "sQ3MDBw==", + "e": "AQAB" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JKT:EC" + }, + { + "ref": "dummy-JKT:OKP" + }, + { + "ref": "dummy-JKT:RSA" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfKeyType-1.5.json.bin b/tests/_data/snapshots/enum_JsfKeyType-1.5.json.bin new file mode 100644 index 000000000..650de4c5c --- /dev/null +++ b/tests/_data/snapshots/enum_JsfKeyType-1.5.json.bin @@ -0,0 +1,76 @@ +{ + "components": [ + { + "bom-ref": "dummy-JKT:EC", + "name": "JsfKeyType: EC", + "signature": { + "algorithm": "ES256", + "value": "DDEEFF==", + "publicKey": { + "kty": "EC", + "crv": "P-256", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JKT:OKP", + "name": "JsfKeyType: OKP", + "signature": { + "algorithm": "Ed25519", + "value": "GGHHII==", + "publicKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "xyz" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JKT:RSA", + "name": "JsfKeyType: RSA", + "signature": { + "algorithm": "RS256", + "value": "AABBCC==", + "publicKey": { + "kty": "RSA", + "n": "sQ3MDBw==", + "e": "AQAB" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JKT:EC" + }, + { + "ref": "dummy-JKT:OKP" + }, + { + "ref": "dummy-JKT:RSA" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfKeyType-1.6.json.bin b/tests/_data/snapshots/enum_JsfKeyType-1.6.json.bin new file mode 100644 index 000000000..c9d31b19b --- /dev/null +++ b/tests/_data/snapshots/enum_JsfKeyType-1.6.json.bin @@ -0,0 +1,76 @@ +{ + "components": [ + { + "bom-ref": "dummy-JKT:EC", + "name": "JsfKeyType: EC", + "signature": { + "algorithm": "ES256", + "value": "DDEEFF==", + "publicKey": { + "kty": "EC", + "crv": "P-256", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JKT:OKP", + "name": "JsfKeyType: OKP", + "signature": { + "algorithm": "Ed25519", + "value": "GGHHII==", + "publicKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "xyz" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JKT:RSA", + "name": "JsfKeyType: RSA", + "signature": { + "algorithm": "RS256", + "value": "AABBCC==", + "publicKey": { + "kty": "RSA", + "n": "sQ3MDBw==", + "e": "AQAB" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JKT:EC" + }, + { + "ref": "dummy-JKT:OKP" + }, + { + "ref": "dummy-JKT:RSA" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfKeyType-1.7.json.bin b/tests/_data/snapshots/enum_JsfKeyType-1.7.json.bin new file mode 100644 index 000000000..9861ee08d --- /dev/null +++ b/tests/_data/snapshots/enum_JsfKeyType-1.7.json.bin @@ -0,0 +1,76 @@ +{ + "components": [ + { + "bom-ref": "dummy-JKT:EC", + "name": "JsfKeyType: EC", + "signature": { + "algorithm": "ES256", + "value": "DDEEFF==", + "publicKey": { + "kty": "EC", + "crv": "P-256", + "x": "abc", + "y": "def" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JKT:OKP", + "name": "JsfKeyType: OKP", + "signature": { + "algorithm": "Ed25519", + "value": "GGHHII==", + "publicKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "xyz" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JKT:RSA", + "name": "JsfKeyType: RSA", + "signature": { + "algorithm": "RS256", + "value": "AABBCC==", + "publicKey": { + "kty": "RSA", + "n": "sQ3MDBw==", + "e": "AQAB" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JKT:EC" + }, + { + "ref": "dummy-JKT:OKP" + }, + { + "ref": "dummy-JKT:RSA" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfOkpCurve-1.4.json.bin b/tests/_data/snapshots/enum_JsfOkpCurve-1.4.json.bin new file mode 100644 index 000000000..32afba810 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfOkpCurve-1.4.json.bin @@ -0,0 +1,48 @@ +{ + "components": [ + { + "bom-ref": "dummy-JOC:ED25519", + "name": "JsfOkpCurve: ED25519", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==", + "publicKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "xyz" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JOC:ED448", + "name": "JsfOkpCurve: ED448", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==", + "publicKey": { + "kty": "OKP", + "crv": "Ed448", + "x": "xyz" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JOC:ED25519" + }, + { + "ref": "dummy-JOC:ED448" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfOkpCurve-1.5.json.bin b/tests/_data/snapshots/enum_JsfOkpCurve-1.5.json.bin new file mode 100644 index 000000000..1807fff63 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfOkpCurve-1.5.json.bin @@ -0,0 +1,58 @@ +{ + "components": [ + { + "bom-ref": "dummy-JOC:ED25519", + "name": "JsfOkpCurve: ED25519", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==", + "publicKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "xyz" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JOC:ED448", + "name": "JsfOkpCurve: ED448", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==", + "publicKey": { + "kty": "OKP", + "crv": "Ed448", + "x": "xyz" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JOC:ED25519" + }, + { + "ref": "dummy-JOC:ED448" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfOkpCurve-1.6.json.bin b/tests/_data/snapshots/enum_JsfOkpCurve-1.6.json.bin new file mode 100644 index 000000000..f5deae3d1 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfOkpCurve-1.6.json.bin @@ -0,0 +1,58 @@ +{ + "components": [ + { + "bom-ref": "dummy-JOC:ED25519", + "name": "JsfOkpCurve: ED25519", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==", + "publicKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "xyz" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JOC:ED448", + "name": "JsfOkpCurve: ED448", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==", + "publicKey": { + "kty": "OKP", + "crv": "Ed448", + "x": "xyz" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JOC:ED25519" + }, + { + "ref": "dummy-JOC:ED448" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/enum_JsfOkpCurve-1.7.json.bin b/tests/_data/snapshots/enum_JsfOkpCurve-1.7.json.bin new file mode 100644 index 000000000..e1f2e0492 --- /dev/null +++ b/tests/_data/snapshots/enum_JsfOkpCurve-1.7.json.bin @@ -0,0 +1,58 @@ +{ + "components": [ + { + "bom-ref": "dummy-JOC:ED25519", + "name": "JsfOkpCurve: ED25519", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==", + "publicKey": { + "kty": "OKP", + "crv": "Ed25519", + "x": "xyz" + } + }, + "type": "library" + }, + { + "bom-ref": "dummy-JOC:ED448", + "name": "JsfOkpCurve: ED448", + "signature": { + "algorithm": "Ed25519", + "value": "AABBCC==", + "publicKey": { + "kty": "OKP", + "crv": "Ed448", + "x": "xyz" + } + }, + "type": "library" + } + ], + "dependencies": [ + { + "ref": "dummy-JOC:ED25519" + }, + { + "ref": "dummy-JOC:ED448" + } + ], + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/test_deserialize_xml.py b/tests/test_deserialize_xml.py index 9d9f270a8..64be7c35b 100644 --- a/tests/test_deserialize_xml.py +++ b/tests/test_deserialize_xml.py @@ -27,7 +27,7 @@ from cyclonedx.schema import OutputFormat, SchemaVersion from tests import OWN_DATA_DIRECTORY, DeepCompareMixin, SnapshotMixin, mksname from tests._data.models import ( - all_get_bom_funct_no_xml_roundtrip, + all_get_bom_funct_no_xml_roundtrip_immut, all_get_bom_funct_valid_immut, all_get_bom_funct_valid_reversible_migrate, all_get_bom_funct_with_incomplete_deps, @@ -48,13 +48,18 @@ def test_prepared(self, get_bom: Callable[[], Bom], *_: Any, **__: Any) -> None: expected = get_bom() with open(self.getSnapshotFile(snapshot_name)) as s: bom = Bom.from_xml(s) - if get_bom in all_get_bom_funct_no_xml_roundtrip: - # BOMs with JSON-only fields (e.g. JSF signatures) cannot fully roundtrip through XML. - # Only verify that XML deserialization succeeds without error. - return self.assertBomDeepEqual(expected, bom, fuzzy_deps=get_bom in all_get_bom_funct_with_incomplete_deps) + @named_data(*all_get_bom_funct_no_xml_roundtrip_immut) + @patch('cyclonedx.contrib.this.builders.__ThisVersion', 'TESTING') + def test_no_xml_roundtrip(self, get_bom: Callable[[], Bom], *_: Any, **__: Any) -> None: + # JSON-only fields (e.g. JSF signatures) are silently dropped during XML serialization. + # Verify only that deserialization of the XML snapshot succeeds without error. + snapshot_name = mksname(get_bom, _LATEST_SCHEMA, OutputFormat.XML) + with open(self.getSnapshotFile(snapshot_name)) as s: + Bom.from_xml(s) + def test_component_evidence_identity(self) -> None: xml_file = join(OWN_DATA_DIRECTORY, 'xml', SchemaVersion.V1_6.to_version(), diff --git a/tests/test_enums.py b/tests/test_enums.py index 88ac8e714..c33695d8e 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -46,6 +46,7 @@ from cyclonedx.model.license import DisjunctiveLicense from cyclonedx.model.lifecycle import LifecyclePhase, PredefinedLifecycle from cyclonedx.model.service import DataClassification, Service +from cyclonedx.model.signature import JsfPublicKey, JsfSimpleSignature from cyclonedx.model.vulnerability import ( BomTarget, BomTargetVersionRange, @@ -55,7 +56,7 @@ ) from cyclonedx.output import make_outputter from cyclonedx.schema import OutputFormat, SchemaVersion -from cyclonedx.schema._res import BOM_JSON as SCHEMA_JSON, BOM_XML as SCHEMA_XML +from cyclonedx.schema._res import BOM_JSON as SCHEMA_JSON, BOM_XML as SCHEMA_XML, JSF as SCHEMA_JSF from cyclonedx.validation import make_schemabased_validator from tests import PROJECT_LIB_MODELS_DIRECTORY, SnapshotMixin from tests._data.models import _make_bom @@ -106,6 +107,12 @@ RelatedCryptoMaterialState, RelatedCryptoMaterialType, ) +from cyclonedx.model.signature import ( # isort:skip + JsfAlgorithm, + JsfEcCurve, + JsfKeyType, + JsfOkpCurve, +) # endregion SUT @@ -153,6 +160,30 @@ def dp_cases_from_json_schemas(*jsonpointer: str) -> set[str]: return cases +def dp_cases_from_jsf_algorithm() -> set[str]: + with open(SCHEMA_JSF) as sfh: + data = json_load(sfh) + return set(data['definitions']['signer']['properties']['algorithm']['oneOf'][0]['enum']) + + +def dp_cases_from_jsf_keytype() -> set[str]: + with open(SCHEMA_JSF) as sfh: + data = json_load(sfh) + return set(data['definitions']['keyType']['enum']) + + +def dp_cases_from_jsf_ec_crv() -> set[str]: + with open(SCHEMA_JSF) as sfh: + data = json_load(sfh) + return set(data['definitions']['publicKey']['allOf'][0]['then']['properties']['crv']['enum']) + + +def dp_cases_from_jsf_okp_crv() -> set[str]: + with open(SCHEMA_JSF) as sfh: + data = json_load(sfh) + return set(data['definitions']['publicKey']['allOf'][1]['then']['properties']['crv']['enum']) + + UNSUPPORTED_OF_SV = frozenset([ (OutputFormat.JSON, SchemaVersion.V1_1), (OutputFormat.JSON, SchemaVersion.V1_0), @@ -977,6 +1008,119 @@ def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, super()._test_cases_render(bom, of, sv) +@ddt +class TestEnumJsfAlgorithm(_EnumTestCase): + + @idata(dp_cases_from_jsf_algorithm()) + def test_knows_value(self, value: str) -> None: + super()._test_knows_value(JsfAlgorithm, value) + + @named_data(*(d for d in NAMED_OF_SV if d[1] == OutputFormat.JSON and d[2] >= SchemaVersion.V1_4)) + def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, **__: Any) -> None: + bom = _make_bom( + components=[ + Component( + name=f'JsfAlgorithm: {alg.name}', bom_ref=f'dummy-JA:{alg.name}', + type=ComponentType.LIBRARY, + signature=JsfSimpleSignature( + algorithm=alg, + value='AABBCC==', + ), + ) for alg in JsfAlgorithm + ]) + super()._test_cases_render(bom, of, sv) + + +@ddt +class TestEnumJsfKeyType(_EnumTestCase): + + @idata(dp_cases_from_jsf_keytype()) + def test_knows_value(self, value: str) -> None: + super()._test_knows_value(JsfKeyType, value) + + @named_data(*(d for d in NAMED_OF_SV if d[1] == OutputFormat.JSON and d[2] >= SchemaVersion.V1_4)) + def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, **__: Any) -> None: + bom = _make_bom( + components=[ + Component( + name='JsfKeyType: EC', bom_ref='dummy-JKT:EC', + type=ComponentType.LIBRARY, + signature=JsfSimpleSignature( + algorithm=JsfAlgorithm.ES256, + value='DDEEFF==', + public_key=JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def'), + ), + ), + Component( + name='JsfKeyType: OKP', bom_ref='dummy-JKT:OKP', + type=ComponentType.LIBRARY, + signature=JsfSimpleSignature( + algorithm=JsfAlgorithm.ED25519, + value='GGHHII==', + public_key=JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz'), + ), + ), + Component( + name='JsfKeyType: RSA', bom_ref='dummy-JKT:RSA', + type=ComponentType.LIBRARY, + signature=JsfSimpleSignature( + algorithm=JsfAlgorithm.RS256, + value='AABBCC==', + public_key=JsfPublicKey(kty=JsfKeyType.RSA, n='sQ3MDBw==', e='AQAB'), + ), + ), + ]) + super()._test_cases_render(bom, of, sv) + + +@ddt +class TestEnumJsfEcCurve(_EnumTestCase): + + @idata(dp_cases_from_jsf_ec_crv()) + def test_knows_value(self, value: str) -> None: + super()._test_knows_value(JsfEcCurve, value) + + @named_data(*(d for d in NAMED_OF_SV if d[1] == OutputFormat.JSON and d[2] >= SchemaVersion.V1_4)) + def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, **__: Any) -> None: + bom = _make_bom( + components=[ + Component( + name=f'JsfEcCurve: {crv.name}', bom_ref=f'dummy-JEC:{crv.name}', + type=ComponentType.LIBRARY, + signature=JsfSimpleSignature( + algorithm=JsfAlgorithm.ES256, + value='AABBCC==', + public_key=JsfPublicKey(kty=JsfKeyType.EC, crv=crv, x='abc', y='def'), + ), + ) for crv in JsfEcCurve + ]) + super()._test_cases_render(bom, of, sv) + + +@ddt +class TestEnumJsfOkpCurve(_EnumTestCase): + + @idata(dp_cases_from_jsf_okp_crv()) + def test_knows_value(self, value: str) -> None: + super()._test_knows_value(JsfOkpCurve, value) + + @named_data(*(d for d in NAMED_OF_SV if d[1] == OutputFormat.JSON and d[2] >= SchemaVersion.V1_4)) + def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, **__: Any) -> None: + bom = _make_bom( + components=[ + Component( + name=f'JsfOkpCurve: {crv.name}', bom_ref=f'dummy-JOC:{crv.name}', + type=ComponentType.LIBRARY, + signature=JsfSimpleSignature( + algorithm=JsfAlgorithm.ED25519, + value='AABBCC==', + public_key=JsfPublicKey(kty=JsfKeyType.OKP, crv=crv, x='xyz'), + ), + ) for crv in JsfOkpCurve + ]) + super()._test_cases_render(bom, of, sv) + + # add new test cases above this line diff --git a/tests/test_model_signature.py b/tests/test_model_signature.py index 0e90d6a5f..45e28eae1 100644 --- a/tests/test_model_signature.py +++ b/tests/test_model_signature.py @@ -20,19 +20,22 @@ from cyclonedx.exception.model import InvalidValueException from cyclonedx.model.signature import ( JsfAlgorithm, + JsfEcCurve, JsfKeyType, + JsfOkpCurve, JsfPublicKey, JsfSignature, JsfSignatureChain, JsfSignatureSigners, JsfSimpleSignature, + _JsfSignatureSerializationHelper, ) class TestJsfPublicKey(TestCase): def test_ec_public_key(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') self.assertEqual(pk.kty, JsfKeyType.EC) self.assertEqual(pk.crv, 'P-256') self.assertEqual(pk.x, 'abc') @@ -41,7 +44,7 @@ def test_ec_public_key(self) -> None: self.assertIsNone(pk.e) def test_okp_public_key(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.OKP, crv='Ed25519', x='xyz') + pk = JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz') self.assertEqual(pk.kty, JsfKeyType.OKP) self.assertEqual(pk.crv, 'Ed25519') self.assertEqual(pk.x, 'xyz') @@ -59,22 +62,22 @@ def test_rsa_public_key(self) -> None: self.assertIsNone(pk.y) def test_equality(self) -> None: - pk1 = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') - pk2 = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + pk1 = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') + pk2 = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') pk3 = JsfPublicKey(kty=JsfKeyType.RSA, n='n', e='e') self.assertEqual(pk1, pk2) self.assertNotEqual(pk1, pk3) def test_hash_stable(self) -> None: - pk1 = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') - pk2 = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + pk1 = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') + pk2 = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') self.assertEqual(hash(pk1), hash(pk2)) def test_sorting(self) -> None: pks = [ JsfPublicKey(kty=JsfKeyType.RSA, n='n', e='e'), - JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def'), - JsfPublicKey(kty=JsfKeyType.OKP, crv='Ed25519', x='xyz'), + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def'), + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz'), ] sorted_pks = sorted(pks) self.assertEqual(len(sorted_pks), 3) @@ -86,12 +89,12 @@ def test_ec_validation_missing_crv(self) -> None: def test_ec_validation_missing_x(self) -> None: with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', y='def') + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, y='def') self.assertIn('EC', str(cm.exception)) def test_ec_validation_missing_y(self) -> None: with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc') + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc') self.assertIn('EC', str(cm.exception)) def test_okp_validation_missing_crv(self) -> None: @@ -101,7 +104,7 @@ def test_okp_validation_missing_crv(self) -> None: def test_okp_validation_missing_x(self) -> None: with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.OKP, crv='Ed25519') + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519) self.assertIn('OKP', str(cm.exception)) def test_rsa_validation_missing_n(self) -> None: @@ -115,10 +118,81 @@ def test_rsa_validation_missing_e(self) -> None: self.assertIn('RSA', str(cm.exception)) def test_repr(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') self.assertIn('JsfPublicKey', repr(pk)) self.assertIn('EC', repr(pk)) + def test_ec_crv_string_rejected(self) -> None: + with self.assertRaises(InvalidValueException): + JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') # type: ignore[arg-type] + + def test_okp_crv_string_rejected(self) -> None: + with self.assertRaises(InvalidValueException): + JsfPublicKey(kty=JsfKeyType.OKP, crv='Ed25519', x='xyz') # type: ignore[arg-type] + + def test_ec_crv_enum_accepted(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_384, x='abc', y='def') + self.assertEqual(pk.crv, JsfEcCurve.P_384) + + def test_okp_crv_enum_accepted(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED448, x='xyz') + self.assertEqual(pk.crv, JsfOkpCurve.ED448) + + def test_ec_crv_wrong_type_rejected(self) -> None: + with self.assertRaises(InvalidValueException): + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfOkpCurve.ED25519, x='abc', y='def') # type: ignore[arg-type] + + def test_okp_crv_wrong_type_rejected(self) -> None: + with self.assertRaises(InvalidValueException): + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfEcCurve.P_256, x='xyz') # type: ignore[arg-type] + + def test_ec_validation_with_n_rejected(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def', n='modulus') + self.assertIn('EC', str(cm.exception)) + + def test_ec_validation_with_e_rejected(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def', e='exponent') + self.assertIn('EC', str(cm.exception)) + + def test_okp_validation_with_y_rejected(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz', y='not-valid') + self.assertIn('OKP', str(cm.exception)) + + def test_okp_validation_with_n_rejected(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz', n='modulus') + self.assertIn('OKP', str(cm.exception)) + + def test_okp_validation_with_e_rejected(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz', e='exponent') + self.assertIn('OKP', str(cm.exception)) + + def test_rsa_validation_with_crv_rejected(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent', crv=JsfEcCurve.P_256) + self.assertIn('RSA', str(cm.exception)) + + def test_rsa_validation_with_x_rejected(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent', x='abc') + self.assertIn('RSA', str(cm.exception)) + + def test_rsa_validation_with_y_rejected(self) -> None: + with self.assertRaises(InvalidValueException) as cm: + JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent', y='def') + self.assertIn('RSA', str(cm.exception)) + + def test_crv_serialized_as_string_value(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_521, x='abc', y='def') + d = pk._as_dict() + self.assertEqual(d['crv'], 'P-521') + self.assertIsInstance(d['crv'], str) + self.assertNotIsInstance(d['crv'], JsfEcCurve) + class TestJsfSimpleSignature(TestCase): """Tests for JsfSimpleSignature (simple signature mode).""" @@ -133,7 +207,7 @@ def test_minimal(self) -> None: self.assertEqual(sig.excludes, []) def test_full(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') + pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') sig = JsfSimpleSignature( algorithm=JsfAlgorithm.ES256, value='sig-value', @@ -156,6 +230,27 @@ def test_algorithm_as_uri_string(self) -> None: self.assertIsInstance(sig.algorithm, str) self.assertNotIsInstance(sig.algorithm, JsfAlgorithm) + def test_proprietary_algorithm_non_uri_rejected(self) -> None: + with self.assertRaises(InvalidValueException): + JsfSimpleSignature(algorithm='not-a-uri', value='v') + + def test_proprietary_algorithm_urn_accepted(self) -> None: + sig = JsfSimpleSignature(algorithm='urn:example:my-algo', value='v') + self.assertEqual(sig.algorithm, 'urn:example:my-algo') + + def test_algorithm_enum_roundtrip(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='AABBCC==') + d = sig._as_dict() + self.assertEqual(d['algorithm'], 'RS256') + restored = JsfSimpleSignature._from_dict(d) + self.assertIsInstance(restored.algorithm, JsfAlgorithm) + self.assertEqual(restored.algorithm, JsfAlgorithm.RS256) + + def test_algorithm_serialized_as_value_string(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.ED25519, value='v') + d = sig._as_dict() + self.assertEqual(d['algorithm'], 'Ed25519') + def test_equality(self) -> None: sig1 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') sig2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') @@ -232,6 +327,10 @@ def test_repr(self) -> None: self.assertIn('JsfSignatureSigners', repr(sig)) self.assertIn('1', repr(sig)) + def test_empty_signers_rejected(self) -> None: + with self.assertRaises(InvalidValueException): + JsfSignatureSigners(signers=[]) + class TestJsfSignatureChain(TestCase): """Tests for JsfSignatureChain (signaturechain mode).""" @@ -278,6 +377,10 @@ def test_repr(self) -> None: self.assertIn('JsfSignatureChain', repr(sig)) self.assertIn('1', repr(sig)) + def test_empty_chain_rejected(self) -> None: + with self.assertRaises(InvalidValueException): + JsfSignatureChain(chain=[]) + class TestJsfSignatureBaseClass(TestCase): """Tests that JsfSignature acts as a proper abstract base class / type for isinstance checks.""" @@ -336,3 +439,60 @@ def test_enum_values(self) -> None: def test_all_key_types_count(self) -> None: self.assertEqual(len(JsfKeyType), 3) + + +class TestJsfEcCurve(TestCase): + + def test_enum_values(self) -> None: + self.assertEqual(JsfEcCurve.P_256.value, 'P-256') + self.assertEqual(JsfEcCurve.P_384.value, 'P-384') + self.assertEqual(JsfEcCurve.P_521.value, 'P-521') + + def test_all_curves_count(self) -> None: + self.assertEqual(len(JsfEcCurve), 3) + + +class TestJsfOkpCurve(TestCase): + + def test_enum_values(self) -> None: + self.assertEqual(JsfOkpCurve.ED25519.value, 'Ed25519') + self.assertEqual(JsfOkpCurve.ED448.value, 'Ed448') + + def test_all_curves_count(self) -> None: + self.assertEqual(len(JsfOkpCurve), 2) + + +class TestJsfSignatureXmlBehavior(TestCase): + """Verify that JSF signatures silently produce no XML output.""" + + def test_xml_normalize_returns_none(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='AABBCC==') + result = _JsfSignatureSerializationHelper.xml_normalize( + sig, element_name='signature', view=None, xmlns=None + ) + self.assertIsNone(result) + + def test_xml_denormalize_returns_none(self) -> None: + from xml.etree.ElementTree import Element # nosec B405 + result = _JsfSignatureSerializationHelper.xml_denormalize( + Element('signature'), default_ns=None + ) + self.assertIsNone(result) + + def test_xml_normalize_signers_returns_none(self) -> None: + sig = JsfSignatureSigners(signers=[ + JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='AABBCC==') + ]) + result = _JsfSignatureSerializationHelper.xml_normalize( + sig, element_name='signature', view=None, xmlns=None + ) + self.assertIsNone(result) + + def test_xml_normalize_chain_returns_none(self) -> None: + sig = JsfSignatureChain(chain=[ + JsfSimpleSignature(algorithm=JsfAlgorithm.ED25519, value='xyzSig==') + ]) + result = _JsfSignatureSerializationHelper.xml_normalize( + sig, element_name='signature', view=None, xmlns=None + ) + self.assertIsNone(result) From 9dc6c6367ec2728711753816c7affa431f508f46 Mon Sep 17 00:00:00 2001 From: Wiebe Vandendriessche Date: Thu, 3 Sep 2026 10:21:40 +0200 Subject: [PATCH 3/3] Refactor testing Signed-off-by: Wiebe Vandendriessche --- cyclonedx/model/signature.py | 20 + tests/test_model_signature.py | 903 +++++++++++++++++++--------------- 2 files changed, 529 insertions(+), 394 deletions(-) diff --git a/cyclonedx/model/signature.py b/cyclonedx/model/signature.py index c4d93bcf3..578bcb1ae 100644 --- a/cyclonedx/model/signature.py +++ b/cyclonedx/model/signature.py @@ -240,6 +240,16 @@ def __lt__(self, other: Any) -> bool: return self.__comparable_tuple() < other.__comparable_tuple() return NotImplemented + def __le__(self, other: Any) -> bool: + if isinstance(other, JsfPublicKey): + return self.__comparable_tuple() <= other.__comparable_tuple() + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, JsfPublicKey): + return self.__comparable_tuple() >= other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) @@ -288,6 +298,16 @@ def __lt__(self, other: Any) -> bool: return _JsfSignatureSerializationHelper._sort_key(self) < _JsfSignatureSerializationHelper._sort_key(other) return NotImplemented + def __le__(self, other: Any) -> bool: + if isinstance(other, JsfSignature): + return _JsfSignatureSerializationHelper._sort_key(self) <= _JsfSignatureSerializationHelper._sort_key(other) + return NotImplemented + + def __ge__(self, other: Any) -> bool: + if isinstance(other, JsfSignature): + return _JsfSignatureSerializationHelper._sort_key(self) >= _JsfSignatureSerializationHelper._sort_key(other) + return NotImplemented + def __hash__(self) -> int: return hash(_JsfSignatureSerializationHelper._sort_key(self)) diff --git a/tests/test_model_signature.py b/tests/test_model_signature.py index 45e28eae1..ce916c4fb 100644 --- a/tests/test_model_signature.py +++ b/tests/test_model_signature.py @@ -24,475 +24,590 @@ JsfKeyType, JsfOkpCurve, JsfPublicKey, - JsfSignature, JsfSignatureChain, JsfSignatureSigners, JsfSimpleSignature, - _JsfSignatureSerializationHelper, ) +from tests import reorder + + +class TestJsfAlgorithm(TestCase): + def test_enum_value(self) -> None: + self.assertEqual(JsfAlgorithm.RS256.value, 'RS256') + self.assertEqual(JsfAlgorithm.RS384.value, 'RS384') + self.assertEqual(JsfAlgorithm.RS512.value, 'RS512') + self.assertEqual(JsfAlgorithm.PS256.value, 'PS256') + self.assertEqual(JsfAlgorithm.PS384.value, 'PS384') + self.assertEqual(JsfAlgorithm.PS512.value, 'PS512') + self.assertEqual(JsfAlgorithm.ES256.value, 'ES256') + self.assertEqual(JsfAlgorithm.ES384.value, 'ES384') + self.assertEqual(JsfAlgorithm.ES512.value, 'ES512') + self.assertEqual(JsfAlgorithm.ED25519.value, 'Ed25519') + self.assertEqual(JsfAlgorithm.ED448.value, 'Ed448') + self.assertEqual(JsfAlgorithm.HS256.value, 'HS256') + self.assertEqual(JsfAlgorithm.HS384.value, 'HS384') + self.assertEqual(JsfAlgorithm.HS512.value, 'HS512') + + def test_enum_comparison(self) -> None: + self.assertEqual(JsfAlgorithm.RS256, JsfAlgorithm('RS256')) + self.assertEqual(JsfAlgorithm.RS384, JsfAlgorithm('RS384')) + self.assertEqual(JsfAlgorithm.RS512, JsfAlgorithm('RS512')) + self.assertEqual(JsfAlgorithm.PS256, JsfAlgorithm('PS256')) + self.assertEqual(JsfAlgorithm.PS384, JsfAlgorithm('PS384')) + self.assertEqual(JsfAlgorithm.PS512, JsfAlgorithm('PS512')) + self.assertEqual(JsfAlgorithm.ES256, JsfAlgorithm('ES256')) + self.assertEqual(JsfAlgorithm.ES384, JsfAlgorithm('ES384')) + self.assertEqual(JsfAlgorithm.ES512, JsfAlgorithm('ES512')) + self.assertEqual(JsfAlgorithm.ED25519, JsfAlgorithm('Ed25519')) + self.assertEqual(JsfAlgorithm.ED448, JsfAlgorithm('Ed448')) + self.assertEqual(JsfAlgorithm.HS256, JsfAlgorithm('HS256')) + self.assertEqual(JsfAlgorithm.HS384, JsfAlgorithm('HS384')) + self.assertEqual(JsfAlgorithm.HS512, JsfAlgorithm('HS512')) + + +class TestJsfKeyType(TestCase): + def test_enum_value(self) -> None: + self.assertEqual(JsfKeyType.EC.value, 'EC') + self.assertEqual(JsfKeyType.OKP.value, 'OKP') + self.assertEqual(JsfKeyType.RSA.value, 'RSA') + + def test_enum_comparison(self) -> None: + self.assertEqual(JsfKeyType.EC, JsfKeyType('EC')) + self.assertEqual(JsfKeyType.OKP, JsfKeyType('OKP')) + self.assertEqual(JsfKeyType.RSA, JsfKeyType('RSA')) + + +class TestJsfEcCurve(TestCase): + def test_enum_value(self) -> None: + self.assertEqual(JsfEcCurve.P_256.value, 'P-256') + self.assertEqual(JsfEcCurve.P_384.value, 'P-384') + self.assertEqual(JsfEcCurve.P_521.value, 'P-521') + + def test_enum_comparison(self) -> None: + self.assertEqual(JsfEcCurve.P_256, JsfEcCurve('P-256')) + self.assertEqual(JsfEcCurve.P_384, JsfEcCurve('P-384')) + self.assertEqual(JsfEcCurve.P_521, JsfEcCurve('P-521')) + + +class TestJsfOkpCurve(TestCase): + def test_enum_value(self) -> None: + self.assertEqual(JsfOkpCurve.ED25519.value, 'Ed25519') + self.assertEqual(JsfOkpCurve.ED448.value, 'Ed448') + + def test_enum_comparison(self) -> None: + self.assertEqual(JsfOkpCurve.ED25519, JsfOkpCurve('Ed25519')) + self.assertEqual(JsfOkpCurve.ED448, JsfOkpCurve('Ed448')) class TestJsfPublicKey(TestCase): - def test_ec_public_key(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') - self.assertEqual(pk.kty, JsfKeyType.EC) - self.assertEqual(pk.crv, 'P-256') - self.assertEqual(pk.x, 'abc') - self.assertEqual(pk.y, 'def') - self.assertIsNone(pk.n) - self.assertIsNone(pk.e) - - def test_okp_public_key(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz') - self.assertEqual(pk.kty, JsfKeyType.OKP) - self.assertEqual(pk.crv, 'Ed25519') - self.assertEqual(pk.x, 'xyz') - self.assertIsNone(pk.y) - self.assertIsNone(pk.n) - self.assertIsNone(pk.e) - - def test_rsa_public_key(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') - self.assertEqual(pk.kty, JsfKeyType.RSA) - self.assertEqual(pk.n, 'modulus') - self.assertEqual(pk.e, 'exponent') - self.assertIsNone(pk.crv) - self.assertIsNone(pk.x) - self.assertIsNone(pk.y) - - def test_equality(self) -> None: - pk1 = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') - pk2 = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') - pk3 = JsfPublicKey(kty=JsfKeyType.RSA, n='n', e='e') - self.assertEqual(pk1, pk2) - self.assertNotEqual(pk1, pk3) - - def test_hash_stable(self) -> None: - pk1 = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') - pk2 = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') - self.assertEqual(hash(pk1), hash(pk2)) - - def test_sorting(self) -> None: - pks = [ - JsfPublicKey(kty=JsfKeyType.RSA, n='n', e='e'), - JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def'), - JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz'), - ] - sorted_pks = sorted(pks) - self.assertEqual(len(sorted_pks), 3) - - def test_ec_validation_missing_crv(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.EC, x='abc', y='def') - self.assertIn('EC', str(cm.exception)) - - def test_ec_validation_missing_x(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, y='def') - self.assertIn('EC', str(cm.exception)) - - def test_ec_validation_missing_y(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc') - self.assertIn('EC', str(cm.exception)) - - def test_okp_validation_missing_crv(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.OKP, x='xyz') - self.assertIn('OKP', str(cm.exception)) - - def test_okp_validation_missing_x(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519) - self.assertIn('OKP', str(cm.exception)) - - def test_rsa_validation_missing_n(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.RSA, e='exponent') - self.assertIn('RSA', str(cm.exception)) - - def test_rsa_validation_missing_e(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.RSA, n='modulus') - self.assertIn('RSA', str(cm.exception)) + # ------------------------------------------------------------------------- + # Constructor & Property Tests (Valid Configurations) + # ------------------------------------------------------------------------- - def test_repr(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') - self.assertIn('JsfPublicKey', repr(pk)) - self.assertIn('EC', repr(pk)) + def test_constructor_ec(self) -> None: + obj = JsfPublicKey( + kty=JsfKeyType.EC, + crv=JsfEcCurve.P_256, + x='foo', + y='bar' + ) + self.assertEqual(obj.kty, JsfKeyType.EC) + self.assertEqual(obj.crv, JsfEcCurve.P_256) + self.assertEqual(obj.x, 'foo') + self.assertEqual(obj.y, 'bar') + self.assertIsNone(obj.n) + self.assertIsNone(obj.e) + + def test_constructor_okp(self) -> None: + obj = JsfPublicKey( + kty=JsfKeyType.OKP, + crv=JsfOkpCurve.ED25519, + x='foo' + ) + self.assertEqual(obj.kty, JsfKeyType.OKP) + self.assertEqual(obj.crv, JsfOkpCurve.ED25519) + self.assertEqual(obj.x, 'foo') + self.assertIsNone(obj.y) + self.assertIsNone(obj.n) + self.assertIsNone(obj.e) + + def test_constructor_rsa(self) -> None: + obj = JsfPublicKey( + kty=JsfKeyType.RSA, + n='modulus', + e='exponent' + ) + self.assertEqual(obj.kty, JsfKeyType.RSA) + self.assertIsNone(obj.crv) + self.assertIsNone(obj.x) + self.assertIsNone(obj.y) + self.assertEqual(obj.n, 'modulus') + self.assertEqual(obj.e, 'exponent') + + def test_property_setters(self) -> None: + obj = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') + obj.n = 'new_modulus' + self.assertEqual(obj.n, 'new_modulus') + + # ------------------------------------------------------------------------- + # Exhaustive Conditional Validation Tests (Every Permutation) + # ------------------------------------------------------------------------- + + # --- EC Key Type Validations --- + + def test_validation_ec_missing_crv(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.EC, crv=None, x='foo', y='bar') + self.assertEqual(str(context.exception), 'EC public key requires crv, x, and y') + + def test_validation_ec_missing_x(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x=None, y='bar') + self.assertEqual(str(context.exception), 'EC public key requires crv, x, and y') + + def test_validation_ec_missing_y(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='foo', y=None) + self.assertEqual(str(context.exception), 'EC public key requires crv, x, and y') + + def test_validation_ec_invalid_curve_type(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfOkpCurve.ED25519, x='foo', y='bar') + self.assertEqual( + str(context.exception), + "EC public key crv must be a JsfEcCurve instance, got 'JsfOkpCurve'" + ) - def test_ec_crv_string_rejected(self) -> None: + def test_validation_ec_prohibits_n(self) -> None: with self.assertRaises(InvalidValueException): - JsfPublicKey(kty=JsfKeyType.EC, crv='P-256', x='abc', y='def') # type: ignore[arg-type] + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='foo', y='bar', n='modulus') - def test_okp_crv_string_rejected(self) -> None: + def test_validation_ec_prohibits_e(self) -> None: with self.assertRaises(InvalidValueException): - JsfPublicKey(kty=JsfKeyType.OKP, crv='Ed25519', x='xyz') # type: ignore[arg-type] + JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='foo', y='bar', e='exponent') + + # --- OKP Key Type Validations --- + + def test_validation_okp_missing_crv(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.OKP, crv=None, x='foo') + self.assertEqual(str(context.exception), 'OKP public key requires crv and x') + + def test_validation_okp_missing_x(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x=None) + self.assertEqual(str(context.exception), 'OKP public key requires crv and x') + + def test_validation_okp_invalid_curve_type(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfEcCurve.P_256, x='foo') + self.assertEqual( + str(context.exception), + "OKP public key crv must be a JsfOkpCurve instance, got 'JsfEcCurve'" + ) - def test_ec_crv_enum_accepted(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_384, x='abc', y='def') - self.assertEqual(pk.crv, JsfEcCurve.P_384) + def test_validation_okp_prohibits_y(self) -> None: + with self.assertRaises(InvalidValueException): + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='foo', y='bar') - def test_okp_crv_enum_accepted(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED448, x='xyz') - self.assertEqual(pk.crv, JsfOkpCurve.ED448) + def test_validation_okp_prohibits_n(self) -> None: + with self.assertRaises(InvalidValueException): + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='foo', n='modulus') - def test_ec_crv_wrong_type_rejected(self) -> None: + def test_validation_okp_prohibits_e(self) -> None: with self.assertRaises(InvalidValueException): - JsfPublicKey(kty=JsfKeyType.EC, crv=JsfOkpCurve.ED25519, x='abc', y='def') # type: ignore[arg-type] + JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='foo', e='exponent') + + # --- RSA Key Type Validations --- + + def test_validation_rsa_missing_n(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.RSA, n=None, e='exponent') + self.assertEqual(str(context.exception), 'RSA public key requires n and e') + + def test_validation_rsa_missing_e(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e=None) + self.assertEqual(str(context.exception), 'RSA public key requires n and e') - def test_okp_crv_wrong_type_rejected(self) -> None: + def test_validation_rsa_prohibits_crv(self) -> None: with self.assertRaises(InvalidValueException): - JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfEcCurve.P_256, x='xyz') # type: ignore[arg-type] - - def test_ec_validation_with_n_rejected(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def', n='modulus') - self.assertIn('EC', str(cm.exception)) - - def test_ec_validation_with_e_rejected(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def', e='exponent') - self.assertIn('EC', str(cm.exception)) - - def test_okp_validation_with_y_rejected(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz', y='not-valid') - self.assertIn('OKP', str(cm.exception)) - - def test_okp_validation_with_n_rejected(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz', n='modulus') - self.assertIn('OKP', str(cm.exception)) - - def test_okp_validation_with_e_rejected(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.OKP, crv=JsfOkpCurve.ED25519, x='xyz', e='exponent') - self.assertIn('OKP', str(cm.exception)) - - def test_rsa_validation_with_crv_rejected(self) -> None: - with self.assertRaises(InvalidValueException) as cm: JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent', crv=JsfEcCurve.P_256) - self.assertIn('RSA', str(cm.exception)) - def test_rsa_validation_with_x_rejected(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent', x='abc') - self.assertIn('RSA', str(cm.exception)) + def test_validation_rsa_prohibits_x(self) -> None: + with self.assertRaises(InvalidValueException): + JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent', x='foo') + + def test_validation_rsa_prohibits_y(self) -> None: + with self.assertRaises(InvalidValueException): + JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent', y='bar') - def test_rsa_validation_with_y_rejected(self) -> None: - with self.assertRaises(InvalidValueException) as cm: - JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent', y='def') - self.assertIn('RSA', str(cm.exception)) + # ------------------------------------------------------------------------- + # Equality, Comparison, Hash & Sort Tests + # ------------------------------------------------------------------------- - def test_crv_serialized_as_string_value(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_521, x='abc', y='def') - d = pk._as_dict() - self.assertEqual(d['crv'], 'P-521') - self.assertIsInstance(d['crv'], str) - self.assertNotIsInstance(d['crv'], JsfEcCurve) + def test_same(self) -> None: + key_1 = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') + key_2 = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') + + self.assertNotEqual(id(key_1), id(key_2)) + self.assertEqual(hash(key_1), hash(key_2)) + self.assertTrue(key_1 == key_2) + + def test_not_same(self) -> None: + key_1 = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus1', e='exponent') + key_2 = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus2', e='exponent') + + self.assertNotEqual(hash(key_1), hash(key_2)) + self.assertFalse(key_1 == key_2) + + def test_compare_same_type(self) -> None: + key_1 = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') + key_2 = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') + + self.assertFalse(key_1 < key_2) + self.assertTrue(key_1 <= key_2) + self.assertTrue(key_1 >= key_2) + + def test_comparison(self) -> None: + key_1 = JsfPublicKey(kty=JsfKeyType.RSA, n='a', e='exponent') + key_2 = JsfPublicKey(kty=JsfKeyType.RSA, n='b', e='exponent') + + self.assertTrue(key_1 < key_2) + self.assertTrue(key_1 <= key_2) + self.assertTrue(key_2 >= key_1) + + def test_sort(self) -> None: + expected_order = [2, 1, 0] + keys = [ + JsfPublicKey(kty=JsfKeyType.RSA, n='c', e='exponent'), + JsfPublicKey(kty=JsfKeyType.RSA, n='b', e='exponent'), + JsfPublicKey(kty=JsfKeyType.RSA, n='a', e='exponent'), + ] + expected_keys = reorder(keys, expected_order) + sorted_keys = sorted(keys) + self.assertListEqual(sorted_keys, expected_keys) + + def test_repr(self) -> None: + obj = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') + self.assertIn('JsfPublicKey', repr(obj)) class TestJsfSimpleSignature(TestCase): - """Tests for JsfSimpleSignature (simple signature mode).""" - def test_minimal(self) -> None: - sig = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='sig-value') - self.assertEqual(sig.algorithm, JsfAlgorithm.ES256) - self.assertEqual(sig.value, 'sig-value') + def test_defaults(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + self.assertIs(JsfAlgorithm.RS256, sig.algorithm) + self.assertEqual(sig.value, 'val') self.assertIsNone(sig.key_id) self.assertIsNone(sig.public_key) self.assertEqual(sig.certificate_path, []) self.assertEqual(sig.excludes, []) - def test_full(self) -> None: - pk = JsfPublicKey(kty=JsfKeyType.EC, crv=JsfEcCurve.P_256, x='abc', y='def') + def test_constructor(self) -> None: + pk = JsfPublicKey(kty=JsfKeyType.RSA, n='modulus', e='exponent') sig = JsfSimpleSignature( algorithm=JsfAlgorithm.ES256, - value='sig-value', + value='val', key_id='my-key', public_key=pk, - certificate_path=['cert-pem'], + certificate_path=['cert-a', 'cert-b'], excludes=['field1', 'field2'], ) - self.assertEqual(sig.public_key, pk) + self.assertIs(JsfAlgorithm.ES256, sig.algorithm) + self.assertEqual(sig.value, 'val') self.assertEqual(sig.key_id, 'my-key') - self.assertEqual(sig.certificate_path, ['cert-pem']) + self.assertEqual(sig.public_key, pk) + # certificate_path/excludes are ordered lists -- order is preserved, not sorted + self.assertEqual(sig.certificate_path, ['cert-a', 'cert-b']) self.assertEqual(sig.excludes, ['field1', 'field2']) - def test_algorithm_as_enum(self) -> None: - sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - self.assertIsInstance(sig.algorithm, JsfAlgorithm) - - def test_algorithm_as_uri_string(self) -> None: - sig = JsfSimpleSignature(algorithm='https://example.com/algo', value='v') - self.assertIsInstance(sig.algorithm, str) - self.assertNotIsInstance(sig.algorithm, JsfAlgorithm) - - def test_proprietary_algorithm_non_uri_rejected(self) -> None: - with self.assertRaises(InvalidValueException): - JsfSimpleSignature(algorithm='not-a-uri', value='v') - - def test_proprietary_algorithm_urn_accepted(self) -> None: - sig = JsfSimpleSignature(algorithm='urn:example:my-algo', value='v') - self.assertEqual(sig.algorithm, 'urn:example:my-algo') - - def test_algorithm_enum_roundtrip(self) -> None: - sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='AABBCC==') - d = sig._as_dict() - self.assertEqual(d['algorithm'], 'RS256') - restored = JsfSimpleSignature._from_dict(d) - self.assertIsInstance(restored.algorithm, JsfAlgorithm) - self.assertEqual(restored.algorithm, JsfAlgorithm.RS256) - - def test_algorithm_serialized_as_value_string(self) -> None: - sig = JsfSimpleSignature(algorithm=JsfAlgorithm.ED25519, value='v') - d = sig._as_dict() - self.assertEqual(d['algorithm'], 'Ed25519') - - def test_equality(self) -> None: - sig1 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') - sig2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') - sig3 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - self.assertEqual(sig1, sig2) - self.assertNotEqual(sig1, sig3) - - def test_not_equal_to_other_modes(self) -> None: - simple = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') - multi = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v')]) - self.assertNotEqual(simple, multi) - - def test_hash_stable(self) -> None: - sig1 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') - sig2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') - self.assertEqual(hash(sig1), hash(sig2)) - - def test_sorting(self) -> None: + def test_create(self) -> None: + sig_rs = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val1') + sig_proprietary = JsfSimpleSignature(algorithm='urn:ietf:rfc:8032', value='val2') + self.assertIs(JsfAlgorithm.RS256, sig_rs.algorithm) + # a proprietary algorithm URI that does not match a known JsfAlgorithm is kept as-is + self.assertEqual('urn:ietf:rfc:8032', sig_proprietary.algorithm) + + def test_update(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + self.assertIs(JsfAlgorithm.RS256, sig.algorithm) + sig.algorithm = JsfOkpCurve.ED25519 + self.assertIs(JsfOkpCurve.ED25519, sig.algorithm) + + def test_sort(self) -> None: + expected_order = [1, 0] sigs = [ JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b'), - JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='a'), + JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a'), ] + expected_sigs = reorder(sigs, expected_order) sorted_sigs = sorted(sigs) - self.assertEqual(len(sorted_sigs), 2) + self.assertListEqual(sorted_sigs, expected_sigs) + + def test_no_params(self) -> None: + with self.assertRaises(TypeError): + JsfSimpleSignature() + + def test_validation_proprietary_algorithm_requires_uri(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfSimpleSignature(algorithm='not-a-uri', value='val') + self.assertEqual( + str(context.exception), + "Proprietary JSF algorithm must be expressed as a URI, got 'not-a-uri'" + ) - def test_repr(self) -> None: - sig = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') - self.assertIn('JsfSimpleSignature', repr(sig)) - self.assertIn('ES256', repr(sig)) + def test_same(self) -> None: + sig_1 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + sig_2 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + self.assertNotEqual(id(sig_1), id(sig_2)) + self.assertEqual(hash(sig_1), hash(sig_2)) + self.assertTrue(sig_1 == sig_2) -class TestJsfSignatureSigners(TestCase): - """Tests for JsfSignatureSigners (multisignature mode).""" - - def test_minimal(self) -> None: - signer = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v1') - sig = JsfSignatureSigners(signers=[signer]) - self.assertEqual(len(sig.signers), 1) - self.assertEqual(sig.signers[0], signer) - - def test_multiple_signers(self) -> None: - s1 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v1') - s2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v2') - sig = JsfSignatureSigners(signers=[s1, s2]) - self.assertEqual(len(sig.signers), 2) - - def test_is_jsfsignature_subclass(self) -> None: - sig = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]) - self.assertIsInstance(sig, JsfSignature) - - def test_equality(self) -> None: - s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - sig1 = JsfSignatureSigners(signers=[s]) - sig2 = JsfSignatureSigners(signers=[s]) - sig3 = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='x')]) - self.assertEqual(sig1, sig2) - self.assertNotEqual(sig1, sig3) - - def test_not_equal_to_chain(self) -> None: - s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - multi = JsfSignatureSigners(signers=[s]) - chain = JsfSignatureChain(chain=[s]) - self.assertNotEqual(multi, chain) - - def test_hash_stable(self) -> None: - s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - sig1 = JsfSignatureSigners(signers=[s]) - sig2 = JsfSignatureSigners(signers=[s]) - self.assertEqual(hash(sig1), hash(sig2)) + def test_not_same(self) -> None: + sig_1 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a') + sig_2 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b') - def test_repr(self) -> None: - s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - sig = JsfSignatureSigners(signers=[s]) - self.assertIn('JsfSignatureSigners', repr(sig)) - self.assertIn('1', repr(sig)) - - def test_empty_signers_rejected(self) -> None: - with self.assertRaises(InvalidValueException): - JsfSignatureSigners(signers=[]) + self.assertNotEqual(hash(sig_1), hash(sig_2)) + self.assertFalse(sig_1 == sig_2) + def test_unequal_different_type(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + self.assertFalse(sig == 'other') -class TestJsfSignatureChain(TestCase): - """Tests for JsfSignatureChain (signaturechain mode).""" - - def test_minimal(self) -> None: - signer = JsfSimpleSignature(algorithm=JsfAlgorithm.ED25519, value='xyzSig==') - sig = JsfSignatureChain(chain=[signer]) - self.assertEqual(len(sig.chain), 1) - self.assertEqual(sig.chain[0], signer) - - def test_multiple_in_chain(self) -> None: - s1 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v1') - s2 = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v2') - sig = JsfSignatureChain(chain=[s1, s2]) - self.assertEqual(len(sig.chain), 2) - - def test_is_jsfsignature_subclass(self) -> None: - sig = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]) - self.assertIsInstance(sig, JsfSignature) - - def test_equality(self) -> None: - s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - sig1 = JsfSignatureChain(chain=[s]) - sig2 = JsfSignatureChain(chain=[s]) - sig3 = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='x')]) - self.assertEqual(sig1, sig2) - self.assertNotEqual(sig1, sig3) - - def test_not_equal_to_multi(self) -> None: - s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - chain = JsfSignatureChain(chain=[s]) - multi = JsfSignatureSigners(signers=[s]) - self.assertNotEqual(chain, multi) - - def test_hash_stable(self) -> None: - s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - sig1 = JsfSignatureChain(chain=[s]) - sig2 = JsfSignatureChain(chain=[s]) - self.assertEqual(hash(sig1), hash(sig2)) + def test_compare_same_type(self) -> None: + sig_1 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + sig_2 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') - def test_repr(self) -> None: - s = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v') - sig = JsfSignatureChain(chain=[s]) - self.assertIn('JsfSignatureChain', repr(sig)) - self.assertIn('1', repr(sig)) - - def test_empty_chain_rejected(self) -> None: - with self.assertRaises(InvalidValueException): - JsfSignatureChain(chain=[]) + self.assertFalse(sig_1 < sig_2) + self.assertTrue(sig_1 <= sig_2) + self.assertTrue(sig_1 >= sig_2) + def test_comparison(self) -> None: + sig_1 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a') + sig_2 = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b') -class TestJsfSignatureBaseClass(TestCase): - """Tests that JsfSignature acts as a proper abstract base class / type for isinstance checks.""" + self.assertTrue(sig_1 < sig_2) + self.assertTrue(sig_1 <= sig_2) + self.assertTrue(sig_2 >= sig_1) - def test_simple_is_jsfsignature(self) -> None: - self.assertIsInstance( - JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v'), - JsfSignature, - ) - - def test_signers_is_jsfsignature(self) -> None: - self.assertIsInstance( - JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]), - JsfSignature, + def test_repr(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + self.assertIn( + repr(sig), + { + '', + '', + ">" + } ) - def test_chain_is_jsfsignature(self) -> None: - self.assertIsInstance( - JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]), - JsfSignature, - ) - def test_modes_are_distinct_types(self) -> None: - simple = JsfSimpleSignature(algorithm=JsfAlgorithm.ES256, value='v') - multi = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]) - chain = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='v')]) - # All are JsfSignature instances - self.assertIsInstance(simple, JsfSignature) - self.assertIsInstance(multi, JsfSignature) - self.assertIsInstance(chain, JsfSignature) - # But they are different types - self.assertEqual(type(simple).__name__, 'JsfSimpleSignature') - self.assertEqual(type(multi).__name__, 'JsfSignatureSigners') - self.assertEqual(type(chain).__name__, 'JsfSignatureChain') +class TestJsfSignatureSigners(TestCase): + def test_no_params(self) -> None: + with self.assertRaises(TypeError): + JsfSignatureSigners() -class TestJsfAlgorithm(TestCase): + def test_validation_requires_at_least_one_signer(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfSignatureSigners(signers=[]) + self.assertEqual(str(context.exception), 'JsfSignatureSigners requires at least one signer') - def test_enum_values(self) -> None: - self.assertEqual(JsfAlgorithm.RS256.value, 'RS256') - self.assertEqual(JsfAlgorithm.ES256.value, 'ES256') - self.assertEqual(JsfAlgorithm.ED25519.value, 'Ed25519') - self.assertEqual(JsfAlgorithm.ED448.value, 'Ed448') - self.assertEqual(JsfAlgorithm.HS512.value, 'HS512') + def test_defaults(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + obj = JsfSignatureSigners(signers=[sig]) + self.assertEqual(list(obj.signers), [sig]) - def test_all_algorithms_count(self) -> None: - self.assertEqual(len(JsfAlgorithm), 14) + def test_constructor(self) -> None: + sigs = [ + JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b'), + JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a'), + ] + obj = JsfSignatureSigners(signers=sigs) + # signers is an ordered list -- input order is preserved, not sorted + self.assertEqual(list(obj.signers), sigs) + + def test_sort(self) -> None: + expected_order = [1, 0] + objs = [ + JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b')]), + JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a')]), + ] + expected_objs = reorder(objs, expected_order) + sorted_objs = sorted(objs) + self.assertListEqual(sorted_objs, expected_objs) + + def test_same(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + obj_1 = JsfSignatureSigners(signers=[sig]) + obj_2 = JsfSignatureSigners(signers=[sig]) + + self.assertNotEqual(id(obj_1), id(obj_2)) + self.assertEqual(hash(obj_1), hash(obj_2)) + self.assertTrue(obj_1 == obj_2) + + def test_not_same(self) -> None: + obj_1 = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a')]) + obj_2 = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b')]) + + self.assertNotEqual(hash(obj_1), hash(obj_2)) + self.assertFalse(obj_1 == obj_2) + + def test_unequal_different_type(self) -> None: + obj = JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val')]) + self.assertFalse(obj == 'other') + + def test_compare_same_type(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + obj_1 = JsfSignatureSigners(signers=[sig]) + obj_2 = JsfSignatureSigners(signers=[sig]) + + self.assertFalse(obj_1 < obj_2) + self.assertTrue(obj_1 <= obj_2) + self.assertTrue(obj_1 >= obj_2) + + def test_comparison(self) -> None: + sig_a = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a') + sig_b = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b') + obj_1 = JsfSignatureSigners(signers=[sig_a]) + obj_2 = JsfSignatureSigners(signers=[sig_b]) + + self.assertTrue(obj_1 < obj_2) + self.assertTrue(obj_1 <= obj_2) + self.assertTrue(obj_2 >= obj_1) + + def test_hash(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + self.assertEqual( + hash(JsfSignatureSigners(signers=[sig])), + hash(JsfSignatureSigners(signers=[sig])), + ) + def test_repr(self) -> None: + self.assertIn( + 'JsfSignatureSigners', + repr(JsfSignatureSigners(signers=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val')])), + ) -class TestJsfKeyType(TestCase): - def test_enum_values(self) -> None: - self.assertEqual(JsfKeyType.EC.value, 'EC') - self.assertEqual(JsfKeyType.OKP.value, 'OKP') - self.assertEqual(JsfKeyType.RSA.value, 'RSA') +class TestJsfSignatureChain(TestCase): - def test_all_key_types_count(self) -> None: - self.assertEqual(len(JsfKeyType), 3) + def test_no_params(self) -> None: + with self.assertRaises(TypeError): + JsfSignatureChain() + def test_validation_requires_at_least_one_element(self) -> None: + with self.assertRaises(InvalidValueException) as context: + JsfSignatureChain(chain=[]) + self.assertEqual(str(context.exception), 'JsfSignatureChain requires at least one element') -class TestJsfEcCurve(TestCase): + def test_defaults(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + obj = JsfSignatureChain(chain=[sig]) + self.assertEqual(list(obj.chain), [sig]) - def test_enum_values(self) -> None: - self.assertEqual(JsfEcCurve.P_256.value, 'P-256') - self.assertEqual(JsfEcCurve.P_384.value, 'P-384') - self.assertEqual(JsfEcCurve.P_521.value, 'P-521') + def test_constructor(self) -> None: + sigs = [ + JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b'), + JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a'), + ] + obj = JsfSignatureChain(chain=sigs) + # chain is an ordered list -- per the JSF spec the first element must be the signature + # certificate and the chain must stay contiguous, so input order is preserved, not sorted + self.assertEqual(list(obj.chain), sigs) + + def test_sort(self) -> None: + expected_order = [1, 0] + objs = [ + JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b')]), + JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a')]), + ] + expected_objs = reorder(objs, expected_order) + sorted_objs = sorted(objs) + self.assertListEqual(sorted_objs, expected_objs) + + def test_same(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + obj_1 = JsfSignatureChain(chain=[sig]) + obj_2 = JsfSignatureChain(chain=[sig]) + + self.assertNotEqual(id(obj_1), id(obj_2)) + self.assertEqual(hash(obj_1), hash(obj_2)) + self.assertTrue(obj_1 == obj_2) + + def test_not_same(self) -> None: + obj_1 = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a')]) + obj_2 = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b')]) + + self.assertNotEqual(hash(obj_1), hash(obj_2)) + self.assertFalse(obj_1 == obj_2) + + def test_unequal_different_type(self) -> None: + obj = JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val')]) + self.assertFalse(obj == 'other') + + def test_compare_same_type(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + obj_1 = JsfSignatureChain(chain=[sig]) + obj_2 = JsfSignatureChain(chain=[sig]) + + self.assertFalse(obj_1 < obj_2) + self.assertTrue(obj_1 <= obj_2) + self.assertTrue(obj_1 >= obj_2) + + def test_comparison(self) -> None: + sig_a = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='a') + sig_b = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='b') + obj_1 = JsfSignatureChain(chain=[sig_a]) + obj_2 = JsfSignatureChain(chain=[sig_b]) + + self.assertTrue(obj_1 < obj_2) + self.assertTrue(obj_1 <= obj_2) + self.assertTrue(obj_2 >= obj_1) + + def test_hash(self) -> None: + sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + self.assertEqual( + hash(JsfSignatureChain(chain=[sig])), + hash(JsfSignatureChain(chain=[sig])), + ) - def test_all_curves_count(self) -> None: - self.assertEqual(len(JsfEcCurve), 3) + def test_repr(self) -> None: + self.assertIn( + 'JsfSignatureChain', + repr(JsfSignatureChain(chain=[JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val')])), + ) -class TestJsfOkpCurve(TestCase): +class TestJsfSignaturePolymorphism(TestCase): + """Tests for equality/ordering across the three JsfSignature subtypes via the shared base class.""" - def test_enum_values(self) -> None: - self.assertEqual(JsfOkpCurve.ED25519.value, 'Ed25519') - self.assertEqual(JsfOkpCurve.ED448.value, 'Ed448') + def test_different_subtypes_are_not_equal(self) -> None: + simple = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + signers = JsfSignatureSigners(signers=[simple]) + chain = JsfSignatureChain(chain=[simple]) - def test_all_curves_count(self) -> None: - self.assertEqual(len(JsfOkpCurve), 2) + self.assertFalse(simple == signers) + self.assertFalse(signers == chain) + self.assertFalse(chain == simple) + def test_sortable_across_subtypes(self) -> None: + # This should not raise TypeError: '<' not supported between instances + simple = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + signers = JsfSignatureSigners(signers=[simple]) + chain = JsfSignatureChain(chain=[simple]) -class TestJsfSignatureXmlBehavior(TestCase): - """Verify that JSF signatures silently produce no XML output.""" + sorted_sigs = sorted([chain, simple, signers]) + self.assertEqual(len(sorted_sigs), 3) - def test_xml_normalize_returns_none(self) -> None: - sig = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='AABBCC==') - result = _JsfSignatureSerializationHelper.xml_normalize( - sig, element_name='signature', view=None, xmlns=None - ) - self.assertIsNone(result) + def test_unequal_different_type(self) -> None: + simple = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + self.assertFalse(simple == 'other') - def test_xml_denormalize_returns_none(self) -> None: - from xml.etree.ElementTree import Element # nosec B405 - result = _JsfSignatureSerializationHelper.xml_denormalize( - Element('signature'), default_ns=None - ) - self.assertIsNone(result) - - def test_xml_normalize_signers_returns_none(self) -> None: - sig = JsfSignatureSigners(signers=[ - JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='AABBCC==') - ]) - result = _JsfSignatureSerializationHelper.xml_normalize( - sig, element_name='signature', view=None, xmlns=None - ) - self.assertIsNone(result) - - def test_xml_normalize_chain_returns_none(self) -> None: - sig = JsfSignatureChain(chain=[ - JsfSimpleSignature(algorithm=JsfAlgorithm.ED25519, value='xyzSig==') - ]) - result = _JsfSignatureSerializationHelper.xml_normalize( - sig, element_name='signature', view=None, xmlns=None - ) - self.assertIsNone(result) + def test_incomparable_type_raises(self) -> None: + simple = JsfSimpleSignature(algorithm=JsfAlgorithm.RS256, value='val') + with self.assertRaises(TypeError): + r = simple < 'other' # pylint: disable=unused-variable # noqa: disable=E841