Skip to main content

stackforge_core/layer/tls/
cert.rs

1//! TLS Certificate handling.
2//!
3//! Wraps raw DER-encoded X.509 certificates for basic inspection.
4//! This is intentionally minimal - just enough for TLS protocol operations.
5
6/// A TLS certificate wrapping raw DER-encoded X.509 data.
7#[derive(Debug, Clone)]
8pub struct TlsCertificate {
9    /// Raw DER-encoded certificate data.
10    pub der: Vec<u8>,
11}
12
13impl TlsCertificate {
14    /// Create from DER-encoded bytes.
15    pub fn from_der(der: Vec<u8>) -> Self {
16        Self { der }
17    }
18
19    /// Create from PEM-encoded string.
20    pub fn from_pem(pem: &str) -> Option<Self> {
21        let lines: Vec<&str> = pem
22            .lines()
23            .filter(|line| !line.starts_with("-----"))
24            .collect();
25        let b64 = lines.join("");
26        let der = base64_decode(&b64)?;
27        Some(Self { der })
28    }
29
30    /// Get the DER-encoded data.
31    pub fn as_der(&self) -> &[u8] {
32        &self.der
33    }
34
35    /// Get the certificate length.
36    pub fn len(&self) -> usize {
37        self.der.len()
38    }
39
40    /// Check if empty.
41    pub fn is_empty(&self) -> bool {
42        self.der.is_empty()
43    }
44
45    /// Extract the subject common name (CN) from the certificate.
46    ///
47    /// This is a basic ASN.1 parser that looks for the CN OID (2.5.4.3).
48    /// In X.509, issuer comes before subject, so subject CN is the second occurrence.
49    /// For self-signed certs (only one CN), falls back to the first occurrence.
50    pub fn subject_cn(&self) -> Option<String> {
51        let cn_oid = [0x55, 0x04, 0x03];
52        find_nth_string_after_oid(&self.der, &cn_oid, 1)
53            .or_else(|| find_nth_string_after_oid(&self.der, &cn_oid, 0))
54    }
55
56    /// Extract the issuer common name from the certificate.
57    pub fn issuer_cn(&self) -> Option<String> {
58        let cn_oid = [0x55, 0x04, 0x03];
59        find_nth_string_after_oid(&self.der, &cn_oid, 0)
60    }
61}
62
63/// Parse a certificate chain from multiple PEM blocks.
64pub fn parse_pem_chain(pem: &str) -> Vec<TlsCertificate> {
65    let mut certs = Vec::new();
66    let mut in_cert = false;
67    let mut current = String::new();
68
69    for line in pem.lines() {
70        if line.contains("BEGIN CERTIFICATE") {
71            in_cert = true;
72            current.clear();
73            current.push_str(line);
74            current.push('\n');
75        } else if line.contains("END CERTIFICATE") {
76            current.push_str(line);
77            current.push('\n');
78            if let Some(cert) = TlsCertificate::from_pem(&current) {
79                certs.push(cert);
80            }
81            in_cert = false;
82        } else if in_cert {
83            current.push_str(line);
84            current.push('\n');
85        }
86    }
87
88    certs
89}
90
91/// Simple base64 decoder (no dependencies).
92fn base64_decode(input: &str) -> Option<Vec<u8>> {
93    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
94
95    let input: Vec<u8> = input
96        .bytes()
97        .filter(|&b| b != b'\n' && b != b'\r' && b != b' ')
98        .collect();
99    if input.is_empty() {
100        return Some(Vec::new());
101    }
102
103    let mut output = Vec::with_capacity(input.len() * 3 / 4);
104    let mut buf: u32 = 0;
105    let mut bits = 0;
106
107    for &byte in &input {
108        let val = if byte == b'=' {
109            continue;
110        } else if let Some(pos) = TABLE.iter().position(|&b| b == byte) {
111            pos as u32
112        } else {
113            return None;
114        };
115
116        buf = (buf << 6) | val;
117        bits += 6;
118
119        if bits >= 8 {
120            bits -= 8;
121            output.push((buf >> bits) as u8);
122            buf &= (1 << bits) - 1;
123        }
124    }
125
126    Some(output)
127}
128
129/// Find the nth UTF-8/PrintableString value after an OID in DER data.
130/// `n=0` returns the first occurrence (issuer CN in X.509),
131/// `n=1` returns the second (subject CN in X.509).
132fn find_nth_string_after_oid(data: &[u8], oid: &[u8], n: usize) -> Option<String> {
133    let mut count = 0;
134    for i in 0..data.len().saturating_sub(oid.len()) {
135        if data[i..].starts_with(oid) {
136            let pos = i + oid.len();
137            if pos + 2 > data.len() {
138                continue;
139            }
140            let tag = data[pos];
141            // UTF8String (0x0C), PrintableString (0x13), IA5String (0x16)
142            if tag == 0x0C || tag == 0x13 || tag == 0x16 {
143                let len = data[pos + 1] as usize;
144                if pos + 2 + len <= data.len() {
145                    if count == n {
146                        return String::from_utf8(data[pos + 2..pos + 2 + len].to_vec()).ok();
147                    }
148                    count += 1;
149                }
150            }
151        }
152    }
153    None
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_from_der() {
162        let cert = TlsCertificate::from_der(vec![0x30, 0x82, 0x01, 0x00]);
163        assert_eq!(cert.len(), 4);
164        assert!(!cert.is_empty());
165    }
166
167    #[test]
168    fn test_base64_decode() {
169        assert_eq!(base64_decode("SGVsbG8="), Some(b"Hello".to_vec()));
170        assert_eq!(base64_decode(""), Some(Vec::new()));
171        assert_eq!(base64_decode("YQ=="), Some(b"a".to_vec()));
172    }
173
174    #[test]
175    fn test_from_pem() {
176        let pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n";
177        let cert = TlsCertificate::from_pem(pem);
178        assert!(cert.is_some());
179    }
180
181    #[test]
182    fn test_find_cn() {
183        // Construct a minimal DER with CN OID followed by UTF8String "test"
184        let mut data = Vec::new();
185        data.extend_from_slice(&[0x30, 0x20]); // SEQUENCE
186        data.extend_from_slice(&[0x06, 0x03, 0x55, 0x04, 0x03]); // OID 2.5.4.3
187        data.extend_from_slice(&[0x0C, 0x04]); // UTF8String, length 4
188        data.extend_from_slice(b"test");
189
190        let result = find_nth_string_after_oid(&data, &[0x55, 0x04, 0x03], 0);
191        assert_eq!(result, Some("test".to_string()));
192    }
193}