feat(cli): encrypt keystore with Argon2id + AES-256-GCM
This commit is contained in:
parent
9eb322322b
commit
61fb9fe2c8
6 changed files with 327 additions and 18 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -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
167
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -27,29 +28,36 @@ pub enum WalletCmd {
|
|||
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 = SigningKey::random(&mut OsRng);
|
||||
let key_bytes = key.to_bytes();
|
||||
let verifying = key.verifying_key();
|
||||
let address = hex::encode(verifying.to_sec1_bytes());
|
||||
|
||||
let dir = crate::config::keystore_dir();
|
||||
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 dir = crate::config::keystore_dir();
|
||||
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
119
src/keystore.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
mod commands;
|
||||
mod config;
|
||||
mod keystore;
|
||||
mod rpc;
|
||||
|
||||
use anyhow::Result;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue