openssl_rehash/
lib.rs

1pub use crate::error::{Error, Result};
2
3use std::collections::HashSet;
4use std::fs;
5use std::os::unix;
6use std::path::{Path, PathBuf};
7
8use log::warn;
9use openssl::hash::MessageDigest;
10use openssl::x509::X509;
11use regex::Regex;
12
13mod error;
14
15/// Rehashes a directory
16///
17/// Removes hash symlinks and broken symlinks (unlike openssl rehash) in
18/// the directory, then for each certificate (or symlink to a certificate) in
19/// the directory, creates a SHA1 hash symlink with a relative path to the
20/// certificate.
21///
22/// Returns an error if there are any I/O failures due to filesystem access or
23/// certificate deserialization
24///
25/// NOTE: CRL hash symlinks are not yet supported
26///
27/// # Examples
28///
29/// ```no_run
30/// openssl_rehash::rehash("/etc/ssl/certs").unwrap();
31/// ```
32pub fn rehash(dir: impl AsRef<Path>) -> Result<()> {
33    let mut seen_fingerprints: HashSet<Vec<u8>> = HashSet::new();
34
35    for entry in clean_dir(dir.as_ref())? {
36        if let Ok(Some(certificate)) = read_single_certificate(&entry) {
37            let fingerprint = certificate.digest(MessageDigest::sha1())?;
38
39            if !seen_fingerprints.contains(&*fingerprint) {
40                seen_fingerprints.insert(fingerprint.to_vec());
41                hash_link(&entry, certificate.subject_name_hash())?;
42            } else {
43                let path_display = entry.display();
44                warn!("rehash: skipping duplicate certificate in {path_display}");
45            }
46        } else {
47            let path_display = entry.display();
48            warn!("rehash: skipping {path_display}, it does not contain exactly one certificate");
49        }
50    }
51
52    Ok(())
53}
54
55/// Returns the directory's entries after any broken or hash symlinks are removed
56fn clean_dir(dir: impl AsRef<Path>) -> std::io::Result<Vec<PathBuf>> {
57    let mut entries: Vec<PathBuf> = vec![];
58    let regex = Regex::new(r"^[[:xdigit:]]{8}\.\d+$").unwrap();
59
60    for entry in fs::read_dir(dir.as_ref())? {
61        let entry = entry?;
62        let path = entry.path();
63        if path.is_symlink() {
64            if let Ok(false) = path.try_exists() {
65                fs::remove_file(&path)?;
66            } else if regex.is_match(&entry.file_name().to_string_lossy()) {
67                fs::remove_file(&path)?;
68            } else {
69                entries.push(path);
70            }
71        } else {
72            entries.push(path);
73        }
74    }
75
76    entries.sort();
77
78    Ok(entries)
79}
80
81fn read_single_certificate(path: impl AsRef<Path>) -> Result<Option<X509>> {
82    let data = std::fs::read(path)?;
83    match X509::stack_from_pem(&data) {
84        Ok(x509) if x509.len() == 1 => Ok(Some(x509[0].clone())),
85        // DER format cannot contain more than one certificate
86        _ => Ok(Some(X509::from_der(&data)?)),
87    }
88}
89
90/// Creates a symlink named after the hex representation of the name hash that
91/// points to the target.
92///
93/// If a symlink with the same name already exists but points to a different
94/// target, then the count on the file extension is incremented.
95fn hash_link(target_path: impl AsRef<Path>, hash: u32) -> Result<()> {
96    let target_path = target_path.as_ref();
97    let parent_dir = target_path.parent().unwrap();
98    let link_name = parent_dir.join(hash_link_stem(hash));
99    let mut count = 0;
100
101    loop {
102        let link_path = link_name.with_extension(format!("{count}"));
103
104        if link_path.is_symlink() {
105            if link_path.try_exists()? {
106                if link_path.read_link()? == target_path {
107                    return Ok(());
108                } else {
109                    count += 1;
110                }
111            } else {
112                fs::remove_file(&link_path)?;
113            }
114        } else {
115            unix::fs::symlink(target_path.file_name().unwrap(), &link_path)?;
116            break;
117        }
118    }
119
120    Ok(())
121}
122
123fn hash_link_stem(hash: u32) -> String {
124    format!("{:08x}", hash)
125}
126
127#[cfg(test)]
128mod test {
129    use std::fs::{self, File};
130    use std::io::Write;
131    use std::os::unix;
132    use std::path::Path;
133
134    use insta::assert_debug_snapshot;
135    use openssl::{
136        asn1::Asn1Time,
137        bn::{BigNum, MsbOption},
138        hash::MessageDigest,
139        nid::Nid,
140        pkey::PKey,
141        rsa::Rsa,
142        x509::{X509Name, X509},
143    };
144    use tempfile::{tempdir, NamedTempFile};
145
146    use super::*;
147
148    /// tests rehash hashes a cert directory correctly
149    ///
150    /// The directory originally contains a cert bundle and two cert links
151    /// pointing to the same physical cert
152    ///
153    /// The result hash directory should contain the exact same entries and a
154    /// hash link pointing to the first cert link
155    #[test]
156    fn test_rehash() {
157        let cert = build_x509("foo");
158        let hash = format!("{:x}", cert.subject_name_hash());
159        let mut cert_file = NamedTempFile::new().unwrap();
160        cert_file.write_all(&cert.to_pem().unwrap()).unwrap();
161        let temp_dir = tempdir().unwrap();
162        let cert_dir = temp_dir.path().to_owned();
163        let hash_link = cert_dir.join(hash).with_extension("0");
164        let cert_link_0 = cert_dir.join("cert-link_0.crt");
165        unix::fs::symlink(cert_file.path(), &cert_link_0).unwrap();
166        let cert_link_1 = cert_dir.join("cert-link_1.crt");
167        unix::fs::symlink(cert_file.path(), cert_link_1).unwrap();
168        let mut cert_bundle = File::create(cert_dir.join("ca-certificates.crt")).unwrap();
169        cert_bundle
170            .write_all(&build_x509("bar").to_pem().unwrap())
171            .unwrap();
172        cert_bundle
173            .write_all(&build_x509("baz").to_pem().unwrap())
174            .unwrap();
175
176        rehash(&cert_dir).unwrap();
177
178        let mut dir_entries: Vec<String> = vec![];
179        for entry in fs::read_dir(&cert_dir).unwrap() {
180            let entry = entry.unwrap();
181            dir_entries.push(entry.file_name().to_string_lossy().into());
182        }
183        dir_entries.sort();
184        assert_debug_snapshot!(&dir_entries);
185
186        assert_eq!(
187            hash_link.read_link().unwrap(),
188            Path::new(cert_link_0.file_name().unwrap())
189        );
190    }
191
192    #[test]
193    fn test_clean_links_on_empty_dir() {
194        let tempdir = tempdir().unwrap();
195        let cert_dir = tempdir.path().to_owned();
196
197        let result = clean_dir(cert_dir).unwrap();
198
199        assert!(result.is_empty());
200    }
201
202    #[test]
203    fn test_clean_links_removes_hash_links() {
204        // setup a cert dir with symlinks that point to a "physical cert" that
205        // was removed
206        let cert_file = NamedTempFile::new().unwrap();
207        let temp_dir = tempdir().unwrap();
208        let cert_dir = temp_dir.path().to_owned();
209        let cert_link_0 = cert_dir.join("cert-link_0.crt");
210        let cert_link_1 = cert_dir.join("cert-link_1.crt");
211        unix::fs::symlink(cert_file.path(), &cert_link_0).unwrap();
212        unix::fs::symlink(cert_file.path(), &cert_link_1).unwrap();
213        let hash_link_0 = cert_dir.join("12345678.0");
214        let hash_link_1 = cert_dir.join("12345678.1");
215        unix::fs::symlink(&cert_link_0, &hash_link_0).unwrap();
216        unix::fs::symlink(&cert_link_1, &hash_link_1).unwrap();
217
218        clean_dir(cert_dir).unwrap();
219
220        assert!(!hash_link_0.exists() && !hash_link_1.exists());
221    }
222
223    #[test]
224    fn test_clean_links_removes_broken_links() {
225        // setup a cert dir with symlinks that point to a "physical cert" that
226        // was removed
227        let cert_file = NamedTempFile::new().unwrap();
228        let temp_dir = tempdir().unwrap();
229        let cert_dir = temp_dir.path().to_owned();
230        let broken_link_0 = cert_dir.join("broken-link_0.crt");
231        let broken_link_1 = cert_dir.join("broken-link_1.crt");
232        unix::fs::symlink(cert_file.path(), &broken_link_0).unwrap();
233        unix::fs::symlink(cert_file.path(), &broken_link_1).unwrap();
234        // break the links
235        fs::remove_file(cert_file.path()).unwrap();
236
237        clean_dir(cert_dir).unwrap();
238
239        assert!(!broken_link_0.exists() && !broken_link_1.exists());
240    }
241
242    #[test]
243    fn test_clean_links_keeps_unbroken_links() {
244        // setup a cert dir with a symlink that points to a "physical cert"
245        let temp_file = NamedTempFile::new().unwrap();
246        let temp_dir = tempdir().unwrap();
247        let cert_dir = temp_dir.path().to_owned();
248        let cert_link = cert_dir.join("cert-link.crt");
249        unix::fs::symlink(temp_file.path(), &cert_link).unwrap();
250
251        clean_dir(cert_dir).unwrap();
252
253        assert!(cert_link.exists());
254    }
255
256    #[test]
257    fn test_hash_link() {
258        // setup a cert dir with a symlink that points to a "physical cert"
259        let temp_file = NamedTempFile::new().unwrap();
260        let temp_dir = tempdir().unwrap();
261        let cert_dir = temp_dir.path().to_owned();
262        let cert_link = cert_dir.join("cert-link.crt");
263        unix::fs::symlink(temp_file.path(), &cert_link).unwrap();
264        let hash: u32 = 12345678;
265        let hash_link_stem = hash_link_stem(hash);
266        let hash_link_path = cert_dir.join(hash_link_stem).with_extension("0");
267
268        hash_link(&cert_link, hash).unwrap();
269
270        assert_eq!(
271            hash_link_path.read_link().unwrap(),
272            Path::new(cert_link.file_name().unwrap())
273        );
274    }
275
276    #[test]
277    fn test_hash_link_does_not_duplicate() {
278        // setup a cert dir with a symlink that points to a "physical cert"
279        // Additionally, setup a hash link that points to the cert link
280        let temp_file = NamedTempFile::new().unwrap();
281        let temp_dir = tempdir().unwrap();
282        let cert_dir = temp_dir.path().to_owned();
283        let cert_link = cert_dir.join("cert-link.crt");
284        unix::fs::symlink(temp_file.path(), &cert_link).unwrap();
285        let hash: u32 = 12345678;
286        let hash_link_stem = hash_link_stem(hash);
287        let hash_link_0 = cert_dir.join(hash_link_stem).with_extension("0");
288
289        unix::fs::symlink(&cert_link, &hash_link_0).unwrap();
290
291        let hash_link_1 = hash_link_0.with_extension("1");
292
293        hash_link(&cert_link, hash).unwrap();
294
295        assert!(!hash_link_1.exists());
296    }
297
298    #[test]
299    fn test_hash_link_resolves_collision() {
300        // setup a cert dir with two symlinks that points to distinct "physical
301        // certs" that have subject names which hash to the same value.
302        // Additionally, setup a hash link that points one of the cert links
303        let temp_file = NamedTempFile::new().unwrap();
304        let temp_dir = tempdir().unwrap();
305        let cert_dir = temp_dir.path().to_owned();
306        let cert_link_0 = cert_dir.join("cert-link_0.crt");
307        unix::fs::symlink(temp_file.path(), &cert_link_0).unwrap();
308        let cert_link_1 = cert_dir.join("cert-link_1.crt");
309        unix::fs::symlink(temp_file.path(), &cert_link_1).unwrap();
310        let hash: u32 = 12345678;
311        let hash_link_stem = hash_link_stem(hash);
312        let hash_link_0 = cert_dir.join(hash_link_stem).with_extension("0");
313        unix::fs::symlink(&cert_link_0, &hash_link_0).unwrap();
314        let hash_link_1 = hash_link_0.with_extension("1");
315
316        hash_link(&cert_link_1, hash).unwrap();
317
318        assert_eq!(
319            hash_link_1.read_link().unwrap(),
320            Path::new(cert_link_1.file_name().unwrap())
321        );
322    }
323
324    fn build_x509(cn: &str) -> X509 {
325        let rsa = Rsa::generate(2048).unwrap();
326        let pkey = PKey::from_rsa(rsa).unwrap();
327
328        let mut name = X509Name::builder().unwrap();
329        name.append_entry_by_nid(Nid::COMMONNAME, cn).unwrap();
330        let name = name.build();
331
332        let mut builder = X509::builder().unwrap();
333        builder.set_version(2).unwrap();
334        builder.set_subject_name(&name).unwrap();
335        builder.set_issuer_name(&name).unwrap();
336        builder
337            .set_not_before(&Asn1Time::days_from_now(0).unwrap())
338            .unwrap();
339        builder
340            .set_not_after(&Asn1Time::days_from_now(365).unwrap())
341            .unwrap();
342        builder.set_pubkey(&pkey).unwrap();
343
344        let mut serial = BigNum::new().unwrap();
345        serial.rand(128, MsbOption::MAYBE_ZERO, false).unwrap();
346        builder
347            .set_serial_number(&serial.to_asn1_integer().unwrap())
348            .unwrap();
349
350        builder.sign(&pkey, MessageDigest::sha256()).unwrap();
351
352        builder.build()
353    }
354}