feat(keystore): read and write quantus-cli wallet files, byte for byte
ci / gate (push) Successful in 4m56s
ci / gate (push) Successful in 4m56s
`wallet_data::keystore` is the CLI's format: AES-256-GCM over the JSON of the wallet data, keyed by Argon2id under the frozen profile (m=19456, t=2, p=1) and a 16-byte salt, with the PHC string carrying the profile and salt and never the digest. A file whose PHC string does embed a digest is refused, as the CLI refuses it. On open the address is re-derived from the decrypted key and the file refused if it differs, so a file that lies about its address cannot be opened, let alone signed with. Legacy files with no `scheme` are ML-DSA-87; files with no `wallet_type` are hot. The directory rules are the CLI's too: a wallet name is a plain filename, writes go through an unpredictable temporary file and a no-clobber rename at mode 0600, and a symlink where a wallet should be is refused. Proof in both directions. Eight real files open here: six written by quantus-cli 2.2.2 for the dev phrase, one per scheme and path, and the two fixtures the extension fork tests against. A file written here lists in `quantus wallet list` with the expected address (ignored test, run with QUANTUS_CLI set). A test greps the written file for phrase words and secret key bytes and finds neither. `Keystore` is a port in wallet-core: list, open, create, import a file, export a file. Opening yields a `Signer` and, when the file carries one, the phrase in a wiping holder; neither leaves Rust. Closes #15 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
Generated
+88
@@ -82,6 +82,18 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[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 = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -152,6 +164,12 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bip39"
|
||||
version = "2.2.2"
|
||||
@@ -657,6 +675,15 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
@@ -827,6 +854,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -1798,6 +1835,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@@ -2249,6 +2292,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -2652,6 +2706,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -3411,6 +3478,19 @@ dependencies = [
|
||||
"toml 1.1.6+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
@@ -4036,11 +4116,19 @@ dependencies = [
|
||||
name = "wallet-data"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"chrono",
|
||||
"directories",
|
||||
"getrandom 0.3.4",
|
||||
"qp-poseidon-core",
|
||||
"qp-rusty-crystals-dilithium",
|
||||
"qp-rusty-crystals-hdwallet",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"wallet-core",
|
||||
"wallet-entities",
|
||||
"zeroize",
|
||||
|
||||
@@ -38,6 +38,13 @@ qp-poseidon-core = "3.1.0"
|
||||
blake2 = { version = "0.10", default-features = false }
|
||||
bs58 = "0.5"
|
||||
getrandom = "0.3"
|
||||
# The quantus-cli keystore format: Argon2id (frozen m=19456,t=2,p=1) into
|
||||
# AES-256-GCM. The versions are pinned to what the CLI itself uses.
|
||||
argon2 = { version = "0.5", features = ["std"] }
|
||||
aes-gcm = "0.10"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||
directories = "6"
|
||||
tempfile = "3"
|
||||
# `isolation`: the IPC isolation pattern (crates/wallet-app/isolation/index.html).
|
||||
tauri = { version = "2", features = ["isolation"] }
|
||||
tauri-build = { version = "2", features = ["isolation"] }
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! implements, and `wallet-core` logic consumes.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use wallet_entities::{AccountRef, Amount, AssetId, AssetInfo, ChainId, SignatureScheme};
|
||||
use wallet_entities::{
|
||||
AccountRef, Amount, AssetId, AssetInfo, ChainId, SignatureScheme, WalletSummary,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// A BIP39 seed: the 64 bytes every key in the wallet descends from. Wiped
|
||||
@@ -60,6 +62,73 @@ pub enum KeyError {
|
||||
Signing(String),
|
||||
}
|
||||
|
||||
/// Where wallets live on disk. The file format is quantus-cli's, so a wallet
|
||||
/// moves freely between this app, the CLI and the browser extension; the
|
||||
/// port hides the directory and the file naming, nothing more. Opening a
|
||||
/// wallet yields a [`Signer`] and, when the file carries one, the phrase it
|
||||
/// was made from; both stay in Rust.
|
||||
pub trait Keystore: Send + Sync {
|
||||
fn list(&self) -> Result<Vec<WalletSummary>, KeystoreError>;
|
||||
|
||||
/// Decrypt a wallet and hand back its signer. The stored address is
|
||||
/// re-derived from the decrypted key and the file refused if it differs.
|
||||
fn open(&self, name: &str, password: &str) -> Result<OpenedWallet, KeystoreError>;
|
||||
|
||||
/// Encrypt and write a wallet. Refuses to overwrite.
|
||||
fn create(&self, wallet: NewWallet<'_>, password: &str)
|
||||
-> Result<WalletSummary, KeystoreError>;
|
||||
|
||||
/// Copy a wallet file in from elsewhere (a CLI keystore, a backup),
|
||||
/// validating it opens with `password` first.
|
||||
fn import_file(
|
||||
&self,
|
||||
path: &std::path::Path,
|
||||
password: &str,
|
||||
) -> Result<WalletSummary, KeystoreError>;
|
||||
|
||||
/// Write the wallet's file, unchanged, to `dest`. It stays encrypted.
|
||||
fn export_file(&self, name: &str, dest: &std::path::Path) -> Result<(), KeystoreError>;
|
||||
}
|
||||
|
||||
/// A wallet the keystore has just decrypted.
|
||||
pub struct OpenedWallet {
|
||||
pub summary: WalletSummary,
|
||||
pub signer: Box<dyn Signer>,
|
||||
pub derivation_path: String,
|
||||
/// Present when the file carried the phrase. Wiped on drop.
|
||||
pub mnemonic: Option<Zeroizing<String>>,
|
||||
}
|
||||
|
||||
/// What it takes to write a new wallet file.
|
||||
pub struct NewWallet<'a> {
|
||||
pub name: &'a str,
|
||||
pub scheme: SignatureScheme,
|
||||
pub derivation_path: &'a str,
|
||||
pub public_key: &'a [u8],
|
||||
pub secret_key: &'a [u8],
|
||||
pub mnemonic: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum KeystoreError {
|
||||
#[error("no wallet named {0}")]
|
||||
NotFound(String),
|
||||
#[error("a wallet named {0} already exists")]
|
||||
AlreadyExists(String),
|
||||
#[error("wrong password")]
|
||||
WrongPassword,
|
||||
#[error("the file is not a wallet this app understands: {0}")]
|
||||
Format(String),
|
||||
#[error("integrity check failed: {0}")]
|
||||
Integrity(String),
|
||||
#[error("invalid wallet name: {0}")]
|
||||
InvalidName(String),
|
||||
#[error("i/o: {0}")]
|
||||
Io(String),
|
||||
#[error(transparent)]
|
||||
Key(#[from] KeyError),
|
||||
}
|
||||
|
||||
/// What the wallet wants done on a chain, in chain-neutral terms. The adapter
|
||||
/// turns it into a transaction the chain understands.
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -17,3 +17,12 @@ getrandom.workspace = true
|
||||
qp-rusty-crystals-dilithium.workspace = true
|
||||
qp-rusty-crystals-hdwallet.workspace = true
|
||||
qp-poseidon-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
argon2.workspace = true
|
||||
aes-gcm.workspace = true
|
||||
chrono.workspace = true
|
||||
directories.workspace = true
|
||||
tracing.workspace = true
|
||||
# For the write-then-rename of a wallet file, not only for tests.
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -87,6 +87,41 @@ pub struct MlDsaSigner {
|
||||
}
|
||||
|
||||
impl MlDsaSigner {
|
||||
/// Rebuild a signer from stored key bytes (a keystore file). The halves
|
||||
/// must correspond; the dilithium crate checks and refuses otherwise.
|
||||
pub fn from_raw(
|
||||
scheme: SignatureScheme,
|
||||
public_key: &[u8],
|
||||
secret_key: &[u8],
|
||||
) -> Result<Self, KeyError> {
|
||||
let bad = |e: &dyn std::fmt::Debug| KeyError::Derivation(format!("{e:?}"));
|
||||
let keypair = match scheme {
|
||||
SignatureScheme::MlDsa65 => {
|
||||
let secret = ml_dsa_65::SecretKey::from_bytes(secret_key).map_err(|e| bad(&e))?;
|
||||
let public = ml_dsa_65::PublicKey::from_bytes(public_key).map_err(|e| bad(&e))?;
|
||||
MlDsaKeypair::V65(Box::new(
|
||||
ml_dsa_65::Keypair::from_parts(secret, public).map_err(|e| bad(&e))?,
|
||||
))
|
||||
}
|
||||
SignatureScheme::MlDsa87 => {
|
||||
let secret = ml_dsa_87::SecretKey::from_bytes(secret_key).map_err(|e| bad(&e))?;
|
||||
let public = ml_dsa_87::PublicKey::from_bytes(public_key).map_err(|e| bad(&e))?;
|
||||
MlDsaKeypair::V87(Box::new(
|
||||
ml_dsa_87::Keypair::from_parts(secret, public).map_err(|e| bad(&e))?,
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(Self::new(keypair))
|
||||
}
|
||||
|
||||
/// The secret key bytes, for writing a keystore file. Wiped on drop.
|
||||
pub fn secret_key_bytes(&self) -> zeroize::Zeroizing<Vec<u8>> {
|
||||
match &self.keypair {
|
||||
MlDsaKeypair::V65(k) => zeroize::Zeroizing::new(k.secret().to_bytes().to_vec()),
|
||||
MlDsaKeypair::V87(k) => zeroize::Zeroizing::new(k.secret().to_bytes().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
fn new(keypair: MlDsaKeypair) -> Self {
|
||||
let public = match &keypair {
|
||||
MlDsaKeypair::V65(k) => k.public().to_bytes().to_vec(),
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
//! The quantus-cli wallet file format, read and written byte-compatibly, and
|
||||
//! a directory of such files behind the `Keystore` port.
|
||||
//!
|
||||
//! A file is JSON: public `name`, `address`, `wallet_type`, a `created_at`,
|
||||
//! and an `encrypted_data` blob that is AES-256-GCM over the JSON of
|
||||
//! [`WalletData`], keyed by Argon2id of the password under a frozen profile
|
||||
//! (m=19456 KiB, t=2, p=1) and a 16-byte salt. The PHC string in
|
||||
//! `argon2_params` carries the profile and the salt and never the digest,
|
||||
//! because the digest is the key. On open, the address is re-derived from the
|
||||
//! decrypted key and the file refused if it differs, as the CLI does.
|
||||
//!
|
||||
//! The CLI's rules on the directory are kept too: a name is a plain filename
|
||||
//! with no separators, a write goes through a fresh temporary file and a
|
||||
//! rename, and a symlink where a wallet file should be is refused.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
use argon2::{Algorithm, Argon2, Params, PasswordHash, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wallet_core::ss58;
|
||||
use wallet_core::{KeyError, Keystore, KeystoreError, NewWallet, OpenedWallet};
|
||||
use wallet_entities::{SignatureScheme, WalletKind, WalletSummary};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::keys::{MlDsaSigner, account_id};
|
||||
|
||||
/// The SS58 prefix the CLI writes addresses under. The format is Quantus's,
|
||||
/// so this is a property of the format, not of the wallet's chain profile.
|
||||
const FILE_SS58_PREFIX: u16 = 189;
|
||||
|
||||
const ARGON2_M_COST: u32 = 19_456;
|
||||
const ARGON2_T_COST: u32 = 2;
|
||||
const ARGON2_P_COST: u32 = 1;
|
||||
|
||||
/// The scheme names the CLI writes. Older files have no `scheme` and are 87.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
enum FileScheme {
|
||||
#[serde(rename = "ml-dsa-65", alias = "ml-dsa65")]
|
||||
MlDsa65,
|
||||
#[serde(rename = "ml-dsa-87", alias = "ml-dsa87")]
|
||||
MlDsa87,
|
||||
}
|
||||
|
||||
fn legacy_scheme() -> FileScheme {
|
||||
FileScheme::MlDsa87
|
||||
}
|
||||
|
||||
impl From<FileScheme> for SignatureScheme {
|
||||
fn from(s: FileScheme) -> Self {
|
||||
match s {
|
||||
FileScheme::MlDsa65 => SignatureScheme::MlDsa65,
|
||||
FileScheme::MlDsa87 => SignatureScheme::MlDsa87,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SignatureScheme> for FileScheme {
|
||||
fn from(s: SignatureScheme) -> Self {
|
||||
match s {
|
||||
SignatureScheme::MlDsa65 => FileScheme::MlDsa65,
|
||||
SignatureScheme::MlDsa87 => FileScheme::MlDsa87,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The encrypted file, field for field as the CLI writes it. Public.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EncryptedWallet {
|
||||
pub name: String,
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub wallet_type: WalletKind,
|
||||
pub encrypted_data: Vec<u8>,
|
||||
#[serde(default)]
|
||||
pub kyber_ciphertext: Vec<u8>,
|
||||
#[serde(default)]
|
||||
pub kyber_public_key: Vec<u8>,
|
||||
pub argon2_salt: Vec<u8>,
|
||||
pub argon2_params: String,
|
||||
pub aes_nonce: Vec<u8>,
|
||||
pub encryption_version: u32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl EncryptedWallet {
|
||||
fn summary(&self) -> WalletSummary {
|
||||
WalletSummary {
|
||||
name: self.name.clone(),
|
||||
address: self.address.clone(),
|
||||
kind: self.wallet_type,
|
||||
created_at: self.created_at.timestamp(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plaintext inside `encrypted_data`. Secret; wiped on drop, redacted in
|
||||
/// `Debug`, never serialised anywhere but into the cipher.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct WalletData {
|
||||
name: String,
|
||||
keypair: FileKeypair,
|
||||
mnemonic: Option<String>,
|
||||
derivation_path: String,
|
||||
#[serde(default)]
|
||||
metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct FileKeypair {
|
||||
public_key: Vec<u8>,
|
||||
private_key: Vec<u8>,
|
||||
#[serde(default = "legacy_scheme")]
|
||||
scheme: FileScheme,
|
||||
}
|
||||
|
||||
impl Drop for WalletData {
|
||||
fn drop(&mut self) {
|
||||
self.keypair.private_key.zeroize();
|
||||
if let Some(m) = self.mnemonic.as_mut() {
|
||||
m.zeroize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WalletData {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WalletData")
|
||||
.field("name", &self.name)
|
||||
.field("scheme", &self.keypair.scheme)
|
||||
.field("derivation_path", &self.derivation_path)
|
||||
.field("mnemonic", &self.mnemonic.as_ref().map(|_| "[redacted]"))
|
||||
.field("private_key", &"[redacted]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn argon2() -> Argon2<'static> {
|
||||
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32))
|
||||
.expect("the frozen Argon2 profile is valid");
|
||||
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
|
||||
}
|
||||
|
||||
/// Derive the AES key for a file. The PHC string must name argon2id v19 with
|
||||
/// exactly the frozen profile; a different profile is a different format.
|
||||
fn derive_key(
|
||||
encrypted: &EncryptedWallet,
|
||||
password: &str,
|
||||
) -> Result<Zeroizing<[u8; 32]>, KeystoreError> {
|
||||
let parsed = PasswordHash::new(&encrypted.argon2_params)
|
||||
.map_err(|e| KeystoreError::Format(format!("argon2 params: {e}")))?;
|
||||
if parsed.algorithm.as_str() != "argon2id" {
|
||||
return Err(KeystoreError::Format("not argon2id".into()));
|
||||
}
|
||||
if parsed.hash.is_some() {
|
||||
// A digest in the PHC string is the AES key on disk. The CLI refuses
|
||||
// such files, and so do we.
|
||||
return Err(KeystoreError::Integrity(
|
||||
"the file embeds its own key digest".into(),
|
||||
));
|
||||
}
|
||||
let version = parsed.version.unwrap_or(Version::V0x13 as u32);
|
||||
if version != Version::V0x13 as u32 {
|
||||
return Err(KeystoreError::Format(format!("argon2 version {version}")));
|
||||
}
|
||||
let m = parsed.params.get_decimal("m").unwrap_or(ARGON2_M_COST);
|
||||
let t = parsed.params.get_decimal("t").unwrap_or(ARGON2_T_COST);
|
||||
let p = parsed.params.get_decimal("p").unwrap_or(ARGON2_P_COST);
|
||||
if (m, t, p) != (ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST) {
|
||||
return Err(KeystoreError::Format(format!(
|
||||
"argon2 profile m={m},t={t},p={p}"
|
||||
)));
|
||||
}
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
argon2()
|
||||
.hash_password_into(password.as_bytes(), &encrypted.argon2_salt, key.as_mut())
|
||||
.map_err(|e| KeystoreError::Format(format!("argon2: {e}")))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn ss58_address(public_key: &[u8]) -> String {
|
||||
ss58::encode(FILE_SS58_PREFIX, &account_id(public_key))
|
||||
}
|
||||
|
||||
/// Decrypt a file. Wrong password and corrupted ciphertext are the same
|
||||
/// error, deliberately: AES-GCM cannot tell them apart and neither can we.
|
||||
fn decrypt(encrypted: &EncryptedWallet, password: &str) -> Result<WalletData, KeystoreError> {
|
||||
match encrypted.encryption_version {
|
||||
1 | 2 => {}
|
||||
v => return Err(KeystoreError::Format(format!("encryption version {v}"))),
|
||||
}
|
||||
if encrypted.wallet_type == WalletKind::Cold {
|
||||
return Err(KeystoreError::Format(
|
||||
"a cold wallet file carries no key material".into(),
|
||||
));
|
||||
}
|
||||
let key = derive_key(encrypted, password)?;
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_ref()));
|
||||
let nonce: [u8; 12] = encrypted.aes_nonce[..]
|
||||
.try_into()
|
||||
.map_err(|_| KeystoreError::Format("nonce is not 12 bytes".into()))?;
|
||||
let mut plain = Zeroizing::new(
|
||||
cipher
|
||||
.decrypt(&Nonce::from(nonce), encrypted.encrypted_data.as_ref())
|
||||
.map_err(|_| KeystoreError::WrongPassword)?,
|
||||
);
|
||||
let data: WalletData = serde_json::from_slice(&plain)
|
||||
.map_err(|e| KeystoreError::Format(format!("wallet data: {e}")))?;
|
||||
plain.zeroize();
|
||||
let derived = ss58_address(&data.keypair.public_key);
|
||||
if derived != encrypted.address {
|
||||
return Err(KeystoreError::Integrity(
|
||||
"stored address does not match the decrypted key".into(),
|
||||
));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn encrypt(data: &WalletData, password: &str) -> Result<EncryptedWallet, KeystoreError> {
|
||||
let mut salt = [0u8; 16];
|
||||
getrandom::fill(&mut salt).map_err(|e| KeystoreError::Io(e.to_string()))?;
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::fill(&mut nonce).map_err(|e| KeystoreError::Io(e.to_string()))?;
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
argon2()
|
||||
.hash_password_into(password.as_bytes(), &salt, key.as_mut())
|
||||
.map_err(|e| KeystoreError::Format(format!("argon2: {e}")))?;
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_ref()));
|
||||
let plain =
|
||||
Zeroizing::new(serde_json::to_vec(data).map_err(|e| KeystoreError::Format(e.to_string()))?);
|
||||
let encrypted_data = cipher
|
||||
.encrypt(&Nonce::from(nonce), plain.as_ref())
|
||||
.map_err(|e| KeystoreError::Format(format!("aes-gcm: {e}")))?;
|
||||
// The salt in the PHC string is the standard unpadded base64 the CLI
|
||||
// writes; the same bytes sit in `argon2_salt` for the decryptor.
|
||||
let salt_b64 = argon2::password_hash::SaltString::encode_b64(&salt)
|
||||
.map_err(|e| KeystoreError::Format(e.to_string()))?;
|
||||
Ok(EncryptedWallet {
|
||||
name: data.name.clone(),
|
||||
address: ss58_address(&data.keypair.public_key),
|
||||
wallet_type: WalletKind::Hot,
|
||||
encrypted_data,
|
||||
kyber_ciphertext: vec![],
|
||||
kyber_public_key: vec![],
|
||||
argon2_salt: salt.to_vec(),
|
||||
argon2_params: format!(
|
||||
"$argon2id$v=19$m={ARGON2_M_COST},t={ARGON2_T_COST},p={ARGON2_P_COST}${}",
|
||||
salt_b64.as_str()
|
||||
),
|
||||
aes_nonce: nonce.to_vec(),
|
||||
encryption_version: 2,
|
||||
created_at: chrono::Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A wallet name is a filename. The CLI's rule, kept exactly: no separators,
|
||||
/// no traversal, none of the characters Windows reserves, no control
|
||||
/// characters, not empty, not a dot-name.
|
||||
fn validate_name(name: &str) -> Result<(), KeystoreError> {
|
||||
let bad = |why: &str| Err(KeystoreError::InvalidName(format!("{name:?}: {why}")));
|
||||
if name.is_empty() || name.len() > 128 {
|
||||
return bad("empty or too long");
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return bad("dot name");
|
||||
}
|
||||
if name.chars().any(|c| {
|
||||
matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control()
|
||||
}) {
|
||||
return bad("contains a separator or a reserved character");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A directory of wallet files. Default location is the platform app-data
|
||||
/// directory; the CLI's own `~/.quantus/wallets` is a valid directory too,
|
||||
/// and pointing this at it is how the two share wallets in place.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileKeystore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl FileKeystore {
|
||||
pub fn in_dir(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
/// `<app data>/wallets` for this platform, e.g.
|
||||
/// `~/.local/share/blackbeard-wallet/wallets` on Linux.
|
||||
pub fn default_dir() -> Option<PathBuf> {
|
||||
directories::ProjectDirs::from("cafe", "lair", "blackbeard-wallet")
|
||||
.map(|d| d.data_dir().join("wallets"))
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
fn path_for(&self, name: &str) -> Result<PathBuf, KeystoreError> {
|
||||
validate_name(name)?;
|
||||
Ok(self.dir.join(format!("{name}.json")))
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<EncryptedWallet, KeystoreError> {
|
||||
let meta = fs::symlink_metadata(path)
|
||||
.map_err(|e| KeystoreError::Io(format!("{}: {e}", path.display())))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(KeystoreError::Io(format!(
|
||||
"{}: refusing to follow a symlink",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let text =
|
||||
fs::read(path).map_err(|e| KeystoreError::Io(format!("{}: {e}", path.display())))?;
|
||||
serde_json::from_slice(&text)
|
||||
.map_err(|e| KeystoreError::Format(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
fn load(&self, name: &str) -> Result<EncryptedWallet, KeystoreError> {
|
||||
let path = self.path_for(name)?;
|
||||
if !path.exists() {
|
||||
return Err(KeystoreError::NotFound(name.to_owned()));
|
||||
}
|
||||
Self::read_file(&path)
|
||||
}
|
||||
|
||||
/// Write through a fresh temporary file and a rename, owner-only, never
|
||||
/// over an existing wallet. The temporary name is unpredictable so a
|
||||
/// planted symlink cannot redirect the write.
|
||||
fn write_new(&self, wallet: &EncryptedWallet) -> Result<PathBuf, KeystoreError> {
|
||||
let path = self.path_for(&wallet.name)?;
|
||||
if path.exists() || fs::symlink_metadata(&path).is_ok() {
|
||||
return Err(KeystoreError::AlreadyExists(wallet.name.clone()));
|
||||
}
|
||||
fs::create_dir_all(&self.dir)
|
||||
.map_err(|e| KeystoreError::Io(format!("{}: {e}", self.dir.display())))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
let text =
|
||||
serde_json::to_vec_pretty(wallet).map_err(|e| KeystoreError::Format(e.to_string()))?;
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.prefix(".wallet-")
|
||||
.suffix(".tmp")
|
||||
.tempfile_in(&self.dir)
|
||||
.map_err(|e| KeystoreError::Io(e.to_string()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| KeystoreError::Io(e.to_string()))?;
|
||||
}
|
||||
tmp.write_all(&text)
|
||||
.map_err(|e| KeystoreError::Io(e.to_string()))?;
|
||||
tmp.as_file()
|
||||
.sync_all()
|
||||
.map_err(|e| KeystoreError::Io(e.to_string()))?;
|
||||
// `persist_noclobber` is a link-then-unlink, which fails rather than
|
||||
// replaces if the destination appeared in the meantime.
|
||||
tmp.persist_noclobber(&path)
|
||||
.map_err(|e| KeystoreError::Io(format!("{}: {}", path.display(), e.error)))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn open_decrypted(
|
||||
&self,
|
||||
encrypted: &EncryptedWallet,
|
||||
password: &str,
|
||||
) -> Result<OpenedWallet, KeystoreError> {
|
||||
let data = decrypt(encrypted, password)?;
|
||||
let scheme: SignatureScheme = data.keypair.scheme.into();
|
||||
let signer =
|
||||
MlDsaSigner::from_raw(scheme, &data.keypair.public_key, &data.keypair.private_key)
|
||||
.map_err(|e| match e {
|
||||
KeyError::Derivation(d) => {
|
||||
KeystoreError::Integrity(format!("key halves do not correspond: {d}"))
|
||||
}
|
||||
other => KeystoreError::Key(other),
|
||||
})?;
|
||||
Ok(OpenedWallet {
|
||||
summary: encrypted.summary(),
|
||||
signer: Box::new(signer),
|
||||
derivation_path: data.derivation_path.clone(),
|
||||
mnemonic: data
|
||||
.mnemonic
|
||||
.as_deref()
|
||||
.map(|m| Zeroizing::new(m.to_owned())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Keystore for FileKeystore {
|
||||
fn list(&self) -> Result<Vec<WalletSummary>, KeystoreError> {
|
||||
if !self.dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for entry in fs::read_dir(&self.dir).map_err(|e| KeystoreError::Io(e.to_string()))? {
|
||||
let path = entry.map_err(|e| KeystoreError::Io(e.to_string()))?.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
match Self::read_file(&path) {
|
||||
Ok(w) => out.push(w.summary()),
|
||||
// A file that is not a wallet is not an error for the listing;
|
||||
// it is logged and skipped, so one stray file cannot hide the rest.
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "skipping unreadable wallet file")
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn open(&self, name: &str, password: &str) -> Result<OpenedWallet, KeystoreError> {
|
||||
let encrypted = self.load(name)?;
|
||||
self.open_decrypted(&encrypted, password)
|
||||
}
|
||||
|
||||
fn create(
|
||||
&self,
|
||||
wallet: NewWallet<'_>,
|
||||
password: &str,
|
||||
) -> Result<WalletSummary, KeystoreError> {
|
||||
validate_name(wallet.name)?;
|
||||
// Refuse key halves that do not correspond before writing anything.
|
||||
MlDsaSigner::from_raw(wallet.scheme, wallet.public_key, wallet.secret_key)?;
|
||||
let data = WalletData {
|
||||
name: wallet.name.to_owned(),
|
||||
keypair: FileKeypair {
|
||||
public_key: wallet.public_key.to_vec(),
|
||||
private_key: wallet.secret_key.to_vec(),
|
||||
scheme: wallet.scheme.into(),
|
||||
},
|
||||
mnemonic: wallet.mnemonic.map(str::to_owned),
|
||||
derivation_path: wallet.derivation_path.to_owned(),
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
let encrypted = encrypt(&data, password)?;
|
||||
self.write_new(&encrypted)?;
|
||||
Ok(encrypted.summary())
|
||||
}
|
||||
|
||||
fn import_file(&self, path: &Path, password: &str) -> Result<WalletSummary, KeystoreError> {
|
||||
let encrypted = Self::read_file(path)?;
|
||||
// Prove it opens and that its address is honest before it gets a name here.
|
||||
let _ = self.open_decrypted(&encrypted, password)?;
|
||||
self.write_new(&encrypted)?;
|
||||
Ok(encrypted.summary())
|
||||
}
|
||||
|
||||
fn export_file(&self, name: &str, dest: &Path) -> Result<(), KeystoreError> {
|
||||
let encrypted = self.load(name)?;
|
||||
let text = serde_json::to_vec_pretty(&encrypted)
|
||||
.map_err(|e| KeystoreError::Format(e.to_string()))?;
|
||||
fs::write(dest, text).map_err(|e| KeystoreError::Io(format!("{}: {e}", dest.display())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keys::{MlDsa, QUANTUS_EXTRINSIC_CONTEXT, seed_from_mnemonic};
|
||||
use wallet_core::KeyScheme;
|
||||
|
||||
const DEV_PHRASE: &str =
|
||||
"bottom drive obey lake curtain smoke basket hold race lonely fit walk";
|
||||
|
||||
/// The secret half of a derived key, for writing a file. Only tests
|
||||
/// need to reach past the `Signer` boundary like this.
|
||||
fn raw_secret(
|
||||
seed: &wallet_core::Seed,
|
||||
scheme: SignatureScheme,
|
||||
path: &str,
|
||||
) -> Zeroizing<Vec<u8>> {
|
||||
let mut holder = qp_rusty_crystals_dilithium::SensitiveBytes64::zeroed();
|
||||
holder.as_mut_bytes().copy_from_slice(seed.as_bytes());
|
||||
match scheme {
|
||||
SignatureScheme::MlDsa65 => {
|
||||
let k = qp_rusty_crystals_hdwallet::ml_dsa_65::derive_key_from_seed(&holder, path)
|
||||
.unwrap();
|
||||
Zeroizing::new(k.secret().to_bytes().to_vec())
|
||||
}
|
||||
SignatureScheme::MlDsa87 => {
|
||||
let k = qp_rusty_crystals_hdwallet::ml_dsa_87::derive_key_from_seed(&holder, path)
|
||||
.unwrap();
|
||||
Zeroizing::new(k.secret().to_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli-wallets")
|
||||
}
|
||||
|
||||
fn fixture_names() -> Vec<String> {
|
||||
let mut names: Vec<String> = fs::read_dir(fixtures())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
e.path()
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
})
|
||||
.filter(|n| n != "readme")
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names.len(), 8, "{names:?}");
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_every_cli_fixture_and_reproduces_its_address() {
|
||||
let ks = FileKeystore::in_dir(fixtures());
|
||||
for name in fixture_names() {
|
||||
let opened = ks.open(&name, "").unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let derived = ss58::encode(189, &opened.signer.account_id());
|
||||
assert_eq!(derived, opened.summary.address, "{name}");
|
||||
let expected_scheme = if name.contains("65") {
|
||||
SignatureScheme::MlDsa65
|
||||
} else {
|
||||
SignatureScheme::MlDsa87
|
||||
};
|
||||
assert_eq!(opened.signer.scheme(), expected_scheme, "{name}");
|
||||
assert!(
|
||||
opened.derivation_path.starts_with("m/44'/189189'/"),
|
||||
"{name}: {}",
|
||||
opened.derivation_path
|
||||
);
|
||||
let phrase = opened
|
||||
.mnemonic
|
||||
.as_ref()
|
||||
.expect("CLI HD wallets carry the phrase");
|
||||
assert_eq!(phrase.as_str(), DEV_PHRASE, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixture_signer_signs_like_a_derived_one_verifies() {
|
||||
let ks = FileKeystore::in_dir(fixtures());
|
||||
let opened = ks.open("v-ml-dsa-65-89001", "").unwrap();
|
||||
let sig = opened
|
||||
.signer
|
||||
.sign(b"payload", QUANTUS_EXTRINSIC_CONTEXT)
|
||||
.unwrap();
|
||||
assert!(crate::keys::verify(
|
||||
SignatureScheme::MlDsa65,
|
||||
opened.signer.public_key(),
|
||||
b"payload",
|
||||
&sig,
|
||||
QUANTUS_EXTRINSIC_CONTEXT
|
||||
));
|
||||
// And it is the same key the seed derives at that path.
|
||||
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
|
||||
let derived = MlDsa::ml_dsa_65()
|
||||
.derive(&seed, &opened.derivation_path)
|
||||
.unwrap();
|
||||
assert_eq!(derived.public_key(), opened.signer.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_fixtures_without_decrypting_anything() {
|
||||
let ks = FileKeystore::in_dir(fixtures());
|
||||
let list = ks.list().unwrap();
|
||||
assert_eq!(list.len(), 8);
|
||||
assert!(
|
||||
list.iter()
|
||||
.all(|w| w.address.starts_with("qz") && w.kind == WalletKind::Hot)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_password_is_wrong_password() {
|
||||
let ks = FileKeystore::in_dir(fixtures());
|
||||
assert!(matches!(
|
||||
ks.open("v-ml-dsa-87-89000", "wrong"),
|
||||
Err(KeystoreError::WrongPassword)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_address_is_refused() {
|
||||
let ks = FileKeystore::in_dir(fixtures());
|
||||
let mut w = ks.load("v-ml-dsa-65-89000").unwrap();
|
||||
w.address = "qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6".into();
|
||||
assert!(matches!(
|
||||
ks.open_decrypted(&w, ""),
|
||||
Err(KeystoreError::Integrity(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_embedding_its_digest_is_refused() {
|
||||
let ks = FileKeystore::in_dir(fixtures());
|
||||
let mut w = ks.load("v-ml-dsa-65-89000").unwrap();
|
||||
w.argon2_params
|
||||
.push_str("$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
assert!(matches!(
|
||||
ks.open_decrypted(&w, ""),
|
||||
Err(KeystoreError::Integrity(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_a_new_wallet_and_never_writes_plaintext() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ks = FileKeystore::in_dir(dir.path());
|
||||
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
|
||||
let signer = MlDsa::ml_dsa_65()
|
||||
.derive(&seed, "m/44'/189189'/0'/0'/1'")
|
||||
.unwrap();
|
||||
let secret = raw_secret(&seed, SignatureScheme::MlDsa65, "m/44'/189189'/0'/0'/1'");
|
||||
let summary = ks
|
||||
.create(
|
||||
NewWallet {
|
||||
name: "roundtrip",
|
||||
scheme: SignatureScheme::MlDsa65,
|
||||
derivation_path: "m/44'/189189'/0'/0'/1'",
|
||||
public_key: signer.public_key(),
|
||||
secret_key: &secret,
|
||||
mnemonic: Some(DEV_PHRASE),
|
||||
},
|
||||
"hunter2",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
summary.address,
|
||||
"qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6"
|
||||
);
|
||||
|
||||
let raw = fs::read(dir.path().join("roundtrip.json")).unwrap();
|
||||
for word in DEV_PHRASE.split(' ') {
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&raw).contains(&format!("\"{word}")),
|
||||
"phrase word on disk"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!raw.windows(32).any(|w| w == &secret[..32]),
|
||||
"secret key bytes on disk"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(dir.path().join("roundtrip.json"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
let opened = ks.open("roundtrip", "hunter2").unwrap();
|
||||
assert_eq!(opened.signer.public_key(), signer.public_key());
|
||||
assert_eq!(
|
||||
opened.mnemonic.as_deref().map(|m| m.as_str()),
|
||||
Some(DEV_PHRASE)
|
||||
);
|
||||
assert!(matches!(
|
||||
ks.open("roundtrip", "hunter3"),
|
||||
Err(KeystoreError::WrongPassword)
|
||||
));
|
||||
assert!(matches!(
|
||||
ks.create(
|
||||
NewWallet {
|
||||
name: "roundtrip",
|
||||
scheme: SignatureScheme::MlDsa65,
|
||||
derivation_path: "m/",
|
||||
public_key: signer.public_key(),
|
||||
secret_key: &secret,
|
||||
mnemonic: None,
|
||||
},
|
||||
"x"
|
||||
),
|
||||
Err(KeystoreError::AlreadyExists(_))
|
||||
));
|
||||
assert_eq!(ks.list().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_a_cli_file_and_exports_it_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ks = FileKeystore::in_dir(dir.path());
|
||||
let src = fixtures().join("v-ml-dsa-87-89100.json");
|
||||
let summary = ks.import_file(&src, "").unwrap();
|
||||
assert_eq!(summary.name, "v-ml-dsa-87-89100");
|
||||
assert!(matches!(
|
||||
ks.import_file(&src, "nope"),
|
||||
Err(KeystoreError::WrongPassword)
|
||||
));
|
||||
let out = dir.path().join("exported.json");
|
||||
ks.export_file("v-ml-dsa-87-89100", &out).unwrap();
|
||||
let a: EncryptedWallet = serde_json::from_slice(&fs::read(&src).unwrap()).unwrap();
|
||||
let b: EncryptedWallet = serde_json::from_slice(&fs::read(&out).unwrap()).unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_bad_names_and_symlinks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ks = FileKeystore::in_dir(dir.path());
|
||||
for bad in ["", "..", "a/b", "c:evil", "x*y", "tab\there"] {
|
||||
assert!(
|
||||
matches!(ks.open(bad, ""), Err(KeystoreError::InvalidName(_))),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::create_dir_all(dir.path()).unwrap();
|
||||
std::os::unix::fs::symlink(
|
||||
fixtures().join("v-ml-dsa-65-89000.json"),
|
||||
dir.path().join("link.json"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(ks.open("link", ""), Err(KeystoreError::Io(_))));
|
||||
}
|
||||
}
|
||||
|
||||
/// The other direction of the acceptance: a file written here lists in
|
||||
/// the real CLI. Runs only when `QUANTUS_CLI` names the binary.
|
||||
#[test]
|
||||
#[ignore = "needs quantus-cli; set QUANTUS_CLI=/path/to/quantus"]
|
||||
fn a_wallet_written_here_lists_in_quantus_cli() {
|
||||
let cli = std::env::var("QUANTUS_CLI").expect("QUANTUS_CLI");
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let dir = home.path().join(".quantus/wallets");
|
||||
let ks = FileKeystore::in_dir(&dir);
|
||||
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
|
||||
let signer = MlDsa::ml_dsa_87()
|
||||
.derive(&seed, "m/44'/189189'/0'/0'/0'")
|
||||
.unwrap();
|
||||
let secret = raw_secret(&seed, SignatureScheme::MlDsa87, "m/44'/189189'/0'/0'/0'");
|
||||
ks.create(
|
||||
NewWallet {
|
||||
name: "written-here",
|
||||
scheme: SignatureScheme::MlDsa87,
|
||||
derivation_path: "m/44'/189189'/0'/0'/0'",
|
||||
public_key: signer.public_key(),
|
||||
secret_key: &secret,
|
||||
mnemonic: Some(DEV_PHRASE),
|
||||
},
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
let out = std::process::Command::new(cli)
|
||||
.env("HOME", home.path())
|
||||
.args(["wallet", "list"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("written-here"), "{text}");
|
||||
assert!(
|
||||
text.contains("qzjrYTUnnE5NduTZKxe9dESCMTZg7nTueKM3bwhnkRdD1iYV4"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
//! `wallet-core` call.
|
||||
|
||||
pub mod keys;
|
||||
pub mod keystore;
|
||||
|
||||
+21299
File diff suppressed because it is too large
Load Diff
+26691
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
# quantus-cli wallet fixtures
|
||||
|
||||
Real wallet files, all encrypted with the **empty password** and all holding
|
||||
the public polkadot-js dev phrase (`bottom drive obey lake curtain smoke
|
||||
basket hold race lonely fit walk`), so nothing here is secret.
|
||||
|
||||
- `v-ml-dsa-{65,87}-89xxx.json`: written by `quantus wallet import` (CLI 2.2.2)
|
||||
on 2026-09-15, one per scheme and path; the trailing digits are the path's
|
||||
account/change/index (`89000` = `0'/0'/0'`, `89001` = `0'/0'/1'`,
|
||||
`89100` = `1'/0'/0'`).
|
||||
- `extension-dev_ml_dsa_{65,87}.json`: the fixtures the polkadot-js
|
||||
extension fork (`Quantus-Network/common`, util-crypto) tests against,
|
||||
written by an earlier CLI 2.x without `wallet_type`.
|
||||
|
||||
`keystore.rs` must open every one of them, and a wallet it writes must open in
|
||||
the CLI. If the CLI's format changes, add the new file here rather than
|
||||
editing an old one.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -121,6 +121,28 @@ pub struct AccountRef {
|
||||
pub scheme: SignatureScheme,
|
||||
}
|
||||
|
||||
/// Whether a stored wallet holds key material (hot) or only an address whose
|
||||
/// keys live on an air-gapped device (cold). Mirrors quantus-cli's file field.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export)]
|
||||
pub enum WalletKind {
|
||||
#[default]
|
||||
Hot,
|
||||
Cold,
|
||||
}
|
||||
|
||||
/// A wallet file as the UI lists it: everything public, nothing secret.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct WalletSummary {
|
||||
pub name: String,
|
||||
pub address: String,
|
||||
pub kind: WalletKind,
|
||||
/// Unix seconds; the file's `created_at`.
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// What the app tells the UI about itself at startup.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Whether a stored wallet holds key material (hot) or only an address whose
|
||||
* keys live on an air-gapped device (cold). Mirrors quantus-cli's file field.
|
||||
*/
|
||||
export type WalletKind = "hot" | "cold";
|
||||
@@ -0,0 +1,11 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { WalletKind } from "./WalletKind";
|
||||
|
||||
/**
|
||||
* A wallet file as the UI lists it: everything public, nothing secret.
|
||||
*/
|
||||
export type WalletSummary = { name: string, address: string, kind: WalletKind,
|
||||
/**
|
||||
* Unix seconds; the file's `created_at`.
|
||||
*/
|
||||
created_at: bigint, };
|
||||
Reference in New Issue
Block a user