Skip to main content

pqaudit/
lib.rs

1pub mod audit;
2pub mod baseline;
3pub mod cli;
4#[cfg(feature = "mcp")]
5pub mod mcp;
6pub mod output;
7pub mod probe;
8pub mod scanner;
9
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13// ── TLS primitives ────────────────────────────────────────────────────────────
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum TlsVersion {
17    Tls13,
18    Tls12,
19    Tls11,
20    Tls10,
21    Unknown(u16),
22}
23
24impl fmt::Display for TlsVersion {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Self::Tls13 => write!(f, "TLS 1.3"),
28            Self::Tls12 => write!(f, "TLS 1.2"),
29            Self::Tls11 => write!(f, "TLS 1.1"),
30            Self::Tls10 => write!(f, "TLS 1.0"),
31            Self::Unknown(v) => write!(f, "Unknown(0x{v:04x})"),
32        }
33    }
34}
35
36/// TLS NamedGroup code point.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct NamedGroup {
39    pub code_point: u16,
40    pub name: String,
41    pub is_pqc: bool,
42}
43
44/// TLS cipher suite identifier.
45#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub struct CipherSuite {
47    pub id: u16,
48    pub name: String,
49}
50
51/// Key type and size from a certificate.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub enum KeyInfo {
54    Rsa { bits: u32 },
55    Ec { curve: String },
56    Ed25519,
57    Ed448,
58    MlDsa { level: u8 },
59    Unknown,
60}
61
62/// Position of a certificate in the chain.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub enum ChainPosition {
65    Leaf,
66    Intermediate { depth: u8 },
67    Root,
68}
69
70/// Algorithm identifier used in deadline tables.
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72pub enum AlgorithmId {
73    Rsa { min_bits: u32 },
74    EcP256,
75    EcP384,
76    EcP521,
77    X25519,
78    X448,
79    Ed25519,
80    Ed448,
81    Dh { min_bits: u32 },
82    MlKem768,
83    MlKem1024,
84    MlDsa65,
85    MlDsa87,
86    Aes128,
87    Aes256,
88    Sha256,
89    Sha384,
90    Sha512,
91    ChaCha20Poly1305,
92}
93
94// ── Probe results ─────────────────────────────────────────────────────────────
95
96/// Results from the Layer 1 rustls PQC handshake probe.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct PqcHandshakeResult {
99    pub negotiated_version: TlsVersion,
100    pub negotiated_suite: CipherSuite,
101    pub negotiated_group: NamedGroup,
102    pub hrr_required: bool,
103    pub cert_chain_der: Vec<Vec<u8>>,
104}
105
106/// Results from active cipher enumeration (--full-scan).
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct CipherInventory {
109    pub tls13_suites: Vec<CipherSuite>,
110    pub tls12_suites: Vec<CipherSuite>,
111    /// True if server accepted the Kyber draft key share (group 0x6399).
112    #[serde(default)]
113    pub kyber_draft_accepted: bool,
114}
115
116/// Result of the downgrade probe.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub enum DowngradeResult {
119    Accepted { negotiated_version: TlsVersion },
120    Rejected,
121    Timeout,
122    Error(String),
123}
124
125/// Aggregate probe data fed to the audit engine.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ProbeResults {
128    pub target: String,
129    pub port: u16,
130    pub pqc_handshake: Result<PqcHandshakeResult, String>,
131    pub cipher_inventory: Option<CipherInventory>,
132    pub downgrade: DowngradeResult,
133}
134
135// ── Error types ───────────────────────────────────────────────────────────────
136
137#[derive(Debug, thiserror::Error)]
138pub enum ProbeError {
139    #[error("connection refused to {host}:{port}")]
140    ConnectionRefused { host: String, port: u16 },
141    #[error("DNS resolution failed for {host}")]
142    DnsResolutionFailed { host: String },
143    #[error("TLS handshake failed: {reason}")]
144    TlsHandshakeFailed { reason: String },
145    #[error("certificate validation failed: {reason}")]
146    CertificateValidationFailed { reason: String },
147    #[error("SNI mismatch: presented={presented}, expected={expected}")]
148    SniMismatch { presented: String, expected: String },
149    #[error("timeout after {after_ms}ms")]
150    Timeout { after_ms: u64 },
151    #[error("STARTTLS upgrade failed for {protocol:?}: {reason}")]
152    StarttlsUpgradeFailed {
153        protocol: StarttlsProtocol,
154        reason: String,
155    },
156    #[error("certificate parse error: {reason}")]
157    CertificateParseError { reason: String },
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub enum StarttlsProtocol {
162    Smtp,
163    Imap,
164    Pop3,
165    Ldap,
166    Other(String),
167}
168
169// ── Scan report ───────────────────────────────────────────────────────────────
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct TargetReport {
173    pub target: String,
174    pub port: u16,
175    pub score: audit::scoring::model::ScoringResult,
176    pub hndl: audit::hndl::HndlAssessment,
177    pub findings: Vec<audit::findings::Finding>,
178    pub cert_chain: Option<audit::cert_chain::CertChainReport>,
179    pub cipher_inventory: Option<CipherInventory>,
180    pub downgrade: DowngradeResult,
181    pub error: Option<String>,
182    /// Negotiated key exchange group from the PQC handshake probe.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub negotiated_group: Option<NamedGroup>,
185    /// Negotiated cipher suite from the PQC handshake probe.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub negotiated_suite: Option<CipherSuite>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ScanReport {
192    pub schema_version: String,
193    pub scanned_at: String,
194    pub compliance_mode: cli::ComplianceMode,
195    pub targets: Vec<TargetReport>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub comparison: Option<output::compare::ComparisonReport>,
198}
199
200// ── Test helpers shared across modules ────────────────────────────────────────
201#[cfg(test)]
202pub mod tests_common {
203    use crate::audit::{
204        cert_chain::CertChainReport,
205        findings::Finding,
206        hndl::{HndlAssessment, HndlRating},
207        scoring::model::{CategoryScore, ScoringResult},
208    };
209    use crate::cli::ComplianceMode;
210    use crate::{CipherInventory, DowngradeResult, ScanReport, TargetReport, TlsVersion};
211
212    fn zero_category(name: &str) -> CategoryScore {
213        CategoryScore {
214            name: name.into(),
215            points: 0,
216            max_points: 0,
217            notes: vec![],
218        }
219    }
220
221    pub fn stub_target_report(score: u8) -> TargetReport {
222        use crate::CipherSuite;
223        TargetReport {
224            target: "example.com".into(),
225            port: 443,
226            score: ScoringResult {
227                total: score,
228                key_exchange: zero_category("key_exchange"),
229                tls_version: zero_category("tls_version"),
230                cipher_suite: zero_category("cipher_suite"),
231                cert_chain: zero_category("cert_chain"),
232                downgrade_posture: zero_category("downgrade_posture"),
233            },
234            hndl: HndlAssessment {
235                rating: HndlRating::None,
236                exposure_window_years: 0.0,
237                cert_expires_before_q_day: false,
238                notes: vec![],
239            },
240            findings: vec![],
241            cert_chain: Some(CertChainReport {
242                entries: vec![],
243                findings: vec![],
244            }),
245            cipher_inventory: Some(CipherInventory {
246                tls13_suites: vec![CipherSuite {
247                    id: 0x1302,
248                    name: "TLS_AES_256_GCM_SHA384".into(),
249                }],
250                tls12_suites: vec![],
251                kyber_draft_accepted: false,
252            }),
253            downgrade: DowngradeResult::Rejected,
254            error: None,
255            negotiated_group: Some(crate::NamedGroup {
256                code_point: 0x11EC,
257                name: "X25519MLKEM768".into(),
258                is_pqc: true,
259            }),
260            negotiated_suite: Some(CipherSuite {
261                id: 0x1302,
262                name: "TLS_AES_256_GCM_SHA384".into(),
263            }),
264        }
265    }
266
267    pub fn stub_scan_report() -> ScanReport {
268        ScanReport {
269            schema_version: "1.0".into(),
270            scanned_at: "2026-01-01T00:00:00Z".into(),
271            compliance_mode: ComplianceMode::Nist,
272            targets: vec![stub_target_report(80)],
273            comparison: None,
274        }
275    }
276
277    /// Returns a ScanReport with one target that has a ClassicalKeyExchangeOnly finding.
278    pub fn stub_scan_report_with_all_findings() -> ScanReport {
279        use crate::audit::findings::{Finding, FindingKind, Severity};
280        use crate::NamedGroup;
281
282        let mut target = stub_target_report(30);
283        target.findings = vec![
284            Finding {
285                kind: FindingKind::ClassicalKeyExchangeOnly {
286                    group: NamedGroup {
287                        code_point: 0x001D,
288                        name: "x25519".into(),
289                        is_pqc: false,
290                    },
291                },
292                severity: Severity::Error,
293            },
294            Finding {
295                kind: FindingKind::DowngradeAccepted,
296                severity: Severity::Warning,
297            },
298        ];
299        ScanReport {
300            schema_version: "1.0".into(),
301            scanned_at: "2026-01-01T00:00:00Z".into(),
302            compliance_mode: ComplianceMode::Nist,
303            targets: vec![target],
304            comparison: None,
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn tls_version_display() {
315        assert_eq!(TlsVersion::Tls13.to_string(), "TLS 1.3");
316        assert_eq!(TlsVersion::Tls12.to_string(), "TLS 1.2");
317    }
318
319    #[test]
320    fn probe_error_is_send_sync() {
321        fn assert_send_sync<T: Send + Sync>() {}
322        assert_send_sync::<ProbeError>();
323    }
324}