Skip to content

Commit 34d0075

Browse files
feat(daemon): authenticate local WebSocket peers before registration
1 parent e67761c commit 34d0075

27 files changed

Lines changed: 1342 additions & 83 deletions
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
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+
}

‎crates/bsk-cli/src/daemon/local_auth/mod.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! Local pairing credentials and policy. Remote grants use a separate store.
2+
pub(crate) mod handshake;
23
pub mod maintenance;
4+
pub(crate) mod read_limit;
35
pub mod store;
46

57
use anyhow::Result;
@@ -41,6 +43,7 @@ fn write_mode(home: &Path, mode: LocalAuthMode) -> Result<()> {
4143
pub struct LocalAuth {
4244
pub store: store::PairingStore,
4345
mode: Mutex<LocalAuthMode>,
46+
gates: Mutex<std::collections::HashMap<String, std::sync::Weak<tokio::sync::Mutex<()>>>>,
4447
}
4548
impl LocalAuth {
4649
/// The caller keeps this lifecycle lock until all daemon work has stopped.
@@ -49,14 +52,26 @@ impl LocalAuth {
4952
Ok(Self {
5053
store: store::PairingStore::open(lock)?,
5154
mode: Mutex::new(read_mode(home)?),
55+
gates: Default::default(),
5256
})
5357
}
5458
pub fn memory(mode: LocalAuthMode) -> Self {
5559
Self {
5660
store: store::PairingStore::memory(),
5761
mode: Mutex::new(mode),
62+
gates: Default::default(),
5863
}
5964
}
65+
pub(crate) fn device_gate(&self, id: &str) -> std::sync::Arc<tokio::sync::Mutex<()>> {
66+
let mut gates = self.gates.lock().expect("local device gates poisoned");
67+
gates.retain(|_, gate| gate.strong_count() > 0);
68+
if let Some(gate) = gates.get(id).and_then(std::sync::Weak::upgrade) {
69+
return gate;
70+
}
71+
let gate = std::sync::Arc::new(tokio::sync::Mutex::new(()));
72+
gates.insert(id.into(), std::sync::Arc::downgrade(&gate));
73+
gate
74+
}
6075
pub fn mode(&self) -> LocalAuthMode {
6176
*self.mode.lock().expect("local auth mode poisoned")
6277
}

0 commit comments

Comments
 (0)