Skip to main content

sbom_tools/model/
crypto.rs

1//! Cryptographic Bill of Materials (CBOM) data structures.
2//!
3//! Format-agnostic representation of cryptographic assets as defined by
4//! CycloneDX 1.6+ `cryptoProperties`. Supports four asset types:
5//! algorithms, certificates, key material, and protocols.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10// ── Top-level CryptoProperties ──────────────────────────────────────────
11
12/// Cryptographic properties for a component of type `cryptographic-asset`.
13///
14/// Mirrors the CycloneDX 1.6+ `cryptoProperties` object. Exactly one of
15/// the four property sub-structs should be populated, matching `asset_type`.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[non_exhaustive]
18pub struct CryptoProperties {
19    /// The type of cryptographic asset.
20    pub asset_type: CryptoAssetType,
21    /// Object Identifier (OID) for unambiguous algorithm identification.
22    pub oid: Option<String>,
23    /// Properties specific to algorithm assets.
24    pub algorithm_properties: Option<AlgorithmProperties>,
25    /// Properties specific to certificate assets.
26    pub certificate_properties: Option<CertificateProperties>,
27    /// Properties specific to key material assets.
28    pub related_crypto_material_properties: Option<RelatedCryptoMaterialProperties>,
29    /// Properties specific to protocol assets.
30    pub protocol_properties: Option<ProtocolProperties>,
31}
32
33impl CryptoProperties {
34    /// Create new crypto properties with the given asset type.
35    #[must_use]
36    pub fn new(asset_type: CryptoAssetType) -> Self {
37        Self {
38            asset_type,
39            oid: None,
40            algorithm_properties: None,
41            certificate_properties: None,
42            related_crypto_material_properties: None,
43            protocol_properties: None,
44        }
45    }
46
47    #[must_use]
48    pub fn with_oid(mut self, oid: String) -> Self {
49        self.oid = Some(oid);
50        self
51    }
52
53    #[must_use]
54    pub fn with_algorithm_properties(mut self, props: AlgorithmProperties) -> Self {
55        self.algorithm_properties = Some(props);
56        self
57    }
58
59    #[must_use]
60    pub fn with_certificate_properties(mut self, props: CertificateProperties) -> Self {
61        self.certificate_properties = Some(props);
62        self
63    }
64
65    #[must_use]
66    pub fn with_related_crypto_material_properties(
67        mut self,
68        props: RelatedCryptoMaterialProperties,
69    ) -> Self {
70        self.related_crypto_material_properties = Some(props);
71        self
72    }
73
74    #[must_use]
75    pub fn with_protocol_properties(mut self, props: ProtocolProperties) -> Self {
76        self.protocol_properties = Some(props);
77        self
78    }
79}
80
81// ── Asset Type ──────────────────────────────────────────────────────────
82
83/// Type of cryptographic asset.
84#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
85#[non_exhaustive]
86pub enum CryptoAssetType {
87    Algorithm,
88    Certificate,
89    RelatedCryptoMaterial,
90    Protocol,
91    Other(String),
92}
93
94impl std::fmt::Display for CryptoAssetType {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::Algorithm => write!(f, "algorithm"),
98            Self::Certificate => write!(f, "certificate"),
99            Self::RelatedCryptoMaterial => write!(f, "related-crypto-material"),
100            Self::Protocol => write!(f, "protocol"),
101            Self::Other(s) => write!(f, "{s}"),
102        }
103    }
104}
105
106// ── Algorithm Properties ────────────────────────────────────────────────
107
108/// Properties of a cryptographic algorithm asset.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[non_exhaustive]
111pub struct AlgorithmProperties {
112    /// Cryptographic primitive category.
113    pub primitive: CryptoPrimitive,
114    /// Algorithm family name (e.g., "AES", "ML-KEM", "SHA-2"). CycloneDX 1.7+.
115    pub algorithm_family: Option<String>,
116    /// Parameter set identifier (e.g., "256", "1024", "P-384").
117    pub parameter_set_identifier: Option<String>,
118    /// Block cipher mode of operation.
119    pub mode: Option<CryptoMode>,
120    /// Padding scheme.
121    pub padding: Option<CryptoPadding>,
122    /// Cryptographic functions this algorithm supports.
123    pub crypto_functions: Vec<CryptoFunction>,
124    /// Execution environment.
125    pub execution_environment: Option<ExecutionEnvironment>,
126    /// Implementation platform.
127    pub implementation_platform: Option<ImplementationPlatform>,
128    /// Certification levels achieved.
129    pub certification_level: Vec<CertificationLevel>,
130    /// Classical security level in bits.
131    pub classical_security_level: Option<u32>,
132    /// NIST post-quantum security category (0 = vulnerable, 1-5 = increasing resistance).
133    pub nist_quantum_security_level: Option<u8>,
134    /// Elliptic curve identifier (CycloneDX 1.7+, e.g., "secg/secp521r1").
135    pub elliptic_curve: Option<String>,
136}
137
138impl AlgorithmProperties {
139    /// Create new algorithm properties with the given primitive.
140    #[must_use]
141    pub fn new(primitive: CryptoPrimitive) -> Self {
142        Self {
143            primitive,
144            algorithm_family: None,
145            parameter_set_identifier: None,
146            mode: None,
147            padding: None,
148            crypto_functions: Vec::new(),
149            execution_environment: None,
150            implementation_platform: None,
151            certification_level: Vec::new(),
152            classical_security_level: None,
153            nist_quantum_security_level: None,
154            elliptic_curve: None,
155        }
156    }
157
158    /// Returns `true` if this algorithm has post-quantum security
159    /// (`nistQuantumSecurityLevel > 0`).
160    #[must_use]
161    pub fn is_quantum_safe(&self) -> bool {
162        self.nist_quantum_security_level.is_some_and(|l| l > 0)
163    }
164
165    /// Returns `true` if this is a hybrid PQC scheme (combiner primitive).
166    #[must_use]
167    pub fn is_hybrid_pqc(&self) -> bool {
168        self.primitive == CryptoPrimitive::Combiner
169    }
170
171    /// Returns `true` if this is a CLASSICAL public-key algorithm broken by a
172    /// cryptographically-relevant quantum computer (Shor's algorithm): RSA,
173    /// finite-field / elliptic-curve Diffie-Hellman, DSA/ECDSA/EdDSA, ElGamal.
174    ///
175    /// These are quantum-vulnerable regardless of key size or a declared
176    /// `nistQuantumSecurityLevel` — the family alone is authoritative. Matched
177    /// on `algorithm_family` (case-insensitive); the PQC families (ML-KEM,
178    /// ML-DSA, SLH-DSA, …) are not in the list and correctly return `false`.
179    #[must_use]
180    pub fn is_classical_quantum_vulnerable(&self) -> bool {
181        const CLASSICAL_PK: &[&str] = &[
182            "RSA", "DSA", "DH", "DHE", "ECDH", "ECDHE", "ECDSA", "EDDSA", "ED25519", "ED448",
183            "X25519", "X448", "ELGAMAL", "ECIES", "ECMQV",
184        ];
185        self.algorithm_family.as_deref().is_some_and(|f| {
186            let upper = f.to_uppercase();
187            CLASSICAL_PK.iter().any(|c| upper == *c)
188        })
189    }
190
191    /// Returns `true` if the algorithm is considered broken or weak.
192    /// Checks `algorithm_family` first, then falls back to matching
193    /// common weak names in the `parameter_set_identifier`.
194    #[must_use]
195    pub fn is_weak(&self) -> bool {
196        /// Unconditionally broken/weak algorithm families.
197        const WEAK_FAMILIES: &[&str] = &[
198            "MD5", "MD4", "MD2", "SHA-1", "DES", "3DES", "TDEA", "RC2", "RC4", "BLOWFISH", "IDEA",
199            "CAST5",
200        ];
201
202        if let Some(family) = &self.algorithm_family {
203            let upper = family.to_uppercase();
204            if WEAK_FAMILIES.iter().any(|w| upper == *w) {
205                return true;
206            }
207        }
208        false
209    }
210
211    /// Returns `true` if the algorithm is considered broken or weak,
212    /// using the component name as a fallback when `algorithm_family` is absent.
213    #[must_use]
214    pub fn is_weak_by_name(&self, component_name: &str) -> bool {
215        if self.is_weak() {
216            return true;
217        }
218        // Fallback: check component name for weak algorithm patterns
219        let upper = component_name.to_uppercase();
220        upper.starts_with("MD5")
221            || upper.starts_with("MD4")
222            || upper.starts_with("SHA-1")
223            || upper.starts_with("DES")
224            || upper.starts_with("3DES")
225            || upper.starts_with("RC4")
226            || upper.starts_with("RC2")
227            || upper.starts_with("BLOWFISH")
228    }
229
230    /// Returns the classical security level in bits, if known.
231    #[must_use]
232    pub fn effective_security_bits(&self) -> Option<u32> {
233        self.classical_security_level
234    }
235
236    #[must_use]
237    pub fn with_algorithm_family(mut self, family: String) -> Self {
238        self.algorithm_family = Some(family);
239        self
240    }
241
242    #[must_use]
243    pub fn with_parameter_set_identifier(mut self, id: String) -> Self {
244        self.parameter_set_identifier = Some(id);
245        self
246    }
247
248    #[must_use]
249    pub fn with_mode(mut self, mode: CryptoMode) -> Self {
250        self.mode = Some(mode);
251        self
252    }
253
254    #[must_use]
255    pub fn with_padding(mut self, padding: CryptoPadding) -> Self {
256        self.padding = Some(padding);
257        self
258    }
259
260    #[must_use]
261    pub fn with_crypto_functions(mut self, funcs: Vec<CryptoFunction>) -> Self {
262        self.crypto_functions = funcs;
263        self
264    }
265
266    #[must_use]
267    pub fn with_execution_environment(mut self, env: ExecutionEnvironment) -> Self {
268        self.execution_environment = Some(env);
269        self
270    }
271
272    #[must_use]
273    pub fn with_implementation_platform(mut self, platform: ImplementationPlatform) -> Self {
274        self.implementation_platform = Some(platform);
275        self
276    }
277
278    #[must_use]
279    pub fn with_certification_level(mut self, levels: Vec<CertificationLevel>) -> Self {
280        self.certification_level = levels;
281        self
282    }
283
284    #[must_use]
285    pub fn with_classical_security_level(mut self, bits: u32) -> Self {
286        self.classical_security_level = Some(bits);
287        self
288    }
289
290    #[must_use]
291    pub fn with_nist_quantum_security_level(mut self, level: u8) -> Self {
292        self.nist_quantum_security_level = Some(level);
293        self
294    }
295
296    #[must_use]
297    pub fn with_elliptic_curve(mut self, curve: String) -> Self {
298        self.elliptic_curve = Some(curve);
299        self
300    }
301}
302
303// ── Certificate Properties ──────────────────────────────────────────────
304
305/// Properties of a digital certificate asset.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[non_exhaustive]
308pub struct CertificateProperties {
309    /// Certificate subject distinguished name.
310    pub subject_name: Option<String>,
311    /// Certificate issuer distinguished name.
312    pub issuer_name: Option<String>,
313    /// Start of validity period.
314    pub not_valid_before: Option<DateTime<Utc>>,
315    /// End of validity period.
316    pub not_valid_after: Option<DateTime<Utc>>,
317    /// Bom-ref of the signature algorithm component.
318    pub signature_algorithm_ref: Option<String>,
319    /// Bom-ref of the subject public key component.
320    pub subject_public_key_ref: Option<String>,
321    /// Certificate format (e.g., "X.509").
322    pub certificate_format: Option<String>,
323    /// Certificate file extension (e.g., "pem", "crt", "der").
324    pub certificate_extension: Option<String>,
325}
326
327impl CertificateProperties {
328    #[must_use]
329    pub fn new() -> Self {
330        Self {
331            subject_name: None,
332            issuer_name: None,
333            not_valid_before: None,
334            not_valid_after: None,
335            signature_algorithm_ref: None,
336            subject_public_key_ref: None,
337            certificate_format: None,
338            certificate_extension: None,
339        }
340    }
341
342    /// Returns `true` if the certificate has expired.
343    #[must_use]
344    pub fn is_expired(&self) -> bool {
345        self.not_valid_after
346            .is_some_and(|expiry| expiry < Utc::now())
347    }
348
349    /// Returns `true` if the certificate expires within the given number of days.
350    #[must_use]
351    pub fn is_expiring_soon(&self, days: u32) -> bool {
352        self.not_valid_after.is_some_and(|expiry| {
353            let threshold = Utc::now() + chrono::Duration::days(i64::from(days));
354            expiry <= threshold && expiry > Utc::now()
355        })
356    }
357
358    /// Returns remaining days until expiry, or `None` if no expiry date is set.
359    /// Returns negative values for already-expired certificates.
360    #[must_use]
361    pub fn validity_days(&self) -> Option<i64> {
362        self.not_valid_after
363            .map(|expiry| (expiry - Utc::now()).num_days())
364    }
365
366    #[must_use]
367    pub fn with_subject_name(mut self, name: String) -> Self {
368        self.subject_name = Some(name);
369        self
370    }
371
372    #[must_use]
373    pub fn with_issuer_name(mut self, name: String) -> Self {
374        self.issuer_name = Some(name);
375        self
376    }
377
378    #[must_use]
379    pub fn with_not_valid_before(mut self, dt: DateTime<Utc>) -> Self {
380        self.not_valid_before = Some(dt);
381        self
382    }
383
384    #[must_use]
385    pub fn with_not_valid_after(mut self, dt: DateTime<Utc>) -> Self {
386        self.not_valid_after = Some(dt);
387        self
388    }
389
390    #[must_use]
391    pub fn with_signature_algorithm_ref(mut self, r: String) -> Self {
392        self.signature_algorithm_ref = Some(r);
393        self
394    }
395
396    #[must_use]
397    pub fn with_subject_public_key_ref(mut self, r: String) -> Self {
398        self.subject_public_key_ref = Some(r);
399        self
400    }
401
402    #[must_use]
403    pub fn with_certificate_format(mut self, fmt: String) -> Self {
404        self.certificate_format = Some(fmt);
405        self
406    }
407
408    #[must_use]
409    pub fn with_certificate_extension(mut self, ext: String) -> Self {
410        self.certificate_extension = Some(ext);
411        self
412    }
413}
414
415impl Default for CertificateProperties {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421// ── Related Crypto Material Properties ──────────────────────────────────
422
423/// Properties of a cryptographic key or related material asset.
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[non_exhaustive]
426pub struct RelatedCryptoMaterialProperties {
427    /// Type of key material.
428    pub material_type: CryptoMaterialType,
429    /// Unique identifier for the material.
430    pub id: Option<String>,
431    /// Lifecycle state of the material.
432    pub state: Option<CryptoMaterialState>,
433    /// Key size in bits.
434    pub size: Option<u32>,
435    /// Bom-ref of the associated algorithm component.
436    pub algorithm_ref: Option<String>,
437    /// How this material is protected.
438    pub secured_by: Option<SecuredBy>,
439    /// Key encoding format (e.g., "PEM", "DER").
440    pub format: Option<String>,
441    /// When the material was created.
442    pub creation_date: Option<DateTime<Utc>>,
443    /// When the material was activated.
444    pub activation_date: Option<DateTime<Utc>>,
445    /// When the material was last updated.
446    pub update_date: Option<DateTime<Utc>>,
447    /// When the material expires.
448    pub expiration_date: Option<DateTime<Utc>>,
449}
450
451impl RelatedCryptoMaterialProperties {
452    #[must_use]
453    pub fn new(material_type: CryptoMaterialType) -> Self {
454        Self {
455            material_type,
456            id: None,
457            state: None,
458            size: None,
459            algorithm_ref: None,
460            secured_by: None,
461            format: None,
462            creation_date: None,
463            activation_date: None,
464            update_date: None,
465            expiration_date: None,
466        }
467    }
468
469    #[must_use]
470    pub fn with_id(mut self, id: String) -> Self {
471        self.id = Some(id);
472        self
473    }
474
475    #[must_use]
476    pub fn with_state(mut self, state: CryptoMaterialState) -> Self {
477        self.state = Some(state);
478        self
479    }
480
481    #[must_use]
482    pub fn with_size(mut self, bits: u32) -> Self {
483        self.size = Some(bits);
484        self
485    }
486
487    #[must_use]
488    pub fn with_algorithm_ref(mut self, r: String) -> Self {
489        self.algorithm_ref = Some(r);
490        self
491    }
492
493    #[must_use]
494    pub fn with_secured_by(mut self, secured: SecuredBy) -> Self {
495        self.secured_by = Some(secured);
496        self
497    }
498
499    #[must_use]
500    pub fn with_format(mut self, fmt: String) -> Self {
501        self.format = Some(fmt);
502        self
503    }
504
505    #[must_use]
506    pub fn with_creation_date(mut self, dt: DateTime<Utc>) -> Self {
507        self.creation_date = Some(dt);
508        self
509    }
510
511    #[must_use]
512    pub fn with_activation_date(mut self, dt: DateTime<Utc>) -> Self {
513        self.activation_date = Some(dt);
514        self
515    }
516
517    #[must_use]
518    pub fn with_expiration_date(mut self, dt: DateTime<Utc>) -> Self {
519        self.expiration_date = Some(dt);
520        self
521    }
522}
523
524// ── Protocol Properties ─────────────────────────────────────────────────
525
526/// Properties of a cryptographic protocol asset.
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528#[non_exhaustive]
529pub struct ProtocolProperties {
530    /// Protocol type.
531    pub protocol_type: ProtocolType,
532    /// Protocol version (e.g., "1.3" for TLS).
533    pub version: Option<String>,
534    /// Cipher suites supported by this protocol.
535    pub cipher_suites: Vec<CipherSuite>,
536    /// IKEv2 transform types (for IPsec protocols).
537    pub ikev2_transform_types: Option<Ikev2TransformTypes>,
538    /// Bom-refs of related crypto assets used by this protocol.
539    pub crypto_ref_array: Vec<String>,
540}
541
542impl ProtocolProperties {
543    #[must_use]
544    pub fn new(protocol_type: ProtocolType) -> Self {
545        Self {
546            protocol_type,
547            version: None,
548            cipher_suites: Vec::new(),
549            ikev2_transform_types: None,
550            crypto_ref_array: Vec::new(),
551        }
552    }
553
554    #[must_use]
555    pub fn with_version(mut self, version: String) -> Self {
556        self.version = Some(version);
557        self
558    }
559
560    #[must_use]
561    pub fn with_cipher_suites(mut self, suites: Vec<CipherSuite>) -> Self {
562        self.cipher_suites = suites;
563        self
564    }
565
566    #[must_use]
567    pub fn with_ikev2_transform_types(mut self, types: Ikev2TransformTypes) -> Self {
568        self.ikev2_transform_types = Some(types);
569        self
570    }
571
572    #[must_use]
573    pub fn with_crypto_ref_array(mut self, refs: Vec<String>) -> Self {
574        self.crypto_ref_array = refs;
575        self
576    }
577}
578
579// ── Supporting Structs ──────────────────────────────────────────────────
580
581/// A cipher suite within a protocol.
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct CipherSuite {
584    /// Cipher suite name (e.g., `"TLS_AES_256_GCM_SHA384"`).
585    pub name: Option<String>,
586    /// Bom-refs of the constituent algorithm components.
587    pub algorithms: Vec<String>,
588    /// IANA cipher suite identifiers (e.g., `["0x13", "0x02"]`).
589    pub identifiers: Vec<String>,
590}
591
592/// IKEv2 transform types for IPsec protocols (RFC 9370).
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct Ikev2TransformTypes {
595    /// Encryption algorithm bom-refs.
596    pub encr: Vec<String>,
597    /// Pseudorandom function bom-refs.
598    pub prf: Vec<String>,
599    /// Integrity algorithm bom-refs.
600    pub integ: Vec<String>,
601    /// Key exchange method bom-refs.
602    pub ke: Vec<String>,
603}
604
605/// How a cryptographic material is secured/protected.
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607pub struct SecuredBy {
608    /// Protection mechanism (e.g., "Software", "HSM").
609    pub mechanism: String,
610    /// Bom-ref of the protection algorithm, if applicable.
611    pub algorithm_ref: Option<String>,
612}
613
614// ── Enums ───────────────────────────────────────────────────────────────
615
616/// Cryptographic primitive type.
617#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
618#[non_exhaustive]
619pub enum CryptoPrimitive {
620    /// Authenticated encryption (e.g., AES-GCM).
621    Ae,
622    /// Block cipher (e.g., AES-CBC).
623    BlockCipher,
624    /// Stream cipher (e.g., ChaCha20).
625    StreamCipher,
626    /// Hash function (e.g., SHA-256).
627    Hash,
628    /// Message authentication code (e.g., HMAC).
629    Mac,
630    /// Digital signature (e.g., ECDSA, ML-DSA).
631    Signature,
632    /// Public-key encryption (e.g., RSA).
633    Pke,
634    /// Key encapsulation mechanism (e.g., ML-KEM).
635    Kem,
636    /// Key derivation function (e.g., HKDF).
637    Kdf,
638    /// Key agreement (e.g., ECDH, X25519).
639    KeyAgree,
640    /// Extendable output function (e.g., SHAKE).
641    Xof,
642    /// Deterministic random bit generator.
643    Drbg,
644    /// Hybrid combiner (classical + PQC).
645    Combiner,
646    Other(String),
647    Unknown,
648}
649
650impl std::fmt::Display for CryptoPrimitive {
651    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652        match self {
653            Self::Ae => write!(f, "ae"),
654            Self::BlockCipher => write!(f, "block-cipher"),
655            Self::StreamCipher => write!(f, "stream-cipher"),
656            Self::Hash => write!(f, "hash"),
657            Self::Mac => write!(f, "mac"),
658            Self::Signature => write!(f, "signature"),
659            Self::Pke => write!(f, "pke"),
660            Self::Kem => write!(f, "kem"),
661            Self::Kdf => write!(f, "kdf"),
662            Self::KeyAgree => write!(f, "key-agree"),
663            Self::Xof => write!(f, "xof"),
664            Self::Drbg => write!(f, "drbg"),
665            Self::Combiner => write!(f, "combiner"),
666            Self::Other(s) => write!(f, "{s}"),
667            Self::Unknown => write!(f, "unknown"),
668        }
669    }
670}
671
672/// Block cipher mode of operation.
673#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
674#[non_exhaustive]
675pub enum CryptoMode {
676    Ecb,
677    Cbc,
678    Ofb,
679    Cfb,
680    Ctr,
681    Gcm,
682    Ccm,
683    Xts,
684    Other(String),
685}
686
687impl std::fmt::Display for CryptoMode {
688    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
689        match self {
690            Self::Ecb => write!(f, "ecb"),
691            Self::Cbc => write!(f, "cbc"),
692            Self::Ofb => write!(f, "ofb"),
693            Self::Cfb => write!(f, "cfb"),
694            Self::Ctr => write!(f, "ctr"),
695            Self::Gcm => write!(f, "gcm"),
696            Self::Ccm => write!(f, "ccm"),
697            Self::Xts => write!(f, "xts"),
698            Self::Other(s) => write!(f, "{s}"),
699        }
700    }
701}
702
703/// Padding scheme.
704#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
705#[non_exhaustive]
706pub enum CryptoPadding {
707    Pkcs5,
708    Oaep,
709    Pss,
710    Other(String),
711}
712
713impl std::fmt::Display for CryptoPadding {
714    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715        match self {
716            Self::Pkcs5 => write!(f, "pkcs5"),
717            Self::Oaep => write!(f, "oaep"),
718            Self::Pss => write!(f, "pss"),
719            Self::Other(s) => write!(f, "{s}"),
720        }
721    }
722}
723
724/// Cryptographic function capability.
725#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
726#[non_exhaustive]
727pub enum CryptoFunction {
728    Keygen,
729    Encrypt,
730    Decrypt,
731    Sign,
732    Verify,
733    Digest,
734    Tag,
735    KeyDerive,
736    Encapsulate,
737    Decapsulate,
738    Wrap,
739    Unwrap,
740    Other(String),
741}
742
743impl std::fmt::Display for CryptoFunction {
744    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
745        match self {
746            Self::Keygen => write!(f, "keygen"),
747            Self::Encrypt => write!(f, "encrypt"),
748            Self::Decrypt => write!(f, "decrypt"),
749            Self::Sign => write!(f, "sign"),
750            Self::Verify => write!(f, "verify"),
751            Self::Digest => write!(f, "digest"),
752            Self::Tag => write!(f, "tag"),
753            Self::KeyDerive => write!(f, "keyderive"),
754            Self::Encapsulate => write!(f, "encapsulate"),
755            Self::Decapsulate => write!(f, "decapsulate"),
756            Self::Wrap => write!(f, "wrap"),
757            Self::Unwrap => write!(f, "unwrap"),
758            Self::Other(s) => write!(f, "{s}"),
759        }
760    }
761}
762
763/// Execution environment for the cryptographic implementation.
764#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
765#[non_exhaustive]
766pub enum ExecutionEnvironment {
767    SoftwarePlainRam,
768    SoftwareEncryptedRam,
769    SoftwareTee,
770    Hardware,
771    Other(String),
772}
773
774impl std::fmt::Display for ExecutionEnvironment {
775    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776        match self {
777            Self::SoftwarePlainRam => write!(f, "software-plain-ram"),
778            Self::SoftwareEncryptedRam => write!(f, "software-encrypted-ram"),
779            Self::SoftwareTee => write!(f, "software-tee"),
780            Self::Hardware => write!(f, "hardware"),
781            Self::Other(s) => write!(f, "{s}"),
782        }
783    }
784}
785
786/// Hardware/software platform of the implementation.
787#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
788#[non_exhaustive]
789pub enum ImplementationPlatform {
790    X86_32,
791    X86_64,
792    Armv7A,
793    Armv7M,
794    Armv8A,
795    S390x,
796    Generic,
797    Other(String),
798}
799
800impl std::fmt::Display for ImplementationPlatform {
801    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
802        match self {
803            Self::X86_32 => write!(f, "x86_32"),
804            Self::X86_64 => write!(f, "x86_64"),
805            Self::Armv7A => write!(f, "armv7-a"),
806            Self::Armv7M => write!(f, "armv7-m"),
807            Self::Armv8A => write!(f, "armv8-a"),
808            Self::S390x => write!(f, "s390x"),
809            Self::Generic => write!(f, "generic"),
810            Self::Other(s) => write!(f, "{s}"),
811        }
812    }
813}
814
815/// Certification or validation level achieved.
816#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
817#[non_exhaustive]
818pub enum CertificationLevel {
819    None,
820    Fips140_1L1,
821    Fips140_1L2,
822    Fips140_1L3,
823    Fips140_1L4,
824    Fips140_2L1,
825    Fips140_2L2,
826    Fips140_2L3,
827    Fips140_2L4,
828    Fips140_3L1,
829    Fips140_3L2,
830    Fips140_3L3,
831    Fips140_3L4,
832    CcEal1,
833    CcEal2,
834    CcEal3,
835    CcEal4,
836    CcEal5,
837    CcEal6,
838    CcEal7,
839    Other(String),
840}
841
842impl std::fmt::Display for CertificationLevel {
843    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844        match self {
845            Self::None => write!(f, "none"),
846            Self::Fips140_1L1 => write!(f, "fips140-1-l1"),
847            Self::Fips140_1L2 => write!(f, "fips140-1-l2"),
848            Self::Fips140_1L3 => write!(f, "fips140-1-l3"),
849            Self::Fips140_1L4 => write!(f, "fips140-1-l4"),
850            Self::Fips140_2L1 => write!(f, "fips140-2-l1"),
851            Self::Fips140_2L2 => write!(f, "fips140-2-l2"),
852            Self::Fips140_2L3 => write!(f, "fips140-2-l3"),
853            Self::Fips140_2L4 => write!(f, "fips140-2-l4"),
854            Self::Fips140_3L1 => write!(f, "fips140-3-l1"),
855            Self::Fips140_3L2 => write!(f, "fips140-3-l2"),
856            Self::Fips140_3L3 => write!(f, "fips140-3-l3"),
857            Self::Fips140_3L4 => write!(f, "fips140-3-l4"),
858            Self::CcEal1 => write!(f, "cc-eal1"),
859            Self::CcEal2 => write!(f, "cc-eal2"),
860            Self::CcEal3 => write!(f, "cc-eal3"),
861            Self::CcEal4 => write!(f, "cc-eal4"),
862            Self::CcEal5 => write!(f, "cc-eal5"),
863            Self::CcEal6 => write!(f, "cc-eal6"),
864            Self::CcEal7 => write!(f, "cc-eal7"),
865            Self::Other(s) => write!(f, "{s}"),
866        }
867    }
868}
869
870/// Type of cryptographic key material.
871#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
872#[non_exhaustive]
873pub enum CryptoMaterialType {
874    PublicKey,
875    PrivateKey,
876    SymmetricKey,
877    SecretKey,
878    KeyPair,
879    Ciphertext,
880    Signature,
881    Digest,
882    Iv,
883    Nonce,
884    Seed,
885    Salt,
886    SharedSecret,
887    Tag,
888    Password,
889    Credential,
890    Token,
891    Other(String),
892    Unknown,
893}
894
895impl std::fmt::Display for CryptoMaterialType {
896    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
897        match self {
898            Self::PublicKey => write!(f, "public-key"),
899            Self::PrivateKey => write!(f, "private-key"),
900            Self::SymmetricKey => write!(f, "symmetric-key"),
901            Self::SecretKey => write!(f, "secret-key"),
902            Self::KeyPair => write!(f, "key-pair"),
903            Self::Ciphertext => write!(f, "ciphertext"),
904            Self::Signature => write!(f, "signature"),
905            Self::Digest => write!(f, "digest"),
906            Self::Iv => write!(f, "initialization-vector"),
907            Self::Nonce => write!(f, "nonce"),
908            Self::Seed => write!(f, "seed"),
909            Self::Salt => write!(f, "salt"),
910            Self::SharedSecret => write!(f, "shared-secret"),
911            Self::Tag => write!(f, "tag"),
912            Self::Password => write!(f, "password"),
913            Self::Credential => write!(f, "credential"),
914            Self::Token => write!(f, "token"),
915            Self::Other(s) => write!(f, "{s}"),
916            Self::Unknown => write!(f, "unknown"),
917        }
918    }
919}
920
921/// Lifecycle state of cryptographic material.
922#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
923#[non_exhaustive]
924pub enum CryptoMaterialState {
925    PreActivation,
926    Active,
927    Suspended,
928    Deactivated,
929    Compromised,
930    Destroyed,
931}
932
933impl std::fmt::Display for CryptoMaterialState {
934    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
935        match self {
936            Self::PreActivation => write!(f, "pre-activation"),
937            Self::Active => write!(f, "active"),
938            Self::Suspended => write!(f, "suspended"),
939            Self::Deactivated => write!(f, "deactivated"),
940            Self::Compromised => write!(f, "compromised"),
941            Self::Destroyed => write!(f, "destroyed"),
942        }
943    }
944}
945
946/// Cryptographic protocol type.
947#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
948#[non_exhaustive]
949pub enum ProtocolType {
950    Tls,
951    Dtls,
952    Ipsec,
953    Ssh,
954    Srtp,
955    Wireguard,
956    Ikev1,
957    Ikev2,
958    Zrtp,
959    Mikey,
960    Other(String),
961    Unknown,
962}
963
964impl std::fmt::Display for ProtocolType {
965    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
966        match self {
967            Self::Tls => write!(f, "tls"),
968            Self::Dtls => write!(f, "dtls"),
969            Self::Ipsec => write!(f, "ipsec"),
970            Self::Ssh => write!(f, "ssh"),
971            Self::Srtp => write!(f, "srtp"),
972            Self::Wireguard => write!(f, "wireguard"),
973            Self::Ikev1 => write!(f, "ikev1"),
974            Self::Ikev2 => write!(f, "ikev2"),
975            Self::Zrtp => write!(f, "zrtp"),
976            Self::Mikey => write!(f, "mikey"),
977            Self::Other(s) => write!(f, "{s}"),
978            Self::Unknown => write!(f, "unknown"),
979        }
980    }
981}
982
983// ── Algorithm Classification ────────────────────────────────────────────
984//
985// Canonical, input-robust algorithm classification shared by the compliance
986// checkers (`src/quality/compliance/crypto.rs`). Real-world CBOMs vary wildly
987// in how they identify algorithms: CycloneDX 1.7 has `algorithmFamily`, 1.6
988// does not (only name/OID/primitive/parameter), spellings drift ("SHA1" vs
989// "SHA-1", "TDES" vs "3DES"), and pre-standardization PQC names (Kyber,
990// Dilithium, SPHINCS+) are still common. [`classify_algorithm`] normalizes
991// all of these into one structured classification so no checker needs its own
992// family table. (`CryptographyMetrics` in `src/quality/metrics.rs` still uses
993// the narrower `is_weak`/`is_classical_quantum_vulnerable` helpers below;
994// prefer [`classify_algorithm`] for new code.)
995
996/// NIST-standardized (or SP 800-208) post-quantum algorithm kind.
997#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
998pub enum PqcKind {
999    /// FIPS 203 module-lattice KEM (formerly CRYSTALS-Kyber).
1000    MlKem,
1001    /// FIPS 204 module-lattice signature (formerly CRYSTALS-Dilithium).
1002    MlDsa,
1003    /// FIPS 205 stateless hash-based signature (formerly SPHINCS+).
1004    SlhDsa,
1005    /// FN-DSA (Falcon) — selected by NIST but not yet standardized.
1006    FnDsa,
1007    /// SP 800-208 Leighton-Micali signature system.
1008    Lms,
1009    /// SP 800-208 eXtended Merkle signature scheme.
1010    Xmss,
1011    /// SP 800-208 hierarchical signature system.
1012    Hss,
1013}
1014
1015/// Coarse security class produced by [`classify_algorithm`].
1016#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1017pub enum AlgorithmClass {
1018    /// Classically broken or disallowed per SP 800-131A (MD5, SHA-1, DES,
1019    /// 3DES, RC2, RC4, Blowfish, IDEA, CAST5, …).
1020    Broken,
1021    /// Classical public-key algorithm broken by Shor's algorithm (RSA,
1022    /// DSA, DH/DHE, ECDH/ECDHE, ECDSA, EdDSA/Ed25519/Ed448, X25519/X448,
1023    /// ElGamal, ECIES, ECMQV, generic EC/ECC).
1024    ClassicalQuantumVulnerable,
1025    /// Modern symmetric cipher (AES, ChaCha20, Camellia, ARIA, …); strength
1026    /// judgments (e.g., CNSA 2.0's AES-256-only rule) are up to the caller.
1027    Symmetric,
1028    /// SHA-2 family hash; the digest size, when known, is in `parameter`.
1029    Sha2,
1030    /// SHA-3 family hash/XOF (SHA3-*, SHAKE, Keccak).
1031    Sha3,
1032    /// Recognized modern hash outside the SHA-2/SHA-3 families (SM3,
1033    /// GOST R 34.11 "Streebog", …) — not broken, but not on approval
1034    /// lists such as CNSA 2.0.
1035    OtherHash,
1036    /// Post-quantum algorithm; the parameter set, when known, is in
1037    /// `parameter` (e.g., "1024" for ML-KEM-1024, "87" for ML-DSA-87).
1038    PostQuantum(PqcKind),
1039    /// Not recognized — callers should treat as "cannot verify", never as
1040    /// implicitly compliant.
1041    Unknown,
1042}
1043
1044impl AlgorithmClass {
1045    /// Rank for "report the most severe mention" selection (higher is
1046    /// worse): Broken > ClassicalQuantumVulnerable > recognized
1047    /// symmetric/hash (not necessarily approved) > post-quantum >
1048    /// Unknown. Used to pick the worst algorithm out of multi-algorithm
1049    /// identities like "sha384-rsa-cert-chain", where reporting the first
1050    /// token (SHA-384, CNSA-approved) would hide the quantum-vulnerable
1051    /// RSA.
1052    #[must_use]
1053    pub const fn severity_rank(self) -> u8 {
1054        match self {
1055            Self::Broken => 5,
1056            Self::ClassicalQuantumVulnerable => 4,
1057            Self::Symmetric | Self::Sha2 | Self::Sha3 | Self::OtherHash => 2,
1058            Self::PostQuantum(_) => 1,
1059            Self::Unknown => 0,
1060        }
1061    }
1062}
1063
1064/// Structured result of [`classify_algorithm`]: canonical family name,
1065/// extracted parameter/size, and coarse security class.
1066#[derive(Debug, Clone, PartialEq, Eq)]
1067pub struct AlgorithmClassification {
1068    /// Canonical family (e.g., "AES", "SHA-2", "ML-KEM", "RSA"), if known.
1069    pub family: Option<String>,
1070    /// Parameter set / key size / digest size (e.g., "256", "1024", "87").
1071    pub parameter: Option<String>,
1072    /// Coarse security class.
1073    pub class: AlgorithmClass,
1074}
1075
1076impl AlgorithmClassification {
1077    /// An unknown classification (no recognizable identity).
1078    #[must_use]
1079    pub const fn unknown() -> Self {
1080        Self {
1081            family: None,
1082            parameter: None,
1083            class: AlgorithmClass::Unknown,
1084        }
1085    }
1086
1087    /// The parameter parsed as a bit/size number, when numeric.
1088    #[must_use]
1089    pub fn parameter_bits(&self) -> Option<u32> {
1090        self.parameter.as_deref().and_then(|p| p.parse().ok())
1091    }
1092
1093    /// Human-readable label, e.g. "AES-128", "SHA-384", "ML-KEM-1024", "RSA".
1094    #[must_use]
1095    pub fn label(&self) -> String {
1096        let Some(family) = self.family.as_deref() else {
1097            return "unclassified".to_string();
1098        };
1099        match (family, self.parameter.as_deref()) {
1100            // "SHA-2" + 384 reads better as "SHA-384".
1101            ("SHA-2", Some(p)) => format!("SHA-{p}"),
1102            ("SHA-3", Some(p)) => format!("SHA3-{p}"),
1103            (f, Some(p)) => format!("{f}-{p}"),
1104            (f, None) => f.to_string(),
1105        }
1106    }
1107}
1108
1109/// Map a canonical family name to its security class.
1110fn family_class(canonical: &str) -> AlgorithmClass {
1111    use AlgorithmClass as C;
1112    match canonical {
1113        "MD2" | "MD4" | "MD5" | "SHA-1" | "DES" | "3DES" | "RC2" | "RC4" | "BLOWFISH" | "IDEA"
1114        | "CAST5" | "SKIPJACK" => C::Broken,
1115        "RSA" | "DSA" | "DH" | "ECDH" | "ECDSA" | "EDDSA" | "ED25519" | "ED448" | "X25519"
1116        | "X448" | "ELGAMAL" | "ECIES" | "ECMQV" | "EC" | "SM2" | "SM9" | "GOST-R-34.10" => {
1117            C::ClassicalQuantumVulnerable
1118        }
1119        "AES" | "CHACHA20" | "CAMELLIA" | "ARIA" | "SEED" | "SERPENT" | "TWOFISH" | "SM4"
1120        | "MAGMA" | "KUZNYECHIK" => C::Symmetric,
1121        "SHA-2" => C::Sha2,
1122        "SHA-3" => C::Sha3,
1123        "SM3" | "GOST-R-34.11" => C::OtherHash,
1124        "ML-KEM" => C::PostQuantum(PqcKind::MlKem),
1125        "ML-DSA" => C::PostQuantum(PqcKind::MlDsa),
1126        "SLH-DSA" => C::PostQuantum(PqcKind::SlhDsa),
1127        "FN-DSA" => C::PostQuantum(PqcKind::FnDsa),
1128        "LMS" => C::PostQuantum(PqcKind::Lms),
1129        "XMSS" => C::PostQuantum(PqcKind::Xmss),
1130        "HSS" => C::PostQuantum(PqcKind::Hss),
1131        _ => C::Unknown,
1132    }
1133}
1134
1135/// Alias table: normalized token → (canonical family, implied parameter,
1136/// is-round-3-Dilithium). Tokens are uppercased with `_`, ` `, `/`, and `.`
1137/// already mapped to `-` by [`normalize_algo_token`].
1138fn alias_lookup(token: &str) -> Option<(&'static str, Option<&'static str>, bool)> {
1139    let hit: (&'static str, Option<&'static str>) = match token {
1140        // Broken / legacy.
1141        "MD2" => ("MD2", None),
1142        "MD4" => ("MD4", None),
1143        "MD5" => ("MD5", None),
1144        // Bare "SHA" appears in TLS cipher-suite names and means SHA-1.
1145        "SHA-1" | "SHA1" | "SHA" => ("SHA-1", None),
1146        "DES" => ("DES", None),
1147        "3DES" | "TDES" | "TDEA" | "DES3" | "DESEDE" | "DESEDE3" | "DES-EDE" | "DES-EDE2"
1148        | "DES-EDE3" | "3DES-EDE" | "TRIPLE-DES" | "TRIPLEDES" => ("3DES", None),
1149        "RC2" => ("RC2", None),
1150        "RC4" | "ARC4" | "ARCFOUR" => ("RC4", None),
1151        "BLOWFISH" => ("BLOWFISH", None),
1152        "IDEA" => ("IDEA", None),
1153        "CAST5" | "CAST-128" | "CAST128" => ("CAST5", None),
1154        "SKIPJACK" => ("SKIPJACK", None),
1155        // Classical public-key (quantum-vulnerable).
1156        "RSA" | "RSAES" | "RSASSA" | "RSA-PSS" | "RSA-OAEP" | "RSAES-OAEP" | "RSASSA-PSS" => {
1157            ("RSA", None)
1158        }
1159        "DSA" | "DSS" => ("DSA", None),
1160        "DH" | "DHE" | "FFDHE" | "EDH" | "ADH" | "DIFFIE-HELLMAN" => ("DH", None),
1161        "ECDH" | "ECDHE" | "XDH" => ("ECDH", None),
1162        "ECDSA" => ("ECDSA", None),
1163        "EDDSA" => ("EDDSA", None),
1164        "ED25519" => ("ED25519", None),
1165        "ED448" => ("ED448", None),
1166        "X25519" => ("X25519", None),
1167        "X448" => ("X448", None),
1168        "ELGAMAL" | "EL-GAMAL" => ("ELGAMAL", None),
1169        "ECIES" => ("ECIES", None),
1170        "ECMQV" => ("ECMQV", None),
1171        "EC" | "ECC" => ("EC", None),
1172        // National classical public-key algorithms (quantum-vulnerable):
1173        // Chinese SM2/SM9 and Russian GOST R 34.10. `normalize_algo_token`
1174        // maps '.'/' '/'_' to '-', so "GOST R 34.10" arrives as
1175        // "GOST-R-34-10"; bare "GOST" most commonly denotes the signature
1176        // scheme and is classified conservatively as such.
1177        "SM2" => ("SM2", None),
1178        "SM9" => ("SM9", None),
1179        "GOST" | "GOST3410" | "GOSTR3410" | "GOST-R-34-10" => ("GOST-R-34.10", None),
1180        // Symmetric.
1181        "AES" | "RIJNDAEL" => ("AES", None),
1182        "CHACHA" | "CHACHA20" | "XCHACHA20" | "CHACHA20-POLY1305" => ("CHACHA20", None),
1183        "CAMELLIA" => ("CAMELLIA", None),
1184        "ARIA" => ("ARIA", None),
1185        "SEED" => ("SEED", None),
1186        "SERPENT" => ("SERPENT", None),
1187        "TWOFISH" => ("TWOFISH", None),
1188        // National symmetric ciphers (SM4 is fixed 128-bit; GOST R 34.12
1189        // Magma/Kuznyechik).
1190        "SM4" => ("SM4", None),
1191        "MAGMA" => ("MAGMA", None),
1192        "KUZNYECHIK" | "KUZNECHIK" => ("KUZNYECHIK", None),
1193        // National hashes — recognized, but not CNSA 2.0-approved.
1194        "SM3" => ("SM3", None),
1195        "GOST3411" | "GOSTR3411" | "GOST-R-34-11" | "STREEBOG" => ("GOST-R-34.11", None),
1196        // SHA-2 (digest size carried in the token where present).
1197        "SHA-2" | "SHA2" => ("SHA-2", None),
1198        "SHA-224" | "SHA224" => ("SHA-2", Some("224")),
1199        "SHA-256" | "SHA256" => ("SHA-2", Some("256")),
1200        "SHA-384" | "SHA384" => ("SHA-2", Some("384")),
1201        "SHA-512" | "SHA512" => ("SHA-2", Some("512")),
1202        // FIPS 180-4 truncated variants ('/' is folded to '-' by
1203        // normalize_algo_token). These must classify by their truncated
1204        // OUTPUT size — SHA-512/256 is a 256-bit digest and NOT on the
1205        // CNSA 2.0 hash allowlist, unlike full SHA-512 (finding: the
1206        // generic rsplit branch preferred the alias-implied 512).
1207        "SHA-512-256" | "SHA512-256" => ("SHA-2", Some("256")),
1208        "SHA-512-224" | "SHA512-224" => ("SHA-2", Some("224")),
1209        // SHA-3 family.
1210        "SHA-3" | "SHA3" | "KECCAK" | "SHAKE" | "SHAKE128" | "SHAKE256" => ("SHA-3", None),
1211        "SHA3-224" => ("SHA-3", Some("224")),
1212        "SHA3-256" => ("SHA-3", Some("256")),
1213        "SHA3-384" => ("SHA-3", Some("384")),
1214        "SHA3-512" => ("SHA-3", Some("512")),
1215        // Post-quantum (final and round-3 names).
1216        "ML-KEM" | "MLKEM" | "KYBER" | "CRYSTALS-KYBER" => ("ML-KEM", None),
1217        "ML-DSA" | "MLDSA" => ("ML-DSA", None),
1218        "DILITHIUM" | "CRYSTALS-DILITHIUM" => {
1219            return Some(("ML-DSA", None, true));
1220        }
1221        "SLH-DSA" | "SLHDSA" | "SPHINCS" | "SPHINCS+" | "SPHINCSPLUS" => ("SLH-DSA", None),
1222        "FALCON" | "FN-DSA" | "FNDSA" => ("FN-DSA", None),
1223        "LMS" | "HSS-LMS" | "LMS-HSS" => ("LMS", None),
1224        "XMSS" | "XMSS-MT" | "XMSSMT" => ("XMSS", None),
1225        "HSS" => ("HSS", None),
1226        _ => return None,
1227    };
1228    Some((hit.0, hit.1, false))
1229}
1230
1231/// Uppercase and map separator characters (`_`, ` `, `/`, `.`) to `-`.
1232fn normalize_algo_token(s: &str) -> String {
1233    s.trim()
1234        .chars()
1235        .map(|c| match c {
1236            '_' | ' ' | '/' | '.' => '-',
1237            other => other.to_ascii_uppercase(),
1238        })
1239        .collect()
1240}
1241
1242/// Map round-3 Dilithium parameter sets to the final ML-DSA ones.
1243fn map_dilithium_param(p: &str) -> String {
1244    match p {
1245        "2" => "44".to_string(),
1246        "3" => "65".to_string(),
1247        "5" => "87".to_string(),
1248        other => other.to_string(),
1249    }
1250}
1251
1252/// Classify one normalized token (a family string, or a joined name-token
1253/// span): direct alias, then trailing `-<digits>` size split, then a trailing
1254/// digit run without separator ("AES128").
1255fn classify_token(token: &str) -> Option<(&'static str, Option<String>)> {
1256    let t = normalize_algo_token(token);
1257
1258    let finish = |family: &'static str, param: Option<String>, dilithium: bool| {
1259        let param = if dilithium {
1260            param.map(|p| map_dilithium_param(&p))
1261        } else {
1262            param
1263        };
1264        Some((family, param))
1265    };
1266
1267    if let Some((family, param, dilithium)) = alias_lookup(&t) {
1268        return finish(family, param.map(str::to_string), dilithium);
1269    }
1270
1271    // Brainpool named curves ("brainpoolP256r1", "brainpoolP384t1", …):
1272    // the whole RFC 5639 family is classical elliptic-curve crypto.
1273    if t.starts_with("BRAINPOOL") {
1274        return Some(("EC", None));
1275    }
1276
1277    // Trailing "-<digits>" size: "ML-KEM-1024", "AES-128", "RSA-2048", "KYBER-768".
1278    if let Some((base, digits)) = t.rsplit_once('-')
1279        && !digits.is_empty()
1280        && digits.bytes().all(|b| b.is_ascii_digit())
1281        && let Some((family, param, dilithium)) = alias_lookup(base)
1282    {
1283        let param = param
1284            .map(str::to_string)
1285            .or_else(|| Some(digits.to_string()));
1286        return finish(family, param, dilithium);
1287    }
1288
1289    // Trailing digit run without separator: "AES128", "KYBER768", "RSA2048".
1290    // (Counting trailing ASCII-digit bytes keeps the split on a char boundary
1291    // even for non-ASCII input.)
1292    let digit_start = t.len() - t.bytes().rev().take_while(u8::is_ascii_digit).count();
1293    if digit_start > 0 && digit_start < t.len() {
1294        let (alpha, digits) = t.split_at(digit_start);
1295        if let Some((family, param, dilithium)) = alias_lookup(alpha.trim_end_matches('-')) {
1296            let param = param
1297                .map(str::to_string)
1298                .or_else(|| Some(digits.to_string()));
1299            return finish(family, param, dilithium);
1300        }
1301    }
1302
1303    None
1304}
1305
1306/// Extract every recognizable algorithm mention from a free-form name using
1307/// word-boundary token matching (never bare substrings). Used for cipher-suite
1308/// names ("`TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256`") and as the guarded
1309/// name-based fallback for assets without `algorithmFamily`/OID.
1310///
1311/// Tokens are split on any non-alphanumeric character (keeping `+` for
1312/// "SPHINCS+"); spans of up to three adjacent tokens are joined with `-` and
1313/// matched longest-first, so "ML KEM 1024" and "AES 128" resolve as units.
1314/// Unrecognized tokens (GCM, CBC, TLS, WITH, …) are simply skipped.
1315#[must_use]
1316pub fn classify_algorithm_names(name: &str) -> Vec<AlgorithmClassification> {
1317    classify_names_impl(name, false)
1318}
1319
1320/// Like [`classify_algorithm_names`], but drops the riskiest bare aliases
1321/// ("SEED", "EC"/"ECC") that collide with everyday words — "seed-expander"
1322/// is a DRBG utility, not the SEED block cipher, and "ec2" is not elliptic
1323/// curve crypto. Used for the name-only fallback and raw bom-ref scans,
1324/// where no structured identity backs the token; declared `algorithmFamily`
1325/// strings and cipher-suite names (where bare "SEED" really is the cipher)
1326/// keep the full alias table.
1327#[must_use]
1328pub fn classify_algorithm_names_guarded(name: &str) -> Vec<AlgorithmClassification> {
1329    classify_names_impl(name, true)
1330}
1331
1332/// Whether a matched token span is too generic to trust in unstructured
1333/// text: the bare SEED / EC / ECC aliases (with or without a trailing
1334/// size digit run) collide with non-crypto words.
1335fn is_overgeneric_span(span: &str) -> bool {
1336    let base = span
1337        .trim_end_matches(|c: char| c.is_ascii_digit())
1338        .trim_end_matches('-');
1339    matches!(base, "SEED" | "EC" | "ECC")
1340}
1341
1342fn classify_names_impl(name: &str, guarded: bool) -> Vec<AlgorithmClassification> {
1343    let upper = name.to_uppercase();
1344    let tokens: Vec<&str> = upper
1345        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '+'))
1346        .filter(|t| !t.is_empty())
1347        .collect();
1348
1349    let mut out: Vec<AlgorithmClassification> = Vec::new();
1350    let mut i = 0;
1351    while i < tokens.len() {
1352        // A pure number can never start an algorithm mention.
1353        if tokens[i].bytes().all(|b| b.is_ascii_digit()) {
1354            i += 1;
1355            continue;
1356        }
1357        let max_span = 3.min(tokens.len() - i);
1358        let mut advanced = false;
1359        for span in (1..=max_span).rev() {
1360            let joined = tokens[i..i + span].join("-");
1361            if let Some((family, parameter)) = classify_token(&joined) {
1362                if guarded && is_overgeneric_span(&joined) {
1363                    continue;
1364                }
1365                let cls = AlgorithmClassification {
1366                    family: Some(family.to_string()),
1367                    parameter,
1368                    class: family_class(family),
1369                };
1370                if !out.contains(&cls) {
1371                    out.push(cls);
1372                }
1373                i += span;
1374                advanced = true;
1375                break;
1376            }
1377        }
1378        if !advanced {
1379            i += 1;
1380        }
1381    }
1382    out
1383}
1384
1385/// The most severe classification among `mentions` (first wins on ties),
1386/// per [`AlgorithmClass::severity_rank`]. Callers that must reduce a
1387/// multi-algorithm identity ("sha384-rsa-cert-chain") to one verdict use
1388/// this so the worst algorithm is reported, never whichever token happened
1389/// to appear first.
1390#[must_use]
1391pub fn worst_classification(
1392    mentions: Vec<AlgorithmClassification>,
1393) -> Option<AlgorithmClassification> {
1394    let mut worst: Option<AlgorithmClassification> = None;
1395    for m in mentions {
1396        if worst
1397            .as_ref()
1398            .is_none_or(|w| m.class.severity_rank() > w.class.severity_rank())
1399        {
1400            worst = Some(m);
1401        }
1402    }
1403    worst
1404}
1405
1406/// Classify an algorithm OID. Covers the common arcs emitted by real CBOM
1407/// generators; unknown OIDs return `None` (callers treat as unverifiable,
1408/// never as compliant).
1409fn classify_oid(oid: &str) -> Option<(&'static str, Option<String>)> {
1410    let o = oid.trim();
1411
1412    // Exact matches first.
1413    let exact: Option<(&'static str, Option<&'static str>)> = match o {
1414        "1.3.14.3.2.26" => Some(("SHA-1", None)),
1415        "1.2.840.113549.2.2" => Some(("MD2", None)),
1416        "1.2.840.113549.2.4" => Some(("MD4", None)),
1417        "1.2.840.113549.2.5" => Some(("MD5", None)),
1418        "1.3.14.3.2.7" => Some(("DES", None)),
1419        "1.2.840.113549.3.7" => Some(("3DES", None)),
1420        "1.2.840.113549.3.2" => Some(("RC2", None)),
1421        "1.2.840.113549.3.4" => Some(("RC4", None)),
1422        "1.2.840.10040.4.1" | "1.2.840.10040.4.3" => Some(("DSA", None)),
1423        "1.2.840.113549.1.3.1" | "1.2.840.10046.2.1" => Some(("DH", None)),
1424        "1.3.101.110" => Some(("X25519", None)),
1425        "1.3.101.111" => Some(("X448", None)),
1426        "1.3.101.112" => Some(("ED25519", None)),
1427        "1.3.101.113" => Some(("ED448", None)),
1428        // NIST hash algorithm arc (2.16.840.1.101.3.4.2.*).
1429        "2.16.840.1.101.3.4.2.1" => Some(("SHA-2", Some("256"))),
1430        "2.16.840.1.101.3.4.2.2" => Some(("SHA-2", Some("384"))),
1431        "2.16.840.1.101.3.4.2.3" => Some(("SHA-2", Some("512"))),
1432        "2.16.840.1.101.3.4.2.4" => Some(("SHA-2", Some("224"))),
1433        "2.16.840.1.101.3.4.2.5" => Some(("SHA-2", Some("224"))), // SHA-512/224
1434        "2.16.840.1.101.3.4.2.6" => Some(("SHA-2", Some("256"))), // SHA-512/256
1435        "2.16.840.1.101.3.4.2.7" => Some(("SHA-3", Some("224"))),
1436        "2.16.840.1.101.3.4.2.8" => Some(("SHA-3", Some("256"))),
1437        "2.16.840.1.101.3.4.2.9" => Some(("SHA-3", Some("384"))),
1438        "2.16.840.1.101.3.4.2.10" => Some(("SHA-3", Some("512"))),
1439        "2.16.840.1.101.3.4.2.11" | "2.16.840.1.101.3.4.2.12" => Some(("SHA-3", None)), // SHAKE
1440        // SP 800-208 / RFC 8708 stateful hash-based signatures.
1441        "1.2.840.113549.1.9.16.3.17" => Some(("LMS", None)), // id-alg-hss-lms-hashsig
1442        "0.4.0.127.0.15.1.1.13.0" => Some(("XMSS", None)),
1443        _ => None,
1444    };
1445    if let Some((family, param)) = exact {
1446        return Some((family, param.map(str::to_string)));
1447    }
1448
1449    // RSA arc: 1.2.840.113549.1.1.* (rsaEncryption, *WithRSAEncryption, PSS, OAEP).
1450    if o.starts_with("1.2.840.113549.1.1.") {
1451        return Some(("RSA", None));
1452    }
1453    // ANSI X9.62 elliptic-curve arc: keys, curves, and ECDSA signatures.
1454    if o.starts_with("1.2.840.10045.4.") {
1455        return Some(("ECDSA", None));
1456    }
1457    if o.starts_with("1.2.840.10045.") {
1458        return Some(("EC", None));
1459    }
1460    // SECG named curves (secp256k1, secp384r1, ...).
1461    if o.starts_with("1.3.132.") {
1462        return Some(("EC", None));
1463    }
1464    // NIST AES arc: 2.16.840.1.101.3.4.1.<n> — n encodes the key size.
1465    if let Some(rest) = o.strip_prefix("2.16.840.1.101.3.4.1.")
1466        && let Ok(n) = rest.parse::<u32>()
1467    {
1468        let bits = match n {
1469            1..=10 => Some("128"),
1470            21..=30 => Some("192"),
1471            41..=50 => Some("256"),
1472            _ => None,
1473        };
1474        return Some(("AES", bits.map(str::to_string)));
1475    }
1476    // NIST KEM arc: 2.16.840.1.101.3.4.4.<n> (ML-KEM-512/768/1024).
1477    if let Some(rest) = o.strip_prefix("2.16.840.1.101.3.4.4.")
1478        && let Ok(n) = rest.parse::<u32>()
1479    {
1480        let param = match n {
1481            1 => Some("512"),
1482            2 => Some("768"),
1483            3 => Some("1024"),
1484            _ => None,
1485        };
1486        return Some(("ML-KEM", param.map(str::to_string)));
1487    }
1488    // NIST signature-algorithm arc: 2.16.840.1.101.3.4.3.<n>.
1489    if let Some(rest) = o.strip_prefix("2.16.840.1.101.3.4.3.")
1490        && let Ok(n) = rest.parse::<u32>()
1491    {
1492        return match n {
1493            1..=8 => Some(("DSA", None)),    // dsa-with-sha2/sha3
1494            9..=12 => Some(("ECDSA", None)), // ecdsa-with-sha3
1495            13..=16 => Some(("RSA", None)),  // rsassa-pkcs1 with sha3
1496            17 => Some(("ML-DSA", Some("44".to_string()))),
1497            18 => Some(("ML-DSA", Some("65".to_string()))),
1498            19 => Some(("ML-DSA", Some("87".to_string()))),
1499            // Per the NIST CSOR registry only 20–31 are SLH-DSA parameter
1500            // sets; 32–34 are id-hash-ml-dsa-44/65/87-with-sha512 (pre-hash
1501            // ML-DSA, FIPS 204) and 35–46 the pre-hash SLH-DSA variants.
1502            20..=31 => Some(("SLH-DSA", None)),
1503            32 => Some(("ML-DSA", Some("44".to_string()))),
1504            33 => Some(("ML-DSA", Some("65".to_string()))),
1505            34 => Some(("ML-DSA", Some("87".to_string()))),
1506            35..=46 => Some(("SLH-DSA", None)),
1507            _ => None,
1508        };
1509    }
1510    // Chinese SM arcs (GM/T 0006): SM2 EC signature/KEX, SM3 hash, SM4
1511    // block cipher — SM2 is elliptic-curve crypto broken by Shor's
1512    // algorithm, exactly like ECDSA.
1513    if o == "1.2.156.10197.1.301" || o.starts_with("1.2.156.10197.1.301.") {
1514        return Some(("SM2", None));
1515    }
1516    if o == "1.2.156.10197.1.401" || o.starts_with("1.2.156.10197.1.401.") {
1517        return Some(("SM3", None));
1518    }
1519    if o == "1.2.156.10197.1.104" || o.starts_with("1.2.156.10197.1.104.") {
1520        return Some(("SM4", None));
1521    }
1522    // Russian GOST R 34.10 (quantum-vulnerable EC signature): 34.10-94/2001
1523    // keys and signatures plus the 34.10-2012 key (1.2.643.7.1.1.1.*) and
1524    // signature (1.2.643.7.1.1.3.*) arcs.
1525    if matches!(
1526        o,
1527        "1.2.643.2.2.19" | "1.2.643.2.2.20" | "1.2.643.2.2.3" | "1.2.643.2.2.4"
1528    ) || o.starts_with("1.2.643.7.1.1.1.")
1529        || o.starts_with("1.2.643.7.1.1.3.")
1530    {
1531        return Some(("GOST-R-34.10", None));
1532    }
1533    // GOST R 34.11 hashes (34.11-94 and the 34.11-2012 "Streebog" arc).
1534    if o == "1.2.643.2.2.9" || o.starts_with("1.2.643.7.1.1.2.") {
1535        return Some(("GOST-R-34.11", None));
1536    }
1537    // Brainpool named-curve arc (RFC 5639): classical EC crypto.
1538    if o.starts_with("1.3.36.3.3.2.8.1.") {
1539        return Some(("EC", None));
1540    }
1541
1542    None
1543}
1544
1545/// Classify a cryptographic algorithm from whatever identity a CBOM provides.
1546///
1547/// Sources are consulted in decreasing order of authority:
1548///
1549/// 1. `family` (CycloneDX 1.7 `algorithmFamily`), normalized through the
1550///    alias table (case, `_`/` `/`/` separators, "SHA1"→"SHA-1",
1551///    "TDES"→"3DES", "Kyber"→"ML-KEM", …) with trailing sizes extracted
1552///    ("ML-KEM-768" → ML-KEM + 768). A bare "SHA" family accompanied by a
1553///    SHA-2 digest size in `parameter_set` reads as SHA-2 of that size
1554///    (bare "SHA" means SHA-1 only in cipher-suite context).
1555/// 2. `oid` (CycloneDX 1.6+ `cryptoProperties.oid`) via [`classify_oid`].
1556/// 3. A declared-but-unrecognized `family` string is token-scanned as a
1557///    last resort ("DES-CBC", "AES-128-CBC", "RSA/ECB/PKCS1Padding"), and
1558///    the most severe mention wins — a mode/padding-qualified family must
1559///    not silently classify as Unknown.
1560/// 4. `elliptic_curve` (CycloneDX 1.7): any named curve marks the asset as
1561///    classical elliptic-curve crypto.
1562/// 5. `name`: word-boundary token matching via
1563///    [`classify_algorithm_names_guarded`], used **only** when both
1564///    `family` and `oid` are absent — bounding false positives to assets
1565///    that carry no structured identity at all. The most severe mention
1566///    wins ("sha384-rsa-signature" is RSA, not SHA-384). Callers should
1567///    only pass names of components that actually have `crypto_properties`.
1568///
1569/// The explicit `parameter_set` (CycloneDX `parameterSetIdentifier`) fills the
1570/// parameter when the identity source did not carry one; failing that, the
1571/// component name is mined for a size of the same family ("AES" + name
1572/// "AES-256-GCM" → 256). The CycloneDX `primitive` field is deliberately not
1573/// used for classification — it cannot distinguish, say, ML-DSA from ECDSA,
1574/// and callers that need primitive-based decisions (symmetric-vs-asymmetric
1575/// severity) already have it.
1576#[must_use]
1577pub fn classify_algorithm(
1578    family: Option<&str>,
1579    name: Option<&str>,
1580    oid: Option<&str>,
1581    parameter_set: Option<&str>,
1582    elliptic_curve: Option<&str>,
1583) -> AlgorithmClassification {
1584    let fill_parameter = |mut cls: AlgorithmClassification| {
1585        if cls.parameter.is_none() {
1586            cls.parameter = parameter_set
1587                .map(str::trim)
1588                .filter(|p| !p.is_empty())
1589                .map(str::to_string);
1590        }
1591        // Last resort: mine the component name for a size carried alongside
1592        // the same family ("AES" + "AES-256-GCM" → 256).
1593        if cls.parameter.is_none()
1594            && let Some(n) = name
1595            && let Some(named) = classify_algorithm_names(n)
1596                .into_iter()
1597                .find(|c| c.family == cls.family)
1598        {
1599            cls.parameter = named.parameter;
1600        }
1601        cls
1602    };
1603
1604    // 1. Explicit algorithm family.
1605    if let Some(f) = family.map(str::trim).filter(|f| !f.is_empty()) {
1606        // Bare "SHA" means SHA-1 only in TLS cipher-suite names; a declared
1607        // family "SHA" with a SHA-2 digest size in parameterSetIdentifier
1608        // is that SHA-2 variant (finding: "SHA" + "384" was reported as the
1609        // broken, self-contradictory "SHA-1-384").
1610        if normalize_algo_token(f) == "SHA"
1611            && let Some(p @ ("224" | "256" | "384" | "512")) = parameter_set.map(str::trim)
1612        {
1613            return AlgorithmClassification {
1614                family: Some("SHA-2".to_string()),
1615                parameter: Some(p.to_string()),
1616                class: AlgorithmClass::Sha2,
1617            };
1618        }
1619        if let Some((canonical, parameter)) = classify_token(f) {
1620            return fill_parameter(AlgorithmClassification {
1621                family: Some(canonical.to_string()),
1622                parameter,
1623                class: family_class(canonical),
1624            });
1625        }
1626    } else if let Some(o) = oid.map(str::trim).filter(|o| !o.is_empty()) {
1627        // 2. OID (only consulted when no family is declared: a recognized
1628        //    family is authoritative over the OID).
1629        if let Some((canonical, parameter)) = classify_oid(o) {
1630            return fill_parameter(AlgorithmClassification {
1631                family: Some(canonical.to_string()),
1632                parameter,
1633                class: family_class(canonical),
1634            });
1635        }
1636    }
1637
1638    // Family declared but unrecognized: still try the OID.
1639    if family.is_some()
1640        && let Some(o) = oid.map(str::trim).filter(|o| !o.is_empty())
1641        && let Some((canonical, parameter)) = classify_oid(o)
1642    {
1643        return fill_parameter(AlgorithmClassification {
1644            family: Some(canonical.to_string()),
1645            parameter,
1646            class: family_class(canonical),
1647        });
1648    }
1649
1650    // 3. Family declared but not a single recognizable token: token-scan
1651    //    the family string itself, taking the most severe mention, so
1652    //    compound spellings carrying a mode/chaining/padding qualifier
1653    //    ("DES-CBC", "AES-128-CBC", "3DES-EDE-CBC", "RSA/ECB/PKCS1Padding")
1654    //    classify by their base algorithm instead of falling through to
1655    //    Unknown (which turned required CNSA2/PQC Errors into Warnings).
1656    //    The declared family is authoritative, so the scan is unguarded.
1657    if let Some(f) = family.map(str::trim).filter(|f| !f.is_empty())
1658        && let Some(cls) = worst_classification(classify_algorithm_names(f))
1659    {
1660        return fill_parameter(cls);
1661    }
1662
1663    // 4. A named elliptic curve is authoritative that this is EC crypto.
1664    if let Some(curve) = elliptic_curve.map(str::trim).filter(|c| !c.is_empty()) {
1665        return AlgorithmClassification {
1666            family: Some("EC".to_string()),
1667            parameter: Some(curve.to_string()),
1668            class: AlgorithmClass::ClassicalQuantumVulnerable,
1669        };
1670    }
1671
1672    // 5. Guarded name fallback: only when family and OID are both absent,
1673    //    with over-generic bare tokens (SEED/EC) dropped and the most
1674    //    severe mention reported.
1675    if family.is_none()
1676        && oid.is_none()
1677        && let Some(n) = name
1678        && let Some(cls) = worst_classification(classify_algorithm_names_guarded(n))
1679    {
1680        return fill_parameter(cls);
1681    }
1682
1683    AlgorithmClassification {
1684        family: None,
1685        parameter: parameter_set.map(str::to_string),
1686        class: AlgorithmClass::Unknown,
1687    }
1688}
1689
1690// ── Tests ───────────────────────────────────────────────────────────────
1691
1692#[cfg(test)]
1693mod tests {
1694    use super::*;
1695
1696    #[test]
1697    fn algorithm_is_quantum_safe() {
1698        let algo =
1699            AlgorithmProperties::new(CryptoPrimitive::Kem).with_nist_quantum_security_level(5);
1700        assert!(algo.is_quantum_safe());
1701
1702        let classical =
1703            AlgorithmProperties::new(CryptoPrimitive::Pke).with_nist_quantum_security_level(0);
1704        assert!(!classical.is_quantum_safe());
1705
1706        let unknown = AlgorithmProperties::new(CryptoPrimitive::Pke);
1707        assert!(!unknown.is_quantum_safe());
1708    }
1709
1710    #[test]
1711    fn algorithm_is_hybrid_pqc() {
1712        let hybrid = AlgorithmProperties::new(CryptoPrimitive::Combiner);
1713        assert!(hybrid.is_hybrid_pqc());
1714
1715        let normal = AlgorithmProperties::new(CryptoPrimitive::Kem);
1716        assert!(!normal.is_hybrid_pqc());
1717    }
1718
1719    #[test]
1720    fn algorithm_is_weak() {
1721        let md5 = AlgorithmProperties::new(CryptoPrimitive::Hash)
1722            .with_algorithm_family("MD5".to_string());
1723        assert!(md5.is_weak());
1724
1725        let sha1 = AlgorithmProperties::new(CryptoPrimitive::Hash)
1726            .with_algorithm_family("SHA-1".to_string());
1727        assert!(sha1.is_weak());
1728
1729        let des = AlgorithmProperties::new(CryptoPrimitive::BlockCipher)
1730            .with_algorithm_family("DES".to_string());
1731        assert!(des.is_weak());
1732
1733        let rc4 = AlgorithmProperties::new(CryptoPrimitive::StreamCipher)
1734            .with_algorithm_family("RC4".to_string());
1735        assert!(rc4.is_weak());
1736
1737        let aes =
1738            AlgorithmProperties::new(CryptoPrimitive::Ae).with_algorithm_family("AES".to_string());
1739        assert!(!aes.is_weak());
1740
1741        let ml_kem = AlgorithmProperties::new(CryptoPrimitive::Kem)
1742            .with_algorithm_family("ML-KEM".to_string());
1743        assert!(!ml_kem.is_weak());
1744    }
1745
1746    #[test]
1747    fn certificate_expiry() {
1748        let expired = CertificateProperties::new()
1749            .with_not_valid_after(Utc::now() - chrono::Duration::days(1));
1750        assert!(expired.is_expired());
1751        assert!(!expired.is_expiring_soon(90));
1752
1753        let valid = CertificateProperties::new()
1754            .with_not_valid_after(Utc::now() + chrono::Duration::days(365));
1755        assert!(!valid.is_expired());
1756        assert!(!valid.is_expiring_soon(90));
1757
1758        let expiring = CertificateProperties::new()
1759            .with_not_valid_after(Utc::now() + chrono::Duration::days(30));
1760        assert!(!expiring.is_expired());
1761        assert!(expiring.is_expiring_soon(90));
1762    }
1763
1764    #[test]
1765    fn certificate_validity_days() {
1766        let no_expiry = CertificateProperties::new();
1767        assert!(no_expiry.validity_days().is_none());
1768
1769        let expired = CertificateProperties::new()
1770            .with_not_valid_after(Utc::now() - chrono::Duration::days(10));
1771        assert!(expired.validity_days().unwrap() < 0);
1772
1773        let future = CertificateProperties::new()
1774            .with_not_valid_after(Utc::now() + chrono::Duration::days(100));
1775        let days = future.validity_days().unwrap();
1776        assert!(days >= 99 && days <= 100);
1777    }
1778
1779    #[test]
1780    fn crypto_properties_builder() {
1781        let props = CryptoProperties::new(CryptoAssetType::Algorithm)
1782            .with_oid("2.16.840.1.101.3.4.1.46".to_string())
1783            .with_algorithm_properties(
1784                AlgorithmProperties::new(CryptoPrimitive::Ae)
1785                    .with_algorithm_family("AES".to_string())
1786                    .with_mode(CryptoMode::Gcm)
1787                    .with_classical_security_level(256)
1788                    .with_nist_quantum_security_level(1),
1789            );
1790
1791        assert_eq!(props.asset_type, CryptoAssetType::Algorithm);
1792        assert_eq!(props.oid.as_deref(), Some("2.16.840.1.101.3.4.1.46"));
1793        let algo = props.algorithm_properties.unwrap();
1794        assert_eq!(algo.primitive, CryptoPrimitive::Ae);
1795        assert_eq!(algo.algorithm_family.as_deref(), Some("AES"));
1796        assert_eq!(algo.mode, Some(CryptoMode::Gcm));
1797        assert_eq!(algo.classical_security_level, Some(256));
1798        assert!(algo.is_quantum_safe());
1799        assert!(!algo.is_weak());
1800    }
1801
1802    #[test]
1803    fn display_impls() {
1804        assert_eq!(CryptoAssetType::Algorithm.to_string(), "algorithm");
1805        assert_eq!(
1806            CryptoAssetType::RelatedCryptoMaterial.to_string(),
1807            "related-crypto-material"
1808        );
1809        assert_eq!(CryptoPrimitive::Kem.to_string(), "kem");
1810        assert_eq!(CryptoPrimitive::Combiner.to_string(), "combiner");
1811        assert_eq!(CryptoMode::Gcm.to_string(), "gcm");
1812        assert_eq!(CryptoFunction::Encapsulate.to_string(), "encapsulate");
1813        assert_eq!(CryptoMaterialType::PublicKey.to_string(), "public-key");
1814        assert_eq!(CryptoMaterialState::Compromised.to_string(), "compromised");
1815        assert_eq!(ProtocolType::Tls.to_string(), "tls");
1816        assert_eq!(CertificationLevel::Fips140_3L1.to_string(), "fips140-3-l1");
1817        assert_eq!(ExecutionEnvironment::Hardware.to_string(), "hardware");
1818        assert_eq!(ImplementationPlatform::X86_64.to_string(), "x86_64");
1819    }
1820
1821    #[test]
1822    fn protocol_builder() {
1823        let proto = ProtocolProperties::new(ProtocolType::Tls)
1824            .with_version("1.3".to_string())
1825            .with_cipher_suites(vec![CipherSuite {
1826                name: Some("TLS_AES_256_GCM_SHA384".to_string()),
1827                algorithms: vec!["algo/aes-256-gcm".to_string()],
1828                identifiers: vec!["0x13".to_string(), "0x02".to_string()],
1829            }]);
1830
1831        assert_eq!(proto.protocol_type, ProtocolType::Tls);
1832        assert_eq!(proto.version.as_deref(), Some("1.3"));
1833        assert_eq!(proto.cipher_suites.len(), 1);
1834    }
1835
1836    // ── classify_algorithm ──────────────────────────────────────────────
1837
1838    fn cls(
1839        family: Option<&str>,
1840        name: Option<&str>,
1841        oid: Option<&str>,
1842        param: Option<&str>,
1843        curve: Option<&str>,
1844    ) -> AlgorithmClassification {
1845        classify_algorithm(family, name, oid, param, curve)
1846    }
1847
1848    #[test]
1849    fn classify_family_spelling_variants() {
1850        // Aliases and separator styles all land on the canonical family.
1851        for (input, family, class) in [
1852            ("SHA1", "SHA-1", AlgorithmClass::Broken),
1853            ("sha-1", "SHA-1", AlgorithmClass::Broken),
1854            ("TDES", "3DES", AlgorithmClass::Broken),
1855            ("DES-EDE3", "3DES", AlgorithmClass::Broken),
1856            ("3DES-EDE", "3DES", AlgorithmClass::Broken),
1857            ("ARC4", "RC4", AlgorithmClass::Broken),
1858            ("ARCFOUR", "RC4", AlgorithmClass::Broken),
1859            (
1860                "Ed25519",
1861                "ED25519",
1862                AlgorithmClass::ClassicalQuantumVulnerable,
1863            ),
1864            ("ECIES", "ECIES", AlgorithmClass::ClassicalQuantumVulnerable),
1865            ("ECDHE", "ECDH", AlgorithmClass::ClassicalQuantumVulnerable),
1866            ("EC", "EC", AlgorithmClass::ClassicalQuantumVulnerable),
1867            ("ChaCha20", "CHACHA20", AlgorithmClass::Symmetric),
1868            ("Camellia", "CAMELLIA", AlgorithmClass::Symmetric),
1869        ] {
1870            let c = cls(Some(input), None, None, None, None);
1871            assert_eq!(c.family.as_deref(), Some(family), "family for {input}");
1872            assert_eq!(c.class, class, "class for {input}");
1873        }
1874    }
1875
1876    #[test]
1877    fn classify_size_in_family_string() {
1878        let c = cls(Some("ML-KEM-768"), None, None, None, None);
1879        assert_eq!(c.family.as_deref(), Some("ML-KEM"));
1880        assert_eq!(c.parameter.as_deref(), Some("768"));
1881        assert_eq!(c.class, AlgorithmClass::PostQuantum(PqcKind::MlKem));
1882
1883        let c = cls(Some("AES-128"), None, None, None, None);
1884        assert_eq!(c.family.as_deref(), Some("AES"));
1885        assert_eq!(c.parameter.as_deref(), Some("128"));
1886
1887        let c = cls(Some("AES128"), None, None, None, None);
1888        assert_eq!(c.parameter.as_deref(), Some("128"));
1889
1890        let c = cls(Some("RSA-2048"), None, None, None, None);
1891        assert_eq!(c.family.as_deref(), Some("RSA"));
1892        assert_eq!(c.parameter.as_deref(), Some("2048"));
1893        assert_eq!(c.class, AlgorithmClass::ClassicalQuantumVulnerable);
1894
1895        // SHA sizes fold into the SHA-2 family.
1896        let c = cls(Some("SHA-256"), None, None, None, None);
1897        assert_eq!(c.family.as_deref(), Some("SHA-2"));
1898        assert_eq!(c.parameter.as_deref(), Some("256"));
1899        assert_eq!(c.class, AlgorithmClass::Sha2);
1900        assert_eq!(c.label(), "SHA-256");
1901    }
1902
1903    #[test]
1904    fn classify_round3_pqc_names() {
1905        let c = cls(Some("Kyber"), None, None, Some("768"), None);
1906        assert_eq!(c.family.as_deref(), Some("ML-KEM"));
1907        assert_eq!(c.parameter.as_deref(), Some("768"));
1908
1909        let c = cls(Some("Kyber-1024"), None, None, None, None);
1910        assert_eq!(c.parameter.as_deref(), Some("1024"));
1911
1912        // Round-3 Dilithium parameter sets map to the final ML-DSA ones.
1913        let c = cls(Some("Dilithium-3"), None, None, None, None);
1914        assert_eq!(c.family.as_deref(), Some("ML-DSA"));
1915        assert_eq!(c.parameter.as_deref(), Some("65"));
1916
1917        let c = cls(Some("SPHINCS+"), None, None, None, None);
1918        assert_eq!(c.class, AlgorithmClass::PostQuantum(PqcKind::SlhDsa));
1919
1920        let c = cls(Some("Falcon"), None, None, None, None);
1921        assert_eq!(c.class, AlgorithmClass::PostQuantum(PqcKind::FnDsa));
1922    }
1923
1924    #[test]
1925    fn classify_by_oid() {
1926        // RSA arc.
1927        let c = cls(None, None, Some("1.2.840.113549.1.1.1"), Some("2048"), None);
1928        assert_eq!(c.family.as_deref(), Some("RSA"));
1929        assert_eq!(c.parameter.as_deref(), Some("2048"));
1930        assert_eq!(c.class, AlgorithmClass::ClassicalQuantumVulnerable);
1931
1932        // SHA-1 / MD5.
1933        assert_eq!(
1934            cls(None, None, Some("1.3.14.3.2.26"), None, None).class,
1935            AlgorithmClass::Broken
1936        );
1937        assert_eq!(
1938            cls(None, None, Some("1.2.840.113549.2.5"), None, None).class,
1939            AlgorithmClass::Broken
1940        );
1941
1942        // AES arc encodes the key size: .2 = AES-128-CBC, .46 = AES-256-GCM.
1943        let c = cls(None, None, Some("2.16.840.1.101.3.4.1.2"), None, None);
1944        assert_eq!(c.family.as_deref(), Some("AES"));
1945        assert_eq!(c.parameter.as_deref(), Some("128"));
1946        let c = cls(None, None, Some("2.16.840.1.101.3.4.1.46"), None, None);
1947        assert_eq!(c.parameter.as_deref(), Some("256"));
1948
1949        // SHA-2 arc.
1950        let c = cls(None, None, Some("2.16.840.1.101.3.4.2.2"), None, None);
1951        assert_eq!(c.class, AlgorithmClass::Sha2);
1952        assert_eq!(c.parameter.as_deref(), Some("384"));
1953
1954        // ML-KEM / ML-DSA / SLH-DSA arcs.
1955        let c = cls(None, None, Some("2.16.840.1.101.3.4.4.3"), None, None);
1956        assert_eq!(c.class, AlgorithmClass::PostQuantum(PqcKind::MlKem));
1957        assert_eq!(c.parameter.as_deref(), Some("1024"));
1958        let c = cls(None, None, Some("2.16.840.1.101.3.4.3.19"), None, None);
1959        assert_eq!(c.class, AlgorithmClass::PostQuantum(PqcKind::MlDsa));
1960        assert_eq!(c.parameter.as_deref(), Some("87"));
1961        let c = cls(None, None, Some("2.16.840.1.101.3.4.3.24"), None, None);
1962        assert_eq!(c.class, AlgorithmClass::PostQuantum(PqcKind::SlhDsa));
1963
1964        // Edwards / Montgomery curves.
1965        assert_eq!(
1966            cls(None, None, Some("1.3.101.112"), None, None)
1967                .family
1968                .as_deref(),
1969            Some("ED25519")
1970        );
1971
1972        // ECDSA signature and EC curve arcs.
1973        assert_eq!(
1974            cls(None, None, Some("1.2.840.10045.4.3.2"), None, None).class,
1975            AlgorithmClass::ClassicalQuantumVulnerable
1976        );
1977        assert_eq!(
1978            cls(None, None, Some("1.2.840.10045.3.1.7"), None, None).class,
1979            AlgorithmClass::ClassicalQuantumVulnerable
1980        );
1981    }
1982
1983    #[test]
1984    fn classify_name_fallback_only_without_family_and_oid() {
1985        // Name fallback fires when family and OID are both absent...
1986        let c = cls(None, Some("AES-128-CBC"), None, None, None);
1987        assert_eq!(c.family.as_deref(), Some("AES"));
1988        assert_eq!(c.parameter.as_deref(), Some("128"));
1989
1990        let c = cls(None, Some("RSA-2048-PKCS1"), None, None, None);
1991        assert_eq!(c.family.as_deref(), Some("RSA"));
1992
1993        // ...but never overrides a present (unrecognized) family or OID.
1994        let c = cls(
1995            Some("proprietary-frobnicator"),
1996            Some("RSA-2048"),
1997            None,
1998            None,
1999            None,
2000        );
2001        assert_eq!(c.class, AlgorithmClass::Unknown);
2002        let c = cls(None, Some("RSA-2048"), Some("9.9.9.9"), None, None);
2003        assert_eq!(c.class, AlgorithmClass::Unknown);
2004
2005        // Word-boundary matching: "DESCRIPTOR" must not match DES.
2006        let c = cls(None, Some("DESCRIPTOR-HANDLER"), None, None, None);
2007        assert_eq!(c.class, AlgorithmClass::Unknown);
2008    }
2009
2010    #[test]
2011    fn classify_elliptic_curve_field() {
2012        // The parsed-but-previously-unread ellipticCurve field marks the
2013        // asset as classical EC crypto even without family/OID/name.
2014        let c = cls(None, None, None, None, Some("secg/secp256r1"));
2015        assert_eq!(c.class, AlgorithmClass::ClassicalQuantumVulnerable);
2016        assert_eq!(c.family.as_deref(), Some("EC"));
2017        assert_eq!(c.parameter.as_deref(), Some("secg/secp256r1"));
2018    }
2019
2020    #[test]
2021    fn classify_name_enriches_missing_parameter() {
2022        // Family without a parameter set picks the size up from the name.
2023        let c = cls(Some("AES"), Some("AES-256-GCM"), None, None, None);
2024        assert_eq!(c.parameter.as_deref(), Some("256"));
2025    }
2026
2027    #[test]
2028    fn classify_cipher_suite_names() {
2029        let found = classify_algorithm_names("TLS_RSA_WITH_RC4_128_SHA");
2030        let families: Vec<_> = found.iter().filter_map(|c| c.family.as_deref()).collect();
2031        assert!(families.contains(&"RSA"), "{families:?}");
2032        assert!(families.contains(&"RC4"), "{families:?}");
2033        assert!(families.contains(&"SHA-1"), "{families:?}");
2034
2035        // A CNSA 2.0 suite: everything recognized resolves to approved
2036        // algorithms, and the noise tokens (TLS/GCM) match nothing.
2037        let found = classify_algorithm_names("TLS_AES_256_GCM_SHA384_ML_KEM_1024");
2038        assert!(
2039            found.iter().any(
2040                |c| c.family.as_deref() == Some("AES") && c.parameter.as_deref() == Some("256")
2041            )
2042        );
2043        assert!(
2044            found
2045                .iter()
2046                .any(|c| c.family.as_deref() == Some("SHA-2")
2047                    && c.parameter.as_deref() == Some("384"))
2048        );
2049        assert!(found.iter().any(
2050            |c| c.family.as_deref() == Some("ML-KEM") && c.parameter.as_deref() == Some("1024")
2051        ));
2052        assert!(!found.iter().any(|c| c.class == AlgorithmClass::Broken));
2053    }
2054
2055    /// Compound algorithmFamily strings carrying a mode/chaining/padding
2056    /// qualifier must classify by their base algorithm (+ key size), not
2057    /// fall through to Unknown (finding: "DES-CBC" passed CNSA2/PQC).
2058    #[test]
2059    fn classify_compound_family_mode_suffixes() {
2060        for (family, canonical, param, class) in [
2061            ("DES-CBC", "DES", None, AlgorithmClass::Broken),
2062            ("3DES-EDE-CBC", "3DES", None, AlgorithmClass::Broken),
2063            ("AES-128-CBC", "AES", Some("128"), AlgorithmClass::Symmetric),
2064            ("AES-256-GCM", "AES", Some("256"), AlgorithmClass::Symmetric),
2065            (
2066                "RSA/ECB/PKCS1Padding",
2067                "RSA",
2068                None,
2069                AlgorithmClass::ClassicalQuantumVulnerable,
2070            ),
2071        ] {
2072            let c = cls(Some(family), None, None, None, None);
2073            assert_eq!(c.family.as_deref(), Some(canonical), "family for {family}");
2074            assert_eq!(c.parameter.as_deref(), param, "parameter for {family}");
2075            assert_eq!(c.class, class, "class for {family}");
2076        }
2077        // The scan reports the most severe mention: a compound family
2078        // mixing a broken cipher with an approved hash is the cipher.
2079        let c = cls(Some("DES-CBC-HMAC-SHA384"), None, None, None, None);
2080        assert_eq!(c.family.as_deref(), Some("DES"));
2081        assert_eq!(c.class, AlgorithmClass::Broken);
2082        // Silent case: families with no recognizable token stay Unknown.
2083        for family in ["Hybrid-KEM", "proprietary-frobnicator"] {
2084            let c = cls(Some(family), None, None, None, None);
2085            assert_eq!(c.class, AlgorithmClass::Unknown, "class for {family}");
2086        }
2087    }
2088
2089    /// FIPS 180-4 truncated SHA-2 must classify by the truncated OUTPUT
2090    /// size — SHA-512/256 is a 256-bit digest, not CNSA-approved SHA-512.
2091    #[test]
2092    fn classify_truncated_sha2_variants() {
2093        for (family, param) in [
2094            ("SHA-512/256", "256"),
2095            ("SHA-512/224", "224"),
2096            ("SHA512/256", "256"),
2097            ("sha-512/224", "224"),
2098        ] {
2099            let c = cls(Some(family), None, None, None, None);
2100            assert_eq!(c.family.as_deref(), Some("SHA-2"), "family for {family}");
2101            assert_eq!(c.parameter.as_deref(), Some(param), "param for {family}");
2102            assert_eq!(c.class, AlgorithmClass::Sha2);
2103        }
2104        // Full SHA-512 keeps its 512-bit reading, and the family-string
2105        // path now agrees with the OID path for the truncated variants.
2106        let c = cls(Some("SHA-512"), None, None, None, None);
2107        assert_eq!(c.parameter.as_deref(), Some("512"));
2108        let by_oid = cls(None, None, Some("2.16.840.1.101.3.4.2.6"), None, None);
2109        assert_eq!(by_oid.parameter.as_deref(), Some("256"));
2110    }
2111
2112    /// The name fallback must report the most severe mention, not the
2113    /// first: "sha384-rsa-signature" is quantum-vulnerable RSA, not
2114    /// CNSA-approved SHA-384 (finding: token order hid RSA).
2115    #[test]
2116    fn classify_name_fallback_picks_most_severe() {
2117        let hash_first = cls(None, Some("sha384-rsa-signature"), None, None, None);
2118        let rsa_first = cls(None, Some("rsa-sha384-signature"), None, None, None);
2119        for c in [&hash_first, &rsa_first] {
2120            assert_eq!(c.family.as_deref(), Some("RSA"), "{c:?}");
2121            assert_eq!(c.class, AlgorithmClass::ClassicalQuantumVulnerable);
2122        }
2123        // Broken outranks quantum-vulnerable.
2124        let c = cls(None, Some("rsa-md5-legacy-signer"), None, None, None);
2125        assert_eq!(c.family.as_deref(), Some("MD5"));
2126        assert_eq!(c.class, AlgorithmClass::Broken);
2127        // Silent case: single-mention names are unaffected.
2128        let c = cls(None, Some("sha384-digest"), None, None, None);
2129        assert_eq!(c.class, AlgorithmClass::Sha2);
2130    }
2131
2132    /// A declared family "SHA" with a SHA-2 digest size in the parameter
2133    /// set is SHA-2 of that size, not broken "SHA-1-384" (finding). Bare
2134    /// "SHA" without a disambiguating parameter keeps the SHA-1 reading
2135    /// (TLS cipher-suite convention).
2136    #[test]
2137    fn classify_bare_sha_with_parameter_set() {
2138        for param in ["224", "256", "384", "512"] {
2139            let c = cls(Some("SHA"), None, None, Some(param), None);
2140            assert_eq!(c.family.as_deref(), Some("SHA-2"), "family for SHA/{param}");
2141            assert_eq!(c.parameter.as_deref(), Some(param));
2142            assert_eq!(c.class, AlgorithmClass::Sha2);
2143        }
2144        let c = cls(Some("SHA"), None, None, None, None);
2145        assert_eq!(c.family.as_deref(), Some("SHA-1"));
2146        assert_eq!(c.class, AlgorithmClass::Broken);
2147        let c = cls(Some("SHA"), None, None, Some("160"), None);
2148        assert_eq!(c.family.as_deref(), Some("SHA-1"));
2149        assert_eq!(c.class, AlgorithmClass::Broken);
2150    }
2151
2152    /// National quantum-vulnerable algorithms (SM2, GOST R 34.10,
2153    /// brainpool curves) must classify as such instead of Unknown, and
2154    /// SM4/GOST hashes get their proper classes (finding: SM2/GOST CBOMs
2155    /// passed NIST PQC with only a warning).
2156    #[test]
2157    fn classify_national_algorithms() {
2158        // By family/name alias.
2159        for (family, canonical) in [
2160            ("SM2", "SM2"),
2161            ("sm9", "SM9"),
2162            ("GOST", "GOST-R-34.10"),
2163            ("GOST R 34.10", "GOST-R-34.10"),
2164            ("GOST-R-34.10-2012", "GOST-R-34.10"),
2165            ("brainpoolP256r1", "EC"),
2166        ] {
2167            let c = cls(Some(family), None, None, None, None);
2168            assert_eq!(c.family.as_deref(), Some(canonical), "family for {family}");
2169            assert_eq!(
2170                c.class,
2171                AlgorithmClass::ClassicalQuantumVulnerable,
2172                "class for {family}"
2173            );
2174        }
2175        // By OID.
2176        for (oid, canonical, class) in [
2177            (
2178                "1.2.156.10197.1.301",
2179                "SM2",
2180                AlgorithmClass::ClassicalQuantumVulnerable,
2181            ),
2182            (
2183                "1.2.643.2.2.19",
2184                "GOST-R-34.10",
2185                AlgorithmClass::ClassicalQuantumVulnerable,
2186            ),
2187            (
2188                "1.2.643.7.1.1.1.1",
2189                "GOST-R-34.10",
2190                AlgorithmClass::ClassicalQuantumVulnerable,
2191            ),
2192            (
2193                "1.3.36.3.3.2.8.1.1.7",
2194                "EC",
2195                AlgorithmClass::ClassicalQuantumVulnerable,
2196            ),
2197            ("1.2.156.10197.1.104", "SM4", AlgorithmClass::Symmetric),
2198            (
2199                "1.2.643.7.1.1.2.2",
2200                "GOST-R-34.11",
2201                AlgorithmClass::OtherHash,
2202            ),
2203        ] {
2204            let c = cls(None, None, Some(oid), None, None);
2205            assert_eq!(c.family.as_deref(), Some(canonical), "family for {oid}");
2206            assert_eq!(c.class, class, "class for {oid}");
2207        }
2208        // Symmetric / hash aliases.
2209        assert_eq!(
2210            cls(Some("SM4"), None, None, None, None).class,
2211            AlgorithmClass::Symmetric
2212        );
2213        assert_eq!(
2214            cls(Some("Kuznyechik"), None, None, None, None).class,
2215            AlgorithmClass::Symmetric
2216        );
2217        assert_eq!(
2218            cls(Some("Streebog"), None, None, None, None).class,
2219            AlgorithmClass::OtherHash
2220        );
2221    }
2222
2223    /// NIST CSOR sigAlgs 32–34 are pre-hash ML-DSA (HashML-DSA-44/65/87
2224    /// with SHA-512), not SLH-DSA (finding: wrong family + FIPS citation).
2225    #[test]
2226    fn classify_hash_ml_dsa_oids() {
2227        for (oid, param) in [
2228            ("2.16.840.1.101.3.4.3.32", "44"),
2229            ("2.16.840.1.101.3.4.3.33", "65"),
2230            ("2.16.840.1.101.3.4.3.34", "87"),
2231        ] {
2232            let c = cls(None, None, Some(oid), None, None);
2233            assert_eq!(
2234                c.class,
2235                AlgorithmClass::PostQuantum(PqcKind::MlDsa),
2236                "class for {oid}"
2237            );
2238            assert_eq!(c.parameter.as_deref(), Some(param), "param for {oid}");
2239        }
2240        // The SLH-DSA parameter-set range (20–31) and the pre-hash SLH-DSA
2241        // range (35–46) still classify as SLH-DSA.
2242        for oid in [
2243            "2.16.840.1.101.3.4.3.20",
2244            "2.16.840.1.101.3.4.3.31",
2245            "2.16.840.1.101.3.4.3.35",
2246        ] {
2247            assert_eq!(
2248                cls(None, None, Some(oid), None, None).class,
2249                AlgorithmClass::PostQuantum(PqcKind::SlhDsa),
2250                "class for {oid}"
2251            );
2252        }
2253    }
2254
2255    /// The guarded name scan drops over-generic bare tokens (SEED, EC/ECC)
2256    /// that collide with everyday words, while the declared-family path and
2257    /// cipher-suite scan keep them (finding: 'seed-expander' was flagged as
2258    /// the SEED block cipher).
2259    #[test]
2260    fn guarded_name_scan_drops_overgeneric_tokens() {
2261        for name in ["seed-expander", "ec2-instance-agent", "ecc-memory-check"] {
2262            assert!(
2263                classify_algorithm_names_guarded(name).is_empty(),
2264                "guarded scan must ignore {name}"
2265            );
2266            assert_eq!(
2267                cls(None, Some(name), None, None, None).class,
2268                AlgorithmClass::Unknown,
2269                "name fallback must not classify {name}"
2270            );
2271        }
2272        // Distinctive tokens still classify through the guarded scan.
2273        assert_eq!(
2274            cls(None, Some("brainpoolP256r1-signer"), None, None, None).class,
2275            AlgorithmClass::ClassicalQuantumVulnerable
2276        );
2277        // The unguarded scan (cipher-suite names) keeps bare SEED, and the
2278        // declared family stays authoritative.
2279        assert!(
2280            classify_algorithm_names("TLS_RSA_WITH_SEED_CBC_SHA")
2281                .iter()
2282                .any(|c| c.family.as_deref() == Some("SEED"))
2283        );
2284        assert_eq!(
2285            cls(Some("SEED"), None, None, None, None).class,
2286            AlgorithmClass::Symmetric
2287        );
2288    }
2289
2290    #[test]
2291    fn related_material_builder() {
2292        let key = RelatedCryptoMaterialProperties::new(CryptoMaterialType::PublicKey)
2293            .with_id("test-id".to_string())
2294            .with_state(CryptoMaterialState::Active)
2295            .with_size(2048)
2296            .with_algorithm_ref("algo/rsa-2048".to_string())
2297            .with_secured_by(SecuredBy {
2298                mechanism: "HSM".to_string(),
2299                algorithm_ref: Some("algo/aes-256".to_string()),
2300            });
2301
2302        assert_eq!(key.material_type, CryptoMaterialType::PublicKey);
2303        assert_eq!(key.state, Some(CryptoMaterialState::Active));
2304        assert_eq!(key.size, Some(2048));
2305        assert!(key.secured_by.is_some());
2306    }
2307}