Phase 1 server: TLS + token auth, session registry, EchoDriver, SSE with cursors
The whole pipe behind one Driver trait and a common event model: spawn/list/delete sessions, message + question answering, append-only JSONL transcripts whose sequence numbers are the phone's resume cursor (surviving backend restarts), bearer-token middleware wrapping every route including the fallback, wg0-only binding that fails closed, and first-run token enrollment via a terminal QR. Verified: cargo test (10), clippy clean, and curl end-to-end over pinned TLS -- auth rejection, spawn, streamed SSE replay/resume, /question round trip, restart continuing seq numbers, delete removing everything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
a6ece28344
commit
967fc814ab
13 files changed
+3349
No files matched your search
@@ -0,0 +1,217 @@
|
||||
//! Bearer-token auth for the entire HTTP surface.
|
||||
//!
|
||||
//! This server's API *is* remote code execution, so the token gates every
|
||||
//! route with zero unauthenticated endpoints -- the middleware is applied
|
||||
//! once around the whole router (including the fallback) in `main.rs`,
|
||||
//! never per-route, so a new route can't forget it. See PLAN.md's security
|
||||
//! section for the threat model; the short version is that the token gates
|
||||
//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it
|
||||
//! defense in depth rather than the sole gate.
|
||||
//!
|
||||
//! Nothing in this module -- and nothing anywhere else -- may log the
|
||||
//! Authorization header or the token; `token_is_never_logged` below holds a
|
||||
//! tripwire against a logging change silently starting to.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{ConnectInfo, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::session::SessionManager;
|
||||
|
||||
/// Applied to every rejection. Not against brute force -- infeasible at 256
|
||||
/// bits -- but so a scanner probing the port shows up as a slow, loggable
|
||||
/// drip rather than a fast one.
|
||||
const REJECT_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
/// 256 bits from the OS CSPRNG, base64url. A machine credential carried by
|
||||
/// a QR code, never typed, so unguessable costs nothing.
|
||||
pub fn generate_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
/// What `config.json` stores instead of the token: hex SHA-256. A plain
|
||||
/// hash is enough for high-entropy random input, and buys that a leaked
|
||||
/// config doesn't leak the credential.
|
||||
pub fn token_hash_hex(token: &str) -> String {
|
||||
Sha256::digest(token.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Hash-then-constant-time-compare against every enrolled hash. The fold
|
||||
/// visits every entry regardless of match so the timing doesn't say which
|
||||
/// entry (if any) matched.
|
||||
fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
|
||||
let presented = token_hash_hex(presented);
|
||||
stored_hashes.iter().fold(false, |matched, stored| {
|
||||
matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes()))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn require_token(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let presented = request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "));
|
||||
if let Some(token) = presented {
|
||||
let hashes: Vec<String> =
|
||||
manager.tokens().into_iter().map(|entry| entry.sha256).collect();
|
||||
if token_matches(token, &hashes) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Peer address only -- never the header value. Absent when there is no
|
||||
// real socket (tests driving the router directly).
|
||||
let peer = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ConnectInfo(addr)| addr.to_string())
|
||||
.unwrap_or_else(|| "unknown peer".to_string());
|
||||
tracing::warn!("rejected request from {peer}: missing or invalid bearer token");
|
||||
tokio::time::sleep(REJECT_DELAY).await;
|
||||
(StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::routing::get;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::config::TokenEntry;
|
||||
|
||||
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
|
||||
let manager = Arc::new(
|
||||
SessionManager::new(dir.join("config.json"), dir.join("sessions"))
|
||||
.expect("manager"),
|
||||
);
|
||||
manager
|
||||
.set_tokens(vec![TokenEntry {
|
||||
name: "phone".to_string(),
|
||||
sha256: token_hash_hex(token),
|
||||
}])
|
||||
.expect("set token");
|
||||
manager
|
||||
}
|
||||
|
||||
fn guarded_router(manager: Arc<SessionManager>) -> Router {
|
||||
Router::new()
|
||||
.route("/probe", get(|| async { "ok" }))
|
||||
.fallback(|| async { StatusCode::NOT_FOUND })
|
||||
.layer(axum::middleware::from_fn_with_state(manager, require_token))
|
||||
}
|
||||
|
||||
fn request(path: &str, auth: Option<&str>) -> Request {
|
||||
let mut builder = axum::http::Request::builder().uri(path);
|
||||
if let Some(auth) = auth {
|
||||
builder = builder.header(header::AUTHORIZATION, auth);
|
||||
}
|
||||
builder.body(Body::empty()).expect("request")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashing_is_stable_and_tokens_verify() {
|
||||
let token = generate_token();
|
||||
assert_eq!(token_hash_hex(&token), token_hash_hex(&token));
|
||||
assert_ne!(token, generate_token(), "tokens must not repeat");
|
||||
|
||||
let hashes = vec![token_hash_hex(&token), token_hash_hex("other")];
|
||||
assert!(token_matches(&token, &hashes));
|
||||
assert!(token_matches("other", &hashes));
|
||||
assert!(!token_matches("wrong", &hashes));
|
||||
assert!(!token_matches(&token, &[]));
|
||||
}
|
||||
|
||||
/// One test rather than separate gating and logging tests,
|
||||
/// deliberately: tracing caches callsite interest process-wide, so a
|
||||
/// test that hits the rejection path with no subscriber installed can
|
||||
/// poison the interest cache for the one that captures logs. Keeping
|
||||
/// every exercise of the middleware under the capturing subscriber
|
||||
/// makes the log assertions deterministic.
|
||||
#[tokio::test]
|
||||
async fn gates_every_route_and_never_logs_the_token() {
|
||||
#[derive(Clone, Default)]
|
||||
struct Capture(Arc<Mutex<Vec<u8>>>);
|
||||
impl std::io::Write for Capture {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
|
||||
type Writer = Capture;
|
||||
fn make_writer(&'a self) -> Capture {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
let capture = Capture::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::TRACE)
|
||||
.with_writer(capture.clone())
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let token = generate_token();
|
||||
let router = guarded_router(manager_with_token(dir.path(), &token));
|
||||
|
||||
// No header, wrong token, wrong scheme: 401 everywhere, including
|
||||
// paths that don't exist -- a scanner learns nothing.
|
||||
for (path, auth) in [
|
||||
("/probe", None),
|
||||
("/probe", Some("Bearer wrong".to_string())),
|
||||
("/probe", Some(format!("Basic {token}"))),
|
||||
("/no-such-route", None),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path, auth.as_deref()))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path} {auth:?}");
|
||||
}
|
||||
|
||||
let ok = router
|
||||
.clone()
|
||||
.oneshot(request("/probe", Some(&format!("Bearer {token}"))))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(ok.status(), StatusCode::OK);
|
||||
|
||||
// The tripwire that keeps a future logging change (e.g. logging
|
||||
// request headers) from silently leaking credentials.
|
||||
let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned();
|
||||
assert!(
|
||||
!logged.contains(&token),
|
||||
"the bearer token leaked into the logs: {logged}"
|
||||
);
|
||||
// The rejections themselves do get logged (that's the point).
|
||||
assert!(logged.contains("missing or invalid bearer token"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user