Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions crates/bsk-cli/src/cli/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ enum DaemonState {
NoListener(DaemonInfo),
ProbeError(anyhow::Error),
Verified {
status: StatusResult,
status: Box<StatusResult>,
local_identity_error: Option<String>,
/// Owned by a terminal or supervisor, which must restart it.
host_managed: bool,
Expand Down Expand Up @@ -208,6 +208,7 @@ fn collect_checks(state: DaemonState) -> Vec<CheckResult> {
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()),
]
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -784,6 +822,7 @@ mod m2_tests {

fn fake_status(browsers: Vec<BrowserStatusEntry>, skew: Vec<VersionSkewEntry>) -> StatusResult {
StatusResult {
local_auth: None,
daemon_version: env!("CARGO_PKG_VERSION").into(),
protocol_version: "1.0".into(),
pid: 1,
Expand Down Expand Up @@ -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,
};
Expand Down
7 changes: 7 additions & 0 deletions crates/bsk-cli/src/cli/render_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
8 changes: 7 additions & 1 deletion crates/bsk-cli/src/daemon/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
72 changes: 50 additions & 22 deletions crates/bsk-cli/src/daemon/local_auth/handshake.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -15,14 +12,24 @@ use tokio::io::{AsyncRead, AsyncWrite};
use tokio_tungstenite::{WebSocketStream, tungstenite::Message};

pub(crate) async fn accept<S: AsyncRead + AsyncWrite + Unpin>(
local: Arc<LocalAuth>,
state: Arc<crate::daemon::state::DaemonState>,
ws: &mut WebSocketStream<S>,
origin: &str,
) -> Result<(PeerAuthorization, Option<Message>)> {
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,
Expand All @@ -36,7 +43,10 @@ pub(crate) async fn accept<S: AsyncRead + AsyncWrite + Unpin>(
.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
Expand All @@ -61,6 +71,7 @@ pub(crate) async fn accept<S: AsyncRead + AsyncWrite + Unpin>(
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,
Expand All @@ -73,18 +84,29 @@ pub(crate) async fn accept<S: AsyncRead + AsyncWrite + Unpin>(
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
Expand Down Expand Up @@ -150,7 +172,7 @@ pub(crate) async fn accept<S: AsyncRead + AsyncWrite + Unpin>(
.is_some_and(|p| p.txn == commit.txn)
{
durable_commit(
local.clone(),
state.clone(),
device_id.clone(),
commit.txn,
hello.instance_id.clone(),
Expand Down Expand Up @@ -236,7 +258,7 @@ pub(crate) async fn accept<S: AsyncRead + AsyncWrite + Unpin>(
"local rotation confirmation rejected"
);
durable_commit(
local.clone(),
state.clone(),
device_id.clone(),
txn,
hello.instance_id.clone(),
Expand Down Expand Up @@ -272,18 +294,24 @@ pub(crate) async fn accept<S: AsyncRead + AsyncWrite + Unpin>(
device_id,
browser_id,
local,
lease: Default::default(),
}),
None,
))
}

async fn durable_commit(
local: Arc<LocalAuth>,
state: Arc<crate::daemon::state::DaemonState>,
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<S: AsyncRead + AsyncWrite + Unpin>(ws: &mut WebSocketStream<S>) -> Result<String> {
Expand Down
Loading
Loading