Coverage Report

Created: 2026-02-14 06:16

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/ztunnel/src/tls/crl.rs
Line
Count
Source
1
// Copyright Istio Authors
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//     http://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
use notify::RecommendedWatcher;
16
use notify_debouncer_full::{
17
    DebounceEventResult, Debouncer, FileIdMap, new_debouncer,
18
    notify::{RecursiveMode, Watcher},
19
};
20
use rustls::pki_types::CertificateRevocationListDer;
21
use rustls_pemfile::Item;
22
use std::io::Cursor;
23
use std::path::PathBuf;
24
use std::sync::{Arc, RwLock};
25
use std::time::Duration;
26
use tracing::{debug, warn};
27
28
#[derive(Debug, thiserror::Error)]
29
pub enum CrlError {
30
    #[error("failed to read CRL file: {0}")]
31
    IoError(#[from] std::io::Error),
32
33
    #[error("failed to parse CRL: {0}")]
34
    ParseError(String),
35
36
    #[error("CRL error: {0}")]
37
    WebPkiError(String),
38
}
39
40
#[derive(Clone)]
41
/// NOTE: CRL updates take effect when new ServerConfigs are created, which happens
42
/// on certificate refresh (~12hrs). For immediate CRL enforcement, a custom
43
/// ClientCertVerifier wrapper would be needed, but rustls doesn't provide a
44
/// built-in mechanism like `with_cert_resolver` for dynamic CRL updates.
45
pub struct CrlManager {
46
    inner: Arc<RwLock<CrlManagerInner>>,
47
}
48
49
impl std::fmt::Debug for CrlManager {
50
0
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51
0
        f.debug_struct("CrlManager").finish_non_exhaustive()
52
0
    }
53
}
54
55
struct CrlManagerInner {
56
    crl_ders: Option<Vec<Vec<u8>>>, // None = not loaded, Some = loaded (may be empty)
57
    crl_path: PathBuf,
58
    _debouncer: Option<Debouncer<RecommendedWatcher, FileIdMap>>,
59
}
60
61
impl CrlManager {
62
    /// creates a new CRL manager
63
0
    pub fn new(crl_path: PathBuf) -> Result<Self, CrlError> {
64
0
        debug!(path = ?crl_path, "initializing crl manager");
65
66
0
        let manager = Self {
67
0
            inner: Arc::new(RwLock::new(CrlManagerInner {
68
0
                crl_ders: None,
69
0
                crl_path: crl_path.clone(),
70
0
                _debouncer: None,
71
0
            })),
72
0
        };
73
74
        // try to load the CRL, but don't fail if the file doesn't exist yet
75
        // (it might be mounted later via ConfigMap)
76
0
        if let Err(e) = manager.load_crl() {
77
0
            match e {
78
0
                CrlError::IoError(ref io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
79
0
                    warn!(
80
                        path = ?crl_path,
81
0
                        "crl file not found, will retry on first validation"
82
                    );
83
                }
84
                _ => {
85
0
                    debug!(error = %e, "failed to initialize crl manager");
86
0
                    return Err(e);
87
                }
88
            }
89
0
        }
90
91
0
        Ok(manager)
92
0
    }
93
94
0
    pub fn load_crl(&self) -> Result<(), CrlError> {
95
0
        let mut inner = self.inner.write().unwrap();
96
97
0
        let data = std::fs::read(&inner.crl_path)?;
98
99
        // empty file means no revocations - this is valid
100
0
        if data.is_empty() {
101
0
            debug!(path = ?inner.crl_path, "crl file is empty, treating as no revocations");
102
0
            inner.crl_ders = Some(Vec::new());
103
0
            return Ok(());
104
0
        }
105
106
        // parse all CRL blocks (handles concatenated CRLs)
107
0
        let is_pem = data.starts_with(b"-----BEGIN");
108
0
        let der_crls = if is_pem {
109
0
            Self::parse_pem_crls(&data)?
110
        } else {
111
0
            vec![data]
112
        };
113
114
        // empty PEM file (no CRL blocks) means no revocations
115
0
        if der_crls.is_empty() {
116
0
            debug!(path = ?inner.crl_path, "no crl blocks found, treating as no revocations");
117
0
            inner.crl_ders = Some(Vec::new());
118
0
            return Ok(());
119
0
        }
120
121
0
        let mut validated_ders = Vec::new();
122
123
0
        for (idx, der_data) in der_crls.into_iter().enumerate() {
124
            // validate with webpki to catch parse errors early
125
            // rustls will use the raw DER bytes directly
126
0
            webpki::OwnedCertRevocationList::from_der(&der_data).map_err(|e| {
127
0
                CrlError::WebPkiError(format!("failed to parse crl {}: {:?}", idx + 1, e))
128
0
            })?;
129
130
0
            validated_ders.push(der_data);
131
        }
132
133
        // store validated DER bytes
134
0
        inner.crl_ders = Some(validated_ders);
135
136
0
        debug!(
137
0
            path = ?inner.crl_path,
138
0
            format = if is_pem { "PEM" } else { "DER" },
139
0
            count = inner.crl_ders.as_ref().map(|v| v.len()).unwrap_or(0),
140
0
            "crl loaded successfully"
141
        );
142
0
        Ok(())
143
0
    }
144
145
    /// parses PEM-encoded CRL data that may contain multiple CRL blocks
146
    /// returns a Vec of DER-encoded CRLs (empty vec if no blocks found)
147
0
    fn parse_pem_crls(pem_data: &[u8]) -> Result<Vec<Vec<u8>>, CrlError> {
148
0
        let mut reader = std::io::BufReader::new(Cursor::new(pem_data));
149
150
0
        rustls_pemfile::read_all(&mut reader)
151
0
            .filter_map(|result| match result {
152
0
                Ok(Item::Crl(crl)) => Some(Ok(crl.to_vec())),
153
0
                Ok(_) => None, // skip non-CRL items
154
0
                Err(e) => Some(Err(CrlError::ParseError(format!(
155
0
                    "failed to parse PEM: {}",
156
0
                    e
157
0
                )))),
158
0
            })
159
0
            .collect()
160
0
    }
161
162
    /// returns CRLs as DER bytes for rustls's with_crls().
163
    /// if no CRLs are loaded, attempts to load them first.
164
0
    pub fn get_crl_ders(&self) -> Vec<CertificateRevocationListDer<'static>> {
165
0
        let inner = self.inner.read().unwrap();
166
0
        if let Some(ref crl_ders) = inner.crl_ders {
167
            // already loaded, use existing lock directly
168
0
            crl_ders
169
0
                .iter()
170
0
                .map(|der| CertificateRevocationListDer::from(der.clone()))
171
0
                .collect()
172
        } else {
173
            // not loaded yet, drop lock to call load_crl()
174
0
            drop(inner);
175
0
            debug!("crl not loaded, attempting to load now");
176
0
            if let Err(e) = self.load_crl() {
177
0
                debug!(error = %e, "failed to load crl");
178
0
                return Vec::new();
179
0
            }
180
            // re-acquire after loading
181
0
            let inner = self.inner.read().unwrap();
182
0
            inner
183
0
                .crl_ders
184
0
                .as_ref()
185
0
                .map(|ders| {
186
0
                    ders.iter()
187
0
                        .map(|der| CertificateRevocationListDer::from(der.clone()))
188
0
                        .collect()
189
0
                })
190
0
                .unwrap_or_default()
191
        }
192
0
    }
