From 945ab9257dcdd78cb1d20e1dcf6a7e37f68e228b Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Mon, 21 Sep 2026 20:51:26 -0700 Subject: [PATCH] feat(opds): optional Basic auth, wired as the all-networks gate Basic authentication for the OPDS server: generated credentials (username + password) persisted with the library, optional per share start, enforced as axum basic-auth middleware on every route. This PR also completes the all-networks gate the state machine introduced: - credentials configured is the input to BindPolicy.allow_global, so AllInterfaces starts only when a password exists - clearing credentials while sharing on all interfaces forces sharing to stop - sharing on the local network never requires credentials --- Cargo.lock | 43 +- crates/citadel-opds/Cargo.toml | 6 + crates/citadel-opds/src/auth.rs | 562 ++++++++++++++++++ crates/citadel-opds/src/catalog.rs | 11 +- crates/citadel-opds/src/credential_store.rs | 230 +++++++ crates/citadel-opds/src/lib.rs | 4 + crates/citadel-opds/src/password.rs | 108 ++++ .../citadel-opds/src/service/credentials.rs | 82 +++ .../src/{service.rs => service/mod.rs} | 253 ++++++-- crates/citadel-opds/src/words.rs | 7 + .../0002-interface-scoped-opds-listener.md | 2 +- ...0003-opds-credentials-are-machine-local.md | 34 ++ .../0004-two-mode-sharing-state-machine.md | 144 +++++ src-tauri/src/main.rs | 15 +- src-tauri/src/opds/commands.rs | 37 ++ src/bindings.ts | 38 +- 16 files changed, 1532 insertions(+), 44 deletions(-) create mode 100644 crates/citadel-opds/src/auth.rs create mode 100644 crates/citadel-opds/src/credential_store.rs create mode 100644 crates/citadel-opds/src/password.rs create mode 100644 crates/citadel-opds/src/service/credentials.rs rename crates/citadel-opds/src/{service.rs => service/mod.rs} (84%) create mode 100644 crates/citadel-opds/src/words.rs create mode 100644 docs/adr/0003-opds-credentials-are-machine-local.md create mode 100644 docs/adr/0004-two-mode-sharing-state-machine.md diff --git a/Cargo.lock b/Cargo.lock index 439409eb..fce63efc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,18 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash 0.5.0", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -381,6 +393,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -657,19 +678,25 @@ dependencies = [ name = "citadel-opds" version = "0.1.0" dependencies = [ + "argon2", "axum", "base64 0.22.1", "bytes", "chrono", "diesel", "futures-util", + "hmac", "libcalibre", "netdev", "quick-xml 0.38.4", + "rand_core 0.6.4", "reqwest 0.12.28", "serde", + "serde_json", + "sha2", "socket2", "specta", + "subtle", "tempfile", "tokio", "tower", @@ -3513,6 +3540,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3533,7 +3571,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", - "password-hash", + "password-hash 0.4.2", "sha2", ] @@ -3898,6 +3936,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" diff --git a/crates/citadel-opds/Cargo.toml b/crates/citadel-opds/Cargo.toml index 7adfa723..d2fae931 100644 --- a/crates/citadel-opds/Cargo.toml +++ b/crates/citadel-opds/Cargo.toml @@ -6,15 +6,21 @@ rust-version.workspace = true description = "OPDS catalog, authentication, and networking runtime for Citadel" [dependencies] +argon2 = "0.5" axum = "0.8.9" base64 = "0.22" bytes = "1" chrono = { version = "0.4.31", features = ["serde"] } futures-util = "0.3" +hmac = "0.12" libcalibre = { path = "../libcalibre" } netdev = { version = "=0.45.0", default-features = false } quick-xml = "0.38" +serde_json = "1.0" socket2 = "0.6" +rand_core = { version = "0.6", features = ["getrandom"] } +sha2 = "0.10" +subtle = "2.6" serde = { version = "1.0", features = ["derive"] } specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] } tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } diff --git a/crates/citadel-opds/src/auth.rs b/crates/citadel-opds/src/auth.rs new file mode 100644 index 00000000..52321e78 --- /dev/null +++ b/crates/citadel-opds/src/auth.rs @@ -0,0 +1,562 @@ +use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Algorithm, Argon2, Params, Version, +}; +use axum::{ + extract::{Request, State}, + http::{ + header::{AUTHORIZATION, RETRY_AFTER, WWW_AUTHENTICATE}, + HeaderValue, StatusCode, + }, + middleware::Next, + response::{IntoResponse, Response}, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use hmac::{Hmac, Mac}; +use rand_core::{OsRng, RngCore}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +const DEFAULT_CACHE_CAPACITY: usize = 256; +const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(30); +const DEFAULT_TARGET_DURATION: Duration = Duration::from_millis(250); +/// Caps parallel Argon2 verifications. Hashing is tuned to be expensive; +/// unbounded parallelism lets one client make every request slow. +const ARGON2_CONCURRENCY: usize = 3; +const BASIC_CHALLENGE: &str = "Basic realm=\"Citadel\", charset=\"UTF-8\""; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpdsAuthCredentials { + pub username: String, + pub verifier: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OpdsAuthError { + InvalidVerifier, + PasswordHashingFailed, +} + +impl std::fmt::Display for OpdsAuthError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidVerifier => { + formatter.write_str("The OPDS credential verifier is invalid.") + } + Self::PasswordHashingFailed => { + formatter.write_str("Could not create OPDS credentials.") + } + } + } +} + +impl std::error::Error for OpdsAuthError {} + +/// Creates credentials which store only a username and an Argon2id PHC verifier. +pub(crate) fn create_credentials( + username: String, + password: &[u8], +) -> Result { + let salt = SaltString::generate(&mut OsRng); + let verifier = argon2id() + .hash_password(password, &salt) + .map_err(|_| OpdsAuthError::PasswordHashingFailed)? + .to_string(); + + Ok(OpdsAuthCredentials { username, verifier }) +} + +#[derive(Clone)] +pub struct OpdsBasicAuth { + enabled: Option>, +} + +struct EnabledAuth { + credentials: OpdsAuthCredentials, + cache_key: [u8; 32], + cache: Mutex, + target_duration: Mutex, + /// Caps concurrent Argon2 verifications; hashing is deliberately expensive + /// and unbounded parallelism would make every request slow. + permits: Arc, +} + +#[derive(Clone, Copy)] +enum CachedOutcome { + Authorized, + Rejected, +} + +struct CacheEntry { + tag: [u8; 32], + outcome: CachedOutcome, + target_duration: Duration, + expires_at: Instant, +} + +struct AuthCache { + entries: Vec, + capacity: usize, + ttl: Duration, +} + +impl AuthCache { + fn new(capacity: usize, ttl: Duration) -> Self { + Self { + entries: Vec::with_capacity(capacity), + capacity, + ttl, + } + } + + fn get(&mut self, tag: &[u8; 32]) -> Option<(CachedOutcome, Duration)> { + let now = Instant::now(); + self.entries.retain(|entry| entry.expires_at > now); + self.entries + .iter() + .find(|entry| bool::from(entry.tag.ct_eq(tag))) + .map(|entry| (entry.outcome, entry.target_duration)) + } + + fn insert(&mut self, tag: [u8; 32], outcome: CachedOutcome, target_duration: Duration) { + if self.capacity == 0 { + return; + } + if self.entries.len() == self.capacity { + self.entries.remove(0); + } + self.entries.push(CacheEntry { + tag, + outcome, + target_duration, + expires_at: Instant::now() + self.ttl, + }); + } +} + +impl OpdsBasicAuth { + pub(crate) fn disabled() -> Self { + Self { enabled: None } + } + + pub(crate) fn enabled(credentials: OpdsAuthCredentials) -> Result { + Self::enabled_with_settings( + credentials, + DEFAULT_CACHE_CAPACITY, + DEFAULT_CACHE_TTL, + DEFAULT_TARGET_DURATION, + ) + } + + fn enabled_with_settings( + credentials: OpdsAuthCredentials, + cache_capacity: usize, + cache_ttl: Duration, + target_duration: Duration, + ) -> Result { + let password_hash = + PasswordHash::new(&credentials.verifier).map_err(|_| OpdsAuthError::InvalidVerifier)?; + if password_hash.algorithm.as_str() != "argon2id" { + return Err(OpdsAuthError::InvalidVerifier); + } + + let mut cache_key = [0; 32]; + OsRng.fill_bytes(&mut cache_key); + Ok(Self { + enabled: Some(Arc::new(EnabledAuth { + credentials, + cache_key, + cache: Mutex::new(AuthCache::new(cache_capacity, cache_ttl)), + target_duration: Mutex::new(target_duration), + permits: Arc::new(tokio::sync::Semaphore::new(ARGON2_CONCURRENCY)), + })), + }) + } + + fn is_enabled(&self) -> bool { + self.enabled.is_some() + } + + /// Authorizes complete Authorization header bytes. Disabled authentication does not parse them. + async fn authorize(&self, authorization: Option<&[u8]>) -> AuthOutcome { + let Some(enabled) = &self.enabled else { + return AuthOutcome::Authorized; + }; + + let started_at = Instant::now(); + let authorization = authorization.unwrap_or_default(); + let tag = opaque_tag(&enabled.cache_key, authorization); + if let Some((outcome, cached_duration)) = enabled + .cache + .lock() + .ok() + .and_then(|mut cache| cache.get(&tag)) + { + let target_duration = current_target_duration(enabled).max(cached_duration); + pad_to_target(started_at, target_duration).await; + return match outcome { + CachedOutcome::Authorized => AuthOutcome::Authorized, + CachedOutcome::Rejected => AuthOutcome::Rejected, + }; + } + + // Cap parallel Argon2 work: hashing is deliberately expensive, and + // unbounded parallelism lets one client make every request slow. + let permit = match enabled.permits.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => return AuthOutcome::Busy, + }; + let parsed = parse_basic_credentials(authorization); + let authorized = match parsed { + Some(credentials) if credentials.username == enabled.credentials.username => { + let verifier = enabled.credentials.verifier.clone(); + tokio::task::spawn_blocking(move || { + verify_password(&verifier, &credentials.password) + }) + .await + .unwrap_or(false) + } + _ => false, + }; + drop(permit); + let target_duration = target_duration(enabled, started_at.elapsed()); + let outcome = if authorized { + CachedOutcome::Authorized + } else { + CachedOutcome::Rejected + }; + if let Ok(mut cache) = enabled.cache.lock() { + cache.insert(tag, outcome, target_duration); + } + pad_to_target(started_at, target_duration).await; + match outcome { + CachedOutcome::Authorized => AuthOutcome::Authorized, + CachedOutcome::Rejected => AuthOutcome::Rejected, + } + } +} + +enum AuthOutcome { + Authorized, + Rejected, + Busy, +} + +/// Axum 0.8 middleware. Apply it as the final layer around the OPDS router. +pub(crate) async fn require_basic_auth( + State(auth): State, + request: Request, + next: Next, +) -> Response { + if !auth.is_enabled() { + return next.run(request).await; + } + + let authorization = request + .headers() + .get(AUTHORIZATION) + .map(|value| value.as_bytes().to_vec()); + match auth.authorize(authorization.as_deref()).await { + AuthOutcome::Authorized => next.run(request).await, + AuthOutcome::Busy => ( + StatusCode::SERVICE_UNAVAILABLE, + [ + (RETRY_AFTER, HeaderValue::from_static("1")), + (WWW_AUTHENTICATE, HeaderValue::from_static(BASIC_CHALLENGE)), + ], + ) + .into_response(), + AuthOutcome::Rejected => unauthorized_response(), + } +} + +fn current_target_duration(enabled: &EnabledAuth) -> Duration { + enabled + .target_duration + .lock() + .map(|duration| *duration) + .unwrap_or(DEFAULT_TARGET_DURATION) +} + +struct BasicCredentials { + username: String, + password: Vec, +} + +fn parse_basic_credentials(authorization: &[u8]) -> Option { + let authorization = std::str::from_utf8(authorization).ok()?; + let (scheme, encoded) = authorization.split_once(' ')?; + if !scheme.eq_ignore_ascii_case("basic") || encoded.is_empty() || encoded.contains(' ') { + return None; + } + let decoded = STANDARD.decode(encoded).ok()?; + let separator = decoded.iter().position(|byte| *byte == b':')?; + let username = std::str::from_utf8(&decoded[..separator]).ok()?.to_string(); + Some(BasicCredentials { + username, + password: decoded[separator + 1..].to_vec(), + }) +} + +fn verify_password(verifier: &str, password: &[u8]) -> bool { + let Ok(password_hash) = PasswordHash::new(verifier) else { + return false; + }; + password_hash.algorithm.as_str() == "argon2id" + && argon2id().verify_password(password, &password_hash).is_ok() +} + +fn argon2id() -> Argon2<'static> { + Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()) +} + +fn opaque_tag(cache_key: &[u8; 32], authorization: &[u8]) -> [u8; 32] { + let mut mac = Hmac::::new_from_slice(cache_key).expect("HMAC accepts a fixed-size key"); + mac.update(authorization); + mac.finalize().into_bytes().into() +} + +fn target_duration(enabled: &EnabledAuth, elapsed: Duration) -> Duration { + let Ok(mut target_duration) = enabled.target_duration.lock() else { + return elapsed.max(DEFAULT_TARGET_DURATION); + }; + *target_duration = (*target_duration).max(elapsed); + *target_duration +} + +async fn pad_to_target(started_at: Instant, target_duration: Duration) { + if let Some(remaining) = target_duration.checked_sub(started_at.elapsed()) { + tokio::time::sleep(remaining).await; + } +} + +fn unauthorized_response() -> Response { + ( + StatusCode::UNAUTHORIZED, + [(WWW_AUTHENTICATE, HeaderValue::from_static(BASIC_CHALLENGE))], + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{header::AUTHORIZATION, Request}, + middleware, + routing::get, + Router, + }; + use tower::ServiceExt; + + use super::*; + + fn basic_header(username: &str, password: &[u8]) -> Vec { + let mut credentials = username.as_bytes().to_vec(); + credentials.push(b':'); + credentials.extend(password); + format!("Basic {}", STANDARD.encode(credentials)).into_bytes() + } + + fn enabled_auth(username: &str, password: &[u8], target_duration: Duration) -> OpdsBasicAuth { + OpdsBasicAuth::enabled_with_settings( + create_credentials(username.to_string(), password).unwrap(), + 2, + Duration::from_secs(1), + target_duration, + ) + .unwrap() + } + + fn app(auth: OpdsBasicAuth) -> Router { + Router::new() + .route("/opds", get(|| async { "catalog" })) + .layer(middleware::from_fn_with_state(auth, require_basic_auth)) + } + + async fn response(auth: OpdsBasicAuth, authorization: Option>) -> Response { + let mut request = Request::builder().uri("/opds"); + if let Some(authorization) = authorization { + request = request.header(AUTHORIZATION, authorization); + } + app(auth) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + } + + #[test] + fn credentials_store_a_random_argon2id_phc_verifier() { + let first = create_credentials("reader".to_string(), b"correct horse").unwrap(); + let second = create_credentials("reader".to_string(), b"correct horse").unwrap(); + + assert_eq!(first.username, "reader"); + assert!(first.verifier.starts_with("$argon2id$")); + assert_ne!(first.verifier, second.verifier); + let password_hash = PasswordHash::new(&first.verifier).unwrap(); + assert!(argon2id() + .verify_password(b"correct horse", &password_hash) + .is_ok()); + } + + #[tokio::test] + async fn disabled_auth_bypasses_even_malformed_authorization() { + let auth = OpdsBasicAuth::disabled(); + + assert!(matches!( + auth.authorize(Some(b"not even close to Basic")).await, + AuthOutcome::Authorized + )); + assert_eq!( + response(auth, Some(b"not even close to Basic".to_vec())) + .await + .status(), + StatusCode::OK + ); + } + + #[tokio::test] + async fn enabled_auth_accepts_only_the_configured_basic_credentials() { + let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + + assert!(matches!( + auth.authorize(Some(&basic_header("reader", b"correct horse"))) + .await, + AuthOutcome::Authorized + )); + assert!(matches!( + auth.authorize(Some(&basic_header("reader", b"wrong password"))) + .await, + AuthOutcome::Rejected + )); + assert!(matches!( + auth.authorize(Some(&basic_header("someone-else", b"correct horse"))) + .await, + AuthOutcome::Rejected + )); + assert!(matches!(auth.authorize(None).await, AuthOutcome::Rejected)); + } + + #[tokio::test] + async fn middleware_challenges_missing_and_wrong_credentials_and_allows_correct_ones() { + let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + + let missing = response(auth.clone(), None).await; + let wrong = response( + auth.clone(), + Some(basic_header("reader", b"wrong password")), + ) + .await; + let correct = response(auth, Some(basic_header("reader", b"correct horse"))).await; + + for rejected in [missing, wrong] { + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + rejected.headers().get(WWW_AUTHENTICATE).unwrap(), + BASIC_CHALLENGE + ); + } + assert_eq!(correct.status(), StatusCode::OK); + } + + #[tokio::test] + async fn cache_records_and_reuses_the_authorization_outcome_without_raw_header_bytes() { + let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + let header = basic_header("reader", b"correct horse"); + + assert!(matches!( + auth.authorize(Some(&header)).await, + AuthOutcome::Authorized + )); + let enabled = auth.enabled.as_ref().unwrap(); + let cache = enabled.cache.lock().unwrap(); + assert_eq!(cache.entries.len(), 1); + assert_eq!( + cache.entries[0].tag, + opaque_tag(&enabled.cache_key, &header) + ); + assert!(matches!( + cache.entries[0].outcome, + CachedOutcome::Authorized + )); + drop(cache); + + assert!(matches!( + auth.authorize(Some(&header)).await, + AuthOutcome::Authorized + )); + assert_eq!(enabled.cache.lock().unwrap().entries.len(), 1); + } + + #[tokio::test] + async fn unknown_usernames_and_cached_rejections_are_padded_to_the_target_duration() { + let target_duration = Duration::from_millis(20); + let auth = enabled_auth("reader", b"correct horse", target_duration); + let unknown_header = basic_header("unknown", b"correct horse"); + + let first_started_at = Instant::now(); + assert!(matches!( + auth.authorize(Some(&unknown_header)).await, + AuthOutcome::Rejected + )); + let first_elapsed = first_started_at.elapsed(); + + let cached_started_at = Instant::now(); + assert!(matches!( + auth.authorize(Some(&unknown_header)).await, + AuthOutcome::Rejected + )); + let cached_elapsed = cached_started_at.elapsed(); + + let minimum_padded_duration = target_duration - Duration::from_millis(2); + assert!(first_elapsed >= minimum_padded_duration); + assert!(cached_elapsed >= minimum_padded_duration); + } + + #[test] + fn basic_parser_preserves_password_bytes_after_the_first_colon() { + let header = basic_header("reader", b"pa:ss:word"); + let credentials = parse_basic_credentials(&header).unwrap(); + + assert_eq!(credentials.username, "reader"); + assert_eq!(credentials.password, b"pa:ss:word"); + } + + #[test] + fn cache_uses_only_opaque_complete_header_tags_and_is_bounded() { + let key = [7; 32]; + let first = opaque_tag(&key, b"Basic cmVhZGVyOmZpcnN0"); + let second = opaque_tag(&key, b"Basic cmVhZGVyOnNlY29uZA=="); + let differently_cased = opaque_tag(&key, b"basic cmVhZGVyOmZpcnN0"); + assert_ne!(first, second); + assert_ne!(first, differently_cased); + + let mut cache = AuthCache::new(1, Duration::from_secs(1)); + cache.insert(first, CachedOutcome::Authorized, Duration::from_millis(10)); + cache.insert(second, CachedOutcome::Rejected, Duration::from_millis(20)); + + assert!(cache.get(&first).is_none()); + assert_eq!( + cache + .get(&second) + .map(|(_, target_duration)| target_duration), + Some(Duration::from_millis(20)) + ); + } + + #[test] + fn unauthorized_response_uses_the_koreader_basic_challenge() { + let response = unauthorized_response(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers().get(WWW_AUTHENTICATE).unwrap(), + "Basic realm=\"Citadel\", charset=\"UTF-8\"" + ); + } +} diff --git a/crates/citadel-opds/src/catalog.rs b/crates/citadel-opds/src/catalog.rs index 9b84df90..bb66a402 100644 --- a/crates/citadel-opds/src/catalog.rs +++ b/crates/citadel-opds/src/catalog.rs @@ -14,6 +14,7 @@ use libcalibre::{BookId, BookPage, CalibreError, ResolvedBookAsset}; use serde::Deserialize; use super::assets::{self, AssetMethod, AssetResponseError}; +use super::auth::{require_basic_auth, OpdsBasicAuth}; use crate::identity::{book_identity, library_identity}; const PAGE_SIZE: u64 = 50; @@ -74,7 +75,7 @@ pub(crate) struct FeedEntry { pub(crate) content: Option, } -pub fn router(source: Arc) -> Router { +pub fn router(source: Arc, auth: OpdsBasicAuth) -> Router { Router::new() .route("/opds", get(root_feed)) .route("/opds/all", get(all_books_feed)) @@ -87,6 +88,10 @@ pub fn router(source: Arc) -> Router { get(book_cover).head(book_cover_head), ) .with_state(CatalogState { source }) + .layer(axum::middleware::from_fn_with_state( + auth, + require_basic_auth, + )) } async fn root_feed(state: State, query: Query) -> Response { @@ -680,7 +685,9 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let task = tokio::spawn(async move { - axum::serve(listener, router(source)).await.unwrap(); + axum::serve(listener, router(source, OpdsBasicAuth::disabled())) + .await + .unwrap(); }); (format!("http://{address}"), task) } diff --git a/crates/citadel-opds/src/credential_store.rs b/crates/citadel-opds/src/credential_store.rs new file mode 100644 index 00000000..df871879 --- /dev/null +++ b/crates/citadel-opds/src/credential_store.rs @@ -0,0 +1,230 @@ +//! Persistence for OPDS sharing credentials. + +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct StoredOpdsCredentials { + pub username: String, + pub password_verifier: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct OpdsCredentialStatus { + pub configured: bool, + pub username: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedOpdsCredentials { + pub username: String, + pub password: String, +} + +#[derive(Clone)] +pub(crate) struct OpdsCredentialStore { + inner: Arc, +} + +struct CredentialStoreInner { + path: Option, + credentials: RwLock>, +} + +impl OpdsCredentialStore { + pub fn load(path: PathBuf) -> io::Result { + let credentials = match fs::read(&path) { + Ok(contents) => Some(serde_json::from_slice(&contents).map_err(io::Error::other)?), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + Ok(Self { + inner: Arc::new(CredentialStoreInner { + path: Some(path), + credentials: RwLock::new(credentials), + }), + }) + } + + #[cfg(test)] + pub fn in_memory() -> Self { + Self { + inner: Arc::new(CredentialStoreInner { + path: None, + credentials: RwLock::new(None), + }), + } + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, Option> { + self.inner + .credentials + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, Option> { + self.inner + .credentials + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Disk is the source of truth; memory mirrors the last successful disk + /// operation. + pub fn status(&self) -> OpdsCredentialStatus { + let credentials = self + .inner + .credentials + .read() + .expect("OPDS credential store poisoned"); + OpdsCredentialStatus { + configured: credentials.is_some(), + username: credentials + .as_ref() + .map(|credentials| credentials.username.clone()), + } + } + + pub fn get(&self) -> Option { + self.inner + .credentials + .read() + .expect("OPDS credential store poisoned") + .clone() + } + + pub fn set(&self, credentials: StoredOpdsCredentials) -> io::Result<()> { + if let Some(path) = &self.inner.path { + persist(path, &credentials)?; + } + *self + .inner + .credentials + .write() + .expect("OPDS credential store poisoned") = Some(credentials); + Ok(()) + } + + pub fn clear(&self) -> io::Result<()> { + if let Some(path) = &self.inner.path { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + *self + .inner + .credentials + .write() + .expect("OPDS credential store poisoned") = None; + Ok(()) + } +} + +fn persist(path: &Path, credentials: &StoredOpdsCredentials) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::other("credential path has no parent"))?; + fs::create_dir_all(parent)?; + let temporary = parent.join(format!(".opds-credentials-{}.tmp", uuid::Uuid::new_v4())); + let bytes = serde_json::to_vec(credentials).map_err(io::Error::other)?; + + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temporary)?; + if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + fs::rename(&temporary, path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persists_only_username_and_verifier_with_private_permissions() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("opds-credentials.json"); + let store = OpdsCredentialStore::load(path.clone()).unwrap(); + store + .set(StoredOpdsCredentials { + username: "reader".to_string(), + password_verifier: "$argon2id$verifier".to_string(), + }) + .unwrap(); + + let contents = fs::read_to_string(&path).unwrap(); + assert!(contents.contains("reader")); + assert!(contents.contains("$argon2id$verifier")); + assert!(!contents.contains("password\":")); + assert_eq!( + OpdsCredentialStore::load(path) + .unwrap() + .status() + .username + .as_deref(), + Some("reader") + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(directory.path().join("opds-credentials.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + fn clear_is_idempotent() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("opds-credentials.json"); + let store = OpdsCredentialStore::load(path.clone()).unwrap(); + store + .set(StoredOpdsCredentials { + username: "reader".to_string(), + password_verifier: "verifier".to_string(), + }) + .unwrap(); + + store.clear().unwrap(); + store.clear().unwrap(); + assert!(!path.exists()); + assert_eq!( + store.status(), + OpdsCredentialStatus { + configured: false, + username: None + } + ); + } +} diff --git a/crates/citadel-opds/src/lib.rs b/crates/citadel-opds/src/lib.rs index 552d13ad..4f1874e4 100644 --- a/crates/citadel-opds/src/lib.rs +++ b/crates/citadel-opds/src/lib.rs @@ -1,10 +1,14 @@ //! Framework-free OPDS catalog, authentication, networking, and service lifecycle for Citadel. pub mod assets; +pub mod auth; pub mod catalog; +pub mod credential_store; mod identity; pub mod network; +pub mod password; pub mod service; +mod words; mod xml; pub use catalog::{router, CatalogSource}; diff --git a/crates/citadel-opds/src/password.rs b/crates/citadel-opds/src/password.rs new file mode 100644 index 00000000..a953e977 --- /dev/null +++ b/crates/citadel-opds/src/password.rs @@ -0,0 +1,108 @@ +//! The generated-password chunk shape and algorithm. + +use super::words::WORD_POOL; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; + +/// Returned once when credentials are generated; the plaintext is never +/// stored, so this is the only chance to copy it into a reader. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedOpdsCredentials { + pub username: String, + pub password: String, +} + +/// Symbols that survive e-reader input fields and `user:pass@host` logins. +pub(crate) const PASSWORD_SYMBOLS: &[char] = &['!', '*', '-', '=', '~', '$']; + +/// Generates a password in a fixed, chunked shape: +/// `word` + three digits (2-9) + one symbol + `word`, e.g. `wren724=wolf`. +/// Lowercase words from a curated pool, digits without 0/1, symbols from a +/// URL-safe set (`! * - = ~ $`) so readers that paste credentials into +/// `http://user:pass@host/` logins cannot mangle them. Roughly 30.5 bits: an +/// online-only attacker faces an Argon2-slowed endpoint, and auth failures +/// back off. +/// Symbols that survive e-reader input fields and `user:pass@host` logins. +pub(crate) fn generate_password() -> String { + use rand_core::{OsRng, RngCore}; + + let word = |used_tail: &mut Option| -> String { + loop { + let candidates: Vec<&str> = WORD_POOL + .split_whitespace() + .filter(|candidate| { + // No doubled glyphs inside a word, and the word must not + // begin with the character that ended the previous chunk. + let chars: Vec = candidate.chars().collect(); + chars.windows(2).all(|pair| pair[0] != pair[1]) + && used_tail + .map(|tail| chars.first() != Some(&tail)) + .unwrap_or(true) + }) + .collect(); + if !candidates.is_empty() { + let word = candidates[OsRng.next_u32() as usize % candidates.len()]; + *used_tail = word.chars().last(); + return word.to_string(); + } + } + }; + + // Three digits from 2-9, never two alike in a row: repeated digits read + // as one digit at a glance and typing a twin twice on e-ink is the + // classic transcription error. + let mut digits = String::new(); + let mut last_digit = None; + for _ in 0..3 { + let digit = loop { + let digit = b'2' + (OsRng.next_u32() % 8) as u8; + if Some(digit as char) != last_digit { + break digit; + } + }; + last_digit = Some(digit as char); + digits.push(digit as char); + } + + let symbol = PASSWORD_SYMBOLS[(OsRng.next_u32() % 6) as usize]; + + let mut first_tail: Option = None; + let first_word = word(&mut first_tail); + let mut second_tail: Option = Some(symbol); + let second_word = word(&mut second_tail); + + format!("{first_word}{digits}{symbol}{second_word}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_passwords_match_the_chunked_shape() { + for _ in 0..128 { + let password = generate_password(); + assert!(password.len() >= 8 && password.len() <= 16); + assert!( + password.chars().all(|c| c.is_ascii_lowercase() + || ('2'..='9').contains(&c) + || PASSWORD_SYMBOLS.contains(&c)), + "unexpected character in {password}" + ); + // No adjacent duplicate glyphs anywhere. + let chars: Vec = password.chars().collect(); + assert!(chars.windows(2).all(|pair| pair[0] != pair[1])); + // Shape: word + three digits (2-9) + symbol + word. + let word1_end = password + .find(|c: char| !c.is_ascii_lowercase()) + .expect("first chunk is a word"); + let body = &password[word1_end..]; + let (digits, rest) = body.split_at(3); + assert!(digits.bytes().all(|d| (b'2'..=b'9').contains(&d))); + let (symbol, second_word) = rest.split_at(1); + assert!(PASSWORD_SYMBOLS.contains(&symbol.chars().next().unwrap())); + assert!(second_word.chars().all(|c| c.is_ascii_lowercase())); + } + } +} diff --git a/crates/citadel-opds/src/service/credentials.rs b/crates/citadel-opds/src/service/credentials.rs new file mode 100644 index 00000000..ebba003d --- /dev/null +++ b/crates/citadel-opds/src/service/credentials.rs @@ -0,0 +1,82 @@ +//! The credential half of [`OpdsService`]: status, set, generate, clear. +//! A child of `service` so it can reach the service's private state without +//! visibility holes. + +use super::super::auth::create_credentials; +use super::super::password::{generate_password, GeneratedOpdsCredentials}; +use super::OpdsService; +use crate::credential_store::{OpdsCredentialStatus, StoredOpdsCredentials}; +use crate::{OpdsErrorCode, OpdsStatusError}; + +impl OpdsService { + pub fn credential_status(&self) -> OpdsCredentialStatus { + self.inner.credentials.status() + } + + pub async fn configure_credentials( + &self, + username: String, + password: String, + ) -> Result { + self.stop_if_active().await; + let credentials = + create_credentials(username, password.as_bytes()).map_err(|error| OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: error.to_string(), + })?; + let credentials = StoredOpdsCredentials { + username: credentials.username, + password_verifier: credentials.verifier, + }; + self.inner + .credentials + .set(credentials) + .map_err(|error| OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: error.to_string(), + })?; + Ok(self.credential_status()) + } + + pub async fn generate_credentials( + &self, + username: String, + ) -> Result { + self.stop_if_active().await; + let password = generate_password(); + let credentials = + create_credentials(username.clone(), password.as_bytes()).map_err(|error| { + OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: error.to_string(), + } + })?; + let credentials = StoredOpdsCredentials { + username: credentials.username, + password_verifier: credentials.verifier, + }; + self.inner + .credentials + .set(credentials) + .map_err(|error| OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: error.to_string(), + })?; + Ok(GeneratedOpdsCredentials { username, password }) + } + + /// Clearing credentials while sharing is gated on them must stop sharing: + /// the running server would keep accepting the password that no longer + /// exists, or worse, keep its auth layer pointed at stale material. + pub async fn clear_credentials(&self) -> Result { + self.stop_if_active().await; + self.inner + .credentials + .clear() + .map_err(|error| OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: error.to_string(), + })?; + Ok(self.credential_status()) + } +} diff --git a/crates/citadel-opds/src/service.rs b/crates/citadel-opds/src/service/mod.rs similarity index 84% rename from crates/citadel-opds/src/service.rs rename to crates/citadel-opds/src/service/mod.rs index 82de0ac1..0241793a 100644 --- a/crates/citadel-opds/src/service.rs +++ b/crates/citadel-opds/src/service/mod.rs @@ -13,6 +13,8 @@ use serde::{Deserialize, Serialize}; use tokio::{sync::oneshot, task::JoinHandle}; use super::{ + auth::{OpdsAuthCredentials, OpdsBasicAuth}, + credential_store::OpdsCredentialStore, network::{ advertised_url, plan_bindings, BindPolicy, InterfaceProvider, InterfaceSnapshot, NetdevInterfaceProvider, WaitingReason, @@ -30,6 +32,7 @@ pub use crate::network::OpdsBindTarget; pub struct OpdsStartConfig { pub target: OpdsBindTarget, pub port: u32, + pub authentication_enabled: bool, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, specta::Type)] @@ -96,18 +99,28 @@ impl InterfaceSnapshots for NetdevInterfaceSnapshots { } trait ListenerFactory: Send + Sync { - fn start(&self, address: SocketAddr, source: Arc) -> io::Result; + fn start( + &self, + address: SocketAddr, + source: Arc, + auth: OpdsBasicAuth, + ) -> io::Result; } struct TcpListenerFactory; impl ListenerFactory for TcpListenerFactory { - fn start(&self, address: SocketAddr, source: Arc) -> io::Result { + fn start( + &self, + address: SocketAddr, + source: Arc, + auth: OpdsBasicAuth, + ) -> io::Result { let listener = bind_socket(address)?; listener.set_nonblocking(true)?; let listener = tokio::net::TcpListener::from_std(listener)?; let (shutdown, shutdown_receiver) = oneshot::channel(); - let app = router(source); + let app = router(source, auth); let task = tokio::spawn(async move { let _ = tokio::spawn(async move { let _ = axum::serve(listener, app) @@ -271,15 +284,6 @@ enum SharingState { } impl SharingState { - fn generation(&self) -> u64 { - match self { - SharingState::Starting { gen } - | SharingState::Running { gen, .. } - | SharingState::Waiting { gen, .. } => *gen, - _ => 0, - } - } - fn begin_start(&mut self, gen: u64) -> Result<(), OpdsStatusError> { match self { SharingState::Stopped | SharingState::Failed { .. } | SharingState::Waiting { .. } => { @@ -415,10 +419,13 @@ impl Default for ServiceDependencies { } } +mod credentials; + struct ServiceInner { state: Mutex, next_gen: std::sync::atomic::AtomicU64, source: Arc, + credentials: OpdsCredentialStore, dependencies: ServiceDependencies, } @@ -437,24 +444,59 @@ enum BindOutcome { } impl OpdsService { - pub fn new(source: Arc) -> Self { - Self::with_dependencies(source, ServiceDependencies::default()) + pub fn new( + source: Arc, + credential_path: std::path::PathBuf, + ) -> io::Result { + let credentials = OpdsCredentialStore::load(credential_path)?; + Ok(Self::with_dependencies_and_credentials( + source, + credentials, + ServiceDependencies::default(), + )) } + #[cfg(test)] fn with_dependencies( source: Arc, dependencies: ServiceDependencies, + ) -> Self { + Self::with_dependencies_and_credentials( + source, + OpdsCredentialStore::in_memory(), + dependencies, + ) + } + + fn with_dependencies_and_credentials( + source: Arc, + credentials: OpdsCredentialStore, + dependencies: ServiceDependencies, ) -> Self { Self { inner: Arc::new(ServiceInner { state: Mutex::new(SharingState::Stopped), - next_gen: std::sync::atomic::AtomicU64::new(0), + next_gen: AtomicU64::new(0), source, + credentials, dependencies, }), } } + /// Any credential change while sharing is not Stopped stops sharing: the + /// running listeners (and a Waiting poll) hold an auth built from the old + /// credentials, and stale passwords must not keep working. + async fn stop_if_active(&self) { + let listeners = { + let mut state = self.inner.state.lock().unwrap(); + state.stop() + }; + if let Some(listeners) = listeners { + listeners.drain().await; + } + } + pub async fn status(&self) -> OpdsServiceStatus { let library_id = active_library_id(self.inner.source.clone()); let mut status = OpdsServiceStatus::from(&*self.inner.state.lock().unwrap()); @@ -467,14 +509,6 @@ impl OpdsService { pub async fn start( &self, config: OpdsStartConfig, - ) -> Result { - self.start_with_policy(config, BindPolicy::default()).await - } - - pub(crate) async fn start_with_policy( - &self, - config: OpdsStartConfig, - policy: BindPolicy, ) -> Result { let port = u16::try_from(config.port) .ok() @@ -501,9 +535,37 @@ impl OpdsService { return Ok(self.status().await); } + let stored = if config.authentication_enabled { + let stored = self.inner.credentials.get(); + if stored.is_none() { + return Err(OpdsStatusError { + code: OpdsErrorCode::AuthRequired, + message: "Set a username and password to require them for sharing.".to_string(), + }); + } + stored + } else { + None + }; + let auth = match &stored { + Some(credentials) => OpdsBasicAuth::enabled(OpdsAuthCredentials { + username: credentials.username.clone(), + verifier: credentials.password_verifier.clone(), + }) + .map_err(|error| OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: error.to_string(), + })?, + None => OpdsBasicAuth::disabled(), + }; + // Credentials configured is the only thing that unlocks serving + // beyond the local network. + let policy = BindPolicy { + allow_global: stored.is_some(), + }; if matches!(config.target, OpdsBindTarget::AllInterfaces) && !policy.allow_global { // AllInterfaces serves every network the computer can reach; it - // exists to be paired with credentials (wired in by the auth PR). + // exists to be paired with credentials. return Err(OpdsStatusError { code: OpdsErrorCode::AuthRequired, message: "Sharing on all networks requires a username and password.".to_string(), @@ -522,6 +584,7 @@ impl OpdsService { &config.target, port, policy, + auth, ) .await; @@ -611,6 +674,7 @@ impl OpdsService { &config.target, port, BindPolicy::default(), + OpdsBasicAuth::disabled(), ) .await; @@ -666,6 +730,7 @@ async fn attempt_bind( target: &OpdsBindTarget, port: u16, policy: BindPolicy, + auth: OpdsBasicAuth, ) -> BindOutcome { let desired = match target { OpdsBindTarget::AllInterfaces => { @@ -698,7 +763,10 @@ async fn attempt_bind( let mut listeners = Listeners::new(); let mut first_failure = None; for address in desired { - match dependencies.listeners.start(address, source.clone()) { + match dependencies + .listeners + .start(address, source.clone(), auth.clone()) + { Ok(mut task) => { task.address = address; let completion = task.task.take().expect("fresh listener has a task"); @@ -787,6 +855,7 @@ mod tests { &self, address: SocketAddr, _source: Arc, + _auth: OpdsBasicAuth, ) -> io::Result { if let Some(kind) = *self.fail_every.lock().unwrap() { return Err(io::Error::from(kind)); @@ -907,6 +976,7 @@ mod tests { OpdsStartConfig { target: OpdsBindTarget::LocalNetworks, port, + authentication_enabled: false, } } @@ -981,7 +1051,8 @@ mod tests { 6, OpdsStartConfig { target: OpdsBindTarget::LocalNetworks, - port: 8080 + port: 8080, + authentication_enabled: false, }, Listeners::new(), Vec::new() @@ -995,7 +1066,8 @@ mod tests { 7, OpdsStartConfig { target: OpdsBindTarget::LocalNetworks, - port: 8080 + port: 8080, + authentication_enabled: false, }, Listeners::new(), Vec::new() @@ -1223,12 +1295,14 @@ mod tests { .start( SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port), missing_source(), + OpdsBasicAuth::disabled(), ) .expect("v4 wildcard bind"); let v6 = factory .start( SocketAddr::new(IpAddr::V6("::".parse().unwrap()), port), missing_source(), + OpdsBasicAuth::disabled(), ) .expect("v6 wildcard bind (V6ONLY must be set)"); @@ -1251,6 +1325,7 @@ mod tests { .start(OpdsStartConfig { target: OpdsBindTarget::AllInterfaces, port: 8080, + authentication_enabled: false, }) .await, Err(OpdsStatusError { @@ -1273,14 +1348,16 @@ mod tests { Duration::from_secs(1), ); + service + .configure_credentials("reader".to_string(), "correct-horse".to_string()) + .await + .unwrap(); let started = service - .start_with_policy( - OpdsStartConfig { - target: OpdsBindTarget::AllInterfaces, - port: 8080, - }, - BindPolicy { allow_global: true }, - ) + .start(OpdsStartConfig { + target: OpdsBindTarget::AllInterfaces, + port: 8080, + authentication_enabled: true, + }) .await .unwrap(); assert_eq!(started.state, OpdsLifecycleState::Running); @@ -1291,6 +1368,108 @@ mod tests { assert!(listeners.active.lock().unwrap().is_empty()); } + #[tokio::test(flavor = "multi_thread")] + async fn generating_credentials_while_running_stops_sharing() { + // Generate persists a new verifier immediately, which invalidates the + // password the running listeners accept - so the share must stop. + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(vec![lan( + OpdsInterfaceState::Up, + [192, 168, 1, 5], + )])); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces, + listeners.clone(), + Duration::from_secs(1), + ); + + service + .configure_credentials("reader".to_string(), "correct-horse".to_string()) + .await + .unwrap(); + service.start(config(8080)).await.unwrap(); + assert_eq!(service.status().await.state, OpdsLifecycleState::Running); + + let generated = service + .generate_credentials("reader".to_string()) + .await + .unwrap(); + assert!(!generated.password.is_empty()); + assert_eq!(service.status().await.state, OpdsLifecycleState::Stopped); + assert!(listeners.active.lock().unwrap().is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn credential_change_while_waiting_stops_the_poll() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(Vec::new())); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces.clone(), + listeners.clone(), + Duration::from_millis(10), + ); + + service + .configure_credentials("reader".to_string(), "correct-horse".to_string()) + .await + .unwrap(); + let started = service + .start(OpdsStartConfig { + target: OpdsBindTarget::LocalNetworks, + port: 8080, + authentication_enabled: true, + }) + .await + .unwrap(); + assert_eq!(started.state, OpdsLifecycleState::WaitingForInterface); + + // Reconfiguring while a Waiting poll holds a stale auth must stop it; + // otherwise the poll would bind with credentials just replaced. + service + .configure_credentials("other".to_string(), "battery-staple".to_string()) + .await + .unwrap(); + assert_eq!(service.status().await.state, OpdsLifecycleState::Stopped); + assert!(listeners.active.lock().unwrap().is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn clearing_credentials_while_gated_stops_sharing() { + let source = test_source(); + let interfaces = Arc::new(FakeInterfaces::new(Vec::new())); + let listeners = Arc::new(FakeListeners::default()); + let service = service_with( + source, + interfaces, + listeners.clone(), + Duration::from_secs(1), + ); + + service + .configure_credentials("reader".to_string(), "correct-horse".to_string()) + .await + .unwrap(); + let started = service + .start(OpdsStartConfig { + target: OpdsBindTarget::AllInterfaces, + port: 8080, + authentication_enabled: true, + }) + .await + .unwrap(); + assert_eq!(started.state, OpdsLifecycleState::Running); + assert_eq!(listeners.active.lock().unwrap().len(), 2); + + let status = service.clear_credentials().await.unwrap(); + assert!(!status.configured); + assert_eq!(service.status().await.state, OpdsLifecycleState::Stopped); + assert!(listeners.active.lock().unwrap().is_empty()); + } + #[tokio::test(flavor = "multi_thread")] async fn restarting_after_a_client_connection_releases_the_port() { let probe = StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); @@ -1300,7 +1479,9 @@ mod tests { let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let factory = TcpListenerFactory; let source = missing_source(); - let mut first = factory.start(address, source.clone()).unwrap(); + let mut first = factory + .start(address, source.clone(), OpdsBasicAuth::disabled()) + .unwrap(); // A client connects and the SERVER closes first: this port now has a // TIME_WAIT-eligible connection on the server side. let client = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); @@ -1310,7 +1491,7 @@ mod tests { // Immediate rebind must succeed (SO_REUSEADDR on the socket2 path). let second = factory - .start(address, source) + .start(address, source, OpdsBasicAuth::disabled()) .expect("rebind after server-side close must not hit TIME_WAIT"); drop(second); } diff --git a/crates/citadel-opds/src/words.rs b/crates/citadel-opds/src/words.rs new file mode 100644 index 00000000..8a540951 --- /dev/null +++ b/crates/citadel-opds/src/words.rs @@ -0,0 +1,7 @@ +//! The generated-password word pool. Common, clean, short; no doubled +//! glyphs inside a word (the generator rejects adjacent duplicates anyway) +//! and nothing that reads as confusable glyphs in lowercase. +//! +//! VETTED 2026-09-21 - re-vet this list by eye on every addition. + +pub(crate) const WORD_POOL: &str = r"cat dog sun map oak ink elm fox jar key rug hat pen box cup mug log nut owl arm art bag bat bed bow bug bus bun cap car cob cog cot cow den dig dim dip dot dry duo ear elf elk end era fan fat fig fin fir fit fly fog gem gin gum gun gut guy hen her hid him hip his hit hob hoe hog hop hot hub hue hug hut ice ilk imp ion irk its ivy jam jaw jet jig job jot jug keg kid kin kit lab lad lag lap law lay leg let lid lie lip lit lob lot low mad man mar mat maw men met mid mix mob mop mud nab nap net new nil nip nod nor not now oar ode oil old one opt orb ore our out owe pad pan par pat paw pay pea peg per pet pew pie pig pin pit ply pod pot pro pub pug pun put rag ram ran rap rat raw red rib rid rig rim rip rob rod roe rot row rub rue rum run rye sad sag sap sat saw sax sea set sew she sin sip sir sit six ski sky sly sob sod son sow spa spy sum tab tag tan tap tar tax tea ten thy tie tin tip toe ton top tow toy try tub tug two urn use van vat vet vex via vie vow wad wag war was wax web wed wet who why wig win wit woe wok won yak yam yap yaw yes yet yew bark bath beam bean bear bird boat bold bolt bone burn bush cage cake calf camp cape card cart cave clay clip coal coat code coin cold colt comb cord core corn cost cove crab crew crow cube cure dart dawn dear desk dice diet dime dine dish dock doze drag draw drip drop drum duck dune dust each earn east easy echo exit face fact fade farm fast fate fawn felt fern file film find fine fire firm fish fist five flag flat fled flip flow flux foam fold folk fond fork form fort four fowl frog from fuel fund gain gale game gate gaze gear gift give glad glow glue goat gold golf gulf hail hair half hand hang hard hare harm hawk heal heap hear heat herb herd hero hide hike hint hire hive hold hole home horn hose host hour huge jump lake lamb lamp land lane last lawn lazy leaf lean leap left lend lens lift like lime line link lion list load loaf loan lock loft long lord loud luck lung made mail main make male many mask mast mate maze meal mean meat melt mend mesh mild mile milk mint mist mite moat mock mold mole monk more moth move mule must myth nail name navy near neat neck nest news next nice node norm nose note oath obey once only open oral oven oval pace pack page paid pain pair pale palm pane park part past path peak pear pest pick pier pile pine pink plan play plea plot plow plug plum plus poem poet pole pond pony pork port pose post pour pray prey pure push quiz race rack raft rage raid rail rain rake ramp rank rate rave read real rent rest rice rich ride rift rile rime rise risk road rock rode role rope rose rosy ruby ruin rule rung rush rust sack safe sage said sail sake sale salt sand sane save scan scar seal seam seat sect self send ship shoe shop shot show shut side sigh silk sing sink site size skin skip slam slab slap sled slid slim slip slot slow snap snow soap soar sock soda sofa soft soil sold sole some song sore sort soul soup sour spin spot spur star stay stem step stir stop stow stub stud such suit sung sunk sure surf swam swan swap swim tail take tale talk tame tank tape task teal team tear tend term than thaw them then they thin this thud thug tick tide tidy tile time tiny tire toad toil told tomb tone tore torn tour town tram trap tray trim trio trip true tuba tuna turf turn tusk twig twin type ugly unit upon urge vain vane vase vast veil vein vent verb very vest veto vial vibe view vine visa void volt vote wade waft wage waif wail wait wake walk wand wane want ward ware warm warn warp wart wary wash wasp wave wavy waxy weak wear weld went wept west what wheat when whim whip whom wide wife wild wilt wind wine wing wink wipe wire wise wish with wits woke wolf word wore work worm worn wove wrap wren yard yarn year your zeal zero zinc zone"; diff --git a/docs/adr/0002-interface-scoped-opds-listener.md b/docs/adr/0002-interface-scoped-opds-listener.md index 4309d4b9..8ae16e69 100644 --- a/docs/adr/0002-interface-scoped-opds-listener.md +++ b/docs/adr/0002-interface-scoped-opds-listener.md @@ -1,6 +1,6 @@ # 2. Bind OPDS listeners to selected interfaces -Status: Accepted - 2026-07-31 +Status: Superseded by 0004 - 2026-09-21 (interface selection and the reconcile loop are replaced; exact-address binding and Basic authentication are retained there). ## Context diff --git a/docs/adr/0003-opds-credentials-are-machine-local.md b/docs/adr/0003-opds-credentials-are-machine-local.md new file mode 100644 index 00000000..ad9b971b --- /dev/null +++ b/docs/adr/0003-opds-credentials-are-machine-local.md @@ -0,0 +1,34 @@ +# 3. OPDS credentials are machine-local + +Status: Accepted - 2026-09-21 + +## Context + +The OPDS sharing server supports optional HTTP Basic authentication. +A credential set is a username and an Argon2 password verifier. +The plaintext password is never stored. +When credentials exist, sharing can reach beyond the local network. + +Citadel libraries are portable. +Users copy them between machines, keep them in synced folders, and include them in backups. +Credentials are secrets. + +## Decision + +Credentials live in the application data directory, not in or next to any library. +There is one credential set per Citadel installation. +It applies to whichever library is open. + +Moving, copying, syncing, or backing up a library never moves credentials. +A leaked library archive contains no credential material. +A leaked credential file contains a verifier, not a password. + +Installing Citadel on a new machine starts with no credentials. +The user sets them again if they want auth there. +Windows cannot enforce Unix file modes; per-user ACLs on the app-data directory are the equivalent. + +## Consequences + +One server per application means one credential set per machine, even across libraries. +Readers configured with the username and password keep working across library switches. +They stop working on a fresh install until credentials are set again. diff --git a/docs/adr/0004-two-mode-sharing-state-machine.md b/docs/adr/0004-two-mode-sharing-state-machine.md new file mode 100644 index 00000000..9e943554 --- /dev/null +++ b/docs/adr/0004-two-mode-sharing-state-machine.md @@ -0,0 +1,144 @@ +# 4. The sharing service has two modes and an explicit state machine + +Status: Accepted - 2026-09-21 + +Supersedes 0002. See "Relationship to 0002" below. + +## Context + +ADR 0002 decided the OPDS listener would bind exact addresses on +user-selected interfaces, with a reconcile loop to keep those binds current. +The design assumed a picker of network interfaces. + +The picker does not survive contact with a real machine. +A developer workstation exposes around 65 interfaces: +Docker and Colima bridges, Tunnelscale and VPN adapters, AWDL, loopback +variants, and the actual LAN. +Choosing one of sixty-five is not a decision a reader app should ask for. + +Sharing is a mode choice, not a network configuration task. +The user decides between "my local network" and "everywhere". +Calibre is the mental-model reference for that simplicity. +It is not a posture reference: Calibre binds a wildcard with no gate. + +The threat model is plain. +On the local network: other devices and guests on the same LAN. +On the host: containers and virtual machines. +Through a VPN: that network's peers. +Through global IPv6 or a public address: anyone the router or firewall lets +through. +Basic authentication is the only tool readers support. +OPDS readers speak plaintext Basic; they do not trust self-signed TLS and +ACME does not exist on a LAN. + +The reconcile loop cost more than it bought. +It was around four hundred lines of time-based code - a failure ledger, +backoff, and per-address reconciliation - and it produced the feature's only +deadlock and its only stale-event race. +Recovery for a changed network is one user action: turn sharing off and on. + +## Decision + +The service has two modes. + +`LocalNetworks` binds exact addresses: interfaces classified as LAN and up, +carrying private (RFC 1918) or unique-local IPv6 addresses. +Globally routable addresses are excluded. +It never binds Docker bridges, VPN adapters, or wildcards. + +`AllInterfaces` binds two wildcard sockets, `0.0.0.0` and `::`, with +`IPV6_ONLY` set on the v6 socket. +It serves every network the computer can reach, and it requires +credentials. + +Credentials gate the mode, not a checkbox. +`AllInterfaces` starts only when a credential set exists. +Clearing credentials while sharing is active stops sharing. +ADR 0003 records where credentials live. + +The service is an explicit state machine. +The states are Stopped, Starting, Running, Waiting, and Failed. +Each variant owns its resources; transitions are the only code that changes +state; a generation counter dismisses events from a superseded run. +The mutex is never held across an await. + +Interfaces are enumerated once per start attempt. +`LocalNetworks` that find no usable address enter `Waiting`, and a small poll +retries while - and only while - the service is Waiting. +There is no reconcile loop. +If the network changes under a running share, the user turns sharing off and +on. + +There is no interface picker. +Per-interface binding is not a feature of the desktop application. + +## Alternatives considered + +- A filtered, grouped interface picker (LAN first, VPN and virtual secondary): + still asks the user a networking question the product does not want to ask. +- Wildcard by default, gated on credentials alone: exposes containers, + tailnet peers, and global addresses even in the default posture. +- Keeping the reconcile loop: the complexity budget went to the state + machine; the loop fixed a case that is one toggle away from self-service. +- Reacting to OS network-change events: no cross-platform event source in + Tauri; three platform bindings for one deferred case. +- Keeping `Interface{id}` in the persisted settings: dead configuration for a + deleted picker; new installs fall back to `LocalNetworks`. +- mDNS/Bonjour advertisement: no OPDS standard exists for it, and it adds a + macOS entitlement. +- TLS: self-signed certificates are untrusted on readers and ACME needs a + public name. Basic over plaintext with a generated password is the ceiling. +- Digest authentication: reader support is poor. +- Typestate encoding of the state machine: consuming transitions do not + compose with shared, async, externally driven state. + +## Consequences + +After sleep or wake with a new address, a Running share binds a stale +address. +The socket does not die, so nothing detects it; connections fail and the +status still says Running until the user re-toggles. +This is the accepted cost of enumerate-once. +A periodic compare-while-Running is the planned mitigation and is deferred. + +The macOS firewall prompts once for the app. +If the user denies it, every bind fails with a "port unavailable" message +that does not mention the firewall. +Known copy gap. + +On Windows, ports reserved by Hyper-V and WSL fail with a permission error. +It is reported with the generic port message. +Known copy gap. + +Bridged LAN ports (`br0`-style) are classified as unknown and are not served +by `LocalNetworks`. +Their path is `AllInterfaces` with credentials. +"Share only on my tailnet" is not expressible. +Both are accepted for v1. + +Under `AllInterfaces`, containers and virtual machines on the host can reach +the library, behind the password. +This is stated plainly, not hidden. + +One credential set per installation is stored as an Argon2 verifier in the +application data directory (ADR 0003). +It is not portable with the library and no keychain is used. + +Transitions are pure and table-tested; network behaviour sits behind two +trait seams (interface snapshots and listener creation). + +This decision reopens if real users demand per-interface binding, a +cross-platform network-change event appears, readers gain TLS trust, or +OPDS 2.0 changes discovery. + +## Relationship to 0002 + +Retained: exact-address binding for local networks, interface +classification, the no-wildcard default for local sharing, and Basic +authentication. + +Replaced: interface selection, the persisted interface identity, the +reconcile loop, and "the user selects one network interface". + +Added: the `AllInterfaces` mode, the credentials-derived global gate, and +the state machine. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 068358cf..6b35ff9a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -71,6 +71,10 @@ fn run_tauri_backend() -> std::io::Result<()> { app_updates::clb_cmd_check_for_updates, app_updates::clb_cmd_install_update_if_available, // OPDS sharing commands + opds::commands::clb_query_opds_credential_status, + opds::commands::clb_cmd_configure_opds_credentials, + opds::commands::clb_cmd_generate_opds_credentials, + opds::commands::clb_cmd_clear_opds_credentials, opds::commands::clb_cmd_start_opds, opds::commands::clb_cmd_stop_opds, opds::commands::clb_query_opds_status, @@ -97,17 +101,24 @@ fn run_tauri_backend() -> std::io::Result<()> { } let state = state::CitadelState::new(); - let opds_service = citadel_opds::OpdsService::new(Arc::new(state.clone())); + let opds_state = state.clone(); let app = tauri_builder .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(state) - .manage(opds_service) .invoke_handler(builder.invoke_handler()) .plugin(tauri_plugin_store::Builder::new().build()) .setup(move |app| { builder.mount_events(app); + // Sharing lives here so the credential store can live under the + // user's app-data directory next to the rest of Citadel's state. + let credential_path = app.path().app_data_dir()?.join("opds-credentials.json"); + app.manage(citadel_opds::OpdsService::new( + Arc::new(opds_state.clone()), + credential_path, + )?); + // Native macOS menu bar: app menu with Settings…, File > Add // Book…, and the standard Edit/View/Window items. #[cfg(target_os = "macos")] diff --git a/src-tauri/src/opds/commands.rs b/src-tauri/src/opds/commands.rs index 4e41e9a5..1dc78588 100644 --- a/src-tauri/src/opds/commands.rs +++ b/src-tauri/src/opds/commands.rs @@ -1,8 +1,45 @@ use citadel_opds::{ + credential_store::OpdsCredentialStatus, + password::GeneratedOpdsCredentials, service::{OpdsServiceStatus, OpdsStartConfig, OpdsStatusError}, OpdsService, }; +#[tauri::command] +#[specta::specta] +pub async fn clb_query_opds_credential_status( + service: tauri::State<'_, OpdsService>, +) -> Result { + Ok(service.credential_status()) +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_configure_opds_credentials( + service: tauri::State<'_, OpdsService>, + username: String, + password: String, +) -> Result { + service.configure_credentials(username, password).await +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_generate_opds_credentials( + service: tauri::State<'_, OpdsService>, + username: String, +) -> Result { + service.generate_credentials(username).await +} + +#[tauri::command] +#[specta::specta] +pub async fn clb_cmd_clear_opds_credentials( + service: tauri::State<'_, OpdsService>, +) -> Result { + service.clear_credentials().await +} + #[tauri::command] #[specta::specta] pub async fn clb_cmd_start_opds( diff --git a/src/bindings.ts b/src/bindings.ts index 60fa49a2..14480072 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -289,6 +289,38 @@ async clbCmdInstallUpdateIfAvailable() : Promise> { else return { status: "error", error: e as any }; } }, +async clbQueryOpdsCredentialStatus() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_query_opds_credential_status") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdConfigureOpdsCredentials(username: string, password: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_configure_opds_credentials", { username, password }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdGenerateOpdsCredentials(username: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_generate_opds_credentials", { username }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async clbCmdClearOpdsCredentials() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("clb_cmd_clear_opds_credentials") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async clbCmdStartOpds(config: OpdsStartConfig) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("clb_cmd_start_opds", { config }) }; @@ -515,10 +547,12 @@ export type LocalOrRemoteUrl = { kind: LocalOrRemote; url: string; local_path: s export type MetadataProvider = "hardcover" | "loc" | "dnb" | "k10plus" | "openlibrary" export type NewAuthor = { name: string; sortable_name: string | null } export type OpdsBindTarget = { type: "localNetworks" } | { type: "allInterfaces" } -export type OpdsErrorCode = "invalidPort" | "libraryNotReady" | "configurationConflict" | "interfaceUnavailable" | "interfaceEnumerationFailed" | "portUnavailable" | "listenerFailed" | "unexpected" +export type OpdsErrorCode = "invalidPort" | "libraryNotReady" | "authRequired" | "configurationConflict" | "interfaceUnavailable" | "portUnavailable" | "listenerFailed" | "unexpected" export type OpdsLifecycleState = "stopped" | "starting" | "running" | "waitingForInterface" | "error" export type OpdsServiceStatus = { state: OpdsLifecycleState; activeLibraryId: string | null; urls: string[]; error: OpdsStatusError | null } -export type OpdsStartConfig = { target: OpdsBindTarget; port: number } +export type OpdsStartConfig = { target: OpdsBindTarget; port: number; authenticationEnabled: boolean } +export type GeneratedOpdsCredentials = { username: string; password: string } +export type OpdsCredentialStatus = { configured: boolean; username: string | null } export type OpdsStatusError = { code: OpdsErrorCode; message: string } export type ProviderStatus = { provider: MetadataProvider; is_valid: boolean; message: string } export type RemoteFile = { url: string }