sos_security_report/
lib.rs

1//! Helpers for security report generation.
2use hex;
3use secrecy::ExposeSecret;
4use serde::{Deserialize, Serialize};
5use sos_account::Account;
6use sos_backend::AccessPoint;
7use sos_core::VaultId;
8use sos_password::generator::measure_entropy;
9use sos_vault::{
10    secret::{Secret, SecretId, SecretType},
11    SecretAccess, Summary,
12};
13use zxcvbn::{Entropy, Score};
14
15/// Generate a security report.
16pub async fn generate_security_report<A, E, T, D, R>(
17    account: &A,
18    options: SecurityReportOptions<T, D, R>,
19) -> Result<SecurityReport<T>, E>
20where
21    A: Account,
22    D: Fn(Vec<String>) -> R + Send + Sync,
23    R: std::future::Future<Output = Vec<T>> + Send + Sync,
24    E: From<A::Error>
25        + From<sos_account::Error>
26        + From<sos_core::Error>
27        + From<sos_vault::Error>
28        + From<sos_backend::Error>
29        + From<std::io::Error>
30        + From<sos_backend::StorageError>
31        + Send
32        + Sync
33        + 'static,
34{
35    let mut records = Vec::new();
36    let mut hashes = Vec::new();
37    let folders = account.list_folders().await?;
38    let targets: Vec<Summary> = folders
39        .into_iter()
40        .filter(|folder| {
41            if let Some(target) = &options.target {
42                return folder.id() == &target.0;
43            }
44            !options.excludes.contains(folder.id())
45        })
46        .collect();
47
48    for target in targets {
49        let folder = account.folder(target.id()).await?;
50        let access_point = folder.access_point();
51        let access_point = access_point.lock().await;
52
53        let vault = access_point.vault();
54        let mut password_hashes: Vec<(
55            SecretId,
56            (Option<Entropy>, Vec<u8>),
57            Option<SecretId>,
58        )> = Vec::new();
59
60        if let Some(target) = &options.target {
61            secret_security_report::<E>(
62                &target.1,
63                &*access_point,
64                &mut password_hashes,
65                target.2.as_ref(),
66            )
67            .await?;
68        } else {
69            for secret_id in vault.keys() {
70                secret_security_report::<E>(
71                    secret_id,
72                    &*access_point,
73                    &mut password_hashes,
74                    None,
75                )
76                .await?;
77            }
78        }
79
80        for (secret_id, check, field_id) in password_hashes {
81            let (entropy, sha1) = check;
82
83            let record = SecurityReportRecord {
84                folder: target.clone(),
85                secret_id,
86                field_id,
87                entropy,
88            };
89
90            hashes.push(hex::encode(sha1));
91            records.push(record);
92        }
93    }
94
95    let database_checks =
96        if let Some(database_handler) = options.database_handler {
97            (database_handler)(hashes).await
98        } else {
99            vec![]
100        };
101    Ok(SecurityReport {
102        records,
103        database_checks,
104    })
105}
106
107/// Specific target for a security report.
108pub struct SecurityReportTarget(
109    pub VaultId,
110    pub SecretId,
111    pub Option<SecretId>,
112);
113
114/// Options for security report generation.
115pub struct SecurityReportOptions<T, H, F>
116where
117    H: Fn(Vec<String>) -> F,
118    F: std::future::Future<Output = Vec<T>>,
119{
120    /// Exclude these folders from report generation.
121    pub excludes: Vec<VaultId>,
122    /// Database handler that can check for breaches
123    /// based on the password hashes (SHA-1).
124    ///
125    /// The handler is passed a list of passwords hashes
126    /// and must return a list of `T` the same length as
127    /// the input.
128    pub database_handler: Option<H>,
129
130    /// Target for report generation.
131    ///
132    /// This is useful when providing a UI to resolve
133    /// security report issues and changes have been
134    /// made; the caller can generate a new report
135    /// for the changed item and decide if the item
136    /// is now deemed safe.
137    ///
138    /// When a target is given excludes are ignored.
139    pub target: Option<SecurityReportTarget>,
140}
141
142/// Row for security report output.
143#[derive(Debug, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct SecurityReportRow<T> {
146    /// Folder name.
147    pub folder_name: String,
148    /// Folder identifier.
149    pub folder_id: VaultId,
150    /// Secret identifier.
151    pub secret_id: SecretId,
152    /// Custom field identifier.
153    pub field_id: Option<SecretId>,
154    /// The entropy score.
155    pub score: Score,
156    /// The estimated number of guesses needed to crack the password.
157    pub guesses: u64,
158    /// The order of magnitude of guesses.
159    pub guesses_log10: f64,
160    /// Result of a database check.
161    #[serde(rename = "breached")]
162    pub database_check: T,
163}
164
165impl SecurityReportRow<bool> {
166    /// Determine if this row is deemed to be secure.
167    ///
168    /// A report is deemed to be secure when the entropy
169    /// score is greater than or equal to 3 and the password
170    /// hash has not been detected as appearing in a database
171    /// of breached passwords.
172    pub fn is_secure(&self) -> bool {
173        self.score >= Score::Three && !self.database_check
174    }
175
176    /// Determine if this row is deemed to be insecure.
177    pub fn is_insecure(&self) -> bool {
178        !self.is_secure()
179    }
180}
181
182/// List of records for a generated security report.
183pub struct SecurityReport<T> {
184    /// Report row records.
185    pub records: Vec<SecurityReportRecord>,
186    /// Caller reports.
187    pub database_checks: Vec<T>,
188}
189
190impl<T> From<SecurityReport<T>> for Vec<SecurityReportRow<T>> {
191    fn from(value: SecurityReport<T>) -> Self {
192        let mut out = Vec::new();
193        for (record, database_check) in value
194            .records
195            .into_iter()
196            .zip(value.database_checks.into_iter())
197        {
198            let score = record
199                .entropy
200                .as_ref()
201                .map(|e| e.score())
202                .unwrap_or(Score::Zero);
203            let guesses =
204                record.entropy.as_ref().map(|e| e.guesses()).unwrap_or(0);
205            let guesses_log10 = record
206                .entropy
207                .as_ref()
208                .map(|e| e.guesses_log10())
209                .unwrap_or(0.0);
210
211            out.push(SecurityReportRow {
212                folder_name: record.folder.name().to_owned(),
213                folder_id: *record.folder.id(),
214                secret_id: record.secret_id,
215                field_id: record.field_id,
216                score,
217                guesses,
218                guesses_log10,
219                database_check,
220            });
221        }
222        out
223    }
224}
225
226/// Security report record.
227pub struct SecurityReportRecord {
228    /// Folder summary.
229    pub folder: Summary,
230    /// Secret identifier.
231    pub secret_id: SecretId,
232    /// Field identifier when the password is a custom field.
233    pub field_id: Option<SecretId>,
234    /// Report on password entropy.
235    ///
236    /// Will be `None` when the password is empty.
237    pub entropy: Option<Entropy>,
238}
239
240async fn secret_security_report<E>(
241    secret_id: &SecretId,
242    access_point: &AccessPoint,
243    password_hashes: &mut Vec<(
244        SecretId,
245        (Option<Entropy>, Vec<u8>),
246        Option<SecretId>,
247    )>,
248    target_field: Option<&SecretId>,
249) -> Result<(), E>
250where
251    E: From<sos_vault::Error>
252        + From<sos_backend::StorageError>
253        + From<sos_backend::Error>,
254{
255    if let Some((_meta, secret, _)) =
256        access_point.read_secret(secret_id).await?
257    {
258        for field in secret.user_data().fields().iter().filter(|field| {
259            if let Some(field_id) = target_field {
260                return field_id == field.id();
261            }
262            true
263        }) {
264            if field.meta().kind() == &SecretType::Account
265                || field.meta().kind() == &SecretType::Password
266            {
267                let check = check_password::<E>(field.secret())?;
268                if let Some(check) = check {
269                    password_hashes.push((
270                        *secret_id,
271                        check,
272                        Some(*field.id()),
273                    ));
274                }
275            }
276        }
277        let check = check_password::<E>(&secret)?;
278        if let Some(check) = check {
279            password_hashes.push((*secret_id, check, None));
280        }
281    }
282    Ok(())
283}
284
285/// Measure entropy for a password and compute a SHA-1 checksum.
286///
287/// Only applies to account and password types, other
288/// types will yield `None.`
289pub fn check_password<E>(
290    secret: &Secret,
291) -> Result<Option<(Option<Entropy>, Vec<u8>)>, E>
292where
293    E: From<sos_vault::Error> + From<sos_backend::StorageError>,
294{
295    // TODO: remove Result type from function return value
296    use sha1::{Digest, Sha1};
297    match secret {
298        Secret::Account {
299            account, password, ..
300        } => {
301            let hash = Sha1::digest(password.expose_secret().as_bytes());
302
303            // Zxcvbn cannot handle empty passwords but we
304            // need to handle this gracefully
305            if password.expose_secret().is_empty() {
306                Ok(Some((None, hash.to_vec())))
307            } else {
308                let entropy =
309                    measure_entropy(password.expose_secret(), &[account]);
310                Ok(Some((Some(entropy), hash.to_vec())))
311            }
312        }
313        Secret::Password { password, name, .. } => {
314            let inputs = if let Some(name) = name {
315                vec![&name.expose_secret()[..]]
316            } else {
317                vec![]
318            };
319
320            let hash = Sha1::digest(password.expose_secret().as_bytes());
321
322            // Zxcvbn cannot handle empty passwords but we
323            // need to handle this gracefully
324            if password.expose_secret().is_empty() {
325                Ok(Some((None, hash.to_vec())))
326            } else {
327                let entropy = measure_entropy(
328                    password.expose_secret(),
329                    inputs.as_slice(),
330                );
331
332                Ok(Some((Some(entropy), hash.to_vec())))
333            }
334        }
335        _ => Ok(None),
336    }
337}