diff --git a/crates/bsk-cli/src/cli/daemon.rs b/crates/bsk-cli/src/cli/daemon.rs index f171a39a..bed223be 100644 --- a/crates/bsk-cli/src/cli/daemon.rs +++ b/crates/bsk-cli/src/cli/daemon.rs @@ -24,10 +24,14 @@ pub enum DaemonCmd { /// Stop then start the daemon. Restart(StartArgs), - /// Generate a one-use remote browser pairing link. - Pair, + /// Generate a local pairing link (or a remote link in server mode). + Pair { + /// Replace this local browser/device after successful activation. + #[arg(long)] + replace: Option, + }, - /// List paired remote browsers (no credentials are displayed). + /// List local and remote devices (no credentials are displayed). Devices, /// Revoke a device grant, or all grants and outstanding pairing links. @@ -37,6 +41,19 @@ pub enum DaemonCmd { #[arg(long)] all: bool, }, + /// Rotate a local device key on its next connection. + Rotate { device_id: String }, + /// Explicitly allow or stop allowing unverified local connections. + TrustLocal { + #[arg(value_enum)] + setting: TrustSetting, + }, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum TrustSetting { + On, + Off, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)] @@ -182,37 +199,109 @@ pub fn dispatch(cmd: DaemonCmd) -> anyhow::Result<()> { daemon::start::run_stop().map_err(|e| e.context("restart failed during stop phase"))?; daemon::start::run_start(args) } - DaemonCmd::Pair => { - println!("{}", authorization_store()?.pair()?); + DaemonCmd::Pair { replace } => { + super::ensure_daemon::ensure_daemon()?; + let value = maintain( + bsk_protocol::Method::LocalAuthPair, + serde_json::json!({"replace":replace}), + )?; + println!( + "{}", + value["link"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("invalid pairing reply"))? + ); Ok(()) } DaemonCmd::Devices => { println!( "{}", - serde_json::to_string_pretty(&authorization_store()?.devices()?)? + serde_json::to_string_pretty(&maintain( + bsk_protocol::Method::LocalAuthDevices, + serde_json::json!({}) + )?)? ); Ok(()) } DaemonCmd::Revoke { device_id, all } => { - let store = authorization_store()?; - if all { - store.revoke_all()?; - } else { - anyhow::ensure!( - store.revoke(device_id.as_deref().unwrap_or_default())?, - "device grant not found" - ); - } + maintain( + bsk_protocol::Method::LocalAuthRevoke, + serde_json::json!({"device_id":device_id,"all":all}), + )?; println!("Authorization revoked"); Ok(()) } + DaemonCmd::Rotate { device_id } => { + maintain( + bsk_protocol::Method::LocalAuthRotate, + serde_json::json!({"device_id":device_id}), + )?; + println!("Key rotation scheduled for the next connection"); + Ok(()) + } + DaemonCmd::TrustLocal { setting } => { + use daemon::local_auth::LocalAuthMode; + let mode = match setting { + TrustSetting::On => LocalAuthMode::TrustLocal, + TrustSetting::Off => LocalAuthMode::Required, + }; + maintain( + bsk_protocol::Method::LocalAuthSetMode, + serde_json::json!({"mode":mode}), + )?; + match setting { + TrustSetting::On => println!( + "Warning: unverified local connections are allowed. Enable skipping in the extension separately." + ), + TrustSetting::Off => println!("Local pairing verification is required"), + } + Ok(()) + } } } -fn authorization_store() -> anyhow::Result { - Ok(daemon::remote::authorization::AuthorizationStore::at_home( - &daemon::paths::ensure_bsk_home()?, - )) +fn maintain( + method: bsk_protocol::Method, + params: serde_json::Value, +) -> anyhow::Result { + let home = daemon::paths::ensure_bsk_home()?; + match daemon::lockfile::acquire() { + Ok(lock) => { + let local = daemon::local_auth::LocalAuth::open(&lock)?; + daemon::local_auth::maintenance::execute( + &local, + &home, + DEFAULT_WS_PORT, + false, + method, + params, + ) + } + Err(error) if error.is::() => { + // Once another process owns the lifecycle lock, never touch files + // offline, even if IPC is not ready yet or the request fails. + tracing::debug!("daemon maintenance waiting for IPC after lifecycle lock contention"); + let running = daemon::probe::wait_for_ready(Duration::from_secs(10))?; + let path = running.info.sock_path.clone(); + drop(running); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let mut client = crate::ipc_client::Client::connect_path(path).await?; + client + .call(method, ¶ms, Duration::from_secs(10)) + .await? + .map_err(|error| { + anyhow::anyhow!( + "{}; if the daemon is older, run `bsk update` and restart it", + error.message + ) + }) + }) + } + Err(error) => Err(error), + } } #[cfg(test)] diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index e755c1e6..c198da9a 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -232,6 +232,19 @@ pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler ); } let body = match method { + Method::LocalAuthPair + | Method::LocalAuthDevices + | Method::LocalAuthRevoke + | Method::LocalAuthRotate + | Method::LocalAuthSetMode => { + super::local_auth::maintenance::handle( + state.clone(), + status.ws_port, + method, + params, + ) + .await + } Method::SystemPing => { let result = PingResult { pong: true }; ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null)) diff --git a/crates/bsk-cli/src/daemon/json_store.rs b/crates/bsk-cli/src/daemon/json_store.rs new file mode 100644 index 00000000..9f99fd92 --- /dev/null +++ b/crates/bsk-cli/src/daemon/json_store.rs @@ -0,0 +1,30 @@ +//! Durable, private atomic replacement. Callers serialize writes with the +//! daemon lifecycle lock and their in-process state mutex. + +use anyhow::{Context, Result}; +use serde::{Serialize, de::DeserializeOwned}; +use std::{fs, io::Write, path::Path}; + +pub fn read(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|_| anyhow::anyhow!("invalid authorization store")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(T::default()), + Err(error) => Err(error.into()), + } +} + +pub fn write(path: &Path, state: &T) -> Result<()> { + let parent = path.parent().context("authorization directory missing")?; + // NamedTempFile creates a private (0600 on Unix) file in the same directory. + let mut file = tempfile::NamedTempFile::new_in(parent)?; + serde_json::to_writer(file.as_file_mut(), state)?; + file.as_file_mut().flush()?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| error.error)?; + // Windows does not permit opening a directory this way. On Unix a failed + // directory sync must not be mistaken for a durable commit. + #[cfg(unix)] + fs::File::open(parent)?.sync_all()?; + Ok(()) +} diff --git a/crates/bsk-cli/src/daemon/local_auth/maintenance.rs b/crates/bsk-cli/src/daemon/local_auth/maintenance.rs new file mode 100644 index 00000000..f6d9851d --- /dev/null +++ b/crates/bsk-cli/src/daemon/local_auth/maintenance.rs @@ -0,0 +1,135 @@ +//! IPC and offline maintenance share exactly the same operations. + +use super::{LocalAuth, LocalAuthMode}; +use anyhow::{Result, ensure}; +use bsk_protocol::{ErrorCode, Method, ResponseBody, RpcError}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{path::Path, sync::Arc}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PairParams { + replace: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RevokeParams { + device_id: Option, + #[serde(default)] + all: bool, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RotateParams { + device_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModeParams { + mode: LocalAuthMode, +} +fn params(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|_| anyhow::anyhow!("invalid pairing maintenance parameters")) +} + +pub fn execute( + local: &LocalAuth, + home: &Path, + port: u16, + server: bool, + method: Method, + value: Value, +) -> Result { + let remote = super::super::remote::authorization::AuthorizationStore::at_home(home); + match method { + Method::LocalAuthPair => { + let args: PairParams = params(value)?; + let link = if server { + ensure!( + args.replace.is_none(), + "--replace is only available for local pairing" + ); + remote.pair()? + } else { + local.store.pair(port, args.replace.as_deref())? + }; + Ok(json!({"link": link})) + } + Method::LocalAuthDevices => { + let mut devices = serde_json::to_value(local.store.devices())? + .as_array() + .unwrap() + .clone(); + for device in remote.devices()? { + let mut device = serde_json::to_value(device)?; + device["kind"] = json!("remote"); + devices.push(device); + } + Ok(Value::Array(devices)) + } + Method::LocalAuthRevoke => { + let args: RevokeParams = params(value)?; + ensure!( + args.all != args.device_id.is_some(), + "specify a device or --all" + ); + if args.all { + local.store.revoke(None)?; + remote.revoke_all()?; + } else { + let id = args.device_id.as_deref().unwrap(); + if local.store.resolve(id).is_ok() { + local.store.revoke(Some(id))?; + } else { + ensure!(remote.revoke(id)?, "device grant not found"); + } + } + Ok(json!({"revoked":true})) + } + Method::LocalAuthRotate => { + let args: RotateParams = params(value)?; + local.store.request_rotation(&args.device_id)?; + Ok(json!({"rotate_on_next_connection":true})) + } + Method::LocalAuthSetMode => { + let args: ModeParams = params(value)?; + local.set_mode(home, args.mode)?; + Ok(json!({"local_auth":args.mode})) + } + _ => anyhow::bail!("unsupported pairing maintenance operation"), + } +} + +pub async fn handle( + state: Arc, + port: u16, + method: Method, + params: Value, +) -> ResponseBody { + let result = tokio::task::spawn_blocking(move || { + execute( + &state.local_auth, + &super::super::paths::ensure_bsk_home()?, + port, + state.config.server.is_some(), + method, + params, + ) + }) + .await; + match result { + Ok(Ok(value)) => ResponseBody::Ok(value), + Ok(Err(error)) => ResponseBody::Err(RpcError { + code: ErrorCode::InvalidParams, + message: error.to_string(), + data: None, + }), + Err(_) => ResponseBody::Err(RpcError { + code: ErrorCode::ProtocolError, + message: "pairing maintenance failed".into(), + data: None, + }), + } +} diff --git a/crates/bsk-cli/src/daemon/local_auth/mod.rs b/crates/bsk-cli/src/daemon/local_auth/mod.rs new file mode 100644 index 00000000..705f59da --- /dev/null +++ b/crates/bsk-cli/src/daemon/local_auth/mod.rs @@ -0,0 +1,69 @@ +//! Local pairing credentials and policy. Remote grants use a separate store. +pub mod maintenance; +pub mod store; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::{path::Path, sync::Mutex}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LocalAuthMode { + #[default] + Warn, + Required, + TrustLocal, +} +impl LocalAuthMode { + pub fn accepts_legacy(self) -> bool { + self != Self::Required + } +} + +#[derive(Default, Serialize, Deserialize)] +struct Settings { + local_auth: Option, + #[serde(flatten)] + other: std::collections::BTreeMap, +} +pub fn read_mode(home: &Path) -> Result { + let settings: Settings = super::json_store::read(&home.join("settings.json"))?; + Ok(settings.local_auth.unwrap_or_default()) +} +fn write_mode(home: &Path, mode: LocalAuthMode) -> Result<()> { + let path = home.join("settings.json"); + let mut settings: Settings = super::json_store::read(&path)?; + settings.local_auth = Some(mode); + super::json_store::write(&path, &settings) +} + +#[derive(Debug)] +pub struct LocalAuth { + pub store: store::PairingStore, + mode: Mutex, +} +impl LocalAuth { + /// The caller keeps this lifecycle lock until all daemon work has stopped. + pub fn open(lock: &super::lockfile::DaemonLock) -> Result { + let home = lock.path().parent().expect("daemon lock has a home"); + Ok(Self { + store: store::PairingStore::open(lock)?, + mode: Mutex::new(read_mode(home)?), + }) + } + pub fn memory(mode: LocalAuthMode) -> Self { + Self { + store: store::PairingStore::memory(), + mode: Mutex::new(mode), + } + } + pub fn mode(&self) -> LocalAuthMode { + *self.mode.lock().expect("local auth mode poisoned") + } + pub fn set_mode(&self, home: &Path, mode: LocalAuthMode) -> Result<()> { + let mut current = self.mode.lock().expect("local auth mode poisoned"); + write_mode(home, mode)?; + *current = mode; + Ok(()) + } +} diff --git a/crates/bsk-cli/src/daemon/local_auth/store.rs b/crates/bsk-cli/src/daemon/local_auth/store.rs new file mode 100644 index 00000000..143b58c7 --- /dev/null +++ b/crates/bsk-cli/src/daemon/local_auth/store.rs @@ -0,0 +1,619 @@ +//! Pairing state. Mutations publish one snapshot before changing memory. + +use anyhow::{Result, bail, ensure}; +use bsk_protocol::local_auth::{Bytes, Credential, CredentialRole, RejectionReason, Rotation}; +use rand::{RngCore, rngs::OsRng}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::PathBuf, sync::Mutex}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +pub fn random() -> Bytes { + let mut value = [0; N]; + OsRng.fill_bytes(&mut value); + Bytes(value) +} +pub fn now() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} +fn date(value: i64) -> String { + OffsetDateTime::from_unix_timestamp(value) + .unwrap() + .format(&Rfc3339) + .unwrap() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeviceStatus { + Unactivated, + Active, + Revoked, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Pending { + pub txn: Bytes<16>, + pub r#gen: u32, + pub key: Bytes<32>, + #[serde(with = "timestamp")] + pub created_at: i64, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Device { + pub status: DeviceStatus, + pub browser_id: Option, + pub label: String, + pub key: Bytes<32>, + pub r#gen: u32, + pub pending: Option, + pub replaces: Option, + #[serde(with = "optional_timestamp")] + pub activated_at: Option, + #[serde(with = "optional_timestamp")] + pub expires_at: Option, + pub rotate_requested: bool, +} +impl Device { + pub fn valid(&self) -> bool { + self.status != DeviceStatus::Revoked && self.expires_at.is_none_or(|expiry| expiry > now()) + } +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct State { + version: u32, + daemon_id: Bytes<16>, + devices: BTreeMap, +} +impl Default for State { + fn default() -> Self { + Self { + version: 1, + daemon_id: random(), + devices: BTreeMap::new(), + } + } +} +pub struct PairingStore { + path: Option, + state: Mutex, +} +impl std::fmt::Debug for PairingStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PairingStore").finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct DeviceSummary { + pub kind: &'static str, + pub device_id: String, + pub browser_id: Option, + pub label: String, + pub status: DeviceStatus, + pub activated_at: Option, + pub expires_at: Option, + pub r#gen: u32, +} +#[derive(Debug, Clone)] +pub struct Selection { + pub device_id: Bytes<16>, + pub device: Device, + pub key: Bytes<32>, + pub r#gen: u32, + pub commit: Option, +} +#[derive(Debug, thiserror::Error)] +#[error("pairing activation rejected")] +pub struct ActivationRejected { + pub reason: RejectionReason, + pub browser_id: String, +} + +impl PairingStore { + /// The caller keeps daemon.lock while this store is in use; its mutex + /// serializes writes within that single process. + pub fn open(lock: &super::super::lockfile::DaemonLock) -> Result { + let path = lock.path().with_file_name("local-pairings.json"); + let state: State = super::super::json_store::read(&path)?; + ensure!( + state.version == 1, + "unsupported local pairing store version" + ); + super::super::json_store::write(&path, &state)?; + Ok(Self { + path: Some(path), + state: Mutex::new(state), + }) + } + pub fn memory() -> Self { + Self { + path: None, + state: Mutex::new(State::default()), + } + } + fn update(&self, work: impl FnOnce(&mut State) -> Result) -> Result { + let mut current = self.state.lock().expect("local pairing store poisoned"); + let mut next = current.clone(); + let result = work(&mut next)?; + if let Some(path) = &self.path { + super::super::json_store::write(path, &next)?; + } + *current = next; + Ok(result) + } + pub fn daemon_id(&self) -> Bytes<16> { + self.state.lock().unwrap().daemon_id + } + pub fn device(&self, id: &str) -> Option { + self.state.lock().unwrap().devices.get(id).cloned() + } + pub fn pair(&self, port: u16, replaces: Option<&str>) -> Result { + self.update(|state| { + let replaced = replaces.map(|id| resolve(state, id)).transpose()?; + if let Some(id) = &replaced { + ensure!( + state.devices[id].status == DeviceStatus::Active, + "replacement target is not active" + ); + } + state + .devices + .retain(|_, device| device.status != DeviceStatus::Unactivated || device.valid()); + ensure!( + state + .devices + .values() + .filter(|d| d.status == DeviceStatus::Unactivated) + .count() + < 16, + "too many outstanding pairing links; wait five minutes" + ); + let id = random::<16>(); + let key = random::<32>(); + state.devices.insert( + id.encode(), + Device { + status: DeviceStatus::Unactivated, + browser_id: None, + label: String::new(), + key, + r#gen: 0, + pending: None, + replaces: replaced, + activated_at: None, + expires_at: Some(now() + 300), + rotate_requested: false, + }, + ); + Ok(format!( + "ws://127.0.0.1:{port}/#bsk-local.v1.{}.{}.{}", + state.daemon_id.encode(), + id.encode(), + key.encode() + )) + }) + } + pub fn devices(&self) -> Vec { + self.state + .lock() + .unwrap() + .devices + .iter() + .map(|(id, d)| DeviceSummary { + kind: "local", + device_id: id.clone(), + browser_id: d.browser_id.clone(), + label: d.label.clone(), + status: d.status, + activated_at: d.activated_at.map(date), + expires_at: d.expires_at.map(date), + r#gen: d.r#gen, + }) + .collect() + } + pub fn resolve(&self, id: &str) -> Result { + resolve(&self.state.lock().unwrap(), id) + } + pub fn revoke(&self, id: Option<&str>) -> Result> { + self.update(|state| { + let ids = match id { + Some(id) => vec![resolve(state, id)?], + None => state.devices.keys().cloned().collect(), + }; + for id in &ids { + let d = state.devices.get_mut(id).unwrap(); + d.status = DeviceStatus::Revoked; + d.pending = None; + d.key = Bytes([0; 32]); + } + Ok(ids) + }) + } + pub fn request_rotation(&self, id: &str) -> Result<()> { + self.update(|state| { + let id = resolve(state, id)?; + let d = state.devices.get_mut(&id).unwrap(); + ensure!(d.status == DeviceStatus::Active, "device is not active"); + d.rotate_requested = true; + Ok(()) + }) + } + pub fn select(&self, offers: &[Credential]) -> Option { + let state = self.state.lock().unwrap(); + for (role, status) in [ + (CredentialRole::Staged, DeviceStatus::Active), + (CredentialRole::Active, DeviceStatus::Active), + (CredentialRole::Staged, DeviceStatus::Unactivated), + ] { + for offer in offers.iter().filter(|offer| offer.role == role) { + let Some(d) = state + .devices + .get(&offer.device_id.encode()) + .filter(|d| d.valid() && d.status == status) + else { + continue; + }; + let (key, r#gen, commit) = if let Some(p) = &offer.pending { + if let Some(saved) = d + .pending + .as_ref() + .filter(|saved| saved.txn == p.txn && saved.r#gen == p.r#gen) + { + (saved.key, saved.r#gen, Some(p.clone())) + } else if d.r#gen == p.r#gen { + (d.key, d.r#gen, Some(p.clone())) + } else if d.r#gen == offer.r#gen { + (d.key, d.r#gen, None) + } else { + continue; + } + } else if d.r#gen == offer.r#gen { + (d.key, d.r#gen, None) + } else { + continue; + }; + return Some(Selection { + device_id: offer.device_id, + device: d.clone(), + key, + r#gen, + commit, + }); + } + } + None + } + /// Call only after the client proof authenticates instance_id. + pub fn check_activation(&self, id: &str, instance: &str) -> Result<()> { + check_activation(&self.state.lock().unwrap(), id, instance) + } + pub fn stage_rotation( + &self, + id: &str, + expected_gen: u32, + expected_txn: Option>, + pending: Pending, + instance: &str, + ) -> Result<()> { + self.update(|state| { + check_activation(state, id, instance)?; + let d = state.devices.get_mut(id).unwrap(); + ensure!( + d.r#gen == expected_gen + && d.pending.as_ref().map(|p| p.txn) == expected_txn + && d.r#gen.checked_add(1) == Some(pending.r#gen), + "stale rotation" + ); + d.pending = Some(pending); + Ok(()) + }) + } + /// Activation and replacement are one durable transaction. Returns the old + /// device ID so the lifecycle layer can close its connection before replying. + pub fn commit(&self, id: &str, txn: Bytes<16>, instance: &str) -> Result> { + self.update(|state| { + check_activation(state, id, instance)?; + let device = state.devices.get_mut(id).unwrap(); + let pending = device + .pending + .take() + .filter(|p| p.txn == txn) + .ok_or_else(|| anyhow::anyhow!("stale rotation"))?; + device.key = pending.key; + device.r#gen = pending.r#gen; + device.rotate_requested = false; + device.status = DeviceStatus::Active; + device.expires_at = None; + device.activated_at.get_or_insert(now()); + device.browser_id = Some(instance.into()); + let replaced = device.replaces.take(); + if let Some(old) = &replaced { + let old = state.devices.get_mut(old).unwrap(); + old.status = DeviceStatus::Revoked; + old.key = Bytes([0; 32]); + old.pending = None; + } + Ok(replaced) + }) + } +} +fn resolve(state: &State, id: &str) -> Result { + if state.devices.contains_key(id) { + return Ok(id.into()); + } + state + .devices + .iter() + .find(|(_, d)| d.status == DeviceStatus::Active && d.browser_id.as_deref() == Some(id)) + .map(|(id, _)| id.clone()) + .ok_or_else(|| anyhow::anyhow!("local device not found")) +} +fn check_activation(state: &State, id: &str, instance: &str) -> Result<()> { + let d = state + .devices + .get(id) + .filter(|d| d.valid()) + .ok_or_else(|| anyhow::anyhow!("local authorization ended"))?; + if d.status == DeviceStatus::Active { + ensure!( + d.browser_id.as_deref() == Some(instance), + "browser identity mismatch" + ); + return Ok(()); + } + if let Some(target) = &d.replaces { + let old = state + .devices + .get(target) + .filter(|d| d.status == DeviceStatus::Active) + .ok_or_else(|| anyhow::anyhow!("replacement target is no longer active"))?; + if old.browser_id.as_deref() != Some(instance) { + bail!(ActivationRejected { + reason: RejectionReason::ReplaceMismatch, + browser_id: old.browser_id.clone().unwrap_or_default() + }); + } + } else if state + .devices + .values() + .any(|d| d.status == DeviceStatus::Active && d.browser_id.as_deref() == Some(instance)) + { + bail!(ActivationRejected { + reason: RejectionReason::AlreadyPaired, + browser_id: instance.into() + }); + } + Ok(()) +} + +mod timestamp { + use super::*; + pub fn serialize(value: &i64, serializer: S) -> Result { + date(*value).serialize(serializer) + } + pub fn deserialize<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + OffsetDateTime::parse(&value, &Rfc3339) + .map(|v| v.unix_timestamp()) + .map_err(serde::de::Error::custom) + } +} +mod optional_timestamp { + use super::*; + pub fn serialize( + value: &Option, + serializer: S, + ) -> Result { + value.map(date).serialize(serializer) + } + pub fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Option::::deserialize(deserializer)? + .map(|value| { + OffsetDateTime::parse(&value, &Rfc3339) + .map(|v| v.unix_timestamp()) + .map_err(serde::de::Error::custom) + }) + .transpose() + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn pairing(store: &PairingStore, replace: Option<&str>) -> (String, Bytes<32>) { + let link = store.pair(52800, replace).unwrap(); + let parts: Vec<_> = link.split('.').collect(); + ( + parts[parts.len() - 2].into(), + Bytes::decode(parts[parts.len() - 1]).unwrap(), + ) + } + fn activate(store: &PairingStore, id: &str, instance: &str) -> Bytes<32> { + let pending = Pending { + txn: random(), + r#gen: 1, + key: random(), + created_at: now(), + }; + store + .stage_rotation(id, 0, None, pending.clone(), instance) + .unwrap(); + store.commit(id, pending.txn, instance).unwrap(); + pending.key + } + #[test] + fn pending_limits_expiry_and_redaction() { + let home = tempfile::tempdir().unwrap(); + let lock = crate::daemon::lockfile::acquire_at(home.path()).unwrap(); + let store = PairingStore::open(&lock).unwrap(); + let (id, key) = pairing(&store, None); + for _ in 1..16 { + pairing(&store, None); + } + assert!(store.pair(52800, None).is_err()); + store + .update(|s| { + s.devices.get_mut(&id).unwrap().expires_at = Some(now() - 1); + Ok(()) + }) + .unwrap(); + pairing(&store, None); + assert!(store.device(&id).is_none()); + let summaries = serde_json::to_string(&store.devices()).unwrap(); + assert!(!summaries.contains(&key.encode())); + assert!(!format!("{store:?}").contains(&key.encode())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(home.path().join("local-pairings.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + #[test] + fn l14_atomic_replacement_and_rejected_attempts_preserve_the_original() { + let home = tempfile::tempdir().unwrap(); + let lock = crate::daemon::lockfile::acquire_at(home.path()).unwrap(); + let store = PairingStore::open(&lock).unwrap(); + let (original, _) = pairing(&store, None); + activate(&store, &original, "a1b2c3d4"); + let (duplicate, _) = pairing(&store, None); + let before = std::fs::read(home.path().join("local-pairings.json")).unwrap(); + let error = store.check_activation(&duplicate, "a1b2c3d4").unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().reason, + RejectionReason::AlreadyPaired + ); + assert_eq!( + before, + std::fs::read(home.path().join("local-pairings.json")).unwrap() + ); + let (replacement, _) = pairing(&store, Some("a1b2c3d4")); + let error = store + .check_activation(&replacement, "12345678") + .unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().reason, + RejectionReason::ReplaceMismatch + ); + assert_eq!( + store.device(&original).unwrap().status, + DeviceStatus::Active + ); + activate(&store, &replacement, "a1b2c3d4"); + drop(store); + let reopened = PairingStore::open(&lock).unwrap(); + assert_eq!( + reopened.device(&original).unwrap().status, + DeviceStatus::Revoked + ); + assert_eq!(reopened.resolve("a1b2c3d4").unwrap(), replacement); + assert!(reopened.device(&replacement).unwrap().expires_at.is_none()); + } + #[test] + fn l08_l09_recovery_selection_and_stale_transactions() { + let store = PairingStore::memory(); + let (id, _) = pairing(&store, None); + let pending = Pending { + txn: random(), + r#gen: 1, + key: random(), + created_at: now(), + }; + let mut offer = Credential { + role: CredentialRole::Staged, + device_id: Bytes::decode(&id).unwrap(), + r#gen: 0, + pending: None, + }; + store + .stage_rotation(&id, 0, None, pending.clone(), "a1b2c3d4") + .unwrap(); + assert_eq!(store.select(&[offer.clone()]).unwrap().r#gen, 0); + offer.pending = Some(Rotation { + txn: pending.txn, + r#gen: 1, + }); + let selection = store.select(&[offer.clone()]).unwrap(); + assert_eq!(selection.key, pending.key); + assert!(store.commit(&id, random(), "a1b2c3d4").is_err()); + assert!( + store + .stage_rotation(&id, 0, None, pending.clone(), "a1b2c3d4") + .is_err() + ); + store.commit(&id, pending.txn, "a1b2c3d4").unwrap(); + let recovered = store.select(&[offer]).unwrap(); + assert_eq!(recovered.key, pending.key); + assert_eq!(recovered.commit.unwrap().txn, pending.txn); + store.revoke(Some(&id)).unwrap(); + assert!(store.commit(&id, pending.txn, "a1b2c3d4").is_err()); + } + #[test] + fn l14b_l14c_staged_selection_does_not_interrupt_active_until_commit() { + let store = PairingStore::memory(); + let (old, _) = pairing(&store, None); + activate(&store, &old, "a1b2c3d4"); + let (new, _) = pairing(&store, Some(&old)); + let active = Credential { + role: CredentialRole::Active, + device_id: Bytes::decode(&old).unwrap(), + r#gen: 1, + pending: None, + }; + let pending = Pending { + txn: random(), + r#gen: 1, + key: random(), + created_at: now(), + }; + store + .stage_rotation(&new, 0, None, pending.clone(), "a1b2c3d4") + .unwrap(); + let staged = Credential { + role: CredentialRole::Staged, + device_id: Bytes::decode(&new).unwrap(), + r#gen: 0, + pending: Some(Rotation { + txn: pending.txn, + r#gen: 1, + }), + }; + let offers = [active, staged]; + assert_eq!(store.select(&offers).unwrap().device_id.encode(), old); + store.commit(&new, pending.txn, "a1b2c3d4").unwrap(); + assert_eq!(store.select(&offers).unwrap().device_id.encode(), new); + } + #[test] + fn failed_durable_write_does_not_publish_a_memory_commit() { + let home = tempfile::tempdir().unwrap(); + let lock = crate::daemon::lockfile::acquire_at(home.path()).unwrap(); + let store = PairingStore::open(&lock).unwrap(); + let (id, key) = pairing(&store, None); + let pending = Pending { + txn: random(), + r#gen: 1, + key: random(), + created_at: now(), + }; + store + .stage_rotation(&id, 0, None, pending.clone(), "a1b2c3d4") + .unwrap(); + std::fs::remove_file(home.path().join("local-pairings.json")).unwrap(); + std::fs::create_dir(home.path().join("local-pairings.json")).unwrap(); + assert!(store.commit(&id, pending.txn, "a1b2c3d4").is_err()); + let d = store.device(&id).unwrap(); + assert_eq!(d.key, key); + assert_eq!(d.status, DeviceStatus::Unactivated); + assert!(d.pending.is_some()); + } +} diff --git a/crates/bsk-cli/src/daemon/lockfile.rs b/crates/bsk-cli/src/daemon/lockfile.rs index 4cea46f7..fe7dd845 100644 --- a/crates/bsk-cli/src/daemon/lockfile.rs +++ b/crates/bsk-cli/src/daemon/lockfile.rs @@ -46,8 +46,12 @@ pub struct AlreadyLocked { /// Acquire the lock exclusively, returning a guard. pub fn acquire() -> Result { - paths::ensure_bsk_home()?; - let path = paths::lock_path()?; + acquire_at(&paths::ensure_bsk_home()?) +} + +/// Acquire the lifecycle lock in an already-created daemon home. +pub fn acquire_at(home: &std::path::Path) -> Result { + let path = home.join("daemon.lock"); let file = OpenOptions::new() .read(true) .write(true) diff --git a/crates/bsk-cli/src/daemon/mod.rs b/crates/bsk-cli/src/daemon/mod.rs index 0e2ab8db..c54b053c 100644 --- a/crates/bsk-cli/src/daemon/mod.rs +++ b/crates/bsk-cli/src/daemon/mod.rs @@ -9,6 +9,8 @@ pub mod file_transfer; pub mod inflight; pub mod info; pub mod ipc; +pub(crate) mod json_store; +pub mod local_auth; pub mod lockfile; pub mod paths; pub(crate) mod probe; diff --git a/crates/bsk-cli/src/daemon/start.rs b/crates/bsk-cli/src/daemon/start.rs index f58df457..6e7e3c32 100644 --- a/crates/bsk-cli/src/daemon/start.rs +++ b/crates/bsk-cli/src/daemon/start.rs @@ -56,6 +56,7 @@ pub(crate) const DAEMON_REPLACEMENT_WAIT_ENV: &str = "BSK_DAEMON_REPLACES_PID"; #[derive(Debug, Clone)] pub struct DaemonConfig { pub server: Option, + pub local_auth: super::local_auth::LocalAuthMode, pub ws_port: u16, pub session_idle: Duration, pub daemon_idle: Duration, @@ -86,6 +87,7 @@ impl DaemonConfig { pub fn new(port: u16) -> Self { Self { server: None, + local_auth: Default::default(), ws_port: port, session_idle: Duration::from_secs(60 * 5), daemon_idle: Duration::from_secs(60 * 30), @@ -123,6 +125,7 @@ impl From<&StartArgs> for DaemonConfig { fn from(args: &StartArgs) -> Self { Self { server: None, + local_auth: Default::default(), ws_port: args.resolved_port(), session_idle: args.resolved_session_idle(), daemon_idle: args.resolved_daemon_idle(), @@ -410,7 +413,7 @@ fn run_daemon(cfg: &DaemonConfig) -> Result<()> { ); let startup = acquire_daemon_lock(cfg.replaces).and_then(|lock| { info!(?lock, "daemon lock acquired"); - serve(cfg, None).map(|stopped| (lock, stopped)) + serve(cfg, &lock, None).map(|stopped| (lock, stopped)) }); let (mut lock, mut stopped) = match startup { Ok(started) => started, @@ -441,7 +444,7 @@ fn run_daemon(cfg: &DaemonConfig) -> Result<()> { record } }; - stopped = match serve(&resumed_cfg, Some(&mut record)) { + stopped = match serve(&resumed_cfg, &lock, Some(&mut record)) { Ok(stopped) => stopped, Err(err) => { record.serving_failed(&err); @@ -454,24 +457,38 @@ fn run_daemon(cfg: &DaemonConfig) -> Result<()> { /// Take the daemon lock. A replacement waits for its predecessor, which /// keeps serving until the replacement has started and then releases it. fn acquire_daemon_lock(predecessor: Option) -> Result { - let Some(pid) = predecessor else { - return lockfile::acquire().context("acquire daemon lock"); + let wait = if let Some(pid) = predecessor { + info!( + predecessor = pid, + "replacement daemon waiting for the daemon lock" + ); + handover::REPLACEMENT_LOCK_WAIT + } else { + Duration::from_secs(2) }; - info!( - predecessor = pid, - "replacement daemon waiting for its predecessor to release the daemon lock" - ); - let deadline = Instant::now() + handover::REPLACEMENT_LOCK_WAIT; + let deadline = Instant::now() + wait; + let initial_info = daemon_info::read()?; + let mut contended = false; loop { + if predecessor.is_none() && contended && daemon_info::read()? != initial_info { + anyhow::bail!("another daemon won startup while waiting for the lifecycle lock"); + } match lockfile::acquire() { Ok(lock) => return Ok(lock), Err(err) if err.is::() && Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(50)); + // Retry a short offline-maintenance lock, but stop as soon as + // another daemon is serving. A losing starter must not linger + // and take over when that daemon is explicitly stopped. + if predecessor.is_none() + && matches!(probe::probe(PROBE_TIMEOUT), Ok(Probe::Ready(_))) + { + return Err(err).context("another daemon is already serving"); + } + contended = true; + std::thread::sleep(Duration::from_millis(10)); } Err(err) => { - return Err( - err.context(format!("acquire daemon lock released by predecessor {pid}")) - ); + return Err(err.context("acquire daemon lifecycle lock")); } } } @@ -531,7 +548,11 @@ fn lock_slot(slot: &TransactionSlot) -> std::sync::MutexGuard<'_, Option) -> Result { +fn serve( + cfg: &DaemonConfig, + lock: &lockfile::DaemonLock, + resumed: Option<&mut UpdateRecord>, +) -> Result { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -554,7 +575,11 @@ fn serve(cfg: &DaemonConfig, resumed: Option<&mut UpdateRecord>) -> Result, pub config: DaemonConfig, + pub local_auth: Arc, pub browsers: Arc, pub sessions: Arc, pub session_requests: super::session_requests::SessionRequests, @@ -106,6 +107,7 @@ impl DaemonState { let transfers = Arc::new(TransferRegistry::new().expect("initialise transfer staging")); Self { audit, + local_auth: Arc::new(super::local_auth::LocalAuth::memory(config.local_auth)), config, browsers, sessions, diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index 9565b868..09864538 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -15,8 +15,12 @@ fn main() -> ExitCode { // The daemon installs its own subscriber (with the rolling file // appender); CLI tracing would race with it and silently win, so - // only init the CLI-side stderr subscriber for client commands. - if !matches!(cli.command, Command::Daemon(_)) { + // only init the CLI-side stderr subscriber for client commands, including + // daemon maintenance. Start/restart own the daemon's logging setup. + if !matches!( + cli.command, + Command::Daemon(cli::daemon::DaemonCmd::Start(_) | cli::daemon::DaemonCmd::Restart(_)) + ) { init_cli_tracing(&cli.flags); } cli::update::print_update_hint_from_cache(&cli.flags, &cli.command); diff --git a/crates/bsk-cli/tests/auto_spawn.rs b/crates/bsk-cli/tests/auto_spawn.rs index 7ee9c300..c79f1abd 100644 --- a/crates/bsk-cli/tests/auto_spawn.rs +++ b/crates/bsk-cli/tests/auto_spawn.rs @@ -35,7 +35,7 @@ impl Drop for StopOnDrop { } #[test] -fn status_auto_spawns_from_an_empty_home() { +fn status_and_pair_auto_spawn_from_an_empty_home() { // BSK_HOME isolates files, not the production default WS port. Exercise // the real default on clean CI hosts; never stop a developer's daemon. match std::net::TcpListener::bind(("127.0.0.1", bsk::cli::daemon::DEFAULT_WS_PORT)) { @@ -74,6 +74,31 @@ fn status_auto_spawns_from_an_empty_home() { assert!(again.status.success()); let reused: bsk_protocol::StatusResult = serde_json::from_slice(&again.stdout).unwrap(); assert_eq!(reused.pid, info.pid); + + // Pairing must print a usable link, not leave the user with an offline + // daemon. Run sequentially with the status case to share the default port. + assert!( + command(&home, &["daemon", "stop"]) + .output() + .unwrap() + .status + .success() + ); + let pair_home = tmp.path().join("pair"); + let _pair_cleanup = StopOnDrop(pair_home.clone()); + let pair = command(&pair_home, &["daemon", "pair"]).output().unwrap(); + assert!( + pair.status.success(), + "{}", + String::from_utf8_lossy(&pair.stderr) + ); + let info: bsk::daemon::info::DaemonInfo = + serde_json::from_slice(&std::fs::read(pair_home.join("daemon.json")).unwrap()).unwrap(); + assert!( + String::from_utf8_lossy(&pair.stdout) + .starts_with(&format!("ws://127.0.0.1:{}/#bsk-local.v1.", info.ws_port)) + ); + assert!(std::net::TcpStream::connect(("127.0.0.1", info.ws_port)).is_ok()); } fn wait_for_pid_exit(pid: i32, timeout: Duration) -> bool { @@ -145,7 +170,11 @@ fn disabled_auto_start_leaves_missing_and_stale_discovery_alone() { bsk::daemon::info::write_to_path(&info, &info_path).unwrap(); } let original = std::fs::read(&info_path).ok(); - for args in [vec!["--json", "status"], vec!["--json", "session", "start"]] { + for args in [ + vec!["--json", "status"], + vec!["--json", "session", "start"], + vec!["--json", "daemon", "pair"], + ] { let out = command(&home, &args) .env("BSK_AUTO_START", "0") .output() diff --git a/crates/bsk-cli/tests/local_pairing_maintenance.rs b/crates/bsk-cli/tests/local_pairing_maintenance.rs new file mode 100644 index 00000000..66c61242 --- /dev/null +++ b/crates/bsk-cli/tests/local_pairing_maintenance.rs @@ -0,0 +1,175 @@ +//! Lifecycle-lock races are tested with real CLI and daemon processes. +use bsk::daemon::local_auth::{LocalAuth, LocalAuthMode, read_mode, store::PairingStore}; +use bsk::daemon::lockfile; +use std::{ + fs, + path::Path, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +fn command(home: &Path) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_bsk")); + cmd.env("BSK_HOME", home) + .env("BSK_AUTO_UPDATE", "off") + .env_remove("BSK_DAEMONIZED") + .env_remove("BSK_DAEMON_REPLACES_PID") + .stdin(Stdio::null()); + cmd +} +struct Process(Child); +impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn output(home: &Path, args: &[&str]) -> String { + let result = command(home).args(args).output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout).unwrap() +} + +#[test] +fn l11a_offline_revoke_and_startup_use_one_lifecycle_lock() { + let home = tempfile::tempdir().unwrap(); + let lock = lockfile::acquire_at(home.path()).unwrap(); + let store = PairingStore::open(&lock).unwrap(); + store.pair(52800, None).unwrap(); + let id = store.devices()[0].device_id.clone(); + let maintenance_log = home.path().join("test-maintenance.log"); + let maintenance_output = home.path().join("test-devices.json"); + // Observe the actual contention before releasing our lock. A fixed sleep + // can let a slowly scheduled CLI start only after release and read offline, + // instead of waiting for the daemon's IPC. + let mut maintenance = Process( + command(home.path()) + .args(["-v", "daemon", "devices"]) + .env("RUST_LOG", "bsk=debug") + .stdout(fs::File::create(&maintenance_output).unwrap()) + .stderr(fs::File::create(&maintenance_log).unwrap()) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let log = fs::read_to_string(&maintenance_log).unwrap(); + if log.contains("daemon maintenance waiting for IPC after lifecycle lock contention") { + break; + } + assert!( + maintenance.0.try_wait().unwrap().is_none(), + "maintenance exited before observing the held lock: {log}" + ); + assert!( + Instant::now() < deadline, + "maintenance did not reach the lock: {log}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(fs::read(&maintenance_output).unwrap().is_empty()); + // Start only after the CLI commits to IPC, so a slow CLI launch cannot + // consume the daemon's shorter startup-lock retry window. + let log_path = home.path().join("test-daemon.log"); + let mut daemon = Process( + command(home.path()) + .args(["daemon", "start", "--foreground", "--port", "0"]) + .stdout(Stdio::null()) + .stderr(fs::File::create(&log_path).unwrap()) + .spawn() + .unwrap(), + ); + store.revoke(Some(&id)).unwrap(); + drop(store); + drop(lock); + let deadline = Instant::now() + Duration::from_secs(10); + while !home.path().join("daemon.json").exists() { + assert!( + Instant::now() < deadline, + "daemon did not retry its lifecycle lock" + ); + assert!( + daemon.0.try_wait().unwrap().is_none(), + "daemon exited during startup: {}", + fs::read_to_string(&log_path).unwrap_or_default() + ); + std::thread::sleep(Duration::from_millis(20)); + } + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = maintenance.0.try_wait().unwrap() { + break status; + } + assert!( + Instant::now() < deadline, + "maintenance did not finish over IPC: {}", + fs::read_to_string(&maintenance_log).unwrap() + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert!( + status.success(), + "{}", + fs::read_to_string(&maintenance_log).unwrap() + ); + let info: serde_json::Value = + serde_json::from_slice(&fs::read(home.path().join("daemon.json")).unwrap()).unwrap(); + assert!( + output(home.path(), &["daemon", "pair"]) + .starts_with(&format!("ws://127.0.0.1:{}/#", info["ws_port"])) + ); + let devices: serde_json::Value = + serde_json::from_slice(&fs::read(&maintenance_output).unwrap()).unwrap(); + assert_eq!( + devices + .as_array() + .unwrap() + .iter() + .find(|d| d["device_id"] == id) + .unwrap()["status"], + "revoked" + ); + output(home.path(), &["daemon", "trust-local", "on"]); + assert_eq!(read_mode(home.path()).unwrap(), LocalAuthMode::TrustLocal); + output(home.path(), &["daemon", "trust-local", "off"]); + assert_eq!(read_mode(home.path()).unwrap(), LocalAuthMode::Required); +} + +#[test] +fn l16_local_keys_are_not_remote_bearer_grants() { + let home = tempfile::tempdir().unwrap(); + let lock = lockfile::acquire_at(home.path()).unwrap(); + let store = PairingStore::open(&lock).unwrap(); + let link = store.pair(52800, None).unwrap(); + drop(store); + drop(lock); + let key = link.trim().rsplit('.').next().unwrap(); + let remote = bsk::daemon::remote::authorization::AuthorizationStore::at_home(home.path()); + assert!(remote.authenticate(key).is_err()); + assert!(bsk_protocol::local_auth::Probe::parse(&format!("{{\"id\":\"x\",\"method\":\"system.ping\",\"params\":{{\"local_auth\":{{\"token\":\"{key}\"}}}}}}")).is_err()); + assert!(!output(home.path(), &["daemon", "devices"]).contains(key)); +} + +#[test] +fn settings_preserve_unrelated_fields_and_absence_uses_release_default() { + let home = tempfile::tempdir().unwrap(); + assert_eq!(read_mode(home.path()).unwrap(), LocalAuthMode::default()); + fs::write( + home.path().join("settings.json"), + br#"{"custom":{"enabled":true}}"#, + ) + .unwrap(); + let lock = lockfile::acquire_at(home.path()).unwrap(); + let local = LocalAuth::open(&lock).unwrap(); + local + .set_mode(home.path(), LocalAuthMode::TrustLocal) + .unwrap(); + let settings: serde_json::Value = + serde_json::from_slice(&fs::read(home.path().join("settings.json")).unwrap()).unwrap(); + assert_eq!(settings["custom"]["enabled"], true); + assert_eq!(read_mode(home.path()).unwrap(), LocalAuthMode::TrustLocal); +} diff --git a/crates/bsk-protocol/src/method.rs b/crates/bsk-protocol/src/method.rs index ef37a333..669c696d 100644 --- a/crates/bsk-protocol/src/method.rs +++ b/crates/bsk-protocol/src/method.rs @@ -23,6 +23,16 @@ pub enum MethodEffect { pub enum Method { #[serde(rename = "tool.cooperate")] ToolCooperate, + #[serde(rename = "local_auth.pair")] + LocalAuthPair, + #[serde(rename = "local_auth.devices")] + LocalAuthDevices, + #[serde(rename = "local_auth.revoke")] + LocalAuthRevoke, + #[serde(rename = "local_auth.rotate")] + LocalAuthRotate, + #[serde(rename = "local_auth.set_mode")] + LocalAuthSetMode, #[serde(rename = "tool.video")] ToolVideo, /// Extension-only local history API; browser identity comes from the peer. @@ -280,7 +290,12 @@ impl Method { | Method::TransferRead | Method::TransferRelease | Method::ToolScreenshotRelease - | Method::Cancel => MethodEffect::ControlPlane, + | Method::Cancel + | Method::LocalAuthPair + | Method::LocalAuthDevices + | Method::LocalAuthRevoke + | Method::LocalAuthRotate + | Method::LocalAuthSetMode => MethodEffect::ControlPlane, } }