193
194
    /// starts watching the CRL file for changes.
195
    /// uses debouncer to handle all file update patterns
196
0
    pub fn start_file_watcher(self: &Arc<Self>) -> Result<(), CrlError> {
197
0
        let crl_path = {
198
0
            let inner = self.inner.read().unwrap();
199
0
            inner.crl_path.clone()
200
        };
201
202
        // watch the parent directory to catch ConfigMap updates via symlinks
203
0
        let watch_path = crl_path
204
0
            .parent()
205
0
            .ok_or_else(|| CrlError::ParseError("crl path has no parent directory".to_string()))?;
206
207
0
        debug!(
208
            path = ?watch_path,
209
            debounce_secs = 2,
210
0
            "starting crl file watcher"
211
        );
212
213
0
        let manager = Arc::clone(self);
214
215
        // create debouncer with 2-second timeout
216
        // this collapses multiple events (CREATE/CHMOD/RENAME/REMOVE) into a single reload
217
0
        let mut debouncer = new_debouncer(
218
0
            Duration::from_secs(2),
219
0
            None,
220
0
            move |result: DebounceEventResult| {
221
0
                match result {
222
0
                    Ok(events) => {
223
0
                        if !events.is_empty() {
224
0
                            debug!(event_count = events.len(), "crl directory events detected");
225
226
                            // reload CRL for any changes in the watched directory
227
                            // this handles Kubernetes ConfigMap updates (..data symlink changes)
228
                            // as well as direct file writes and text editor saves
229
0
                            debug!("crl directory changed, reloading");
230
0
                            match manager.load_crl() {
231
                                Ok(()) => {
232
0
                                    debug!("crl reloaded successfully after file change");
233
                                }
234
0
                                Err(e) => debug!(error = %e, "failed to reload crl"),
235
                            }
236
0
                        }
237
                    }
238
0
                    Err(errors) => {
239
0
                        for error in errors {
240
0
                            debug!(error = ?error, "crl watcher error");
241
                        }
242
                    }
243
                }
244
0
            },
245
        )
246
0
        .map_err(|e| CrlError::ParseError(format!("failed to create debouncer: {}", e)))?;
247
248
        // start watching the directory
249
0
        debouncer
250
0
            .watcher()
251
0
            .watch(watch_path, RecursiveMode::NonRecursive)
252
0
            .map_err(|e| CrlError::ParseError(format!("failed to watch directory: {}", e)))?;
253
254
        // store debouncer to keep it alive
255
0
        {
256
0
            let mut inner = self.inner.write().unwrap();
257
0
            inner._debouncer = Some(debouncer);
258
0
        }
259
260
0
        debug!("crl file watcher started successfully");
261
0
        Ok(())
262
0
    }
