Skip to main content

luct_scanner/
lib.rs

1//! Certificate transparency auditing logic used by luCT firefox extension and CLI tool
2
3#![forbid(unsafe_code)]
4
5use crate::log::{ScannerLog, builder::LogImpls};
6use chrono::{DateTime, Local};
7use futures::future::{self, join_all};
8use luct_client::{Client, ClientError};
9use luct_core::{
10    CertificateChain, CertificateError, CtLog, CtLogConfig, Fingerprint, LogId,
11    store::{SearchableStore, StoreRead, StoreWrite},
12    tiling::TilingError,
13    v1::{SignedCertificateTimestamp, SignedTreeHead},
14};
15use serde::{Deserialize, Serialize};
16use std::{collections::BTreeMap, fmt::Debug, sync::Arc};
17use thiserror::Error;
18use web_time::{Duration, SystemTime, UNIX_EPOCH};
19pub use {
20    config::{ScannerConfig, ScannerConfigBuilder},
21    report::{Report, SctReport, SthReport},
22    utils::Validated,
23};
24
25type HashOutput = [u8; 32];
26
27mod config;
28mod log;
29mod report;
30mod utils;
31
32/// Bundle trait for [`Scanner`]
33///
34/// Defines the [`Store`](luct_core::store::Store) and [`Client`] backends to be used by the scanner
35pub trait ScannerImpl {
36    /// [`Client`] implementation to make connections to logs to
37    type Client: Client + Clone;
38    /// The [`Store`](luct_core::store::Store) type used to store cached [`Reports`](Report) of audit results
39    type ReportStore: SearchableStore<Fingerprint, Report>;
40    /// The [`Store`](luct_core::store::Store) use to store [`SignedTreeHeads`](SignedTreeHead)
41    type SthStore: SearchableStore<u64, Validated<SignedTreeHead>>;
42}
43
44/// The scanner holds the state that is necessary to perform audits as well as the auditing logic
45///
46/// It is generic over [`ScannerImpl`], which is a bundle trait containing implementations of [`Stores`](luct_core::store::Store)
47/// and [`Clients`](Client).
48pub struct Scanner<S: ScannerImpl> {
49    config: ScannerConfig,
50    logs: BTreeMap<LogId, ScannerLog<S>>,
51    report_store: S::ReportStore,
52    client: S::Client,
53    time_source: Box<dyn Fn() -> DateTime<Local>>,
54}
55
56#[allow(clippy::type_complexity)]
57impl<S: ScannerImpl> Scanner<S> {
58    pub fn logs<'a>(&'a self) -> Box<dyn Iterator<Item = &'a CtLog> + 'a> {
59        Box::new(self.logs.values().map(|val| val.client().log()))
60    }
61
62    pub fn new<F: Fn() -> DateTime<Local> + 'static>(
63        config: ScannerConfig,
64        report_store: S::ReportStore,
65        client: S::Client,
66        time_source: F,
67    ) -> Self {
68        Self {
69            config,
70            logs: BTreeMap::new(),
71            report_store,
72            client,
73            time_source: Box::new(time_source) as _,
74        }
75    }
76
77    pub fn add_log(&mut self, log: &CtLog, sth_store: S::SthStore) -> &mut Self {
78        let impls = LogImpls {
79            client: self.client.clone(),
80            sth_store,
81        };
82        let scanner_log = ScannerLog::new(log, impls);
83        let log_id = scanner_log.client().log().log_id().clone();
84
85        self.logs.insert(log_id, scanner_log);
86        self
87    }
88
89    /// Updates all log's STHs
90    pub async fn refresh_all_logs(&self) -> Result<(), ScannerError> {
91        let updates = self
92            .logs
93            .values()
94            .map(|log| log.update_sth())
95            .collect::<Vec<_>>();
96
97        future::try_join_all(updates).await?;
98
99        Ok(())
100    }
101
102    pub async fn collect_report_pem(&self, data: &str) -> Result<Report, ScannerError> {
103        let cert_chain = Arc::new(CertificateChain::from_pem_chain(data)?);
104
105        if self.config.validate_cert_chain {
106            cert_chain.verify_chain()?;
107        }
108
109        self.collect_report(cert_chain).await
110    }
111
112    pub async fn collect_report(
113        &self,
114        chain: Arc<CertificateChain>,
115    ) -> Result<Report, ScannerError> {
116        let cert = chain.cert();
117        let cert_fp = cert.fingerprint_sha256();
118
119        if let Some(report) = self.report_store.get(&cert_fp) {
120            tracing::debug!("Found report for {} in cache", cert_fp.to_string());
121            return Ok(report);
122        }
123
124        let (not_before, not_after) = cert.get_validity();
125        let embedded_scts = cert.extract_scts_v1()?;
126
127        let scts = join_all(
128            embedded_scts
129                .into_iter()
130                .map(|sct| self.collect_embedded_sct_report(sct, &chain)),
131        )
132        .await;
133
134        let report = Report {
135            ca_issuer: chain.root().get_issuer_name(),
136            ca_subject: chain.root().get_subject_name(),
137            cert_issuer: chain.cert().get_issuer_name(),
138            cert_subject: chain.cert().get_subject_name(),
139            fingerprint: chain.cert().fingerprint_sha256().to_string(),
140            ca_fingerprint: chain.root().fingerprint_sha256().to_string(),
141            not_before: not_before.into(),
142            not_after: not_after.into(),
143            scts,
144            error_description: None,
145        };
146
147        let report = self.evaluate_policy(report, (self.time_source)());
148        if report.get_error().is_none() {
149            self.report_store.insert(cert_fp, report.clone());
150        }
151
152        Ok(report)
153    }
154
155    pub(crate) async fn collect_embedded_sct_report(
156        &self,
157        sct: SignedCertificateTimestamp,
158        chain: &Arc<CertificateChain>,
159    ) -> SctReport {
160        let report = SctReport::new(sct.log_id());
161
162        // Find the log this sct belongs to
163        let Some(log) = self.logs.get(&sct.log_id()) else {
164            return report.error_description("Unknown log id".to_string());
165        };
166        let log_name = log.client().log().description().to_string();
167        let report = report.log_name(log_name);
168
169        // Validate the signature
170        if let Err(err) = log.client().log().validate_sct_v1(chain, &sct, true) {
171            return report.error_description(err.to_string());
172        };
173        let report = report.signature_validation_time(
174            DateTime::from_timestamp_millis(
175                SystemTime::now()
176                    .duration_since(UNIX_EPOCH)
177                    .unwrap()
178                    .as_millis() as i64,
179            )
180            .unwrap()
181            .into(),
182        );
183
184        // Get a fresh sth
185        let fresh_sth = match self.get_fresh_sth(log).await {
186            Ok(sth) => sth,
187            Err(err) => return report.error_description(err.to_string()),
188        };
189        let report = report.latest_sth(SthReport::from(&fresh_sth));
190
191        let leaf = match chain.as_leaf_v1(&sct, true) {
192            Err(err) => {
193                return report.error_description(err.to_string());
194            }
195            Ok(leaf) => leaf,
196        };
197
198        // Check inclusion
199        let oldest_sth = log.oldest_viable_sth(&sct).unwrap_or(fresh_sth);
200        let report = match log.check_sct_inclusion(&sct, &oldest_sth, &leaf).await {
201            Ok(index) => report.index(index),
202            Err(err) => return report.error_description(err.to_string()),
203        };
204
205        report.inclusion_proof(SthReport::from(&oldest_sth))
206    }
207
208    /// Get a fresh STH
209    ///
210    /// Checks whether the latest STH is still new enough.
211    /// If it is too old, it will fetch a fresh one
212    async fn get_fresh_sth(
213        &self,
214        log: &ScannerLog<S>,
215    ) -> Result<Validated<SignedTreeHead>, ScannerError> {
216        let log_name = log.client().log().description();
217
218        // If we have no STH whatsoever, simply fetch it
219        let Some(last_sth) = log.get_latest_sth() else {
220            tracing::debug!("No prior known STHs for {}, fetching fresh STH", log_name);
221            return log.update_sth().await;
222        };
223
224        let now = SystemTime::now();
225        let timestamp = UNIX_EPOCH + Duration::from_millis(last_sth.timestamp());
226        if timestamp + self.config.sth_update_threshold < now {
227            tracing::debug!("Updating old STH for log {}", log_name);
228            log.update_sth().await
229        } else {
230            Ok(last_sth)
231        }
232    }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236pub struct ScannerBuilder {
237    config: ScannerConfig,
238    logs: Vec<CtLogConfig>,
239}
240
241#[derive(Debug, Clone, Error)]
242pub enum ScannerError {
243    #[error("Invalid certificate: {0}")]
244    CertificateError(#[from] CertificateError),
245
246    #[error("HTTP client error {0}")]
247    ClientError(#[from] ClientError),
248
249    #[error("Failed to construct proof from tiles {0}")]
250    TilingError(#[from] TilingError),
251}