diff --git a/crates/bsk-cli/src/cli/doctor.rs b/crates/bsk-cli/src/cli/doctor.rs index fe408a6a..4f3a2bba 100644 --- a/crates/bsk-cli/src/cli/doctor.rs +++ b/crates/bsk-cli/src/cli/doctor.rs @@ -173,7 +173,7 @@ enum DaemonState { NoListener(DaemonInfo), ProbeError(anyhow::Error), Verified { - status: StatusResult, + status: Box, local_identity_error: Option, /// Owned by a terminal or supervisor, which must restart it. host_managed: bool, @@ -208,6 +208,7 @@ fn collect_checks(state: DaemonState) -> Vec { check_version_compatible(state.status()), check_auto_update(state.status(), state.host_managed()), check_extension_connected(state.status()), + check_local_auth(state.status()), check_browsers_protocol_compatible(state.status()), ] } @@ -407,7 +408,7 @@ fn current_state(browser_wait: Duration) -> DaemonState { .err() .map(|err| format!("{err:#}")), host_managed: daemon.info.host_managed, - status: daemon.status, + status: Box::new(daemon.status), }, Ok(Probe::Absent(Some(info))) => DaemonState::NoListener(info), Ok(Probe::Absent(None)) => DaemonState::Missing, @@ -648,6 +649,31 @@ fn display_protocol(value: &str) -> &str { if value.is_empty() { "unknown" } else { value } } +fn check_local_auth(status: Option<&StatusResult>) -> CheckResult { + let mode = status + .and_then(|s| s.local_auth.as_ref()) + .map(|s| s.mode.clone()) + .or_else(|| { + paths::bsk_home() + .ok() + .and_then(|home| crate::daemon::local_auth::read_mode(&home).ok()) + .and_then(|mode| serde_json::to_value(mode).ok()) + .and_then(|value| value.as_str().map(str::to_owned)) + }); + if mode.as_deref() == Some("trust_local") { + CheckResult::warn( + "local connection verification", + "unverified local connections are allowed", + "run `bsk daemon trust-local off` and turn off skipping in the extension to restore verification", + ) + } else { + CheckResult::ok( + "local connection verification", + "local pairing verification is available", + ) + } +} + fn check_extension_connected(status: Option<&StatusResult>) -> CheckResult { let name = "extension connected"; let Some(status) = status else { @@ -657,6 +683,18 @@ fn check_extension_connected(status: Option<&StatusResult>) -> CheckResult { "start the daemon and load the extension first", ); }; + if status.browsers.is_empty() + && status + .local_auth + .as_ref() + .is_some_and(|auth| auth.last_unpaired_attempt.is_some()) + { + return CheckResult::fail( + name, + "extension is waiting for local pairing", + "run `bsk daemon pair`, then paste the link into the extension's connection settings", + ); + } let browsers = status.browsers.len(); let unresponsive = status .browsers @@ -784,6 +822,7 @@ mod m2_tests { fn fake_status(browsers: Vec, skew: Vec) -> StatusResult { StatusResult { + local_auth: None, daemon_version: env!("CARGO_PKG_VERSION").into(), protocol_version: "1.0".into(), pid: 1, @@ -955,7 +994,7 @@ mod m2_tests { #[test] fn unverified_local_identity_warns_without_failing_usable_ipc() { let state = DaemonState::Verified { - status: fake_status(Vec::new(), Vec::new()), + status: Box::new(fake_status(Vec::new(), Vec::new())), local_identity_error: Some("peer identity is unavailable".into()), host_managed: false, }; diff --git a/crates/bsk-cli/src/cli/render_error.rs b/crates/bsk-cli/src/cli/render_error.rs index c751cfc1..a87a2776 100644 --- a/crates/bsk-cli/src/cli/render_error.rs +++ b/crates/bsk-cli/src/cli/render_error.rs @@ -244,6 +244,13 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend return base; }; match (code, reason) { + (ErrorCode::NoBrowserConnected, "local_pairing_required") => RenderInfo { + summary: "the extension needs local pairing", + hint: Some( + "run `bsk daemon pair`, then paste the link into the extension's connection settings", + ), + ..base + }, (_, "user_denied") => RenderInfo { summary: "the user denied the tab borrow", hint: Some("do not repeat the same authorization request"), diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index c198da9a..384ab1df 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -96,6 +96,7 @@ impl DaemonStatus { /// fields are empty. pub fn snapshot(&self) -> StatusResult { StatusResult { + local_auth: None, daemon_version: self.daemon_version.to_string(), protocol_version: self.protocol_version.to_string(), pid: std::process::id(), @@ -138,6 +139,7 @@ impl DaemonStatus { .map(|s| s.status_entry()) .collect(); StatusResult { + local_auth: state.local_auth_status(), daemon_version: self.daemon_version.to_string(), protocol_version: self.protocol_version.to_string(), pid: std::process::id(), @@ -1342,7 +1344,11 @@ pub(super) async fn handle_session_start( if recoverable && let StartSessionError::CleanupFailed { session_id, .. } = &err { state.tool_queues.spawn(session_id.clone()); } - Err(map_start_error(err)) + let mut error = map_start_error(err); + if error.code == ErrorCode::NoBrowserConnected && state.recent_unpaired().is_some() { + error.data = Some(serde_json::json!({"reason":"local_pairing_required"})); + } + Err(error) } } } diff --git a/crates/bsk-cli/src/daemon/local_auth/handshake.rs b/crates/bsk-cli/src/daemon/local_auth/handshake.rs index e4ddd66a..5a7930bc 100644 --- a/crates/bsk-cli/src/daemon/local_auth/handshake.rs +++ b/crates/bsk-cli/src/daemon/local_auth/handshake.rs @@ -1,8 +1,5 @@ //! Authentication is isolated from browser registration and business dispatch. -use super::{ - LocalAuth, - store::{ActivationRejected, DeviceStatus, Pending, now, random}, -}; +use super::store::{ActivationRejected, DeviceStatus, Pending, now, random}; use crate::daemon::peer_authorization::{LocalPeer, PeerAuthorization}; use anyhow::{Result, anyhow, bail, ensure}; use bsk_protocol::local_auth::{ @@ -15,14 +12,24 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tokio_tungstenite::{WebSocketStream, tungstenite::Message}; pub(crate) async fn accept( - local: Arc, + state: Arc, ws: &mut WebSocketStream, origin: &str, ) -> Result<(PeerAuthorization, Option)> { + let local = state.local_auth.clone(); let first = text(ws).await?; let request: RequestFrame = serde_json::from_str(&first).map_err(|_| anyhow!("invalid authentication probe"))?; if request.method == Method::SystemHandshake { + let version = request + .params + .as_ref() + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()); + let capable = version + .and_then(|v| semver::Version::parse(v).ok()) + .is_some_and(|v| v >= semver::Version::new(0, 4, 0)); + state.record_unpaired(version, capable); if !local.mode().accepts_legacy() { reply( ws, @@ -36,7 +43,10 @@ pub(crate) async fn accept( .await?; bail!("local pairing required"); } - return Ok((PeerAuthorization::Legacy, Some(Message::Text(first)))); + return Ok(( + PeerAuthorization::Legacy(local.clone()), + Some(Message::Text(first)), + )); } if request.method != Method::SystemPing || request @@ -61,6 +71,7 @@ pub(crate) async fn accept( let probe = Probe::parse(&first)?; let hello = probe.params.local_auth; let Some(initial) = local.store.select(&hello.credentials) else { + state.record_unpaired(None, true); challenge( ws, &probe.id, @@ -73,18 +84,29 @@ pub(crate) async fn accept( bail!("local pairing required"); }; let device_id = initial.device_id.encode(); - let gate = local.device_gate(&device_id); - let Ok(_guard) = gate.try_lock_owned() else { - challenge( - ws, - &probe.id, - ChallengeResponse::Busy { - retry_after_ms: 1000, - }, - ) - .await?; - bail!("local device authentication busy"); - }; + let mut ids = vec![device_id.clone()]; + if let Some(replaces) = &initial.device.replaces { + ids.push(replaces.clone()); + } + ids.sort(); + ids.dedup(); + let mut guards = Vec::new(); + for id in &ids { + match local.device_gate(id).try_lock_owned() { + Ok(guard) => guards.push(guard), + Err(_) => { + challenge( + ws, + &probe.id, + ChallengeResponse::Busy { + retry_after_ms: 1000, + }, + ) + .await?; + bail!("local device authentication busy"); + } + } + } // Selection preceded the gate; re-read under it before issuing a proof. let selected = local .store @@ -150,7 +172,7 @@ pub(crate) async fn accept( .is_some_and(|p| p.txn == commit.txn) { durable_commit( - local.clone(), + state.clone(), device_id.clone(), commit.txn, hello.instance_id.clone(), @@ -236,7 +258,7 @@ pub(crate) async fn accept( "local rotation confirmation rejected" ); durable_commit( - local.clone(), + state.clone(), device_id.clone(), txn, hello.instance_id.clone(), @@ -272,18 +294,24 @@ pub(crate) async fn accept( device_id, browser_id, local, + lease: Default::default(), }), None, )) } async fn durable_commit( - local: Arc, + state: Arc, id: String, txn: bsk_protocol::local_auth::Bytes<16>, instance: String, ) -> Result<()> { - tokio::task::spawn_blocking(move || local.store.commit(&id, txn, &instance)).await??; + let local = state.local_auth.clone(); + let replaced = + tokio::task::spawn_blocking(move || local.store.commit(&id, txn, &instance)).await??; + if let Some(old) = replaced { + super::lifecycle::disconnect(&state, &[old]).await?; + } Ok(()) } async fn text(ws: &mut WebSocketStream) -> Result { diff --git a/crates/bsk-cli/src/daemon/local_auth/lifecycle.rs b/crates/bsk-cli/src/daemon/local_auth/lifecycle.rs new file mode 100644 index 00000000..d0fffb96 --- /dev/null +++ b/crates/bsk-cli/src/daemon/local_auth/lifecycle.rs @@ -0,0 +1,277 @@ +//! Registration, revocation and replacement share the same device gates. +use crate::daemon::{browsers::BrowserClient, state::DaemonState}; +use anyhow::{Result, ensure}; +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use tokio::sync::watch; + +#[derive(Debug)] +pub struct Lifecycle { + connections: Mutex>, + changed: watch::Sender<()>, + pub(crate) registry_gate: Arc>, +} +#[derive(Debug)] +struct Connection { + browser: Weak, + active: Arc, + closed: watch::Receiver, +} +pub(crate) struct Lease { + active: Arc, + closed: watch::Sender, +} +impl Default for Lifecycle { + fn default() -> Self { + Self { + connections: Mutex::new(HashMap::new()), + changed: watch::channel(()).0, + registry_gate: Arc::new(tokio::sync::Mutex::new(())), + } + } +} +impl Default for Lease { + fn default() -> Self { + Self { + active: Arc::new(AtomicBool::new(true)), + closed: watch::channel(false).0, + } + } +} +impl Lease { + pub fn active(&self) -> bool { + self.active.load(Ordering::Acquire) + } +} +impl Lifecycle { + pub(crate) fn changes(&self) -> watch::Receiver<()> { + self.changed.subscribe() + } + pub(crate) fn notify(&self) { + self.changed.send_replace(()); + } + pub(crate) fn register(&self, id: &str, lease: &Lease, browser: &Arc) { + let previous = self.connections.lock().unwrap().insert( + id.into(), + Connection { + browser: Arc::downgrade(browser), + active: lease.active.clone(), + closed: lease.closed.subscribe(), + }, + ); + if let Some(previous) = previous { + previous.active.store(false, Ordering::Release); + } + self.notify(); + } + pub(crate) fn closed(&self, id: &str, lease: &Lease) { + let mut connections = self.connections.lock().unwrap(); + if connections + .get(id) + .is_some_and(|entry| Arc::ptr_eq(&entry.active, &lease.active)) + { + connections.remove(id); + } + lease.closed.send_replace(true); + } +} + +/// Called with the relevant device gates held, after the durable revocation. +pub(crate) async fn disconnect(state: &Arc, ids: &[String]) -> Result<()> { + let mut closing = Vec::new(); + { + let mut connections = state.local_auth.lifecycle.connections.lock().unwrap(); + for id in ids { + if let Some(connection) = connections.remove(id) { + connection.active.store(false, Ordering::Release); + if let Some(browser) = connection.browser.upgrade() { + remove_browser(state, &browser); + } + closing.push(connection.closed); + } + } + } + state.local_auth.lifecycle.notify(); + for mut closed in closing { + tokio::time::timeout(Duration::from_secs(2), async { + while !*closed.borrow_and_update() { + if closed.changed().await.is_err() { + break; + } + } + }) + .await + .map_err(|_| anyhow::anyhow!("authorization revoked; connection shutdown timed out"))?; + } + Ok(()) +} +pub(crate) fn remove_browser(state: &DaemonState, client: &BrowserClient) { + client.pending.lock().unwrap().close(); + if state + .browsers + .remove_if_generation_matches(&client.id, client.generation) + .is_some() + { + for session in state.sessions.purge_browser(&client.id) { + state.tool_queues.remove(&session.id); + state.session_interrupts.drop_session(&session.id); + state.transfers.release_session(&session.id.0); + } + } +} +pub async fn revoke(state: Arc, id: Option) -> Result<()> { + let mut ids = if let Some(id) = &id { + vec![state.local_auth.store.resolve(id)?] + } else { + state + .local_auth + .store + .devices() + .into_iter() + .map(|d| d.device_id) + .collect() + }; + ids.sort(); + let mut guards = Vec::new(); + for id in &ids { + guards.push(state.local_auth.device_gate(id).lock_owned().await); + } + // Also covers a newly issued record that appeared after the --all snapshot: + // no final check/insert may cross the durable revocation of all records. + let registry = state + .local_auth + .lifecycle + .registry_gate + .clone() + .lock_owned() + .await; + let local = state.local_auth.clone(); + let revoked = tokio::task::spawn_blocking(move || local.store.revoke(id.as_deref())).await??; + disconnect(&state, &revoked).await?; + drop(registry); + drop(guards); + Ok(()) +} + +pub(crate) async fn revoke_command( + state: Arc, + value: serde_json::Value, +) -> Result { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Params { + device_id: Option, + #[serde(default)] + all: bool, + } + let params: Params = serde_json::from_value(value) + .map_err(|_| anyhow::anyhow!("invalid revocation parameters"))?; + ensure!( + params.all != params.device_id.is_some(), + "specify a device or --all" + ); + let home = crate::daemon::paths::ensure_bsk_home()?; + let remote = crate::daemon::remote::authorization::AuthorizationStore::at_home(&home); + if params.all { + revoke(state, None).await?; + tokio::task::spawn_blocking(move || remote.revoke_all()).await??; + } else { + let id = params.device_id.unwrap(); + if state.local_auth.store.resolve(&id).is_ok() { + revoke(state, Some(id)).await?; + } else { + ensure!( + tokio::task::spawn_blocking(move || remote.revoke(&id)).await??, + "device grant not found" + ); + } + } + Ok(serde_json::json!({"revoked":true})) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::{ + DaemonConfig, + browsers::{BrowserId, BrowserSink, Liveness, Pending, next_browser_generation}, + local_auth::{LocalAuth, LocalAuthMode, store}, + peer_authorization::{LocalPeer, PeerAuthorization}, + }; + + fn activate(local: &LocalAuth) -> String { + local.store.pair(52800, None).unwrap(); + let id = local.store.devices()[0].device_id.clone(); + let txn = store::random(); + local + .store + .stage_rotation( + &id, + 0, + None, + store::Pending { + txn, + r#gen: 1, + key: store::random(), + created_at: store::now(), + }, + "a1b2c3d4", + ) + .unwrap(); + local.store.commit(&id, txn, "a1b2c3d4").unwrap(); + id + } + + #[tokio::test] + async fn l10_revocation_waits_for_a_paused_final_registration() { + let state = Arc::new(DaemonState::new( + DaemonConfig::new(0).with_local_auth(LocalAuthMode::Required), + )); + let id = activate(&state.local_auth); + let peer = PeerAuthorization::Local(LocalPeer { + device_id: id.clone(), + browser_id: "a1b2c3d4".into(), + local: state.local_auth.clone(), + lease: Lease::default(), + }); + let guard = peer.registration_guard().await; + assert!(peer.authorized().await); + // Deliberately pause after the final authorization check and before insert. + let revoker = tokio::spawn(revoke(state.clone(), Some(id))); + tokio::task::yield_now().await; + assert!(!revoker.is_finished()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let client = Arc::new(BrowserClient { + id: BrowserId("a1b2c3d4".into()), + browser_name: "Chrome".into(), + browser_version: "130".into(), + extension_version: "0.4.0".into(), + extension_protocol_version: "1.3".into(), + label: String::new(), + sink: BrowserSink { tx }, + pending: Mutex::new(Pending::default()), + generation: next_browser_generation(), + connected_at_ms: 0, + version_skew: false, + liveness: Liveness::default(), + }); + state.browsers.insert(client.clone()); + peer.register(&client); + drop(guard); + let socket_state = state.clone(); + let socket = tokio::spawn(async move { + peer.revoked().await; + remove_browser(&socket_state, &client); + drop(peer); + }); + revoker.await.unwrap().unwrap(); + assert!(state.browsers.is_empty()); + socket.await.unwrap(); + } +} diff --git a/crates/bsk-cli/src/daemon/local_auth/maintenance.rs b/crates/bsk-cli/src/daemon/local_auth/maintenance.rs index f6d9851d..e75dbe16 100644 --- a/crates/bsk-cli/src/daemon/local_auth/maintenance.rs +++ b/crates/bsk-cli/src/daemon/local_auth/maintenance.rs @@ -108,6 +108,28 @@ pub async fn handle( method: Method, params: Value, ) -> ResponseBody { + if method == Method::LocalAuthRevoke { + return match super::lifecycle::revoke_command(state, params).await { + Ok(value) => ResponseBody::Ok(value), + Err(error) => ResponseBody::Err(RpcError { + code: ErrorCode::InvalidParams, + message: error.to_string(), + data: None, + }), + }; + } + // A rotation request cannot be lost behind an in-flight activation commit. + let _rotation_guard = if method == Method::LocalAuthRotate { + match serde_json::from_value::(params.clone()) + .ok() + .and_then(|args| state.local_auth.store.resolve(&args.device_id).ok()) + { + Some(id) => Some(state.local_auth.device_gate(&id).lock_owned().await), + None => None, // The shared executor returns the validation error. + } + } else { + None + }; let result = tokio::task::spawn_blocking(move || { execute( &state.local_auth, diff --git a/crates/bsk-cli/src/daemon/local_auth/mod.rs b/crates/bsk-cli/src/daemon/local_auth/mod.rs index c47e778e..e2f57579 100644 --- a/crates/bsk-cli/src/daemon/local_auth/mod.rs +++ b/crates/bsk-cli/src/daemon/local_auth/mod.rs @@ -1,5 +1,6 @@ //! Local pairing credentials and policy. Remote grants use a separate store. pub(crate) mod handshake; +pub mod lifecycle; pub mod maintenance; pub(crate) mod read_limit; pub mod store; @@ -42,6 +43,7 @@ fn write_mode(home: &Path, mode: LocalAuthMode) -> Result<()> { #[derive(Debug)] pub struct LocalAuth { pub store: store::PairingStore, + pub lifecycle: lifecycle::Lifecycle, mode: Mutex, gates: Mutex>>>, } @@ -53,6 +55,7 @@ impl LocalAuth { store: store::PairingStore::open(lock)?, mode: Mutex::new(read_mode(home)?), gates: Default::default(), + lifecycle: Default::default(), }) } pub fn memory(mode: LocalAuthMode) -> Self { @@ -60,6 +63,7 @@ impl LocalAuth { store: store::PairingStore::memory(), mode: Mutex::new(mode), gates: Default::default(), + lifecycle: Default::default(), } } pub(crate) fn device_gate(&self, id: &str) -> std::sync::Arc> { @@ -79,6 +83,7 @@ impl LocalAuth { let mut current = self.mode.lock().expect("local auth mode poisoned"); write_mode(home, mode)?; *current = mode; + self.lifecycle.notify(); Ok(()) } } diff --git a/crates/bsk-cli/src/daemon/peer_authorization.rs b/crates/bsk-cli/src/daemon/peer_authorization.rs index 69ea614a..87d74676 100644 --- a/crates/bsk-cli/src/daemon/peer_authorization.rs +++ b/crates/bsk-cli/src/daemon/peer_authorization.rs @@ -6,27 +6,31 @@ pub(crate) struct LocalPeer { pub device_id: String, pub browser_id: String, pub local: Arc, + pub lease: super::local_auth::lifecycle::Lease, } pub(crate) enum PeerAuthorization { - Legacy, + Legacy(Arc), Local(LocalPeer), Remote(super::remote::ConnectionAuthorization), } impl PeerAuthorization { pub fn browser_id<'a>(&'a self, claimed: &'a str) -> &'a str { match self { - Self::Legacy => claimed, + Self::Legacy(_) => claimed, Self::Local(peer) => &peer.browser_id, Self::Remote(peer) => &peer.device.browser_id, } } pub fn active(&self) -> bool { match self { - Self::Legacy => true, - Self::Local(peer) => peer.local.store.device(&peer.device_id).is_some_and(|d| { - d.status == DeviceStatus::Active - && d.browser_id.as_deref() == Some(&peer.browser_id) - }), + Self::Legacy(local) => local.mode().accepts_legacy(), + Self::Local(peer) => { + peer.lease.active() + && peer.local.store.device(&peer.device_id).is_some_and(|d| { + d.status == DeviceStatus::Active + && d.browser_id.as_deref() == Some(&peer.browser_id) + }) + } Self::Remote(peer) => peer.active(), } } @@ -38,21 +42,61 @@ impl PeerAuthorization { } pub async fn revoked(&self) { match self { - Self::Legacy => std::future::pending::<()>().await, + Self::Legacy(local) => { + let mut changed = local.lifecycle.changes(); + while self.active() { + if changed.changed().await.is_err() { + return; + } + } + } Self::Remote(peer) => peer.revoked().await, Self::Local(peer) => { let mut changed = peer.local.store.changes(); - let mut timer = tokio::time::interval(std::time::Duration::from_secs(1)); - loop { - if !self.active() { - return; + let mut lifecycle = peer.local.lifecycle.changes(); + while self.active() { + tokio::select! { + _ = changed.changed() => {}, + _ = lifecycle.changed() => {}, } - tokio::select! { _ = changed.changed() => {}, _ = timer.tick() => {} } } } } } + pub async fn registration_guard( + &self, + ) -> Option<( + tokio::sync::OwnedMutexGuard<()>, + tokio::sync::OwnedMutexGuard<()>, + )> { + if let Self::Local(peer) = self { + let device = peer.local.device_gate(&peer.device_id).lock_owned().await; + let registry = peer + .local + .lifecycle + .registry_gate + .clone() + .lock_owned() + .await; + Some((device, registry)) + } else { + None + } + } + pub fn register(&self, client: &Arc) { + if let Self::Local(peer) = self { + peer.local + .lifecycle + .register(&peer.device_id, &peer.lease, client); + } + } pub fn matches_instance(&self, instance: &str) -> bool { !matches!(self, Self::Local(peer) if peer.browser_id != instance) } } + +impl Drop for LocalPeer { + fn drop(&mut self) { + self.local.lifecycle.closed(&self.device_id, &self.lease); + } +} diff --git a/crates/bsk-cli/src/daemon/state.rs b/crates/bsk-cli/src/daemon/state.rs index 8437efca..b14291d9 100644 --- a/crates/bsk-cli/src/daemon/state.rs +++ b/crates/bsk-cli/src/daemon/state.rs @@ -30,6 +30,8 @@ pub struct DaemonState { pub audit: Arc, pub config: DaemonConfig, pub local_auth: Arc, + last_unpaired_attempt: + std::sync::Mutex>, pub browsers: Arc, pub sessions: Arc, pub session_requests: super::session_requests::SessionRequests, @@ -88,6 +90,52 @@ impl DaemonState { } impl DaemonState { + pub fn record_unpaired(&self, version: Option<&str>, pairing_capable: bool) { + if !pairing_capable && self.local_auth.mode() == super::local_auth::LocalAuthMode::Warn { + return; + } + let extension_version = version + .and_then(|v| semver::Version::parse(v).ok()) + .map(|v| v.to_string()); + let at = time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .expect("current timestamp"); + *self.last_unpaired_attempt.lock().unwrap() = Some(( + std::time::Instant::now(), + bsk_protocol::system::UnpairedAttempt { + at, + extension_version, + }, + )); + } + pub fn recent_unpaired(&self) -> Option { + self.last_unpaired_attempt + .lock() + .unwrap() + .as_ref() + .filter(|(when, _)| when.elapsed() < std::time::Duration::from_secs(300)) + .map(|(_, attempt)| attempt.clone()) + } + pub fn local_auth_status(&self) -> Option { + if self.config.server.is_some() { + return None; + } + Some(bsk_protocol::system::LocalAuthStatus { + mode: serde_json::to_value(self.local_auth.mode()) + .expect("local policy") + .as_str() + .unwrap() + .into(), + paired_devices: self + .local_auth + .store + .devices() + .iter() + .filter(|d| d.status == super::local_auth::store::DeviceStatus::Active) + .count(), + last_unpaired_attempt: self.recent_unpaired(), + }) + } pub fn new(config: DaemonConfig) -> Self { if crate::cli::human_loop::legacy_help_override_requested() { crate::cli::interaction_policy::warn_legacy_override("BSK_REQUEST_HELP=off"); @@ -108,6 +156,7 @@ impl DaemonState { Self { audit, local_auth: Arc::new(super::local_auth::LocalAuth::memory(config.local_auth)), + last_unpaired_attempt: Default::default(), config, browsers, sessions, diff --git a/crates/bsk-cli/src/daemon/ws.rs b/crates/bsk-cli/src/daemon/ws.rs index f4718742..17a20e62 100644 --- a/crates/bsk-cli/src/daemon/ws.rs +++ b/crates/bsk-cli/src/daemon/ws.rs @@ -189,7 +189,7 @@ async fn handle_connection( let origin = captured_origin.lock().unwrap().clone().unwrap_or_default(); let authentication = tokio::time::timeout( HANDSHAKE_FIRST_FRAME_TIMEOUT, - super::local_auth::handshake::accept(state.local_auth.clone(), &mut ws, &origin), + super::local_auth::handshake::accept(state.clone(), &mut ws, &origin), ) .await; let (authorization, first) = match authentication { @@ -342,6 +342,7 @@ pub(super) async fn drive_connection, /// Semver of the daemon binary (`env!("CARGO_PKG_VERSION")`). pub daemon_version: String, /// Logical protocol revision (e.g. `"1.0"`). @@ -604,6 +606,18 @@ pub struct StatusResult { pub version_skew_browsers: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct LocalAuthStatus { + pub mode: String, + pub paired_devices: usize, + pub last_unpaired_attempt: Option, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct UnpairedAttempt { + pub at: String, + pub extension_version: Option, +} + #[cfg(test)] mod status_compat_tests { use super::*;