From 77a154f394aa333b555b3ce3101743df8b27f676 Mon Sep 17 00:00:00 2001 From: Quentin Kaiser Date: Tue, 14 Jul 2026 12:07:11 +0200 Subject: [PATCH 1/7] feat(crypto): support certificate serial numbers Certificate serial numbers are needed to identify issued certificates unambiguously in CycloneDX 1.7 CBOMs. Gate the value to the 1.7 serialization view, place it in schema order for XML, and include it in deterministic comparison and round-trip coverage. Refs: CycloneDX 1.7 `cryptoProperties.certificateProperties.serialNumber`. Signed-off-by: Quentin Kaiser --- cyclonedx/model/crypto.py | 29 ++++++++++++++----- tests/_data/models.py | 1 + ..._v1_6_with_crypto_certificate-1.7.json.bin | 1 + ...m_v1_6_with_crypto_certificate-1.7.xml.bin | 1 + tests/test_model_crypto.py | 24 +++++++++++++++ 5 files changed, 48 insertions(+), 8 deletions(-) diff --git a/cyclonedx/model/crypto.py b/cyclonedx/model/crypto.py index 44bdc182f..5fa08ee62 100644 --- a/cyclonedx/model/crypto.py +++ b/cyclonedx/model/crypto.py @@ -602,6 +602,7 @@ class CertificateProperties: def __init__( self, *, + serial_number: Optional[str] = None, subject_name: Optional[str] = None, issuer_name: Optional[str] = None, not_valid_before: Optional[datetime] = None, @@ -611,6 +612,7 @@ def __init__( certificate_format: Optional[str] = None, certificate_extension: Optional[str] = None, ) -> None: + self.serial_number = serial_number self.subject_name = subject_name self.issuer_name = issuer_name self.not_valid_before = not_valid_before @@ -621,7 +623,18 @@ def __init__( self.certificate_extension = certificate_extension @property + @serializable.view(SchemaVersion1Dot7) @serializable.xml_sequence(10) + def serial_number(self) -> Optional[str]: + """The unique serial number assigned to the certificate by its issuer.""" + return self._serial_number + + @serial_number.setter + def serial_number(self, serial_number: Optional[str]) -> None: + self._serial_number = serial_number + + @property + @serializable.xml_sequence(20) def subject_name(self) -> Optional[str]: """ The subject name for the certificate. @@ -636,7 +649,7 @@ def subject_name(self, subject_name: Optional[str]) -> None: self._subject_name = subject_name @property - @serializable.xml_sequence(20) + @serializable.xml_sequence(30) def issuer_name(self) -> Optional[str]: """ The issuer name for the certificate. @@ -652,7 +665,7 @@ def issuer_name(self, issuer_name: Optional[str]) -> None: @property @serializable.type_mapping(serializable.helpers.XsdDateTime) - @serializable.xml_sequence(30) + @serializable.xml_sequence(40) def not_valid_before(self) -> Optional[datetime]: """ The date and time according to ISO-8601 standard from which the certificate is valid. @@ -668,7 +681,7 @@ def not_valid_before(self, not_valid_before: Optional[datetime]) -> None: @property @serializable.type_mapping(serializable.helpers.XsdDateTime) - @serializable.xml_sequence(40) + @serializable.xml_sequence(50) def not_valid_after(self) -> Optional[datetime]: """ The date and time according to ISO-8601 standard from which the certificate is not valid anymore. @@ -684,7 +697,7 @@ def not_valid_after(self, not_valid_after: Optional[datetime]) -> None: @property @serializable.type_mapping(BomRef) - @serializable.xml_sequence(50) + @serializable.xml_sequence(60) def signature_algorithm_ref(self) -> Optional[BomRef]: """ The bom-ref to signature algorithm used by the certificate. @@ -700,7 +713,7 @@ def signature_algorithm_ref(self, signature_algorithm_ref: Optional[BomRef]) -> @property @serializable.type_mapping(BomRef) - @serializable.xml_sequence(60) + @serializable.xml_sequence(70) def subject_public_key_ref(self) -> Optional[BomRef]: """ The bom-ref to the public key of the subject. @@ -715,7 +728,7 @@ def subject_public_key_ref(self, subject_public_key_ref: Optional[BomRef]) -> No self._subject_public_key_ref = subject_public_key_ref @property - @serializable.xml_sequence(70) + @serializable.xml_sequence(80) def certificate_format(self) -> Optional[str]: """ The format of the certificate. Examples include X.509, PEM, DER, and CVC. @@ -730,7 +743,7 @@ def certificate_format(self, certificate_format: Optional[str]) -> None: self._certificate_format = certificate_format @property - @serializable.xml_sequence(80) + @serializable.xml_sequence(90) def certificate_extension(self) -> Optional[str]: """ The file extension of the certificate. Examples include crt, pem, cer, der, and p12. @@ -746,7 +759,7 @@ def certificate_extension(self, certificate_extension: Optional[str]) -> None: def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( - self.subject_name, self.issuer_name, self.not_valid_before, self.not_valid_after, + self.serial_number, self.subject_name, self.issuer_name, self.not_valid_before, self.not_valid_after, self.certificate_format, self.certificate_extension )) diff --git a/tests/_data/models.py b/tests/_data/models.py index 565f56ec9..61254515a 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -197,6 +197,7 @@ def get_crypto_properties_certificate() -> CryptoProperties: return CryptoProperties( asset_type=CryptoAssetType.CERTIFICATE, certificate_properties=CertificateProperties( + serial_number='3942447fac867ae5cdb3229b658f4d48', subject_name='cyclonedx.org', issuer_name='Cloudflare Inc ECC CA-3', not_valid_before=datetime(year=2023, month=5, day=19, hour=1, minute=0, second=0, microsecond=0, diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin index 1ded17727..e379945d8 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin @@ -10,6 +10,7 @@ "issuerName": "Cloudflare Inc ECC CA-3", "notValidAfter": "2024-05-19T00:59:59.999999+00:00", "notValidBefore": "2023-05-19T01:00:00+00:00", + "serialNumber": "3942447fac867ae5cdb3229b658f4d48", "subjectName": "cyclonedx.org" }, "oid": "an-oid-here" diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin index 81a2ac3ca..2964edfeb 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin @@ -10,6 +10,7 @@ certificate + 3942447fac867ae5cdb3229b658f4d48 cyclonedx.org Cloudflare Inc ECC CA-3 2023-05-19T01:00:00+00:00 diff --git a/tests/test_model_crypto.py b/tests/test_model_crypto.py index 12265ee19..7678c6db2 100644 --- a/tests/test_model_crypto.py +++ b/tests/test_model_crypto.py @@ -15,7 +15,9 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. +from json import loads as json_loads from unittest import TestCase +from xml.etree.ElementTree import fromstring as xml_fromstring from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.crypto import ( @@ -29,6 +31,7 @@ RelatedCryptoMaterialSecuredBy, RelatedCryptoMaterialType, ) +from cyclonedx.schema.schema import SchemaVersion1Dot6, SchemaVersion1Dot7 class TestModelAlgorithmProperties(TestCase): @@ -47,6 +50,27 @@ def test_algorithm_properties_sorting(self) -> None: class TestModelCertificateProperties(TestCase): + def test_serial_number_construction_and_comparison(self) -> None: + first = CertificateProperties(serial_number='1') + second = CertificateProperties(serial_number='2') + + self.assertEqual('1', first.serial_number) + self.assertNotEqual(first, second) + self.assertNotEqual(hash(first), hash(second)) + self.assertEqual([first, second], sorted([second, first])) + + def test_serial_number_version_gating_and_round_trip(self) -> None: + properties = CertificateProperties(serial_number='3942447fac867ae5cdb3229b658f4d48') + + json_v1_6 = json_loads(properties.as_json(view_=SchemaVersion1Dot6)) + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + self.assertNotIn('serialNumber', json_v1_6) + self.assertEqual(properties.serial_number, json_v1_7['serialNumber']) + self.assertEqual(properties, CertificateProperties.from_json(json_v1_7)) + + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, CertificateProperties.from_xml(xml_v1_7)) + def test_certificate_properties_sorting(self) -> None: """Test that CertificateProperties instances can be sorted without triggering TypeError""" cert1 = CertificateProperties(subject_name='CN=Test1', certificate_format='X.509') From 2cf079decfeaef6e3e6433fdecc3a086b1c8cfec Mon Sep 17 00:00:00 2001 From: Quentin Kaiser Date: Tue, 14 Jul 2026 12:10:00 +0200 Subject: [PATCH 2/7] feat(crypto): support certificate file extensions Consumers need the preferred CycloneDX 1.7 representation while older documents still rely on the deprecated certificate extension field. Store and serialize both values independently, gate the preferred value to 1.7, and retain the legacy value during downgrade and round-trip operations. Refs: CycloneDX 1.7 `cryptoProperties.certificateProperties.certificateFileExtension`. Signed-off-by: Quentin Kaiser --- cyclonedx/model/crypto.py | 15 ++++++++++++++- tests/_data/models.py | 3 ++- ...om_v1_6_with_crypto_certificate-1.7.json.bin | 1 + ...bom_v1_6_with_crypto_certificate-1.7.xml.bin | 1 + tests/test_model_crypto.py | 17 +++++++++++++++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/cyclonedx/model/crypto.py b/cyclonedx/model/crypto.py index 5fa08ee62..075ee2e6f 100644 --- a/cyclonedx/model/crypto.py +++ b/cyclonedx/model/crypto.py @@ -611,6 +611,7 @@ def __init__( subject_public_key_ref: Optional[BomRef] = None, certificate_format: Optional[str] = None, certificate_extension: Optional[str] = None, + certificate_file_extension: Optional[str] = None, ) -> None: self.serial_number = serial_number self.subject_name = subject_name @@ -621,6 +622,7 @@ def __init__( self.subject_public_key_ref = subject_public_key_ref self.certificate_format = certificate_format self.certificate_extension = certificate_extension + self.certificate_file_extension = certificate_file_extension @property @serializable.view(SchemaVersion1Dot7) @@ -757,10 +759,21 @@ def certificate_extension(self) -> Optional[str]: def certificate_extension(self, certificate_extension: Optional[str]) -> None: self._certificate_extension = certificate_extension + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(100) + def certificate_file_extension(self) -> Optional[str]: + """The preferred file extension for the certificate.""" + return self._certificate_file_extension + + @certificate_file_extension.setter + def certificate_file_extension(self, certificate_file_extension: Optional[str]) -> None: + self._certificate_file_extension = certificate_file_extension + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.serial_number, self.subject_name, self.issuer_name, self.not_valid_before, self.not_valid_after, - self.certificate_format, self.certificate_extension + self.certificate_format, self.certificate_extension, self.certificate_file_extension, )) def __eq__(self, other: object) -> bool: diff --git a/tests/_data/models.py b/tests/_data/models.py index 61254515a..a074f3e71 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -207,7 +207,8 @@ def get_crypto_properties_certificate() -> CryptoProperties: signature_algorithm_ref=None, subject_public_key_ref=None, certificate_format='pem', - certificate_extension='csr' + certificate_extension='csr', + certificate_file_extension='pem', ), oid='an-oid-here' ) diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin index e379945d8..e9179ced3 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin @@ -6,6 +6,7 @@ "assetType": "certificate", "certificateProperties": { "certificateExtension": "csr", + "certificateFileExtension": "pem", "certificateFormat": "pem", "issuerName": "Cloudflare Inc ECC CA-3", "notValidAfter": "2024-05-19T00:59:59.999999+00:00", diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin index 2964edfeb..91d83a96b 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin @@ -17,6 +17,7 @@ 2024-05-19T00:59:59.999999+00:00 pem csr + pem an-oid-here diff --git a/tests/test_model_crypto.py b/tests/test_model_crypto.py index 7678c6db2..42231b29b 100644 --- a/tests/test_model_crypto.py +++ b/tests/test_model_crypto.py @@ -50,6 +50,23 @@ def test_algorithm_properties_sorting(self) -> None: class TestModelCertificateProperties(TestCase): + def test_certificate_file_extension_preserves_deprecated_extension(self) -> None: + properties = CertificateProperties( + certificate_extension='crt', + certificate_file_extension='pem', + ) + + json_v1_6 = json_loads(properties.as_json(view_=SchemaVersion1Dot6)) + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + self.assertEqual('crt', json_v1_6['certificateExtension']) + self.assertNotIn('certificateFileExtension', json_v1_6) + self.assertEqual('crt', json_v1_7['certificateExtension']) + self.assertEqual('pem', json_v1_7['certificateFileExtension']) + self.assertEqual(properties, CertificateProperties.from_json(json_v1_7)) + + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, CertificateProperties.from_xml(xml_v1_7)) + def test_serial_number_construction_and_comparison(self) -> None: first = CertificateProperties(serial_number='1') second = CertificateProperties(serial_number='2') From cbd3edd4dd95316d8a1d954ac1081ef8aff71143 Mon Sep 17 00:00:00 2001 From: Quentin Kaiser Date: Tue, 14 Jul 2026 12:13:19 +0200 Subject: [PATCH 3/7] feat(crypto): support cryptographic asset fingerprints CBOM consumers need stable digests for certificates and related cryptographic material without conflating those digests with signatures. Reuse the existing hash model for JSON and XML mappings, gate both properties to 1.7, and include them in deterministic comparison and schema-valid round trips. Refs: CycloneDX 1.7 certificate and related-material `fingerprint` properties. Signed-off-by: Quentin Kaiser --- cyclonedx/model/crypto.py | 32 +++++++++++++++++-- tests/_data/models.py | 5 ++- ..._v1_6_with_crypto_certificate-1.7.json.bin | 4 +++ ...m_v1_6_with_crypto_certificate-1.7.xml.bin | 1 + ..._with_crypto_related_material-1.7.json.bin | 4 +++ ...6_with_crypto_related_material-1.7.xml.bin | 1 + tests/test_model_crypto.py | 27 ++++++++++++++++ 7 files changed, 71 insertions(+), 3 deletions(-) diff --git a/cyclonedx/model/crypto.py b/cyclonedx/model/crypto.py index 075ee2e6f..caf786b42 100644 --- a/cyclonedx/model/crypto.py +++ b/cyclonedx/model/crypto.py @@ -36,6 +36,7 @@ from .._internal.compare import ComparableTuple as _ComparableTuple from ..exception.model import InvalidNistQuantumSecurityLevelException, InvalidRelatedCryptoMaterialSizeException from ..schema.schema import SchemaVersion1Dot6, SchemaVersion1Dot7 +from . import HashType from .bom_ref import BomRef @@ -612,6 +613,7 @@ def __init__( certificate_format: Optional[str] = None, certificate_extension: Optional[str] = None, certificate_file_extension: Optional[str] = None, + fingerprint: Optional[HashType] = None, ) -> None: self.serial_number = serial_number self.subject_name = subject_name @@ -623,6 +625,7 @@ def __init__( self.certificate_format = certificate_format self.certificate_extension = certificate_extension self.certificate_file_extension = certificate_file_extension + self.fingerprint = fingerprint @property @serializable.view(SchemaVersion1Dot7) @@ -770,10 +773,21 @@ def certificate_file_extension(self) -> Optional[str]: def certificate_file_extension(self, certificate_file_extension: Optional[str]) -> None: self._certificate_file_extension = certificate_file_extension + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(110) + def fingerprint(self) -> Optional[HashType]: + """A cryptographic hash of the certificate.""" + return self._fingerprint + + @fingerprint.setter + def fingerprint(self, fingerprint: Optional[HashType]) -> None: + self._fingerprint = fingerprint + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.serial_number, self.subject_name, self.issuer_name, self.not_valid_before, self.not_valid_after, - self.certificate_format, self.certificate_extension, self.certificate_file_extension, + self.certificate_format, self.certificate_extension, self.certificate_file_extension, self.fingerprint, )) def __eq__(self, other: object) -> bool: @@ -953,6 +967,7 @@ def __init__( size: Optional[int] = None, format: Optional[str] = None, secured_by: Optional[RelatedCryptoMaterialSecuredBy] = None, + fingerprint: Optional[HashType] = None, ) -> None: self.type = type self.id = id @@ -966,6 +981,7 @@ def __init__( self.size = size self.format = format self.secured_by = secured_by + self.fingerprint = fingerprint @property @serializable.xml_sequence(10) @@ -1152,10 +1168,22 @@ def secured_by(self) -> Optional[RelatedCryptoMaterialSecuredBy]: def secured_by(self, secured_by: Optional[RelatedCryptoMaterialSecuredBy]) -> None: self._secured_by = secured_by + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(130) + def fingerprint(self) -> Optional[HashType]: + """A cryptographic hash of the related material.""" + return self._fingerprint + + @fingerprint.setter + def fingerprint(self, fingerprint: Optional[HashType]) -> None: + self._fingerprint = fingerprint + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.type, self.id, self.state, self.algorithm_ref, self.creation_date, self.activation_date, - self.update_date, self.expiration_date, self.value, self.size, self.format, self.secured_by + self.update_date, self.expiration_date, self.value, self.size, self.format, self.secured_by, + self.fingerprint, )) def __eq__(self, other: object) -> bool: diff --git a/tests/_data/models.py b/tests/_data/models.py index a074f3e71..0372146f1 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -36,6 +36,7 @@ Encoding, ExternalReference, ExternalReferenceType, + HashAlgorithm, HashType, Note, NoteText, @@ -209,6 +210,7 @@ def get_crypto_properties_certificate() -> CryptoProperties: certificate_format='pem', certificate_extension='csr', certificate_file_extension='pem', + fingerprint=HashType(alg=HashAlgorithm.SHA_256, content='a' * 64), ), oid='an-oid-here' ) @@ -284,7 +286,8 @@ def get_crypto_properties_related_material() -> CryptoProperties: secured_by=RelatedCryptoMaterialSecuredBy( mechanism='hard-work', algorithm_ref=None - ) + ), + fingerprint=HashType(alg=HashAlgorithm.SHA_256, content='b' * 64), ), oid='an-oid-here' ) diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin index e9179ced3..0e91e7957 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin @@ -8,6 +8,10 @@ "certificateExtension": "csr", "certificateFileExtension": "pem", "certificateFormat": "pem", + "fingerprint": { + "alg": "SHA-256", + "content": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, "issuerName": "Cloudflare Inc ECC CA-3", "notValidAfter": "2024-05-19T00:59:59.999999+00:00", "notValidBefore": "2023-05-19T01:00:00+00:00", diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin index 91d83a96b..74d52ce4b 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin @@ -18,6 +18,7 @@ pem csr pem + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa an-oid-here diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.json.bin index 9c404d2e5..45fb254fe 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.json.bin @@ -9,6 +9,10 @@ "activationDate": "2023-05-19T01:00:00+00:00", "creationDate": "2023-05-19T01:00:00+00:00", "expirationDate": "2024-05-19T00:59:59.999999+00:00", + "fingerprint": { + "alg": "SHA-256", + "content": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, "format": "a-format", "id": "some-identifier", "securedBy": { diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.xml.bin index 239e2c956..feda08b86 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.xml.bin @@ -22,6 +22,7 @@ hard-work + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb an-oid-here diff --git a/tests/test_model_crypto.py b/tests/test_model_crypto.py index 42231b29b..8fe9c87d5 100644 --- a/tests/test_model_crypto.py +++ b/tests/test_model_crypto.py @@ -19,6 +19,7 @@ from unittest import TestCase from xml.etree.ElementTree import fromstring as xml_fromstring +from cyclonedx.model import HashAlgorithm, HashType from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.crypto import ( AlgorithmProperties, @@ -50,6 +51,19 @@ def test_algorithm_properties_sorting(self) -> None: class TestModelCertificateProperties(TestCase): + def test_fingerprint_version_gating_comparison_and_round_trip(self) -> None: + fingerprint = HashType(alg=HashAlgorithm.SHA_256, content='a' * 64) + properties = CertificateProperties(fingerprint=fingerprint) + + self.assertNotEqual(properties, CertificateProperties()) + self.assertNotEqual(hash(properties), hash(CertificateProperties())) + self.assertNotIn('fingerprint', json_loads(properties.as_json(view_=SchemaVersion1Dot6))) + + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, CertificateProperties.from_json(json_v1_7)) + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, CertificateProperties.from_xml(xml_v1_7)) + def test_certificate_file_extension_preserves_deprecated_extension(self) -> None: properties = CertificateProperties( certificate_extension='crt', @@ -116,6 +130,19 @@ def test_related_crypto_material_secured_by_sorting(self) -> None: class TestModelRelatedCryptoMaterialProperties(TestCase): + def test_fingerprint_version_gating_comparison_and_round_trip(self) -> None: + fingerprint = HashType(alg=HashAlgorithm.SHA_256, content='b' * 64) + properties = RelatedCryptoMaterialProperties(fingerprint=fingerprint) + + self.assertNotEqual(properties, RelatedCryptoMaterialProperties()) + self.assertNotEqual(hash(properties), hash(RelatedCryptoMaterialProperties())) + self.assertNotIn('fingerprint', json_loads(properties.as_json(view_=SchemaVersion1Dot6))) + + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, RelatedCryptoMaterialProperties.from_json(json_v1_7)) + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, RelatedCryptoMaterialProperties.from_xml(xml_v1_7)) + def test_related_crypto_material_properties_sorting(self) -> None: """Test that RelatedCryptoMaterialProperties instances can be sorted without triggering TypeError""" material1 = RelatedCryptoMaterialProperties( From 4d927f9542b162c1cf461a6622a05a0421b5b064 Mon Sep 17 00:00:00 2001 From: Quentin Kaiser Date: Tue, 14 Jul 2026 12:16:52 +0200 Subject: [PATCH 4/7] feat(crypto): support certificate lifecycle states Certificate inventories need machine-readable lifecycle status while still permitting organization-specific states. Model predefined and custom variants as a discriminated union, store them deterministically, and select the concrete variant from its JSON or XML shape during deserialization. Refs: CycloneDX 1.7 `cryptoProperties.certificateProperties.certificateState`. Signed-off-by: Quentin Kaiser --- cyclonedx/model/crypto.py | 170 ++++++++++++++++++ tests/_data/models.py | 14 ++ ...num_CertificateLifecycleState-1.7.json.bin | 132 ++++++++++++++ ...enum_CertificateLifecycleState-1.7.xml.bin | 86 +++++++++ ..._v1_6_with_crypto_certificate-1.7.json.bin | 11 ++ ...m_v1_6_with_crypto_certificate-1.7.xml.bin | 9 + tests/test_enums.py | 38 +++- tests/test_model_crypto.py | 36 ++++ 8 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 tests/_data/snapshots/enum_CertificateLifecycleState-1.7.json.bin create mode 100644 tests/_data/snapshots/enum_CertificateLifecycleState-1.7.xml.bin diff --git a/cyclonedx/model/crypto.py b/cyclonedx/model/crypto.py index caf786b42..4e10a5300 100644 --- a/cyclonedx/model/crypto.py +++ b/cyclonedx/model/crypto.py @@ -29,12 +29,14 @@ from datetime import datetime from enum import Enum from typing import Any, Optional +from xml.etree.ElementTree import Element # nosec B405 import py_serializable as serializable from sortedcontainers import SortedSet from .._internal.compare import ComparableTuple as _ComparableTuple from ..exception.model import InvalidNistQuantumSecurityLevelException, InvalidRelatedCryptoMaterialSizeException +from ..exception.serialization import CycloneDxDeserializationException from ..schema.schema import SchemaVersion1Dot6, SchemaVersion1Dot7 from . import HashType from .bom_ref import BomRef @@ -587,6 +589,158 @@ def __repr__(self) -> str: return f'' +@serializable.serializable_enum +class CertificateLifecycleState(str, Enum): + """A pre-defined state in the certificate lifecycle.""" + + PRE_ACTIVATION = 'pre-activation' + ACTIVE = 'active' + SUSPENDED = 'suspended' + DEACTIVATED = 'deactivated' + REVOKED = 'revoked' + DESTROYED = 'destroyed' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class _CertificateState: + """Non-public common type and deserialization discriminator for certificate states.""" + + @classmethod + def from_json(cls, data: dict[str, Any]) -> '_CertificateState': + if 'state' in data: + return CertificatePredefinedState.from_json(data) + if 'name' in data: + return CertificateCustomState.from_json(data) + raise CycloneDxDeserializationException(f'unexpected certificate state: {data!r}') + + @classmethod + def from_xml(cls, data: Element, default_namespace: Optional[str] = None) -> '_CertificateState': + child_names = {child.tag.rsplit('}', 1)[-1] for child in data} + if 'state' in child_names: + return CertificatePredefinedState.from_xml(data, default_namespace) + if 'name' in child_names: + return CertificateCustomState.from_xml(data, default_namespace) + raise CycloneDxDeserializationException(f'unexpected certificate state: {data!r}') + + def _comparable_tuple(self) -> _ComparableTuple: + raise NotImplementedError() + + def __eq__(self, other: object) -> bool: + return isinstance(other, _CertificateState) and self._comparable_tuple() == other._comparable_tuple() + + def __lt__(self, other: object) -> bool: + if isinstance(other, _CertificateState): + return self._comparable_tuple() < other._comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self._comparable_tuple()) + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class CertificatePredefinedState(_CertificateState): + """A standardized certificate lifecycle state with an optional reason.""" + + def __init__(self, *, state: CertificateLifecycleState, reason: Optional[str] = None) -> None: + self.state = state + self.reason = reason + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(10) + def state(self) -> CertificateLifecycleState: + return self._state + + @state.setter + def state(self, state: CertificateLifecycleState) -> None: + self._state = state + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(20) + def reason(self) -> Optional[str]: + return self._reason + + @reason.setter + def reason(self, reason: Optional[str]) -> None: + self._reason = reason + + def _comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(('predefined', self.state, self.reason)) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class CertificateCustomState(_CertificateState): + """An application-defined certificate lifecycle state.""" + + def __init__(self, *, name: str, description: Optional[str] = None, reason: Optional[str] = None) -> None: + self.name = name + self.description = description + self.reason = reason + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(10) + def name(self) -> str: + return self._name + + @name.setter + def name(self, name: str) -> None: + self._name = name + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(20) + def description(self) -> Optional[str]: + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(30) + def reason(self) -> Optional[str]: + return self._reason + + @reason.setter + def reason(self, reason: Optional[str]) -> None: + self._reason = reason + + def _comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(('custom', self.name, self.description, self.reason)) + + def __repr__(self) -> str: + return f'' + + +def _certificate_state_from_json(cls: type[_CertificateState], data: dict[str, Any]) -> _CertificateState: + if 'state' in data: + return CertificatePredefinedState.from_json(data) + if 'name' in data: + return CertificateCustomState.from_json(data) + raise CycloneDxDeserializationException(f'unexpected certificate state: {data!r}') + + +def _certificate_state_from_xml( + cls: type[_CertificateState], data: Element, default_namespace: Optional[str] = None +) -> _CertificateState: + child_names = {child.tag.rsplit('}', 1)[-1] for child in data} + if 'state' in child_names: + return CertificatePredefinedState.from_xml(data, default_namespace) + if 'name' in child_names: + return CertificateCustomState.from_xml(data, default_namespace) + raise CycloneDxDeserializationException(f'unexpected certificate state: {data!r}') + + +_CertificateState.from_json = classmethod(_certificate_state_from_json) # type:ignore[assignment] +_CertificateState.from_xml = classmethod(_certificate_state_from_xml) # type:ignore[assignment] + + @serializable.serializable_class(ignore_unknown_during_deserialization=True) class CertificateProperties: """ @@ -614,6 +768,7 @@ def __init__( certificate_extension: Optional[str] = None, certificate_file_extension: Optional[str] = None, fingerprint: Optional[HashType] = None, + certificate_states: Optional[Iterable[_CertificateState]] = None, ) -> None: self.serial_number = serial_number self.subject_name = subject_name @@ -626,6 +781,7 @@ def __init__( self.certificate_extension = certificate_extension self.certificate_file_extension = certificate_file_extension self.fingerprint = fingerprint + self.certificate_states = certificate_states or [] @property @serializable.view(SchemaVersion1Dot7) @@ -784,10 +940,24 @@ def fingerprint(self) -> Optional[HashType]: def fingerprint(self, fingerprint: Optional[HashType]) -> None: self._fingerprint = fingerprint + @property + @serializable.json_name('certificateState') + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'certificateState') + @serializable.xml_sequence(120) + def certificate_states(self) -> 'SortedSet[_CertificateState]': + """The lifecycle states associated with the certificate.""" + return self._certificate_states + + @certificate_states.setter + def certificate_states(self, certificate_states: Iterable[_CertificateState]) -> None: + self._certificate_states = SortedSet(certificate_states) + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.serial_number, self.subject_name, self.issuer_name, self.not_valid_before, self.not_valid_after, self.certificate_format, self.certificate_extension, self.certificate_file_extension, self.fingerprint, + _ComparableTuple(self.certificate_states), )) def __eq__(self, other: object) -> bool: diff --git a/tests/_data/models.py b/tests/_data/models.py index 0372146f1..6f4d8dedc 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -71,6 +71,9 @@ from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity, PostalAddress from cyclonedx.model.crypto import ( AlgorithmProperties, + CertificateCustomState, + CertificateLifecycleState, + CertificatePredefinedState, CertificateProperties, CryptoAssetType, CryptoCertificationLevel, @@ -211,6 +214,17 @@ def get_crypto_properties_certificate() -> CryptoProperties: certificate_extension='csr', certificate_file_extension='pem', fingerprint=HashType(alg=HashAlgorithm.SHA_256, content='a' * 64), + certificate_states=[ + CertificatePredefinedState( + state=CertificateLifecycleState.ACTIVE, + reason='certificate is in use', + ), + CertificateCustomState( + name='pending-rotation', + description='The certificate is waiting to be rotated.', + reason='scheduled maintenance', + ), + ], ), oid='an-oid-here' ) diff --git a/tests/_data/snapshots/enum_CertificateLifecycleState-1.7.json.bin b/tests/_data/snapshots/enum_CertificateLifecycleState-1.7.json.bin new file mode 100644 index 000000000..d21991364 --- /dev/null +++ b/tests/_data/snapshots/enum_CertificateLifecycleState-1.7.json.bin @@ -0,0 +1,132 @@ +{ + "components": [ + { + "bom-ref": "dummy-CLS:ACTIVE", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateState": [ + { + "state": "active" + } + ] + } + }, + "name": "CertificateLifecycleState: ACTIVE", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CLS:DEACTIVATED", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateState": [ + { + "state": "deactivated" + } + ] + } + }, + "name": "CertificateLifecycleState: DEACTIVATED", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CLS:DESTROYED", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateState": [ + { + "state": "destroyed" + } + ] + } + }, + "name": "CertificateLifecycleState: DESTROYED", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CLS:PRE_ACTIVATION", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateState": [ + { + "state": "pre-activation" + } + ] + } + }, + "name": "CertificateLifecycleState: PRE_ACTIVATION", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CLS:REVOKED", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateState": [ + { + "state": "revoked" + } + ] + } + }, + "name": "CertificateLifecycleState: REVOKED", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CLS:SUSPENDED", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateState": [ + { + "state": "suspended" + } + ] + } + }, + "name": "CertificateLifecycleState: SUSPENDED", + "type": "cryptographic-asset" + } + ], + "dependencies": [ + { + "ref": "dummy-CLS:ACTIVE" + }, + { + "ref": "dummy-CLS:DEACTIVATED" + }, + { + "ref": "dummy-CLS:DESTROYED" + }, + { + "ref": "dummy-CLS:PRE_ACTIVATION" + }, + { + "ref": "dummy-CLS:REVOKED" + }, + { + "ref": "dummy-CLS:SUSPENDED" + } + ], + "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_CertificateLifecycleState-1.7.xml.bin b/tests/_data/snapshots/enum_CertificateLifecycleState-1.7.xml.bin new file mode 100644 index 000000000..a718d67ee --- /dev/null +++ b/tests/_data/snapshots/enum_CertificateLifecycleState-1.7.xml.bin @@ -0,0 +1,86 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + CertificateLifecycleState: ACTIVE + + certificate + + + active + + + + + + CertificateLifecycleState: DEACTIVATED + + certificate + + + deactivated + + + + + + CertificateLifecycleState: DESTROYED + + certificate + + + destroyed + + + + + + CertificateLifecycleState: PRE_ACTIVATION + + certificate + + + pre-activation + + + + + + CertificateLifecycleState: REVOKED + + certificate + + + revoked + + + + + + CertificateLifecycleState: SUSPENDED + + certificate + + + suspended + + + + + + + + + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin index 0e91e7957..7e25b5702 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin @@ -8,6 +8,17 @@ "certificateExtension": "csr", "certificateFileExtension": "pem", "certificateFormat": "pem", + "certificateState": [ + { + "description": "The certificate is waiting to be rotated.", + "name": "pending-rotation", + "reason": "scheduled maintenance" + }, + { + "reason": "certificate is in use", + "state": "active" + } + ], "fingerprint": { "alg": "SHA-256", "content": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin index 74d52ce4b..e83d13b06 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin @@ -19,6 +19,15 @@ csr pem aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + pending-rotation + The certificate is waiting to be rotated. + scheduled maintenance + + + active + certificate is in use + an-oid-here diff --git a/tests/test_enums.py b/tests/test_enums.py index 88ac8e714..9ffa8e6ac 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -23,7 +23,7 @@ from itertools import chain from json import load as json_load from os import path -from typing import Any, Optional +from typing import Any, Optional, Union from unittest import TestCase from warnings import warn from xml.etree.ElementTree import parse as xml_parse # nosec B405 @@ -94,6 +94,9 @@ VulnerabilitySeverity, ) from cyclonedx.model.crypto import ( # isort:skip + CertificateLifecycleState, + CertificatePredefinedState, + CertificateProperties, CryptoAssetType, CryptoCertificationLevel, CryptoExecutionEnvironment, @@ -130,7 +133,7 @@ def dp_cases_from_xml_schemas(xpath: str) -> set[str]: return cases -def dp_cases_from_json_schema(sf: str, jsonpointer: Iterable[str]) -> Generator[str, None, None]: +def dp_cases_from_json_schema(sf: str, jsonpointer: Iterable[Union[str, int]]) -> Generator[str, None, None]: with open(sf) as sfh: data = json_load(sfh) try: @@ -142,7 +145,7 @@ def dp_cases_from_json_schema(sf: str, jsonpointer: Iterable[str]) -> Generator[ yield from data['enum'] -def dp_cases_from_json_schemas(*jsonpointer: str) -> set[str]: +def dp_cases_from_json_schemas(*jsonpointer: Union[str, int]) -> set[str]: cases: set[str] = set() for sf in SCHEMA_JSON.values(): if sf is None: @@ -881,6 +884,35 @@ def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, super()._test_cases_render(bom, of, sv) +@ddt +class TestEnumCertificateLifecycleState(_EnumTestCase): + + @idata(dp_cases_from_json_schemas( + 'definitions', 'cryptoProperties', 'properties', 'certificateProperties', 'properties', + 'certificateState', 'items', 'oneOf', 0, 'properties', 'state' + )) + def test_knows_value(self, value: str) -> None: + super()._test_knows_value(CertificateLifecycleState, value) + + @named_data(*(d for d in NAMED_OF_SV if d[2] is SchemaVersion.V1_7)) + def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, **__: Any) -> None: + bom = _make_bom( + components=[ + Component( + name=f'CertificateLifecycleState: {state.name}', bom_ref=f'dummy-CLS:{state.name}', + type=ComponentType.CRYPTOGRAPHIC_ASSET, + crypto_properties=CryptoProperties( + asset_type=CryptoAssetType.CERTIFICATE, + certificate_properties=CertificateProperties( + certificate_states=[CertificatePredefinedState(state=state)] + ), + ), + ) for state in CertificateLifecycleState + ] + ) + super()._test_cases_render(bom, of, sv) + + @ddt class TestEnumRelatedCryptoMaterialType(_EnumTestCase): diff --git a/tests/test_model_crypto.py b/tests/test_model_crypto.py index 8fe9c87d5..17e45f465 100644 --- a/tests/test_model_crypto.py +++ b/tests/test_model_crypto.py @@ -23,6 +23,9 @@ from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.crypto import ( AlgorithmProperties, + CertificateCustomState, + CertificateLifecycleState, + CertificatePredefinedState, CertificateProperties, CryptoPrimitive, Ikev2TransformTypes, @@ -51,6 +54,39 @@ def test_algorithm_properties_sorting(self) -> None: class TestModelCertificateProperties(TestCase): + def test_certificate_states_are_gated_deterministic_and_round_trip(self) -> None: + predefined = CertificatePredefinedState( + state=CertificateLifecycleState.ACTIVE, + reason='in use', + ) + custom = CertificateCustomState( + name='pending-rotation', + description='custom state', + reason='scheduled maintenance', + ) + properties = CertificateProperties(certificate_states=[predefined, custom]) + + self.assertEqual([custom, predefined], list(properties.certificate_states)) + self.assertNotEqual(properties, CertificateProperties()) + self.assertNotEqual(hash(properties), hash(CertificateProperties())) + self.assertNotIn('certificateState', json_loads(properties.as_json(view_=SchemaVersion1Dot6))) + + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + from_json = CertificateProperties.from_json(json_v1_7) + self.assertEqual(properties, from_json) + self.assertEqual( + {CertificateCustomState, CertificatePredefinedState}, + {type(state) for state in from_json.certificate_states}, + ) + + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + from_xml = CertificateProperties.from_xml(xml_v1_7) + self.assertEqual(properties, from_xml) + self.assertEqual( + {CertificateCustomState, CertificatePredefinedState}, + {type(state) for state in from_xml.certificate_states}, + ) + def test_fingerprint_version_gating_comparison_and_round_trip(self) -> None: fingerprint = HashType(alg=HashAlgorithm.SHA_256, content='a' * 64) properties = CertificateProperties(fingerprint=fingerprint) From 72dc3c3f6c9d37e461bc1758cee83d6fda7c1713 Mon Sep 17 00:00:00 2001 From: Quentin Kaiser Date: Tue, 14 Jul 2026 12:19:16 +0200 Subject: [PATCH 5/7] feat(crypto): support certificate lifecycle dates Certificate lifecycle analysis depends on distinct issuance, operation, retirement, and destruction events rather than validity bounds alone. Map each timestamp through the existing date-time serializer, gate them to 1.7, preserve schema ordering, and include them in equality, hashing, and round-trip coverage. Refs: CycloneDX 1.7 certificate lifecycle date properties. Signed-off-by: Quentin Kaiser --- cyclonedx/model/crypto.py | 73 ++++++++++++++++++- tests/_data/models.py | 5 ++ ..._v1_6_with_crypto_certificate-1.7.json.bin | 5 ++ ...m_v1_6_with_crypto_certificate-1.7.xml.bin | 5 ++ tests/test_model_crypto.py | 29 ++++++++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/cyclonedx/model/crypto.py b/cyclonedx/model/crypto.py index 4e10a5300..539afc2a4 100644 --- a/cyclonedx/model/crypto.py +++ b/cyclonedx/model/crypto.py @@ -769,6 +769,11 @@ def __init__( certificate_file_extension: Optional[str] = None, fingerprint: Optional[HashType] = None, certificate_states: Optional[Iterable[_CertificateState]] = None, + creation_date: Optional[datetime] = None, + activation_date: Optional[datetime] = None, + deactivation_date: Optional[datetime] = None, + revocation_date: Optional[datetime] = None, + destruction_date: Optional[datetime] = None, ) -> None: self.serial_number = serial_number self.subject_name = subject_name @@ -782,6 +787,11 @@ def __init__( self.certificate_file_extension = certificate_file_extension self.fingerprint = fingerprint self.certificate_states = certificate_states or [] + self.creation_date = creation_date + self.activation_date = activation_date + self.deactivation_date = deactivation_date + self.revocation_date = revocation_date + self.destruction_date = destruction_date @property @serializable.view(SchemaVersion1Dot7) @@ -953,11 +963,72 @@ def certificate_states(self) -> 'SortedSet[_CertificateState]': def certificate_states(self, certificate_states: Iterable[_CertificateState]) -> None: self._certificate_states = SortedSet(certificate_states) + @property + @serializable.type_mapping(serializable.helpers.XsdDateTime) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(130) + def creation_date(self) -> Optional[datetime]: + """The date and time when the certificate was created.""" + return self._creation_date + + @creation_date.setter + def creation_date(self, creation_date: Optional[datetime]) -> None: + self._creation_date = creation_date + + @property + @serializable.type_mapping(serializable.helpers.XsdDateTime) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(140) + def activation_date(self) -> Optional[datetime]: + """The date and time when the certificate became active.""" + return self._activation_date + + @activation_date.setter + def activation_date(self, activation_date: Optional[datetime]) -> None: + self._activation_date = activation_date + + @property + @serializable.type_mapping(serializable.helpers.XsdDateTime) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(150) + def deactivation_date(self) -> Optional[datetime]: + """The date and time when the certificate was deactivated.""" + return self._deactivation_date + + @deactivation_date.setter + def deactivation_date(self, deactivation_date: Optional[datetime]) -> None: + self._deactivation_date = deactivation_date + + @property + @serializable.type_mapping(serializable.helpers.XsdDateTime) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(160) + def revocation_date(self) -> Optional[datetime]: + """The date and time when the certificate was revoked.""" + return self._revocation_date + + @revocation_date.setter + def revocation_date(self, revocation_date: Optional[datetime]) -> None: + self._revocation_date = revocation_date + + @property + @serializable.type_mapping(serializable.helpers.XsdDateTime) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(170) + def destruction_date(self) -> Optional[datetime]: + """The date and time when the certificate was destroyed.""" + return self._destruction_date + + @destruction_date.setter + def destruction_date(self, destruction_date: Optional[datetime]) -> None: + self._destruction_date = destruction_date + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.serial_number, self.subject_name, self.issuer_name, self.not_valid_before, self.not_valid_after, self.certificate_format, self.certificate_extension, self.certificate_file_extension, self.fingerprint, - _ComparableTuple(self.certificate_states), + _ComparableTuple(self.certificate_states), self.creation_date, self.activation_date, + self.deactivation_date, self.revocation_date, self.destruction_date, )) def __eq__(self, other: object) -> bool: diff --git a/tests/_data/models.py b/tests/_data/models.py index 6f4d8dedc..ded5f561e 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -225,6 +225,11 @@ def get_crypto_properties_certificate() -> CryptoProperties: reason='scheduled maintenance', ), ], + creation_date=datetime(2023, 5, 18, 12, 0, tzinfo=timezone.utc), + activation_date=datetime(2023, 5, 19, 1, 0, tzinfo=timezone.utc), + deactivation_date=datetime(2024, 5, 18, 12, 0, tzinfo=timezone.utc), + revocation_date=datetime(2024, 5, 18, 13, 0, tzinfo=timezone.utc), + destruction_date=datetime(2024, 5, 18, 14, 0, tzinfo=timezone.utc), ), oid='an-oid-here' ) diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin index 7e25b5702..22f6544cd 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin @@ -5,6 +5,7 @@ "cryptoProperties": { "assetType": "certificate", "certificateProperties": { + "activationDate": "2023-05-19T01:00:00+00:00", "certificateExtension": "csr", "certificateFileExtension": "pem", "certificateFormat": "pem", @@ -19,6 +20,9 @@ "state": "active" } ], + "creationDate": "2023-05-18T12:00:00+00:00", + "deactivationDate": "2024-05-18T12:00:00+00:00", + "destructionDate": "2024-05-18T14:00:00+00:00", "fingerprint": { "alg": "SHA-256", "content": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -26,6 +30,7 @@ "issuerName": "Cloudflare Inc ECC CA-3", "notValidAfter": "2024-05-19T00:59:59.999999+00:00", "notValidBefore": "2023-05-19T01:00:00+00:00", + "revocationDate": "2024-05-18T13:00:00+00:00", "serialNumber": "3942447fac867ae5cdb3229b658f4d48", "subjectName": "cyclonedx.org" }, diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin index e83d13b06..71ecff65c 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin @@ -28,6 +28,11 @@ active certificate is in use + 2023-05-18T12:00:00+00:00 + 2023-05-19T01:00:00+00:00 + 2024-05-18T12:00:00+00:00 + 2024-05-18T13:00:00+00:00 + 2024-05-18T14:00:00+00:00 an-oid-here diff --git a/tests/test_model_crypto.py b/tests/test_model_crypto.py index 17e45f465..f9b1a27af 100644 --- a/tests/test_model_crypto.py +++ b/tests/test_model_crypto.py @@ -15,6 +15,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. +from datetime import datetime, timezone from json import loads as json_loads from unittest import TestCase from xml.etree.ElementTree import fromstring as xml_fromstring @@ -54,6 +55,34 @@ def test_algorithm_properties_sorting(self) -> None: class TestModelCertificateProperties(TestCase): + def test_lifecycle_dates_are_gated_ordered_and_round_trip(self) -> None: + properties = CertificateProperties( + creation_date=datetime(2023, 5, 18, 12, 0, tzinfo=timezone.utc), + activation_date=datetime(2023, 5, 19, 1, 0, tzinfo=timezone.utc), + deactivation_date=datetime(2024, 5, 18, 12, 0, tzinfo=timezone.utc), + revocation_date=datetime(2024, 5, 18, 13, 0, tzinfo=timezone.utc), + destruction_date=datetime(2024, 5, 18, 14, 0, tzinfo=timezone.utc), + ) + + lifecycle_names = [ + 'creationDate', + 'activationDate', + 'deactivationDate', + 'revocationDate', + 'destructionDate', + ] + json_v1_6 = json_loads(properties.as_json(view_=SchemaVersion1Dot6)) + self.assertTrue(all(name not in json_v1_6 for name in lifecycle_names)) + self.assertNotEqual(properties, CertificateProperties()) + self.assertNotEqual(hash(properties), hash(CertificateProperties())) + + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, CertificateProperties.from_json(json_v1_7)) + + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + self.assertEqual(lifecycle_names, [child.tag for child in xml_v1_7]) + self.assertEqual(properties, CertificateProperties.from_xml(xml_v1_7)) + def test_certificate_states_are_gated_deterministic_and_round_trip(self) -> None: predefined = CertificatePredefinedState( state=CertificateLifecycleState.ACTIVE, From b696d58de3050e83eb1a0024a6f5b8b95c8775a2 Mon Sep 17 00:00:00 2001 From: Quentin Kaiser Date: Tue, 14 Jul 2026 12:21:40 +0200 Subject: [PATCH 6/7] feat(crypto): support certificate extensions X.509 inventories need standardized extension names and vendor-defined extensions without collapsing their distinct shapes into unstructured strings. Model common and custom variants as a discriminated union, derive common names from the 1.7 schema, and preserve concrete variants across deterministic JSON and XML round trips. Refs: CycloneDX 1.7 `cryptoProperties.certificateProperties.certificateExtensions`. Signed-off-by: Quentin Kaiser --- cyclonedx/model/crypto.py | 166 ++++++++++++++ tests/_data/models.py | 13 ++ ...ertificateCommonExtensionName-1.7.json.bin | 214 ++++++++++++++++++ ...CertificateCommonExtensionName-1.7.xml.bin | 164 ++++++++++++++ ..._v1_6_with_crypto_certificate-1.7.json.bin | 10 + ...m_v1_6_with_crypto_certificate-1.7.xml.bin | 10 + tests/test_enums.py | 35 +++ tests/test_model_crypto.py | 35 +++ 8 files changed, 647 insertions(+) create mode 100644 tests/_data/snapshots/enum_CertificateCommonExtensionName-1.7.json.bin create mode 100644 tests/_data/snapshots/enum_CertificateCommonExtensionName-1.7.xml.bin diff --git a/cyclonedx/model/crypto.py b/cyclonedx/model/crypto.py index 539afc2a4..2b400f59f 100644 --- a/cyclonedx/model/crypto.py +++ b/cyclonedx/model/crypto.py @@ -741,6 +741,157 @@ def _certificate_state_from_xml( _CertificateState.from_xml = classmethod(_certificate_state_from_xml) # type:ignore[assignment] +@serializable.serializable_enum +class CertificateCommonExtensionName(str, Enum): + """Names of common X.509 certificate extensions supported by CycloneDX 1.7.""" + + BASIC_CONSTRAINTS = 'basicConstraints' + KEY_USAGE = 'keyUsage' + EXTENDED_KEY_USAGE = 'extendedKeyUsage' + SUBJECT_ALTERNATIVE_NAME = 'subjectAlternativeName' + AUTHORITY_KEY_IDENTIFIER = 'authorityKeyIdentifier' + SUBJECT_KEY_IDENTIFIER = 'subjectKeyIdentifier' + AUTHORITY_INFORMATION_ACCESS = 'authorityInformationAccess' + CERTIFICATE_POLICIES = 'certificatePolicies' + CRL_DISTRIBUTION_POINTS = 'crlDistributionPoints' + SIGNED_CERTIFICATE_TIMESTAMP = 'signedCertificateTimestamp' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class _CertificateExtension: + """Non-public common type and deserialization discriminator for certificate extensions.""" + + @classmethod + def from_json(cls, data: dict[str, Any]) -> '_CertificateExtension': + if 'commonExtensionName' in data: + return CertificateCommonExtension.from_json(data) + if 'customExtensionName' in data: + return CertificateCustomExtension.from_json(data) + raise CycloneDxDeserializationException(f'unexpected certificate extension: {data!r}') + + @classmethod + def from_xml(cls, data: Element, default_namespace: Optional[str] = None) -> '_CertificateExtension': + child_names = {child.tag.rsplit('}', 1)[-1] for child in data} + if 'commonExtensionName' in child_names: + return CertificateCommonExtension.from_xml(data, default_namespace) + if 'customExtensionName' in child_names: + return CertificateCustomExtension.from_xml(data, default_namespace) + raise CycloneDxDeserializationException(f'unexpected certificate extension: {data!r}') + + def _comparable_tuple(self) -> _ComparableTuple: + raise NotImplementedError() + + def __eq__(self, other: object) -> bool: + return isinstance(other, _CertificateExtension) and self._comparable_tuple() == other._comparable_tuple() + + def __lt__(self, other: object) -> bool: + if isinstance(other, _CertificateExtension): + return self._comparable_tuple() < other._comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self._comparable_tuple()) + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class CertificateCommonExtension(_CertificateExtension): + """A standardized certificate extension and its value.""" + + def __init__( + self, *, + common_extension_name: CertificateCommonExtensionName, + common_extension_value: str, + ) -> None: + self.common_extension_name = common_extension_name + self.common_extension_value = common_extension_value + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(10) + def common_extension_name(self) -> CertificateCommonExtensionName: + return self._common_extension_name + + @common_extension_name.setter + def common_extension_name(self, common_extension_name: CertificateCommonExtensionName) -> None: + self._common_extension_name = common_extension_name + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(20) + def common_extension_value(self) -> str: + return self._common_extension_value + + @common_extension_value.setter + def common_extension_value(self, common_extension_value: str) -> None: + self._common_extension_value = common_extension_value + + def _comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(('common', self.common_extension_name, self.common_extension_value)) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class CertificateCustomExtension(_CertificateExtension): + """An application- or vendor-defined certificate extension.""" + + def __init__(self, *, custom_extension_name: str, custom_extension_value: Optional[str] = None) -> None: + self.custom_extension_name = custom_extension_name + self.custom_extension_value = custom_extension_value + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(10) + def custom_extension_name(self) -> str: + return self._custom_extension_name + + @custom_extension_name.setter + def custom_extension_name(self, custom_extension_name: str) -> None: + self._custom_extension_name = custom_extension_name + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(20) + def custom_extension_value(self) -> Optional[str]: + return self._custom_extension_value + + @custom_extension_value.setter + def custom_extension_value(self, custom_extension_value: Optional[str]) -> None: + self._custom_extension_value = custom_extension_value + + def _comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(('custom', self.custom_extension_name, self.custom_extension_value)) + + def __repr__(self) -> str: + return f'' + + +def _certificate_extension_from_json( + cls: type[_CertificateExtension], data: dict[str, Any] +) -> _CertificateExtension: + if 'commonExtensionName' in data: + return CertificateCommonExtension.from_json(data) + if 'customExtensionName' in data: + return CertificateCustomExtension.from_json(data) + raise CycloneDxDeserializationException(f'unexpected certificate extension: {data!r}') + + +def _certificate_extension_from_xml( + cls: type[_CertificateExtension], data: Element, default_namespace: Optional[str] = None +) -> _CertificateExtension: + child_names = {child.tag.rsplit('}', 1)[-1] for child in data} + if 'commonExtensionName' in child_names: + return CertificateCommonExtension.from_xml(data, default_namespace) + if 'customExtensionName' in child_names: + return CertificateCustomExtension.from_xml(data, default_namespace) + raise CycloneDxDeserializationException(f'unexpected certificate extension: {data!r}') + + +_CertificateExtension.from_json = classmethod(_certificate_extension_from_json) # type:ignore[assignment] +_CertificateExtension.from_xml = classmethod(_certificate_extension_from_xml) # type:ignore[assignment] + + @serializable.serializable_class(ignore_unknown_during_deserialization=True) class CertificateProperties: """ @@ -774,6 +925,7 @@ def __init__( deactivation_date: Optional[datetime] = None, revocation_date: Optional[datetime] = None, destruction_date: Optional[datetime] = None, + certificate_extensions: Optional[Iterable[_CertificateExtension]] = None, ) -> None: self.serial_number = serial_number self.subject_name = subject_name @@ -792,6 +944,7 @@ def __init__( self.deactivation_date = deactivation_date self.revocation_date = revocation_date self.destruction_date = destruction_date + self.certificate_extensions = certificate_extensions or [] @property @serializable.view(SchemaVersion1Dot7) @@ -1023,12 +1176,25 @@ def destruction_date(self) -> Optional[datetime]: def destruction_date(self, destruction_date: Optional[datetime]) -> None: self._destruction_date = destruction_date + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'certificateExtension') + @serializable.xml_sequence(180) + def certificate_extensions(self) -> 'SortedSet[_CertificateExtension]': + """The X.509 extensions associated with the certificate.""" + return self._certificate_extensions + + @certificate_extensions.setter + def certificate_extensions(self, certificate_extensions: Iterable[_CertificateExtension]) -> None: + self._certificate_extensions = SortedSet(certificate_extensions) + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.serial_number, self.subject_name, self.issuer_name, self.not_valid_before, self.not_valid_after, self.certificate_format, self.certificate_extension, self.certificate_file_extension, self.fingerprint, _ComparableTuple(self.certificate_states), self.creation_date, self.activation_date, self.deactivation_date, self.revocation_date, self.destruction_date, + _ComparableTuple(self.certificate_extensions), )) def __eq__(self, other: object) -> bool: diff --git a/tests/_data/models.py b/tests/_data/models.py index ded5f561e..250ed778a 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -71,6 +71,9 @@ from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity, PostalAddress from cyclonedx.model.crypto import ( AlgorithmProperties, + CertificateCommonExtension, + CertificateCommonExtensionName, + CertificateCustomExtension, CertificateCustomState, CertificateLifecycleState, CertificatePredefinedState, @@ -230,6 +233,16 @@ def get_crypto_properties_certificate() -> CryptoProperties: deactivation_date=datetime(2024, 5, 18, 12, 0, tzinfo=timezone.utc), revocation_date=datetime(2024, 5, 18, 13, 0, tzinfo=timezone.utc), destruction_date=datetime(2024, 5, 18, 14, 0, tzinfo=timezone.utc), + certificate_extensions=[ + CertificateCommonExtension( + common_extension_name=CertificateCommonExtensionName.KEY_USAGE, + common_extension_value='digitalSignature', + ), + CertificateCustomExtension( + custom_extension_name='1.2.3.4.5', + custom_extension_value='custom-value', + ), + ], ), oid='an-oid-here' ) diff --git a/tests/_data/snapshots/enum_CertificateCommonExtensionName-1.7.json.bin b/tests/_data/snapshots/enum_CertificateCommonExtensionName-1.7.json.bin new file mode 100644 index 000000000..e8cb70b0d --- /dev/null +++ b/tests/_data/snapshots/enum_CertificateCommonExtensionName-1.7.json.bin @@ -0,0 +1,214 @@ +{ + "components": [ + { + "bom-ref": "dummy-CCEN:AUTHORITY_INFORMATION_ACCESS", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "authorityInformationAccess", + "commonExtensionValue": "authorityInformationAccess" + } + ] + } + }, + "name": "CertificateCommonExtensionName: AUTHORITY_INFORMATION_ACCESS", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:AUTHORITY_KEY_IDENTIFIER", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "authorityKeyIdentifier", + "commonExtensionValue": "authorityKeyIdentifier" + } + ] + } + }, + "name": "CertificateCommonExtensionName: AUTHORITY_KEY_IDENTIFIER", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:BASIC_CONSTRAINTS", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "basicConstraints", + "commonExtensionValue": "basicConstraints" + } + ] + } + }, + "name": "CertificateCommonExtensionName: BASIC_CONSTRAINTS", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:CERTIFICATE_POLICIES", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "certificatePolicies", + "commonExtensionValue": "certificatePolicies" + } + ] + } + }, + "name": "CertificateCommonExtensionName: CERTIFICATE_POLICIES", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:CRL_DISTRIBUTION_POINTS", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "crlDistributionPoints", + "commonExtensionValue": "crlDistributionPoints" + } + ] + } + }, + "name": "CertificateCommonExtensionName: CRL_DISTRIBUTION_POINTS", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:EXTENDED_KEY_USAGE", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "extendedKeyUsage", + "commonExtensionValue": "extendedKeyUsage" + } + ] + } + }, + "name": "CertificateCommonExtensionName: EXTENDED_KEY_USAGE", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:KEY_USAGE", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "keyUsage", + "commonExtensionValue": "keyUsage" + } + ] + } + }, + "name": "CertificateCommonExtensionName: KEY_USAGE", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:SIGNED_CERTIFICATE_TIMESTAMP", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "signedCertificateTimestamp", + "commonExtensionValue": "signedCertificateTimestamp" + } + ] + } + }, + "name": "CertificateCommonExtensionName: SIGNED_CERTIFICATE_TIMESTAMP", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:SUBJECT_ALTERNATIVE_NAME", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "subjectAlternativeName", + "commonExtensionValue": "subjectAlternativeName" + } + ] + } + }, + "name": "CertificateCommonExtensionName: SUBJECT_ALTERNATIVE_NAME", + "type": "cryptographic-asset" + }, + { + "bom-ref": "dummy-CCEN:SUBJECT_KEY_IDENTIFIER", + "cryptoProperties": { + "assetType": "certificate", + "certificateProperties": { + "certificateExtensions": [ + { + "commonExtensionName": "subjectKeyIdentifier", + "commonExtensionValue": "subjectKeyIdentifier" + } + ] + } + }, + "name": "CertificateCommonExtensionName: SUBJECT_KEY_IDENTIFIER", + "type": "cryptographic-asset" + } + ], + "dependencies": [ + { + "ref": "dummy-CCEN:AUTHORITY_INFORMATION_ACCESS" + }, + { + "ref": "dummy-CCEN:AUTHORITY_KEY_IDENTIFIER" + }, + { + "ref": "dummy-CCEN:BASIC_CONSTRAINTS" + }, + { + "ref": "dummy-CCEN:CERTIFICATE_POLICIES" + }, + { + "ref": "dummy-CCEN:CRL_DISTRIBUTION_POINTS" + }, + { + "ref": "dummy-CCEN:EXTENDED_KEY_USAGE" + }, + { + "ref": "dummy-CCEN:KEY_USAGE" + }, + { + "ref": "dummy-CCEN:SIGNED_CERTIFICATE_TIMESTAMP" + }, + { + "ref": "dummy-CCEN:SUBJECT_ALTERNATIVE_NAME" + }, + { + "ref": "dummy-CCEN:SUBJECT_KEY_IDENTIFIER" + } + ], + "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_CertificateCommonExtensionName-1.7.xml.bin b/tests/_data/snapshots/enum_CertificateCommonExtensionName-1.7.xml.bin new file mode 100644 index 000000000..cb70f66d5 --- /dev/null +++ b/tests/_data/snapshots/enum_CertificateCommonExtensionName-1.7.xml.bin @@ -0,0 +1,164 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + CertificateCommonExtensionName: AUTHORITY_INFORMATION_ACCESS + + certificate + + + + authorityInformationAccess + authorityInformationAccess + + + + + + + CertificateCommonExtensionName: AUTHORITY_KEY_IDENTIFIER + + certificate + + + + authorityKeyIdentifier + authorityKeyIdentifier + + + + + + + CertificateCommonExtensionName: BASIC_CONSTRAINTS + + certificate + + + + basicConstraints + basicConstraints + + + + + + + CertificateCommonExtensionName: CERTIFICATE_POLICIES + + certificate + + + + certificatePolicies + certificatePolicies + + + + + + + CertificateCommonExtensionName: CRL_DISTRIBUTION_POINTS + + certificate + + + + crlDistributionPoints + crlDistributionPoints + + + + + + + CertificateCommonExtensionName: EXTENDED_KEY_USAGE + + certificate + + + + extendedKeyUsage + extendedKeyUsage + + + + + + + CertificateCommonExtensionName: KEY_USAGE + + certificate + + + + keyUsage + keyUsage + + + + + + + CertificateCommonExtensionName: SIGNED_CERTIFICATE_TIMESTAMP + + certificate + + + + signedCertificateTimestamp + signedCertificateTimestamp + + + + + + + CertificateCommonExtensionName: SUBJECT_ALTERNATIVE_NAME + + certificate + + + + subjectAlternativeName + subjectAlternativeName + + + + + + + CertificateCommonExtensionName: SUBJECT_KEY_IDENTIFIER + + certificate + + + + subjectKeyIdentifier + subjectKeyIdentifier + + + + + + + + + + + + + + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin index 22f6544cd..4965acbb7 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin @@ -7,6 +7,16 @@ "certificateProperties": { "activationDate": "2023-05-19T01:00:00+00:00", "certificateExtension": "csr", + "certificateExtensions": [ + { + "commonExtensionName": "keyUsage", + "commonExtensionValue": "digitalSignature" + }, + { + "customExtensionName": "1.2.3.4.5", + "customExtensionValue": "custom-value" + } + ], "certificateFileExtension": "pem", "certificateFormat": "pem", "certificateState": [ diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin index 71ecff65c..7d6b6b541 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin @@ -33,6 +33,16 @@ 2024-05-18T12:00:00+00:00 2024-05-18T13:00:00+00:00 2024-05-18T14:00:00+00:00 + + + keyUsage + digitalSignature + + + 1.2.3.4.5 + custom-value + + an-oid-here diff --git a/tests/test_enums.py b/tests/test_enums.py index 9ffa8e6ac..728a82d7c 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -94,6 +94,8 @@ VulnerabilitySeverity, ) from cyclonedx.model.crypto import ( # isort:skip + CertificateCommonExtension, + CertificateCommonExtensionName, CertificateLifecycleState, CertificatePredefinedState, CertificateProperties, @@ -913,6 +915,39 @@ def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, super()._test_cases_render(bom, of, sv) +@ddt +class TestEnumCertificateCommonExtensionName(_EnumTestCase): + + @idata(dp_cases_from_json_schemas( + 'definitions', 'cryptoProperties', 'properties', 'certificateProperties', 'properties', + 'certificateExtensions', 'items', 'oneOf', 0, 'properties', 'commonExtensionName' + )) + def test_knows_value(self, value: str) -> None: + super()._test_knows_value(CertificateCommonExtensionName, value) + + @named_data(*(d for d in NAMED_OF_SV if d[2] is SchemaVersion.V1_7)) + def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, **__: Any) -> None: + bom = _make_bom( + components=[ + Component( + name=f'CertificateCommonExtensionName: {extension.name}', + bom_ref=f'dummy-CCEN:{extension.name}', + type=ComponentType.CRYPTOGRAPHIC_ASSET, + crypto_properties=CryptoProperties( + asset_type=CryptoAssetType.CERTIFICATE, + certificate_properties=CertificateProperties( + certificate_extensions=[CertificateCommonExtension( + common_extension_name=extension, + common_extension_value=extension.value, + )] + ), + ), + ) for extension in CertificateCommonExtensionName + ] + ) + super()._test_cases_render(bom, of, sv) + + @ddt class TestEnumRelatedCryptoMaterialType(_EnumTestCase): diff --git a/tests/test_model_crypto.py b/tests/test_model_crypto.py index f9b1a27af..1f0fd77ea 100644 --- a/tests/test_model_crypto.py +++ b/tests/test_model_crypto.py @@ -24,6 +24,9 @@ from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.crypto import ( AlgorithmProperties, + CertificateCommonExtension, + CertificateCommonExtensionName, + CertificateCustomExtension, CertificateCustomState, CertificateLifecycleState, CertificatePredefinedState, @@ -55,6 +58,38 @@ def test_algorithm_properties_sorting(self) -> None: class TestModelCertificateProperties(TestCase): + def test_certificate_extensions_are_gated_deterministic_and_round_trip(self) -> None: + common = CertificateCommonExtension( + common_extension_name=CertificateCommonExtensionName.KEY_USAGE, + common_extension_value='digitalSignature', + ) + custom = CertificateCustomExtension( + custom_extension_name='1.2.3.4.5', + custom_extension_value='custom-value', + ) + properties = CertificateProperties(certificate_extensions=[custom, common]) + + self.assertEqual([common, custom], list(properties.certificate_extensions)) + self.assertNotEqual(properties, CertificateProperties()) + self.assertNotEqual(hash(properties), hash(CertificateProperties())) + self.assertNotIn('certificateExtensions', json_loads(properties.as_json(view_=SchemaVersion1Dot6))) + + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + from_json = CertificateProperties.from_json(json_v1_7) + self.assertEqual(properties, from_json) + self.assertEqual( + {CertificateCommonExtension, CertificateCustomExtension}, + {type(extension) for extension in from_json.certificate_extensions}, + ) + + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + from_xml = CertificateProperties.from_xml(xml_v1_7) + self.assertEqual(properties, from_xml) + self.assertEqual( + {CertificateCommonExtension, CertificateCustomExtension}, + {type(extension) for extension in from_xml.certificate_extensions}, + ) + def test_lifecycle_dates_are_gated_ordered_and_round_trip(self) -> None: properties = CertificateProperties( creation_date=datetime(2023, 5, 18, 12, 0, tzinfo=timezone.utc), From 546d0b49a3b3137d537352238898a3cb1b12d9d9 Mon Sep 17 00:00:00 2001 From: Quentin Kaiser Date: Tue, 14 Jul 2026 12:25:08 +0200 Subject: [PATCH 7/7] feat(crypto): support cryptographic asset relationships Deprecated direct references cannot describe all relationships between certificates, related material, algorithms, and keys. Use reusable typed references in deterministic collections, gate them to 1.7, and retain every deprecated reference unchanged for compatibility with existing 1.6 and 1.7 consumers. Refs: CycloneDX 1.7 `relatedCryptographicAssets`. Signed-off-by: Quentin Kaiser --- cyclonedx/model/crypto.py | 90 +++++++++++++++++-- tests/_data/models.py | 16 +++- ..._v1_6_with_crypto_certificate-1.6.json.bin | 4 +- ...m_v1_6_with_crypto_certificate-1.6.xml.bin | 2 + ..._v1_6_with_crypto_certificate-1.7.json.bin | 14 ++- ...m_v1_6_with_crypto_certificate-1.7.xml.bin | 12 +++ ..._with_crypto_related_material-1.6.json.bin | 2 + ...6_with_crypto_related_material-1.6.xml.bin | 2 + ..._with_crypto_related_material-1.7.json.bin | 8 ++ ...6_with_crypto_related_material-1.7.xml.bin | 8 ++ tests/test_model_crypto.py | 63 +++++++++++++ 11 files changed, 209 insertions(+), 12 deletions(-) diff --git a/cyclonedx/model/crypto.py b/cyclonedx/model/crypto.py index 2b400f59f..a77bbe2b5 100644 --- a/cyclonedx/model/crypto.py +++ b/cyclonedx/model/crypto.py @@ -892,6 +892,53 @@ def _certificate_extension_from_xml( _CertificateExtension.from_xml = classmethod(_certificate_extension_from_xml) # type:ignore[assignment] +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class RelatedCryptographicAsset: + """A typed reference to another cryptographic asset.""" + + def __init__(self, *, type: Optional[str] = None, ref: Optional[BomRef] = None) -> None: + self.type = type + self.ref = ref + + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(10) + def type(self) -> Optional[str]: + return self._type + + @type.setter + def type(self, type: Optional[str]) -> None: + self._type = type + + @property + @serializable.type_mapping(BomRef) + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_sequence(20) + def ref(self) -> Optional[BomRef]: + return self._ref + + @ref.setter + def ref(self, ref: Optional[BomRef]) -> None: + self._ref = ref + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple((self.type, self.ref)) + + def __eq__(self, other: object) -> bool: + return isinstance(other, RelatedCryptographicAsset) and self.__comparable_tuple() == other.__comparable_tuple() + + def __lt__(self, other: object) -> bool: + if isinstance(other, RelatedCryptographicAsset): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + @serializable.serializable_class(ignore_unknown_during_deserialization=True) class CertificateProperties: """ @@ -926,6 +973,7 @@ def __init__( revocation_date: Optional[datetime] = None, destruction_date: Optional[datetime] = None, certificate_extensions: Optional[Iterable[_CertificateExtension]] = None, + related_cryptographic_assets: Optional[Iterable[RelatedCryptographicAsset]] = None, ) -> None: self.serial_number = serial_number self.subject_name = subject_name @@ -945,6 +993,7 @@ def __init__( self.revocation_date = revocation_date self.destruction_date = destruction_date self.certificate_extensions = certificate_extensions or [] + self.related_cryptographic_assets = related_cryptographic_assets or [] @property @serializable.view(SchemaVersion1Dot7) @@ -1188,19 +1237,32 @@ def certificate_extensions(self) -> 'SortedSet[_CertificateExtension]': def certificate_extensions(self, certificate_extensions: Iterable[_CertificateExtension]) -> None: self._certificate_extensions = SortedSet(certificate_extensions) + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'relatedCryptographicAsset') + @serializable.xml_sequence(190) + def related_cryptographic_assets(self) -> 'SortedSet[RelatedCryptographicAsset]': + """Cryptographic assets related to this certificate.""" + return self._related_cryptographic_assets + + @related_cryptographic_assets.setter + def related_cryptographic_assets( + self, related_cryptographic_assets: Iterable[RelatedCryptographicAsset] + ) -> None: + self._related_cryptographic_assets = SortedSet(related_cryptographic_assets) + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.serial_number, self.subject_name, self.issuer_name, self.not_valid_before, self.not_valid_after, - self.certificate_format, self.certificate_extension, self.certificate_file_extension, self.fingerprint, + self.signature_algorithm_ref, self.subject_public_key_ref, self.certificate_format, + self.certificate_extension, self.certificate_file_extension, self.fingerprint, _ComparableTuple(self.certificate_states), self.creation_date, self.activation_date, self.deactivation_date, self.revocation_date, self.destruction_date, - _ComparableTuple(self.certificate_extensions), + _ComparableTuple(self.certificate_extensions), _ComparableTuple(self.related_cryptographic_assets), )) def __eq__(self, other: object) -> bool: - if isinstance(other, CertificateProperties): - return self.__comparable_tuple() == other.__comparable_tuple() - return False + return isinstance(other, CertificateProperties) and self.__comparable_tuple() == other.__comparable_tuple() def __lt__(self, other: object) -> bool: if isinstance(other, CertificateProperties): @@ -1375,6 +1437,7 @@ def __init__( format: Optional[str] = None, secured_by: Optional[RelatedCryptoMaterialSecuredBy] = None, fingerprint: Optional[HashType] = None, + related_cryptographic_assets: Optional[Iterable[RelatedCryptographicAsset]] = None, ) -> None: self.type = type self.id = id @@ -1389,6 +1452,7 @@ def __init__( self.format = format self.secured_by = secured_by self.fingerprint = fingerprint + self.related_cryptographic_assets = related_cryptographic_assets or [] @property @serializable.xml_sequence(10) @@ -1586,11 +1650,25 @@ def fingerprint(self) -> Optional[HashType]: def fingerprint(self, fingerprint: Optional[HashType]) -> None: self._fingerprint = fingerprint + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'relatedCryptographicAsset') + @serializable.xml_sequence(140) + def related_cryptographic_assets(self) -> 'SortedSet[RelatedCryptographicAsset]': + """Cryptographic assets related to this material.""" + return self._related_cryptographic_assets + + @related_cryptographic_assets.setter + def related_cryptographic_assets( + self, related_cryptographic_assets: Iterable[RelatedCryptographicAsset] + ) -> None: + self._related_cryptographic_assets = SortedSet(related_cryptographic_assets) + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( self.type, self.id, self.state, self.algorithm_ref, self.creation_date, self.activation_date, self.update_date, self.expiration_date, self.value, self.size, self.format, self.secured_by, - self.fingerprint, + self.fingerprint, _ComparableTuple(self.related_cryptographic_assets), )) def __eq__(self, other: object) -> bool: diff --git a/tests/_data/models.py b/tests/_data/models.py index 250ed778a..b4ddb4bf9 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -90,6 +90,7 @@ ProtocolProperties, ProtocolPropertiesCipherSuite, ProtocolPropertiesType, + RelatedCryptographicAsset, RelatedCryptoMaterialProperties, RelatedCryptoMaterialSecuredBy, RelatedCryptoMaterialState, @@ -211,8 +212,8 @@ def get_crypto_properties_certificate() -> CryptoProperties: tzinfo=timezone.utc), not_valid_after=datetime(year=2024, month=5, day=19, hour=0, minute=59, second=59, microsecond=999999, tzinfo=timezone.utc), - signature_algorithm_ref=None, - subject_public_key_ref=None, + signature_algorithm_ref=BomRef('signature-algorithm'), + subject_public_key_ref=BomRef('subject-public-key'), certificate_format='pem', certificate_extension='csr', certificate_file_extension='pem', @@ -243,6 +244,10 @@ def get_crypto_properties_certificate() -> CryptoProperties: custom_extension_value='custom-value', ), ], + related_cryptographic_assets=[ + RelatedCryptographicAsset(type='algorithm', ref=BomRef('signature-algorithm')), + RelatedCryptographicAsset(type='publicKey', ref=BomRef('subject-public-key')), + ], ), oid='an-oid-here' ) @@ -304,7 +309,7 @@ def get_crypto_properties_related_material() -> CryptoProperties: type=RelatedCryptoMaterialType.DIGEST, id='some-identifier', state=RelatedCryptoMaterialState.ACTIVE, - algorithm_ref=None, + algorithm_ref=BomRef('material-algorithm'), creation_date=datetime(year=2023, month=5, day=19, hour=1, minute=0, second=0, microsecond=0, tzinfo=timezone.utc), activation_date=datetime(year=2023, month=5, day=19, hour=1, minute=0, second=0, microsecond=0, @@ -317,9 +322,12 @@ def get_crypto_properties_related_material() -> CryptoProperties: format='a-format', secured_by=RelatedCryptoMaterialSecuredBy( mechanism='hard-work', - algorithm_ref=None + algorithm_ref=BomRef('securing-algorithm'), ), fingerprint=HashType(alg=HashAlgorithm.SHA_256, content='b' * 64), + related_cryptographic_assets=[ + RelatedCryptographicAsset(type='algorithm', ref=BomRef('material-algorithm')), + ], ), oid='an-oid-here' ) diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.6.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.6.json.bin index bb1fdf249..2e2311a45 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.6.json.bin @@ -10,7 +10,9 @@ "issuerName": "Cloudflare Inc ECC CA-3", "notValidAfter": "2024-05-19T00:59:59.999999+00:00", "notValidBefore": "2023-05-19T01:00:00+00:00", - "subjectName": "cyclonedx.org" + "signatureAlgorithmRef": "signature-algorithm", + "subjectName": "cyclonedx.org", + "subjectPublicKeyRef": "subject-public-key" }, "oid": "an-oid-here" }, diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.6.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.6.xml.bin index 777628923..dec42a5cb 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.6.xml.bin @@ -14,6 +14,8 @@ Cloudflare Inc ECC CA-3 2023-05-19T01:00:00+00:00 2024-05-19T00:59:59.999999+00:00 + signature-algorithm + subject-public-key pem csr diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin index 4965acbb7..2078bf2b1 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.json.bin @@ -40,9 +40,21 @@ "issuerName": "Cloudflare Inc ECC CA-3", "notValidAfter": "2024-05-19T00:59:59.999999+00:00", "notValidBefore": "2023-05-19T01:00:00+00:00", + "relatedCryptographicAssets": [ + { + "ref": "signature-algorithm", + "type": "algorithm" + }, + { + "ref": "subject-public-key", + "type": "publicKey" + } + ], "revocationDate": "2024-05-18T13:00:00+00:00", "serialNumber": "3942447fac867ae5cdb3229b658f4d48", - "subjectName": "cyclonedx.org" + "signatureAlgorithmRef": "signature-algorithm", + "subjectName": "cyclonedx.org", + "subjectPublicKeyRef": "subject-public-key" }, "oid": "an-oid-here" }, diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin index 7d6b6b541..cc7576496 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_certificate-1.7.xml.bin @@ -15,6 +15,8 @@ Cloudflare Inc ECC CA-3 2023-05-19T01:00:00+00:00 2024-05-19T00:59:59.999999+00:00 + signature-algorithm + subject-public-key pem csr pem @@ -43,6 +45,16 @@ custom-value + + + algorithm + signature-algorithm + + + publicKey + subject-public-key + + an-oid-here diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.6.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.6.json.bin index 07cee9c2c..708d4234f 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.6.json.bin @@ -7,11 +7,13 @@ "oid": "an-oid-here", "relatedCryptoMaterialProperties": { "activationDate": "2023-05-19T01:00:00+00:00", + "algorithmRef": "material-algorithm", "creationDate": "2023-05-19T01:00:00+00:00", "expirationDate": "2024-05-19T00:59:59.999999+00:00", "format": "a-format", "id": "some-identifier", "securedBy": { + "algorithmRef": "securing-algorithm", "mechanism": "hard-work" }, "size": 32, diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.6.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.6.xml.bin index dca04e899..0c8ab3dd1 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.6.xml.bin @@ -13,6 +13,7 @@ digest some-identifier active + material-algorithm 2023-05-19T01:00:00+00:00 2023-05-19T01:00:00+00:00 2024-05-19T00:59:59.999999+00:00 @@ -21,6 +22,7 @@ a-format hard-work + securing-algorithm an-oid-here diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.json.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.json.bin index 45fb254fe..776e36f89 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.json.bin @@ -7,6 +7,7 @@ "oid": "an-oid-here", "relatedCryptoMaterialProperties": { "activationDate": "2023-05-19T01:00:00+00:00", + "algorithmRef": "material-algorithm", "creationDate": "2023-05-19T01:00:00+00:00", "expirationDate": "2024-05-19T00:59:59.999999+00:00", "fingerprint": { @@ -15,7 +16,14 @@ }, "format": "a-format", "id": "some-identifier", + "relatedCryptographicAssets": [ + { + "ref": "material-algorithm", + "type": "algorithm" + } + ], "securedBy": { + "algorithmRef": "securing-algorithm", "mechanism": "hard-work" }, "size": 32, diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.xml.bin index feda08b86..1e80dc869 100644 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_v1_6_with_crypto_related_material-1.7.xml.bin @@ -13,6 +13,7 @@ digest some-identifier active + material-algorithm 2023-05-19T01:00:00+00:00 2023-05-19T01:00:00+00:00 2024-05-19T00:59:59.999999+00:00 @@ -21,8 +22,15 @@ a-format hard-work + securing-algorithm bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + + + algorithm + material-algorithm + + an-oid-here diff --git a/tests/test_model_crypto.py b/tests/test_model_crypto.py index 1f0fd77ea..0bd515718 100644 --- a/tests/test_model_crypto.py +++ b/tests/test_model_crypto.py @@ -35,6 +35,7 @@ Ikev2TransformTypes, ProtocolProperties, ProtocolPropertiesType, + RelatedCryptographicAsset, RelatedCryptoMaterialProperties, RelatedCryptoMaterialSecuredBy, RelatedCryptoMaterialType, @@ -58,6 +59,40 @@ def test_algorithm_properties_sorting(self) -> None: class TestModelCertificateProperties(TestCase): + def test_related_assets_are_gated_deterministic_and_preserve_deprecated_refs(self) -> None: + algorithm = RelatedCryptographicAsset(type='algorithm', ref=BomRef('signature')) + public_key = RelatedCryptographicAsset(type='publicKey', ref=BomRef('public-key')) + properties = CertificateProperties( + signature_algorithm_ref=BomRef('signature'), + subject_public_key_ref=BomRef('public-key'), + related_cryptographic_assets=[public_key, algorithm], + ) + + self.assertEqual([algorithm, public_key], list(properties.related_cryptographic_assets)) + self.assertNotEqual(properties, CertificateProperties()) + self.assertNotEqual(hash(properties), hash(CertificateProperties())) + + json_v1_6 = json_loads(properties.as_json(view_=SchemaVersion1Dot6)) + self.assertNotIn('relatedCryptographicAssets', json_v1_6) + self.assertEqual('signature', json_v1_6['signatureAlgorithmRef']) + self.assertEqual('public-key', json_v1_6['subjectPublicKeyRef']) + + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + self.assertEqual('signature', json_v1_7['signatureAlgorithmRef']) + self.assertEqual('public-key', json_v1_7['subjectPublicKeyRef']) + self.assertEqual(properties, CertificateProperties.from_json(json_v1_7)) + + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, CertificateProperties.from_xml(xml_v1_7)) + + def test_related_cryptographic_asset_comparison_and_hashing(self) -> None: + first = RelatedCryptographicAsset(type='algorithm', ref=BomRef('a')) + second = RelatedCryptographicAsset(type='publicKey', ref=BomRef('b')) + + self.assertNotEqual(first, second) + self.assertNotEqual(hash(first), hash(second)) + self.assertEqual([first, second], sorted([second, first])) + def test_certificate_extensions_are_gated_deterministic_and_round_trip(self) -> None: common = CertificateCommonExtension( common_extension_name=CertificateCommonExtensionName.KEY_USAGE, @@ -230,6 +265,34 @@ def test_related_crypto_material_secured_by_sorting(self) -> None: class TestModelRelatedCryptoMaterialProperties(TestCase): + def test_related_assets_are_gated_deterministic_and_preserve_deprecated_refs(self) -> None: + related_asset = RelatedCryptographicAsset(type='algorithm', ref=BomRef('material-algorithm')) + properties = RelatedCryptoMaterialProperties( + algorithm_ref=BomRef('material-algorithm'), + secured_by=RelatedCryptoMaterialSecuredBy( + mechanism='HSM', + algorithm_ref=BomRef('securing-algorithm'), + ), + related_cryptographic_assets=[related_asset], + ) + + self.assertEqual([related_asset], list(properties.related_cryptographic_assets)) + self.assertNotEqual(properties, RelatedCryptoMaterialProperties()) + self.assertNotEqual(hash(properties), hash(RelatedCryptoMaterialProperties())) + + json_v1_6 = json_loads(properties.as_json(view_=SchemaVersion1Dot6)) + self.assertNotIn('relatedCryptographicAssets', json_v1_6) + self.assertEqual('material-algorithm', json_v1_6['algorithmRef']) + self.assertEqual('securing-algorithm', json_v1_6['securedBy']['algorithmRef']) + + json_v1_7 = json_loads(properties.as_json(view_=SchemaVersion1Dot7)) + self.assertEqual('material-algorithm', json_v1_7['algorithmRef']) + self.assertEqual('securing-algorithm', json_v1_7['securedBy']['algorithmRef']) + self.assertEqual(properties, RelatedCryptoMaterialProperties.from_json(json_v1_7)) + + xml_v1_7 = xml_fromstring(properties.as_xml(view_=SchemaVersion1Dot7)) + self.assertEqual(properties, RelatedCryptoMaterialProperties.from_xml(xml_v1_7)) + def test_fingerprint_version_gating_comparison_and_round_trip(self) -> None: fingerprint = HashType(alg=HashAlgorithm.SHA_256, content='b' * 64) properties = RelatedCryptoMaterialProperties(fingerprint=fingerprint)