1use std::collections::BTreeMap;
19use std::sync::mpsc;
20use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
21
22use aws_lc_rs::signature::{ED25519, UnparsedPublicKey};
23use kernel::{AxCode, AxError, B3Hash, TimeMs};
24use tokio::sync::watch;
25
26use crate::answer::{DeviceId, DevicesAnswer};
27use crate::auth::Pairing;
28use crate::reception::{
29 BindFace, BrowserDoor, DeviceKey, Guess, Keys, LABEL_MAX, PairedBrowser, Sessions, decode_hex,
30};
31
32use super::pairing::{PairProof, SessionBody};
33use super::seat::Seat;
34
35const SIGNED_LABEL: &str = "sprawling local session v1";
38
39pub type KeepBrowsers = Arc<dyn Fn(&[PairedBrowser]) -> Result<(), AxError> + Send + Sync>;
41
42pub type DoorClock = Arc<dyn Fn() -> Result<TimeMs, AxError> + Send + Sync>;
44
45pub type DoorEntropy = Arc<dyn Fn(&mut [u8]) -> Result<(), AxError> + Send + Sync>;
47
48#[derive(Clone)]
51pub struct DoorSenses {
52 pub clock: DoorClock,
53 pub entropy: DoorEntropy,
54}
55
56pub struct OpenCode {
58 pub code: String,
59 pub redeemed: mpsc::Receiver<()>,
62}
63
64#[derive(Clone)]
66pub struct LocalDoor(Arc<Inner>);
67
68struct Inner {
69 held: Mutex<Held>,
70 senses: DoorSenses,
71 keep: KeepBrowsers,
72 code: watch::Sender<String>,
73 ended: watch::Sender<()>,
76}
77
78struct Held {
79 door: BrowserDoor,
80 waiting: BTreeMap<B3Hash, mpsc::Sender<()>>,
81}
82
83impl LocalDoor {
84 pub fn new(
89 browsers: Vec<PairedBrowser>,
90 senses: DoorSenses,
91 keep: KeepBrowsers,
92 ) -> Result<Self, AxError> {
93 let door = BrowserDoor::new(browsers, drawn(&senses)?);
94 let (code, _) = watch::channel(door.pairing_code().to_owned());
95 let (ended, _) = watch::channel(());
96 Ok(Self(Arc::new(Inner {
97 held: Mutex::new(Held {
98 door,
99 waiting: BTreeMap::new(),
100 }),
101 senses,
102 keep,
103 code,
104 ended,
105 })))
106 }
107
108 #[must_use]
110 pub fn pairing_code(&self) -> watch::Receiver<String> {
111 self.0.code.subscribe()
112 }
113
114 pub fn issue_open_code(&self) -> Result<OpenCode, AxError> {
119 let entropy: [u8; 16] = drawn(&self.0.senses)?;
120 let now = (self.0.senses.clock)()?;
121 let (tell, redeemed) = mpsc::channel();
122 let mut held = self.held();
123 let code = held.door.issue_open_code(entropy, now);
124 held.waiting.insert(B3Hash::digest(code.as_bytes()), tell);
125 Ok(OpenCode { code, redeemed })
126 }
127
128 #[must_use]
130 pub fn devices(&self) -> DevicesAnswer {
131 self.held().door.devices()
132 }
133
134 pub fn forget(&self, device: &DeviceId) -> Result<bool, AxError> {
140 let mut held = self.held();
141 let table: Vec<PairedBrowser> = held
142 .door
143 .browsers()
144 .into_iter()
145 .filter(|browser| &browser.id != device)
146 .collect();
147 (self.0.keep)(&table)?;
148 let forgotten = held.door.forget(device);
149 self.0.ended.send_replace(());
150 Ok(forgotten)
151 }
152
153 pub(crate) fn with_sessions<R>(
159 &self,
160 judge: impl FnOnce(&Sessions, TimeMs) -> R,
161 ) -> Result<R, AxError> {
162 let now = (self.0.senses.clock)()?;
163 Ok(judge(self.held().door.sessions(), now))
164 }
165
166 pub(crate) fn pairing(
172 &self,
173 face: &BindFace,
174 offered: Option<&str>,
175 ) -> Result<Pairing, AxError> {
176 self.with_sessions(|sessions, now| {
177 Keys {
178 face,
179 sessions,
180 now,
181 }
182 .pairing(offered)
183 })
184 }
185
186 pub(crate) fn seat(&self, presented: Option<String>) -> Result<Seat, AxError> {
197 let now = (self.0.senses.clock)()?;
198 let mut held = self.held();
199 let mut ended = self.0.ended.subscribe();
200 ended.mark_changed();
201 let token = presented
202 .as_deref()
203 .and_then(|token| held.door.sessions_mut().seat(token, now));
204 Ok(Seat {
205 door: self.clone(),
206 presented,
207 token,
208 ended,
209 })
210 }
211
212 pub(crate) fn pair(
214 &self,
215 proof: &PairProof,
216 public_key: &str,
217 label: &str,
218 ) -> Result<DeviceId, AxError> {
219 let key = DeviceKey::parse(public_key).ok_or_else(|| {
220 invalid(
221 "public_key",
222 "send the 32 bytes of the Ed25519 public key as 64 hex characters",
223 )
224 })?;
225 let label = readable_label(label)?;
226 let now = (self.0.senses.clock)()?;
227 let entropy: [u8; 32] = drawn(&self.0.senses)?;
228 let mut held = self.held();
229 match proof {
230 PairProof::Code(word) => match held.door.guess(word, now, entropy) {
231 Guess::Paired => {
232 self.0
233 .code
234 .send_replace(held.door.pairing_code().to_owned());
235 }
236 Guess::Wrong => {
237 self.0
238 .code
239 .send_replace(held.door.pairing_code().to_owned());
240 return Err(refused("type the pairing code the terminal shows now"));
241 }
242 Guess::TooSoon => {
243 return Err(refused(
244 "wait a second, then type the code the terminal shows",
245 ));
246 }
247 },
248 PairProof::Open(code) => {
249 let Some(digest) = held.door.redeem_open_code(code, now) else {
250 return Err(refused(
251 "open the city again from its terminal, or type the pairing code it shows",
252 ));
253 };
254 if let Some(tell) = held.waiting.remove(&digest) {
255 let _unheard: Result<(), mpsc::SendError<()>> = tell.send(());
258 }
259 }
260 }
261 let line = PairedBrowser::new(key, label, now);
262 let mut table: Vec<PairedBrowser> = held
263 .door
264 .browsers()
265 .into_iter()
266 .filter(|browser| browser.id != line.id)
267 .collect();
268 table.push(line.clone());
269 (self.0.keep)(&table)?;
270 Ok(held.door.pair(line))
271 }
272
273 pub(super) fn unseat(&self, token: &B3Hash) {
276 let now = (self.0.senses.clock)();
277 let mut held = self.held();
278 match now {
279 Ok(now) => held.door.sessions_mut().unseat(token, now),
280 Err(_) => held.door.sessions_mut().end(token),
283 }
284 }
285
286 pub(crate) fn challenge(&self) -> Result<String, AxError> {
288 let entropy: [u8; 32] = drawn(&self.0.senses)?;
289 let now = (self.0.senses.clock)()?;
290 Ok(self.held().door.challenge(entropy, now))
291 }
292
293 pub(crate) fn open_session(&self, body: &SessionBody, origin: &str) -> Result<String, AxError> {
296 let now = (self.0.senses.clock)()?;
297 let entropy: [u8; 32] = drawn(&self.0.senses)?;
298 let mut held = self.held();
299 if !held.door.take_nonce(&body.nonce, now) {
300 return Err(unsigned(
301 "ask /session/challenge for a new nonce and sign it",
302 ));
303 }
304 let Some(key) = held.door.device_key(&body.device).copied() else {
305 return Err(unsigned(
306 "pair this browser again: the city does not know it",
307 ));
308 };
309 let signature: [u8; 64] = decode_hex(&body.signature)
310 .ok_or_else(|| unsigned("send the 64-byte Ed25519 signature as 128 hex characters"))?;
311 let signed = format!("{SIGNED_LABEL}\n{}\n{origin}", body.nonce);
312 UnparsedPublicKey::new(&ED25519, key.bytes())
313 .verify(signed.as_bytes(), &signature)
314 .map_err(|_| unsigned("pair this browser again: its signature does not verify"))?;
315 let token = held
316 .door
317 .open_session(&body.device, entropy, now)
318 .ok_or_else(|| unsigned("pair this browser again: the city does not know it"))?;
319 self.0.ended.send_replace(());
321 Ok(token)
322 }
323
324 fn held(&self) -> MutexGuard<'_, Held> {
327 self.0.held.lock().unwrap_or_else(PoisonError::into_inner)
328 }
329}
330
331fn drawn<const N: usize>(senses: &DoorSenses) -> Result<[u8; N], AxError> {
333 let mut bytes = [0u8; N];
334 (senses.entropy)(&mut bytes)?;
335 Ok(bytes)
336}
337
338fn readable_label(label: &str) -> Result<String, AxError> {
341 let label = label.trim();
342 if label.is_empty() || label.chars().count() > LABEL_MAX || label.chars().any(char::is_control)
343 {
344 return Err(invalid(
345 "label",
346 "name the browser in 1 to 64 characters, with no control characters",
347 ));
348 }
349 Ok(label.to_owned())
350}
351
352fn refused(recovery: &str) -> AxError {
353 AxError::failure(
354 AxCode::PairingRefused,
355 "pair a browser",
356 "the code was wrong, used, expired or sent too soon",
357 )
358 .with_recovery(recovery.to_owned())
359}
360
361fn unsigned(recovery: &str) -> AxError {
362 AxError::failure(
363 AxCode::GateDenied,
364 "open a browser session",
365 "the challenge was not answered by a paired browser",
366 )
367 .with_recovery(recovery.to_owned())
368}
369
370fn invalid(field: &str, recovery: &str) -> AxError {
371 AxError::failure(
372 AxCode::InvalidArgs,
373 "pair a browser",
374 format!("`{field}` cannot be read"),
375 )
376 .with_recovery(recovery.to_owned())
377}