|
| 1 | +//! Authentication is isolated from browser registration and business dispatch. |
| 2 | +use super::{ |
| 3 | + LocalAuth, |
| 4 | + store::{ActivationRejected, DeviceStatus, Pending, now, random}, |
| 5 | +}; |
| 6 | +use crate::daemon::peer_authorization::{LocalPeer, PeerAuthorization}; |
| 7 | +use anyhow::{Result, anyhow, bail, ensure}; |
| 8 | +use bsk_protocol::local_auth::{ |
| 9 | + AuthMessage, ChallengeResponse, Commit, Probe, Purpose, Rotation, Transcript, V1, prove, verify, |
| 10 | +}; |
| 11 | +use bsk_protocol::{ErrorCode, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError}; |
| 12 | +use futures_util::{SinkExt, StreamExt}; |
| 13 | +use std::sync::Arc; |
| 14 | +use tokio::io::{AsyncRead, AsyncWrite}; |
| 15 | +use tokio_tungstenite::{WebSocketStream, tungstenite::Message}; |
| 16 | + |
| 17 | +pub(crate) async fn accept<S: AsyncRead + AsyncWrite + Unpin>( |
| 18 | + local: Arc<LocalAuth>, |
| 19 | + ws: &mut WebSocketStream<S>, |
| 20 | + origin: &str, |
| 21 | +) -> Result<(PeerAuthorization, Option<Message>)> { |
| 22 | + let first = text(ws).await?; |
| 23 | + let request: RequestFrame = |
| 24 | + serde_json::from_str(&first).map_err(|_| anyhow!("invalid authentication probe"))?; |
| 25 | + if request.method == Method::SystemHandshake { |
| 26 | + if !local.mode().accepts_legacy() { |
| 27 | + reply( |
| 28 | + ws, |
| 29 | + &request.id, |
| 30 | + ResponseBody::Err(RpcError { |
| 31 | + code: ErrorCode::PairingRequired, |
| 32 | + message: "update the extension and run `bsk daemon pair` to connect".into(), |
| 33 | + data: None, |
| 34 | + }), |
| 35 | + ) |
| 36 | + .await?; |
| 37 | + bail!("local pairing required"); |
| 38 | + } |
| 39 | + return Ok((PeerAuthorization::Legacy, Some(Message::Text(first)))); |
| 40 | + } |
| 41 | + if request.method != Method::SystemPing |
| 42 | + || request |
| 43 | + .params |
| 44 | + .as_ref() |
| 45 | + .and_then(|p| p.get("local_auth")) |
| 46 | + .is_none() |
| 47 | + { |
| 48 | + reply( |
| 49 | + ws, |
| 50 | + &request.id, |
| 51 | + ResponseBody::Err(RpcError { |
| 52 | + code: ErrorCode::ProtocolError, |
| 53 | + message: "first frame must be system.handshake or a local authentication probe" |
| 54 | + .into(), |
| 55 | + data: None, |
| 56 | + }), |
| 57 | + ) |
| 58 | + .await?; |
| 59 | + bail!("invalid first frame"); |
| 60 | + } |
| 61 | + let probe = Probe::parse(&first)?; |
| 62 | + let hello = probe.params.local_auth; |
| 63 | + let Some(initial) = local.store.select(&hello.credentials) else { |
| 64 | + challenge( |
| 65 | + ws, |
| 66 | + &probe.id, |
| 67 | + ChallengeResponse::PairingRequired { |
| 68 | + daemon_id: local.store.daemon_id(), |
| 69 | + legacy_accepted: local.mode().accepts_legacy(), |
| 70 | + }, |
| 71 | + ) |
| 72 | + .await?; |
| 73 | + bail!("local pairing required"); |
| 74 | + }; |
| 75 | + let device_id = initial.device_id.encode(); |
| 76 | + let gate = local.device_gate(&device_id); |
| 77 | + let Ok(_guard) = gate.try_lock_owned() else { |
| 78 | + challenge( |
| 79 | + ws, |
| 80 | + &probe.id, |
| 81 | + ChallengeResponse::Busy { |
| 82 | + retry_after_ms: 1000, |
| 83 | + }, |
| 84 | + ) |
| 85 | + .await?; |
| 86 | + bail!("local device authentication busy"); |
| 87 | + }; |
| 88 | + // Selection preceded the gate; re-read under it before issuing a proof. |
| 89 | + let selected = local |
| 90 | + .store |
| 91 | + .select(&hello.credentials) |
| 92 | + .filter(|s| s.device_id == initial.device_id) |
| 93 | + .ok_or_else(|| anyhow!("local authorization changed"))?; |
| 94 | + let server_nonce = random(); |
| 95 | + let daemon_id = local.store.daemon_id(); |
| 96 | + let transcript = Transcript { |
| 97 | + daemon_id: &daemon_id, |
| 98 | + device_id: &selected.device_id, |
| 99 | + r#gen: selected.r#gen, |
| 100 | + instance_id: &hello.instance_id, |
| 101 | + origin, |
| 102 | + client_nonce: &hello.client_nonce, |
| 103 | + server_nonce: &server_nonce, |
| 104 | + } |
| 105 | + .encode(); |
| 106 | + challenge( |
| 107 | + ws, |
| 108 | + &probe.id, |
| 109 | + ChallengeResponse::Challenge { |
| 110 | + daemon_id, |
| 111 | + device_id: selected.device_id, |
| 112 | + r#gen: selected.r#gen, |
| 113 | + server_nonce, |
| 114 | + server_proof: prove(&selected.key, Purpose::Server, &transcript, None), |
| 115 | + }, |
| 116 | + ) |
| 117 | + .await?; |
| 118 | + let AuthMessage::Prove { client_proof, .. } = AuthMessage::parse(&text(ws).await?)? else { |
| 119 | + bail!("expected client proof"); |
| 120 | + }; |
| 121 | + ensure!( |
| 122 | + verify( |
| 123 | + &selected.key, |
| 124 | + Purpose::Client, |
| 125 | + &transcript, |
| 126 | + None, |
| 127 | + &client_proof |
| 128 | + ), |
| 129 | + "local client proof rejected" |
| 130 | + ); |
| 131 | + if let Err(error) = local.store.check_activation(&device_id, &hello.instance_id) { |
| 132 | + if let Some(rejected) = error.downcast_ref::<ActivationRejected>() { |
| 133 | + send( |
| 134 | + ws, |
| 135 | + &AuthMessage::Rejected { |
| 136 | + v: V1, |
| 137 | + reason: rejected.reason, |
| 138 | + browser_id: rejected.browser_id.clone(), |
| 139 | + }, |
| 140 | + ) |
| 141 | + .await?; |
| 142 | + } |
| 143 | + return Err(error); |
| 144 | + } |
| 145 | + if let Some(commit) = selected.commit { |
| 146 | + if selected |
| 147 | + .device |
| 148 | + .pending |
| 149 | + .as_ref() |
| 150 | + .is_some_and(|p| p.txn == commit.txn) |
| 151 | + { |
| 152 | + durable_commit( |
| 153 | + local.clone(), |
| 154 | + device_id.clone(), |
| 155 | + commit.txn, |
| 156 | + hello.instance_id.clone(), |
| 157 | + ) |
| 158 | + .await?; |
| 159 | + } |
| 160 | + send( |
| 161 | + ws, |
| 162 | + &AuthMessage::Ready { |
| 163 | + v: V1, |
| 164 | + rotation: None, |
| 165 | + commit: Some(Commit { |
| 166 | + txn: commit.txn, |
| 167 | + r#gen: commit.r#gen, |
| 168 | + proof: prove( |
| 169 | + &selected.key, |
| 170 | + Purpose::Committed, |
| 171 | + &transcript, |
| 172 | + Some(&commit.txn), |
| 173 | + ), |
| 174 | + }), |
| 175 | + }, |
| 176 | + ) |
| 177 | + .await?; |
| 178 | + } else if selected.device.status == DeviceStatus::Unactivated |
| 179 | + || selected.device.rotate_requested |
| 180 | + || selected.device.pending.is_some() |
| 181 | + { |
| 182 | + let txn = random(); |
| 183 | + let generation = selected |
| 184 | + .r#gen |
| 185 | + .checked_add(1) |
| 186 | + .ok_or_else(|| anyhow!("local key generation exhausted"))?; |
| 187 | + let candidate = prove(&selected.key, Purpose::Rotate, &transcript, Some(&txn)); |
| 188 | + let pending = Pending { |
| 189 | + txn, |
| 190 | + r#gen: generation, |
| 191 | + key: candidate, |
| 192 | + created_at: now(), |
| 193 | + }; |
| 194 | + let store = local.clone(); |
| 195 | + let id = device_id.clone(); |
| 196 | + let instance = hello.instance_id.clone(); |
| 197 | + tokio::task::spawn_blocking(move || { |
| 198 | + store.store.stage_rotation( |
| 199 | + &id, |
| 200 | + selected.r#gen, |
| 201 | + selected.device.pending.map(|p| p.txn), |
| 202 | + pending, |
| 203 | + &instance, |
| 204 | + ) |
| 205 | + }) |
| 206 | + .await??; |
| 207 | + send( |
| 208 | + ws, |
| 209 | + &AuthMessage::Ready { |
| 210 | + v: V1, |
| 211 | + commit: None, |
| 212 | + rotation: Some(Rotation { |
| 213 | + txn, |
| 214 | + r#gen: generation, |
| 215 | + }), |
| 216 | + }, |
| 217 | + ) |
| 218 | + .await?; |
| 219 | + let AuthMessage::RotateConfirm { |
| 220 | + txn: confirmed, |
| 221 | + proof, |
| 222 | + .. |
| 223 | + } = AuthMessage::parse(&text(ws).await?)? |
| 224 | + else { |
| 225 | + bail!("expected rotation confirmation"); |
| 226 | + }; |
| 227 | + ensure!( |
| 228 | + confirmed == txn |
| 229 | + && verify( |
| 230 | + &candidate, |
| 231 | + Purpose::Confirm, |
| 232 | + &transcript, |
| 233 | + Some(&txn), |
| 234 | + &proof |
| 235 | + ), |
| 236 | + "local rotation confirmation rejected" |
| 237 | + ); |
| 238 | + durable_commit( |
| 239 | + local.clone(), |
| 240 | + device_id.clone(), |
| 241 | + txn, |
| 242 | + hello.instance_id.clone(), |
| 243 | + ) |
| 244 | + .await?; |
| 245 | + send( |
| 246 | + ws, |
| 247 | + &AuthMessage::Committed { |
| 248 | + v: V1, |
| 249 | + r#gen: generation, |
| 250 | + proof: prove(&candidate, Purpose::Committed, &transcript, Some(&txn)), |
| 251 | + }, |
| 252 | + ) |
| 253 | + .await?; |
| 254 | + } else { |
| 255 | + send( |
| 256 | + ws, |
| 257 | + &AuthMessage::Ready { |
| 258 | + v: V1, |
| 259 | + commit: None, |
| 260 | + rotation: None, |
| 261 | + }, |
| 262 | + ) |
| 263 | + .await?; |
| 264 | + } |
| 265 | + let browser_id = local |
| 266 | + .store |
| 267 | + .device(&device_id) |
| 268 | + .and_then(|d| d.browser_id) |
| 269 | + .ok_or_else(|| anyhow!("local activation incomplete"))?; |
| 270 | + Ok(( |
| 271 | + PeerAuthorization::Local(LocalPeer { |
| 272 | + device_id, |
| 273 | + browser_id, |
| 274 | + local, |
| 275 | + }), |
| 276 | + None, |
| 277 | + )) |
| 278 | +} |
| 279 | + |
| 280 | +async fn durable_commit( |
| 281 | + local: Arc<LocalAuth>, |
| 282 | + id: String, |
| 283 | + txn: bsk_protocol::local_auth::Bytes<16>, |
| 284 | + instance: String, |
| 285 | +) -> Result<()> { |
| 286 | + tokio::task::spawn_blocking(move || local.store.commit(&id, txn, &instance)).await??; |
| 287 | + Ok(()) |
| 288 | +} |
| 289 | +async fn text<S: AsyncRead + AsyncWrite + Unpin>(ws: &mut WebSocketStream<S>) -> Result<String> { |
| 290 | + match ws.next().await { |
| 291 | + Some(Ok(Message::Text(text))) |
| 292 | + if text.len() <= bsk_protocol::local_auth::MAX_MESSAGE_BYTES => |
| 293 | + { |
| 294 | + Ok(text) |
| 295 | + } |
| 296 | + _ => bail!("invalid local authentication frame"), |
| 297 | + } |
| 298 | +} |
| 299 | +async fn send<S: AsyncRead + AsyncWrite + Unpin>( |
| 300 | + ws: &mut WebSocketStream<S>, |
| 301 | + message: &AuthMessage, |
| 302 | +) -> Result<()> { |
| 303 | + ws.send(Message::Text(serde_json::to_string(message)?)) |
| 304 | + .await?; |
| 305 | + Ok(()) |
| 306 | +} |
| 307 | +async fn challenge<S: AsyncRead + AsyncWrite + Unpin>( |
| 308 | + ws: &mut WebSocketStream<S>, |
| 309 | + id: &str, |
| 310 | + value: ChallengeResponse, |
| 311 | +) -> Result<()> { |
| 312 | + reply( |
| 313 | + ws, |
| 314 | + id, |
| 315 | + ResponseBody::Ok(serde_json::json!({"local_auth":value})), |
| 316 | + ) |
| 317 | + .await |
| 318 | +} |
| 319 | +async fn reply<S: AsyncRead + AsyncWrite + Unpin>( |
| 320 | + ws: &mut WebSocketStream<S>, |
| 321 | + id: &str, |
| 322 | + body: ResponseBody, |
| 323 | +) -> Result<()> { |
| 324 | + ws.send(Message::Text(serde_json::to_string(&ResponseFrame { |
| 325 | + id: id.into(), |
| 326 | + body, |
| 327 | + })?)) |
| 328 | + .await?; |
| 329 | + Ok(()) |
| 330 | +} |
0 commit comments