merge: SEC-3/CONC-1/2/3 vault zeroize + async mutex + cancellation (resolved lib.rs conflict)

This commit is contained in:
Vantz Stockwell 2026-03-29 16:41:47 -04:00
commit 8a66103d3d
15 changed files with 189 additions and 116 deletions

@ -0,0 +1 @@
Subproject commit da2dd5bbfcd4070e8976b7b8e19c10df7b9cfb5b

@ -0,0 +1 @@
Subproject commit 17973fc3dcf1dedb618b9761101daa19e43da181

@ -0,0 +1 @@
Subproject commit ff9fc798c3fff68b9c3ebf63485ca04dae9907be

@ -0,0 +1 @@
Subproject commit 24e8b1e359cb2f47728be7707259ef257487e45e

@ -0,0 +1 @@
Subproject commit a907213d57da81eb3ffe6dae98db191c642b6976

@ -0,0 +1 @@
Subproject commit fca6ed023e7d5baa70daa21305ac36ca4acb18f0

2
src-tauri/Cargo.lock generated
View File

@ -8913,9 +8913,11 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"ureq", "ureq",
"uuid", "uuid",
"x509-cert", "x509-cert",
"zeroize",
] ]
[[package]] [[package]]

View File

@ -37,6 +37,8 @@ uuid = { version = "1", features = ["v4"] }
base64 = "0.22" base64 = "0.22"
dashmap = "6" dashmap = "6"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"
zeroize = { version = "1", features = ["derive"] }
async-trait = "0.1" async-trait = "0.1"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"

View File

@ -3,34 +3,16 @@ use tauri::State;
use crate::credentials::Credential; use crate::credentials::Credential;
use crate::AppState; use crate::AppState;
/// Guard helper: lock the credentials mutex and return a ref to the inner
/// `CredentialService`, or a "Vault is locked" error if the vault has not
/// been unlocked for this session.
///
/// This is a macro rather than a function because returning a `MutexGuard`
/// from a helper function would require lifetime annotations that complicate
/// the tauri command signatures unnecessarily.
macro_rules! require_unlocked {
($state:expr) => {{
let guard = $state
.credentials
.lock()
.map_err(|_| "Credentials mutex was poisoned".to_string())?;
if guard.is_none() {
return Err("Vault is locked — call unlock before accessing credentials".into());
}
// SAFETY: we just checked `is_none` above, so `unwrap` cannot panic.
guard
}};
}
/// Return all credentials ordered by name. /// Return all credentials ordered by name.
/// ///
/// Secret values (passwords, private keys) are never included — only metadata. /// Secret values (passwords, private keys) are never included — only metadata.
#[tauri::command] #[tauri::command]
pub fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, String> { pub async fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard.as_ref().unwrap().list() let svc = guard
.as_ref()
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
svc.list()
} }
/// Store a new username/password credential. /// Store a new username/password credential.
@ -39,18 +21,18 @@ pub fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, S
/// Returns the created credential record (without the plaintext password). /// Returns the created credential record (without the plaintext password).
/// `domain` is `None` for non-domain credentials; `Some("")` is treated as NULL. /// `domain` is `None` for non-domain credentials; `Some("")` is treated as NULL.
#[tauri::command] #[tauri::command]
pub fn create_password( pub async fn create_password(
name: String, name: String,
username: String, username: String,
password: String, password: String,
domain: Option<String>, domain: Option<String>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Credential, String> { ) -> Result<Credential, String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard let svc = guard
.as_ref() .as_ref()
.unwrap() .ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
.create_password(name, username, password, domain) svc.create_password(name, username, password, domain)
} }
/// Store a new SSH private key credential. /// Store a new SSH private key credential.
@ -59,18 +41,18 @@ pub fn create_password(
/// Pass `None` for `passphrase` when the key has no passphrase. /// Pass `None` for `passphrase` when the key has no passphrase.
/// Returns the created credential record without any secret material. /// Returns the created credential record without any secret material.
#[tauri::command] #[tauri::command]
pub fn create_ssh_key( pub async fn create_ssh_key(
name: String, name: String,
username: String, username: String,
private_key_pem: String, private_key_pem: String,
passphrase: Option<String>, passphrase: Option<String>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Credential, String> { ) -> Result<Credential, String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard let svc = guard
.as_ref() .as_ref()
.unwrap() .ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
.create_ssh_key(name, username, private_key_pem, passphrase) svc.create_ssh_key(name, username, private_key_pem, passphrase)
} }
/// Delete a credential by id. /// Delete a credential by id.
@ -78,21 +60,30 @@ pub fn create_ssh_key(
/// For SSH key credentials, the associated `ssh_keys` row is also deleted. /// For SSH key credentials, the associated `ssh_keys` row is also deleted.
/// Returns `Err` if the vault is locked or the id does not exist. /// Returns `Err` if the vault is locked or the id does not exist.
#[tauri::command] #[tauri::command]
pub fn delete_credential(id: i64, state: State<'_, AppState>) -> Result<(), String> { pub async fn delete_credential(id: i64, state: State<'_, AppState>) -> Result<(), String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard.as_ref().unwrap().delete(id) let svc = guard
.as_ref()
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
svc.delete(id)
} }
/// Decrypt and return the password for a credential. /// Decrypt and return the password for a credential.
#[tauri::command] #[tauri::command]
pub fn decrypt_password(credential_id: i64, state: State<'_, AppState>) -> Result<String, String> { pub async fn decrypt_password(credential_id: i64, state: State<'_, AppState>) -> Result<String, String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard.as_ref().unwrap().decrypt_password(credential_id) let svc = guard
.as_ref()
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
svc.decrypt_password(credential_id)
} }
/// Decrypt and return the SSH private key and passphrase. /// Decrypt and return the SSH private key and passphrase.
#[tauri::command] #[tauri::command]
pub fn decrypt_ssh_key(ssh_key_id: i64, state: State<'_, AppState>) -> Result<(String, String), String> { pub async fn decrypt_ssh_key(ssh_key_id: i64, state: State<'_, AppState>) -> Result<(String, String), String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard.as_ref().unwrap().decrypt_ssh_key(ssh_key_id) let svc = guard
.as_ref()
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
svc.decrypt_ssh_key(ssh_key_id)
} }

