feat(session): password in, signatures out, locked again after idling
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:
2026-09-15 18:28:40 +03:00
co-authored by Claude Fable 5.1
parent de55d1dfb9
commit 18caa2b81c
11 changed files with 549 additions and 7 deletions
+69
View File
@@ -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,
})
}
+24
View File
@@ -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");
+7
View File
@@ -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),
})
}
}
+1
View File
@@ -14,6 +14,7 @@
pub mod backup;
pub mod paths;
pub mod ports;
pub mod session;
pub mod ss58;
pub use ports::*;
+11 -6
View File
@@ -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")
}
}
+386
View File
@@ -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");
}
}
+1 -1
View File
@@ -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 {
+19
View File
@@ -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, };
+15
View File
@@ -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')
}