feat(wallet): add encrypted local vault runtime
test / workspace (push) Successful in 12m26s

This commit is contained in:
Tom You
2026-07-09 00:08:24 -05:00
parent 480d686c41
commit fc17c44c38
11 changed files with 608 additions and 3 deletions
+1
View File
@@ -1,2 +1,3 @@
pub mod config_store;
pub mod pty;
pub mod wallet_vault;
+280
View File
@@ -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"));
}
}