| //! Who may talk to the server. It listens on loopback, which keeps other hosts out but | |
| //! not other pages or other users of the machine: any site the browser has open can aim | |
| //! a request or a websocket at `127.0.0.1`, a name that rebinds to loopback arrives with a | |
| //! foreign `Host`, and anything another user runs can listen on a port nearby. | |
| //! | |
| //! So every request names a loopback host, a request that carries an `Origin` comes from | |
| //! this server's own origin or one the operator allowed, and everything past the static | |
| //! pages carries a key as a bearer: the server's token, which the command line holds, or a | |
| //! browser's own key. A browser gets its key by redeeming a pairing code: single use, two | |
| //! minutes, minted by the token's holder, so what passes through an opener's command line | |
| //! is worth one pairing and soon nothing. The page keeps its key in its origin's storage, | |
| //! which, unlike a cookie, is not shared with other ports on the same host. A websocket, | |
| //! which cannot carry a header from a browser, offers its key as a subprotocol. Browser | |
| //! keys live as long as the server that issued them: a tab left open on a stopped server | |
| //! keeps offering its key to whatever takes the port next, so the next server must not | |
| //! know it. | |
| //! | |
| //! A command line asks whether a server holds the token without sending it: the server | |
| //! answers a nonce with an HMAC under the token over the address it is bound to and the | |
| //! root it serves, so the answer cannot be relayed from another server's port. | |
| use crate::state::WebState; | |
| use axum::extract::{Request, State}; | |
| use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; | |
| use axum::middleware::Next; | |
| use axum::response::{IntoResponse, Response}; | |
| use sha2::{Digest, Sha256}; | |
| use std::collections::HashMap; | |
| use std::net::SocketAddr; | |
| use std::path::{Path, PathBuf}; | |
| use std::sync::{Arc, Mutex}; | |
| use std::time::{Duration, Instant}; | |
| /// How long a pairing code may wait for its browser. | |
| pub const CODE_LIFETIME: Duration = Duration::from_secs(120); | |
| /// The subprotocol a page's websocket asks for, beside the one carrying its key. | |
| pub const PROTOCOL: &str = "amem"; | |
| const KEY_PROTOCOL: &str = "amem.key."; | |
| /// What the gate admits, shared by every request. | |
| #[derive(Clone, Debug)] | |
| pub struct Access { | |
| token: String, | |
| /// Origins besides the server's own whose pages may call it, exactly as a browser | |
| /// sends them: `https://example.org`. | |
| origins: Vec<String>, | |
| /// The root the server serves, which the proof covers. | |
| root: PathBuf, | |
| inner: Arc<Mutex<Inner>>, | |
| } | |
| #[derive(Debug, Default)] | |
| struct Inner { | |
| /// Where the server is bound, once it is; the proof covers it. | |
| address: Option<SocketAddr>, | |
| browsers: Vec<String>, | |
| codes: HashMap<String, Instant>, | |
| } | |
| impl Access { | |
| /// The gate for a server over `root` with the token in `state`, and no browser paired. | |
| #[must_use] | |
| pub fn new(root: &Path, state: &WebState, origins: Vec<String>) -> Access { | |
| Access { | |
| token: state.token.clone(), | |
| origins, | |
| root: root.to_path_buf(), | |
| inner: Arc::new(Mutex::new(Inner::default())), | |
| } | |
| } | |
| /// A gate on one token over `root`: for tests. | |
| #[must_use] | |
| pub fn with_token(root: &Path, token: &str) -> Access { | |
| Access::new( | |
| root, | |
| &WebState { | |
| token: token.to_string(), | |
| ..WebState::default() | |
| }, | |
| Vec::new(), | |
| ) | |
| } | |
| /// The hosted origins this server lets through. | |
| #[must_use] | |
| pub fn origins(&self) -> &[String] { | |
| &self.origins | |
| } | |
| /// The address the server is bound to, which the proof covers. [`crate::serve`] sets | |
| /// it from its listener; until then the server proves nothing. | |
| pub fn bound(&self, address: SocketAddr) { | |
| self.inner().address = Some(address); | |
| } | |
| fn inner(&self) -> std::sync::MutexGuard<'_, Inner> { | |
| self.inner | |
| .lock() | |
| .unwrap_or_else(std::sync::PoisonError::into_inner) | |
| } | |
| /// A pairing code: single use, good for [`CODE_LIFETIME`]. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When there is no randomness to make one. | |
| pub fn pairing_code(&self) -> Result<String, std::io::Error> { | |
| let code = random_hex::<16>()?; | |
| let mut i = self.inner(); | |
| let now = Instant::now(); | |
| i.codes | |
| .retain(|_, born| now.duration_since(*born) < CODE_LIFETIME); | |
| i.codes.insert(code.clone(), now); | |
| Ok(code) | |
| } | |
| /// Trade a code for a browser key, once; the key lives as long as this server. | |
| fn redeem(&self, code: &str) -> Result<Option<String>, std::io::Error> { | |
| let mut i = self.inner(); | |
| let Some(born) = i.codes.remove(code) else { | |
| return Ok(None); | |
| }; | |
| if born.elapsed() >= CODE_LIFETIME { | |
| return Ok(None); | |
| } | |
| let key = random_hex::<32>()?; | |
| i.browsers.push(key.clone()); | |
| // a code passes through an opener's command line; whoever redeems it first is | |
| // paired, so every pairing is said where the operator sees it | |
| eprintln!("amem: a browser paired ({} so far)", i.browsers.len()); | |
| Ok(Some(key)) | |
| } | |
| fn is_token(&self, key: &str) -> bool { | |
| same(key, &self.token) | |
| } | |
| fn admits(&self, key: &str) -> bool { | |
| self.is_token(key) || self.inner().browsers.iter().any(|b| same(b, key)) | |
| } | |
| /// The answer to a nonce: proof of holding the token, at this address, over this | |
| /// root, that does not reveal the token. `None` until the server is bound. | |
| #[must_use] | |
| pub fn proof(&self, nonce: &str) -> Option<String> { | |
| let address = self.inner().address?; | |
| Some(proof(&self.token, address, &self.root, nonce)) | |
| } | |
| } | |
| /// HMAC-SHA256 under the token of the address the server is bound to, the root it | |
| /// serves and the nonce, hex. A caller checks it against the address it dialled and the | |
| /// root it means, so another server's answer, relayed, does not match. | |
| #[must_use] | |
| pub fn proof(token: &str, address: SocketAddr, root: &Path, nonce: &str) -> String { | |
| let message = format!("amem-hello\n{address}\n{}\n{nonce}", root.display()); | |
| hmac_sha256(token.as_bytes(), message.as_bytes()) | |
| .iter() | |
| .map(|x| format!("{x:02x}")) | |
| .collect() | |
| } | |
| /// RFC 2104 over SHA-256. | |
| fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] { | |
| const BLOCK: usize = 64; | |
| let mut k = [0u8; BLOCK]; | |
| if key.len() > BLOCK { | |
| k[..32].copy_from_slice(&Sha256::digest(key)); | |
| } else { | |
| k[..key.len()].copy_from_slice(key); | |
| } | |
| let pad = |b: u8| k.iter().map(|x| x ^ b).collect::<Vec<u8>>(); | |
| let mut inner = Sha256::new(); | |
| inner.update(pad(0x36)); | |
| inner.update(message); | |
| let mut outer = Sha256::new(); | |
| outer.update(pad(0x5c)); | |
| outer.update(inner.finalize()); | |
| outer.finalize().into() | |
| } | |
| /// A fresh token: 32 random bytes, hex. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the operating system has no randomness to give. | |
| pub fn new_token() -> Result<String, std::io::Error> { | |
| random_hex::<32>() | |
| } | |
| /// `N` random bytes, hex. | |
| pub(crate) fn random_hex<const N: usize>() -> Result<String, std::io::Error> { | |
| let mut b = [0u8; N]; | |
| getrandom::fill(&mut b).map_err(|e| std::io::Error::other(e.to_string()))?; | |
| Ok(b.iter().map(|x| format!("{x:02x}")).collect()) | |
| } | |
| /// The host part of a `Host` header or an origin's authority, without the port. | |
| fn host_only(authority: &str) -> &str { | |
| if let Some(rest) = authority.strip_prefix('[') { | |
| // [::1]:4200 | |
| return rest.split(']').next().unwrap_or_default(); | |
| } | |
| authority.split(':').next().unwrap_or_default() | |
| } | |
| /// Whether a `Host` header names this machine's loopback, whatever the port. | |
| fn loopback_host(host: &str) -> bool { | |
| matches!( | |
| host_only(host).to_ascii_lowercase().as_str(), | |
| "127.0.0.1" | "localhost" | "::1" | |
| ) | |
| } | |
| /// Whether an `Origin` may call: the server's own (the scheme and the authority the | |
| /// request was sent to), or one the operator named. | |
| fn origin_allowed(access: &Access, origin: &str, host: &str) -> bool { | |
| origin == format!("http://{host}") || access.origins.iter().any(|o| o == origin) | |
| } | |
| /// Whether an `Origin` is a browser extension's on the companion's socket: the | |
| /// companion is software the owner installed, not a page, and it offers a key as a page | |
| /// does; it is let in there and nowhere else. | |
| fn companion_origin(path: &str, origin: &str) -> bool { | |
| path == crate::companion::WS_PATH | |
| && (origin.starts_with("chrome-extension://") || origin.starts_with("moz-extension://")) | |
| } | |
| fn bearer(headers: &HeaderMap) -> Option<&str> { | |
| headers | |
| .get(header::AUTHORIZATION)? | |
| .to_str() | |
| .ok()? | |
| .strip_prefix("Bearer ") | |
| } | |
| /// The key a websocket offers among its subprotocols. | |
| fn protocol_key(headers: &HeaderMap) -> Option<&str> { | |
| headers | |
| .get(header::SEC_WEBSOCKET_PROTOCOL)? | |
| .to_str() | |
| .ok()? | |
| .split(',') | |
| .find_map(|p| p.trim().strip_prefix(KEY_PROTOCOL)) | |
| } | |
| /// Equal in time that does not depend on where the first difference is. | |
| fn same(a: &str, b: &str) -> bool { | |
| a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |d, (x, y)| d | (x ^ y)) == 0 | |
| } | |
| /// What a path asks of the caller. | |
| #[derive(Debug, PartialEq, Eq)] | |
| enum Needs { | |
| /// The pages, their assets, the hello and the redeem: nothing of the record's. | |
| Nothing, | |
| /// Minting a pairing code: the token itself, not a browser's key. | |
| Token, | |
| /// Everything else: the token or a browser's key. | |
| Key, | |
| } | |
| /// The pages and what they load: the same files a hosted copy serves, nothing of the | |
| /// record's in any of them. | |
| const STATIC: &[&str] = &[ | |
| "/", | |
| "/index.html", | |
| "/session.html", | |
| "/board", | |
| "/board.html", | |
| "/reader", | |
| "/reader.html", | |
| "/amem.js", | |
| "/tokens.css", | |
| ]; | |
| fn needs(path: &str) -> Needs { | |
| if STATIC.contains(&path) | |
| || path == "/api/hello" | |
| || path == "/api/pair/redeem" | |
| || path.starts_with("/s/") | |
| || path.starts_with("/vendor/") | |
| || path.starts_with("/reader/") | |
| { | |
| Needs::Nothing | |
| } else if path == "/api/pair" || path == crate::companion::FETCH_PATH { | |
| // a fetch through the companion goes out with the browser's cookies: the | |
| // launcher asks for it with the token, and no page can | |
| Needs::Token | |
| } else { | |
| Needs::Key | |
| } | |
| } | |
| fn refuse(status: StatusCode, why: &'static str) -> Response { | |
| (status, why).into_response() | |
| } | |
| /// The cross-origin answer for a hosted page the operator allowed: its origin, the | |
| /// headers its calls carry, and, for a page on the public internet calling loopback, | |
| /// the private-network grant Chrome asks for. | |
| fn cors(r: &mut Response, origin: &HeaderValue, private_network: bool) { | |
| let h = r.headers_mut(); | |
| h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone()); | |
| h.insert(header::VARY, HeaderValue::from_static("Origin")); | |
| if private_network { | |
| h.insert( | |
| "access-control-allow-private-network", | |
| HeaderValue::from_static("true"), | |
| ); | |
| } | |
| } | |
| /// The gate, in front of every route. | |
| pub async fn gate(State(access): State<Access>, req: Request, next: Next) -> Response { | |
| let headers = req.headers(); | |
| let Some(host) = headers.get(header::HOST).and_then(|h| h.to_str().ok()) else { | |
| return refuse(StatusCode::BAD_REQUEST, "no Host"); | |
| }; | |
| if !loopback_host(host) { | |
| return refuse( | |
| StatusCode::FORBIDDEN, | |
| "this server answers to loopback names only", | |
| ); | |
| } | |
| // a hosted page the operator allowed: its calls are cross-origin | |
| let mut foreign = None; | |
| if let Some(origin) = headers.get(header::ORIGIN) { | |
| let ok = origin.to_str().is_ok_and(|o| { | |
| origin_allowed(&access, o, host) || companion_origin(req.uri().path(), o) | |
| }); | |
| if !ok { | |
| return refuse(StatusCode::FORBIDDEN, "a page from another origin"); | |
| } | |
| if origin | |
| .to_str() | |
| .is_ok_and(|o| o != format!("http://{host}") && !companion_origin(req.uri().path(), o)) | |
| { | |
| foreign = Some(origin.clone()); | |
| } | |
| } | |
| if let Some(origin) = &foreign { | |
| // a preflight carries no key; it is answered here, and the call it precedes | |
| // is gated like any other | |
| if req.method() == axum::http::Method::OPTIONS { | |
| let pna = headers | |
| .get("access-control-request-private-network") | |
| .is_some_and(|v| v == "true"); | |
| let mut r = StatusCode::NO_CONTENT.into_response(); | |
| cors(&mut r, origin, pna); | |
| let h = r.headers_mut(); | |
| h.insert( | |
| header::ACCESS_CONTROL_ALLOW_METHODS, | |
| HeaderValue::from_static("GET, POST, DELETE"), | |
| ); | |
| h.insert( | |
| header::ACCESS_CONTROL_ALLOW_HEADERS, | |
| HeaderValue::from_static("authorization, content-type"), | |
| ); | |
| h.insert( | |
| header::ACCESS_CONTROL_MAX_AGE, | |
| HeaderValue::from_static("600"), | |
| ); | |
| return r; | |
| } | |
| } | |
| let key = bearer(headers).or_else(|| protocol_key(headers)); | |
| let ok = match needs(req.uri().path()) { | |
| Needs::Nothing => true, | |
| Needs::Token => key.is_some_and(|k| access.is_token(k)), | |
| Needs::Key => key.is_some_and(|k| access.admits(k)), | |
| }; | |
| let mut r = if ok { | |
| next.run(req).await | |
| } else { | |
| refuse( | |
| StatusCode::UNAUTHORIZED, | |
| "not paired: run amem, which opens a link that pairs this browser", | |
| ) | |
| }; | |
| // the page reads the answer, a refusal included, so it can say what to do | |
| if let Some(origin) = &foreign { | |
| cors(&mut r, origin, false); | |
| } | |
| r | |
| } | |
| /// `POST /api/pair/redeem` with `{"code": ...}`: a browser key, or 401 for a code that | |
| /// is unknown, used or stale. | |
| pub(crate) fn redeem_response(access: &Access, code: &str) -> Response { | |
| match access.redeem(code) { | |
| Ok(Some(key)) => axum::Json(serde_json::json!({ "key": key })).into_response(), | |
| Ok(None) => refuse( | |
| StatusCode::UNAUTHORIZED, | |
| "that pairing link was used already or is more than two minutes old: run amem again", | |
| ), | |
| Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| #[test] | |
| fn hosts_are_loopback_names_only() { | |
| for h in [ | |
| "127.0.0.1:4200", | |
| "localhost:4200", | |
| "LOCALHOST", | |
| "[::1]:4200", | |
| ] { | |
| assert!(loopback_host(h), "{h}"); | |
| } | |
| for h in [ | |
| "evil.example:4200", | |
| "127.0.0.1.evil.example", | |
| "0.0.0.0:4200", | |
| "[::]:1", | |
| ] { | |
| assert!(!loopback_host(h), "{h}"); | |
| } | |
| } | |
| #[test] | |
| fn an_origin_is_the_servers_own_or_named() { | |
| let mut a = Access::with_token(Path::new("/r"), "t"); | |
| a.origins = vec!["https://amem.example".into()]; | |
| assert!(origin_allowed( | |
| &a, | |
| "http://127.0.0.1:4200", | |
| "127.0.0.1:4200" | |
| )); | |
| assert!(origin_allowed(&a, "https://amem.example", "127.0.0.1:4200")); | |
| // another port on the same host is another origin | |
| assert!(!origin_allowed( | |
| &a, | |
| "http://127.0.0.1:3000", | |
| "127.0.0.1:4200" | |
| )); | |
| assert!(!origin_allowed(&a, "null", "127.0.0.1:4200")); | |
| // an extension's origin is let in on the companion's socket, nowhere else | |
| let ext = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; | |
| assert!(companion_origin("/ws/companion", ext)); | |
| assert!(companion_origin("/ws/companion", "moz-extension://x")); | |
| assert!(!companion_origin("/api/sessions", ext)); | |
| assert!(!companion_origin("/ws/companion", "https://amem.example")); | |
| assert!(!origin_allowed(&a, ext, "127.0.0.1:4200")); | |
| assert!(!origin_allowed( | |
| &a, | |
| "https://amem.example.evil", | |
| "127.0.0.1:4200" | |
| )); | |
| } | |
| #[test] | |
| fn a_code_pairs_one_browser_once() { | |
| let a = Access::with_token(Path::new("/r"), &"t".repeat(64)); | |
| let code = a.pairing_code().unwrap(); | |
| let key = a.redeem(&code).unwrap().unwrap(); | |
| assert!( | |
| a.admits(&key) && !a.is_token(&key), | |
| "a browser key is not the token" | |
| ); | |
| assert_eq!(a.redeem(&code).unwrap(), None, "a code is single use"); | |
| assert_eq!(a.redeem("not-a-code").unwrap(), None); | |
| assert_ne!(a.pairing_code().unwrap(), code); | |
| a.inner() | |
| .codes | |
| .insert("stale".into(), Instant::now() - CODE_LIFETIME); | |
| assert_eq!(a.redeem("stale").unwrap(), None, "a stale code is refused"); | |
| } | |
| #[test] | |
| fn a_browser_key_does_not_outlive_its_server() { | |
| let s = WebState { | |
| token: "t".repeat(64), | |
| ..WebState::default() | |
| }; | |
| let a = Access::new(Path::new("/r"), &s, vec![]); | |
| let key = a.redeem(&a.pairing_code().unwrap()).unwrap().unwrap(); | |
| assert!(a.admits(&key)); | |
| let next = Access::new(Path::new("/r"), &s, vec![]); | |
| assert!( | |
| !next.admits(&key), | |
| "the next server over the same token knows no browser" | |
| ); | |
| assert!(next.admits(&s.token)); | |
| } | |
| #[test] | |
| fn the_proof_covers_the_token_the_address_the_root_and_the_nonce() { | |
| let at: SocketAddr = "127.0.0.1:4200".parse().unwrap(); | |
| let other: SocketAddr = "127.0.0.1:4201".parse().unwrap(); | |
| let r = Path::new("/r"); | |
| let p = proof("token-a", at, r, "n1"); | |
| assert_eq!(p.len(), 64); | |
| assert_eq!(p, proof("token-a", at, r, "n1")); | |
| assert_ne!(p, proof("token-b", at, r, "n1")); | |
| assert_ne!( | |
| p, | |
| proof("token-a", other, r, "n1"), | |
| "a relayed answer names its own port" | |
| ); | |
| assert_ne!(p, proof("token-a", at, Path::new("/s"), "n1")); | |
| assert_ne!(p, proof("token-a", at, r, "n2")); | |
| assert!(!p.contains("token-a")); | |
| let a = Access::with_token(r, "token-a"); | |
| assert_eq!(a.proof("n1"), None, "an unbound server proves nothing"); | |
| a.bound(at); | |
| assert_eq!(a.proof("n1").as_deref(), Some(p.as_str())); | |
| // RFC 4231, test cases 2 and 6: a short key, and one longer than the block | |
| let hex = |b: [u8; 32]| b.iter().map(|x| format!("{x:02x}")).collect::<String>(); | |
| assert_eq!( | |
| hex(hmac_sha256(b"Jefe", b"what do ya want for nothing?")), | |
| "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" | |
| ); | |
| assert_eq!( | |
| hex(hmac_sha256( | |
| &[0xaa; 131], | |
| b"Test Using Larger Than Block-Size Key - Hash Key First" | |
| )), | |
| "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" | |
| ); | |
| } | |
| #[test] | |
| fn keys_come_from_the_bearer_or_a_subprotocol_and_paths_ask_for_what_they_need() { | |
| let mut h = HeaderMap::new(); | |
| h.insert( | |
| header::SEC_WEBSOCKET_PROTOCOL, | |
| "amem, amem.key.k1".parse().unwrap(), | |
| ); | |
| assert_eq!(protocol_key(&h), Some("k1")); | |
| h.insert(header::AUTHORIZATION, "Bearer k2".parse().unwrap()); | |
| assert_eq!(bearer(&h), Some("k2")); | |
| assert_eq!(needs("/"), Needs::Nothing); | |
| assert_eq!(needs("/api/pair/redeem"), Needs::Nothing); | |
| assert_eq!(needs("/api/pair"), Needs::Token); | |
| assert_eq!(needs("/api/companion/fetch"), Needs::Token); | |
| assert_eq!(needs("/api/companion"), Needs::Key); | |
| assert_eq!(needs("/ws/companion"), Needs::Key); | |
| assert_eq!(needs("/api/sessions"), Needs::Key); | |
| assert_eq!(needs("/ws/abc"), Needs::Key); | |
| assert_eq!(needs("/bundle/d/l/manifest.yaml"), Needs::Key); | |
| assert!(same("abc", "abc") && !same("abc", "abd") && !same("abc", "ab")); | |
| assert_eq!(new_token().unwrap().len(), 64); | |
| } | |
| } |