Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cfd88b39b
|
||
|
|
4ea85465c6
|
@@ -43,10 +43,13 @@ valuable half of the history.
|
||||
legacy, plus the file's own keypair if it sits at another path. The file
|
||||
records the ML-DSA-65 keypair (`paths::CURRENT_SCHEME`); the rest is
|
||||
derived on every open (`keystore::accounts_for_phrase`).
|
||||
- Any number of wallets are open at once (#66). Nothing may read "the"
|
||||
wallet: session status lists `wallets`, each account names its wallet,
|
||||
`lock` takes an optional wallet, `with_seed` and `wormhole_summary` take
|
||||
one. The idle rule locks them all.
|
||||
- Any number of wallets are open at once (#66), and one password opens
|
||||
them all (#84): every wallet file is encrypted with it, in quantus-cli's
|
||||
format, and the session holds it while open so a new wallet is written
|
||||
under it without asking. There is no per-wallet password, lock or open.
|
||||
Nothing may read "the" wallet: session status lists `wallets`, each
|
||||
account names its wallet, `with_seed` and `wormhole_summary` take one.
|
||||
The idle rule locks them all and wipes the password.
|
||||
- The keystore file format is quantus-cli's (Argon2id + AES-256-GCM). It is
|
||||
shared with the CLI and the browser extension; do not invent a second one.
|
||||
- `cargo test` regenerates `ui/src/api/generated` via ts-rs; commit the result
|
||||
|
||||
Generated
+5
-5
@@ -6956,7 +6956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-app"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bs58",
|
||||
@@ -6985,7 +6985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-core"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake2",
|
||||
@@ -7003,7 +7003,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-data"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -7039,7 +7039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-entities"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -7049,7 +7049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-near"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"bip39",
|
||||
"bs58",
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -11,10 +11,10 @@ authors = ["Rob Thijssen <rob@lair.cafe>"]
|
||||
repository = "https://git.lair.cafe/blackbeard/wallet"
|
||||
|
||||
[workspace.dependencies]
|
||||
wallet-entities = { path = "crates/wallet-entities", version = "=0.4.0" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.4.0" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.4.0" }
|
||||
wallet-near = { path = "crates/wallet-near", version = "=0.4.0" }
|
||||
wallet-entities = { path = "crates/wallet-entities", version = "=0.4.1" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.4.1" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.4.1" }
|
||||
wallet-near = { path = "crates/wallet-near", version = "=0.4.1" }
|
||||
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -25,9 +25,10 @@ use wallet_entities::{
|
||||
AccountBalance, AccountRef, AccountScan, Amount, AssetId, AssetKind, CashoutDestination,
|
||||
ChainId, ChainProfile, ChainStatus, HighSecurityInfo, HistoryCursor, HistoryPage,
|
||||
IntentsSwapQuote, NearAccountSummary, OpenWalletInfo, PendingReversible, PreparedTransferInfo,
|
||||
SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo, WormholeSummary,
|
||||
SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo, UnlockOutcome, WormholeSummary,
|
||||
};
|
||||
use wallet_entities::{AppInfo, CreationChallenge, CreationStart, WalletError, WalletSummary};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::state::{AppState, PendingCreation};
|
||||
|
||||
@@ -142,9 +143,10 @@ pub fn wallet_create_begin(state: State<'_, AppState>) -> Result<CreationStart,
|
||||
pub fn wallet_create_confirm(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
password: String,
|
||||
answers: Vec<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<WalletSummary, WalletError> {
|
||||
let password = password_for_new_wallet(&state, password)?;
|
||||
let pending = state
|
||||
.pending_creation
|
||||
.lock()
|
||||
@@ -156,7 +158,7 @@ pub fn wallet_create_confirm(
|
||||
"the backup check did not match; start again".into(),
|
||||
));
|
||||
}
|
||||
state
|
||||
let summary = state
|
||||
.keystore
|
||||
.create_from_phrase(
|
||||
&name,
|
||||
@@ -165,7 +167,9 @@ pub fn wallet_create_confirm(
|
||||
wallet_core::paths::CURRENT_SCHEME,
|
||||
None,
|
||||
)
|
||||
.map_err(keystore_error)
|
||||
.map_err(keystore_error)?;
|
||||
adopt_new_wallet(&state, &name, &password)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -182,14 +186,15 @@ pub fn wallet_create_cancel(state: State<'_, AppState>) -> Result<(), WalletErro
|
||||
pub fn wallet_import_phrase(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
password: String,
|
||||
phrase: String,
|
||||
password: Option<String>,
|
||||
) -> Result<WalletSummary, WalletError> {
|
||||
let password = password_for_new_wallet(&state, password)?;
|
||||
let phrase =
|
||||
mnemonic::validate(&phrase).map_err(|e| WalletError::InvalidInput(e.to_string()))?;
|
||||
// The file records the current scheme's keypair, as quantus-cli does;
|
||||
// opening it derives the other scheme's account beside it.
|
||||
state
|
||||
let summary = state
|
||||
.keystore
|
||||
.create_from_phrase(
|
||||
&name,
|
||||
@@ -198,20 +203,27 @@ pub fn wallet_import_phrase(
|
||||
wallet_core::paths::CURRENT_SCHEME,
|
||||
None,
|
||||
)
|
||||
.map_err(keystore_error)
|
||||
.map_err(keystore_error)?;
|
||||
adopt_new_wallet(&state, &name, &password)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Bring in a quantus-cli wallet file.
|
||||
/// Bring in a quantus-cli wallet file: opened with the password the CLI
|
||||
/// set, written here under the wallet's one password.
|
||||
#[tauri::command]
|
||||
pub fn wallet_import_file(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
password: String,
|
||||
file_password: String,
|
||||
password: Option<String>,
|
||||
) -> Result<WalletSummary, WalletError> {
|
||||
state
|
||||
let password = password_for_new_wallet(&state, password)?;
|
||||
let summary = state
|
||||
.keystore
|
||||
.import_file(std::path::Path::new(&path), &password)
|
||||
.map_err(keystore_error)
|
||||
.import_file(std::path::Path::new(&path), &file_password, &password)
|
||||
.map_err(keystore_error)?;
|
||||
adopt_new_wallet(&state, &summary.name, &password)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// The human checkphrase for an address.
|
||||
@@ -221,17 +233,13 @@ pub fn checkphrase(address: String) -> Result<String, WalletError> {
|
||||
Ok(mnemonic::checkphrase(&address))
|
||||
}
|
||||
|
||||
/// Open a wallet: decrypt it, hand the signer and the seed to the session,
|
||||
/// drop the phrase. Anything already open is dropped first.
|
||||
#[tauri::command]
|
||||
pub fn unlock(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
password: String,
|
||||
) -> Result<SessionStatusInfo, WalletError> {
|
||||
/// Decrypt one wallet and hand its signers and seed to the session. The
|
||||
/// phrase is dropped; the account numbers added in an earlier run come back
|
||||
/// from the seed.
|
||||
fn open_into_session(state: &AppState, name: &str, password: &str) -> Result<(), WalletError> {
|
||||
let opened = state
|
||||
.keystore
|
||||
.open(&name, &password)
|
||||
.open(name, password)
|
||||
.map_err(keystore_error)?;
|
||||
let seed = match opened.mnemonic.as_deref() {
|
||||
Some(phrase) => Some(
|
||||
@@ -243,7 +251,7 @@ pub fn unlock(
|
||||
let extra: Vec<wallet_core::OpenedAccount> = match (&seed, state.settings.load()) {
|
||||
(Some(seed), Ok(settings)) => settings
|
||||
.wallet_accounts
|
||||
.get(&name)
|
||||
.get(name)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|&n| wallet_data::keystore::accounts_at(seed, n))
|
||||
@@ -261,12 +269,142 @@ pub fn unlock(
|
||||
if !extra.is_empty() {
|
||||
state
|
||||
.session
|
||||
.add_accounts(&name, extra)
|
||||
.add_accounts(name, extra)
|
||||
.map_err(session_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unlock: one password opens every wallet. A file it does not open (one
|
||||
/// the CLI wrote under a password of its own) is named in `unopened` and
|
||||
/// can be re-keyed; a password that opens nothing is the wrong password.
|
||||
/// Anything already open is dropped first.
|
||||
#[tauri::command]
|
||||
pub fn unlock(state: State<'_, AppState>, password: String) -> Result<UnlockOutcome, WalletError> {
|
||||
let password = Zeroizing::new(password);
|
||||
let wallets = state.keystore.list().map_err(keystore_error)?;
|
||||
if wallets.is_empty() {
|
||||
return Err(WalletError::Refused(
|
||||
"no wallets yet: create, restore or import one".into(),
|
||||
));
|
||||
}
|
||||
state.session.lock().map_err(session_error)?;
|
||||
let mut unopened = vec![];
|
||||
for w in &wallets {
|
||||
match open_into_session(&state, &w.name, &password) {
|
||||
Ok(()) => {}
|
||||
Err(WalletError::WrongPassword) => unopened.push(w.name.clone()),
|
||||
Err(e) => {
|
||||
tracing::warn!(wallet = %w.name, error = %e, "wallet did not open");
|
||||
unopened.push(w.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if unopened.len() == wallets.len() {
|
||||
return Err(WalletError::WrongPassword);
|
||||
}
|
||||
state
|
||||
.session
|
||||
.set_password(password)
|
||||
.map_err(session_error)?;
|
||||
Ok(UnlockOutcome {
|
||||
session: session_status(state)?,
|
||||
unopened,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-encrypt a wallet that kept a password of its own under the session's
|
||||
/// password, then open it. The one-time step for a file the CLI wrote.
|
||||
#[tauri::command]
|
||||
pub fn wallet_rekey(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
old_password: String,
|
||||
) -> Result<SessionStatusInfo, WalletError> {
|
||||
let password = session_password(&state)?;
|
||||
state
|
||||
.keystore
|
||||
.rekey(&name, &old_password, &password)
|
||||
.map_err(keystore_error)?;
|
||||
open_into_session(&state, &name, &password)?;
|
||||
tracing::info!(wallet = %name, "re-keyed under the wallet password");
|
||||
session_status(state)
|
||||
}
|
||||
|
||||
/// Change the one password: every wallet file is re-encrypted, all or none.
|
||||
#[tauri::command]
|
||||
pub fn password_change(
|
||||
state: State<'_, AppState>,
|
||||
current: String,
|
||||
new: String,
|
||||
) -> Result<(), WalletError> {
|
||||
let held = session_password(&state)?;
|
||||
if *held != current {
|
||||
return Err(WalletError::WrongPassword);
|
||||
}
|
||||
if new.is_empty() {
|
||||
return Err(WalletError::InvalidInput(
|
||||
"the new password is empty".into(),
|
||||
));
|
||||
}
|
||||
let n = state
|
||||
.keystore
|
||||
.rekey_all(¤t, &new)
|
||||
.map_err(keystore_error)?;
|
||||
state
|
||||
.session
|
||||
.set_password(Zeroizing::new(new))
|
||||
.map_err(session_error)?;
|
||||
tracing::info!(wallets = n, "password changed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The session's password, for anything that writes a wallet file.
|
||||
fn session_password(state: &AppState) -> Result<Zeroizing<String>, WalletError> {
|
||||
state
|
||||
.session
|
||||
.password()
|
||||
.map_err(session_error)?
|
||||
.ok_or_else(|| WalletError::Refused("unlock first".into()))
|
||||
}
|
||||
|
||||
/// The password a new wallet file is written with: the session's while
|
||||
/// unlocked, or the one being set on first run, when no wallet exists yet.
|
||||
/// With wallets on disk and the session locked there is no right answer,
|
||||
/// and a second password must not come into being.
|
||||
fn password_for_new_wallet(
|
||||
state: &AppState,
|
||||
provided: Option<String>,
|
||||
) -> Result<Zeroizing<String>, WalletError> {
|
||||
if let Some(p) = state.session.password().map_err(session_error)? {
|
||||
return Ok(p);
|
||||
}
|
||||
if !state.keystore.list().map_err(keystore_error)?.is_empty() {
|
||||
return Err(WalletError::Refused("unlock first".into()));
|
||||
}
|
||||
match provided {
|
||||
Some(p) if !p.is_empty() => Ok(Zeroizing::new(p)),
|
||||
_ => Err(WalletError::InvalidInput("choose a password".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// After a wallet is written: open it beside the others, and on first run
|
||||
/// make its password the session's.
|
||||
fn adopt_new_wallet(
|
||||
state: &AppState,
|
||||
name: &str,
|
||||
password: &Zeroizing<String>,
|
||||
) -> Result<(), WalletError> {
|
||||
open_into_session(state, name, password)?;
|
||||
if state.session.password().map_err(session_error)?.is_none() {
|
||||
state
|
||||
.session
|
||||
.set_password(password.clone())
|
||||
.map_err(session_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many consecutive empty account numbers end a scan, as the mobile
|
||||
/// wallet's import uses (BIP-44's gap limit).
|
||||
const ACCOUNT_GAP_LIMIT: u32 = 20;
|
||||
@@ -469,13 +607,10 @@ pub fn wallet_add_account(
|
||||
session_status(state)
|
||||
}
|
||||
|
||||
/// Lock one wallet by name, or every open wallet when none is named.
|
||||
/// Lock: every wallet, together; the password is wiped with them.
|
||||
#[tauri::command]
|
||||
pub fn lock(state: State<'_, AppState>, wallet: Option<String>) -> Result<(), WalletError> {
|
||||
match wallet {
|
||||
Some(name) => state.session.lock_wallet(&name).map_err(session_error),
|
||||
None => state.session.lock().map_err(session_error),
|
||||
}
|
||||
pub fn lock(state: State<'_, AppState>) -> Result<(), WalletError> {
|
||||
state.session.lock().map_err(session_error)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -58,6 +58,8 @@ pub fn run() {
|
||||
commands::wallet_import_file,
|
||||
commands::checkphrase,
|
||||
commands::unlock,
|
||||
commands::wallet_rekey,
|
||||
commands::password_change,
|
||||
commands::wallet_add_account,
|
||||
commands::wallet_discover_accounts,
|
||||
commands::lock,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "blackbeard-wallet",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"identifier": "cafe.lair.blackbeard.wallet",
|
||||
"build": {
|
||||
"frontendDist": "../../ui/dist",
|
||||
|
||||
@@ -83,14 +83,26 @@ pub trait Keystore: Send + Sync {
|
||||
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.
|
||||
/// Copy a wallet file in from elsewhere (a CLI keystore, a backup). It
|
||||
/// is opened with `file_password`, the one the CLI set, and written
|
||||
/// here encrypted with `password`, the one every wallet shares.
|
||||
fn import_file(
|
||||
&self,
|
||||
path: &std::path::Path,
|
||||
file_password: &str,
|
||||
password: &str,
|
||||
) -> Result<WalletSummary, KeystoreError>;
|
||||
|
||||
/// Re-encrypt one wallet from `old` to `new`, in place. For a file that
|
||||
/// arrived with a password of its own.
|
||||
fn rekey(&self, name: &str, old: &str, new: &str) -> Result<(), KeystoreError>;
|
||||
|
||||
/// Re-encrypt every wallet from `old` to `new`. Every file is decrypted
|
||||
/// before any is written, and every new file is written before any
|
||||
/// replaces its old one, so a wrong password or a full disk changes
|
||||
/// nothing. Returns how many wallets were re-encrypted.
|
||||
fn rekey_all(&self, old: &str, new: &str) -> Result<usize, KeystoreError>;
|
||||
|
||||
/// Write the wallet's file, unchanged, to `dest`. It stays encrypted.
|
||||
fn export_file(&self, name: &str, dest: &std::path::Path) -> Result<(), KeystoreError>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wallet_entities::{SignatureScheme, WalletSummary};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{KeyError, OpenedAccount, OpenedWallet, Seed};
|
||||
|
||||
@@ -27,6 +28,10 @@ struct Unlocked {
|
||||
struct Inner {
|
||||
/// In the order they were opened.
|
||||
wallets: Vec<Unlocked>,
|
||||
/// The one password every wallet file is encrypted with, kept while
|
||||
/// the session is open so a wallet added now is written with it and
|
||||
/// nothing asks for it twice. Wiped on lock.
|
||||
password: Option<Zeroizing<String>>,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
@@ -89,6 +94,7 @@ impl Session {
|
||||
idle: Mutex::new(idle),
|
||||
inner: Mutex::new(Inner {
|
||||
wallets: Vec::new(),
|
||||
password: None,
|
||||
last_used: Instant::now(),
|
||||
}),
|
||||
}
|
||||
@@ -123,6 +129,7 @@ impl Session {
|
||||
&& now.saturating_duration_since(inner.last_used) >= self.idle_interval()
|
||||
{
|
||||
inner.wallets.clear();
|
||||
inner.password = None;
|
||||
}
|
||||
let name = opened.summary.name.clone();
|
||||
inner.wallets.retain(|w| w.wallet.name != name);
|
||||
@@ -159,23 +166,27 @@ impl Session {
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
/// Lock every open wallet.
|
||||
/// Lock: every wallet closes and the password is wiped. There is no
|
||||
/// locking of one wallet; one password opens and closes them all.
|
||||
pub fn lock(&self) -> Result<(), SessionError> {
|
||||
self.inner()?.wallets.clear();
|
||||
let mut inner = self.inner()?;
|
||||
inner.wallets.clear();
|
||||
inner.password = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock one wallet; the others stay open.
|
||||
pub fn lock_wallet(&self, name: &str) -> Result<(), SessionError> {
|
||||
let mut inner = self.inner()?;
|
||||
let before = inner.wallets.len();
|
||||
inner.wallets.retain(|w| w.wallet.name != name);
|
||||
if inner.wallets.len() == before {
|
||||
return Err(SessionError::NoSuchWallet(name.to_owned()));
|
||||
}
|
||||
/// Remember the password the open wallets were decrypted with.
|
||||
pub fn set_password(&self, password: Zeroizing<String>) -> Result<(), SessionError> {
|
||||
self.inner()?.password = Some(password);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The session's password, for writing a new wallet file or checking a
|
||||
/// change; `None` while locked.
|
||||
pub fn password(&self) -> Result<Option<Zeroizing<String>>, SessionError> {
|
||||
Ok(self.inner()?.password.clone())
|
||||
}
|
||||
|
||||
/// Apply the idle rule. Returns `true` if this call locked the session.
|
||||
pub fn expire_if_idle(&self, now: Instant) -> Result<bool, SessionError> {
|
||||
let mut inner = self.inner()?;
|
||||
@@ -183,6 +194,7 @@ impl Session {
|
||||
&& now.saturating_duration_since(inner.last_used) >= self.idle_interval()
|
||||
{
|
||||
inner.wallets.clear();
|
||||
inner.password = None;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
@@ -550,20 +562,16 @@ mod tests {
|
||||
s.unlock(named("spending", &[1, 2]), None, t0).unwrap();
|
||||
assert_eq!(s.status(t0).unwrap().wallets.len(), 2);
|
||||
|
||||
// Locking one leaves the other signing.
|
||||
s.lock_wallet("spending").unwrap();
|
||||
assert!(matches!(
|
||||
s.sign(&a1, b"m", b"", t0),
|
||||
Err(SessionError::NoSuchAccount)
|
||||
));
|
||||
assert!(s.sign(&a3, b"m", b"", t0).is_ok());
|
||||
assert!(matches!(
|
||||
s.lock_wallet("spending"),
|
||||
Err(SessionError::NoSuchWallet(_))
|
||||
));
|
||||
// The password is held while open and wiped by a lock, with the
|
||||
// wallets.
|
||||
s.set_password(Zeroizing::new("pw".into())).unwrap();
|
||||
assert_eq!(
|
||||
s.password().unwrap().as_deref().map(String::as_str),
|
||||
Some("pw")
|
||||
);
|
||||
assert!(s.with_seed("savings", |_| ()).unwrap().is_some());
|
||||
assert!(matches!(
|
||||
s.with_seed("spending", |_| ()),
|
||||
s.with_seed("nobody", |_| ()),
|
||||
Err(SessionError::NoSuchWallet(_))
|
||||
));
|
||||
|
||||
|
||||
@@ -338,6 +338,20 @@ impl FileKeystore {
|
||||
if path.exists() || fs::symlink_metadata(&path).is_ok() {
|
||||
return Err(KeystoreError::AlreadyExists(wallet.name.clone()));
|
||||
}
|
||||
let tmp = self.write_tmp(wallet)?;
|
||||
// `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)
|
||||
}
|
||||
|
||||
/// A wallet written, synced, owner-only, to an unpredictable temporary
|
||||
/// name in the keystore directory, ready to be renamed into place.
|
||||
fn write_tmp(
|
||||
&self,
|
||||
wallet: &EncryptedWallet,
|
||||
) -> Result<tempfile::NamedTempFile, KeystoreError> {
|
||||
fs::create_dir_all(&self.dir)
|
||||
.map_err(|e| KeystoreError::Io(format!("{}: {e}", self.dir.display())))?;
|
||||
#[cfg(unix)]
|
||||
@@ -363,11 +377,7 @@ impl FileKeystore {
|
||||
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)
|
||||
Ok(tmp)
|
||||
}
|
||||
|
||||
/// Derive an account from a phrase and write it, with the phrase inside
|
||||
@@ -524,14 +534,58 @@ impl Keystore for FileKeystore {
|
||||
Ok(encrypted.summary())
|
||||
}
|
||||
|
||||
fn import_file(&self, path: &Path, password: &str) -> Result<WalletSummary, KeystoreError> {
|
||||
fn import_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
file_password: &str,
|
||||
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)?;
|
||||
// Prove it opens and that its address is honest before it gets a
|
||||
// name here; then it is written under the wallet's own password.
|
||||
let _ = self.open_decrypted(&encrypted, file_password)?;
|
||||
let encrypted = if file_password == password {
|
||||
encrypted
|
||||
} else {
|
||||
let data = decrypt(&encrypted, file_password)?;
|
||||
encrypt(&data, password)?
|
||||
};
|
||||
self.write_new(&encrypted)?;
|
||||
Ok(encrypted.summary())
|
||||
}
|
||||
|
||||
fn rekey(&self, name: &str, old: &str, new: &str) -> Result<(), KeystoreError> {
|
||||
let encrypted = self.load(name)?;
|
||||
let data = decrypt(&encrypted, old)?;
|
||||
let fresh = encrypt(&data, new)?;
|
||||
let tmp = self.write_tmp(&fresh)?;
|
||||
tmp.persist(self.path_for(name)?)
|
||||
.map_err(|e| KeystoreError::Io(format!("{name}: {}", e.error)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rekey_all(&self, old: &str, new: &str) -> Result<usize, KeystoreError> {
|
||||
// Decrypt everything first: a file that does not open with `old`
|
||||
// stops the change before a byte is written.
|
||||
let mut fresh = vec![];
|
||||
for summary in self.list()? {
|
||||
let encrypted = self.load(&summary.name)?;
|
||||
let data = decrypt(&encrypted, old)?;
|
||||
fresh.push((summary.name, encrypt(&data, new)?));
|
||||
}
|
||||
// Then every new file, then every rename: a failure in the first
|
||||
// loop leaves the old files untouched.
|
||||
let mut staged = vec![];
|
||||
for (name, wallet) in &fresh {
|
||||
staged.push((self.path_for(name)?, self.write_tmp(wallet)?));
|
||||
}
|
||||
for (path, tmp) in staged {
|
||||
tmp.persist(&path)
|
||||
.map_err(|e| KeystoreError::Io(format!("{}: {}", path.display(), e.error)))?;
|
||||
}
|
||||
Ok(fresh.len())
|
||||
}
|
||||
|
||||
fn export_file(&self, name: &str, dest: &Path) -> Result<(), KeystoreError> {
|
||||
let encrypted = self.load(name)?;
|
||||
let text = serde_json::to_vec_pretty(&encrypted)
|
||||
@@ -836,10 +890,10 @@ mod tests {
|
||||
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();
|
||||
let summary = ks.import_file(&src, "", "").unwrap();
|
||||
assert_eq!(summary.name, "v-ml-dsa-87-89100");
|
||||
assert!(matches!(
|
||||
ks.import_file(&src, "nope"),
|
||||
ks.import_file(&src, "nope", ""),
|
||||
Err(KeystoreError::WrongPassword)
|
||||
));
|
||||
let out = dir.path().join("exported.json");
|
||||
@@ -909,4 +963,51 @@ mod tests {
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_file_is_rewritten_under_the_wallets_password() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ks = FileKeystore::in_dir(dir.path());
|
||||
let src = fixtures().join("v-ml-dsa-65-89001.json");
|
||||
// The fixture's password is empty; here the wallet's is "one".
|
||||
let summary = ks.import_file(&src, "", "one").unwrap();
|
||||
assert!(ks.open(&summary.name, "one").is_ok());
|
||||
assert!(matches!(
|
||||
ks.open(&summary.name, ""),
|
||||
Err(KeystoreError::WrongPassword)
|
||||
));
|
||||
// The address and the phrase survive the rewrite.
|
||||
let a: EncryptedWallet = serde_json::from_slice(&fs::read(&src).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
ks.open(&summary.name, "one").unwrap().summary.address,
|
||||
a.address
|
||||
);
|
||||
assert!(ks.open(&summary.name, "one").unwrap().mnemonic.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_all_moves_every_wallet_or_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ks = FileKeystore::in_dir(dir.path());
|
||||
ks.import_file(&fixtures().join("v-ml-dsa-65-89001.json"), "", "one")
|
||||
.unwrap();
|
||||
ks.import_file(&fixtures().join("v-ml-dsa-87-89100.json"), "", "one")
|
||||
.unwrap();
|
||||
assert_eq!(ks.rekey_all("one", "two").unwrap(), 2);
|
||||
for w in ks.list().unwrap() {
|
||||
assert!(ks.open(&w.name, "two").is_ok(), "{}", w.name);
|
||||
}
|
||||
// One file with another password stops the whole change.
|
||||
ks.import_file(&fixtures().join("crystal_alice.json"), "", "odd")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
ks.rekey_all("two", "three"),
|
||||
Err(KeystoreError::WrongPassword)
|
||||
));
|
||||
assert!(ks.open("v-ml-dsa-65-89001", "two").is_ok());
|
||||
assert!(ks.open("crystal_alice", "odd").is_ok());
|
||||
// Re-keying that one file alone brings it into line.
|
||||
ks.rekey("crystal_alice", "odd", "two").unwrap();
|
||||
assert_eq!(ks.rekey_all("two", "three").unwrap(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +449,17 @@ pub struct SessionStatusInfo {
|
||||
pub locks_in_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
/// What an unlock did: the session, and any wallet file the password did
|
||||
/// not open. One password opens every wallet; a file that keeps another
|
||||
/// (one the CLI wrote) stays closed until it is re-keyed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct UnlockOutcome {
|
||||
pub session: SessionStatusInfo,
|
||||
/// Wallets, by name, that did not open with the password.
|
||||
pub unopened: Vec<String>,
|
||||
}
|
||||
|
||||
/// Persisted preferences. Nothing here is secret.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(default)]
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wallet-ui",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
|
||||
+11
-8
@@ -1,6 +1,6 @@
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { useSession, useSessionLockedListener } from './api/hooks'
|
||||
import { LockScreen, OpenAnotherWallet } from './components/LockScreen'
|
||||
import { useSession, useSessionLockedListener, useWallets } from './api/hooks'
|
||||
import { LockScreen } from './components/LockScreen'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { UpdateOffer } from './components/UpdateOffer'
|
||||
@@ -23,17 +23,21 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// Locked: the lock screen, or onboarding if that is where the person went.
|
||||
// Unlocked: the shell. Onboarding is reachable in both states, since adding
|
||||
// a second wallet is the same flow as adding the first.
|
||||
// Locked: the lock screen; or onboarding, on first run only, when there is
|
||||
// no wallet to unlock. With wallets on disk the one password must open
|
||||
// them before another is added, so a second password never comes into
|
||||
// being. Unlocked: the shell, where onboarding adds a wallet beside the
|
||||
// others.
|
||||
function Gate() {
|
||||
const session = useSession()
|
||||
const wallets = useWallets()
|
||||
const location = useLocation()
|
||||
if (session.isPending) return <div className="lock muted">starting…</div>
|
||||
if (session.isPending || wallets.isPending) return <div className="lock muted">starting…</div>
|
||||
if (session.isError) return <div className="lock error">{session.error.message}</div>
|
||||
const onboarding = location.pathname.startsWith('/onboarding')
|
||||
const firstRun = (wallets.data?.length ?? 0) === 0
|
||||
if (session.data.locked) {
|
||||
return onboarding ? (
|
||||
return onboarding && firstRun ? (
|
||||
<div className="lock">
|
||||
<Routes>
|
||||
<Route path="/onboarding/*" element={<OnboardingRoutes />} />
|
||||
@@ -53,7 +57,6 @@ function Gate() {
|
||||
<Route path="/send" element={<Send />} />
|
||||
<Route path="/receive" element={<Receive />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/wallets" element={<OpenAnotherWallet />} />
|
||||
<Route path="/swap" element={<Swap />} />
|
||||
<Route path="/settings" element={<SettingsRoute />} />
|
||||
<Route path="/onboarding/*" element={<OnboardingRoutes />} />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SessionStatusInfo } from "./SessionStatusInfo";
|
||||
|
||||
/**
|
||||
* What an unlock did: the session, and any wallet file the password did
|
||||
* not open. One password opens every wallet; a file that keeps another
|
||||
* (one the CLI wrote) stays closed until it is re-keyed.
|
||||
*/
|
||||
export type UnlockOutcome = { session: SessionStatusInfo,
|
||||
/**
|
||||
* Wallets, by name, that did not open with the password.
|
||||
*/
|
||||
unopened: Array<string>, };
|
||||
+6
-3
@@ -66,8 +66,11 @@ export function useSession() {
|
||||
export function useUnlock() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ name, password }: { name: string; password: string }) => unlock(name, password),
|
||||
onSuccess: (s) => qc.setQueryData(queryKeys.session, s),
|
||||
mutationFn: (password: string) => unlock(password),
|
||||
onSuccess: (o) => {
|
||||
qc.setQueryData(queryKeys.session, o.session)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.wallets })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,7 +103,7 @@ export function useDiscoverAccounts() {
|
||||
export function useLock() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (wallet?: string) => lock(wallet),
|
||||
mutationFn: () => lock(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.session }),
|
||||
})
|
||||
}
|
||||
|
||||
+46
-12
@@ -13,6 +13,7 @@ import type { PendingReversible } from './generated/PendingReversible'
|
||||
import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
|
||||
import type { CreationStart } from './generated/CreationStart'
|
||||
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
|
||||
import type { UnlockOutcome } from './generated/UnlockOutcome'
|
||||
import type { Settings } from './generated/Settings'
|
||||
import type { AccountScan } from './generated/AccountScan'
|
||||
import type { AssetInfo } from './generated/AssetInfo'
|
||||
@@ -77,12 +78,19 @@ export function walletCreateBegin(): Promise<CreationStart> {
|
||||
return command<CreationStart>('wallet_create_begin')
|
||||
}
|
||||
|
||||
// The new wallet is written under the one wallet password and opened
|
||||
// beside the others. `password` is only taken on first run, when no wallet
|
||||
// exists yet and this one sets it; otherwise the session's is used.
|
||||
export function walletCreateConfirm(
|
||||
name: string,
|
||||
password: string,
|
||||
answers: string[],
|
||||
password?: string,
|
||||
): Promise<WalletSummary> {
|
||||
return command<WalletSummary>('wallet_create_confirm', { name, password, answers })
|
||||
return command<WalletSummary>('wallet_create_confirm', {
|
||||
name,
|
||||
answers,
|
||||
password: password ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function walletCreateCancel(): Promise<void> {
|
||||
@@ -92,24 +100,50 @@ export function walletCreateCancel(): Promise<void> {
|
||||
// No scheme: a phrase opens with its ML-DSA-65 and ML-DSA-87 accounts both.
|
||||
export function walletImportPhrase(
|
||||
name: string,
|
||||
password: string,
|
||||
phrase: string,
|
||||
password?: string,
|
||||
): Promise<WalletSummary> {
|
||||
return command<WalletSummary>('wallet_import_phrase', { name, password, phrase })
|
||||
return command<WalletSummary>('wallet_import_phrase', {
|
||||
name,
|
||||
phrase,
|
||||
password: password ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function walletImportFile(path: string, password: string): Promise<WalletSummary> {
|
||||
return command<WalletSummary>('wallet_import_file', { path, password })
|
||||
// A quantus-cli file: opened with the password the CLI set, written here
|
||||
// under the wallet password.
|
||||
export function walletImportFile(
|
||||
path: string,
|
||||
filePassword: string,
|
||||
password?: string,
|
||||
): Promise<WalletSummary> {
|
||||
return command<WalletSummary>('wallet_import_file', {
|
||||
path,
|
||||
filePassword,
|
||||
password: password ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function checkphrase(address: string): Promise<string> {
|
||||
return command<string>('checkphrase', { address })
|
||||
}
|
||||
|
||||
// The session: one wallet open at a time, locked again after idling. The
|
||||
// Rust side emits `session:locked` when the idle rule fires.
|
||||
export function unlock(name: string, password: string): Promise<SessionStatusInfo> {
|
||||
return command<SessionStatusInfo>('unlock', { name, password })
|
||||
// The session: one password opens every wallet, and the idle rule locks
|
||||
// them all together. The Rust side emits `session:locked` when it fires.
|
||||
// A file the password did not open is named in `unopened`.
|
||||
export function unlock(password: string): Promise<UnlockOutcome> {
|
||||
return command<UnlockOutcome>('unlock', { password })
|
||||
}
|
||||
|
||||
// Re-encrypt a wallet that kept a password of its own (one the CLI wrote)
|
||||
// under the wallet password, and open it.
|
||||
export function walletRekey(name: string, oldPassword: string): Promise<SessionStatusInfo> {
|
||||
return command<SessionStatusInfo>('wallet_rekey', { name, oldPassword })
|
||||
}
|
||||
|
||||
// Every wallet file re-encrypted, all or none.
|
||||
export function passwordChange(current: string, next: string): Promise<void> {
|
||||
return command<void>('password_change', { current, new: next })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,8 +164,8 @@ export function walletDiscoverAccounts(chain: ChainId, wallet: string): Promise<
|
||||
}
|
||||
|
||||
/** Lock one open wallet by name, or all of them when none is named. */
|
||||
export function lock(wallet?: string): Promise<void> {
|
||||
return command<void>('lock', { wallet: wallet ?? null })
|
||||
export function lock(): Promise<void> {
|
||||
return command<void>('lock')
|
||||
}
|
||||
|
||||
export function sessionStatus(): Promise<SessionStatusInfo> {
|
||||
|
||||
@@ -1,94 +1,24 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useSession, useUnlock, useWallets } from '../api/hooks'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useUnlock, useWallets } from '../api/hooks'
|
||||
|
||||
/**
|
||||
* Picks a wallet file and takes its password; the password goes to Rust
|
||||
* and nowhere else. Wallets already open are not offered again. Used by
|
||||
* the lock screen, when nothing is open, and by the page that opens another
|
||||
* wallet beside the ones already open.
|
||||
* One password opens every wallet. It goes to Rust and nowhere else. A
|
||||
* wallet file the password does not open (one quantus-cli wrote under a
|
||||
* password of its own) stays closed and is offered a one-time re-key on
|
||||
* the accounts page, once the rest are open.
|
||||
*/
|
||||
export function UnlockForm({ onUnlocked }: { onUnlocked?: () => void }) {
|
||||
export function LockScreen() {
|
||||
const wallets = useWallets()
|
||||
const session = useSession()
|
||||
const unlock = useUnlock()
|
||||
const [name, setName] = useState<string>('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const open = new Set(session.data?.wallets.map((w) => w.wallet.name) ?? [])
|
||||
const list = (wallets.data ?? []).filter((w) => !open.has(w.name))
|
||||
const selected = list.some((w) => w.name === name) ? name : (list[0]?.name ?? '')
|
||||
const none = wallets.isSuccess && (wallets.data?.length ?? 0) === 0
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selected) return
|
||||
unlock.mutate(
|
||||
{ name: selected, password },
|
||||
{
|
||||
onSettled: () => setPassword(''),
|
||||
onSuccess: () => onUnlocked?.(),
|
||||
},
|
||||
)
|
||||
unlock.mutate(password, { onSettled: () => setPassword('') })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{wallets.isPending && <p className="muted">Reading wallets…</p>}
|
||||
{wallets.isError && <p className="error">{wallets.error.message}</p>}
|
||||
{wallets.isSuccess && (wallets.data?.length ?? 0) === 0 && (
|
||||
<p className="muted">No wallets yet.</p>
|
||||
)}
|
||||
{wallets.isSuccess && (wallets.data?.length ?? 0) > 0 && list.length === 0 && (
|
||||
<p className="muted">Every wallet is already open.</p>
|
||||
)}
|
||||
{list.length > 0 && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="wallet">Wallet</label>
|
||||
<select
|
||||
id="wallet"
|
||||
className="select"
|
||||
value={selected}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
>
|
||||
{list.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name} · {w.address.slice(0, 8)}…
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{unlock.isError && <p className="error">{unlock.error.message}</p>}
|
||||
<div className="row">
|
||||
<button className="button primary" type="submit" disabled={unlock.isPending}>
|
||||
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{wallets.isSuccess && (
|
||||
<p className="muted" style={{ marginTop: 16 }}>
|
||||
<Link to="/onboarding">Create, restore or import a wallet</Link>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fronts everything while no wallet is open. */
|
||||
export function LockScreen() {
|
||||
return (
|
||||
<div className="lock">
|
||||
<div className="panel">
|
||||
@@ -97,28 +27,42 @@ export function LockScreen() {
|
||||
<span className="eyebrow">blackbeard wallet</span>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<UnlockForm />
|
||||
{wallets.isPending && <p className="muted">Reading wallets…</p>}
|
||||
{wallets.isError && <p className="error">{wallets.error.message}</p>}
|
||||
{none && (
|
||||
<p className="muted">
|
||||
No wallets yet. <Link to="/onboarding">Create, restore or import one</Link> to set
|
||||
your password.
|
||||
</p>
|
||||
)}
|
||||
{wallets.isSuccess && !none && (
|
||||
<form onSubmit={submit}>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{wallets.data.length === 1 ? '1 wallet' : `${wallets.data.length} wallets`} open
|
||||
together with one password.
|
||||
</p>
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{unlock.isError && <p className="error">{unlock.error.message}</p>}
|
||||
<div className="row">
|
||||
<button className="button primary" type="submit" disabled={unlock.isPending}>
|
||||
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Open another wallet beside the ones already open. */
|
||||
export function OpenAnotherWallet() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Open another wallet</h2>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Wallets open side by side: their accounts all appear on the accounts page, and send and
|
||||
receive can use any of them. The idle lock closes them all together.
|
||||
</p>
|
||||
<UnlockForm onUnlocked={() => navigate('/')} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,17 +62,10 @@ export function Sidebar() {
|
||||
{session.data.wallets.map((w) => (
|
||||
<div className="wallet-row" key={w.wallet.name}>
|
||||
<span title={w.wallet.address}>{w.wallet.name}</span>
|
||||
<button
|
||||
className="linkish muted"
|
||||
type="button"
|
||||
onClick={() => lock.mutate(w.wallet.name)}
|
||||
>
|
||||
lock
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<NavLink to="/wallets" className="linkish">
|
||||
+ open another
|
||||
<NavLink to="/onboarding" className="linkish">
|
||||
+ add a wallet
|
||||
</NavLink>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
@@ -80,8 +73,8 @@ export function Sidebar() {
|
||||
<>locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
|
||||
)}
|
||||
</span>
|
||||
<button className="button" type="button" onClick={() => lock.mutate(undefined)}>
|
||||
Lock all
|
||||
<button className="button" type="button" onClick={() => lock.mutate()}>
|
||||
Lock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { queryKeys, useSession, useWallets } from '../api/hooks'
|
||||
import { walletRekey } from '../api/wallet'
|
||||
|
||||
/**
|
||||
* Wallet files the password did not open: ones quantus-cli wrote under a
|
||||
* password of their own. Each is re-keyed once, with that password, under
|
||||
* the wallet password, and opens with the rest from then on. Shown only
|
||||
* while such a file exists.
|
||||
*/
|
||||
export function UnopenedWallets() {
|
||||
const wallets = useWallets()
|
||||
const session = useSession()
|
||||
const open = new Set(session.data?.wallets.map((w) => w.wallet.name) ?? [])
|
||||
const closed = (wallets.data ?? []).filter((w) => !open.has(w.name))
|
||||
if (session.data?.locked || closed.length === 0) return null
|
||||
return (
|
||||
<section className="panel" data-testid="unopened-wallets">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Wallets with another password</h2>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
These files did not open with your password; quantus-cli set another on them. Enter that
|
||||
password once and each is rewritten under your wallet password.
|
||||
</p>
|
||||
{closed.map((w) => (
|
||||
<RekeyRow key={w.name} name={w.name} address={w.address} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RekeyRow({ name, address }: { name: string; address: string }) {
|
||||
const qc = useQueryClient()
|
||||
const [password, setPassword] = useState('')
|
||||
const rekey = useMutation({
|
||||
mutationFn: () => walletRekey(name, password),
|
||||
onSuccess: (s) => {
|
||||
qc.setQueryData(queryKeys.session, s)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.wallets })
|
||||
},
|
||||
onSettled: () => setPassword(''),
|
||||
})
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
rekey.mutate()
|
||||
}
|
||||
return (
|
||||
<form className="row" onSubmit={submit}>
|
||||
<span>
|
||||
{name} <span className="muted mono">{address.slice(0, 10)}…</span>
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="its password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-label={`Password of ${name}`}
|
||||
/>
|
||||
<button className="button" type="submit" disabled={rekey.isPending}>
|
||||
{rekey.isPending ? 'Re-keying…' : 'Re-key and open'}
|
||||
</button>
|
||||
{rekey.isError && <span className="error">{rekey.error.message}</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
useDiscoverAccounts,
|
||||
useBalances,
|
||||
useChains,
|
||||
useLock,
|
||||
useSession,
|
||||
useSettings,
|
||||
} from '../api/hooks'
|
||||
@@ -13,6 +12,7 @@ import { ChainCaveat } from '../components/ChainCaveat'
|
||||
import { NetworkOverview } from '../components/NetworkOverview'
|
||||
import { PendingReversibleList } from '../components/PendingReversible'
|
||||
import { NearIntentsPanel } from '../components/NearIntentsPanel'
|
||||
import { UnopenedWallets } from '../components/UnopenedWallets'
|
||||
import { WormholePanel } from '../components/WormholePanel'
|
||||
import { schemeLabel } from '../lib/accounts'
|
||||
import { formatAmount } from '../lib/format'
|
||||
@@ -28,7 +28,6 @@ import { formatAmount } from '../lib/format'
|
||||
export function Accounts() {
|
||||
const settings = useSettings()
|
||||
const session = useSession()
|
||||
const lock = useLock()
|
||||
const addAccount = useAddAccount()
|
||||
const discover = useDiscoverAccounts()
|
||||
const unlocked = !!session.data && !session.data.locked
|
||||
@@ -124,13 +123,6 @@ export function Accounts() {
|
||||
: 'Add account'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
onClick={() => lock.mutate(w.wallet.name)}
|
||||
>
|
||||
Lock
|
||||
</button>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -163,6 +155,7 @@ export function Accounts() {
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
<UnopenedWallets />
|
||||
{chain && profile && unlocked && (
|
||||
<PendingReversibleList chain={chain} profile={profile} addresses={addresses} />
|
||||
)}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { CreationStart } from '../api/generated/CreationStart'
|
||||
import { pickWalletFile } from '../api/dialog'
|
||||
import { queryKeys, useSettings } from '../api/hooks'
|
||||
import { queryKeys, useSettings, useWallets } from '../api/hooks'
|
||||
import {
|
||||
chainConnect,
|
||||
checkphrase,
|
||||
unlock,
|
||||
sessionStatus,
|
||||
walletCreateBegin,
|
||||
walletCreateCancel,
|
||||
walletCreateConfirm,
|
||||
@@ -50,9 +50,11 @@ function useFinish(discover = false) {
|
||||
const navigate = useNavigate()
|
||||
const settings = useSettings()
|
||||
const discoverOn = discover ? settings.data?.network : undefined
|
||||
return async (name: string, password: string) => {
|
||||
return async (name: string) => {
|
||||
// The command opened the wallet beside the others (and, on first run,
|
||||
// set the session's password); the session is read back.
|
||||
await qc.invalidateQueries({ queryKey: queryKeys.wallets })
|
||||
const status = await unlock(name, password)
|
||||
const status = await sessionStatus()
|
||||
// A restored phrase may already use accounts beyond the first. Best
|
||||
// effort: the chain has to be reachable, and a failure here must not
|
||||
// stand between the person and their wallet.
|
||||
@@ -73,10 +75,21 @@ function useFinish(discover = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this is the first wallet: the one that sets the password. */
|
||||
function useFirstRun() {
|
||||
const wallets = useWallets()
|
||||
return (wallets.data?.length ?? 0) === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A name, and on first run the password every wallet will share. After
|
||||
* that the session's password is used and nothing asks for it again.
|
||||
*/
|
||||
function NamePassword({
|
||||
name,
|
||||
password,
|
||||
confirm,
|
||||
askPassword,
|
||||
onName,
|
||||
onPassword,
|
||||
onConfirm,
|
||||
@@ -84,6 +97,7 @@ function NamePassword({
|
||||
name: string
|
||||
password: string
|
||||
confirm: string
|
||||
askPassword: boolean
|
||||
onName: (v: string) => void
|
||||
onPassword: (v: string) => void
|
||||
onConfirm: (v: string) => void
|
||||
@@ -94,34 +108,44 @@ function NamePassword({
|
||||
<label htmlFor="name">Wallet name</label>
|
||||
<input id="name" className="input" value={name} onChange={(e) => onName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="pw">Password</label>
|
||||
<input
|
||||
id="pw"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => onPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="pw2">Password again</label>
|
||||
<input
|
||||
id="pw2"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => onConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!askPassword && (
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: 0 }}>
|
||||
It will open with the same password as your other wallets.
|
||||
</p>
|
||||
)}
|
||||
{askPassword && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="pw">Password</label>
|
||||
<input
|
||||
id="pw"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => onPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="pw2">Password again</label>
|
||||
<input
|
||||
id="pw2"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => onConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Create() {
|
||||
const finish = useFinish()
|
||||
const firstRun = useFirstRun()
|
||||
const [start, setStart] = useState<CreationStart | null>(null)
|
||||
const [step, setStep] = useState<'start' | 'words' | 'challenge' | 'done'>('start')
|
||||
const [answers, setAnswers] = useState<string[]>([])
|
||||
@@ -156,17 +180,17 @@ export function Create() {
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (password !== confirm) {
|
||||
if (firstRun && password !== confirm) {
|
||||
setError('The passwords differ.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const summary = await walletCreateConfirm(name, password, answers)
|
||||
const summary = await walletCreateConfirm(name, answers, firstRun ? password : undefined)
|
||||
const phrase = await checkphrase(summary.address)
|
||||
setResult({ address: summary.address, phrase })
|
||||
setStep('done')
|
||||
await finish(name, password)
|
||||
await finish(name)
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
// A failed challenge dropped the phrase on the Rust side; start over.
|
||||
@@ -221,7 +245,7 @@ export function Create() {
|
||||
{step === 'challenge' && start && (
|
||||
<form onSubmit={submit}>
|
||||
<p className="muted">
|
||||
Type the words at these positions from your copy, then choose a name and password.
|
||||
Type the words at these positions from your copy, then choose a name.
|
||||
</p>
|
||||
{start.challenge.positions.map((pos, i) => (
|
||||
<div className="field" key={pos}>
|
||||
@@ -238,6 +262,7 @@ export function Create() {
|
||||
</div>
|
||||
))}
|
||||
<NamePassword
|
||||
askPassword={firstRun}
|
||||
name={name}
|
||||
password={password}
|
||||
confirm={confirm}
|
||||
@@ -272,6 +297,7 @@ export function Create() {
|
||||
|
||||
export function Restore() {
|
||||
const finish = useFinish(true)
|
||||
const firstRun = useFirstRun()
|
||||
const [phrase, setPhrase] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -282,15 +308,15 @@ export function Restore() {
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (password !== confirm) {
|
||||
if (firstRun && password !== confirm) {
|
||||
setError('The passwords differ.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await walletImportPhrase(name, password, phrase)
|
||||
await walletImportPhrase(name, phrase, firstRun ? password : undefined)
|
||||
setPhrase('')
|
||||
await finish(name, password)
|
||||
await finish(name)
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
@@ -326,6 +352,7 @@ export function Restore() {
|
||||
will be there.
|
||||
</p>
|
||||
<NamePassword
|
||||
askPassword={firstRun}
|
||||
name={name}
|
||||
password={password}
|
||||
confirm={confirm}
|
||||
@@ -343,8 +370,11 @@ export function Restore() {
|
||||
|
||||
export function ImportFile() {
|
||||
const finish = useFinish()
|
||||
const firstRun = useFirstRun()
|
||||
const [path, setPath] = useState<string | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [setPw, setSetPw] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -352,10 +382,14 @@ export function ImportFile() {
|
||||
e.preventDefault()
|
||||
if (!path) return
|
||||
setError(null)
|
||||
if (firstRun && setPw !== confirm) {
|
||||
setError('The passwords differ.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const summary = await walletImportFile(path, password)
|
||||
await finish(summary.name, password)
|
||||
const summary = await walletImportFile(path, password, firstRun ? setPw : undefined)
|
||||
await finish(summary.name)
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
@@ -396,7 +430,37 @@ export function ImportFile() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
The password quantus-cli set on it. The copy kept here is rewritten under
|
||||
{firstRun ? ' the password you choose below' : ' your wallet password'}.
|
||||
</span>
|
||||
</div>
|
||||
{firstRun && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="setpw">Wallet password</label>
|
||||
<input
|
||||
id="setpw"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={setPw}
|
||||
onChange={(e) => setSetPw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="setpw2">Wallet password again</label>
|
||||
<input
|
||||
id="setpw2"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button className="button primary" type="submit" disabled={busy || !path}>
|
||||
{busy ? 'Opening…' : 'Import'}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAppInfo, useSetSettings, useSettings } from '../api/hooks'
|
||||
import { passwordChange } from '../api/wallet'
|
||||
import { setTheme, storedTheme, type ThemePreference } from '../lib/theme'
|
||||
|
||||
export function SettingsRoute() {
|
||||
@@ -71,7 +73,77 @@ export function SettingsRoute() {
|
||||
</select>
|
||||
</div>
|
||||
{set.isError && <p className="error">{set.error.message}</p>}
|
||||
<ChangePassword />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one password every wallet shares. Changing it re-encrypts every
|
||||
* wallet file, all or none; the files stay in quantus-cli's format, so the
|
||||
* CLI opens them with the new password too.
|
||||
*/
|
||||
function ChangePassword() {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [mismatch, setMismatch] = useState(false)
|
||||
const change = useMutation({
|
||||
mutationFn: () => passwordChange(current, next),
|
||||
onSuccess: () => {
|
||||
setCurrent('')
|
||||
setNext('')
|
||||
setConfirm('')
|
||||
},
|
||||
})
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setMismatch(next !== confirm)
|
||||
if (next === confirm) change.mutate()
|
||||
}
|
||||
return (
|
||||
<form className="field" onSubmit={submit} data-testid="change-password">
|
||||
<label>Change the wallet password</label>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
One password opens every wallet. Every wallet file is re-encrypted with the new one, and
|
||||
none is changed unless all are.
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="current password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
aria-label="Current password"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="new password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
aria-label="New password"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="new password again"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
aria-label="New password again"
|
||||
/>
|
||||
{mismatch && <p className="error">The new passwords differ.</p>}
|
||||
{change.isError && <p className="error">{change.error.message}</p>}
|
||||
{change.isSuccess && <p className="muted">Password changed.</p>}
|
||||
<div className="row">
|
||||
<button className="button" type="submit" disabled={!current || !next || change.isPending}>
|
||||
{change.isPending ? 'Re-encrypting…' : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user