From 18caa2b81c411afa1de7a7a1a146a035d173b3dd Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 15 Sep 2026 18:28:40 +0300 Subject: [PATCH] feat(session): password in, signatures out, locked again after idling `wallet_core::session::Session` is the only place decrypted key material lives between commands: one wallet open at a time, its signers and, when the file carried a phrase, its seed. Every signature goes through `Session::sign`, which applies the idle rule before it looks at the key, so a stale session refuses rather than signing one last time. Time is passed in, so the rule is tested at 59, 118 and 119 seconds without waiting. The seed is now a heap allocation behind a wiping box. Moving an inline 64-byte array through `Some(...)` and a function argument can leave copies in dead stack frames that no destructor visits; moving a box moves a pointer. A Linux test unlocks with a runtime-built seed and a signer whose secret is a second runtime-built pattern, locks, then reads every writable anonymous mapping in /proc/self/mem and asserts neither pattern is anywhere in the process. It passes in debug and release. The comparison copies are XOR-masked so the test cannot find itself. In the app: `unlock` opens a file, turns its phrase into a seed and drops the phrase; `lock` and `session_status` do what they say; a ticker thread applies the idle rule every five seconds and emits `session:locked` so the lock screen appears without a click. Ten minutes until settings exist. Closes #17 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW --- crates/wallet-app/src/commands.rs | 69 ++++ crates/wallet-app/src/lib.rs | 24 ++ crates/wallet-app/src/state.rs | 7 + crates/wallet-core/src/lib.rs | 1 + crates/wallet-core/src/ports.rs | 17 +- crates/wallet-core/src/session.rs | 386 +++++++++++++++++++++ crates/wallet-data/src/keys/mod.rs | 2 +- crates/wallet-entities/src/lib.rs | 19 + ui/src/api/generated/SessionAccountInfo.ts | 7 + ui/src/api/generated/SessionStatusInfo.ts | 9 + ui/src/api/wallet.ts | 15 + 11 files changed, 549 insertions(+), 7 deletions(-) create mode 100644 crates/wallet-core/src/session.rs create mode 100644 ui/src/api/generated/SessionAccountInfo.ts create mode 100644 ui/src/api/generated/SessionStatusInfo.ts diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index e18775c..d45de34 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -6,13 +6,19 @@ //! returns a freshly generated phrase exactly once so the person can write //! it down. No command returns a phrase that already exists. +use std::time::Instant; + use tauri::State; use wallet_core::backup::BackupChallenge; +use wallet_core::session::SessionError; +use wallet_core::ss58; use wallet_core::{Keystore, KeystoreError}; +use wallet_data::keys::seed_from_mnemonic; use wallet_data::mnemonic; use wallet_entities::{ AppInfo, CreationChallenge, CreationStart, SignatureScheme, WalletError, WalletSummary, }; +use wallet_entities::{SessionAccountInfo, SessionStatusInfo}; use crate::state::{AppState, PendingCreation}; @@ -31,6 +37,19 @@ fn keystore_error(e: KeystoreError) -> WalletError { } } +fn session_error(e: SessionError) -> WalletError { + match e { + SessionError::Locked => WalletError::Locked, + SessionError::NoSuchAccount => WalletError::NotFound("account".into()), + SessionError::Key(k) => WalletError::Internal(k.to_string()), + SessionError::Poisoned => WalletError::Internal(e.to_string()), + } +} + +/// The SS58 prefix addresses are shown under until chain profiles (#18) +/// make it a property of the selected chain. +const DISPLAY_SS58_PREFIX: u16 = 189; + /// What the UI needs to know about the running app before anything is /// unlocked: the version, and later the enabled chain profiles. #[tauri::command] @@ -147,3 +166,53 @@ pub fn checkphrase(address: String) -> Result { wallet_core::ss58::decode(&address).map_err(|e| WalletError::InvalidInput(e.to_string()))?; 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 { + let opened = state + .keystore + .open(&name, &password) + .map_err(keystore_error)?; + let seed = match opened.mnemonic.as_deref() { + Some(phrase) => Some( + seed_from_mnemonic(phrase, None).map_err(|e| WalletError::Internal(e.to_string()))?, + ), + None => None, + }; + state + .session + .unlock(opened, seed, Instant::now()) + .map_err(session_error)?; + session_status(state) +} + +#[tauri::command] +pub fn lock(state: State<'_, AppState>) -> Result<(), WalletError> { + state.session.lock().map_err(session_error) +} + +#[tauri::command] +pub fn session_status(state: State<'_, AppState>) -> Result { + let now = Instant::now(); + state.session.expire_if_idle(now).map_err(session_error)?; + let st = state.session.status(now).map_err(session_error)?; + Ok(SessionStatusInfo { + locked: st.wallet.is_none(), + wallet: st.wallet, + accounts: st + .accounts + .iter() + .map(|a| SessionAccountInfo { + address: ss58::encode(DISPLAY_SS58_PREFIX, &a.account_id), + scheme: a.scheme, + }) + .collect(), + locks_in_seconds: st.locks_in, + }) +} diff --git a/crates/wallet-app/src/lib.rs b/crates/wallet-app/src/lib.rs index 0a131bb..d036e4e 100644 --- a/crates/wallet-app/src/lib.rs +++ b/crates/wallet-app/src/lib.rs @@ -14,6 +14,27 @@ pub fn run() { tauri::Builder::default() .manage(state) + .setup(|app| { + // The idle rule is applied on every command too; this ticker is + // what locks a wallet nobody is touching, and tells the UI so + // the lock screen appears without a click. + let handle = app.handle().clone(); + std::thread::Builder::new() + .name("idle-lock".into()) + .spawn(move || { + use tauri::{Emitter, Manager}; + loop { + std::thread::sleep(std::time::Duration::from_secs(5)); + let state = handle.state::(); + if let Ok(true) = state.session.expire_if_idle(std::time::Instant::now()) { + tracing::info!("session locked after idling"); + let _ = handle.emit("session:locked", ()); + } + } + }) + .expect("the idle-lock thread starts"); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ commands::app_info, commands::wallets_list, @@ -23,6 +44,9 @@ pub fn run() { commands::wallet_import_phrase, commands::wallet_import_file, commands::checkphrase, + commands::unlock, + commands::lock, + commands::session_status, ]) .run(tauri::generate_context!()) .expect("the Tauri runtime failed to start"); diff --git a/crates/wallet-app/src/state.rs b/crates/wallet-app/src/state.rs index d21a8cf..5436062 100644 --- a/crates/wallet-app/src/state.rs +++ b/crates/wallet-app/src/state.rs @@ -4,8 +4,10 @@ //! because Tauri commands run concurrently. use std::sync::Mutex; +use std::time::Duration; use wallet_core::backup::BackupChallenge; +use wallet_core::session::Session; use wallet_data::keystore::FileKeystore; use wallet_entities::SignatureScheme; use zeroize::Zeroizing; @@ -18,9 +20,13 @@ pub struct PendingCreation { pub scheme: SignatureScheme, } +/// Until the settings store exists (#25), the idle interval is fixed. +pub const DEFAULT_IDLE_LOCK: Duration = Duration::from_secs(10 * 60); + pub struct AppState { pub keystore: FileKeystore, pub pending_creation: Mutex>, + pub session: Session, } impl AppState { @@ -37,6 +43,7 @@ impl AppState { Ok(Self { keystore: FileKeystore::in_dir(dir), pending_creation: Mutex::new(None), + session: Session::new(DEFAULT_IDLE_LOCK), }) } } diff --git a/crates/wallet-core/src/lib.rs b/crates/wallet-core/src/lib.rs index 2674845..c962a3e 100644 --- a/crates/wallet-core/src/lib.rs +++ b/crates/wallet-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod backup; pub mod paths; pub mod ports; +pub mod session; pub mod ss58; pub use ports::*; diff --git a/crates/wallet-core/src/ports.rs b/crates/wallet-core/src/ports.rs index 3a7ff63..3d495cd 100644 --- a/crates/wallet-core/src/ports.rs +++ b/crates/wallet-core/src/ports.rs @@ -7,17 +7,22 @@ use wallet_entities::{ }; use zeroize::Zeroizing; -/// A BIP39 seed: the 64 bytes every key in the wallet descends from. Wiped -/// on drop; never cloned into anything that is not also wiped. -pub struct Seed(Zeroizing<[u8; 64]>); +/// A BIP39 seed: the 64 bytes every key in the wallet descends from. Held +/// on the heap behind a wiping box, so moving a `Seed` moves a pointer and +/// never smears a copy through dead stack frames; the heap bytes are wiped +/// on drop. +pub struct Seed(Zeroizing>); impl Seed { - pub fn new(bytes: [u8; 64]) -> Self { - Self(Zeroizing::new(bytes)) + /// Copy the seed in from a caller-owned buffer. The caller is + /// responsible for wiping its buffer; borrowing rather than taking by + /// value keeps this function from making a stack copy of its own. + pub fn copy_from(bytes: &[u8; 64]) -> Self { + Self(Zeroizing::new(bytes.to_vec().into_boxed_slice())) } pub fn as_bytes(&self) -> &[u8; 64] { - &self.0 + (&self.0[..]).try_into().expect("a seed is always 64 bytes") } } diff --git a/crates/wallet-core/src/session.rs b/crates/wallet-core/src/session.rs new file mode 100644 index 0000000..fa1f929 --- /dev/null +++ b/crates/wallet-core/src/session.rs @@ -0,0 +1,386 @@ +//! The unlock session: the only place decrypted key material lives between +//! commands. One wallet is open at a time. Every signing operation goes +//! through [`Session::sign`], which refuses when locked, and the session +//! locks itself after an idle interval. Time is passed in so the idle rule +//! can be tested without waiting. +//! +//! Locking drops the signers and the seed; both wipe themselves on drop. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use wallet_entities::{SignatureScheme, WalletSummary}; + +use crate::{KeyError, OpenedWallet, Seed, Signer}; + +/// What is open right now: the wallet's public summary, its signers by +/// account id, and the seed when the file carried a phrase (for deriving +/// further accounts later). Dropped whole on lock. +struct Unlocked { + wallet: WalletSummary, + signers: Vec>, + seed: Option, + last_used: Instant, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionAccount { + pub account_id: [u8; 32], + pub scheme: SignatureScheme, + pub public_key: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionStatus { + pub wallet: Option, + pub accounts: Vec, + /// Seconds until the idle rule locks the session; `None` when locked. + pub locks_in: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("the wallet is locked")] + Locked, + #[error("no such account in the open wallet")] + NoSuchAccount, + #[error(transparent)] + Key(#[from] KeyError), + #[error("session state is poisoned")] + Poisoned, +} + +pub struct Session { + idle: Duration, + inner: Mutex>, +} + +impl Session { + pub fn new(idle: Duration) -> Self { + Self { + idle, + inner: Mutex::new(None), + } + } + + pub fn idle_interval(&self) -> Duration { + self.idle + } + + /// Open a wallet. Anything previously open is dropped first. The + /// mnemonic, if present, becomes a seed and the string is dropped. + pub fn unlock( + &self, + opened: OpenedWallet, + seed: Option, + now: Instant, + ) -> Result<(), SessionError> { + let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; + *guard = None; + *guard = Some(Unlocked { + wallet: opened.summary, + signers: vec![opened.signer], + seed, + last_used: now, + }); + Ok(()) + } + + pub fn lock(&self) -> Result<(), SessionError> { + let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; + *guard = None; + Ok(()) + } + + /// Apply the idle rule. Returns `true` if this call locked the session. + pub fn expire_if_idle(&self, now: Instant) -> Result { + let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; + let idle_for = guard + .as_ref() + .map(|u| now.saturating_duration_since(u.last_used)); + match idle_for { + Some(d) if d >= self.idle => { + *guard = None; + Ok(true) + } + _ => Ok(false), + } + } + + pub fn is_locked(&self) -> bool { + self.inner.lock().map(|g| g.is_none()).unwrap_or(true) + } + + pub fn status(&self, now: Instant) -> Result { + let guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; + Ok(match guard.as_ref() { + None => SessionStatus { + wallet: None, + accounts: vec![], + locks_in: None, + }, + Some(u) => SessionStatus { + wallet: Some(u.wallet.clone()), + accounts: u + .signers + .iter() + .map(|s| SessionAccount { + account_id: s.account_id(), + scheme: s.scheme(), + public_key: s.public_key().to_vec(), + }) + .collect(), + locks_in: Some( + self.idle + .saturating_sub(now.saturating_duration_since(u.last_used)) + .as_secs(), + ), + }, + }) + } + + /// Bump the idle clock without doing anything else. + pub fn touch(&self, now: Instant) -> Result<(), SessionError> { + let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; + match guard.as_mut() { + Some(u) => { + u.last_used = now; + Ok(()) + } + None => Err(SessionError::Locked), + } + } + + /// Sign with the open wallet's account. The idle rule is applied first, + /// so a stale session refuses rather than signing one last time. + pub fn sign( + &self, + account_id: &[u8; 32], + payload: &[u8], + context: &[u8], + now: Instant, + ) -> Result, SessionError> { + if self.expire_if_idle(now)? { + return Err(SessionError::Locked); + } + let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; + let u = guard.as_mut().ok_or(SessionError::Locked)?; + let signer = u + .signers + .iter() + .find(|s| &s.account_id() == account_id) + .ok_or(SessionError::NoSuchAccount)?; + let sig = signer.sign(payload, context)?; + u.last_used = now; + Ok(sig) + } + + /// Run `f` with the seed, if the open wallet has one. For deriving + /// further accounts; the seed never leaves the closure. + pub fn with_seed(&self, f: impl FnOnce(&Seed) -> T) -> Result, SessionError> { + let guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?; + let u = guard.as_ref().ok_or(SessionError::Locked)?; + Ok(u.seed.as_ref().map(f)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wallet_entities::WalletKind; + + /// A signer that records what it signed; the "secret" is a marker + /// pattern so tests can look for it in memory. + #[derive(Debug)] + struct FakeSigner { + secret: zeroize::Zeroizing>, + public: Vec, + } + + impl Signer for FakeSigner { + fn scheme(&self) -> SignatureScheme { + SignatureScheme::MlDsa65 + } + fn public_key(&self) -> &[u8] { + &self.public + } + fn account_id(&self) -> [u8; 32] { + let mut id = [0u8; 32]; + id[..self.public.len().min(32)] + .copy_from_slice(&self.public[..self.public.len().min(32)]); + id + } + fn sign(&self, message: &[u8], context: &[u8]) -> Result, KeyError> { + Ok([&self.secret[..4], message, context].concat()) + } + } + + fn opened(public: u8) -> OpenedWallet { + OpenedWallet { + summary: WalletSummary { + name: "w".into(), + address: "qz".into(), + kind: WalletKind::Hot, + created_at: 0, + }, + signer: Box::new(FakeSigner { + secret: zeroize::Zeroizing::new(vec![public; 64]), + public: vec![public; 4], + }), + derivation_path: "m/".into(), + mnemonic: None, + } + } + + #[test] + fn locked_by_default_and_refuses_to_sign() { + let s = Session::new(Duration::from_secs(60)); + assert!(s.is_locked()); + assert!(matches!( + s.sign(&[1; 32], b"m", b"", Instant::now()), + Err(SessionError::Locked) + )); + assert!(matches!(s.touch(Instant::now()), Err(SessionError::Locked))); + assert_eq!(s.status(Instant::now()).unwrap().locks_in, None); + } + + #[test] + fn unlock_sign_lock() { + let s = Session::new(Duration::from_secs(60)); + let t0 = Instant::now(); + s.unlock(opened(7), None, t0).unwrap(); + assert!(!s.is_locked()); + let st = s.status(t0).unwrap(); + assert_eq!(st.accounts.len(), 1); + let id = st.accounts[0].account_id; + assert_eq!( + s.sign(&id, b"hello", b"ctx", t0).unwrap(), + [&[7u8; 4][..], b"hello", b"ctx"].concat() + ); + assert!(matches!( + s.sign(&[9; 32], b"hello", b"", t0), + Err(SessionError::NoSuchAccount) + )); + s.lock().unwrap(); + assert!(s.is_locked()); + assert!(matches!( + s.sign(&id, b"hello", b"", t0), + Err(SessionError::Locked) + )); + } + + #[test] + fn idle_rule_locks_and_signing_refreshes_it() { + let s = Session::new(Duration::from_secs(60)); + let t0 = Instant::now(); + s.unlock(opened(1), None, t0).unwrap(); + let id = s.status(t0).unwrap().accounts[0].account_id; + assert!(!s.expire_if_idle(t0 + Duration::from_secs(59)).unwrap()); + s.sign(&id, b"m", b"", t0 + Duration::from_secs(59)) + .unwrap(); + // Signing at 59 s reset the clock, so 118 s is still inside the window. + assert!(!s.expire_if_idle(t0 + Duration::from_secs(118)).unwrap()); + assert_eq!( + s.status(t0 + Duration::from_secs(118)).unwrap().locks_in, + Some(1) + ); + assert!(s.expire_if_idle(t0 + Duration::from_secs(119)).unwrap()); + assert!(s.is_locked()); + // A sign call past the deadline locks and refuses in one step. + s.unlock(opened(1), None, t0).unwrap(); + assert!(matches!( + s.sign(&id, b"m", b"", t0 + Duration::from_secs(61)), + Err(SessionError::Locked) + )); + assert!(s.is_locked()); + } + + /// After `lock()`, neither the seed nor the signer's secret is anywhere + /// in this process's readable memory. Linux only: it reads + /// /proc/self/maps and /proc/self/mem. + #[cfg(target_os = "linux")] + #[test] + fn lock_leaves_no_secret_in_process_memory() { + use std::io::{Read, Seek, SeekFrom}; + + // Build the patterns at runtime so the literals are not in the + // binary's rodata, which would be a false positive. + let mut seed_bytes = [0u8; 64]; + for (i, b) in seed_bytes.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(37).wrapping_add(11); + } + // The comparison copies are XOR-masked so the scan cannot match them. + const MASK: u8 = 0x5a; + let seed_masked: Vec = seed_bytes.iter().map(|b| b ^ MASK).collect(); + let mut secret = vec![0u8; 64]; + for (i, b) in secret.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(53).wrapping_add(29); + } + let secret_masked: Vec = secret.iter().map(|b| b ^ MASK).collect(); + let matches = |w: &[u8], masked: &[u8]| w.iter().zip(masked).all(|(a, m)| a ^ MASK == *m); + + let s = Session::new(Duration::from_secs(60)); + let signer = FakeSigner { + secret: zeroize::Zeroizing::new(secret), + public: vec![3; 4], + }; + let opened = OpenedWallet { + summary: WalletSummary { + name: "w".into(), + address: "qz".into(), + kind: WalletKind::Hot, + created_at: 0, + }, + signer: Box::new(signer), + derivation_path: "m/".into(), + mnemonic: None, + }; + s.unlock(opened, Some(Seed::copy_from(&seed_bytes)), Instant::now()) + .unwrap(); + // Wipe the test's own copy of the seed bytes before scanning. + zeroize::Zeroize::zeroize(&mut seed_bytes); + assert!(s.with_seed(|_| ()).unwrap().is_some()); + s.lock().unwrap(); + + let maps = std::fs::read_to_string("/proc/self/maps").unwrap(); + let mut mem = std::fs::File::open("/proc/self/mem").unwrap(); + let mut hits = 0usize; + let mut scanned = 0usize; + for line in maps.lines() { + let mut parts = line.split_whitespace(); + let range = parts.next().unwrap(); + let perms = parts.next().unwrap(); + let path = parts.nth(3).unwrap_or(""); + // Only writable anonymous memory: heap and stacks. Read-only + // mappings cannot hold a decrypted secret, and file-backed ones + // are the binary itself. + if !perms.starts_with("rw") + || path.starts_with('/') + || path == "[vvar]" + || path == "[vsyscall]" + { + continue; + } + let (lo, hi) = range.split_once('-').unwrap(); + let lo = usize::from_str_radix(lo, 16).unwrap(); + let hi = usize::from_str_radix(hi, 16).unwrap(); + let mut buf = vec![0u8; hi - lo]; + if mem.seek(SeekFrom::Start(lo as u64)).is_err() || mem.read_exact(&mut buf).is_err() { + continue; + } + scanned += buf.len(); + if buf.windows(64).any(|w| matches(w, &seed_masked)) { + hits += 1; + } + if buf.windows(64).any(|w| matches(w, &secret_masked)) { + hits += 1; + } + zeroize::Zeroize::zeroize(&mut buf); + } + assert!( + scanned > 0, + "scanned nothing; /proc/self/mem is not readable here" + ); + assert_eq!(hits, 0, "a secret survived lock() in process memory"); + } +} diff --git a/crates/wallet-data/src/keys/mod.rs b/crates/wallet-data/src/keys/mod.rs index bb63d42..542d854 100644 --- a/crates/wallet-data/src/keys/mod.rs +++ b/crates/wallet-data/src/keys/mod.rs @@ -39,7 +39,7 @@ pub fn seed_from_mnemonic(mnemonic: &str, passphrase: Option<&str>) -> Result, + pub accounts: Vec, + pub locks_in_seconds: Option, +} + /// What the app tells the UI about itself at startup. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] diff --git a/ui/src/api/generated/SessionAccountInfo.ts b/ui/src/api/generated/SessionAccountInfo.ts new file mode 100644 index 0000000..48820ba --- /dev/null +++ b/ui/src/api/generated/SessionAccountInfo.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SignatureScheme } from "./SignatureScheme"; + +/** + * An account the open wallet can sign with, as the UI sees it. + */ +export type SessionAccountInfo = { address: string, scheme: SignatureScheme, }; diff --git a/ui/src/api/generated/SessionStatusInfo.ts b/ui/src/api/generated/SessionStatusInfo.ts new file mode 100644 index 0000000..0f8f59c --- /dev/null +++ b/ui/src/api/generated/SessionStatusInfo.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SessionAccountInfo } from "./SessionAccountInfo"; +import type { WalletSummary } from "./WalletSummary"; + +/** + * Whether a wallet is open, which one, and how long until the idle rule + * locks it again. + */ +export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array, locks_in_seconds: bigint | null, }; diff --git a/ui/src/api/wallet.ts b/ui/src/api/wallet.ts index d940caa..c62c43f 100644 --- a/ui/src/api/wallet.ts +++ b/ui/src/api/wallet.ts @@ -6,6 +6,7 @@ import { invoke } from '@tauri-apps/api/core' import type { AppInfo } from './generated/AppInfo' import type { CreationStart } from './generated/CreationStart' +import type { SessionStatusInfo } from './generated/SessionStatusInfo' import type { SignatureScheme } from './generated/SignatureScheme' import type { WalletSummary } from './generated/WalletSummary' import type { WalletError as WalletErrorShape } from './generated/WalletError' @@ -87,3 +88,17 @@ export function walletImportFile(path: string, password: string): Promise { return command('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 { + return command('unlock', { name, password }) +} + +export function lock(): Promise { + return command('lock') +} + +export function sessionStatus(): Promise { + return command('session_status') +}