/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 | | } |