335 lines
12 KiB
Rust
335 lines
12 KiB
Rust
use aes_gcm::{Aes256Gcm, Key, Nonce}; // Or `AesGcm`
|
|
use aes_gcm::aead::Aead;
|
|
use argon2::{Argon2, password_hash::{SaltString, PasswordHasher, PasswordVerifier}, PasswordHash, PasswordHasher as _};
|
|
use rand::rngs::OsRng;
|
|
use rand::RngCore;
|
|
use base64::{encode as b64encode, decode as b64decode};
|
|
use serde::{Serialize, Deserialize};
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use anyhow::Result;
|
|
|
|
use keyring::Keyring;
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct EncryptedSeedFile {
|
|
salt: String,
|
|
nonce: String,
|
|
ciphertext: String,
|
|
}
|
|
|
|
/// Derive 32-byte key using Argon2 from password + salt
|
|
fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
|
|
let argon2 = Argon2::default();
|
|
// Use password-hash crate to produce a derived key via Argon2: we will directly hash and then extract bytes
|
|
// Simpler: use Argon2::hash_password_simple to produce a string and then hash it again - but here create PHC and use raw hashing
|
|
use argon2::password_hash::Salt;
|
|
let salt = Salt::new(b64encode(salt).as_str()).expect("salt create");
|
|
let mut out = [0u8; 32];
|
|
// Use Argon2's hash_password_into
|
|
argon2.hash_password_into(password.as_bytes(), salt.as_ref().as_bytes(), &mut out).expect("argon2 derive");
|
|
out
|
|
}
|
|
|
|
/// Encrypt and write seed to file (JSON containing salt, nonce, ciphertext)
|
|
pub fn encrypt_seed_file(seed: &[u8; 32], password: &str, path: &str) -> Result<()> {
|
|
// generate salt + nonce
|
|
let mut salt = [0u8; 16];
|
|
OsRng.fill_bytes(&mut salt);
|
|
let keybytes = derive_key(password, &salt);
|
|
let key = Key::from_slice(&keybytes);
|
|
let cipher = Aes256Gcm::new(key);
|
|
|
|
let mut nonce_bytes = [0u8; 12];
|
|
OsRng.fill_bytes(&mut nonce_bytes);
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
let ct = cipher.encrypt(nonce, seed.as_ref()).expect("encryption");
|
|
|
|
let file = EncryptedSeedFile {
|
|
salt: b64encode(&salt),
|
|
nonce: b64encode(&nonce_bytes),
|
|
ciphertext: b64encode(&ct),
|
|
};
|
|
|
|
let s = serde_json::to_string_pretty(&file)?;
|
|
fs::write(path, s)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Decrypt a seed file given a password
|
|
pub fn decrypt_seed_file(path: &str, password: &str) -> Result<[u8; 32]> {
|
|
let content = fs::read_to_string(path)?;
|
|
let file: EncryptedSeedFile = serde_json::from_str(&content)?;
|
|
let salt = b64decode(&file.salt)?;
|
|
let nonce_bytes = b64decode(&file.nonce)?;
|
|
let ct = b64decode(&file.ciphertext)?;
|
|
|
|
let keybytes = derive_key(password, &salt);
|
|
let key = Key::from_slice(&keybytes);
|
|
let cipher = Aes256Gcm::new(key);
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
let pt = cipher.decrypt(nonce, ct.as_ref())?;
|
|
let mut out = [0u8; 32];
|
|
out.copy_from_slice(&pt[..32]);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Store a password in the OS keyring (optional convenience)
|
|
pub fn store_password_in_keyring(service: &str, user: &str, password: &str) -> Result<()> {
|
|
let kr = Keyring::new(service, user);
|
|
kr.set_password(password)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Retrieve a password from the OS keyring
|
|
pub fn retrieve_password_from_keyring(service: &str, user: &str) -> Result<String> {
|
|
let kr = Keyring::new(service, user);
|
|
let pwd = kr.get_password()?;
|
|
Ok(pwd)
|
|
}
|
|
|
|
/// Generate and store a random 32-byte master key in the OS keyring for DB encryption
|
|
pub fn generate_db_master_key(service: &str, user: &str) -> Result<()> {
|
|
let mut key = [0u8; 32];
|
|
OsRng.fill_bytes(&mut key);
|
|
let s = b64encode(&key);
|
|
store_password_in_keyring(service, user, &s)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Retrieve the 32-byte master key from the OS keyring
|
|
pub fn get_db_master_key(service: &str, user: &str) -> Result<[u8; 32]> {
|
|
let s = retrieve_password_from_keyring(service, user)?;
|
|
let bytes = b64decode(&s)?;
|
|
let mut out = [0u8; 32];
|
|
out.copy_from_slice(&bytes[0..32]);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Encrypt an arbitrary file using the master key stored in the OS keyring
|
|
pub fn encrypt_file_with_master_key(in_path: &str, out_path: &str, service: &str, user: &str) -> Result<()> {
|
|
let keybytes = get_db_master_key(service, user)?;
|
|
let key = Key::from_slice(&keybytes);
|
|
let cipher = Aes256Gcm::new(key);
|
|
|
|
let mut nonce_bytes = [0u8; 12];
|
|
OsRng.fill_bytes(&mut nonce_bytes);
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
let data = fs::read(in_path)?;
|
|
let ct = cipher.encrypt(nonce, data.as_ref())?;
|
|
|
|
let file = EncryptedSeedFile {
|
|
salt: "".to_string(),
|
|
nonce: b64encode(&nonce_bytes),
|
|
ciphertext: b64encode(&ct),
|
|
};
|
|
let s = serde_json::to_string_pretty(&file)?;
|
|
fs::write(out_path, s)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Decrypt an arbitrary file previously encrypted with `encrypt_file_with_master_key`
|
|
pub fn decrypt_file_with_master_key(in_path: &str, out_path: &str, service: &str, user: &str) -> Result<()> {
|
|
let content = fs::read_to_string(in_path)?;
|
|
let file: EncryptedSeedFile = serde_json::from_str(&content)?;
|
|
let nonce_bytes = b64decode(&file.nonce)?;
|
|
let ct = b64decode(&file.ciphertext)?;
|
|
|
|
let keybytes = get_db_master_key(service, user)?;
|
|
let key = Key::from_slice(&keybytes);
|
|
let cipher = Aes256Gcm::new(key);
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
let pt = cipher.decrypt(nonce, ct.as_ref())?;
|
|
|
|
// write with restricted permissions
|
|
fs::write(out_path, &pt)?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(out_path, fs::Permissions::from_mode(0o600))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Decrypt an encrypted file and return the plaintext bytes in memory (avoid writing to disk)
|
|
pub fn decrypt_file_to_memory(in_path: &str, service: &str, user: &str) -> Result<Vec<u8>> {
|
|
let content = fs::read_to_string(in_path)?;
|
|
let file: EncryptedSeedFile = serde_json::from_str(&content)?;
|
|
let nonce_bytes = b64decode(&file.nonce)?;
|
|
let ct = b64decode(&file.ciphertext)?;
|
|
|
|
let keybytes = get_db_master_key(service, user)?;
|
|
let key = Key::from_slice(&keybytes);
|
|
let cipher = Aes256Gcm::new(key);
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
let pt = cipher.decrypt(nonce, ct.as_ref())?;
|
|
Ok(pt)
|
|
}
|
|
/// Overwrite the file content with zeros and then remove the file. Best-effort secure delete.
|
|
pub fn secure_delete(path: &str) -> Result<()> {
|
|
if !Path::new(path).exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
// Platform specific helpers
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use std::process::Command;
|
|
// Prefer `srm -s` (secure rm) if available
|
|
if Command::new("srm").arg("-s").arg(path).status().map(|s| s.success()).unwrap_or(false) {
|
|
return Ok(());
|
|
}
|
|
// Fallback to `shred -u` if available
|
|
if Command::new("shred").arg("-u").arg(path).status().map(|s| s.success()).unwrap_or(false) {
|
|
return Ok(());
|
|
}
|
|
// Fallback to `rm -P` which attempts to overwrite
|
|
if Command::new("rm").arg("-P").arg(path).status().map(|s| s.success()).unwrap_or(false) {
|
|
return Ok(());
|
|
}
|
|
// else fall through to generic overwrite
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// Use Windows API `ReplaceFile` as a best-effort removal after overwrite
|
|
use std::fs::OpenOptions;
|
|
use std::os::windows::fs::OpenOptionsExt;
|
|
use winapi::um::winbase::FILE_FLAG_WRITE_THROUGH;
|
|
use winapi::um::winbase::ReplaceFileW;
|
|
use std::os::windows::prelude::OsStrExt;
|
|
|
|
let metadata = fs::metadata(path)?;
|
|
let len = metadata.len();
|
|
let mut f = OpenOptions::new().write(true).custom_flags(FILE_FLAG_WRITE_THROUGH).open(path)?;
|
|
// overwrite multiple patterns (zeros, 0xFF, random)
|
|
let mut remaining = len;
|
|
let mut zeros = vec![0u8; 4096];
|
|
let mut ff = vec![0xFFu8; 4096];
|
|
while remaining > 0 {
|
|
let write_len = std::cmp::min(remaining, zeros.len() as u64) as usize;
|
|
f.write_all(&zeros[0..write_len])?;
|
|
f.flush()?;
|
|
f.write_all(&ff[0..write_len])?;
|
|
f.flush()?;
|
|
let mut rnd = vec![0u8; write_len];
|
|
OsRng.fill_bytes(&mut rnd);
|
|
f.write_all(&rnd)?;
|
|
f.flush()?;
|
|
remaining -= write_len as u64;
|
|
}
|
|
f.sync_all()?;
|
|
drop(f);
|
|
|
|
// Attempt to replace the file with an empty temporary file
|
|
let tmp = tempfile::NamedTempFile::new()?;
|
|
let tmp_wstr: Vec<u16> = tmp.path().as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
|
let path_wstr: Vec<u16> = std::path::Path::new(path).as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
|
unsafe {
|
|
let r = ReplaceFileW(path_wstr.as_ptr(), tmp_wstr.as_ptr(), std::ptr::null(), 0, std::ptr::null_mut(), std::ptr::null_mut());
|
|
if r == 0 {
|
|
// fallback to delete
|
|
let _ = fs::remove_file(path);
|
|
} else {
|
|
let _ = fs::remove_file(tmp.path());
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Generic Unix-like overwrite (including Linux)
|
|
{
|
|
let metadata = fs::metadata(path)?;
|
|
let len = metadata.len();
|
|
let mut f = fs::OpenOptions::new().write(true).open(path)?;
|
|
// overwrite with zeros
|
|
let zeros = vec![0u8; 4096];
|
|
let mut remaining = len;
|
|
while remaining > 0 {
|
|
let write_len = std::cmp::min(remaining, zeros.len() as u64) as usize;
|
|
f.write_all(&zeros[0..write_len])?;
|
|
remaining -= write_len as u64;
|
|
}
|
|
f.sync_all()?;
|
|
drop(f);
|
|
fs::remove_file(path)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::NamedTempFile;
|
|
|
|
#[test]
|
|
fn roundtrip_encrypt_decrypt() {
|
|
let mut seed = [0u8; 32];
|
|
OsRng.fill_bytes(&mut seed);
|
|
let tmp = NamedTempFile::new().unwrap();
|
|
let path = tmp.path().to_str().unwrap();
|
|
encrypt_seed_file(&seed, "password123", path).expect("encrypt");
|
|
let out = decrypt_seed_file(path, "password123").expect("decrypt");
|
|
assert_eq!(seed, out);
|
|
}
|
|
|
|
#[test]
|
|
fn db_master_key_encrypt_decrypt_file() {
|
|
// prepare sample file
|
|
let tmp_in = NamedTempFile::new().unwrap();
|
|
let in_path = tmp_in.path().to_str().unwrap();
|
|
fs::write(in_path, b"hello-db") .unwrap();
|
|
|
|
// generate master key
|
|
let _ = generate_db_master_key("cryptec-db", "test-user");
|
|
let tmp_out = NamedTempFile::new().unwrap();
|
|
let out_path = tmp_out.path().to_str().unwrap();
|
|
|
|
encrypt_file_with_master_key(in_path, out_path, "cryptec-db", "test-user").expect("encrypt file");
|
|
|
|
// decrypt to another temp path
|
|
let tmp_dec = NamedTempFile::new().unwrap();
|
|
let dec_path = tmp_dec.path().to_str().unwrap();
|
|
decrypt_file_with_master_key(out_path, dec_path, "cryptec-db", "test-user").expect("decrypt file");
|
|
|
|
let content = fs::read(dec_path).expect("read");
|
|
assert_eq!(content, b"hello-db");
|
|
}
|
|
|
|
#[test]
|
|
fn secure_delete_removes_file() {
|
|
let tmp = NamedTempFile::new().unwrap();
|
|
let p = tmp.path().to_str().unwrap().to_string();
|
|
// write some data
|
|
fs::write(&p, b"sensitive").unwrap();
|
|
assert!(Path::new(&p).exists());
|
|
secure_delete(&p).expect("secure delete");
|
|
assert!(!Path::new(&p).exists());
|
|
}
|
|
|
|
// Windows-specific secure delete test will only run on Windows
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn secure_delete_windows_api() {
|
|
let tmp = NamedTempFile::new().unwrap();
|
|
let p = tmp.path().to_str().unwrap().to_string();
|
|
fs::write(&p, b"sensitive").unwrap();
|
|
secure_delete(&p).expect("secure delete windows");
|
|
assert!(!Path::new(&p).exists());
|
|
}
|
|
|
|
// macOS-specific test
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn secure_delete_macos_cmd() {
|
|
let tmp = NamedTempFile::new().unwrap();
|
|
let p = tmp.path().to_str().unwrap().to_string();
|
|
fs::write(&p, b"sensitive").unwrap();
|
|
secure_delete(&p).expect("secure delete macos");
|
|
assert!(!Path::new(&p).exists());
|
|
}
|
|
}
|