diff --git a/Cargo.lock b/Cargo.lock index 2889ba3..cc51c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "dead-man-switch" -version = "0.4.2" +version = "0.5.0" dependencies = [ "chrono", "directories-next", @@ -531,11 +531,12 @@ dependencies = [ "serde", "thiserror 2.0.3", "toml", + "zeroize", ] [[package]] name = "dead-man-switch-tui" -version = "0.4.2" +version = "0.5.0" dependencies = [ "crossterm", "dead-man-switch", @@ -545,7 +546,7 @@ dependencies = [ [[package]] name = "dead-man-switch-web" -version = "0.4.2" +version = "0.5.0" dependencies = [ "anyhow", "askama", @@ -559,6 +560,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "zeroize", ] [[package]] @@ -2925,6 +2927,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerovec" diff --git a/Cargo.toml b/Cargo.toml index a5af387..038894c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] resolver = "2" members = [ - "crates/dead-man-switch", - "crates/dead-man-switch-tui", - "crates/dead-man-switch-web", + "crates/dead-man-switch", + "crates/dead-man-switch-tui", + "crates/dead-man-switch-web", ] default-members = ["crates/dead-man-switch", "crates/dead-man-switch-tui"] @@ -15,6 +15,11 @@ description = "A simple no-BS Dead Man's Switch" license = "AGPL-3.0-only" readme = "README.md" +[workspace.dependencies] +dead-man-switch = { path = "crates/dead-man-switch", version = "0.5.0" } + +zeroize = { version = "1.8.1", features = ["derive"] } + [profile.release] opt-level = "z" # Optimized for size, use 3 for speed lto = true # Enable Link Time Optimization diff --git a/README.md b/README.md index 043f235..3d4b1de 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ To check-in, you just need to press the `c` key as in **c**heck-in. There are several ways to install Dead Man's Switch: -1. [Crates.io](https://crates.io/crates/dead-man-switch): `cargo install dead-man-switch-tui`. -1. [GitHub](https://github.com/storopoli/dead-man-switch): `cargo install --git https://github.com/storopoli/dead-man-switch -p dead-man-switch-tui`. -1. From source: Clone the repository and run `cargo install --path .`. +1. [Crates.io](https://crates.io/crates/dead-man-switch): `cargo install --locked dead-man-switch-tui`. +1. [GitHub](https://github.com/storopoli/dead-man-switch): `cargo install --git --locked https://github.com/storopoli/dead-man-switch -p dead-man-switch-tui`. +1. From source: Clone the repository and run `cargo install --locked --path .`. 1. Using Nix: `nix run github:storopoli/dead-man-switch`. 1. Using Nix Flakes: add this to your `flake.nix`: @@ -110,7 +110,7 @@ To do so you can add the following to your `Cargo.toml`: ```toml [dependencies] -dead-man-switch = "0.4" +dead-man-switch = "0.5" ``` ## Web Interface diff --git a/crates/dead-man-switch-tui/Cargo.toml b/crates/dead-man-switch-tui/Cargo.toml index 12b2585..0f9aacd 100644 --- a/crates/dead-man-switch-tui/Cargo.toml +++ b/crates/dead-man-switch-tui/Cargo.toml @@ -1,14 +1,15 @@ [package] name = "dead-man-switch-tui" edition = "2021" -version = "0.4.2" +version = "0.5.0" authors = ["Jose Storopoli "] description = "A simple no-BS Dead Man's Switch Tui Interface" license = "AGPL-3.0-only" readme = "../../README.md" [dependencies] -dead-man-switch = { version = "0.4.2", path = "../dead-man-switch" } +dead-man-switch.workspace = true + thiserror = "2" ratatui = "0.28" crossterm = "0.28" diff --git a/crates/dead-man-switch-tui/src/main.rs b/crates/dead-man-switch-tui/src/main.rs index fbebc30..1bcdfb0 100644 --- a/crates/dead-man-switch-tui/src/main.rs +++ b/crates/dead-man-switch-tui/src/main.rs @@ -233,14 +233,14 @@ fn ascii_block(content: &[&'static str]) -> Paragraph<'static> { /// Contains a [`Gauge`] widget to display the timer. /// The timer will be updated every second. /// -/// ## Parameters +/// # Parameters /// /// - `title`: The title for the timer. /// - `current_percent`: The current percentage of the timer. /// - `label`: The label for the timer. /// - `gauge_style`: The [`Style`] for the timer. /// -/// ## Notes +/// # Notes /// /// The timer will be green if is still counting the warning time. /// Eventually, it will turn red when the warning time is done, diff --git a/crates/dead-man-switch-web/Cargo.toml b/crates/dead-man-switch-web/Cargo.toml index 46c702b..9fac6c3 100644 --- a/crates/dead-man-switch-web/Cargo.toml +++ b/crates/dead-man-switch-web/Cargo.toml @@ -1,14 +1,17 @@ [package] name = "dead-man-switch-web" edition = "2021" -version = "0.4.2" +version = "0.5.0" authors = ["Jose Storopoli "] description = "A simple no-BS Dead Man's Switch Web Interface" license = "AGPL-3.0-only" readme = "../../README.md" [dependencies] -dead-man-switch = { version = "0.4.2", path = "../dead-man-switch" } +dead-man-switch.workspace = true + +zeroize.workspace = true + anyhow = "1.0.92" askama = "0.12.1" axum = "0.7.9" diff --git a/crates/dead-man-switch-web/src/main.rs b/crates/dead-man-switch-web/src/main.rs index 9f93376..ff6a21f 100644 --- a/crates/dead-man-switch-web/src/main.rs +++ b/crates/dead-man-switch-web/src/main.rs @@ -1,6 +1,6 @@ //! Web implementation for the Dead Man's Switch. -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{collections::HashMap, ops::Deref, sync::Arc, time::Duration}; use anyhow::Context; use askama::Template; @@ -19,8 +19,11 @@ use dead_man_switch::{ timer::{Timer, TimerType}, }; use serde::Serialize; -use tokio::sync::{Mutex, RwLock}; use tokio::{net::TcpListener, time::sleep}; +use tokio::{ + runtime::Handle, + sync::{Mutex, RwLock}, +}; use tower::{buffer::BufferLayer, limit::RateLimitLayer, ServiceBuilder}; use tower_http::{ cors::{Any, CorsLayer}, @@ -28,6 +31,7 @@ use tower_http::{ }; use tracing::{info, subscriber, warn, Level}; use tracing_subscriber::FmtSubscriber; +use zeroize::{Zeroize, ZeroizeOnDrop}; /// App state. struct AppState { @@ -36,21 +40,88 @@ struct AppState { timer: Mutex, } +/// Secret data to be zeroized. +#[derive(Zeroize, ZeroizeOnDrop)] +struct SecretData { + /// Password from the config. + password: String, + /// Hashed password from the config. + hashed_password: String, +} + +/// Wrapper for [`Key`] that provides secure zeroization. +#[derive(Clone)] +struct SecureKey { + /// The wrapped [`Key`]. + key: Key, + /// The pointer to the key's memory. + /// + /// Using an `Arc>>` to make the pointer thread-safe. + bytes: Arc>>, +} + +impl SecureKey { + /// Create a new [`SecureKey`] from a [`Key`]. + fn new(key: Key) -> Self { + let bytes = key.master().to_vec(); + Self { + key, + bytes: Arc::new(Mutex::new(bytes)), + } + } +} + +impl Zeroize for SecureKey { + fn zeroize(&mut self) { + match Handle::try_current() { + Ok(rt) => { + // block_on returns the MutexGuard directly + let mut guard = rt.block_on(async { self.bytes.lock().await }); + guard.zeroize(); + } + Err(_) => { + // No runtime available, try to zeroize synchronously + if let Ok(mut guard) = self.bytes.try_lock() { + guard.zeroize(); + } + } + } + } +} + +impl Drop for SecureKey { + fn drop(&mut self) { + // Use try_lock() instead of depending on the runtime + if let Ok(guard) = self.bytes.try_lock() { + let mut bytes = guard.to_vec(); + bytes.zeroize(); + } + } +} + +impl Deref for SecureKey { + type Target = Key; + + fn deref(&self) -> &Self::Target { + &self.key + } +} + /// Combined state containing both AppState and SecretState. #[derive(Clone)] struct SharedState { /// Dead Man's Switch [`AppState`]. app_state: Arc, - /// Hashed password from the config - hashed_password: String, + /// [`SecretData`] from the config. + secret_data: Arc, /// Secret key for cookie encryption. - key: Key, + key: SecureKey, } /// Tells [`PrivateCookieJar`] how to access the key from a [`SharedState`]. impl FromRef for Key { fn from_ref(state: &SharedState) -> Self { - state.key.clone() + state.key.key.clone() } } @@ -139,11 +210,17 @@ async fn handle_login( State(state): State, Form(params): Form>, ) -> impl IntoResponse { - let jar = PrivateCookieJar::new(state.key.clone()); + let jar = PrivateCookieJar::new(state.key.key.clone()); + + let mut user_password = params.get("password").expect("Password not found").clone(); - let user_password = params.get("password").expect("Password not found").clone(); + let is_valid = verify(&user_password, &state.secret_data.hashed_password) + .expect("Failed to verify password"); - if verify(user_password, &state.hashed_password).expect("Failed to verify password") { + // Zeroize the user-provided password after use + user_password.zeroize(); + + if is_valid { let updated_jar = jar.add(Cookie::new("auth", "true")); (updated_jar, Redirect::to("/dashboard")) } else { @@ -246,7 +323,13 @@ async fn main() -> anyhow::Result<()> { let config = load_or_initialize_config().context("Failed to load or initialize config")?; // Hash the password let password = config.web_password.clone(); - let hashed_password = hash(password, DEFAULT_COST).expect("Failed to hash password"); + let hashed_password = hash(&password, DEFAULT_COST).expect("Failed to hash password"); + + // Create a new SecretData + let secret_data = Arc::new(SecretData { + password, + hashed_password, + }); // Create a new Timer let timer = Timer::new( @@ -261,8 +344,8 @@ async fn main() -> anyhow::Result<()> { // Create combined shared state let shared_state = SharedState { app_state: app_state.clone(), - key: Key::generate(), - hashed_password, + key: SecureKey::new(Key::generate()), + secret_data, }; // CORS Layer @@ -303,5 +386,6 @@ async fn main() -> anyhow::Result<()> { serve(addr, app) .await .context("error while starting server")?; + Ok(()) } diff --git a/crates/dead-man-switch/Cargo.toml b/crates/dead-man-switch/Cargo.toml index 886d789..f904e82 100644 --- a/crates/dead-man-switch/Cargo.toml +++ b/crates/dead-man-switch/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dead-man-switch" edition = "2021" -version = "0.4.2" +version = "0.5.0" authors = ["Jose Storopoli "] description = "A simple no-BS Dead Man's Switch" license = "AGPL-3.0-only" @@ -16,3 +16,4 @@ lettre = { version = "0.11", features = ["rustls-tls", "builder"] } lettre_email = "0.9" mime_guess = "2" chrono = "0.4" +zeroize.workspace = true diff --git a/crates/dead-man-switch/src/config.rs b/crates/dead-man-switch/src/config.rs index 8ab94bd..7871ed9 100644 --- a/crates/dead-man-switch/src/config.rs +++ b/crates/dead-man-switch/src/config.rs @@ -10,14 +10,15 @@ use directories_next::BaseDirs; use serde::{Deserialize, Serialize}; use thiserror::Error; use toml::{de::Error as DerTomlError, ser::Error as SerTomlError}; +use zeroize::{Zeroize, ZeroizeOnDrop}; /// Configuration struct used for the application /// -/// ## Default +/// # Default /// /// If the configuration file does not exist, it will be created with /// the default values. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] pub struct Config { /// The username for the email account. pub username: String, @@ -44,7 +45,7 @@ pub struct Config { /// The email address to send the email from. pub from: String, /// Attachment to send with the email. - pub attachment: Option, + pub attachment: Option, /// Timer in seconds for the warning email. pub timer_warning: u64, /// Timer in seconds for the dead man's email. @@ -80,15 +81,21 @@ impl Default for Config { /// Configuration errors #[derive(Error, Debug)] pub enum ConfigError { - /// IO operations on config module + /// IO operations on config module. #[error(transparent)] Io(#[from] std::io::Error), - /// TOML serialization + + /// TOML serialization. #[error(transparent)] TomlSerialization(#[from] SerTomlError), - /// TOML deserialization + + /// TOML deserialization. #[error(transparent)] TomlDeserialization(#[from] DerTomlError), + + /// Attachment not found. + #[error("Attachment not found")] + AttachmentNotFound, } /// Enum to represent the type of email to send. @@ -105,12 +112,12 @@ pub enum Email { /// Under the hood uses the [`directories_next`] crate to find the /// home directory and the config. /// -/// ## Errors +/// # Errors /// /// - Fails if the home directory cannot be found /// - Fails if the config directory cannot be created /// -/// ## Notes +/// # Notes /// /// This function handles testing and non-testing environments. pub fn config_path() -> Result { @@ -139,7 +146,7 @@ pub fn config_path() -> Result { /// Under the hood uses the [`directories_next`] crate to find the /// home directory and the config. /// -/// ## Errors +/// # Errors /// /// - Fails if the home directory cannot be found /// - Fails if the config directory cannot be created @@ -158,12 +165,12 @@ pub fn save_config(config: &Config) -> Result<(), ConfigError> { /// Under the hood uses the [`directories_next`] crate to find the /// home directory and the config. /// -/// ## Errors +/// # Errors /// /// - Fails if the home directory cannot be found /// - Fails if the config directory cannot be created /// -/// ## Example +/// # Example /// /// ```rust /// use dead_man_switch::config::load_or_initialize_config; @@ -184,6 +191,20 @@ pub fn load_or_initialize_config() -> Result { } } +/// Parses the attachment path from the [`Config`]. +/// +/// # Errors +/// +/// - If the attachment path is not found. +/// - If the attachment path is not a valid path. +pub fn attachment_path(config: &Config) -> Result { + let attachment_path = config + .attachment + .as_ref() + .ok_or(ConfigError::AttachmentNotFound)?; + Ok(PathBuf::from(attachment_path)) +} + #[cfg(test)] mod test { use super::*; diff --git a/crates/dead-man-switch/src/email.rs b/crates/dead-man-switch/src/email.rs index 62d5062..0284d64 100644 --- a/crates/dead-man-switch/src/email.rs +++ b/crates/dead-man-switch/src/email.rs @@ -20,7 +20,7 @@ use lettre::{ }; use thiserror::Error; -use crate::config::{Config, Email}; +use crate::config::{attachment_path, Config, ConfigError, Email}; /// Errors that can occur when sending an email. #[derive(Error, Debug)] @@ -28,30 +28,38 @@ pub enum EmailError { /// TLS error when sending the email. #[error(transparent)] TlsError(#[from] smtp::Error), + /// Error when parsing email addresses. #[error(transparent)] EmailError(#[from] AddressError), + /// Error when building the email. #[error(transparent)] BuilderError(#[from] LettreError), + /// Error when reading the attachment. #[error(transparent)] IoError(#[from] IoError), + /// Error when determining the content type of the attachment. #[error(transparent)] InvalidContent(#[from] ContentTypeErr), + + /// Error when determining the content type of the attachment. + #[error(transparent)] + AttachmentPath(#[from] ConfigError), } impl Config { /// Send the email using the provided configuration. /// - /// ## Errors + /// # Errors /// /// - If the email fails to send. /// - If the email cannot be created. /// - If the attachment cannot be read. /// - /// ## Notes + /// # Notes /// /// If the attachment MIME type cannot be determined, it will default to /// `application/octet-stream`. @@ -103,7 +111,8 @@ impl Config { // Conditionally add the attachment for DeadMan email type if let Email::DeadMan = email_type { if let Some(attachment) = &self.attachment { - let filename = attachment + let attachment_path = attachment_path(self)?; + let filename = attachment_path .file_name() .ok_or_else(|| IoError::new(IoErrorKind::NotFound, "Failed to get filename"))? .to_string_lossy(); @@ -137,7 +146,6 @@ impl Config { #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; fn get_test_config() -> Config { Config { @@ -171,7 +179,7 @@ mod tests { fn test_create_email_with_attachment() { let mut config = get_test_config(); // Assuming there's a test file at this path - config.attachment = Some(PathBuf::from("Cargo.toml")); + config.attachment = Some("Cargo.toml".into()); let email_result = config.create_email(Email::Warning); assert!(email_result.is_ok()); let email_result = config.create_email(Email::DeadMan);