View File

@ -1,4 +1,5 @@
use tauri::State; use tauri::State;
use zeroize::Zeroize;
use crate::vault::{self, VaultService}; use crate::vault::{self, VaultService};
use crate::credentials::CredentialService; use crate::credentials::CredentialService;
@ -21,14 +22,15 @@ pub fn is_first_run(state: State<'_, AppState>) -> bool {
/// Returns `Err` if the vault has already been set up or if any storage /// Returns `Err` if the vault has already been set up or if any storage
/// operation fails. /// operation fails.
#[tauri::command] #[tauri::command]
pub fn create_vault(password: String, state: State<'_, AppState>) -> Result<(), String> { pub async fn create_vault(mut password: String, state: State<'_, AppState>) -> Result<(), String> {
let result = async {
if !state.is_first_run() { if !state.is_first_run() {
return Err("Vault already exists — use unlock instead of create".into()); return Err("Vault already exists — use unlock instead of create".into());
} }
let salt = vault::generate_salt(); let salt = vault::generate_salt();
let key = vault::derive_key(&password, &salt); let key = vault::derive_key(&password, &salt);
let vs = VaultService::new(key); let vs = VaultService::new(key.clone());
// Persist the salt so we can re-derive the key on future unlocks. // Persist the salt so we can re-derive the key on future unlocks.
state.settings.set("vault_salt", &hex::encode(salt))?; state.settings.set("vault_salt", &hex::encode(salt))?;
@ -39,10 +41,14 @@ pub fn create_vault(password: String, state: State<'_, AppState>) -> Result<(),
// Activate the vault and credentials service for this session. // Activate the vault and credentials service for this session.
let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key)); let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key));
*state.credentials.lock().unwrap() = Some(cred_svc); *state.credentials.lock().await = Some(cred_svc);
*state.vault.lock().unwrap() = Some(vs); *state.vault.lock().await = Some(vs);
Ok(()) Ok(())
}.await;
password.zeroize();
result
} }
/// Unlock an existing vault using the master password. /// Unlock an existing vault using the master password.
@ -52,7 +58,8 @@ pub fn create_vault(password: String, state: State<'_, AppState>) -> Result<(),
/// ///
/// Returns `Err("Incorrect master password")` if the password is wrong. /// Returns `Err("Incorrect master password")` if the password is wrong.
#[tauri::command] #[tauri::command]
pub fn unlock(password: String, state: State<'_, AppState>) -> Result<(), String> { pub async fn unlock(mut password: String, state: State<'_, AppState>) -> Result<(), String> {
let result = async {
let salt_hex = state let salt_hex = state
.settings .settings
.get("vault_salt") .get("vault_salt")
@ -62,7 +69,7 @@ pub fn unlock(password: String, state: State<'_, AppState>) -> Result<(), String
.map_err(|e| format!("Stored vault salt is corrupt: {e}"))?; .map_err(|e| format!("Stored vault salt is corrupt: {e}"))?;
let key = vault::derive_key(&password, &salt); let key = vault::derive_key(&password, &salt);
let vs = VaultService::new(key); let vs = VaultService::new(key.clone());
// Verify the password by decrypting the check value. // Verify the password by decrypting the check value.
let check_blob = state let check_blob = state
@ -80,14 +87,18 @@ pub fn unlock(password: String, state: State<'_, AppState>) -> Result<(), String
// Activate the vault and credentials service for this session. // Activate the vault and credentials service for this session.
let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key)); let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key));
*state.credentials.lock().unwrap() = Some(cred_svc); *state.credentials.lock().await = Some(cred_svc);
*state.vault.lock().unwrap() = Some(vs); *state.vault.lock().await = Some(vs);
Ok(()) Ok(())
}.await;
password.zeroize();
result
} }
/// Returns `true` if the vault is currently unlocked for this session. /// Returns `true` if the vault is currently unlocked for this session.
#[tauri::command] #[tauri::command]
pub fn is_unlocked(state: State<'_, AppState>) -> bool { pub async fn is_unlocked(state: State<'_, AppState>) -> Result<bool, String> {
state.is_unlocked() Ok(state.is_unlocked().await)
} }

