Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ resolver = "2"
members = [
"crates/citadel-core",
"crates/citadel-opds",
"crates/citadel-server",
"crates/libcalibre",
"src-tauri",
]
Expand Down
53 changes: 48 additions & 5 deletions crates/citadel-opds/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//!
//! The credential snapshot is hot-swappable: listeners read it per request,
//! so rotating or replacing credentials never interrupts a running share
//! (see ADR 0005 for why the secret is stored reversibly). Consecutive
//! (desktop persistence supports reveal; external hosts can supply ephemeral users). Consecutive
//! rejections back off exponentially, which is the online-guessing defense.

use std::{
Expand Down Expand Up @@ -80,7 +80,7 @@ pub struct OpdsBasicAuth {

struct AuthInner {
required: AtomicBool,
credentials: Mutex<Option<Arc<OpdsAuthCredentials>>>,
credentials: Mutex<Option<Arc<Vec<OpdsAuthCredentials>>>>,
backoff: Mutex<Backoff>,
}

Expand Down Expand Up @@ -112,8 +112,17 @@ impl OpdsBasicAuth {
self.inner.required.load(Ordering::Relaxed)
}

#[cfg(test)]
pub(crate) fn swap_credentials(&self, credentials: Option<OpdsAuthCredentials>) {
*self.inner.credentials.lock().unwrap() = credentials.map(Arc::new);
self.swap_credential_list(credentials.into_iter().collect());
}

pub(crate) fn swap_credential_list(&self, credentials: Vec<OpdsAuthCredentials>) {
*self.inner.credentials.lock().unwrap() = if credentials.is_empty() {
None
} else {
Some(Arc::new(credentials))
};
}

fn authorize(&self, authorization: Option<&[u8]>) -> AuthOutcome {
Expand All @@ -130,8 +139,13 @@ impl OpdsBasicAuth {

let credentials = self.inner.credentials.lock().unwrap().clone();
let parsed = authorization.and_then(parse_basic_credentials);
let authorized = parsed
.is_some_and(|provided| credentials.is_some_and(|stored| stored.matches(&provided)));
let authorized = parsed.is_some_and(|provided| {
credentials.is_some_and(|stored| {
stored
.iter()
.fold(false, |matched, user| matched | user.matches(&provided))
})
});
{
let mut backoff = self.inner.backoff.lock().unwrap();
if authorized {
Expand Down Expand Up @@ -429,4 +443,33 @@ mod tests {
"Basic realm=\"Citadel\", charset=\"UTF-8\""
);
}
#[test]
fn multiple_users_have_independent_passwords() {
let auth = OpdsBasicAuth::new();
auth.set_required(true);
auth.swap_credential_list(vec![
OpdsAuthCredentials {
username: "alice".into(),
password: "one".into(),
},
OpdsAuthCredentials {
username: "bob".into(),
password: "two:three".into(),
},
]);
for (user, password) in [("alice", "one"), ("bob", "two:three")] {
assert!(matches!(
auth.authorize(Some(&basic_header(user, password.as_bytes()))),
AuthOutcome::Authorized
));
}
assert!(matches!(
auth.authorize(Some(&basic_header("alice", b"two:three"))),
AuthOutcome::Rejected
));
assert!(matches!(
auth.authorize(Some(&basic_header("bob", b"two:three"))),
AuthOutcome::Authorized
));
}
}
36 changes: 24 additions & 12 deletions crates/citadel-opds/src/credential_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub(crate) struct OpdsCredentialStore {

struct CredentialStoreInner {
path: Option<PathBuf>,
credentials: RwLock<Option<StoredOpdsCredentials>>,
credentials: RwLock<Vec<StoredOpdsCredentials>>,
}

impl OpdsCredentialStore {
Expand All @@ -74,7 +74,7 @@ impl OpdsCredentialStore {
Ok(Self {
inner: Arc::new(CredentialStoreInner {
path: Some(path),
credentials: RwLock::new(credentials),
credentials: RwLock::new(credentials.into_iter().collect()),
}),
})
}
Expand All @@ -84,41 +84,53 @@ impl OpdsCredentialStore {
Self {
inner: Arc::new(CredentialStoreInner {
path: None,
credentials: RwLock::new(None),
credentials: RwLock::new(Vec::new()),
}),
}
}

fn read(&self) -> std::sync::RwLockReadGuard<'_, Option<StoredOpdsCredentials>> {
fn read(&self) -> std::sync::RwLockReadGuard<'_, Vec<StoredOpdsCredentials>> {
self.inner
.credentials
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

fn write(&self) -> std::sync::RwLockWriteGuard<'_, Option<StoredOpdsCredentials>> {
fn write(&self) -> std::sync::RwLockWriteGuard<'_, Vec<StoredOpdsCredentials>> {
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.
/// Desktop stores mirror disk; ephemeral host stores stay in memory.
pub fn status(&self) -> OpdsCredentialStatus {
let credentials = self.read();
OpdsCredentialStatus {
configured: credentials.is_some(),
configured: !credentials.is_empty(),
username: credentials
.as_ref()
.first()
.map(|credentials| credentials.username.clone()),
}
}

pub fn get(&self) -> Option<StoredOpdsCredentials> {
pub fn all(&self) -> Vec<StoredOpdsCredentials> {
self.read().clone()
}

pub fn ephemeral(credentials: Vec<StoredOpdsCredentials>) -> Self {
Self {
inner: Arc::new(CredentialStoreInner {
path: None,
credentials: RwLock::new(credentials),
}),
}
}

pub fn get(&self) -> Option<StoredOpdsCredentials> {
self.read().first().cloned()
}

pub fn secret(&self) -> Option<OpdsCredentialSecret> {
self.get().map(|credentials| OpdsCredentialSecret {
username: credentials.username,
Expand All @@ -130,7 +142,7 @@ impl OpdsCredentialStore {
if let Some(path) = &self.inner.path {
persist(path, &credentials)?;
}
*self.write() = Some(credentials);
*self.write() = vec![credentials];
Ok(())
}

Expand All @@ -142,7 +154,7 @@ impl OpdsCredentialStore {
Err(error) => return Err(error),
}
}
*self.write() = None;
self.write().clear();
Ok(())
}
}
Expand Down
85 changes: 85 additions & 0 deletions crates/citadel-opds/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ impl WaitingReason {
pub enum OpdsBindTarget {
LocalNetworks,
AllInterfaces,
Addresses { addresses: Vec<String> },
}

#[derive(Clone, Debug, Eq, PartialEq)]
Expand All @@ -309,6 +310,15 @@ pub(crate) fn plan_bindings(
policy: BindPolicy,
) -> BindPlan {
match target {
OpdsBindTarget::Addresses { addresses } => match explicit_addresses(addresses, policy) {
Ok(addresses) => BindPlan::Listen(
addresses
.into_iter()
.map(|address| SocketAddr::new(address, port))
.collect(),
),
Err(_) => BindPlan::Wait(WaitingReason::NoLocalInterface),
},
OpdsBindTarget::AllInterfaces => {
let mut sockets = BTreeSet::new();
sockets.insert(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port));
Expand Down Expand Up @@ -634,3 +644,78 @@ mod tests {
}
}
}

/// Validate explicit hosts with the same exposure policy as desktop sharing.
pub fn validate_explicit_addresses(
addresses: &[String],
authenticated: bool,
) -> Result<(), String> {
explicit_addresses(
addresses,
BindPolicy {
allow_global: authenticated,
},
)
.map(|_| ())
}

fn explicit_addresses(addresses: &[String], policy: BindPolicy) -> Result<Vec<IpAddr>, String> {
if addresses.is_empty() {
return Err("At least one listener address is required.".into());
}
addresses
.iter()
.map(|address| {
let address: IpAddr = address
.parse()
.map_err(|_| "Invalid listener address.".to_string())?;
let scope = match address {
IpAddr::V4(a) => ipv4_scope(a),
IpAddr::V6(a) => ipv6_scope(a),
};
match scope {
AddressScope::Loopback | AddressScope::Private | AddressScope::Shared => {
Ok(address)
}
AddressScope::Global if policy.allow_global => Ok(address),
AddressScope::Global => {
Err("Public listener addresses require reader sign-in.".into())
}
_ => Err(
"Wildcard, link-local and non-unicast listener addresses are not allowed."
.into(),
),
}
})
.collect()
}

#[cfg(test)]
mod explicit_tests {
use super::*;
#[test]
fn explicit_hosts_enforce_exposure_policy() {
for address in [
"0.0.0.0",
"::",
"fe80::1",
"169.254.1.1",
"224.0.0.1",
"bad",
] {
assert!(
validate_explicit_addresses(&[address.into()], true).is_err(),
"{address}"
);
}
assert!(validate_explicit_addresses(&[], true).is_err());
for address in ["127.0.0.1", "::1", "192.168.1.2", "fd00::1"] {
assert!(
validate_explicit_addresses(&[address.into()], false).is_ok(),
"{address}"
);
}
assert!(validate_explicit_addresses(&["8.8.8.8".into()], false).is_err());
assert!(validate_explicit_addresses(&["8.8.8.8".into()], true).is_ok());
}
}
Loading
Loading