add auth system

This commit is contained in:
2026-07-12 18:10:17 +02:00
parent 93be6a7f68
commit 63287fcc8a
8 changed files with 654 additions and 58 deletions
+206
View File
@@ -0,0 +1,206 @@
//! Authentication helpers for the optional bearer-token scheme.
//!
//! The daemon can be configured to require `Authorization: Bearer <token>`
//! for any request whose peer is not a loopback address. This module owns
//! the three primitives every other auth site relies on:
//!
//! * [`generate_token`] — 32 cryptographically random bytes encoded as
//! URL-safe unpadded base64 (43 chars, header-safe).
//! * [`constant_time_eq`] — string compare that does not short-circuit on
//! mismatched prefixes/lengths, so a guesser's per-token timing leak is
//! bounded to the constant overhead of the loop.
//! * [`is_local_request`] — true iff the request's TCP peer is in the
//! loopback space (including IPv4-mapped IPv6 like `::ffff:127.0.0.1`,
//! which `IpAddr::is_loopback` alone does not classify as loopback).
//!
//! [`bearer_from_request`] is the only parse path the HTTP layer uses to
//! extract the credential string, and [`request_is_authorized`] packages
//! the full "is this caller allowed?" decision so the dispatcher can ask
//! one boolean question per route.
//!
//! These helpers never log the credential. The HTTP server's rejection
//! paths must call [`request_is_authorized`] (or `bearer_from_request`
//! + [`constant_time_eq`]) and never `debug!` the bearer header itself:
//! leaking the value to disk or stdout would defeat the entire scheme.
use std::net::{IpAddr, SocketAddr};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::RngCore;
use tiny_http::Request;
/// Length of the URL-safe-base64 encoding of 32 bytes (256 / 6 = 43
/// chars, no padding). Mirrored as a constant in the unit tests below.
const TOKEN_LEN: usize = 43;
/// Generate a fresh 32-byte bearer token. Output is 43 chars of URL-safe
/// unpadded base64, drawn from `rand::thread_rng()` which is itself
/// backed by `getrandom` on Linux — adequate for a single shared secret
/// against non-state-level attackers.
pub fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Constant-time equality on string slices. We do NOT short-circuit when
/// lengths differ: the loop runs over `max(a.len(), b.len())` and folds
/// the length mismatch into the same XOR-accumulator. For tokens the
/// legitimate length is a 43-char public constant, so even the
/// short-circuit-on-length approach wouldn't give an attacker useful
/// information — but defending against the corner case costs almost
/// nothing here and keeps the timing profile uniform.
///
/// The `core::hint::black_box` calls and a final `Ordering::SeqCst`
/// compiler fence keep LLVM from recognising the "compute a single
/// boolean, all of whose inputs feed through `|=`" idiom and
/// optimising it down to a short-circuiting `==` in release builds.
/// Defense in depth against an optimisation that would re-introduce
/// the very timing channel this function exists to suppress.
pub fn constant_time_eq(a: &str, b: &str) -> bool {
use std::sync::atomic::{compiler_fence, Ordering};
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let n = a_bytes.len().max(b_bytes.len());
let mut diff: u8 = (a_bytes.len() != b_bytes.len()) as u8;
for i in 0..n {
let x = a_bytes.get(i).copied().unwrap_or(0);
let y = b_bytes.get(i).copied().unwrap_or(0);
diff |= x ^ y;
}
let diff = black_box_u8(diff);
compiler_fence(Ordering::SeqCst);
diff == 0
}
#[inline]
fn black_box_u8(x: u8) -> u8 {
// `black_box` is a hint to the optimiser: the value may be observed
// by an external observer and so should not be elided or folded.
std::hint::black_box(x)
}
/// True iff `req`'s peer address is the IPv4 loopback subnet (including
/// `127.0.0.0/8` in principle, though tiny_http's `remote_addr` always
/// reports `127.0.0.1` on Linux), `::1`, OR an IPv4-mapped IPv6 address
/// like `::ffff:127.0.0.1` that the OS produces when a daemon with a
/// dual-stack socket accepts a v4 client.
pub fn is_local_request(req: &Request) -> bool {
match req.remote_addr() {
Some(addr) => peer_is_loopback(*addr),
None => false,
}
}
fn peer_is_loopback(addr: SocketAddr) -> bool {
match addr.ip() {
IpAddr::V4(v4) => v4.is_loopback(),
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.to_ipv4_mapped()
.map(|v4| v4.is_loopback())
.unwrap_or(false)
}
}
}
/// Extract the bearer token from an `Authorization` header. Returns
/// `None` if the header is absent, falls outside the Bearer scheme
/// (e.g. Basic / Digest), or carries an empty token. Both the header
/// name and the scheme word are matched case-insensitively per RFC 7235.
pub fn bearer_from_request(req: &Request) -> Option<String> {
for header in req.headers() {
if !header.field.equiv("Authorization") {
continue;
}
let trimmed = header.value.as_str().trim_start();
const PREFIX: &str = "Bearer ";
if trimmed.len() >= PREFIX.len()
&& trimmed[..PREFIX.len()].eq_ignore_ascii_case(PREFIX)
{
let token = trimmed[PREFIX.len()..].trim();
if !token.is_empty() {
return Some(token.to_string());
}
}
// Authorization header was present but malformed (wrong scheme,
// empty token, etc). Do NOT silently fall through to treat it
// as "no credential"; the caller's auth check will return 401.
return None;
}
None
}
/// One-line auth decision used by the HTTP dispatcher for every non-auth
/// route. True if the request is from a loopback peer (always trusted),
/// OR if the daemon has authentication disabled, OR if the request
/// carries a `Bearer` header whose value matches the configured token
/// under [`constant_time_eq`].
pub fn request_is_authorized(req: &Request, auth_token: Option<&str>) -> bool {
if is_local_request(req) {
return true;
}
let Some(expected) = auth_token else {
// No token configured → authentication is off entirely.
return true;
};
bearer_from_request(req)
.map(|t| constant_time_eq(&t, expected))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_matches_equal_strings() {
assert!(constant_time_eq("", ""));
assert!(constant_time_eq("a", "a"));
assert!(constant_time_eq("hello world", "hello world"));
}
#[test]
fn constant_time_rejects_unequal_strings() {
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "ab"));
assert!(!constant_time_eq("ab", "abc"));
assert!(!constant_time_eq("", "x"));
assert!(!constant_time_eq("x", ""));
}
#[test]
fn generate_produces_distinct_well_formed_tokens() {
let t1 = generate_token();
let t2 = generate_token();
assert_eq!(t1.len(), TOKEN_LEN);
assert_eq!(t2.len(), TOKEN_LEN);
assert_ne!(t1, t2, "32 random bytes should never collide");
// URL-safe base64 alphabet: A-Z a-z 0-9 - _.
for c in t1.chars() {
assert!(
c.is_ascii_alphanumeric() || c == '-' || c == '_',
"token contained non-url-safe char: {c:?}"
);
}
}
#[test]
fn peer_loopback_accepts_v4_and_v6_variants() {
let v4_loopback: SocketAddr = "127.0.0.1:1234".parse().unwrap();
let v4_other: SocketAddr = "10.0.0.1:1234".parse().unwrap();
let v6_loopback: SocketAddr = "[::1]:1234".parse().unwrap();
let v6_other: SocketAddr = "[2001:db8::1]:1234".parse().unwrap();
let v4_mapped_loopback: SocketAddr =
std::net::SocketAddrV6::new("::ffff:127.0.0.1".parse().unwrap(), 1234, 0, 0)
.into();
assert!(peer_is_loopback(v4_loopback));
assert!(!peer_is_loopback(v4_other));
assert!(peer_is_loopback(v6_loopback));
assert!(!peer_is_loopback(v6_other));
assert!(
peer_is_loopback(v4_mapped_loopback),
"IPv4-mapped IPv6 must classify as loopback"
);
}
}
+27
View File
@@ -116,9 +116,36 @@ pub struct Config {
pub search: SearchConfig,
#[serde(default)]
pub download: DownloadConfig,
/// When `true`, the daemon requires `Authorization: Bearer <token>`
/// for any request whose peer is NOT a loopback address. The matching
/// `auth_token` is auto-generated at startup if it is still empty
/// (see `daemon::run`), then persisted to disk via
/// [`Config::save`] so subsequent restarts reuse the same token.
#[serde(default)]
pub auth_enabled: bool,
/// Bearer credential compared against incoming `Authorization` headers
/// when `auth_enabled = true`. Empty string disables auth entirely
/// regardless of the `auth_enabled` flag — the daemon treats any
/// `auth_token == ""` as "no token configured, allow all remote".
#[serde(default)]
pub auth_token: String,
}
impl Config {
/// Persist the current `Config` back to its TOML file. Used by the
/// daemon only after a new bearer token has been auto-generated at
/// startup, so the same token is reusable across restarts. Falls
/// back silently on serialization/write failures — the daemon will
/// still boot with the in-memory token, it just won't survive a
/// restart (and will log a warning about that).
pub fn save(&self) -> std::io::Result<()> {
let path = config_path();
let serialized = toml::to_string_pretty(self).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
})?;
fs::write(&path, serialized)
}
/// Load configuration from the TOML file. If the file does not exist, create
/// it with sensible defaults so the daemon can be configured on first run.
pub fn load() -> Self {
+89 -2
View File
@@ -5,6 +5,7 @@ use log::{debug, error, info, warn};
use crate::{
audio::spawn_audio_thread,
auth,
config::Config,
db::open_db,
mpris,
@@ -12,9 +13,95 @@ use crate::{
state::{DaemonState, RepeatMode, Song},
};
pub fn run(cfg: Config) {
/// Returns `true` when the bind host is a loopback interface
/// (`127.0.0.1`, `::1`, or the conventional `localhost` string). Used by
/// the startup warning paths to decide whether to alert the operator
/// about exposing the daemon on a network-reachable address.
fn bind_is_loopback(host: &str) -> bool {
matches!(host, "127.0.0.1" | "::1" | "localhost")
}
fn warn_if_publicly_exposed(host: &str, auth_on: bool) {
if bind_is_loopback(host) {
return;
}
if auth_on {
warn!(
"sjnmusicd is listening on {host}; remote peers require the configured bearer token (Authorization: Bearer <token>)"
);
} else {
warn!(
"sjnmusicd is listening on {host} WITHOUT authentication; anyone on the network can control playback"
);
// Mirror the warning to stderr so the operator sees it even
// when they start the daemon interactively without a log file
// and without RUST_LOG configured. env_logger's default filter
// ("info") would already include the warn! above, but historical
// service-launch recipes often run sjnmusicd via a unit-file
// and pipe stdout to /dev/null — belt and braces for safety.
eprintln!(
"WARNING: sjnmusicd is listening on {host} WITHOUT authentication. \
Anyone on the network can control playback. \
Set auth_enabled = true in {} (alongside any auth_token you want to use) to require bearer tokens.",
crate::paths::config_path(),
);
}
}
/// Build the effective bearer-token the HTTP server should use for
/// the lifetime of this process. Behaviour matches the design spec:
///
/// * `auth_enabled = false` ⇒ authentication is off entirely.
/// Token returned is `None`, regardless of any leftover config value.
/// * `auth_enabled = true && auth_token == ""` ⇒ generate a fresh
/// 32-byte URL-safe base64 token, persist it to disk so the next
/// startup keeps using the same one, and return it.
/// * `auth_enabled = true && auth_token != ""` ⇒ reuse what the
/// operator already configured. We do NOT validate the token's
/// content (e.g. base64-ness) at boot — if it's malformed,
/// `request_is_authorized` will simply never match and any remote
/// caller will see 401. That's safe: a wrong token != a correct
/// token to a constant-time comparator.
///
/// Returns `Some(token)` when auth is on, `None` otherwise. The
/// caller decides whether to emit startup warnings based on whether
/// the daemon is exposed on a non-loopback address.
fn resolve_auth_token(cfg: &mut Config) -> Option<String> {
if !cfg.auth_enabled {
return None;
}
if !cfg.auth_token.is_empty() {
info!(
"Authentication enabled; using configured auth_token (length {})",
cfg.auth_token.len()
);
return Some(cfg.auth_token.clone());
}
let token = auth::generate_token();
info!(
"Authentication enabled; generated new auth_token (length {}). Use `sjnmusic --host 127.0.0.1 GET /auth/token` to read it.",
token.len()
);
cfg.auth_token = token.clone();
match cfg.save() {
Ok(()) => info!(
"Persisted new auth_token to {} so restarts keep using the same token.",
crate::paths::config_path()
),
Err(e) => warn!(
"Failed to persist new auth_token to {}: {e}. Token will rotate on every restart until the file becomes writable.",
crate::paths::config_path()
),
}
Some(token)
}
pub fn run(mut cfg: Config) {
info!("Starting sjnmusicd");
let auth_token = resolve_auth_token(&mut cfg);
warn_if_publicly_exposed(&cfg.server.host, auth_token.is_some());
let conn = open_db();
let audio_handle = Arc::new(spawn_audio_thread());
@@ -35,7 +122,7 @@ pub fn run(cfg: Config) {
let server_handle = thread::Builder::new()
.name("sjnmusic-http".into())
.spawn(move || {
start_server(server_state, &host, port);
start_server(server_state, &host, port, auth_token);
});
if let Err(e) = server_handle {
error!("Failed to spawn HTTP server thread: {e}");
+1
View File
@@ -1,4 +1,5 @@
mod audio;
mod auth;
mod config;
mod daemon;
mod db;
+153 -3
View File
@@ -5,6 +5,7 @@ use serde::Serialize;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use crate::audio::AudioCmd;
use crate::auth;
use crate::state::{init_async, init_batch, DaemonState, RepeatMode};
use crate::youtube::{pick_best, rank_query, AUTO_PICK_MARGIN};
@@ -37,12 +38,24 @@ fn parse_body(body: &str) -> serde_json::Result<serde_json::Value> {
}
}
pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) {
pub fn start_server(
state: Arc<Mutex<DaemonState>>,
host: &str,
port: u16,
auth_token: Option<String>,
) {
let addr = format!("{host}:{port}");
let server = match Server::http(&addr) {
Ok(s) => {
info!("HTTP server listening on http://{addr}");
info!("Set RUST_LOG=debug for verbose logs");
if auth_token.is_some() {
info!(
"Bearer-token authentication enabled (remote peers require Authorization: Bearer <token>)"
);
} else {
info!("Authentication disabled (all peers trusted)");
}
s
}
Err(e) => {
@@ -55,8 +68,11 @@ pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) {
let state = state.clone();
let url = request.url().to_string();
let method = request.method().clone();
debug!("{} {}", method, url);
if let Err(e) = handle(request, state, &method, &url) {
// Clone per request so handlers can borrow it; tiny_http consumes
// `request` in `respond`, so we can't share a long-lived binding
// across the loop without sending it back in.
let auth_token = auth_token.clone();
if let Err(e) = handle(request, state, &method, &url, &auth_token) {
warn!("Failed to respond: {e}");
}
}
@@ -73,14 +89,31 @@ fn handle(
state: Arc<Mutex<DaemonState>>,
method: &Method,
url: &str,
auth_token: &Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let path = url.split('?').next().unwrap_or(url);
let route = route_for(method, path);
// ----------------------------------------------------------------
// Token / pairing endpoints bypass the bearer check but enforce a
// hard localhost requirement: they only respond to loopback peers.
// A non-local request to /auth/token or /auth/pair is a misuse, and
// returning a clear 403 (rather than 401, which would imply "you
// could supply the right credential") avoids confusing attackers
// who probe them from off-host.
// ----------------------------------------------------------------
match route {
Route::AuthTokenGet => return handle_auth_token(request, auth_token),
Route::AuthPair => return handle_auth_pair(request, auth_token),
_ => {}
}
if matches!(route, Route::Help) {
let resp = json_response(200, &serde_json::json!({
"endpoints": [
"GET /help",
"GET /auth/token (localhost-only; returns the bearer token when auth is enabled)",
"POST /auth/pair (localhost-only; pairing-style token endpoint, currently returns the same token as /auth/token)",
"GET /songs?q=",
"GET /search?q=... (best match) or POST /search {query}",
"GET /search/all?q=... (every match above threshold)",
@@ -122,6 +155,45 @@ fn handle(
return Ok(request.respond(resp)?);
}
// ----------------------------------------------------------------
// Auth gate for every other route. We deliberately do NOT log the
// Authorization header's value (or even its presence with explicit
// token bytes) — the attacker-supplied bytes are the secret. Only
// the route + method is logged so an operator can correlate
// rejections with client intent.
// ----------------------------------------------------------------
if !auth::request_is_authorized(&request, auth_token.as_deref()) {
debug!(
"auth: rejecting {method} {path} from {} (no/invalid token)",
request
.remote_addr()
.map(|a| a.ip().to_string())
.unwrap_or_else(|| "<unknown>".to_string())
);
let mut resp = json_response(
401,
&ApiError {
error: "unauthorized".to_string(),
},
);
// Advertise the auth scheme to well-behaved clients (the CLI's
// auto-fetch fallback, future GUI implementations, third-party
// tools). The `realm` is purely informational; the daemon
// doesn't currently namespace per-user, so a single realm label
// is sufficient. We only attach the header when authentication
// IS actually enabled, so a 401 from a daemon with auth off
// doesn't advertise a mechanism that wouldn't accept a token.
if auth_token.is_some() {
if let Ok(h) = Header::from_bytes(
&b"WWW-Authenticate"[..],
&b"Bearer realm=\"sjnmusicd\""[..],
) {
resp.add_header(h);
}
}
return Ok(request.respond(resp)?);
}
let mut body_text = String::new();
if matches!(method, Method::Post | Method::Delete | Method::Put | Method::Patch) {
match read_body(&mut request) {
@@ -152,6 +224,13 @@ fn handle(
let response = match route {
Route::Help => unreachable!(),
// /auth/token and /auth/pair are intercepted at the top of
// `handle()` (before the auth gate) and short-circuited with an
// explicit `return`. After that gate they cannot reach this
// dispatch block, so the arms below are defensive.
Route::AuthTokenGet | Route::AuthPair => unreachable!(
"/auth/token and /auth/pair are intercepted before this match"
),
Route::Songs => {
let q = query_params.get("q").map(|s| s.as_str());
@@ -490,6 +569,73 @@ fn json_response_now_playing(state: &DaemonState) -> Response<std::io::Cursor<Ve
)
}
fn handle_auth_token(
request: Request,
auth_token: &Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
if !auth::is_local_request(&request) {
let resp = json_response(
403,
&ApiError {
error: "/auth/token is reachable only from loopback".to_string(),
},
);
return Ok(request.respond(resp)?);
}
match auth_token.as_deref() {
Some(t) => {
let resp = json_response(200, &serde_json::json!({ "token": t }));
Ok(request.respond(resp)?)
}
None => {
// No token configured → authentication is off. We expose
// this explicitly so the CLI knows not to bother retrying.
let resp = json_response(
404,
&ApiError {
error: "authentication disabled (no token configured)".to_string(),
},
);
Ok(request.respond(resp)?)
}
}
}
fn handle_auth_pair(
request: Request,
auth_token: &Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
if !auth::is_local_request(&request) {
let resp = json_response(
403,
&ApiError {
error: "/auth/pair is reachable only from loopback".to_string(),
},
);
return Ok(request.respond(resp)?);
}
// The full pairing endpoint can in future rotate / generate /
// revoke. Today it mirrors /auth/token: token is generated at
// daemon startup and persists in `~/.sjn/music/config.toml`, so
// there is nothing to do here. The endpoint still exists so future
// servers can extend it without a CLI breaking change.
match auth_token.as_deref() {
Some(t) => {
let resp = json_response(200, &serde_json::json!({ "token": t }));
Ok(request.respond(resp)?)
}
None => {
let resp = json_response(
404,
&ApiError {
error: "authentication disabled (no token configured)".to_string(),
},
);
Ok(request.respond(resp)?)
}
}
}
#[derive(Debug)]
enum AudioRoute {
Pause,
@@ -524,6 +670,8 @@ fn handle_audio_route(
#[derive(Debug)]
enum Route {
Help,
AuthTokenGet,
AuthPair,
Songs,
Search,
SearchAll,
@@ -565,6 +713,8 @@ fn route_for(method: &Method, path: &str) -> Route {
let p = path.trim_end_matches('/');
match (method, p) {
(Method::Get, "") | (Method::Get, "/help") => Route::Help,
(Method::Get, "/auth/token") => Route::AuthTokenGet,
(Method::Post, "/auth/pair") => Route::AuthPair,
(Method::Get, "/songs") => Route::Songs,
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
(Method::Get, "/search/all") => Route::SearchAll,