From 34d0075b047a848cde41677c53ca21c190823747 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 13 Oct 2026 19:41:38 +0800 Subject: [PATCH] feat(daemon): authenticate local WebSocket peers before registration --- .../src/daemon/local_auth/handshake.rs | 330 ++++++++++ crates/bsk-cli/src/daemon/local_auth/mod.rs | 15 + .../src/daemon/local_auth/read_limit.rs | 186 ++++++ crates/bsk-cli/src/daemon/local_auth/store.rs | 8 + crates/bsk-cli/src/daemon/mod.rs | 1 + .../bsk-cli/src/daemon/peer_authorization.rs | 58 ++ crates/bsk-cli/src/daemon/remote/server.rs | 10 +- crates/bsk-cli/src/daemon/start.rs | 9 +- crates/bsk-cli/src/daemon/ws.rs | 90 +-- crates/bsk-cli/tests/audit_ws.rs | 2 +- crates/bsk-cli/tests/browser_list_ordering.rs | 3 +- crates/bsk-cli/tests/browser_liveness.rs | 1 + crates/bsk-cli/tests/browser_wait.rs | 3 +- crates/bsk-cli/tests/cancel_forwarding.rs | 3 +- crates/bsk-cli/tests/dialog_control.rs | 9 +- crates/bsk-cli/tests/handshake_compat.rs | 3 +- crates/bsk-cli/tests/local_auth_ws.rs | 618 ++++++++++++++++++ crates/bsk-cli/tests/per_session_queue.rs | 9 +- .../bsk-cli/tests/record_export_recovery.rs | 9 +- crates/bsk-cli/tests/record_stop_retry.rs | 9 +- .../bsk-cli/tests/session_user_interrupt.rs | 3 +- crates/bsk-cli/tests/sessions_ipc.rs | 7 +- crates/bsk-cli/tests/tools_ipc.rs | 9 +- crates/bsk-cli/tests/tools_m7_ipc.rs | 9 +- crates/bsk-cli/tests/tools_m8_ipc.rs | 9 +- crates/bsk-cli/tests/tools_m9_ipc.rs | 9 +- crates/bsk-cli/tests/ws_handshake.rs | 3 +- 27 files changed, 1342 insertions(+), 83 deletions(-) create mode 100644 crates/bsk-cli/src/daemon/local_auth/handshake.rs create mode 100644 crates/bsk-cli/src/daemon/local_auth/read_limit.rs create mode 100644 crates/bsk-cli/src/daemon/peer_authorization.rs create mode 100644 crates/bsk-cli/tests/local_auth_ws.rs diff --git a/crates/bsk-cli/src/daemon/local_auth/handshake.rs b/crates/bsk-cli/src/daemon/local_auth/handshake.rs new file mode 100644 index 000000000..e4ddd66af --- /dev/null +++ b/crates/bsk-cli/src/daemon/local_auth/handshake.rs @@ -0,0 +1,330 @@ +//! Authentication is isolated from browser registration and business dispatch. +use super::{ + LocalAuth, + store::{ActivationRejected, DeviceStatus, Pending, now, random}, +}; +use crate::daemon::peer_authorization::{LocalPeer, PeerAuthorization}; +use anyhow::{Result, anyhow, bail, ensure}; +use bsk_protocol::local_auth::{ + AuthMessage, ChallengeResponse, Commit, Probe, Purpose, Rotation, Transcript, V1, prove, verify, +}; +use bsk_protocol::{ErrorCode, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError}; +use futures_util::{SinkExt, StreamExt}; +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_tungstenite::{WebSocketStream, tungstenite::Message}; + +pub(crate) async fn accept( + local: Arc, + ws: &mut WebSocketStream, + origin: &str, +) -> Result<(PeerAuthorization, Option)> { + let first = text(ws).await?; + let request: RequestFrame = + serde_json::from_str(&first).map_err(|_| anyhow!("invalid authentication probe"))?; + if request.method == Method::SystemHandshake { + if !local.mode().accepts_legacy() { + reply( + ws, + &request.id, + ResponseBody::Err(RpcError { + code: ErrorCode::PairingRequired, + message: "update the extension and run `bsk daemon pair` to connect".into(), + data: None, + }), + ) + .await?; + bail!("local pairing required"); + } + return Ok((PeerAuthorization::Legacy, Some(Message::Text(first)))); + } + if request.method != Method::SystemPing + || request + .params + .as_ref() + .and_then(|p| p.get("local_auth")) + .is_none() + { + reply( + ws, + &request.id, + ResponseBody::Err(RpcError { + code: ErrorCode::ProtocolError, + message: "first frame must be system.handshake or a local authentication probe" + .into(), + data: None, + }), + ) + .await?; + bail!("invalid first frame"); + } + let probe = Probe::parse(&first)?; + let hello = probe.params.local_auth; + let Some(initial) = local.store.select(&hello.credentials) else { + challenge( + ws, + &probe.id, + ChallengeResponse::PairingRequired { + daemon_id: local.store.daemon_id(), + legacy_accepted: local.mode().accepts_legacy(), + }, + ) + .await?; + 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"); + }; + // Selection preceded the gate; re-read under it before issuing a proof. + let selected = local + .store + .select(&hello.credentials) + .filter(|s| s.device_id == initial.device_id) + .ok_or_else(|| anyhow!("local authorization changed"))?; + let server_nonce = random(); + let daemon_id = local.store.daemon_id(); + let transcript = Transcript { + daemon_id: &daemon_id, + device_id: &selected.device_id, + r#gen: selected.r#gen, + instance_id: &hello.instance_id, + origin, + client_nonce: &hello.client_nonce, + server_nonce: &server_nonce, + } + .encode(); + challenge( + ws, + &probe.id, + ChallengeResponse::Challenge { + daemon_id, + device_id: selected.device_id, + r#gen: selected.r#gen, + server_nonce, + server_proof: prove(&selected.key, Purpose::Server, &transcript, None), + }, + ) + .await?; + let AuthMessage::Prove { client_proof, .. } = AuthMessage::parse(&text(ws).await?)? else { + bail!("expected client proof"); + }; + ensure!( + verify( + &selected.key, + Purpose::Client, + &transcript, + None, + &client_proof + ), + "local client proof rejected" + ); + if let Err(error) = local.store.check_activation(&device_id, &hello.instance_id) { + if let Some(rejected) = error.downcast_ref::() { + send( + ws, + &AuthMessage::Rejected { + v: V1, + reason: rejected.reason, + browser_id: rejected.browser_id.clone(), + }, + ) + .await?; + } + return Err(error); + } + if let Some(commit) = selected.commit { + if selected + .device + .pending + .as_ref() + .is_some_and(|p| p.txn == commit.txn) + { + durable_commit( + local.clone(), + device_id.clone(), + commit.txn, + hello.instance_id.clone(), + ) + .await?; + } + send( + ws, + &AuthMessage::Ready { + v: V1, + rotation: None, + commit: Some(Commit { + txn: commit.txn, + r#gen: commit.r#gen, + proof: prove( + &selected.key, + Purpose::Committed, + &transcript, + Some(&commit.txn), + ), + }), + }, + ) + .await?; + } else if selected.device.status == DeviceStatus::Unactivated + || selected.device.rotate_requested + || selected.device.pending.is_some() + { + let txn = random(); + let generation = selected + .r#gen + .checked_add(1) + .ok_or_else(|| anyhow!("local key generation exhausted"))?; + let candidate = prove(&selected.key, Purpose::Rotate, &transcript, Some(&txn)); + let pending = Pending { + txn, + r#gen: generation, + key: candidate, + created_at: now(), + }; + let store = local.clone(); + let id = device_id.clone(); + let instance = hello.instance_id.clone(); + tokio::task::spawn_blocking(move || { + store.store.stage_rotation( + &id, + selected.r#gen, + selected.device.pending.map(|p| p.txn), + pending, + &instance, + ) + }) + .await??; + send( + ws, + &AuthMessage::Ready { + v: V1, + commit: None, + rotation: Some(Rotation { + txn, + r#gen: generation, + }), + }, + ) + .await?; + let AuthMessage::RotateConfirm { + txn: confirmed, + proof, + .. + } = AuthMessage::parse(&text(ws).await?)? + else { + bail!("expected rotation confirmation"); + }; + ensure!( + confirmed == txn + && verify( + &candidate, + Purpose::Confirm, + &transcript, + Some(&txn), + &proof + ), + "local rotation confirmation rejected" + ); + durable_commit( + local.clone(), + device_id.clone(), + txn, + hello.instance_id.clone(), + ) + .await?; + send( + ws, + &AuthMessage::Committed { + v: V1, + r#gen: generation, + proof: prove(&candidate, Purpose::Committed, &transcript, Some(&txn)), + }, + ) + .await?; + } else { + send( + ws, + &AuthMessage::Ready { + v: V1, + commit: None, + rotation: None, + }, + ) + .await?; + } + let browser_id = local + .store + .device(&device_id) + .and_then(|d| d.browser_id) + .ok_or_else(|| anyhow!("local activation incomplete"))?; + Ok(( + PeerAuthorization::Local(LocalPeer { + device_id, + browser_id, + local, + }), + None, + )) +} + +async fn durable_commit( + local: 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??; + Ok(()) +} +async fn text(ws: &mut WebSocketStream) -> Result { + match ws.next().await { + Some(Ok(Message::Text(text))) + if text.len() <= bsk_protocol::local_auth::MAX_MESSAGE_BYTES => + { + Ok(text) + } + _ => bail!("invalid local authentication frame"), + } +} +async fn send( + ws: &mut WebSocketStream, + message: &AuthMessage, +) -> Result<()> { + ws.send(Message::Text(serde_json::to_string(message)?)) + .await?; + Ok(()) +} +async fn challenge( + ws: &mut WebSocketStream, + id: &str, + value: ChallengeResponse, +) -> Result<()> { + reply( + ws, + id, + ResponseBody::Ok(serde_json::json!({"local_auth":value})), + ) + .await +} +async fn reply( + ws: &mut WebSocketStream, + id: &str, + body: ResponseBody, +) -> Result<()> { + ws.send(Message::Text(serde_json::to_string(&ResponseFrame { + id: id.into(), + body, + })?)) + .await?; + Ok(()) +} diff --git a/crates/bsk-cli/src/daemon/local_auth/mod.rs b/crates/bsk-cli/src/daemon/local_auth/mod.rs index 705f59da4..c47e778e0 100644 --- a/crates/bsk-cli/src/daemon/local_auth/mod.rs +++ b/crates/bsk-cli/src/daemon/local_auth/mod.rs @@ -1,5 +1,7 @@ //! Local pairing credentials and policy. Remote grants use a separate store. +pub(crate) mod handshake; pub mod maintenance; +pub(crate) mod read_limit; pub mod store; use anyhow::Result; @@ -41,6 +43,7 @@ fn write_mode(home: &Path, mode: LocalAuthMode) -> Result<()> { pub struct LocalAuth { pub store: store::PairingStore, mode: Mutex, + gates: Mutex>>>, } impl LocalAuth { /// The caller keeps this lifecycle lock until all daemon work has stopped. @@ -49,14 +52,26 @@ impl LocalAuth { Ok(Self { store: store::PairingStore::open(lock)?, mode: Mutex::new(read_mode(home)?), + gates: Default::default(), }) } pub fn memory(mode: LocalAuthMode) -> Self { Self { store: store::PairingStore::memory(), mode: Mutex::new(mode), + gates: Default::default(), } } + pub(crate) fn device_gate(&self, id: &str) -> std::sync::Arc> { + let mut gates = self.gates.lock().expect("local device gates poisoned"); + gates.retain(|_, gate| gate.strong_count() > 0); + if let Some(gate) = gates.get(id).and_then(std::sync::Weak::upgrade) { + return gate; + } + let gate = std::sync::Arc::new(tokio::sync::Mutex::new(())); + gates.insert(id.into(), std::sync::Arc::downgrade(&gate)); + gate + } pub fn mode(&self) -> LocalAuthMode { *self.mode.lock().expect("local auth mode poisoned") } diff --git a/crates/bsk-cli/src/daemon/local_auth/read_limit.rs b/crates/bsk-cli/src/daemon/local_auth/read_limit.rs new file mode 100644 index 000000000..315f460b4 --- /dev/null +++ b/crates/bsk-cli/src/daemon/local_auth/read_limit.rs @@ -0,0 +1,186 @@ +//! Enforce the small pre-authentication limit before tungstenite allocates a +//! payload. Its public async wrapper cannot change WebSocketConfig after open. +use bsk_protocol::local_auth::MAX_MESSAGE_BYTES; +use std::{ + io, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +pub struct AuthReadLimit { + inner: S, + released: Arc, + parser: HeaderLimit, +} +impl AuthReadLimit { + pub fn new(inner: S) -> (Self, Arc) { + let released = Arc::new(AtomicBool::new(false)); + ( + Self { + inner, + released: released.clone(), + parser: HeaderLimit::default(), + }, + released, + ) + } +} +#[derive(Default)] +struct HeaderLimit { + http_done: bool, + http_bytes: usize, + tail: u32, + header: Vec, + remaining: usize, + message_bytes: usize, + finish_message: bool, +} +impl HeaderLimit { + fn accept(&mut self, bytes: &[u8]) -> io::Result<()> { + let reject = || { + io::Error::new( + io::ErrorKind::InvalidData, + "local authentication frame limit", + ) + }; + for &byte in bytes { + if !self.http_done { + self.http_bytes += 1; + if self.http_bytes > 16 * 1024 { + return Err(reject()); + } + self.tail = (self.tail << 8) | u32::from(byte); + if self.tail == 0x0d0a0d0a { + self.http_done = true; + } + continue; + } + if self.remaining > 0 { + self.remaining -= 1; + if self.remaining == 0 && self.finish_message { + self.message_bytes = 0; + } + continue; + } + self.header.push(byte); + if self.header.len() < 2 { + continue; + } + let code = self.header[1] & 127; + let length_bytes = match code { + 126 => 2, + 127 => 8, + _ => 0, + }; + // Inspect length before passing its last byte to tungstenite. + if self.header.len() != 2 + length_bytes { + continue; + } + let length = if length_bytes == 0 { + u64::from(code) + } else { + self.header[2..] + .iter() + .fold(0u64, |n, b| (n << 8) | u64::from(*b)) + }; + if length > MAX_MESSAGE_BYTES as u64 { + return Err(reject()); + } + let data = self.header[0] & 8 == 0; + if data { + self.message_bytes += length as usize; + if self.message_bytes > MAX_MESSAGE_BYTES { + return Err(reject()); + } + } + self.finish_message = data && self.header[0] & 128 != 0; + self.remaining = length as usize + if self.header[1] & 128 != 0 { 4 } else { 0 }; + if self.remaining == 0 && self.finish_message { + self.message_bytes = 0; + } + self.header.clear(); + } + Ok(()) + } +} +impl AsyncRead for AuthReadLimit { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if this.released.load(Ordering::Acquire) { + return Pin::new(&mut this.inner).poll_read(cx, out); + } + let mut bytes = [0u8; 8192]; + let mut buffer = ReadBuf::new(&mut bytes[..out.remaining().min(8192)]); + match Pin::new(&mut this.inner).poll_read(cx, &mut buffer) { + Poll::Ready(Ok(())) => { + if let Err(error) = this.parser.accept(buffer.filled()) { + return Poll::Ready(Err(error)); + } + out.put_slice(buffer.filled()); + Poll::Ready(Ok(())) + } + result => result, + } + } +} +impl AsyncWrite for AuthReadLimit { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write(cx, bytes) + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_huge_and_fragmented_messages_before_payload_allocation() { + let mut parser = HeaderLimit::default(); + parser.accept(b"GET / HTTP/1.1\r\n\r\n").unwrap(); + assert!( + parser + .accept(&[0x81, 0xff, 0, 0, 0, 0, 0, 1, 0, 0]) + .is_err() + ); + let mut parser = HeaderLimit::default(); + parser.accept(b"\r\n\r\n").unwrap(); + parser.accept(&[0x01, 126, 0x0f, 0xff]).unwrap(); + parser.accept(&vec![0; 4095]).unwrap(); + parser.accept(&[0x89, 1, 0]).unwrap(); // interleaved control frame + assert!(parser.accept(&[0x80, 2]).is_err()); + } + #[test] + fn accepts_split_headers_masks_and_successive_messages() { + let bytes = [ + b"\r\n\r\n".as_slice(), + &[0x81, 0x82, 1, 2, 3, 4, 1, 2, 0x81, 0], + ] + .concat(); + for width in 1..bytes.len() { + let mut parser = HeaderLimit::default(); + for chunk in bytes.chunks(width) { + parser.accept(chunk).unwrap(); + } + assert_eq!(parser.remaining, 0); + assert_eq!(parser.message_bytes, 0); + } + } +} diff --git a/crates/bsk-cli/src/daemon/local_auth/store.rs b/crates/bsk-cli/src/daemon/local_auth/store.rs index 143b58c77..46d79f305 100644 --- a/crates/bsk-cli/src/daemon/local_auth/store.rs +++ b/crates/bsk-cli/src/daemon/local_auth/store.rs @@ -78,6 +78,7 @@ impl Default for State { pub struct PairingStore { path: Option, state: Mutex, + changed: tokio::sync::watch::Sender<()>, } impl std::fmt::Debug for PairingStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -125,12 +126,14 @@ impl PairingStore { Ok(Self { path: Some(path), state: Mutex::new(state), + changed: tokio::sync::watch::channel(()).0, }) } pub fn memory() -> Self { Self { path: None, state: Mutex::new(State::default()), + changed: tokio::sync::watch::channel(()).0, } } fn update(&self, work: impl FnOnce(&mut State) -> Result) -> Result { @@ -141,8 +144,13 @@ impl PairingStore { super::super::json_store::write(path, &next)?; } *current = next; + drop(current); + self.changed.send_replace(()); Ok(result) } + pub(crate) fn changes(&self) -> tokio::sync::watch::Receiver<()> { + self.changed.subscribe() + } pub fn daemon_id(&self) -> Bytes<16> { self.state.lock().unwrap().daemon_id } diff --git a/crates/bsk-cli/src/daemon/mod.rs b/crates/bsk-cli/src/daemon/mod.rs index c54b053c8..8355da9cb 100644 --- a/crates/bsk-cli/src/daemon/mod.rs +++ b/crates/bsk-cli/src/daemon/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod json_store; pub mod local_auth; pub mod lockfile; pub mod paths; +pub(crate) mod peer_authorization; pub(crate) mod probe; pub mod queue; pub mod remote; diff --git a/crates/bsk-cli/src/daemon/peer_authorization.rs b/crates/bsk-cli/src/daemon/peer_authorization.rs new file mode 100644 index 000000000..69ea614aa --- /dev/null +++ b/crates/bsk-cli/src/daemon/peer_authorization.rs @@ -0,0 +1,58 @@ +//! Local HMAC and remote TLS/Bearer authorization share the business driver. +use super::local_auth::{LocalAuth, store::DeviceStatus}; +use std::sync::Arc; + +pub(crate) struct LocalPeer { + pub device_id: String, + pub browser_id: String, + pub local: Arc, +} +pub(crate) enum PeerAuthorization { + Legacy, + 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::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::Remote(peer) => peer.active(), + } + } + pub async fn authorized(&self) -> bool { + match self { + Self::Remote(peer) => peer.authorized().await, + _ => self.active(), + } + } + pub async fn revoked(&self) { + match self { + Self::Legacy => std::future::pending::<()>().await, + 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; + } + tokio::select! { _ = changed.changed() => {}, _ = timer.tick() => {} } + } + } + } + } + pub fn matches_instance(&self, instance: &str) -> bool { + !matches!(self, Self::Local(peer) if peer.browser_id != instance) + } +} diff --git a/crates/bsk-cli/src/daemon/remote/server.rs b/crates/bsk-cli/src/daemon/remote/server.rs index 257d45090..e33a48476 100644 --- a/crates/bsk-cli/src/daemon/remote/server.rs +++ b/crates/bsk-cli/src/daemon/remote/server.rs @@ -328,7 +328,7 @@ async fn handle( serde_json::json!({"error": "not_found"}), )); } - if !one_header(&request, "origin").is_some_and(|origin| origin_allowed(origin, false)) { + if !one_header(&request, "origin").is_some_and(origin_allowed) { return Ok(denied()); } let Some(protocol) = one_header(&request, "sec-websocket-protocol").map(str::to_owned) else { @@ -419,7 +419,13 @@ async fn handle( // The native dispatcher supplies session isolation and cancellation. // The authenticated device, rather than a self-reported instance ID, // supplies the browser identity used by that dispatcher. - let _ = drive_connection(gateway.state.clone(), ws, Some(authorization)).await; + let _ = drive_connection( + gateway.state.clone(), + ws, + crate::daemon::peer_authorization::PeerAuthorization::Remote(authorization), + None, + ) + .await; }); Ok(upgrade_response.map(|_| Full::new(Bytes::new()))) } diff --git a/crates/bsk-cli/src/daemon/start.rs b/crates/bsk-cli/src/daemon/start.rs index 6e7e3c326..fe6e4d217 100644 --- a/crates/bsk-cli/src/daemon/start.rs +++ b/crates/bsk-cli/src/daemon/start.rs @@ -60,8 +60,6 @@ pub struct DaemonConfig { pub ws_port: u16, pub session_idle: Duration, pub daemon_idle: Duration, - /// Skip the Origin allow-list (tests / `--insecure-origin`). - pub allow_any_origin: bool, /// How long `session.start` polls for an extension handshake before /// returning `no_browser_connected`. pub extension_connect_wait: Duration, @@ -91,7 +89,6 @@ impl DaemonConfig { ws_port: port, session_idle: Duration::from_secs(60 * 5), daemon_idle: Duration::from_secs(60 * 30), - allow_any_origin: false, extension_connect_wait: EXTENSION_CONNECT_WAIT, browser_liveness_timeout: BROWSER_LIVENESS_TIMEOUT, browser_liveness_tick: BROWSER_LIVENESS_TICK, @@ -100,6 +97,11 @@ impl DaemonConfig { } } + pub fn with_local_auth(mut self, mode: super::local_auth::LocalAuthMode) -> Self { + self.local_auth = mode; + self + } + pub fn with_extension_connect_wait(mut self, wait: Duration) -> Self { self.extension_connect_wait = wait; self @@ -129,7 +131,6 @@ impl From<&StartArgs> for DaemonConfig { ws_port: args.resolved_port(), session_idle: args.resolved_session_idle(), daemon_idle: args.resolved_daemon_idle(), - allow_any_origin: false, extension_connect_wait: EXTENSION_CONNECT_WAIT, browser_liveness_timeout: BROWSER_LIVENESS_TIMEOUT, browser_liveness_tick: BROWSER_LIVENESS_TICK, diff --git a/crates/bsk-cli/src/daemon/ws.rs b/crates/bsk-cli/src/daemon/ws.rs index 24945e41e..f47187423 100644 --- a/crates/bsk-cli/src/daemon/ws.rs +++ b/crates/bsk-cli/src/daemon/ws.rs @@ -30,23 +30,13 @@ use tokio_tungstenite::tungstenite::protocol::{CloseFrame, Message}; use tracing::{debug, info, warn}; use super::browsers::{BrowserClient, BrowserId, BrowserSink, Pending}; +use super::peer_authorization::PeerAuthorization; use super::state::{ DaemonState, LEGACY_MIN_COMPATIBLE_PEER, MIN_COMPATIBLE_PROTOCOL, PROTOCOL_VERSION, SERVER_NAME, }; -/// Result of the optional Origin allow-list check. -/// -/// TODO(M10/M12): pair v0.1 GA with an actual extension-id allow-list -/// (`DaemonConfig::allowed_extension_ids: HashSet`) populated -/// from a config file / pairing flow, rather than accepting any -/// extension-shaped origin. Review M4/M5 I8 — acceptable defense-in- -/// depth gap for now because pairing happens through the popup, but -/// a side-loaded extension on the same machine currently passes the -/// gate. -pub(super) fn origin_allowed(origin: &str, allow_any: bool) -> bool { - if allow_any { - return true; - } +/// Origin is a filtering layer; local identity is established by HMAC below. +pub(super) fn origin_allowed(origin: &str) -> bool { // MV3 extension ids are 32 a–p characters per Chrome spec. let prefix = "chrome-extension://"; if let Some(rest) = origin.strip_prefix(prefix) { @@ -147,7 +137,6 @@ async fn handle_connection( stream: TcpStream, peer: SocketAddr, ) -> anyhow::Result<()> { - let allow_any = state.config.allow_any_origin; let captured_origin: std::sync::Arc>> = std::sync::Arc::new(std::sync::Mutex::new(None)); let captured_origin_for_cb = std::sync::Arc::clone(&captured_origin); @@ -159,7 +148,7 @@ async fn handle_connection( .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_string(); - if !origin_allowed(&origin, allow_any) { + if !origin_allowed(&origin) { warn!(?origin, "rejecting WS upgrade with disallowed origin"); let mut err = ErrorResponse::new(Some( "Origin not in chrome-extension allow-list".to_string(), @@ -176,7 +165,8 @@ async fn handle_connection( Ok(resp) }; - let ws = match tokio::time::timeout( + let (stream, read_limit) = super::local_auth::read_limit::AuthReadLimit::new(stream); + let mut ws = match tokio::time::timeout( WS_UPGRADE_TIMEOUT, tokio_tungstenite::accept_hdr_async(stream, callback), ) @@ -196,10 +186,23 @@ async fn handle_connection( return Ok(()); } }; - let origin = captured_origin.lock().unwrap().clone().unwrap_or_default(); - debug!(?peer, %origin, "ws connection upgraded"); - drive_connection(state, ws, None).await + let authentication = tokio::time::timeout( + HANDSHAKE_FIRST_FRAME_TIMEOUT, + super::local_auth::handshake::accept(state.local_auth.clone(), &mut ws, &origin), + ) + .await; + let (authorization, first) = match authentication { + Ok(Ok(value)) => value, + _ => { + // Do not log peer-controlled authentication frames or parser errors. + debug!(?peer, "local authentication ended before completion"); + let _ = tokio::time::timeout(Duration::from_millis(100), ws.close(None)).await; + return Ok(()); + } + }; + read_limit.store(true, std::sync::atomic::Ordering::Release); + drive_connection(state, ws, authorization, first).await } /// Hard cap on how long the daemon will wait for a freshly-upgraded @@ -212,19 +215,20 @@ const HANDSHAKE_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(5); pub(super) async fn drive_connection( state: Arc, ws: WebSocketStream, - authorization: Option, + authorization: PeerAuthorization, + first_message: Option, ) -> anyhow::Result<()> { let (mut writer, mut reader) = ws.split(); // The first frame MUST be `system.handshake` per §4.2. Bound the // wait so a stalled client cannot park resources forever. - let first = if let Some(auth) = authorization.as_ref() { + let first = if let Some(message) = first_message { + Ok(Some(Ok(message))) + } else { tokio::select! { first = tokio::time::timeout(HANDSHAKE_FIRST_FRAME_TIMEOUT, reader.next()) => first, - _ = auth.revoked() => return Err(anyhow!("device authorization ended before handshake")), + _ = authorization.revoked() => return Err(anyhow!("device authorization ended before handshake")), } - } else { - tokio::time::timeout(HANDSHAKE_FIRST_FRAME_TIMEOUT, reader.next()).await }; let first = match first { Ok(Some(Ok(msg))) => msg, @@ -338,15 +342,12 @@ pub(super) async fn drive_connection { - if authorization.as_ref().is_some_and(|auth| !auth.active()) { break; } + if !authorization.active() { break; } match outbound { Some(frame) => { let json = serde_json::to_string(&frame)?; @@ -428,9 +429,13 @@ pub(super) async fn drive_connection { - if authorization.as_ref().is_some_and(|auth| !auth.active()) { break; } + if !authorization.active() { break; } match msg { Some(Ok(Message::Text(t))) => { + if serde_json::from_str::(&t).ok() + .and_then(|value|value.get("type").and_then(|v|v.as_str()).map(|s|s.starts_with("local.auth."))).unwrap_or(false) { + break; + } // Any inbound frame — tool response, event, or the // ~20s heartbeat — counts as proof of life. pump_browser.touch(); @@ -462,10 +467,7 @@ pub(super) async fn drive_connection, bool) = tokio::select! { result = pump => (result, false), - _ = async { - if let Some(auth) = authorization.as_ref() { auth.revoked().await; } - else { std::future::pending::<()>().await; } - } => (Ok(()), true), + _ = authorization.revoked() => (Ok(()), true), }; if revoked { let _ = @@ -856,30 +858,30 @@ mod tests { #[test] fn origin_allowlist_accepts_valid_extension_id() { let id = "a".repeat(32); - assert!(origin_allowed(&format!("chrome-extension://{id}"), false)); + assert!(origin_allowed(&format!("chrome-extension://{id}"))); } #[test] fn origin_allowlist_rejects_uppercase() { let id = "A".repeat(32); - assert!(!origin_allowed(&format!("chrome-extension://{id}"), false)); + assert!(!origin_allowed(&format!("chrome-extension://{id}"))); } #[test] fn origin_allowlist_rejects_unknown_scheme() { let id = "a".repeat(32); - assert!(!origin_allowed(&format!("http://{id}.example"), false)); + assert!(!origin_allowed(&format!("http://{id}.example"))); } #[test] fn origin_allowlist_rejects_chars_outside_ap() { let id = "z".repeat(32); // 'z' > 'p' - assert!(!origin_allowed(&format!("chrome-extension://{id}"), false)); + assert!(!origin_allowed(&format!("chrome-extension://{id}"))); } #[test] - fn origin_allowlist_bypassed_when_allow_any() { - assert!(origin_allowed("http://localhost", true)); + fn origin_allowlist_rejects_local_web_pages() { + assert!(!origin_allowed("http://localhost")); } } diff --git a/crates/bsk-cli/tests/audit_ws.rs b/crates/bsk-cli/tests/audit_ws.rs index cd11debc4..288b27c65 100644 --- a/crates/bsk-cli/tests/audit_ws.rs +++ b/crates/bsk-cli/tests/audit_ws.rs @@ -78,7 +78,7 @@ fn ok(body: ResponseBody) -> Value { async fn records_production_dispatch_and_isolates_browser_history() { tokio::time::timeout(Duration::from_secs(20), async { let temp = tempfile::tempdir().unwrap(); - let mut state = DaemonState::new(DaemonConfig::new(0)); + let mut state = DaemonState::new(DaemonConfig::new(0).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal)); state.audit = Arc::new(AuditStore::new(Some(temp.path().join("audit")))); state.sessions = Arc::new(SessionRegistry::with_audit(Arc::clone(&state.audit))); state.tool_queues = Arc::new(ToolQueueRegistry::new( diff --git a/crates/bsk-cli/tests/browser_list_ordering.rs b/crates/bsk-cli/tests/browser_list_ordering.rs index 00829c306..4b99281b2 100644 --- a/crates/bsk-cli/tests/browser_list_ordering.rs +++ b/crates/bsk-cli/tests/browser_list_ordering.rs @@ -31,7 +31,8 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; - let config = DaemonConfig::new(port); + let config = + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal); let sock = tempfile_path("bsk-test-browser-list"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); (handle, sock) diff --git a/crates/bsk-cli/tests/browser_liveness.rs b/crates/bsk-cli/tests/browser_liveness.rs index 232096c80..c96fd880f 100644 --- a/crates/bsk-cli/tests/browser_liveness.rs +++ b/crates/bsk-cli/tests/browser_liveness.rs @@ -56,6 +56,7 @@ fn fake_session(session_id: &str, browser_id: &str) -> Session { async fn spawn_reaping_daemon() -> daemon::DaemonHandle { // Tiny window so the reaper acts within the test's lifetime. let config = DaemonConfig::new(0) + .with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal) .with_browser_liveness(Duration::from_millis(150), Duration::from_millis(25)); daemon::run(config, None).await.unwrap() } diff --git a/crates/bsk-cli/tests/browser_wait.rs b/crates/bsk-cli/tests/browser_wait.rs index fa3e4b690..b99db75da 100644 --- a/crates/bsk-cli/tests/browser_wait.rs +++ b/crates/bsk-cli/tests/browser_wait.rs @@ -78,7 +78,8 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; - let config = DaemonConfig::new(port); + let config = + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal); let sock = tempfile_path("bsk-test-browser-wait"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); (handle, sock) diff --git a/crates/bsk-cli/tests/cancel_forwarding.rs b/crates/bsk-cli/tests/cancel_forwarding.rs index 346105139..4fd25dc0d 100644 --- a/crates/bsk-cli/tests/cancel_forwarding.rs +++ b/crates/bsk-cli/tests/cancel_forwarding.rs @@ -41,7 +41,8 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; - let config = DaemonConfig::new(port); + let config = + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal); let sock = tempfile_path("bsk-test-cancel"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); (handle, sock) diff --git a/crates/bsk-cli/tests/dialog_control.rs b/crates/bsk-cli/tests/dialog_control.rs index d46fd1684..a7b12fa70 100644 --- a/crates/bsk-cli/tests/dialog_control.rs +++ b/crates/bsk-cli/tests/dialog_control.rs @@ -66,9 +66,12 @@ async fn call(sock: &Path, id: &str, method: Method, params: Value) -> Result PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; - let config = DaemonConfig::new(port); + let config = + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal); let sock = tempfile_path("bsk-test-handshake"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); (handle, sock) diff --git a/crates/bsk-cli/tests/local_auth_ws.rs b/crates/bsk-cli/tests/local_auth_ws.rs new file mode 100644 index 000000000..a48bdc97c --- /dev/null +++ b/crates/bsk-cli/tests/local_auth_ws.rs @@ -0,0 +1,618 @@ +//! Real WebSocket peers exercise the prelude separately from business traffic. +mod support; +use bsk::daemon::{ + self, DaemonConfig, + local_auth::{LocalAuthMode, store::DeviceStatus}, +}; +use bsk_protocol::local_auth::*; +use bsk_protocol::{ResponseBody, ResponseFrame}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use std::time::Duration; +use tokio_tungstenite::{ + MaybeTlsStream, WebSocketStream, + tungstenite::{Message, client::IntoClientRequest}, +}; +type Socket = WebSocketStream>; +const INSTANCE: &str = "a1b2c3d4"; +const ORIGIN: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; + +#[derive(Clone)] +struct Pairing { + daemon: Bytes<16>, + device: Bytes<16>, + key: Bytes<32>, + generation: u32, + pending: Option<(Rotation, Bytes<32>)>, + role: CredentialRole, +} +impl Pairing { + fn create(handle: &daemon::DaemonHandle, replace: Option<&str>) -> Self { + let link = handle + .state() + .local_auth + .store + .pair(handle.ws_addr().port(), replace) + .unwrap(); + let parts: Vec<_> = link + .split("#bsk-local.v1.") + .nth(1) + .unwrap() + .split('.') + .collect(); + Self { + daemon: Bytes::decode(parts[0]).unwrap(), + device: Bytes::decode(parts[1]).unwrap(), + key: Bytes::decode(parts[2]).unwrap(), + generation: 0, + pending: None, + role: CredentialRole::Staged, + } + } + fn offer(&self) -> Credential { + Credential { + role: self.role, + device_id: self.device, + r#gen: self.generation, + pending: self.pending.as_ref().map(|(r, _)| r.clone()), + } + } +} +async fn daemon() -> daemon::DaemonHandle { + daemon::run( + DaemonConfig::new(0).with_local_auth(LocalAuthMode::Required), + None, + ) + .await + .unwrap() +} +async fn connect(handle: &daemon::DaemonHandle) -> Socket { + let mut request = format!("ws://{}/", handle.ws_addr()) + .into_client_request() + .unwrap(); + request + .headers_mut() + .insert("Origin", ORIGIN.parse().unwrap()); + tokio_tungstenite::connect_async(request).await.unwrap().0 +} +async fn send(socket: &mut Socket, value: impl serde::Serialize) { + socket + .send(Message::Text(serde_json::to_string(&value).unwrap())) + .await + .unwrap(); +} +async fn receive(socket: &mut Socket) -> String { + tokio::time::timeout(Duration::from_secs(3), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap() + .into_text() + .unwrap() +} +async fn closed(socket: &mut Socket) { + let result = tokio::time::timeout(Duration::from_secs(3), socket.next()) + .await + .unwrap(); + assert!(matches!( + result, + None | Some(Ok(Message::Close(_))) | Some(Err(_)) + )); +} +async fn probe( + socket: &mut Socket, + offers: Vec, + instance: &str, +) -> (Hello, ChallengeResponse) { + let hello = Hello { + v: V1, + client_nonce: bsk::daemon::local_auth::store::random(), + instance_id: instance.into(), + credentials: offers, + }; + send( + socket, + Probe { + id: "probe".into(), + method: ProbeMethod::Ping, + params: ProbeParams { + local_auth: hello.clone(), + }, + }, + ) + .await; + let reply: ResponseFrame = serde_json::from_str(&receive(socket).await).unwrap(); + let ResponseBody::Ok(value) = reply.body else { + panic!("expected probe response") + }; + ( + hello, + serde_json::from_value(value["local_auth"].clone()).unwrap(), + ) +} +fn challenge(hello: &Hello, response: ChallengeResponse, pair: &Pairing) -> (Vec, Bytes<32>) { + let ChallengeResponse::Challenge { + daemon_id, + device_id, + r#gen, + server_nonce, + server_proof, + } = response + else { + panic!("expected challenge") + }; + assert_eq!(daemon_id, pair.daemon); + assert_eq!(device_id, pair.device); + let key = pair + .pending + .as_ref() + .filter(|(r, _)| r.r#gen == r#gen) + .map(|(_, key)| *key) + .unwrap_or(pair.key); + let t = Transcript { + daemon_id: &daemon_id, + device_id: &device_id, + r#gen, + instance_id: &hello.instance_id, + origin: ORIGIN, + client_nonce: &hello.client_nonce, + server_nonce: &server_nonce, + } + .encode(); + assert!(verify(&key, Purpose::Server, &t, None, &server_proof)); + (t, key) +} +async fn proved( + socket: &mut Socket, + pair: &Pairing, + instance: &str, +) -> (Vec, Bytes<32>, AuthMessage) { + let (hello, response) = probe(socket, vec![pair.offer()], instance).await; + let (t, key) = challenge(&hello, response, pair); + send( + socket, + AuthMessage::Prove { + v: V1, + client_proof: prove(&key, Purpose::Client, &t, None), + }, + ) + .await; + let next = AuthMessage::parse(&receive(socket).await).unwrap(); + (t, key, next) +} +async fn authenticate(socket: &mut Socket, pair: &mut Pairing) { + let (t, key, message) = proved(socket, pair, INSTANCE).await; + match message { + AuthMessage::Ready { + rotation: Some(rotation), + commit: None, + .. + } => { + let candidate = prove(&key, Purpose::Rotate, &t, Some(&rotation.txn)); + pair.pending = Some((rotation.clone(), candidate)); + send( + socket, + AuthMessage::RotateConfirm { + v: V1, + txn: rotation.txn, + proof: prove(&candidate, Purpose::Confirm, &t, Some(&rotation.txn)), + }, + ) + .await; + let AuthMessage::Committed { r#gen, proof, .. } = + AuthMessage::parse(&receive(socket).await).unwrap() + else { + panic!("expected commit") + }; + assert!(verify( + &candidate, + Purpose::Committed, + &t, + Some(&rotation.txn), + &proof + )); + pair.key = candidate; + pair.generation = r#gen; + pair.pending = None; + } + AuthMessage::Ready { + commit: Some(commit), + rotation: None, + .. + } => { + let (rotation, candidate) = pair.pending.take().unwrap(); + assert_eq!(rotation.txn, commit.txn); + assert!(verify( + &candidate, + Purpose::Committed, + &t, + Some(&commit.txn), + &commit.proof + )); + pair.key = candidate; + pair.generation = commit.r#gen; + } + AuthMessage::Ready { + commit: None, + rotation: None, + .. + } => {} + _ => panic!("unexpected authentication result"), + } + pair.role = CredentialRole::Active; +} +async fn handshake(socket: &mut Socket, instance: &str) -> Value { + send(socket,json!({"id":"handshake","method":"system.handshake","params":{"client":"browser-skill-extension","version":env!("CARGO_PKG_VERSION"),"protocol_version":bsk::daemon::state::PROTOCOL_VERSION,"instance_id":instance,"browser":{"name":"Chrome","version":"130"},"label":"test"}})).await; + serde_json::from_str(&receive(socket).await).unwrap() +} + +#[tokio::test] +async fn l01_l02_no_registration_or_session_purge_without_a_valid_proof() { + let handle = daemon().await; + let state = handle.state(); + let session = state + .sessions + .reserve_id(daemon::browsers::BrowserId(INSTANCE.into()), 1, || 0) + .unwrap(); + let mut socket = connect(&handle).await; + assert!(matches!( + probe(&mut socket, vec![], INSTANCE).await.1, + ChallengeResponse::PairingRequired { + legacy_accepted: false, + .. + } + )); + closed(&mut socket).await; + let pair = Pairing::create(&handle, None); + let mut socket = connect(&handle).await; + assert!(matches!( + probe(&mut socket, vec![pair.offer()], INSTANCE).await.1, + ChallengeResponse::Challenge { .. } + )); + send( + &mut socket, + AuthMessage::Prove { + v: V1, + client_proof: Bytes([0; 32]), + }, + ) + .await; + closed(&mut socket).await; + assert!(state.browsers.is_empty()); + assert!(state.sessions.get(&session).is_some()); + assert!( + state + .local_auth + .store + .device(&pair.device.encode()) + .unwrap() + .pending + .is_none() + ); + handle.shutdown().await; +} +#[tokio::test] +async fn l05_replayed_client_proof_and_l09_busy_are_rejected() { + let handle = daemon().await; + let pair = Pairing::create(&handle, None); + let mut first = connect(&handle).await; + let (hello, response) = probe(&mut first, vec![pair.offer()], INSTANCE).await; + let (t, key) = challenge(&hello, response, &pair); + let mut second = connect(&handle).await; + assert!(matches!( + probe(&mut second, vec![pair.offer()], INSTANCE).await.1, + ChallengeResponse::Busy { .. } + )); + first.close(None).await.unwrap(); + closed(&mut first).await; + let mut next = connect(&handle).await; + let _ = probe(&mut next, vec![pair.offer()], INSTANCE).await; + send( + &mut next, + AuthMessage::Prove { + v: V1, + client_proof: prove(&key, Purpose::Client, &t, None), + }, + ) + .await; + closed(&mut next).await; + assert!(handle.state().browsers.is_empty()); + handle.shutdown().await; +} +#[tokio::test] +async fn l07_activation_invalidates_link_before_the_online_connection_ends() { + let handle = daemon().await; + let mut pair = Pairing::create(&handle, None); + let old = pair.clone(); + let mut socket = connect(&handle).await; + authenticate(&mut socket, &mut pair).await; + assert!( + handshake(&mut socket, INSTANCE) + .await + .get("result") + .is_some() + ); + let mut replay = connect(&handle).await; + assert!(matches!( + probe(&mut replay, vec![old.offer()], INSTANCE).await.1, + ChallengeResponse::PairingRequired { .. } + )); + closed(&mut replay).await; + assert_eq!(handle.state().browsers.len(), 1); + socket.close(None).await.unwrap(); + handle.shutdown().await; +} +#[tokio::test] +async fn l08_recovery_before_save_after_save_and_after_daemon_commit() { + for point in 0..3 { + let handle = daemon().await; + let mut pair = Pairing::create(&handle, None); + let mut socket = connect(&handle).await; + let (t, key, message) = proved(&mut socket, &pair, INSTANCE).await; + let AuthMessage::Ready { + rotation: Some(rotation), + .. + } = message + else { + panic!("rotation") + }; + let candidate = prove(&key, Purpose::Rotate, &t, Some(&rotation.txn)); + if point > 0 { + pair.pending = Some((rotation.clone(), candidate)); + } + if point == 2 { + send( + &mut socket, + AuthMessage::RotateConfirm { + v: V1, + txn: rotation.txn, + proof: prove(&candidate, Purpose::Confirm, &t, Some(&rotation.txn)), + }, + ) + .await; + let _lost = receive(&mut socket).await; + } + socket.close(None).await.unwrap(); + closed(&mut socket).await; + let mut recovered = connect(&handle).await; + authenticate(&mut recovered, &mut pair).await; + assert_eq!( + pair.key, + handle + .state() + .local_auth + .store + .device(&pair.device.encode()) + .unwrap() + .key + ); + assert!( + handshake(&mut recovered, INSTANCE) + .await + .get("result") + .is_some() + ); + recovered.close(None).await.unwrap(); + handle.shutdown().await; + } +} +#[tokio::test] +async fn l13_l14_l14a_reject_before_rotation_and_replace_atomically() { + let handle = daemon().await; + let mut pair = Pairing::create(&handle, None); + let mut main = connect(&handle).await; + authenticate(&mut main, &mut pair).await; + handshake(&mut main, INSTANCE).await; + let state = handle.state(); + let session = state + .sessions + .reserve_id(daemon::browsers::BrowserId(INSTANCE.into()), 1, || 0) + .unwrap(); + let duplicate = Pairing::create(&handle, None); + let mut verifier = connect(&handle).await; + assert!(matches!( + proved(&mut verifier, &duplicate, INSTANCE).await.2, + AuthMessage::Rejected { + reason: RejectionReason::AlreadyPaired, + .. + } + )); + assert!(state.sessions.get(&session).is_some()); + assert_eq!(state.browsers.len(), 1); + let mut replacement = Pairing::create(&handle, Some(INSTANCE)); + let mut mismatch = connect(&handle).await; + assert!(matches!( + proved(&mut mismatch, &replacement, "12345678").await.2, + AuthMessage::Rejected { + reason: RejectionReason::ReplaceMismatch, + .. + } + )); + closed(&mut mismatch).await; + assert_eq!( + state + .local_auth + .store + .device(&pair.device.encode()) + .unwrap() + .status, + DeviceStatus::Active + ); + let mut verifier = connect(&handle).await; + authenticate(&mut verifier, &mut replacement).await; + closed(&mut main).await; + support::wait_for_browser_count(&state, 0).await; + assert!(state.sessions.get(&session).is_none()); + assert_eq!( + state + .local_auth + .store + .device(&pair.device.encode()) + .unwrap() + .status, + DeviceStatus::Revoked + ); + verifier.close(None).await.unwrap(); + handle.shutdown().await; +} +#[tokio::test] +async fn l15_oversized_unknown_duplicate_and_business_before_commit_are_closed() { + let handle = daemon().await; + for bad in [ + "x".repeat(4097), + r#"{"id":"x","method":"system.ping","params":{"local_auth":{"v":1,"v":1}}}"#.into(), + r#"{"id":"x","method":"system.ping","params":{"local_auth":{"unknown":true}}}"#.into(), + ] { + let mut socket = connect(&handle).await; + socket.send(Message::Text(bad)).await.unwrap(); + closed(&mut socket).await; + } + let pair = Pairing::create(&handle, None); + let mut socket = connect(&handle).await; + let _ = proved(&mut socket, &pair, INSTANCE).await; + send( + &mut socket, + json!({"id":"hs","method":"system.handshake","params":{}}), + ) + .await; + closed(&mut socket).await; + assert!(handle.state().browsers.is_empty()); + assert_eq!( + handle + .state() + .local_auth + .store + .device(&pair.device.encode()) + .unwrap() + .status, + DeviceStatus::Unactivated + ); + handle.shutdown().await; +} +#[tokio::test] +async fn l08a_auth_messages_after_final_never_reach_business_dispatch() { + let handle = daemon().await; + let mut pair = Pairing::create(&handle, None); + let mut socket = connect(&handle).await; + authenticate(&mut socket, &mut pair).await; + handshake(&mut socket, INSTANCE).await; + send( + &mut socket, + AuthMessage::Ready { + v: V1, + commit: None, + rotation: None, + }, + ) + .await; + closed(&mut socket).await; + support::wait_for_browser_count(&handle.state(), 0).await; + handle.shutdown().await; +} + +#[tokio::test] +async fn l14b_committed_staged_credential_wins_over_the_old_active_record() { + let handle = daemon().await; + let mut active = Pairing::create(&handle, None); + let mut main = connect(&handle).await; + authenticate(&mut main, &mut active).await; + handshake(&mut main, INSTANCE).await; + let mut staged = Pairing::create(&handle, Some(INSTANCE)); + let mut verifier = connect(&handle).await; + let (t, key, message) = proved(&mut verifier, &staged, INSTANCE).await; + let AuthMessage::Ready { + rotation: Some(rotation), + .. + } = message + else { + panic!("rotation") + }; + let candidate = prove(&key, Purpose::Rotate, &t, Some(&rotation.txn)); + staged.pending = Some((rotation.clone(), candidate)); + send( + &mut verifier, + AuthMessage::RotateConfirm { + v: V1, + txn: rotation.txn, + proof: prove(&candidate, Purpose::Confirm, &t, Some(&rotation.txn)), + }, + ) + .await; + let _lost = receive(&mut verifier).await; + verifier.close(None).await.unwrap(); + closed(&mut verifier).await; + let mut recovered = connect(&handle).await; + let (hello, response) = probe( + &mut recovered, + vec![active.offer(), staged.offer()], + INSTANCE, + ) + .await; + let (t, key) = challenge(&hello, response, &staged); + send( + &mut recovered, + AuthMessage::Prove { + v: V1, + client_proof: prove(&key, Purpose::Client, &t, None), + }, + ) + .await; + let AuthMessage::Ready { + commit: Some(commit), + rotation: None, + .. + } = AuthMessage::parse(&receive(&mut recovered).await).unwrap() + else { + panic!("recovery commit") + }; + assert!(verify( + &candidate, + Purpose::Committed, + &t, + Some(&commit.txn), + &commit.proof + )); + assert!( + handshake(&mut recovered, INSTANCE) + .await + .get("result") + .is_some() + ); + recovered.close(None).await.unwrap(); + handle.shutdown().await; +} + +#[tokio::test] +async fn identity_binding_rejects_a_different_business_handshake() { + let handle = daemon().await; + let mut pair = Pairing::create(&handle, None); + let mut socket = connect(&handle).await; + authenticate(&mut socket, &mut pair).await; + send(&mut socket,json!({"id":"hs","method":"system.handshake","params":{"client":"browser-skill-extension","version":env!("CARGO_PKG_VERSION"),"protocol_version":bsk::daemon::state::PROTOCOL_VERSION,"instance_id":"12345678","browser":{"name":"Chrome","version":"130"},"label":"test"}})).await; + closed(&mut socket).await; + assert!(handle.state().browsers.is_empty()); + handle.shutdown().await; +} + +#[tokio::test] +async fn l17_legacy_handshake_obeys_daemon_policy() { + for mode in [ + LocalAuthMode::Warn, + LocalAuthMode::Required, + LocalAuthMode::TrustLocal, + ] { + let handle = daemon::run(DaemonConfig::new(0).with_local_auth(mode), None) + .await + .unwrap(); + let mut socket = connect(&handle).await; + let result = handshake(&mut socket, INSTANCE).await; + if mode == LocalAuthMode::Required { + assert_eq!(result["error"]["code"], "pairing_required"); + closed(&mut socket).await; + } else { + assert!(result.get("result").is_some()); + socket.close(None).await.unwrap(); + } + handle.shutdown().await; + } +} diff --git a/crates/bsk-cli/tests/per_session_queue.rs b/crates/bsk-cli/tests/per_session_queue.rs index f003d38f2..43207ddda 100644 --- a/crates/bsk-cli/tests/per_session_queue.rs +++ b/crates/bsk-cli/tests/per_session_queue.rs @@ -47,9 +47,12 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; let sock = tempfile_path("bsk-test-queue"); - let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone())) - .await - .unwrap(); + let handle = daemon::run( + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal), + Some(sock.clone()), + ) + .await + .unwrap(); (handle, sock) } diff --git a/crates/bsk-cli/tests/record_export_recovery.rs b/crates/bsk-cli/tests/record_export_recovery.rs index a216c7de8..9844d6b68 100644 --- a/crates/bsk-cli/tests/record_export_recovery.rs +++ b/crates/bsk-cli/tests/record_export_recovery.rs @@ -200,9 +200,12 @@ async fn run_record_stop(home: &Path, output: &Path) -> std::process::Output { async fn setup_daemon(temp: &Path) -> (bsk::daemon::DaemonHandle, PathBuf, PathBuf) { let home = temp.join("bsk-home"); let sock = temp.join("daemon.sock"); - let daemon = daemon::run(DaemonConfig::new(0), Some(sock.clone())) - .await - .unwrap(); + let daemon = daemon::run( + DaemonConfig::new(0).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal), + Some(sock.clone()), + ) + .await + .unwrap(); bsk::daemon::info::write_to_path( &DaemonInfo::now( std::process::id(), diff --git a/crates/bsk-cli/tests/record_stop_retry.rs b/crates/bsk-cli/tests/record_stop_retry.rs index 7d42a57cd..01cf2531c 100644 --- a/crates/bsk-cli/tests/record_stop_retry.rs +++ b/crates/bsk-cli/tests/record_stop_retry.rs @@ -209,9 +209,12 @@ async fn failed_record_stop_keeps_state_and_session_for_retry() { let sock = temp.path().join("daemon.sock"); let output = temp.path().join("trace"); - let daemon = daemon::run(DaemonConfig::new(0), Some(sock.clone())) - .await - .unwrap(); + let daemon = daemon::run( + DaemonConfig::new(0).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal), + Some(sock.clone()), + ) + .await + .unwrap(); bsk::daemon::info::write_to_path( &DaemonInfo::now( std::process::id(), diff --git a/crates/bsk-cli/tests/session_user_interrupt.rs b/crates/bsk-cli/tests/session_user_interrupt.rs index d8782195a..538ac96da 100644 --- a/crates/bsk-cli/tests/session_user_interrupt.rs +++ b/crates/bsk-cli/tests/session_user_interrupt.rs @@ -47,7 +47,8 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; - let config = DaemonConfig::new(port); + let config = + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal); let sock = tempfile_path("bsk-test-user-interrupt"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); (handle, sock) diff --git a/crates/bsk-cli/tests/sessions_ipc.rs b/crates/bsk-cli/tests/sessions_ipc.rs index 9e3e31bda..d296cff41 100644 --- a/crates/bsk-cli/tests/sessions_ipc.rs +++ b/crates/bsk-cli/tests/sessions_ipc.rs @@ -48,7 +48,9 @@ async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { async fn spawn_daemon_with_connect_wait(connect_wait: Duration) -> (daemon::DaemonHandle, PathBuf) { let port = 0; - let config = DaemonConfig::new(port).with_extension_connect_wait(connect_wait); + let config = DaemonConfig::new(port) + .with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal) + .with_extension_connect_wait(connect_wait); let sock = tempfile_path("bsk-test-ipc"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); (handle, sock) @@ -57,7 +59,8 @@ async fn spawn_daemon_with_connect_wait(connect_wait: Duration) -> (daemon::Daem async fn spawn_daemon_with_session_idle(session_idle: Duration) -> (daemon::DaemonHandle, PathBuf) { let port = 0; - let mut config = DaemonConfig::new(port); + let mut config = + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal); config.session_idle = session_idle; let sock = tempfile_path("bsk-test-ipc"); let handle = daemon::run(config, Some(sock.clone())).await.unwrap(); diff --git a/crates/bsk-cli/tests/tools_ipc.rs b/crates/bsk-cli/tests/tools_ipc.rs index 787e2a09c..7e51c646a 100644 --- a/crates/bsk-cli/tests/tools_ipc.rs +++ b/crates/bsk-cli/tests/tools_ipc.rs @@ -43,9 +43,12 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; let sock = tempfile_path("bsk-test-tools"); - let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone())) - .await - .unwrap(); + let handle = daemon::run( + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal), + Some(sock.clone()), + ) + .await + .unwrap(); (handle, sock) } diff --git a/crates/bsk-cli/tests/tools_m7_ipc.rs b/crates/bsk-cli/tests/tools_m7_ipc.rs index 326617543..1577f5c87 100644 --- a/crates/bsk-cli/tests/tools_m7_ipc.rs +++ b/crates/bsk-cli/tests/tools_m7_ipc.rs @@ -46,9 +46,12 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; let sock = tempfile_path("bsk-test-tools-m7"); - let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone())) - .await - .unwrap(); + let handle = daemon::run( + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal), + Some(sock.clone()), + ) + .await + .unwrap(); (handle, sock) } diff --git a/crates/bsk-cli/tests/tools_m8_ipc.rs b/crates/bsk-cli/tests/tools_m8_ipc.rs index 91c5813b7..1e04339ac 100644 --- a/crates/bsk-cli/tests/tools_m8_ipc.rs +++ b/crates/bsk-cli/tests/tools_m8_ipc.rs @@ -44,9 +44,12 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; let sock = tempfile_path("bsk-test-tools-m8"); - let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone())) - .await - .unwrap(); + let handle = daemon::run( + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal), + Some(sock.clone()), + ) + .await + .unwrap(); (handle, sock) } diff --git a/crates/bsk-cli/tests/tools_m9_ipc.rs b/crates/bsk-cli/tests/tools_m9_ipc.rs index 631cedf33..fd2d69b82 100644 --- a/crates/bsk-cli/tests/tools_m9_ipc.rs +++ b/crates/bsk-cli/tests/tools_m9_ipc.rs @@ -54,9 +54,12 @@ fn tempfile_path(prefix: &str) -> PathBuf { async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) { let port = 0; let sock = tempfile_path("bsk-test-tools-m9"); - let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone())) - .await - .unwrap(); + let handle = daemon::run( + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal), + Some(sock.clone()), + ) + .await + .unwrap(); (handle, sock) } diff --git a/crates/bsk-cli/tests/ws_handshake.rs b/crates/bsk-cli/tests/ws_handshake.rs index 19c24c3ad..a5082cffd 100644 --- a/crates/bsk-cli/tests/ws_handshake.rs +++ b/crates/bsk-cli/tests/ws_handshake.rs @@ -18,7 +18,8 @@ pub async fn spawn_daemon() -> daemon::DaemonHandle { // Bind to any free TCP port. let port = 0; - let config = DaemonConfig::new(port); + let config = + DaemonConfig::new(port).with_local_auth(bsk::daemon::local_auth::LocalAuthMode::TrustLocal); daemon::run(config, None).await.unwrap() }