security: authenticate the local control server with a per-session token (CWE-306) (#1844)

* security: authenticate the local control server with a per-session token (CWE-306)

The optional `--control PORT` server listens on loopback TCP and dispatched any
JSON request without authenticating the peer. Loopback TCP does not distinguish
operating-system users, so another local account could connect and drive the
desktop user's command and file authority (for example reading files through
native Lisp, or create/truncate writes).

Authenticate the peer with an unguessable per-session token:

- New `cadcraft_mcp::control_token` module: `issue(port)` generates a 256-bit
  token and writes it to `cadcraft-control-<port>.token` in the per-user runtime
  directory with owner-only permissions (0600 on Unix; per-user LOCALAPPDATA on
  Windows). `read(port)` reads it; `matches` compares in constant time.
- The control server issues the token at startup and requires every request to
  carry a matching `token` field; requests without one get an `unauthorized`
  reply and the connection is closed before dispatch.
- The MCP bridge (`Remote`) reads the token from the owner-only file — which
  needs the desktop user's own filesystem access — and sends it on every
  request, so the bridge keeps working unchanged for the legitimate user. A
  different local user cannot read the token and cannot authenticate.

Updates `docs/control-protocol.md`. New tests cover token issue/read/compare,
file permissions, and that the server refuses unauthenticated requests while
accepting authenticated ones.

Addresses finding row 48.

