diff --git a/Cargo.lock b/Cargo.lock index ee66b122..a137c901 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -604,6 +604,7 @@ dependencies = [ "metrics-exporter-prometheus", "rustls", "rustls-pemfile", + "subtle", "thiserror 2.0.18", "tikv-jemallocator", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 933ace41..13571346 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ clap = { version = "4", features = ["derive"] } # checksums crc32fast = "1" +# constant-time comparison (auth) +subtle = "2" + # float ordering (used for sorted set scores) ordered-float = "5" diff --git a/crates/ember-persistence/src/aof.rs b/crates/ember-persistence/src/aof.rs index 96e313b1..9a1e3921 100644 --- a/crates/ember-persistence/src/aof.rs +++ b/crates/ember-persistence/src/aof.rs @@ -443,7 +443,14 @@ impl AofWriter { let path = path.into(); let exists = path.exists() && fs::metadata(&path).map(|m| m.len() > 0).unwrap_or(false); - let file = OpenOptions::new().create(true).append(true).open(&path)?; + let mut opts = OpenOptions::new(); + opts.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let file = opts.open(&path)?; let mut writer = BufWriter::new(file); if !exists { @@ -488,11 +495,14 @@ impl AofWriter { self.writer.flush()?; // reopen the file with truncation, write fresh header - let file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&self.path)?; + let mut opts = OpenOptions::new(); + opts.create(true).write(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let file = opts.open(&self.path)?; let mut writer = BufWriter::new(file); format::write_header(&mut writer, format::AOF_MAGIC)?; writer.flush()?; diff --git a/crates/ember-persistence/src/snapshot.rs b/crates/ember-persistence/src/snapshot.rs index 0c103879..5bad3424 100644 --- a/crates/ember-persistence/src/snapshot.rs +++ b/crates/ember-persistence/src/snapshot.rs @@ -22,7 +22,7 @@ //! v1 entries (no type tag) are still readable for backward compatibility. use std::collections::{HashMap, HashSet, VecDeque}; -use std::fs::{self, File}; +use std::fs::{self, File, OpenOptions}; use std::io::{self, BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; @@ -85,7 +85,16 @@ impl SnapshotWriter { let final_path = path.into(); let tmp_path = final_path.with_extension("snap.tmp"); - let file = File::create(&tmp_path)?; + let file = { + let mut opts = OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + opts.open(&tmp_path)? + }; let mut writer = BufWriter::new(file); // write header: magic + version + shard_id + placeholder entry count diff --git a/crates/ember-server/Cargo.toml b/crates/ember-server/Cargo.toml index 6ace2686..b6d1c713 100644 --- a/crates/ember-server/Cargo.toml +++ b/crates/ember-server/Cargo.toml @@ -28,6 +28,7 @@ metrics-exporter-prometheus = { workspace = true } thiserror = { workspace = true } futures = "0.3" dashmap = "6" +subtle = { workspace = true } # tls tokio-rustls = { workspace = true } diff --git a/crates/ember-server/src/concurrent_handler.rs b/crates/ember-server/src/concurrent_handler.rs index f3815178..8ccc0321 100644 --- a/crates/ember-server/src/concurrent_handler.rs +++ b/crates/ember-server/src/concurrent_handler.rs @@ -19,6 +19,7 @@ use std::time::{Duration, Instant}; use bytes::BytesMut; use ember_core::{ConcurrentKeyspace, Engine, TtlResult}; use ember_protocol::{parse_frame, Command, Frame, SetExpire}; +use subtle::ConstantTimeEq; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use crate::connection_common::{ @@ -277,7 +278,7 @@ async fn execute_concurrent( ); } } - if password == *expected { + if bool::from(password.as_bytes().ct_eq(expected.as_bytes())) { Frame::Simple("OK".into()) } else { Frame::Error( diff --git a/crates/ember-server/src/connection.rs b/crates/ember-server/src/connection.rs index ed0a7415..29843451 100644 --- a/crates/ember-server/src/connection.rs +++ b/crates/ember-server/src/connection.rs @@ -14,6 +14,7 @@ use bytes::{Bytes, BytesMut}; use ember_core::{Engine, KeyspaceStats, ShardRequest, ShardResponse, TtlResult, Value}; use ember_protocol::{parse_frame, Command, Frame, SetExpire}; use futures::future::join_all; +use subtle::ConstantTimeEq; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::sync::broadcast; @@ -1474,7 +1475,7 @@ async fn execute( ); } } - if password == *expected { + if bool::from(password.as_bytes().ct_eq(expected.as_bytes())) { Frame::Simple("OK".into()) } else { Frame::Error( diff --git a/crates/ember-server/src/connection_common.rs b/crates/ember-server/src/connection_common.rs index e450b7dd..abc2d5e8 100644 --- a/crates/ember-server/src/connection_common.rs +++ b/crates/ember-server/src/connection_common.rs @@ -8,6 +8,7 @@ use std::time::Duration; use ember_protocol::types::Frame; use ember_protocol::Command; +use subtle::ConstantTimeEq; use crate::server::ServerContext; @@ -89,7 +90,7 @@ pub fn try_auth(frame: Frame, ctx: &ServerContext) -> (Frame, bool) { ); } } - if password == *expected { + if bool::from(password.as_bytes().ct_eq(expected.as_bytes())) { (Frame::Simple("OK".into()), true) } else { ( diff --git a/crates/ember-server/src/main.rs b/crates/ember-server/src/main.rs index 5c5c2add..31b7822a 100644 --- a/crates/ember-server/src/main.rs +++ b/crates/ember-server/src/main.rs @@ -83,6 +83,12 @@ struct Args { #[arg(long, env = "EMBER_REQUIREPASS")] requirepass: Option, + /// path to a file containing the password (alternative to --requirepass). + /// avoids exposing the password in /proc/cmdline. the file contents are + /// trimmed of trailing whitespace. + #[arg(long, env = "EMBER_REQUIREPASS_FILE")] + requirepass_file: Option, + // -- TLS options (matching redis) -- /// port for TLS connections. when set, enables TLS alongside plain TCP #[arg(long, env = "EMBER_TLS_PORT")] @@ -115,7 +121,32 @@ async fn main() { ) .init(); - let args = Args::parse(); + let mut args = Args::parse(); + + // resolve password: --requirepass-file takes the same role as --requirepass + if args.requirepass.is_some() && args.requirepass_file.is_some() { + eprintln!("error: --requirepass and --requirepass-file are mutually exclusive"); + std::process::exit(1); + } + if let Some(ref path) = args.requirepass_file { + match std::fs::read_to_string(path) { + Ok(contents) => { + let password = contents.trim_end().to_string(); + if password.is_empty() { + eprintln!("error: --requirepass-file is empty: {}", path.display()); + std::process::exit(1); + } + args.requirepass = Some(password); + } + Err(e) => { + eprintln!( + "error: failed to read --requirepass-file '{}': {e}", + path.display() + ); + std::process::exit(1); + } + } + } let addr: SocketAddr = format!("{}:{}", args.host, args.port) .parse()