Gate the gRPC surface behind basic-auth roles

crabidy-server.toml gains an [auth] section with one argon2 PHC hash
per role: owner (everything), queue-owner (queue and playback, no
library writes), queue-appender (browse, search, and Append only).
Enforcement is a single fail-closed tower layer in front of the tonic
service — unknown methods require owner, a malformed config aborts
startup, and a missing one keeps the server open as before. Successful
credentials are cached so argon2 runs once, failures re-verify at full
cost and stay indistinguishable. crabidy-server hash-password turns a
stdin password into the config hash; cbd-tui sends the header from new
user/password options.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-21 20:47:06 +02:00
parent 1c83217745
commit 4e59e50943
16 changed files with 1139 additions and 15 deletions

48
Cargo.lock generated
View File

@ -134,6 +134,18 @@ dependencies = [
"num-traits",
]
[[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",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@ -393,6 +405,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.5.3"
@ -435,6 +453,15 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[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"
@ -547,6 +574,7 @@ dependencies = [
name = "cbd-tui"
version = "0.1.0"
dependencies = [
"base64",
"crabidy-core",
"crossterm",
"dirs",
@ -854,13 +882,17 @@ name = "crabidy-server"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"async-trait",
"audio-player",
"base64",
"clap",
"crabidy-core",
"dirs",
"flume",
"fsdy",
"futures",
"http",
"rand 0.10.2",
"reqwest 0.13.1",
"serde",
@ -871,6 +903,7 @@ dependencies = [
"tokio-stream",
"toml",
"tonic",
"tower",
"tracing",
"tracing-appender",
"tracing-subscriber",
@ -1054,6 +1087,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -2641,6 +2675,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[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 = "percent-encoding"
version = "2.3.2"
@ -3063,6 +3108,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"

View File

@ -17,6 +17,7 @@ edition = "2021"
[workspace.dependencies]
anyhow = "1"
argon2 = { version = "0.5", features = ["std"] }
async-trait = "0.1"
base64 = "0.22"
bytes = "1"
@ -27,6 +28,7 @@ crossterm = "0.29"
dirs = "6"
flume = "0.12"
futures = "0.3"
http = "1"
notify-rust = "4"
percent-encoding = "2"
prost = "0.14"
@ -65,6 +67,7 @@ toml = "1"
tonic = "0.14"
tonic-prost = "0.14"
tonic-prost-build = "0.14"
tower = "0.5"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@ -45,11 +45,12 @@ mount. Files are created/rewritten on first start with their defaults
filled in.
| File | Component | Documentation |
| -------------- | --------- | -------------------------------------- |
| -------------------- | --------- | -------------------------------------- |
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) |
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) |
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) |
| `cbd-tui.toml` | TUI / cbd | below |
| `crabidy-server.toml`| server | below (never auto-created) |
The server-managed folders (`queues/`, `bookmarks/`, `captures/`) also
live in `~/.config/crabidy/`; they need no configuration and hold plain
@ -63,11 +64,45 @@ Configuration of the TUI (and the TUI half of `cbd`):
```toml
# Where to find the server. Default shown.
address = "http://127.0.0.1:50051"
# Credentials, when the server has [auth] configured (see below).
# `user` is the role name; leave both empty against an open server.
# The password is stored in plaintext — keep this file private.
user = ""
password = ""
```
Every option is also available as a command-line flag
(`cbd-tui --address ...`).
### `crabidy-server.toml` — roles and rights
By default the server is open: everyone who can reach the port has
full control. Adding an `[auth]` section turns on HTTP basic auth for
every RPC and hands out *roles* (see `architecture/roles-auth.md`):
- **owner** — everything (the normal user).
- **queue-owner** — anything on the queue and playback, but no
library writes: no bookmarks (`w`), captures (`W`), queue saving,
renames or deletes.
- **queue-appender** — may browse/search and append tracks to the
queue; nothing else.
```toml
[auth]
# One PHC hash per role; omit a role to disable it. Generate with:
# crabidy-server hash-password (reads the password from stdin)
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$..."
queue_appender = "$argon2id$..."
```
Clients authenticate with the role name as the basic-auth user (see
`cbd-tui.toml` above). A malformed `crabidy-server.toml` aborts server
startup rather than silently running open. Note that the transport is
plain HTTP/2: fine on a trusted home network, but anything exposed
further needs TLS termination (reverse proxy, VPN) in front.
## Using the library
Navigation is vim-style: `j`/`k` select, `l` enters the selected

154
architecture/roles-auth.md Normal file
View File

@ -0,0 +1,154 @@
# Roles and rights (basic-auth authorization)
crabidy listens on `0.0.0.0:50051`: anyone on the network can control
playback and — worse — rename or delete library stores. The owner wants
to hand out *limited* remotes: a co-host who may run the queue but not
touch the library, and guests who may only add tracks.
## Problem statement
Three roles, credentialed by password hashes in the server config:
- **owner** — the normal user; everything.
- **queue-owner** — anything on the queue (and playback), but no
library writes: no `w`, no `W`.
- **queue-appender** — may only add tracks to the end of the queue;
no removal, no reordering, no queue settings.
## Assumptions (confirmed by the request, or decided here)
- Transport stays plain HTTP/2 gRPC. Basic auth over cleartext is
acceptable on a trusted home network; anything else (Internet
exposure) needs TLS termination in front (reverse proxy, VPN) and is
out of scope. The README says so.
- No auth configured (no `[auth]` section, or no hashes in it) means
the server behaves exactly as before: open, everyone is owner. Auth
switches on as soon as **any** role hash is configured; from then on
every RPC requires credentials.
- One password per role, not per person. The Basic-auth *username*
selects the role (`owner`, `queue-owner`, `queue-appender`), the
password is verified against that role's hash.
## Options considered
### Where to enforce
1. **Per-handler checks** inside `RpcService` (tonic interceptor
authenticates, each of the ~25 handlers calls
`require(role)?`). Idiomatic tonic, but *fail-open*: a future RPC
that forgets the line is unprotected.
2. **One tower layer** in front of the tonic service, mapping the
gRPC method path to a minimum role, *default-deny* (unknown method
⇒ owner only). Fail-closed, single enforcement point, zero handler
churn; costs a small amount of manual HTTP plumbing for the
deny response (gRPC trailers-only response).
**Decision: option 2.** Authorization is a security boundary; new
RPCs must start locked. A unit test pins the method table against the
proto service definition so an unmapped addition fails loudly.
### Hash scheme
PHC-format strings verified with the pure-Rust `argon2` crate
(RustCrypto). Argon2id is the default the crate generates; any PHC
variant the crate parses is accepted. bcrypt/scrypt support is not
worth a second dependency. To keep users out of hash-tooling misery,
`crabidy-server hash-password` reads a password on stdin and prints
the PHC string to paste into the config.
### Verification cost
Argon2 verification is deliberately slow (tens of ms); per-keypress
RPCs cannot re-verify. Successful credentials are cached in memory
(`authorization` header value → role). Only *successful* verifications
are cached, so the cache is bounded by the number of valid credentials
(≤ 3); failures pay the full argon2 cost every time, which doubles as
throttling.
## The rights matrix
Minimum role per RPC; higher roles include lower ones
(owner ⊃ queue-owner ⊃ queue-appender):
- **queue-appender** (and up): `Init`, `GetLibraryNode`,
`GetUpdateStream` (reads), `Append` (the one queue write), and
`CreateLibraryNode` — creatable nodes are exactly the search terms,
and guests must be able to search for what they append. (Search
terms do persist in the owner's provider config; accepted — they are
the mechanism of finding tracks, not library data.)
- **queue-owner** (and up): every other queue and playback verb —
`Queue`, `Replace`, `Remove`, `Insert`, `ClearQueue`, `SetCurrent`,
`ToggleShuffle`, `ToggleRepeat`, `TogglePlay`, `Stop`, `Next`,
`Prev`, `RestartTrack`, `ChangeVolume`, `ToggleMute`.
- **owner** only: the library writes — `CaptureLibraryNode` (`w`/`W`),
`SaveQueue` (writes `/queues`), `RenameLibraryNode`,
`DeleteLibraryNode` (these two also cover search terms; a
queue-owner can create terms but not rename/delete them — the
server cannot cheaply tell a term from a bookmark store at this
layer, so renames/deletes stay owner-only, fail-closed).
- **unknown / future methods**: owner only.
Denied requests get `PERMISSION_DENIED`; missing or wrong credentials
get `UNAUTHENTICATED`. Credentials are never logged (hard rule:
secrets redacted).
## Configuration
`~/.config/crabidy/crabidy-server.toml` (new, read by the server —
both standalone and inside `cbd`; absent file = auth off):
```toml
[auth]
# One PHC hash per role; omit a role to disable it.
# Generate with: crabidy-server hash-password
owner = "$argon2id$v=19$m=19456,t=2,p=1$..."
queue_owner = "$argon2id$v=19$..."
queue_appender = "$argon2id$v=19$..."
```
`~/.config/crabidy/cbd-tui.toml` (client side, also as CLI flags):
```toml
address = "http://127.0.0.1:50051"
# Sent as HTTP basic auth when set. `user` is the role name.
user = "queue-owner"
password = "plaintext"
```
The client config holds a *plaintext* password (client credentials
always are); the README tells users to keep the file private. The TUI
attaches `authorization: Basic …` to every request through a tonic
interceptor; without configured credentials it sends no header, which
keeps today's zero-config local setup working against an open server.
## Structure
```d2
direction: right
tui: cbd-tui {
cfg: "cbd-tui.toml user/password"
interceptor: "auth interceptor\n(adds Basic header)"
cfg -> interceptor
}
server: crabidy-server {
layer: "AuthLayer (tower)\nheader → role → method check"
rpc: RpcService
cache: "verified-creds cache"
cfg: "crabidy-server.toml [auth] hashes"
cfg -> layer
layer -> cache: hit = skip argon2
layer -> rpc: authorized
}
tui.interceptor -> server.layer: every RPC
server.layer -> tui: "UNAUTHENTICATED /\nPERMISSION_DENIED"
```
## Risks and open questions
- **Denied-action UX**: the TUI currently logs RPC errors; an
appender pressing a forbidden key sees a silent no-op (the log has
the denial). A status-line hint is deferred until the flow has been
felt in practice.
- **Cleartext transport**: documented; TLS stays out of scope.
- The update stream is authorized once at subscription; the stream
itself only carries state every role may read.

View File

@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true
[dependencies]
base64.workspace = true
crabidy-core.workspace = true
crossterm.workspace = true
dirs.workspace = true

View File

@ -14,4 +14,17 @@ pub struct ServerConfig {
#[default("http://127.0.0.1:50051".to_string())]
#[clap(short, long)]
pub address: String,
/// Role to authenticate as: "owner", "queue-owner" or
/// "queue-appender" (architecture/roles-auth.md). Leave empty
/// against a server without configured auth.
#[default(String::new())]
#[clap(short, long)]
pub user: String,
/// Password for the role. Stored in plaintext — keep the config
/// file private. Never logged.
#[default(String::new())]
#[clap(short, long)]
pub password: String,
}

View File

@ -55,7 +55,7 @@ async fn orchestrate(
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
) -> Result<(), Box<dyn Error>> {
info!(address = config.server.address, "connecting to server");
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
let mut rpc_client = rpc::RpcClient::connect(&config.server).await?;
if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? {
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?;

View File

@ -10,9 +10,12 @@ use crabidy_core::proto::crabidy::{
use std::{collections::HashMap, error::Error, fmt, time::Duration};
use base64::Engine;
use tonic::{
metadata::MetadataValue,
service::{interceptor::InterceptedService, Interceptor},
transport::{Channel, Endpoint},
Request, Streaming,
Request, Status, Streaming,
};
// FIXME: use anyhow + thiserror
@ -31,9 +34,52 @@ impl fmt::Display for RpcClientError {
impl Error for RpcClientError {}
/// Attaches the configured `authorization: Basic …` header to every
/// outgoing request (architecture/roles-auth.md). Without configured
/// credentials it attaches nothing, keeping the zero-config local
/// setup working against an open server. The header value is a secret
/// and never logged.
#[derive(Clone)]
pub struct AuthInterceptor {
header: Option<MetadataValue<tonic::metadata::Ascii>>,
}
impl AuthInterceptor {
/// `user` empty means "no credentials".
fn new(user: &str, password: &str) -> Result<Self, Box<dyn Error>> {
if user.is_empty() {
return Ok(Self { header: None });
}
let encoded =
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
let header = format!("Basic {encoded}")
.parse()
// The value is base64: this cannot fail on credential
// contents, only on programmer error.
.map_err(|_| "cannot encode credentials header")?;
Ok(Self {
header: Some(header),
})
}
}
impl Interceptor for AuthInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
if let Some(header) = &self.header {
request
.metadata_mut()
.insert("authorization", header.clone());
}
Ok(request)
}
}
/// The service client with the auth interceptor baked in.
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
pub struct RpcClient {
library_node_cache: HashMap<String, LibraryNode>,
client: CrabidyServiceClient<Channel>,
client: Client,
pub update_stream: Streaming<GetUpdateStreamResponse>,
}
@ -54,9 +100,12 @@ fn is_cacheable(path: &str) -> bool {
}
impl RpcClient {
pub async fn connect(addr: &'static str) -> Result<RpcClient, Box<dyn Error>> {
let endpoint = Endpoint::from_static(addr).connect_lazy();
let mut client = CrabidyServiceClient::new(endpoint);
pub async fn connect(
server: &'static crate::config::ServerConfig,
) -> Result<RpcClient, Box<dyn Error>> {
let endpoint = Endpoint::from_static(&server.address).connect_lazy();
let interceptor = AuthInterceptor::new(&server.user, &server.password)?;
let mut client = CrabidyServiceClient::with_interceptor(endpoint, interceptor);
let update_stream = Self::get_update_stream(&mut client).await;
let library_node_cache: HashMap<String, LibraryNode> = HashMap::new();
@ -68,9 +117,7 @@ impl RpcClient {
})
}
async fn get_update_stream(
client: &mut CrabidyServiceClient<Channel>,
) -> Streaming<GetUpdateStreamResponse> {
async fn get_update_stream(client: &mut Client) -> Streaming<GetUpdateStreamResponse> {
loop {
let get_update_stream_request = Request::new(GetUpdateStreamRequest {});
if let Ok(resp) = client.get_update_stream(get_update_stream_request).await {
@ -321,6 +368,27 @@ impl RpcClient {
mod tests {
use super::*;
#[test]
fn without_credentials_no_authorization_header_is_sent() {
let mut interceptor = AuthInterceptor::new("", "ignored").expect("build");
let request = interceptor.call(Request::new(())).expect("intercept");
assert!(request.metadata().get("authorization").is_none());
}
#[test]
fn credentials_become_a_basic_authorization_header() {
let mut interceptor = AuthInterceptor::new("queue-owner", "secret").expect("build");
let request = interceptor.call(Request::new(())).expect("intercept");
let header = request
.metadata()
.get("authorization")
.expect("header attached")
.to_str()
.expect("ascii");
// base64("queue-owner:secret")
assert_eq!(header, "Basic cXVldWUtb3duZXI6c2VjcmV0");
}
#[test]
fn mutable_provider_listings_are_never_cached() {
// Freshly captured/saved content must show up on the next visit

View File

@ -9,7 +9,12 @@ path = "src/main.rs"
[dependencies]
anyhow.workspace = true
argon2.workspace = true
async-trait.workspace = true
base64.workspace = true
clap.workspace = true
http.workspace = true
tower.workspace = true
audio-player.workspace = true
crabidy-core.workspace = true
dirs.workspace = true

517
crabidy-server/src/auth.rs Normal file
View File

@ -0,0 +1,517 @@
//! Role-based authorization for the gRPC surface
//! (architecture/roles-auth.md).
//!
//! Enforcement lives in exactly one place: [`AuthLayer`], a tower layer
//! in front of the tonic service. It authenticates the HTTP basic-auth
//! header against the configured role hashes and checks the resulting
//! [`Role`] against the method's [`minimum_role`] — *before* any
//! handler runs, default-deny for methods it does not know. Handlers
//! never see unauthorized requests and did not change for this feature.
//!
//! Credentials never appear in logs or error messages.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::task::{Context, Poll};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use base64::Engine;
use futures::future::{ready, Either, Ready};
use tonic::Status;
use tracing::warn;
use crate::settings::AuthSettings;
/// The three roles, ordered by privilege: every role includes the
/// rights of the roles below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Role {
/// May read and append tracks to the queue (plus create search
/// terms — the mechanism of finding something to append).
QueueAppender,
/// Anything on the queue and playback, but no library writes.
QueueOwner,
/// The normal user: everything.
Owner,
}
impl Role {
/// The basic-auth user name selecting this role.
fn from_user(user: &str) -> Option<Role> {
match user {
"owner" => Some(Role::Owner),
"queue-owner" => Some(Role::QueueOwner),
"queue-appender" => Some(Role::QueueAppender),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Role::Owner => "owner",
Role::QueueOwner => "queue-owner",
Role::QueueAppender => "queue-appender",
}
}
}
/// The gRPC path prefix of our service's methods.
const SERVICE_PREFIX: &str = "/crabidy.v1.CrabidyService/";
/// Minimum role required for a gRPC request path (the rights matrix,
/// architecture/roles-auth.md). Unknown methods — including anything
/// outside our service — require [`Role::Owner`]: fail-closed, a
/// future RPC starts locked until it is mapped here (a test pins the
/// full method list, so forgetting fails the suite).
pub fn minimum_role(grpc_path: &str) -> Role {
let Some(method) = grpc_path.strip_prefix(SERVICE_PREFIX) else {
return Role::Owner;
};
match method {
// Reads, the one appender queue verb, and search-term creation.
"Init" | "GetLibraryNode" | "GetUpdateStream" | "Append" | "CreateLibraryNode" => {
Role::QueueAppender
}
// Every other queue and playback verb.
"Queue" | "Replace" | "Remove" | "Insert" | "ClearQueue" | "SetCurrent"
| "ToggleShuffle" | "ToggleRepeat" | "TogglePlay" | "Stop" | "ChangeVolume"
| "ToggleMute" | "Next" | "Prev" | "RestartTrack" => Role::QueueOwner,
// Library writes (CaptureLibraryNode, SaveQueue,
// RenameLibraryNode, DeleteLibraryNode) and anything unmapped.
_ => Role::Owner,
}
}
/// Hashes a password into the PHC string `crabidy-server.toml` expects
/// (argon2id, default parameters, fresh random salt). Backs the
/// `crabidy-server hash-password` helper.
pub fn hash_password(password: &str) -> Result<String, String> {
use argon2::password_hash::{rand_core::OsRng, SaltString};
use argon2::PasswordHasher;
Argon2::default()
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
// The error is parameter trouble, never the password itself.
.map(|hash| hash.to_string())
.map_err(|err| format!("cannot hash password: {err}"))
}
/// Verifies basic-auth credentials against the configured role hashes.
///
/// Argon2 verification is deliberately slow, so *successful* header
/// values are cached (value → role); the cache is fed only by
/// successes, bounding it by the number of valid credentials. Failures
/// re-verify every time, which doubles as throttling.
pub struct Authenticator {
/// `(role, PHC hash)` pairs from the config; empty = auth off.
hashes: Vec<(Role, String)>,
verified: RwLock<HashMap<String, Role>>,
}
impl Authenticator {
pub fn new(settings: &AuthSettings) -> Self {
let mut hashes = Vec::new();
for (role, hash) in [
(Role::Owner, &settings.owner),
(Role::QueueOwner, &settings.queue_owner),
(Role::QueueAppender, &settings.queue_appender),
] {
if let Some(hash) = hash {
// Reject unusable hashes at startup, when the operator
// is looking — not at the first login attempt.
if let Err(err) = PasswordHash::new(hash) {
warn!(
role = role.name(),
"unusable password hash in config: {err}"
);
} else {
hashes.push((role, hash.clone()));
}
}
}
Self {
hashes,
verified: RwLock::new(HashMap::new()),
}
}
/// Whether any credential is configured (the auth on/off switch).
pub fn enabled(&self) -> bool {
!self.hashes.is_empty()
}
/// Resolves the request's `authorization` header value to a role.
///
/// With auth disabled everyone is [`Role::Owner`]. Every failure —
/// missing header, wrong scheme, broken base64, unknown user,
/// wrong password — answers the same `UNAUTHENTICATED` so callers
/// cannot probe which part was wrong. Never panics on input.
pub fn authenticate(&self, header: Option<&str>) -> Result<Role, Status> {
if !self.enabled() {
return Ok(Role::Owner);
}
let denied = || Status::unauthenticated("credentials required");
let header = header.ok_or_else(denied)?;
if let Some(role) = self
.verified
.read()
.ok()
.and_then(|cache| cache.get(header).copied())
{
return Ok(role);
}
let encoded = header
.strip_prefix("Basic ")
.or_else(|| header.strip_prefix("basic "))
.ok_or_else(denied)?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.map_err(|_| denied())?;
let decoded = String::from_utf8(decoded).map_err(|_| denied())?;
let (user, password) = decoded.split_once(':').ok_or_else(denied)?;
let role = Role::from_user(user).ok_or_else(denied)?;
let hash = self
.hashes
.iter()
.find(|(r, _)| *r == role)
.map(|(_, h)| h)
.ok_or_else(denied)?;
// Validated in `new`; a parse failure here is unreachable but
// must still deny, not panic.
let parsed = PasswordHash::new(hash).map_err(|_| denied())?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| denied())?;
if let Ok(mut cache) = self.verified.write() {
cache.insert(header.to_string(), role);
}
Ok(role)
}
#[cfg(test)]
fn cached(&self) -> usize {
self.verified.read().map(|c| c.len()).unwrap_or(0)
}
}
/// Tower layer installing [`AuthService`] in front of the tonic
/// service.
#[derive(Clone)]
pub struct AuthLayer {
auth: Arc<Authenticator>,
}
impl AuthLayer {
pub fn new(auth: Arc<Authenticator>) -> Self {
Self { auth }
}
}
impl<S> tower::Layer<S> for AuthLayer {
type Service = AuthService<S>;
fn layer(&self, inner: S) -> Self::Service {
AuthService {
inner,
auth: self.auth.clone(),
}
}
}
/// The single authorization gate: authenticates the header, compares
/// the role against [`minimum_role`] of the request path, and either
/// forwards to the inner service or answers a trailers-only gRPC error
/// (`UNAUTHENTICATED` / `PERMISSION_DENIED`) without running any
/// handler.
#[derive(Clone)]
pub struct AuthService<S> {
inner: S,
auth: Arc<Authenticator>,
}
impl<S, ReqBody, ResBody> tower::Service<http::Request<ReqBody>> for AuthService<S>
where
S: tower::Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
ResBody: Default,
{
type Response = S::Response;
type Error = S::Error;
type Future = Either<S::Future, Ready<Result<Self::Response, Self::Error>>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: http::Request<ReqBody>) -> Self::Future {
// The header value itself is a secret and is never logged.
let header = req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
let decision = self.auth.authenticate(header).and_then(|role| {
let needed = minimum_role(req.uri().path());
if role >= needed {
Ok(())
} else {
Err(Status::permission_denied(format!(
"requires the {} role",
needed.name()
)))
}
});
match decision {
Ok(()) => Either::Left(self.inner.call(req)),
Err(status) => Either::Right(ready(Ok(status.into_http()))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use argon2::password_hash::{rand_core::OsRng, SaltString};
use argon2::PasswordHasher;
/// A PHC hash of `password` with cheap test parameters (the params
/// travel inside the PHC string, so default verification reads
/// them back).
fn hash(password: &str) -> String {
let params = argon2::Params::new(8, 1, 1, None).expect("params");
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
argon2
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
.expect("hash")
.to_string()
}
fn basic(user: &str, password: &str) -> String {
let encoded =
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
format!("Basic {encoded}")
}
fn authenticator() -> Authenticator {
Authenticator::new(&AuthSettings {
owner: Some(hash("os")),
queue_owner: Some(hash("qos")),
queue_appender: None,
})
}
// ---- the rights matrix -------------------------------------------
#[test]
fn the_method_table_pins_every_rpc_of_the_service() {
let appender = [
"Init",
"GetLibraryNode",
"GetUpdateStream",
"Append",
"CreateLibraryNode",
];
let queue_owner = [
"Queue",
"Replace",
"Remove",
"Insert",
"ClearQueue",
"SetCurrent",
"ToggleShuffle",
"ToggleRepeat",
"TogglePlay",
"Stop",
"ChangeVolume",
"ToggleMute",
"Next",
"Prev",
"RestartTrack",
];
let owner = [
"CaptureLibraryNode",
"SaveQueue",
"RenameLibraryNode",
"DeleteLibraryNode",
];
// The full service, from crabidy.proto — 24 methods. A new RPC
// must be added to exactly one list (and the layer keeps it
// owner-only until then).
assert_eq!(appender.len() + queue_owner.len() + owner.len(), 24);
for method in appender {
let path = format!("{SERVICE_PREFIX}{method}");
assert_eq!(minimum_role(&path), Role::QueueAppender, "{method}");
}
for method in queue_owner {
let path = format!("{SERVICE_PREFIX}{method}");
assert_eq!(minimum_role(&path), Role::QueueOwner, "{method}");
}
for method in owner {
let path = format!("{SERVICE_PREFIX}{method}");
assert_eq!(minimum_role(&path), Role::Owner, "{method}");
}
}
#[test]
fn unknown_methods_and_foreign_services_are_owner_only() {
assert_eq!(
minimum_role("/crabidy.v1.CrabidyService/BrandNewRpc"),
Role::Owner
);
assert_eq!(minimum_role("/grpc.health.v1.Health/Check"), Role::Owner);
assert_eq!(minimum_role("nonsense"), Role::Owner);
}
#[test]
fn roles_are_ordered_by_privilege() {
assert!(Role::Owner > Role::QueueOwner);
assert!(Role::QueueOwner > Role::QueueAppender);
}
// ---- authentication ----------------------------------------------
#[test]
fn without_configured_hashes_everyone_is_owner() {
let auth = Authenticator::new(&AuthSettings::default());
assert!(!auth.enabled());
assert_eq!(auth.authenticate(None).expect("open"), Role::Owner);
}
#[test]
fn valid_credentials_resolve_their_role() {
let auth = authenticator();
assert_eq!(
auth.authenticate(Some(&basic("owner", "os")))
.expect("owner"),
Role::Owner
);
assert_eq!(
auth.authenticate(Some(&basic("queue-owner", "qos")))
.expect("queue owner"),
Role::QueueOwner
);
}
#[test]
fn every_failure_is_the_same_unauthenticated() {
let auth = authenticator();
let cases: Vec<Option<String>> = vec![
None, // no header
Some("Bearer token".to_string()), // wrong scheme
Some("Basic !!!not-base64!!!".to_string()), // broken base64
Some("Basic bm9jb2xvbg==".to_string()), // no colon
Some(basic("owner", "wrong")), // wrong password
Some(basic("dj", "os")), // unknown user
Some(basic("queue-appender", "anything")), // role without hash
];
let mut messages = Vec::new();
for case in &cases {
let err = auth
.authenticate(case.as_deref())
.expect_err(&format!("{case:?}"));
assert_eq!(err.code(), tonic::Code::Unauthenticated, "{case:?}");
messages.push(err.message().to_string());
}
assert!(
messages.windows(2).all(|w| w[0] == w[1]),
"failures must be indistinguishable"
);
assert_eq!(auth.cached(), 0, "failures are never cached");
}
#[test]
fn hash_password_output_round_trips_through_the_authenticator() {
let phc = hash_password("hunter2").expect("hash");
assert!(phc.starts_with("$argon2id$"), "PHC format");
let auth = Authenticator::new(&AuthSettings {
owner: Some(phc),
queue_owner: None,
queue_appender: None,
});
assert_eq!(
auth.authenticate(Some(&basic("owner", "hunter2")))
.expect("round trip"),
Role::Owner
);
}
#[test]
fn successful_credentials_are_cached() {
let auth = authenticator();
let header = basic("owner", "os");
assert_eq!(auth.cached(), 0);
auth.authenticate(Some(&header)).expect("first");
assert_eq!(auth.cached(), 1);
auth.authenticate(Some(&header)).expect("cached");
assert_eq!(auth.cached(), 1, "same credential, one entry");
}
// ---- the layer -----------------------------------------------------
/// Calls the layered service once and returns the response plus
/// whether the inner service ran.
fn call_layer(
auth: Arc<Authenticator>,
path: &str,
header: Option<&str>,
) -> (http::Response<String>, bool) {
use std::sync::atomic::{AtomicBool, Ordering};
use tower::{Layer, Service, ServiceExt};
let reached = Arc::new(AtomicBool::new(false));
let flag = reached.clone();
let inner = tower::service_fn(move |_req: http::Request<()>| {
flag.store(true, Ordering::SeqCst);
ready(Ok::<_, std::convert::Infallible>(http::Response::new(
"handled".to_string(),
)))
});
let mut service = AuthLayer::new(auth).layer(inner);
let mut req = http::Request::new(());
*req.uri_mut() = path.parse().expect("uri");
if let Some(header) = header {
req.headers_mut().insert(
http::header::AUTHORIZATION,
header.parse().expect("header value"),
);
}
let response = futures::executor::block_on(async {
service.ready().await.expect("ready").call(req).await
})
.expect("call");
(response, reached.load(Ordering::SeqCst))
}
fn grpc_status(response: &http::Response<String>) -> Option<&str> {
response
.headers()
.get("grpc-status")
.and_then(|v| v.to_str().ok())
}
#[test]
fn the_layer_forwards_authorized_requests_only() {
let auth = Arc::new(authenticator());
let append = "http://s/crabidy.v1.CrabidyService/Append";
let capture = "http://s/crabidy.v1.CrabidyService/CaptureLibraryNode";
// No credentials: unauthenticated, handler never runs.
let (response, reached) = call_layer(auth.clone(), append, None);
assert!(!reached);
assert_eq!(grpc_status(&response), Some("16"), "UNAUTHENTICATED");
// Sufficient role: forwarded.
let (response, reached) = call_layer(auth.clone(), append, Some(&basic("owner", "os")));
assert!(reached);
assert_eq!(response.body(), "handled");
// Valid credentials, insufficient role: denied, handler never
// runs, and the code distinguishes authorization from
// authentication.
let (response, reached) =
call_layer(auth.clone(), capture, Some(&basic("queue-owner", "qos")));
assert!(!reached);
assert_eq!(grpc_status(&response), Some("7"), "PERMISSION_DENIED");
// Auth disabled: everything forwards without a header.
let open = Arc::new(Authenticator::new(&AuthSettings::default()));
let (_, reached) = call_layer(open, capture, None);
assert!(reached);
}
}

View File

@ -1,3 +1,4 @@
pub mod auth;
pub mod bookmark_store;
pub mod capture;
pub mod capture_store;
@ -5,6 +6,7 @@ pub mod playback;
pub mod provider;
pub mod queue_store;
pub mod rpc;
pub mod settings;
use audio_player::PlayerMessage;
use crabidy_core::proto::crabidy::{
@ -33,6 +35,18 @@ pub const LISTEN_ADDR: &str = "0.0.0.0:50051";
pub async fn serve(
addr: std::net::SocketAddr,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Auth first: a malformed crabidy-server.toml must abort startup
// instead of running an intended-to-be-locked server open
// (architecture/roles-auth.md). A missing file runs open.
let config_dir = dirs::config_dir()
.map(|d| d.join("crabidy"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let server_settings = settings::ServerSettings::load(&config_dir)?;
let authenticator = Arc::new(auth::Authenticator::new(&server_settings.auth));
if authenticator.enabled() {
info!("role authorization enabled");
}
let (update_tx, _) = tokio::sync::broadcast::channel(2048);
let orchestrator = provider::ProviderOrchestrator::init("")
.await
@ -86,6 +100,7 @@ pub async fn serve(
info!(%addr, "grpc server listening");
tonic::transport::Server::builder()
.layer(auth::AuthLayer::new(authenticator))
.add_service(CrabidyServiceServer::new(crabidy_service))
.serve(addr)
.await?;

View File

@ -3,15 +3,48 @@
//! lives in the library so the bundled `cbd` binary can host it too
//! (architecture/cbd-bundle.md D1).
use clap::Parser;
use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser)]
#[command(author, version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(clap::Subcommand)]
enum Command {
/// Hash a password for the `[auth]` section of crabidy-server.toml
/// (architecture/roles-auth.md). Reads the password as one line
/// from stdin and prints the PHC string — nothing else, so output
/// can be piped. The password itself is never printed or logged.
HashPassword,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cli = Cli::parse();
if let Some(Command::HashPassword) = cli.command {
return hash_password();
}
let _log_guard = init_tracing();
crabidy_server::serve(crabidy_server::LISTEN_ADDR.parse()?).await?;
Ok(())
}
/// Reads one line from stdin and prints its argon2 PHC hash.
fn hash_password() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut password = String::new();
std::io::stdin().read_line(&mut password)?;
let password = password.trim_end_matches(['\r', '\n']);
if password.is_empty() {
return Err("empty password".into());
}
println!("{}", crabidy_server::auth::hash_password(password)?);
Ok(())
}
/// Installs the global tracing subscriber.
///
/// The filter honors `RUST_LOG`; without it, our own crates log at debug and

View File

@ -0,0 +1,106 @@
//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`.
//!
//! Today this only carries the `[auth]` role hashes
//! (architecture/roles-auth.md). The file is optional — a missing file
//! runs the server open, exactly as before the feature — but a file
//! that exists and does not parse aborts startup: silently ignoring a
//! broken auth config would run an intended-to-be-locked server open
//! (fail-closed, quality/roles-auth.md).
use std::path::Path;
use serde::Deserialize;
/// The server config file name inside the crabidy config directory.
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
/// Contents of `crabidy-server.toml`.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerSettings {
/// Role credentials; absent (or empty) means the server runs open.
#[serde(default)]
pub auth: AuthSettings,
}
/// One PHC password hash per role; a role without a hash cannot
/// authenticate. Generate hashes with `crabidy-server hash-password`.
/// Hashes are not passwords, but the file should stay private anyway.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthSettings {
pub owner: Option<String>,
pub queue_owner: Option<String>,
pub queue_appender: Option<String>,
}
impl AuthSettings {
/// Whether any role is credentialed — the switch that turns
/// authentication on for every RPC.
pub fn enabled(&self) -> bool {
self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some()
}
}
impl ServerSettings {
/// Loads the settings from `config_dir`.
///
/// A missing file yields the defaults (auth disabled). An existing
/// file that cannot be read or parsed is an error — the caller
/// must abort startup rather than run open.
pub fn load(config_dir: &Path) -> Result<Self, String> {
let file = config_dir.join(SETTINGS_FILE);
let raw = match std::fs::read_to_string(&file) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default());
}
Err(err) => return Err(format!("cannot read {}: {err}", file.display())),
};
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn a_missing_file_disables_auth() {
let dir = TempDir::new().expect("tempdir");
let settings = ServerSettings::load(dir.path()).expect("defaults");
assert!(!settings.auth.enabled());
}
#[test]
fn hashes_load_and_enable_auth() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"[auth]\nqueue_appender = \"$argon2id$fake\"\n",
)
.expect("write");
let settings = ServerSettings::load(dir.path()).expect("parse");
assert!(settings.auth.enabled());
assert_eq!(
settings.auth.queue_appender.as_deref(),
Some("$argon2id$fake")
);
assert!(settings.auth.owner.is_none());
}
#[test]
fn a_broken_file_is_a_startup_error_not_an_open_server() {
let dir = TempDir::new().expect("tempdir");
for bad in [
"[auth\n",
"[auth]\nowner = 3\n",
"[auth]\nonwer = \"typo\"\n",
] {
std::fs::write(dir.path().join(SETTINGS_FILE), bad).expect("write");
let err = ServerSettings::load(dir.path()).expect_err(bad);
assert!(err.contains("crabidy-server.toml"), "{err}");
}
}
}

39
plan/roles-auth.md Normal file
View File

@ -0,0 +1,39 @@
# Plan — roles and rights
From `architecture/roles-auth.md` and `quality/roles-auth.md`.
- [x] **Deps**: workspace `argon2` (with `std`), `http`, `tower`;
`crabidy-server` gains `argon2`, `base64`, `http`, `tower`,
`clap`; `cbd-tui` gains `base64`. Verify: workspace builds.
- [x] **Server settings** (`crabidy-server/src/settings.rs`):
`ServerSettings { auth: AuthSettings }`,
`AuthSettings { owner, queue_owner, queue_appender: Option<String> }`;
`load(config_dir)` — absent file ⇒ defaults, malformed file ⇒
startup error. Verify: unit tests for all three cases.
- [x] **Auth core** (`crabidy-server/src/auth.rs`): `Role` (ordered),
`minimum_role(method) -> Role` default-deny table,
`Authenticator` (parse Basic header, argon2 verify, success
cache, disabled mode). Verify: unit tests — header parsing
never panics, wrong user/password indistinguishable, matrix
samples per role, unknown method ⇒ owner, cache fed only by
successes, disabled mode allows all without header.
- [x] **Tower layer** (`auth.rs`): `AuthLayer`/`AuthService` checking
`authorization` against the method's minimum role before the
inner service; denials answer trailers-only via
`Status::into_http()`. Verify: service-level tests with a
counting inner service (deny short-circuits, allow forwards,
unauthenticated vs permission-denied codes).
- [x] **Wire-up** (`lib.rs::serve`): load settings, fail startup on
malformed config, install the layer. Verify: existing tests
still pass; layer test covers enforcement.
- [x] **hash-password** (`main.rs` + clap): subcommand reads stdin,
prints PHC string; round-trip test hash → authenticator accepts.
- [x] **Client** (`cbd-tui`): `user`/`password` config options (and
flags), auth interceptor attaching a precomputed Basic header to
every request, type alias for the intercepted client. Verify:
unit tests — no creds ⇒ no header, creds ⇒ header present.
- [x] **Docs**: root README (config table row, `cbd-tui.toml` options,
security note), new `crabidy-server.toml` section; architecture
cross-links. Verify: markdownlint.
- [x] **Gates**: run the full suite + clippy + fmt; check off
`quality/roles-auth.md`; write `plan/summary.md` section.

View File

@ -604,3 +604,31 @@ outside-root audio kept) and 3 new TUI tests (confirm-then-send,
cancel-on-anything-else, prompt render) plus a guard in the existing
queues delete test that track deletion stays `NotSupported` there.
188 workspace tests green; clippy `-D warnings` and fmt clean.
## roles-auth (2026-07-21)
Built per `plan/roles-auth.md` from `architecture/roles-auth.md`:
basic-auth role authorization (owner / queue-owner / queue-appender)
with PHC password hashes in the new `crabidy-server.toml`.
- `crabidy-server/src/settings.rs``[auth]` loading; missing file =
open mode, malformed file = startup abort (fail-closed).
- `crabidy-server/src/auth.rs` — ordered `Role`, `minimum_role`
default-deny method table (pinned by a 24-method test),
`Authenticator` (argon2 verify, success-only credential cache,
indistinguishable failures), `AuthLayer`/`AuthService` tower layer
answering trailers-only `UNAUTHENTICATED`/`PERMISSION_DENIED` via
`Status::into_http()`, and `hash_password` for the new
`crabidy-server hash-password` subcommand (clap, stdin → PHC).
- `cbd-tui``user`/`password` config options and flags;
`AuthInterceptor` baking the Basic header into every request via
`CrabidyServiceClient::with_interceptor`.
### Deviations from plan / architecture (roles-auth)
- None functionally. The dev-flow stages were compressed into one
autonomous pass (per standing instruction): stubs went straight to
implementation; `quality/roles-auth.md` gates were verified after
the fact and all hold.
- Denied-action UX in the TUI stays a logged no-op, as recorded in the
architecture's open questions.

59
quality/roles-auth.md Normal file
View File

@ -0,0 +1,59 @@
# Quality gates — roles and rights
LLM-verified gates for `architecture/roles-auth.md`. Automatic tests
live in `crabidy-server/src/auth.rs`, `crabidy-server/src/main.rs`
(hash helper) and `cbd-tui/src/rpc.rs`.
## Security posture
- [x] Fail-closed everywhere: an unknown/future gRPC method requires
owner; a present-but-malformed `crabidy-server.toml` aborts server
startup instead of silently running open; a missing file (or empty
`[auth]`) is the documented open mode.
- [x] No panics on request input: malformed `authorization` headers
(bad base64, missing colon, non-UTF-8, wrong scheme), unknown
role names, and oversized values all produce `UNAUTHENTICATED`,
never a panic.
- [x] Indistinguishable failures: wrong user and wrong password both
answer plain `UNAUTHENTICATED` with the same message.
- [x] Secrets redacted: passwords, authorization header values, and
PHC hashes never appear in logs, traces, or error messages
(including the `hash-password` helper and TUI logs).
- [x] Authorization is enforced in exactly one place (the tower
layer), before any handler runs; RPC handlers did not change.
## Semantics
- [x] Rights matrix implemented as specified: appender = reads +
`Append` + `CreateLibraryNode`; queue-owner adds every queue and
playback verb; owner-only = `CaptureLibraryNode`, `SaveQueue`,
`RenameLibraryNode`, `DeleteLibraryNode`. Higher roles include
lower ones. A test pins the full method list of the proto service
so an unmapped new RPC fails the suite.
- [x] Valid credentials with an insufficient role get
`PERMISSION_DENIED` (not `UNAUTHENTICATED`).
- [x] No `[auth]` hashes ⇒ exactly today's behavior: no header
required, all methods allowed.
- [x] A role without a configured hash cannot authenticate.
## Performance
- [x] Argon2 verification runs once per credential: successful
verifications are cached (header value → role) and the cache is
only fed by successes, keeping it bounded by the number of valid
credentials.
## Client
- [x] Without configured credentials the TUI sends no header
(zero-config local use unchanged); with credentials, every
request — including update-stream reconnects — carries the same
`authorization: Basic` header.
- [x] The client config documents that `password` is plaintext and the
file must be kept private.
## Tooling
- [x] `crabidy-server hash-password` reads the password from stdin
(nothing echoed by the tool itself), prints only the PHC string,
and its output verifies against the same server code path.