feat(cli): encrypt keystore with Argon2id + AES-256-GCM

This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 12:18:11 +03:00
parent 9eb322322b
commit 61fb9fe2c8
6 changed files with 327 additions and 18 deletions

View file

@ -7,6 +7,17 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]
## [0.3.0] — 2026-04-24
### Added
- `src/keystore.rs` — AES-256-GCM + Argon2id şifreleme; `save_encrypted` / `load_encrypted` / `prompt_password`
- `nu wallet new`: şifre ister (confirm), private key şifreli JSON olarak `~/.nu/keystore/<label>.key`'e yazar
- `nu wallet address`: şifre ister, decrypt edip compressed public key basar
### Changed
- Keystore formatı plaintext hex'ten JSON (`argon2_hash` + `nonce_hex` + `ciphertext_hex`) formatına geçti
- Eski `.key` dosyaları yeni format ile uyumsuz — `wallet new` ile yeniden oluşturulmalı
## [0.2.0] — 2026-04-24
### Added

167
Cargo.lock generated
View file

@ -2,6 +2,41 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "anstream"
version = "1.0.0"
@ -58,6 +93,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -88,6 +135,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -125,6 +181,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.1"
@ -231,9 +297,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "der"
version = "0.7.10"
@ -478,6 +554,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "group"
version = "0.13.0"
@ -781,6 +867,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -927,13 +1022,16 @@ dependencies = [
name = "nu-cli"
version = "0.1.0"
dependencies = [
"aes-gcm",
"anyhow",
"argon2",
"clap",
"dirs",
"hex",
"k256",
"rand",
"reqwest",
"rpassword",
"serde",
"serde_json",
"tokio",
@ -951,6 +1049,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.78"
@ -1024,6 +1128,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -1052,6 +1167,18 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -1218,6 +1345,27 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rpassword"
version = "7.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.59.0",
]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
@ -1720,6 +1868,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -1947,6 +2105,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.61.2"

View file

@ -18,3 +18,6 @@ k256 = { version = "0.13", features = ["ecdsa"] } # secp256k1
rand = "0.8"
hex = "0.4"
dirs = "5"
argon2 = "0.5"
aes-gcm = "0.10"
rpassword = "7"

View file

@ -3,20 +3,21 @@ use clap::Subcommand;
use k256::ecdsa::SigningKey;
use rand::rngs::OsRng;
use crate::keystore;
#[derive(Subcommand)]
pub enum WalletCmd {
/// Generate a new keypair
/// Generate a new encrypted keypair
New {
/// Label for the keystore file
#[arg(long, default_value = "default")]
label: String,
},
/// Show address for a keystore entry
/// Show compressed public key (address) for a keystore entry
Address {
#[arg(long, default_value = "default")]
label: String,
},
/// Send NUT tokens
/// Send NUT tokens (stub — Faz 1 tx signing)
Send {
#[arg(long)] to: String,
#[arg(long)] amount: u64,
@ -28,28 +29,35 @@ pub async fn run(cmd: WalletCmd) -> Result<()> {
match cmd {
WalletCmd::New { label } => {
let key = SigningKey::random(&mut OsRng);
let bytes = key.to_bytes();
let hex_key = hex::encode(bytes);
let key_bytes = key.to_bytes();
let verifying = key.verifying_key();
let address = hex::encode(verifying.to_sec1_bytes());
let password = keystore::prompt_password(true)?;
let dir = crate::config::keystore_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{label}.key"));
// Store as hex — Faz 1: add password encryption
std::fs::write(&path, &hex_key)?;
let verifying = key.verifying_key();
println!("Created keystore: {}", path.display());
println!("Address (pubkey compressed): {}", hex::encode(verifying.to_sec1_bytes()));
keystore::save_encrypted(&path, &key_bytes, &password)?;
println!("Keystore : {}", path.display());
println!("Address : {address}");
println!("(Private key encrypted with Argon2id + AES-256-GCM)");
}
WalletCmd::Address { label } => {
let dir = crate::config::keystore_dir();
let raw = std::fs::read_to_string(dir.join(format!("{label}.key")))?;
let bytes = hex::decode(raw.trim())?;
let key = SigningKey::from_bytes(bytes.as_slice().into())?;
let path = dir.join(format!("{label}.key"));
let password = keystore::prompt_password(false)?;
let key_bytes = keystore::load_encrypted(&path, &password)?;
let key = SigningKey::from_bytes(key_bytes.as_slice().into())?;
println!("{}", hex::encode(key.verifying_key().to_sec1_bytes()));
}
WalletCmd::Send { to, amount, from } => {
println!("TODO: nu wallet send --to {to} --amount {amount} (from: {from})");
println!("TODO: nu wallet send --to {to} --amount {amount} Shell (from: {from}) — Faz 1");
}
}
Ok(())

119
src/keystore.rs Normal file
View file

@ -0,0 +1,119 @@
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use anyhow::{bail, Context, Result};
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
use argon2::password_hash::{rand_core::OsRng, PasswordHash, SaltString};
use serde::{Deserialize, Serialize};
use std::path::Path;
/// On-disk format for an encrypted keystore entry.
#[derive(Serialize, Deserialize)]
struct KeystoreFile {
/// Argon2id PHC string — contains salt and params
argon2_hash: String,
/// AES-256-GCM nonce, hex-encoded (12 bytes)
nonce_hex: String,
/// AES-256-GCM ciphertext, hex-encoded
ciphertext_hex: String,
}
/// Encrypt `private_key_bytes` with `password`, write to `path`.
pub fn save_encrypted(path: &Path, private_key_bytes: &[u8], password: &str) -> Result<()> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
// Derive 32-byte key from password
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("argon2 hash: {e}"))?;
let derived = derive_aes_key(&hash)?;
#[allow(deprecated)]
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&derived));
let nonce_bytes: [u8; 12] = {
use rand::RngCore;
let mut b = [0u8; 12];
rand::rngs::OsRng.fill_bytes(&mut b);
b
};
#[allow(deprecated)]
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, private_key_bytes)
.map_err(|e| anyhow::anyhow!("aes-gcm encrypt: {e}"))?;
let entry = KeystoreFile {
argon2_hash: hash.to_string(),
nonce_hex: hex::encode(nonce_bytes),
ciphertext_hex: hex::encode(&ciphertext),
};
let json = serde_json::to_string_pretty(&entry)?;
std::fs::write(path, json)?;
Ok(())
}
/// Decrypt the keystore file at `path` using `password`.
/// Returns raw private key bytes.
pub fn load_encrypted(path: &Path, password: &str) -> Result<Vec<u8>> {
let json = std::fs::read_to_string(path)
.with_context(|| format!("keystore not found: {}", path.display()))?;
let entry: KeystoreFile = serde_json::from_str(&json)
.with_context(|| format!("invalid keystore format: {}", path.display()))?;
let hash = PasswordHash::new(&entry.argon2_hash)
.map_err(|e| anyhow::anyhow!("argon2 parse: {e}"))?;
// Verify password — Argon2::default() matches the stored params
Argon2::default()
.verify_password(password.as_bytes(), &hash)
.map_err(|_| anyhow::anyhow!("Wrong password"))?;
let derived = derive_aes_key(&hash)?;
#[allow(deprecated)]
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&derived));
let nonce_bytes = hex::decode(&entry.nonce_hex)
.context("invalid nonce in keystore")?;
let ciphertext = hex::decode(&entry.ciphertext_hex)
.context("invalid ciphertext in keystore")?;
#[allow(deprecated)]
let nonce = Nonce::from_slice(&nonce_bytes);
cipher
.decrypt(nonce, ciphertext.as_slice())
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong password or corrupted keystore"))
}
/// Prompt for password (no echo), confirm if `confirm = true`.
pub fn prompt_password(confirm: bool) -> Result<String> {
let password = rpassword::prompt_password("Keystore password: ")?;
if password.is_empty() {
bail!("Password cannot be empty");
}
if confirm {
let confirm = rpassword::prompt_password("Confirm password: ")?;
if password != confirm {
bail!("Passwords do not match");
}
}
Ok(password)
}
/// Derives a 32-byte AES key from an Argon2 PHC hash using its output hash bytes.
fn derive_aes_key(hash: &PasswordHash<'_>) -> Result<[u8; 32]> {
// The hash output is stored in the PHC string; re-derive from stored params
let output = hash.hash.ok_or_else(|| anyhow::anyhow!("no hash output in PHC string"))?;
let bytes = output.as_bytes();
if bytes.len() < 32 {
bail!("argon2 output too short for AES-256 key");
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes[..32]);
Ok(key)
}

View file

@ -1,5 +1,6 @@
mod commands;
mod config;
mod keystore;
mod rpc;
use anyhow::Result;