* fix: recover control authentication across server restarts

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
This commit is contained in:
Dylan M. Taylor
2026-10-22 17:35:18 -04:00
committed by GitHub
co-authored by skylartaylor
parent 916be26ed3
commit ca578a8fb7
8 changed files with 376 additions and 26 deletions
Generated
+2
View File
@@ -551,6 +551,7 @@ version = "0.11.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-mcp",
"cadcraft-ui-egui",
"chrono",
"eframe",
@@ -687,6 +688,7 @@ dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-render",
"getrandom 0.3.4",
"log",
"serde_json",
]
+2
View File
@@ -14,6 +14,8 @@ workspace = true
cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
# For the shared per-session control-token helper (control_server.rs authentication).
cadcraft-mcp = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
+74 -12
View File
@@ -51,8 +51,21 @@ impl Default for Limits {
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => {
log::info!("control server listening on 127.0.0.1:{port}");
serve_listener(l, Limits::default(), ctx)
// Loopback TCP cannot tell local users apart, so issue an unguessable per-session
// token in an owner-only file and require it on every request (CWE-306). A client
// (the MCP bridge) reads it from the file, which needs the desktop user's filesystem
// access; another local account cannot.
let token = match cadcraft_mcp::control_token::issue(port) {
Ok(t) => Arc::new(t),
Err(e) => {
log::error!("control server not started: could not write the control token: {e}");
return sync_channel::<ControlRequest>(0).1;
}
};
if let Ok(path) = cadcraft_mcp::control_token::token_path(port) {
log::info!("control server listening on 127.0.0.1:{port} (token in {})", path.display());
}
serve_listener(l, Limits::default(), ctx, token)
}
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
@@ -72,9 +85,9 @@ fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: imp
}
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context, token: Arc<String>) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits));
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits, token));
rx
}
@@ -88,7 +101,7 @@ impl Drop for Slot {
}
}
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits) {
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
@@ -100,9 +113,10 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
let slot = Slot(Arc::clone(&open));
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().spawn(move || {
let _slot = slot;
serve(stream, &tx, &ctx, limits);
serve(stream, &tx, &ctx, limits, &token);
});
}
}
@@ -141,6 +155,7 @@ enum Line {
id: Value,
method: String,
params: Value,
token: Option<String>,
},
Blank,
/// Not a request: reply with this error and close the connection.
@@ -172,6 +187,7 @@ fn parse_line(bytes: &[u8]) -> Line {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
}
}
@@ -182,7 +198,7 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
@@ -200,7 +216,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
Line::Request { id, method, params, token: given } => {
// Authenticate the peer before dispatching: loopback TCP does not identify the
// OS user, so only a client holding the owner-only session token may drive the
// desktop user's commands (CWE-306).
if !given.map(|g| cadcraft_mcp::control_token::matches(token, &g)).unwrap_or(false) {
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"});
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
let _ = writeln!(out, "{r}");
break;
}
let (req, pending) = ControlRequest::new(method, params);
let mut r = match tx.try_send(req) {
Ok(()) => {
@@ -277,11 +304,14 @@ mod tests {
}
}
/// A fixed token for tests; real tokens are random (see `cadcraft_mcp::control_token`).
const TEST_TOKEN: &str = "test-control-token";
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
(addr, serve_listener(listener, limits, egui::Context::default()))
(addr, serve_listener(listener, limits, egui::Context::default(), Arc::new(TEST_TOKEN.to_string())))
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
@@ -295,6 +325,11 @@ mod tests {
addr
}
/// A request line carrying the test token, so it authenticates.
fn req_line(id: i64, method: &str) -> String {
json!({"id": id, "method": method, "token": TEST_TOKEN}).to_string() + "\n"
}
/// Send `input` on a new connection and read reply lines until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
let mut c = TcpStream::connect(addr).unwrap();
@@ -354,12 +389,12 @@ mod tests {
fn a_full_queue_answers_busy() {
let (addr, rx) = serve_with(short());
// No UI drains the one-place queue: the first request fills it, the second finds it full.
let first = std::thread::spawn(move || exchange(addr, b"{\"id\":1,\"method\":\"m\"}\n"));
let first = std::thread::spawn(move || exchange(addr, req_line(1, "m").as_bytes()));
let deadline = std::time::Instant::now() + Duration::from_secs(4);
let busy = loop {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
c.write_all(req_line(2, "m").as_bytes()).unwrap();
let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
@@ -377,7 +412,8 @@ mod tests {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
let input = format!("{}\n{}", req_line(3, "ping").trim_end(), req_line(4, "pong"));
c.write_all(input.as_bytes()).unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
@@ -387,6 +423,32 @@ mod tests {
}
}
#[test]
fn a_request_without_the_token_is_unauthorized() {
// A local peer that does not hold the session token cannot drive the app (CWE-306).
let addr = serve_echo();
let replies = exchange(addr, b"{\"id\":9,\"method\":\"ping\"}\n");
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
assert!(replies[0]["error"].as_str().unwrap().contains("unauthorized"), "{replies:?}");
// A wrong token is also refused.
let wrong = exchange(addr, b"{\"id\":9,\"method\":\"ping\",\"token\":\"nope\"}\n");
assert_eq!(wrong[0]["ok"], false);
assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}");
}
#[test]
fn a_request_with_the_token_is_dispatched() {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(req_line(10, "ping").as_bytes()).unwrap();
let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["ok"].as_bool(), v["method"].as_str()), (Some(true), Some("ping")), "{line}");
}
#[test]
fn extra_connections_are_refused() {
let addr = serve_echo();
+2
View File
@@ -14,3 +14,5 @@ cadcraft-render = { workspace = true }
cadcraft-io = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# Secure randomness for the per-session control token (MIT OR Apache-2.0).
getrandom = "0.3"
+106 -13
View File
@@ -23,6 +23,14 @@ pub struct Remote {
next_id: u64,
/// Open the app when nothing answers (see [`Remote::lazy`]).
launch: bool,
/// The per-session control token for this port, read from the owner-only token file on first
/// use so requests can authenticate (CWE-306).
token: Option<String>,
}
/// The port component of a `HOST:PORT` address, for locating the token file.
fn port_of(addr: &str) -> Option<u16> {
addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
}
/// How long a launched app has to open its control port.
@@ -30,7 +38,7 @@ const LAUNCH_WAIT: Duration = Duration::from_secs(30);
impl Remote {
pub fn connect(addr: &str) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false };
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None };
r.reconnect()?;
Ok(r)
}
@@ -38,7 +46,20 @@ impl Remote {
/// the server before the app is open. With `launch`, a call that finds nothing at `addr`
/// opens the app with that control port first (macOS) and waits for it.
pub fn lazy(addr: &str, launch: bool) -> Self {
Self { addr: addr.to_string(), conn: None, next_id: 1, launch }
Self { addr: addr.to_string(), conn: None, next_id: 1, launch, token: None }
}
/// The per-session token for this port, read (and cached) from the owner-only token file. A
/// missing file is not fatal here: the request is sent without a token and the server replies
/// with an authentication error the user can act on.
fn token(&mut self) -> Option<&str> {
if self.token.is_none()
&& let Some(port) = port_of(&self.addr)
&& let Ok(t) = crate::control_token::read(port)
{
self.token = Some(t);
}
self.token.as_deref()
}
/// Connect, opening the app first when asked to and nothing answers.
fn reach(&mut self) -> std::io::Result<()> {
@@ -91,7 +112,9 @@ impl Remote {
let sent = writer.write_all(line.as_bytes()).and_then(|()| writer.write_all(b"\n")).and_then(|()| writer.flush());
if let Err(e) = sent {
self.conn = None;
return Err((e, false));
// write_all can fail after partial delivery. The server may have received the full
// JSON before the newline write failed, so a mutation must not be retried blindly.
return Err((e, true));
}
let mut reply = String::new();
match reader.read_line(&mut reply) {
@@ -162,17 +185,35 @@ impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
let reply = match self.roundtrip(&line) {
let token = self.token().map(str::to_string);
let mut req = json!({"id": id, "method": method, "params": params});
if let (Some(obj), Some(token)) = (req.as_object_mut(), token) {
obj.insert("token".into(), Value::String(token));
}
let reply = match self.roundtrip(&req.to_string()) {
Ok(r) => r,
// Resend only when the request cannot have been applied. After a sent mutation with no reply the outcome is unknown, and
// resending could apply it twice (for example two circles from one call), so report that instead.
Err((_, sent)) if !sent || is_read_only(method) => self.roundtrip(&line).map_err(|(e, _)| format!("CADCraft at {}: {e}", self.addr))?,
Err((e, _)) => {
return Err(format!(
"CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first",
self.addr
));
Err((e, sent)) => {
self.token = None;
self.conn = None;
// A mutation whose reply was lost must never be sent twice.
if sent && !is_read_only(method) {
return Err(format!(
"CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first",
self.addr
));
}
// A restarted server may have rotated its token before dropping the old socket.
if let Some(obj) = req.as_object_mut() {
obj.remove("token");
if let Some(token) = self.token() {
obj.insert("token".into(), Value::String(token.to_owned()));
}
}
self.roundtrip(&req.to_string()).map_err(|(e, _)| {
self.token = None;
self.conn = None;
format!("CADCraft at {}: {e}", self.addr)
})?
}
};
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))?;
@@ -180,6 +221,10 @@ impl Backend for Remote {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
let e = v.get("error").and_then(Value::as_str).unwrap_or("error");
if e.starts_with("unauthorized") {
self.token = None;
self.conn = None;
}
// A timeout says whether the request can still have run (#345).
Err(match v.get("state").and_then(Value::as_str) {
Some("not-run") => format!("{e}: CADCraft did not start `{method}` and won't; it is safe to send again"),
@@ -375,9 +420,12 @@ mod tests {
requests
});
let mut remote = Remote::connect(&addr).unwrap();
remote.token = Some("stale-token".into());
let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
assert!(err.contains("may or may not have been applied"), "{err}");
assert_eq!(server.join().unwrap(), 1);
assert!(remote.token.is_none());
assert!(remote.conn.is_none());
}
/// The app's timeout reply says whether the request can still run; the error passes that on (#345).
@@ -399,4 +447,49 @@ mod tests {
server.join().unwrap();
}
}
#[test]
fn safe_retry_rereads_a_rotated_token_after_a_lost_reply() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let old = crate::control_token::issue(addr.port()).unwrap();
let server = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
let mut line = String::new();
let mut reader = BufReader::new(conn);
reader.read_line(&mut line).unwrap();
let first: Value = serde_json::from_str(&line).unwrap();
assert!(first["token"].as_str() == Some(old.as_str()));
let fresh = crate::control_token::issue(addr.port()).unwrap();
drop(reader);
let (mut conn, _) = listener.accept().unwrap();
line.clear();
BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap();
let retry: Value = serde_json::from_str(&line).unwrap();
assert!(retry["token"].as_str() == Some(fresh.as_str()));
writeln!(conn, "{}", json!({"ok": true, "result": "pong"})).unwrap();
crate::control_token::revoke(addr.port());
});
let mut remote = Remote::connect(&addr.to_string()).unwrap();
assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong"));
server.join().unwrap();
}
#[test]
fn failed_mutation_write_is_not_resent() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut remote = Remote::connect(&addr).unwrap();
remote.conn.as_ref().unwrap().1.shutdown(std::net::Shutdown::Write).unwrap();
let server = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
let mut line = String::new();
assert_eq!(BufReader::new(conn).read_line(&mut line).unwrap(), 0);
listener.set_nonblocking(true).unwrap();
std::thread::sleep(Duration::from_millis(300));
listener.accept().is_ok()
});
let error = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
assert!(error.contains("may or may not have been applied"));
assert!(!server.join().unwrap(), "a failed mutation write was retried");
assert!(remote.token.is_none());
}
}
+180
View File
@@ -0,0 +1,180 @@
//! A per-session credential for the loopback control channel.
//!
//! The control server listens on `127.0.0.1`, but loopback TCP does not distinguish operating
//! system users: any local account can connect and drive the desktop user's command and file
//! authority (CWE-306). To authenticate the peer without changing the transport, the app issues an
//! unguessable token at startup and writes it to a file only the desktop user can read. A client
//! (the MCP bridge) reads the token from that file — which requires the same user's filesystem
//! access — and sends it with every request. A different local user cannot read the token, so
//! cannot authenticate.
use std::io;
use std::path::PathBuf;
/// Length of the token in bytes (256 bits), rendered as lowercase hex.
const TOKEN_BYTES: usize = 32;
/// The file holding the control token for `port`, inside a per-user private directory.
///
/// On Unix the runtime dir (`XDG_RUNTIME_DIR`, already `0700` and per-user) is preferred, else the
/// user's home; on Windows the local app-data directory. The file itself is created with
/// owner-only permissions regardless.
pub fn token_path(port: u16) -> io::Result<PathBuf> {
Ok(private_dir()?.join(format!("cadcraft-control-{port}.token")))
}
#[cfg(unix)]
fn private_dir() -> io::Result<PathBuf> {
if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(d));
}
if let Some(h) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(h).join(".cache"));
}
Ok(std::env::temp_dir())
}
#[cfg(windows)]
fn private_dir() -> io::Result<PathBuf> {
windows_private_dir(std::env::var_os("LOCALAPPDATA"))
}
#[cfg(any(windows, test))]
fn windows_private_dir(local: Option<std::ffi::OsString>) -> io::Result<PathBuf> {
local
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "LOCALAPPDATA is required for a private control token"))
}
#[cfg(not(any(unix, windows)))]
fn private_dir() -> io::Result<PathBuf> {
Err(io::Error::new(io::ErrorKind::Unsupported, "no private control token directory on this platform"))
}
/// Generate a fresh token and write it to [`token_path`] with owner-only permissions, returning
/// the token. The file is replaced if it already exists.
pub fn issue(port: u16) -> io::Result<String> {
let token = random_hex()?;
let path = token_path(port)?;
issue_at(&path, token.as_bytes())?;
Ok(token)
}
fn issue_at(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
write_private(path, bytes)
}
/// Read the token for `port`, trimming trailing whitespace. Used by clients (the MCP bridge).
pub fn read(port: u16) -> io::Result<String> {
let raw = std::fs::read_to_string(token_path(port)?)?;
let token = raw.trim().to_string();
if token.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty control token"));
}
Ok(token)
}
/// Remove the token file (best effort), called when the server stops.
pub fn revoke(port: u16) {
if let Ok(path) = token_path(port) {
let _ = std::fs::remove_file(path);
}
}
/// Constant-time comparison of two tokens, so a wrong token cannot be guessed byte by byte.
pub fn matches(expected: &str, given: &str) -> bool {
let (a, b) = (expected.as_bytes(), given.as_bytes());
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn random_hex() -> io::Result<String> {
let mut bytes = [0u8; TOKEN_BYTES];
getrandom::fill(&mut bytes).map_err(|e| io::Error::other(format!("no secure randomness: {e}")))?;
let mut s = String::with_capacity(TOKEN_BYTES * 2);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
Ok(s)
}
#[cfg(unix)]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
// Replace any stale file, then create fresh with mode 0600 so no other user can read the token.
let _ = std::fs::remove_file(path);
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
f.write_all(bytes)?;
f.flush()
}
#[cfg(not(unix))]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
// On Windows the per-user LOCALAPPDATA directory is already ACL-protected to the user.
let _ = std::fs::remove_file(path);
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
f.write_all(bytes)?;
f.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_then_read_round_trips() {
let port = 50000 + (std::process::id() % 10000) as u16;
let token = issue(port).unwrap();
assert_eq!(token.len(), TOKEN_BYTES * 2);
assert_eq!(read(port).unwrap(), token);
revoke(port);
assert!(read(port).is_err());
}
#[test]
fn matches_is_exact() {
assert!(matches("abc", "abc"));
assert!(!matches("abc", "abd"));
assert!(!matches("abc", "ab"));
assert!(!matches("", "x"));
}
#[cfg(unix)]
#[test]
fn token_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let port = 40000 + (std::process::id() % 10000) as u16;
issue(port).unwrap();
let mode = std::fs::metadata(token_path(port).unwrap()).unwrap().permissions().mode();
assert_eq!(mode & 0o077, 0, "token file is readable by group/other: {:o}", mode);
revoke(port);
}
#[test]
fn windows_requires_local_app_data() {
assert!(windows_private_dir(None).is_err());
assert!(windows_private_dir(Some("".into())).is_err());
assert_eq!(windows_private_dir(Some("private-app-data".into())).unwrap(), PathBuf::from("private-app-data"));
}
#[test]
fn issuance_creates_missing_parent() {
let dir = std::env::temp_dir().join(format!("cadcraft-token-parent-{}", std::process::id()));
let path = dir.join("missing/cache/session.token");
issue_at(&path, b"secret").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"secret");
std::fs::remove_dir_all(dir).unwrap();
}
}
+1
View File
@@ -9,6 +9,7 @@
#![forbid(unsafe_code)]
mod backend;
pub mod control_token;
mod server;
mod tools;
+9 -1
View File
@@ -5,10 +5,18 @@ Start the desktop app with `--control PORT` (or `CADCRAFT_CONTROL_PORT=PORT`). I
`--control` wins over the variable; a bare `--control` keeps the variable's port.
```json
{"id": 1, "method": "cmdline.input", "params": {"text": "circle 0,0 5"}}
{"id": 1, "method": "cmdline.input", "params": {"text": "circle 0,0 5"}, "token": "<session token>"}
{"id": 1, "ok": true, "result": {"prompt": "Command:", "output": ["Command: CIRCLE"], ...}}
```
Every request must carry the session `token`. Because loopback TCP cannot tell local operating
system users apart, the app writes an unguessable per-session token to an owner-only file when the
control server starts, and refuses (`unauthorized`) any request without a matching token. The file
is `cadcraft-control-<port>.token` in the per-user runtime directory (`$XDG_RUNTIME_DIR`, else
`~/.cache`; `%LOCALAPPDATA%` on Windows); its path is logged at startup. A client reads the token
from that file — which requires the desktop user's own filesystem access — and includes it on each
line. The MCP bridge (`cadcraft-cli mcp --connect`) does this automatically.
Errors come back as `{"id": …, "ok": false, "error": "message"}`. An unknown method's error points
to `control.methods`, which lists the methods the server answers.