263
}
264
265
#[cfg(test)]
266
mod tests {
267
    use super::*;
268
    use std::io::Write;
269
    use tempfile::NamedTempFile;
270
271
    #[test]
272
    fn test_crl_manager_missing_file() {
273
        let result = CrlManager::new(PathBuf::from("/nonexistent/path/crl.pem"));
274
        assert!(result.is_ok(), "should handle missing CRL file gracefully");
275
    }
276
277
    #[test]
278
    fn test_crl_manager_invalid_file() {
279
        let mut file = NamedTempFile::new().expect("failed to create temporary test file");
280
        file.write_all(b"not a valid CRL")
281
            .expect("failed to write test data to temporary file");
282
        file.flush().expect("failed to flush temporary test file");
283
284
        let result = CrlManager::new(file.path().to_path_buf());
285
        assert!(result.is_err(), "should fail on invalid CRL data");
286
    }
287
288
    #[test]
289
    fn test_crl_manager_empty_file() {
290
        let file = NamedTempFile::new().expect("failed to create temporary test file");
291
        // file is empty by default
292
293
        let result = CrlManager::new(file.path().to_path_buf());
294
        assert!(result.is_ok(), "should handle empty CRL file gracefully");
295
    }
296
297
    #[test]
298
    fn test_crl_manager_valid_crl() {
299
        use rcgen::{
300
            CertificateParams, CertificateRevocationListParams, Issuer, KeyIdMethod, KeyPair,
301
            RevocationReason, RevokedCertParams, SerialNumber,
302
        };
303
304
        // generate a CA key pair
305
        let ca_key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
306
            .expect("failed to generate CA key pair");
307
308
        // create CA certificate params
309
        let mut ca_params = CertificateParams::default();
310
        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
311
        ca_params.key_usages = vec![
312
            rcgen::KeyUsagePurpose::KeyCertSign,
313
            rcgen::KeyUsagePurpose::CrlSign,
314
        ];
315
316
        // create issuer from CA params and key
317
        let issuer = Issuer::from_params(&ca_params, &ca_key_pair);
318
319
        // create CRL with one revoked certificate
320
        let crl_params = CertificateRevocationListParams {
321
            this_update: time::OffsetDateTime::now_utc(),
322
            next_update: time::OffsetDateTime::now_utc() + time::Duration::days(30),
323
            crl_number: SerialNumber::from(1u64),
324
            issuing_distribution_point: None,
325
            revoked_certs: vec![RevokedCertParams {
326
                serial_number: SerialNumber::from(12345u64),
327
                revocation_time: time::OffsetDateTime::now_utc(),
328
                reason_code: Some(RevocationReason::KeyCompromise),
329
                invalidity_date: None,
330
            }],
331
            key_identifier_method: KeyIdMethod::Sha256,
332
        };
333
334
        let crl = crl_params.signed_by(&issuer).expect("failed to sign CRL");
335
        let crl_pem = crl.pem().expect("failed to encode CRL as PEM");
336
337
        // write CRL to temp file
338
        let mut file = NamedTempFile::new().expect("failed to create temporary test file");
339
        file.write_all(crl_pem.as_bytes())
340
            .expect("failed to write CRL to temporary file");
341
        file.flush().expect("failed to flush temporary test file");
342
343
        // test that CrlManager can load it
344
        let manager = CrlManager::new(file.path().to_path_buf())
345
            .expect("should successfully parse valid CRL");
346
347
        let ders = manager.get_crl_ders();
348
        assert_eq!(ders.len(), 1, "should have loaded one CRL");
349
    }
350
}