View File

@ -23,7 +23,6 @@ pub mod scanner;
pub mod commands; pub mod commands;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex;
use db::Database; use db::Database;
use vault::VaultService; use vault::VaultService;
@ -41,10 +40,10 @@ use mcp::error_watcher::ErrorWatcher;
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
pub vault: Mutex<Option<VaultService>>, pub vault: tokio::sync::Mutex<Option<VaultService>>,
pub settings: SettingsService, pub settings: SettingsService,
pub connections: ConnectionService, pub connections: ConnectionService,
pub credentials: Mutex<Option<CredentialService>>, pub credentials: tokio::sync::Mutex<Option<CredentialService>>,
pub ssh: SshService, pub ssh: SshService,
pub sftp: SftpService, pub sftp: SftpService,
pub rdp: RdpService, pub rdp: RdpService,
@ -63,9 +62,9 @@ impl AppState {
let settings = SettingsService::new(database.clone()); let settings = SettingsService::new(database.clone());
Ok(Self { Ok(Self {
db: database.clone(), db: database.clone(),
vault: Mutex::new(None), vault: tokio::sync::Mutex::new(None),
connections: ConnectionService::new(database.clone()), connections: ConnectionService::new(database.clone()),
credentials: Mutex::new(None), credentials: tokio::sync::Mutex::new(None),
ssh: SshService::new(database.clone()), ssh: SshService::new(database.clone()),
sftp: SftpService::new(), sftp: SftpService::new(),
rdp: RdpService::new(), rdp: RdpService::new(),
@ -86,8 +85,8 @@ impl AppState {
self.settings.get("vault_salt").unwrap_or_default().is_empty() self.settings.get("vault_salt").unwrap_or_default().is_empty()
} }
pub fn is_unlocked(&self) -> bool { pub async fn is_unlocked(&self) -> bool {
self.vault.lock().unwrap().is_some() self.vault.lock().await.is_some()
} }
} }

View File

@ -16,6 +16,7 @@ use russh::ChannelMsg;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::sync::watch; use tokio::sync::watch;
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use tokio_util::sync::CancellationToken;
use crate::ssh::session::SshClient; use crate::ssh::session::SshClient;
@ -39,13 +40,15 @@ impl CwdTracker {
/// Spawn a background tokio task that polls `pwd` every 2 seconds on a /// Spawn a background tokio task that polls `pwd` every 2 seconds on a
/// separate exec channel. /// separate exec channel.
/// ///
/// The task runs until the SSH connection is closed or the channel cannot /// The task runs until cancelled via the `CancellationToken`, or until the
/// be opened. CWD changes are emitted as `ssh:cwd:{session_id}` events. /// SSH connection is closed or the channel cannot be opened.
/// CWD changes are emitted as `ssh:cwd:{session_id}` events.
pub fn start( pub fn start(
&self, &self,
handle: Arc<TokioMutex<Handle<SshClient>>>, handle: Arc<TokioMutex<Handle<SshClient>>>,
app_handle: AppHandle, app_handle: AppHandle,
session_id: String, session_id: String,
cancel: CancellationToken,
) { ) {
let sender = self._sender.clone(); let sender = self._sender.clone();
@ -56,6 +59,10 @@ impl CwdTracker {
let mut previous_cwd = String::new(); let mut previous_cwd = String::new();
loop { loop {
if cancel.is_cancelled() {
break;
}
// Open a fresh exec channel for each `pwd` invocation. // Open a fresh exec channel for each `pwd` invocation.
// Some SSH servers do not allow multiple exec requests on a // Some SSH servers do not allow multiple exec requests on a
// single channel, so we open a new one each time. // single channel, so we open a new one each time.
@ -119,8 +126,11 @@ impl CwdTracker {
} }
} }
// Wait 2 seconds before the next poll. // Wait 2 seconds before the next poll, or cancel.
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; tokio::select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs(2)) => {}
_ = cancel.cancelled() => { break; }
}
} }
debug!("CWD tracker for session {} stopped", session_id); debug!("CWD tracker for session {} stopped", session_id);

View File

@ -6,11 +6,13 @@
use std::sync::Arc; use std::sync::Arc;
use log::warn;
use russh::client::Handle; use russh::client::Handle;
use russh::ChannelMsg; use russh::ChannelMsg;
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use tokio_util::sync::CancellationToken;
use crate::ssh::session::SshClient; use crate::ssh::session::SshClient;
@ -30,26 +32,53 @@ pub struct SystemStats {
} }
/// Spawn a background task that polls system stats every 5 seconds. /// Spawn a background task that polls system stats every 5 seconds.
///
/// The task runs until cancelled via the `CancellationToken`, or until the
/// SSH connection is closed.
pub fn start_monitor( pub fn start_monitor(
handle: Arc<TokioMutex<Handle<SshClient>>>, handle: Arc<TokioMutex<Handle<SshClient>>>,
app_handle: AppHandle, app_handle: AppHandle,
session_id: String, session_id: String,
cancel: CancellationToken,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
// Brief delay to let the shell start up // Brief delay to let the shell start up
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let mut consecutive_timeouts: u32 = 0;
loop { loop {
if cancel.is_cancelled() {
break;
}
let stats = collect_stats(&handle).await; let stats = collect_stats(&handle).await;
if let Some(stats) = stats { match stats {
Some(stats) => {
consecutive_timeouts = 0;
let _ = app_handle.emit( let _ = app_handle.emit(
&format!("ssh:monitor:{}", session_id), &format!("ssh:monitor:{}", session_id),
&stats, &stats,
); );
} }
None => {
consecutive_timeouts += 1;
if consecutive_timeouts >= 3 {
warn!(
"SSH monitor for session {}: 3 consecutive failures, stopping",
session_id
);
break;
}
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; // Wait 5 seconds before the next poll, or cancel.
tokio::select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {}
_ = cancel.cancelled() => { break; }
}
} }
}); });
} }
@ -125,7 +154,24 @@ fn parse_stats(raw: &str) -> Option<SystemStats> {
}) })
} }
/// Execute a command on a separate exec channel with a 10-second timeout.
async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> { async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
exec_command_inner(handle, cmd),
)
.await;
match result {
Ok(output) => output,
Err(_) => {
warn!("SSH monitor exec_command timed out after 10s");
None
}
}
}
async fn exec_command_inner(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
let mut channel = { let mut channel = {
let h = handle.lock().await; let h = handle.lock().await;
h.channel_open_session().await.ok()? h.channel_open_session().await.ok()?

View File

@ -17,6 +17,7 @@ use crate::mcp::error_watcher::ErrorWatcher;
use crate::sftp::SftpService; use crate::sftp::SftpService;
use crate::ssh::cwd::CwdTracker; use crate::ssh::cwd::CwdTracker;
use crate::ssh::host_key::{HostKeyResult, HostKeyStore}; use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
use tokio_util::sync::CancellationToken;
pub enum AuthMethod { pub enum AuthMethod {
Password(String), Password(String),
@ -47,6 +48,7 @@ pub struct SshSession {
pub handle: Arc<TokioMutex<Handle<SshClient>>>, pub handle: Arc<TokioMutex<Handle<SshClient>>>,
pub command_tx: mpsc::UnboundedSender<ChannelCommand>, pub command_tx: mpsc::UnboundedSender<ChannelCommand>,
pub cwd_tracker: Option<CwdTracker>, pub cwd_tracker: Option<CwdTracker>,
pub cancel_token: CancellationToken,
} }
pub struct SshClient { pub struct SshClient {
@ -135,10 +137,11 @@ impl SshService {
let channel_id = channel.id(); let channel_id = channel.id();
let handle = Arc::new(TokioMutex::new(handle)); let handle = Arc::new(TokioMutex::new(handle));
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<ChannelCommand>(); let (command_tx, mut command_rx) = mpsc::unbounded_channel::<ChannelCommand>();
let cancel_token = CancellationToken::new();
let cwd_tracker = CwdTracker::new(); let cwd_tracker = CwdTracker::new();
cwd_tracker.start(handle.clone(), app_handle.clone(), session_id.clone()); cwd_tracker.start(handle.clone(), app_handle.clone(), session_id.clone(), cancel_token.clone());
let session = Arc::new(SshSession { id: session_id.clone(), hostname: hostname.to_string(), port, username: username.to_string(), channel_id, handle: handle.clone(), command_tx: command_tx.clone(), cwd_tracker: Some(cwd_tracker) }); let session = Arc::new(SshSession { id: session_id.clone(), hostname: hostname.to_string(), port, username: username.to_string(), channel_id, handle: handle.clone(), command_tx: command_tx.clone(), cwd_tracker: Some(cwd_tracker), cancel_token: cancel_token.clone() });
self.sessions.insert(session_id.clone(), session); self.sessions.insert(session_id.clone(), session);
{ let h = handle.lock().await; { let h = handle.lock().await;
@ -158,7 +161,7 @@ impl SshService {
error_watcher.watch(&session_id); error_watcher.watch(&session_id);
// Start remote monitoring if enabled (runs on a separate exec channel) // Start remote monitoring if enabled (runs on a separate exec channel)
crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone()); crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone(), cancel_token.clone());
// Inject OSC 7 CWD reporting hook into the user's shell. // Inject OSC 7 CWD reporting hook into the user's shell.
// This enables SFTP CWD following on all platforms (Linux, macOS, FreeBSD). // This enables SFTP CWD following on all platforms (Linux, macOS, FreeBSD).
@ -246,6 +249,8 @@ impl SshService {
pub async fn disconnect(&self, session_id: &str, sftp_service: &SftpService) -> Result<(), String> { pub async fn disconnect(&self, session_id: &str, sftp_service: &SftpService) -> Result<(), String> {
let (_, session) = self.sessions.remove(session_id).ok_or_else(|| format!("Session {} not found", session_id))?; let (_, session) = self.sessions.remove(session_id).ok_or_else(|| format!("Session {} not found", session_id))?;
// Cancel background tasks (CWD tracker, monitor) before tearing down the connection.
session.cancel_token.cancel();
let _ = session.command_tx.send(ChannelCommand::Shutdown); let _ = session.command_tx.send(ChannelCommand::Shutdown);
{ let handle = session.handle.lock().await; let _ = handle.disconnect(Disconnect::ByApplication, "", "en").await; } { let handle = session.handle.lock().await; let _ = handle.disconnect(Disconnect::ByApplication, "", "en").await; }
sftp_service.remove_client(session_id); sftp_service.remove_client(session_id);

View File

@ -4,6 +4,7 @@ use aes_gcm::{
Aes256Gcm, Key, Nonce, Aes256Gcm, Key, Nonce,
}; };
use argon2::{Algorithm, Argon2, Params, Version}; use argon2::{Algorithm, Argon2, Params, Version};
use zeroize::Zeroizing;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// VaultService // VaultService
@ -21,18 +22,18 @@ use argon2::{Algorithm, Argon2, Params, Version};
/// The version prefix allows a future migration to a different algorithm /// The version prefix allows a future migration to a different algorithm
/// without breaking existing stored blobs. /// without breaking existing stored blobs.
pub struct VaultService { pub struct VaultService {
key: [u8; 32], key: Zeroizing<[u8; 32]>,
} }
impl VaultService { impl VaultService {
pub fn new(key: [u8; 32]) -> Self { pub fn new(key: Zeroizing<[u8; 32]>) -> Self {
Self { key } Self { key }
} }
/// Encrypt `plaintext` and return a `v1:{iv_hex}:{sealed_hex}` blob. /// Encrypt `plaintext` and return a `v1:{iv_hex}:{sealed_hex}` blob.
pub fn encrypt(&self, plaintext: &str) -> Result<String, String> { pub fn encrypt(&self, plaintext: &str) -> Result<String, String> {
// Build the AES-256-GCM cipher from our key. // Build the AES-256-GCM cipher from our key.
let key = Key::<Aes256Gcm>::from_slice(&self.key); let key = Key::<Aes256Gcm>::from_slice(&*self.key);
let cipher = Aes256Gcm::new(key); let cipher = Aes256Gcm::new(key);
// Generate a random 12-byte nonce (96-bit is the GCM standard). // Generate a random 12-byte nonce (96-bit is the GCM standard).
@ -71,7 +72,7 @@ impl VaultService {
)); ));
} }
let key = Key::<Aes256Gcm>::from_slice(&self.key); let key = Key::<Aes256Gcm>::from_slice(&*self.key);
let cipher = Aes256Gcm::new(key); let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(&iv_bytes); let nonce = Nonce::from_slice(&iv_bytes);
@ -95,7 +96,7 @@ impl VaultService {
/// t = 3 iterations /// t = 3 iterations
/// m = 65536 KiB (64 MiB) memory /// m = 65536 KiB (64 MiB) memory
/// p = 4 parallelism lanes /// p = 4 parallelism lanes
pub fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] { pub fn derive_key(password: &str, salt: &[u8]) -> Zeroizing<[u8; 32]> {
let params = Params::new( let params = Params::new(
65536, // m_cost: 64 MiB 65536, // m_cost: 64 MiB
3, // t_cost: iterations 3, // t_cost: iterations
@ -106,9 +107,9 @@ pub fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut output_key = [0u8; 32]; let mut output_key = Zeroizing::new([0u8; 32]);
argon2 argon2
.hash_password_into(password.as_bytes(), salt, &mut output_key) .hash_password_into(password.as_bytes(), salt, &mut *output_key)
.expect("Argon2id key derivation failed"); .expect("Argon2id key derivation failed");
output_key output_key