2 Commits
Author SHA1 Message Date
grenadeandClaude Fable 5.1 8cfd88b39b chore(release): 0.4.1
ci / gate (push) Successful in 12m21s
release / linux (push) Successful in 14m58s
release / package (44) (push) Successful in 39s
release / publish (44) (push) Successful in 17s
One password unlocks every wallet (#84). 0.4.0 shipped from 038f097
before that landed: its tag run had never started, the retry I pushed
could not be stopped once the operator asked to hold the release, and
a published version is never rebuilt with other contents.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 11:24:22 +03:00
grenadeandClaude Fable 5.1 4ea85465c6 feat: one password unlocks every wallet
ci / gate (push) Successful in 14m23s
Unlocking each wallet with its own password was a convoluted UX, and
the wallets people already use open everything with one. This drops
per-wallet passwords entirely (#84).

The files stay in quantus-cli's format, so there is no vault: the
password is simply the one every wallet file is encrypted with.

- unlock(password) opens every wallet in the keystore. A file it does
  not open (one the CLI wrote under a password of its own) is named in
  the outcome's `unopened`; a password that opens nothing is wrong.
- The session holds the password while open and wipes it on lock, so
  creating, restoring or importing a wallet asks only for a name and
  writes the file under the same password. On first run, with no wallet
  yet, the flow sets it. With wallets on disk and the session locked, a
  new wallet is refused, so a second password cannot come into being.
- wallet_rekey re-encrypts one file from its own password to the
  wallet's, once; the accounts page offers it for every unopened file.
  Importing a CLI file re-encrypts it the same way at import.
- password_change re-encrypts every file: all decrypted before any is
  written, every new file written before any replaces its old one, so a
  wrong password or a full disk changes nothing.
- lock takes no wallet. The per-wallet lock buttons, the open-another
  page and the wallet picker on the lock screen are gone; the sidebar
  offers "add a wallet", and Settings a password change.

Checked in a dev run from an empty data directory: the first import set
the password, the second inherited it, a wrong password was refused, a
CLI file dropped beside them was reported and re-keyed, and a password
change moved all three; the lock screen has one password field and no
picker. quantus-cli exported a re-keyed file's phrase with the new
password and refused the old, so the CLI keeps opening these files.

Closes #84

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 11:06:11 +03:00
21 changed files with 719 additions and 257 deletions
+7 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+164 -29
View File
@@ -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(&current, &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]
+2
View File
@@ -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 -1
View File
@@ -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",
+14 -2
View File
@@ -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>;
}
+30 -22
View File
@@ -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(_))
));
+111 -10
View File
@@ -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);
}
}
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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 />} />
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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> {
+43 -99
View File
@@ -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>
)
}
+4 -11
View File
@@ -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>
+71
View File
@@ -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>
)
}
+2 -9
View File
@@ -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} />
)}
+99 -35
View File
@@ -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>
+73 -1
View File
@@ -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>
)
}