This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
pub mod config_store;
|
||||
pub mod pty;
|
||||
pub mod wallet_vault;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use argon2::Argon2;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use rabby_core::WalletDashboard;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
const VAULT_VERSION: u8 = 1;
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const KEY_LEN: usize = 32;
|
||||
const CIPHER: &str = "aes-256-gcm";
|
||||
const KDF: &str = "argon2id";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WalletVaultError {
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
Crypto(String),
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for WalletVaultError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(err) => write!(f, "wallet vault I/O error: {err}"),
|
||||
Self::Json(err) => write!(f, "wallet vault JSON error: {err}"),
|
||||
Self::Crypto(message) => write!(f, "wallet vault crypto error: {message}"),
|
||||
Self::Invalid(message) => write!(f, "invalid wallet vault: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WalletVaultError {}
|
||||
|
||||
impl From<std::io::Error> for WalletVaultError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for WalletVaultError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Json(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VaultEnvelope {
|
||||
pub version: u8,
|
||||
pub kdf: String,
|
||||
pub cipher: String,
|
||||
pub salt_b64: String,
|
||||
pub nonce_b64: String,
|
||||
pub ciphertext_b64: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct WalletVaultStatus {
|
||||
pub path: String,
|
||||
pub cipher: &'static str,
|
||||
pub kdf: &'static str,
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
pub struct EncryptedWalletVault;
|
||||
|
||||
impl EncryptedWalletVault {
|
||||
pub fn save_dashboard(
|
||||
path: impl AsRef<Path>,
|
||||
dashboard: &WalletDashboard,
|
||||
passphrase: &str,
|
||||
) -> Result<WalletVaultStatus, WalletVaultError> {
|
||||
validate_passphrase(passphrase)?;
|
||||
dashboard.validate().map_err(WalletVaultError::Invalid)?;
|
||||
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
let mut nonce = [0u8; NONCE_LEN];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
|
||||
let key = derive_key(passphrase, &salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| {
|
||||
WalletVaultError::Crypto("failed to create AES-256-GCM key".to_string())
|
||||
})?;
|
||||
let plaintext = serde_json::to_vec(dashboard)?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(Nonce::from_slice(&nonce), plaintext.as_ref())
|
||||
.map_err(|_| {
|
||||
WalletVaultError::Crypto("failed to encrypt wallet dashboard".to_string())
|
||||
})?;
|
||||
|
||||
let envelope = VaultEnvelope {
|
||||
version: VAULT_VERSION,
|
||||
kdf: KDF.to_string(),
|
||||
cipher: CIPHER.to_string(),
|
||||
salt_b64: B64.encode(salt),
|
||||
nonce_b64: B64.encode(nonce),
|
||||
ciphertext_b64: B64.encode(ciphertext),
|
||||
};
|
||||
write_envelope(path.as_ref(), &envelope)?;
|
||||
Ok(WalletVaultStatus {
|
||||
path: path.as_ref().display().to_string(),
|
||||
cipher: CIPHER,
|
||||
kdf: KDF,
|
||||
encrypted: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_dashboard(
|
||||
path: impl AsRef<Path>,
|
||||
passphrase: &str,
|
||||
) -> Result<WalletDashboard, WalletVaultError> {
|
||||
validate_passphrase(passphrase)?;
|
||||
let raw = std::fs::read_to_string(path.as_ref())?;
|
||||
let envelope: VaultEnvelope = serde_json::from_str(&raw)?;
|
||||
envelope.validate()?;
|
||||
|
||||
let salt = decode_fixed::<SALT_LEN>(&envelope.salt_b64, "salt")?;
|
||||
let nonce = decode_fixed::<NONCE_LEN>(&envelope.nonce_b64, "nonce")?;
|
||||
let ciphertext = B64
|
||||
.decode(envelope.ciphertext_b64.as_bytes())
|
||||
.map_err(|_| WalletVaultError::Invalid("ciphertext is not valid base64".to_string()))?;
|
||||
let key = derive_key(passphrase, &salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| {
|
||||
WalletVaultError::Crypto("failed to create AES-256-GCM key".to_string())
|
||||
})?;
|
||||
let plaintext = cipher
|
||||
.decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
|
||||
.map_err(|_| {
|
||||
WalletVaultError::Crypto(
|
||||
"failed to decrypt wallet vault; check passphrase".to_string(),
|
||||
)
|
||||
})?;
|
||||
let dashboard: WalletDashboard = serde_json::from_slice(&plaintext)?;
|
||||
dashboard.validate().map_err(WalletVaultError::Invalid)?;
|
||||
Ok(dashboard)
|
||||
}
|
||||
}
|
||||
|
||||
impl VaultEnvelope {
|
||||
fn validate(&self) -> Result<(), WalletVaultError> {
|
||||
if self.version != VAULT_VERSION {
|
||||
return Err(WalletVaultError::Invalid(format!(
|
||||
"unsupported vault version {}",
|
||||
self.version
|
||||
)));
|
||||
}
|
||||
if self.kdf != KDF {
|
||||
return Err(WalletVaultError::Invalid(format!(
|
||||
"unsupported vault KDF {}",
|
||||
self.kdf
|
||||
)));
|
||||
}
|
||||
if self.cipher != CIPHER {
|
||||
return Err(WalletVaultError::Invalid(format!(
|
||||
"unsupported vault cipher {}",
|
||||
self.cipher
|
||||
)));
|
||||
}
|
||||
if self.ciphertext_b64.trim().is_empty() {
|
||||
return Err(WalletVaultError::Invalid(
|
||||
"ciphertext is required".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_passphrase(passphrase: &str) -> Result<(), WalletVaultError> {
|
||||
if passphrase.trim().len() < 8 {
|
||||
return Err(WalletVaultError::Invalid(
|
||||
"passphrase must be at least 8 non-blank characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN], WalletVaultError> {
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
Argon2::default()
|
||||
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
|
||||
.map_err(|err| WalletVaultError::Crypto(format!("failed to derive key: {err}")))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn decode_fixed<const N: usize>(value: &str, label: &str) -> Result<[u8; N], WalletVaultError> {
|
||||
let decoded = B64
|
||||
.decode(value.as_bytes())
|
||||
.map_err(|_| WalletVaultError::Invalid(format!("{label} is not valid base64")))?;
|
||||
decoded
|
||||
.try_into()
|
||||
.map_err(|_| WalletVaultError::Invalid(format!("{label} has incorrect length")))
|
||||
}
|
||||
|
||||
fn write_envelope(path: &Path, envelope: &VaultEnvelope) -> Result<(), WalletVaultError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(envelope)?;
|
||||
std::fs::write(path, raw)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rabby_core::demo_dashboard;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_path(name: &str) -> PathBuf {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("rabby-vault-{name}-{unique}.json"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_vault_round_trips_dashboard_with_passphrase() {
|
||||
let path = temp_path("round-trip");
|
||||
let dashboard = demo_dashboard();
|
||||
|
||||
EncryptedWalletVault::save_dashboard(&path, &dashboard, "correct horse battery staple")
|
||||
.unwrap();
|
||||
let restored =
|
||||
EncryptedWalletVault::load_dashboard(&path, "correct horse battery staple").unwrap();
|
||||
|
||||
assert_eq!(restored, dashboard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_vault_does_not_store_wallet_plaintext() {
|
||||
let path = temp_path("opaque");
|
||||
let dashboard = demo_dashboard();
|
||||
|
||||
EncryptedWalletVault::save_dashboard(&path, &dashboard, "correct horse battery staple")
|
||||
.unwrap();
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
assert!(raw.contains("aes-256-gcm"));
|
||||
assert!(!raw.contains(&dashboard.active_account.address));
|
||||
assert!(!raw.contains("Main Wallet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_vault_rejects_wrong_passphrase() {
|
||||
let path = temp_path("wrong-passphrase");
|
||||
let dashboard = demo_dashboard();
|
||||
|
||||
EncryptedWalletVault::save_dashboard(&path, &dashboard, "correct horse battery staple")
|
||||
.unwrap();
|
||||
|
||||
let err = EncryptedWalletVault::load_dashboard(&path, "wrong password").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("decrypt")
|
||||
|| err.to_string().to_lowercase().contains("passphrase")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_vault_rejects_blank_passphrase() {
|
||||
let path = temp_path("blank-passphrase");
|
||||
let dashboard = demo_dashboard();
|
||||
|
||||
let err = EncryptedWalletVault::save_dashboard(&path, &dashboard, " ").unwrap_err();
|
||||
|
||||
assert!(err.to_string().to_lowercase().contains("passphrase"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user