Skip to main content

zond_engine/core/models/port/
security.rs

1// Copyright (c) 2026 Erik Lening (hollowpointer) and Contributors
2//
3// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4// If a copy of the MPL was not distributed with this file, You can obtain one at
5// https://mozilla.org/MPL/2.0/.
6
7//! # Port Security and Encryption Metadata
8//!
9//! This module provides the [`Security`] model, focused on capturing TLS/SSL
10//! negotiated parameters, ALPN records, and X.509 certificate lifecycles.
11
12use std::time::{Duration, SystemTime};
13
14/// Information about transport security (TLS/SSL) successfully negotiated on a port.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Security {
17    /// The specific TLS version negotiated (e.g., "TLSv1.3").
18    tls_version: Option<String>,
19
20    /// The cipher suite selected by the server (e.g., "TLS_AES_256_GCM_SHA384").
21    cipher_suite: Option<String>,
22
23    /// Application-Layer Protocol Negotiation (ALPN) protocols supported (e.g., ["h2", "http/1.1"]).
24    alpn: Vec<String>,
25
26    /// Public key information and lifecycle summaries for the presented X.509 certificate.
27    certificate: Option<CertificateInfo>,
28}
29
30impl Security {
31    /// Creates a new, empty security record.
32    pub fn new() -> Self {
33        Self {
34            tls_version: None,
35            cipher_suite: None,
36            alpn: Vec::new(),
37            certificate: None,
38        }
39    }
40
41    /// Returns the negotiated TLS version, if any.
42    pub fn tls_version(&self) -> Option<&str> {
43        self.tls_version.as_deref()
44    }
45
46    /// Returns the negotiated cipher suite, if any.
47    pub fn cipher_suite(&self) -> Option<&str> {
48        self.cipher_suite.as_deref()
49    }
50
51    /// Returns the negotiated ALPN protocols.
52    pub fn alpn(&self) -> &[String] {
53        &self.alpn
54    }
55
56    /// Returns the certificate information, if available.
57    pub fn certificate(&self) -> Option<&CertificateInfo> {
58        self.certificate.as_ref()
59    }
60
61    /// Builder method to set the negotiated TLS version.
62    pub fn with_tls_version(mut self, version: impl Into<String>) -> Self {
63        self.tls_version = Some(version.into());
64        self
65    }
66
67    /// Builder method to set the negotiated cipher suite.
68    pub fn with_cipher_suite(mut self, cipher: impl Into<String>) -> Self {
69        self.cipher_suite = Some(cipher.into());
70        self
71    }
72
73    /// Builder method to add an ALPN protocol string.
74    pub fn add_alpn(mut self, protocol: impl Into<String>) -> Self {
75        let proto_str = protocol.into();
76        if !self.alpn.contains(&proto_str) {
77            self.alpn.push(proto_str);
78        }
79        self
80    }
81
82    /// Builder method to attach parsed certificate information.
83    pub fn with_certificate(mut self, cert: CertificateInfo) -> Self {
84        self.certificate = Some(cert);
85        self
86    }
87
88    /// Merges another security record into this one.
89    ///
90    /// Preserves existing TLS version and cipher suite if already populated,
91    /// but safely deduplicates and merges ALPN arrays.
92    pub fn merge(&mut self, other: Security) {
93        if self.tls_version.is_none() {
94            self.tls_version = other.tls_version;
95        }
96        if self.cipher_suite.is_none() {
97            self.cipher_suite = other.cipher_suite;
98        }
99        if self.certificate.is_none() {
100            self.certificate = other.certificate;
101        }
102
103        // Merge and deduplicate ALPN protocols
104        for protocol in other.alpn {
105            if !self.alpn.contains(&protocol) {
106                self.alpn.push(protocol);
107            }
108        }
109    }
110
111    /// Returns `true` if the certificate is actively valid at the current system time.
112    /// Returns `false` if the certificate is expired, not yet valid, or missing.
113    pub fn is_cert_valid(&self) -> bool {
114        self.is_cert_valid_at(SystemTime::now())
115    }
116
117    /// Returns `true` if the certificate is valid at a specific target time.
118    pub fn is_cert_valid_at(&self, target_time: SystemTime) -> bool {
119        self.certificate
120            .as_ref()
121            .is_some_and(|c| target_time >= c.validity_start() && target_time <= c.validity_end())
122    }
123
124    /// Returns `true` if the certificate is currently valid, but expires within the given threshold.
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use std::time::Duration;
130    /// # use zond_engine::core::models::port::{Security, CertificateInfo};
131    /// # let mut sec = Security::new();
132    ///
133    /// // Check if the certificate expires in the next 30 days
134    /// let expires_soon = sec.is_cert_expiring(Duration::from_secs(86400 * 30));
135    /// ```
136    pub fn is_cert_expiring(&self, threshold: Duration) -> bool {
137        let now = SystemTime::now();
138        self.certificate.as_ref().is_some_and(|c| {
139            // Must be currently valid...
140            if now < c.validity_start() || now > c.validity_end() {
141                return false;
142            }
143            // ...but expiring before the threshold
144            c.validity_end() < now + threshold
145        })
146    }
147}
148
149impl Default for Security {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155/// A parsed summary of a service's X.509 security certificate.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct CertificateInfo {
158    /// The Common Name (CN) of the certificate subject.
159    common_name: String,
160
161    /// Subject Alternative Names (SANs) associated with the certificate.
162    sans: Vec<String>,
163
164    /// The name of the Issuing Certificate Authority (CA).
165    issuer: String,
166
167    /// The timestamp when the certificate becomes valid.
168    validity_start: SystemTime,
169
170    /// The timestamp when the certificate expires.
171    validity_end: SystemTime,
172
173    /// The type of public key used (e.g., "RSA", "ECDSA", "Ed25519").
174    pubkey_type: String,
175
176    /// The size of the public key in bits (e.g., 2048, 4096, 256).
177    pubkey_bits: u32,
178
179    /// The SHA-256 fingerprint of the raw DER-encoded certificate.
180    fingerprint_sha256: String,
181}
182
183impl CertificateInfo {
184    /// Creates a new certificate information record.
185    #[allow(clippy::too_many_arguments)]
186    pub fn new(
187        common_name: impl Into<String>,
188        sans: Vec<String>,
189        issuer: impl Into<String>,
190        validity_start: SystemTime,
191        validity_end: SystemTime,
192        pubkey_type: impl Into<String>,
193        pubkey_bits: u32,
194        fingerprint_sha256: impl Into<String>,
195    ) -> Self {
196        Self {
197            common_name: common_name.into(),
198            sans,
199            issuer: issuer.into(),
200            validity_start,
201            validity_end,
202            pubkey_type: pubkey_type.into(),
203            pubkey_bits,
204            fingerprint_sha256: fingerprint_sha256.into(),
205        }
206    }
207
208    /// Returns the Common Name (CN) of the certificate.
209    pub fn common_name(&self) -> &str {
210        &self.common_name
211    }
212
213    /// Returns the Subject Alternative Names (SANs).
214    pub fn sans(&self) -> &[String] {
215        &self.sans
216    }
217
218    /// Returns the issuer of the certificate.
219    pub fn issuer(&self) -> &str {
220        &self.issuer
221    }
222
223    /// Returns the start time of the certificate's validity.
224    pub fn validity_start(&self) -> SystemTime {
225        self.validity_start
226    }
227
228    /// Returns the expiration time of the certificate.
229    pub fn validity_end(&self) -> SystemTime {
230        self.validity_end
231    }
232
233    /// Returns the public key type (e.g., "RSA").
234    pub fn pubkey_type(&self) -> &str {
235        &self.pubkey_type
236    }
237
238    /// Returns the size of the public key in bits.
239    pub fn pubkey_bits(&self) -> u32 {
240        self.pubkey_bits
241    }
242
243    /// Returns the SHA-256 fingerprint of the certificate.
244    pub fn fingerprint_sha256(&self) -> &str {
245        &self.fingerprint_sha256
246    }
247}
248
249// ╔════════════════════════════════════════════╗
250// ║ ████████╗███████╗███████╗████████╗███████╗ ║
251// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
252// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
253// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
254// ║    ██║   ███████╗███████║   ██║   ███████║ ║
255// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
256// ╚════════════════════════════════════════════╝
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn mock_cert(start_offset: i64, end_offset: i64) -> CertificateInfo {
263        let now = SystemTime::now();
264
265        let start = if start_offset < 0 {
266            now - Duration::from_secs(start_offset.unsigned_abs())
267        } else {
268            now + Duration::from_secs(start_offset as u64)
269        };
270
271        let end = if end_offset < 0 {
272            now - Duration::from_secs(end_offset.unsigned_abs())
273        } else {
274            now + Duration::from_secs(end_offset as u64)
275        };
276
277        CertificateInfo::new(
278            "test.local",
279            vec!["*.test.local".into()],
280            "Local CA",
281            start,
282            end,
283            "RSA",
284            2048,
285            "deadbeef",
286        )
287    }
288
289    #[test]
290    fn security_builder_pattern() {
291        let sec = Security::new()
292            .with_tls_version("TLSv1.3")
293            .with_cipher_suite("TLS_AES_256_GCM_SHA384")
294            .add_alpn("h2")
295            .add_alpn("http/1.1");
296
297        assert_eq!(sec.tls_version(), Some("TLSv1.3"));
298        assert_eq!(sec.cipher_suite(), Some("TLS_AES_256_GCM_SHA384"));
299        assert_eq!(sec.alpn().len(), 2);
300    }
301
302    #[test]
303    fn security_merge_logic() {
304        let mut s1 = Security::new()
305            .with_tls_version("TLSv1.2")
306            .add_alpn("http/1.1");
307
308        let s2 = Security::new()
309            .with_cipher_suite("AES128-GCM")
310            .add_alpn("h2")
311            .add_alpn("http/1.1"); // Should be deduplicated
312
313        s1.merge(s2);
314
315        assert_eq!(s1.tls_version(), Some("TLSv1.2"));
316        assert_eq!(s1.cipher_suite(), Some("AES128-GCM"));
317        assert_eq!(s1.alpn().len(), 2);
318        assert!(s1.alpn().contains(&"h2".to_string()));
319    }
320
321    #[test]
322    fn certificate_validity_lifecycle() {
323        // Valid from 10 days ago until 10 days from now
324        let valid_cert = mock_cert(-864000, 864000);
325        let sec_valid = Security::new().with_certificate(valid_cert);
326
327        assert!(sec_valid.is_cert_valid());
328        // Threshold check: Does it expire in the next 5 days? No.
329        assert!(!sec_valid.is_cert_expiring(Duration::from_secs(86400 * 5)));
330        // Threshold check: Does it expire in the next 15 days? Yes.
331        assert!(sec_valid.is_cert_expiring(Duration::from_secs(86400 * 15)));
332
333        // Expired 5 days ago
334        let expired_cert = mock_cert(-864000, -432000);
335        let sec_expired = Security::new().with_certificate(expired_cert);
336
337        assert!(!sec_expired.is_cert_valid());
338        // An already expired cert shouldn't trigger "expiring soon" alerts
339        assert!(!sec_expired.is_cert_expiring(Duration::from_secs(86400 * 30)));
340
341        // Not yet valid (starts tomorrow)
342        let future_cert = mock_cert(86400, 864000);
343        let sec_future = Security::new().with_certificate(future_cert);
344
345        assert!(!sec_future.is_cert_valid());
346    }
347}