Skip to content

Commit 8c56212

Browse files
committed
pairing-daemon typed-error -> HTTP mapping, remove permissive CORS, Host/Origin/Referer allowlist + 421, 64 KiB body + depth-16 + 4 KiB string caps, tiered per-IP rate limit w/ lookup-miss backoff + uniform-time floor
1 parent 898fb4b commit 8c56212

21 files changed

Lines changed: 1870 additions & 168 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/auths-cli/src/commands/device/pair/lan_server.rs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::time::Duration;
55

66
use tokio_util::sync::CancellationToken;
77

8-
use auths_pairing_daemon::{PairingDaemonBuilder, PairingDaemonHandle};
8+
use auths_pairing_daemon::{HostAllowlist, PairingDaemonBuilder, PairingDaemonHandle};
99
use auths_sdk::pairing::{CreateSessionRequest, SubmitResponseRequest};
1010

1111
/// Detect the LAN IP address of this machine.
@@ -34,9 +34,12 @@ impl LanPairingServer {
3434
let daemon = PairingDaemonBuilder::new().build(session)?;
3535
let pairing_token_b64 = daemon.token().to_string();
3636

37-
let (router, handle) = daemon.into_parts();
37+
// Bind FIRST so we know the port, then build the router with
38+
// a Host/Origin/Referer allowlist scoped to the bound
39+
// `SocketAddr`. Reversing this order would either leave the
40+
// port unknown (fail-closed allowlist → 421 for every request)
41+
// or require mutable state in the middleware.
3842
let cancel = CancellationToken::new();
39-
4043
let listener = tokio::net::TcpListener::bind(SocketAddr::new(bind_ip, 0))
4144
.await
4245
.map_err(|e| {
@@ -48,6 +51,8 @@ impl LanPairingServer {
4851
)
4952
})?;
5053
let addr = listener.local_addr()?;
54+
let allowlist = HostAllowlist::for_bound_addr(addr, None);
55+
let (router, handle) = daemon.into_parts(allowlist);
5156

5257
let cancel_clone = cancel.clone();
5358
let task = tokio::spawn(async move {

‎crates/auths-pairing-daemon/Cargo.toml‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,20 @@ auths-core = { workspace = true }
1515
thiserror.workspace = true
1616
serde = { version = "1", features = ["derive"] }
1717
tokio = { workspace = true, features = ["sync"] }
18-
# fn-128.T7 startup entropy / CRNG-init probe.
18+
# Startup entropy / CRNG-init probe.
1919
rand.workspace = true
20-
# fn-130.T1: structured server-side error logging from DaemonError::into_response.
20+
# Structured server-side error logging from DaemonError::into_response.
2121
tracing = "0.1"
2222

2323
# Server feature deps
2424
axum = { version = "0.8", optional = true }
2525
tower = { version = "0.5", features = ["util"], optional = true }
26-
tower-http = { version = "0.6", features = ["trace", "cors"], optional = true }
26+
# `cors` feature intentionally NOT enabled — this daemon is strictly
27+
# app-to-daemon on the LAN; a browser hitting it is DNS rebinding or
28+
# a bug. See router.rs for the full rationale.
29+
# `limit` feature enables `RequestBodyLimitLayer`.
30+
tower-http = { version = "0.6", features = ["trace", "limit"], optional = true }
31+
serde_json = { version = "1", optional = true }
2732
ring = { workspace = true, optional = true }
2833
base64 = { workspace = true, optional = true }
2934
subtle = { workspace = true, optional = true }
@@ -34,7 +39,7 @@ mdns-sd = { version = "0.18.0", optional = true }
3439

3540
[features]
3641
default = ["server", "mdns"]
37-
server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:ring", "dep:base64", "dep:subtle", "dep:if-addrs"]
42+
server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:ring", "dep:base64", "dep:subtle", "dep:if-addrs", "dep:serde_json"]
3843
mdns = ["dep:mdns-sd"]
3944
# Pass-through to auths-crypto's FIPS / CNSA builds via auths-core (direct dep).
4045
fips = ["auths-core/fips"]

‎crates/auths-pairing-daemon/src/entropy_probe.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Startup RNG health probe (fn-128.T7).
1+
//! Startup RNG health probe.
22
//!
33
//! Refuses to run if the kernel CRNG is not initialised or if `OsRng`'s
44
//! output fails a lightweight NIST SP 800-90B-style health check. Catches

‎crates/auths-pairing-daemon/src/error.rs‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
//! Typed errors for the pairing daemon + the HTTP status-code mapping.
22
//!
3-
//! fn-130.T1 centralized the error → HTTP boundary here: every handler
4-
//! returns `Result<Json<T>, DaemonError>` and every status code in the
5-
//! daemon is emitted from a single `IntoResponse` impl. Prior to T1, eight
6-
//! handlers in `handlers.rs` hand-mapped codes inline; any future
7-
//! middleware (421 from Host-allowlist, 413 from body-limit, 503 from
8-
//! CPU-budget, etc.) had to either invent its own mapping or wait for
9-
//! this consolidation.
3+
//! Every handler returns `Result<Json<T>, DaemonError>` and every
4+
//! status code in the daemon is emitted from a single `IntoResponse`
5+
//! impl. Centralizing this mapping is what lets new middleware (421
6+
//! from the Host allowlist, 413 from the body-limit, 503 from the
7+
//! CPU-budget semaphore, etc.) share one error path instead of
8+
//! hand-rolling its own status-code translation.
109
//!
1110
//! # Error body shape
1211
//!
@@ -57,14 +56,14 @@ pub enum DaemonError {
5756
#[error(transparent)]
5857
Pairing(#[from] auths_core::pairing::PairingError),
5958

60-
/// RNG health check at startup failed (fn-128.T7). The daemon refuses
61-
/// to bind a listener if the OS CSPRNG appears unhealthy — this catches
62-
/// the early-boot entropy-starvation window that has produced real-world
63-
/// key-collision incidents.
59+
/// RNG health check at startup failed. The daemon refuses to
60+
/// bind a listener if the OS CSPRNG appears unhealthy — this
61+
/// catches the early-boot entropy-starvation window that has
62+
/// produced real-world key-collision incidents.
6463
#[error("RNG health check failed: {0}")]
6564
EntropyCheckFailed(String),
6665

67-
// --- fn-130.T1: HTTP-boundary variants -----------------------------------
66+
// --- HTTP-boundary variants ----------------------------------------------
6867
/// Resource not found (404). Emitted for unknown session_id /
6968
/// short_code lookups. Carries no detail to avoid enumeration.
7069
#[error("not found")]
@@ -77,9 +76,9 @@ pub enum DaemonError {
7776
#[error("conflict")]
7877
Conflict,
7978

80-
/// Missing / invalid `X-Pairing-Token` (401). Legacy bootstrap path
81-
/// — will be replaced by `UnauthorizedHmac` / `UnauthorizedSig` as
82-
/// later fn-130 tasks land.
79+
/// Missing / invalid `X-Pairing-Token` (401). Legacy bootstrap
80+
/// path — will be replaced by `UnauthorizedHmac` /
81+
/// `UnauthorizedSig` once the hybrid-auth middleware lands.
8382
#[error("unauthorized")]
8483
Unauthorized,
8584

‎crates/auths-pairing-daemon/src/handlers.rs‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
//! methods on [`DaemonState`]. Handlers only map between HTTP and domain
55
//! types — every error path returns a typed [`DaemonError`] and the
66
//! status-code mapping lives in a single `IntoResponse` impl (see
7-
//! `src/error.rs`). Pre-fn-130.T1 the codes were hand-mapped inline at
8-
//! every handler; keeping them centralized is what unblocks the 421 /
9-
//! 413 / 429 / 503 middleware that fn-130 tasks T3-T9 install.
7+
//! `src/error.rs`). Centralizing that mapping is what lets the
8+
//! middleware stack (421 / 413 / 429 / 503) share one error path.
109
1110
use std::sync::Arc;
11+
use std::time::Instant;
1212

1313
use axum::{
1414
Json,
15-
extract::{Path, State},
15+
extract::{ConnectInfo, Path, State},
1616
http::HeaderMap,
1717
};
1818

@@ -23,6 +23,8 @@ use auths_core::pairing::types::{
2323

2424
use crate::DaemonState;
2525
use crate::error::DaemonError;
26+
use crate::rate_limiter::{TieredRateLimiter, uniform_time_floor};
27+
use crate::request_limits::LimitedJson;
2628
use crate::token::validate_pairing_token;
2729

2830
/// Health check endpoint.
@@ -47,12 +49,21 @@ pub async fn handle_health() -> &'static str {
4749
pub async fn handle_lookup_by_code(
4850
Path(code): Path<String>,
4951
State(state): State<Arc<DaemonState>>,
52+
axum::extract::Extension(limiter): axum::extract::Extension<Arc<TieredRateLimiter>>,
53+
ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
5054
) -> Result<Json<GetSessionResponse>, DaemonError> {
51-
state
52-
.lookup_by_code(&code)
53-
.await
54-
.map(Json)
55-
.ok_or(DaemonError::NotFound)
55+
// Equal-time: compute the floor deadline at the top of the
56+
// handler and sleep until it on both the hit and the miss paths.
57+
// Otherwise an attacker can enumerate short codes by measuring
58+
// response time.
59+
let start = Instant::now();
60+
let floor = limiter.config().uniform_miss_floor;
61+
62+
let result = state.lookup_by_code(&code).await;
63+
limiter.record_lookup_outcome(addr.ip(), result.is_some());
64+
uniform_time_floor(start, floor).await;
65+
66+
result.map(Json).ok_or(DaemonError::NotFound)
5667
}
5768

5869
/// Get session details by ID.
@@ -90,7 +101,7 @@ pub async fn handle_submit_response(
90101
Path(id): Path<String>,
91102
State(state): State<Arc<DaemonState>>,
92103
headers: HeaderMap,
93-
Json(request): Json<SubmitResponseRequest>,
104+
LimitedJson(request): LimitedJson<SubmitResponseRequest>,
94105
) -> Result<Json<SuccessResponse>, DaemonError> {
95106
if !validate_pairing_token(&headers, state.pairing_token()) {
96107
return Err(DaemonError::Unauthorized);
@@ -118,7 +129,7 @@ pub async fn handle_submit_confirmation(
118129
Path(id): Path<String>,
119130
State(state): State<Arc<DaemonState>>,
120131
headers: HeaderMap,
121-
Json(request): Json<SubmitConfirmationRequest>,
132+
LimitedJson(request): LimitedJson<SubmitConfirmationRequest>,
122133
) -> Result<Json<SuccessResponse>, DaemonError> {
123134
if !validate_pairing_token(&headers, state.pairing_token()) {
124135
return Err(DaemonError::Unauthorized);

0 commit comments

Comments
 (0)