feat(session): password in, signatures out, locked again after idling
ci / gate (push) Waiting to run
ci / gate (push) Waiting to run
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
@@ -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<String, WalletError> {
|
||||
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<SessionStatusInfo, WalletError> {
|
||||
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<SessionStatusInfo, WalletError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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::<state::AppState>();
|
||||
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");
|
||||
|
||||
@@ -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<Option<PendingCreation>>,
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
pub mod backup;
|
||||
pub mod paths;
|
||||
pub mod ports;
|
||||
pub mod session;
|
||||
pub mod ss58;
|
||||
|
||||
pub use ports::*;
|
||||
|
||||
@@ -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<Box<[u8]>>);
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Box<dyn Signer>>,
|
||||
seed: Option<Seed>,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionAccount {
|
||||
pub account_id: [u8; 32],
|
||||
pub scheme: SignatureScheme,
|
||||
pub public_key: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionStatus {
|
||||
pub wallet: Option<WalletSummary>,
|
||||
pub accounts: Vec<SessionAccount>,
|
||||
/// Seconds until the idle rule locks the session; `None` when locked.
|
||||
pub locks_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[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<Option<Unlocked>>,
|
||||
}
|
||||
|
||||
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<Seed>,
|
||||
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<bool, SessionError> {
|
||||
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<SessionStatus, SessionError> {
|
||||
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<Vec<u8>, 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<T>(&self, f: impl FnOnce(&Seed) -> T) -> Result<Option<T>, 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<Vec<u8>>,
|
||||
public: Vec<u8>,
|
||||
}
|
||||
|
||||
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<Vec<u8>, 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<u8> = 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<u8> = 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");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub fn seed_from_mnemonic(mnemonic: &str, passphrase: Option<&str>) -> Result<Se
|
||||
let mut seed = SensitiveBytes64::zeroed();
|
||||
hd::mnemonic_to_seed(mnemonic.to_owned(), passphrase, &mut seed)
|
||||
.map_err(|e| KeyError::Derivation(e.to_string()))?;
|
||||
Ok(Seed::new(*seed.as_bytes()))
|
||||
Ok(Seed::copy_from(seed.as_bytes()))
|
||||
}
|
||||
|
||||
impl MlDsa {
|
||||
|
||||
@@ -161,6 +161,25 @@ pub struct CreationStart {
|
||||
pub challenge: CreationChallenge,
|
||||
}
|
||||
|
||||
/// An account the open wallet can sign with, as the UI sees it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SessionAccountInfo {
|
||||
pub address: String,
|
||||
pub scheme: SignatureScheme,
|
||||
}
|
||||
|
||||
/// Whether a wallet is open, which one, and how long until the idle rule
|
||||
/// locks it again.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SessionStatusInfo {
|
||||
pub locked: bool,
|
||||
pub wallet: Option<WalletSummary>,
|
||||
pub accounts: Vec<SessionAccountInfo>,
|
||||
pub locks_in_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
/// What the app tells the UI about itself at startup.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
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, };
|
||||
@@ -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<SessionAccountInfo>, locks_in_seconds: bigint | null, };
|
||||
@@ -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<Wallet
|
||||
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 })
|
||||
}
|
||||
|
||||
export function lock(): Promise<void> {
|
||||
return command<void>('lock')
|
||||
}
|
||||
|
||||
export function sessionStatus(): Promise<SessionStatusInfo> {
|
||||
return command<SessionStatusInfo>('session_status')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user