//! Authentication of the control channel (#777). //! //! Anything on the machine, and a web page that POSTs `text/plain` to `127.0.0.1:`, can //! reach a loopback port. So the first line of every connection must be //! `{"id":…,"method":"auth","params":{"token":"…"}}`, with the secret the app made at start. The //! app keeps it in memory and writes it to a file only the user can read (or the user chooses it //! with `GRIDCRAFT_CONTROL_TOKEN`); [`Remote`](crate::Remote) reads it from the same places. use std::path::PathBuf; use serde_json::{Value, json}; /// Environment variable holding a token chosen by the user. The app uses it instead of making /// one (and writes no file); clients use it instead of reading the file. pub const TOKEN_ENV: &str = "GRIDCRAFT_CONTROL_TOKEN"; /// The method of the handshake line. pub const AUTH_METHOD: &str = "auth"; /// Shortest token the app accepts from the user (a made one has 256 bits). pub const MIN_TOKEN_LEN: usize = 16; /// Longest first line the app reads from a peer that hasn't authenticated yet. pub const MAX_AUTH_LINE: usize = 4096; /// Compare two secrets without stopping at the first difference. Unequal lengths are unequal /// at once (the length of a token isn't secret). pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut diff = 0u8; for (x, y) in a.iter().zip(b) { diff |= x ^ y; } // Keep the optimiser from turning the loop into an early exit. std::hint::black_box(diff) == 0 } /// A made token as lowercase hex (two characters per byte). pub fn hex(bytes: &[u8]) -> String { const DIGITS: &[u8; 16] = b"0123456789abcdef"; let mut s = String::with_capacity(bytes.len().saturating_mul(2)); for b in bytes { s.push(char::from(DIGITS[usize::from(b >> 4)])); s.push(char::from(DIGITS[usize::from(b & 15)])); } s } /// The handshake line a client sends first. pub fn auth_line(id: u64, token: &str) -> String { json!({"id": id, "method": AUTH_METHOD, "params": {"token": token}}).to_string() } /// Check a connection's first line. `Ok` is the reply to send before serving further requests; /// `Err` is the reply for a refused connection, which the caller sends and then closes. The /// refusal is the same whatever was wrong, so it tells a stranger nothing. pub fn check_first_line(line: &str, token: &str) -> Result { let refused = || json!({"ok": false, "error": "authentication required"}); let msg: Value = serde_json::from_str(line.trim()).map_err(|_| refused())?; if msg.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) { return Err(refused()); } let given = msg.get("params").and_then(|p| p.get("token")).and_then(Value::as_str).unwrap_or(""); // Always compare, so a missing token costs the same as a wrong one. if constant_time_eq(given.as_bytes(), token.as_bytes()) && !token.is_empty() { Ok(json!({"id": msg.get("id").cloned().unwrap_or(Value::Null), "ok": true, "result": {"authenticated": true}})) } else { Err(refused()) } } /// Where the app writes the token it made for `port`, and where clients look for it: a folder /// only the user can open (`$XDG_RUNTIME_DIR/gridcraft`, else `~/.cache/gridcraft`; on Windows /// `%USERPROFILE%\.gridcraft`), file `control-.token`. `None` when no such folder is known. pub fn token_path(port: u16) -> Option { let dir = if cfg!(windows) { PathBuf::from(std::env::var_os("USERPROFILE").filter(|v| !v.is_empty())?).join(".gridcraft") } else if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) { PathBuf::from(run).join("gridcraft") } else { PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache").join("gridcraft") }; Some(dir.join(format!("control-{port}.token"))) } /// The port of a control address (`127.0.0.1:7979` → 7979). fn port_of(addr: &str) -> Option { addr.rsplit(':').next()?.parse().ok() } /// The token a client uses for `addr`: [`TOKEN_ENV`] if set, else the file the app wrote. pub fn client_token(addr: &str) -> Result { if let Ok(t) = std::env::var(TOKEN_ENV) && !t.trim().is_empty() { return Ok(t.trim().to_string()); } let path = port_of(addr).and_then(token_path).ok_or_else(|| format!("no control token: set {TOKEN_ENV}"))?; match std::fs::read_to_string(&path) { Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()), _ => Err(format!("no control token: set {TOKEN_ENV}, or start the app on this machine and user (it writes {})", path.display())), } } /// Write `token` to [`token_path`] for the app: the folder is made private (mode 0700 on Unix), /// an older file is replaced, and the new one is created readable by the user only (mode 0600). /// Returns the path. pub fn write_token_file(port: u16, token: &str) -> std::io::Result { use std::io::Write; let path = token_path(port).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user folder for the control token"))?; let dir = path.parent().ok_or_else(|| std::io::Error::other("token path has no folder"))?; std::fs::create_dir_all(dir)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; // Only our own folder: leave a shared one (`$XDG_RUNTIME_DIR` itself) as it is. std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; } // Replace rather than open: a leftover file may have looser permissions, or be a link. match std::fs::remove_file(&path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e), } let mut opts = std::fs::OpenOptions::new(); opts.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; opts.mode(0o600); } let mut f = opts.open(&path)?; f.write_all(token.as_bytes())?; f.write_all(b"\n")?; Ok(path) }