From 3a75c54bcb945522d104c2a30d99bdd6b6e156c0 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 21:02:26 +0200 Subject: [PATCH 1/7] feat(swarm): the auth-callout responder (#3112 slice 2) Slice 1 shipped the NATS container with an auth_callout block and no responder, which is the fail-closed state: the server answers auth_required and admits nobody. This crate is what lets it say yes. Connects as the callout-exempt user by nkey (never by name - the server refuses to start if that entry carries a username), subscribes to $SYS.REQ.USER.AUTH, validates the presented bearer token against authelia's introspection endpoint, and replies with a signed NATS user JWT. A denial is a signed response carrying an error, never silence: a server that hears nothing cannot tell a refusing responder from a dead one, so staying quiet would turn every rejection into a timeout and hide an outage inside what looks like ordinary denials. Everything that is not an explicit active:true denies - network error, timeout, non-2xx, unparseable body, no token at all. Those are exactly the conditions under which an attacker would most like this to fall open. The introspection budget is held under the server's own 2s auth_callout timeout by a test, since the two numbers live in different languages in different files. nats-jwt mints the user JWT. It cannot mint the authorization_response wrapper - its claim enum is closed and its claims carry no aud, which the response needs so a reply cannot be replayed at another server in the cluster - so that half is hand-written, and a test builds a user token both ways and requires the bytes to match. That is the only honest basis for trusting the hand-written path on the shape the crate does not model. async-nats is taken with default-features off: the default set carries jetstream, kv, object-store, websockets and service, none of which a callout responder speaks. --- Cargo.lock | 200 ++++++++++++++++++++-- Cargo.toml | 5 + swarm-nats-auth/Cargo.toml | 53 ++++++ swarm-nats-auth/src/introspect.rs | 109 ++++++++++++ swarm-nats-auth/src/main.rs | 179 ++++++++++++++++++++ swarm-nats-auth/src/request.rs | 134 +++++++++++++++ swarm-nats-auth/src/respond.rs | 272 ++++++++++++++++++++++++++++++ 7 files changed, 942 insertions(+), 10 deletions(-) create mode 100644 swarm-nats-auth/Cargo.toml create mode 100644 swarm-nats-auth/src/introspect.rs create mode 100644 swarm-nats-auth/src/main.rs create mode 100644 swarm-nats-auth/src/request.rs create mode 100644 swarm-nats-auth/src/respond.rs diff --git a/Cargo.lock b/Cargo.lock index 693b1121..3ca8da35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,38 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-nats" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83a251fa1a4c9d0fe6e816b7acd60549e473e08d14f27a1d992c2675abff05f" +dependencies = [ + "base64", + "bytes", + "futures-util", + "memchr", + "nkeys", + "pin-project", + "portable-atomic", + "rand 0.10.2", + "regex", + "ring", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "url", +] + [[package]] name = "async-once-cell" version = "0.5.4" @@ -449,6 +481,9 @@ name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] [[package]] name = "bytesize" @@ -958,6 +993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "pem-rfc7468", "zeroize", ] @@ -1091,6 +1127,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2 0.10.9", + "signature", "subtle", "zeroize", ] @@ -1150,7 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2018,7 +2055,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -2637,7 +2674,7 @@ dependencies = [ "url", "urlencoding", "vodozemac", - "webpki-roots", + "webpki-roots 1.0.8", "zeroize", ] @@ -2903,12 +2940,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nats-jwt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51104ed9bd9e7d1f9117603e1186a56a3a437ceaf0761383910180f5879b801" +dependencies = [ + "data-encoding", + "nkeys", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.7", + "signatory", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3120,6 +3185,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3176,6 +3250,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3407,7 +3501,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3648,7 +3742,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -3681,6 +3775,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -3693,7 +3788,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -3945,7 +4040,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4004,7 +4099,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4226,6 +4321,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -4305,12 +4411,25 @@ dependencies = [ "libc", ] +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + [[package]] name = "signature" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -4437,6 +4556,26 @@ dependencies = [ "utoipa-axum", ] +[[package]] +name = "swarm-nats-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-nats", + "clap", + "data-encoding", + "futures", + "nats-jwt", + "nkeys", + "reqwest 0.13.1", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "swarmctl" version = "0.1.0" @@ -4471,6 +4610,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4522,7 +4672,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4712,6 +4862,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.8.7", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -5288,6 +5459,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -5325,7 +5505,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 184b75b3..cb0b4417 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "hive-types", "hivectl", "swarm-controller", + "swarm-nats-auth", "swarmctl", ] @@ -112,6 +113,10 @@ tokio-stream = { version = "0.1", features = ["sync"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } reqwest = { version = "0.13", default-features = false, features = [ + # RFC 7662 introspection posts an urlencoded body; without this, + # `.form()` does not exist and the alternative is percent-encoding a + # credential by hand. + "form", "json", "rustls", ] } diff --git a/swarm-nats-auth/Cargo.toml b/swarm-nats-auth/Cargo.toml new file mode 100644 index 00000000..e9610f82 --- /dev/null +++ b/swarm-nats-auth/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "swarm-nats-auth" +version.workspace = true +readme = "README.md" +edition.workspace = true + +[[bin]] +name = "swarm-nats-auth" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +# The NATS protocol client. `default-features = false` because the default set +# is broad - jetstream, kv, object-store, websockets, service - and a callout +# responder speaks none of them. What is named here is the whole requirement: +# the server generation we actually deploy, nkey auth, and a TLS backend. +# (Checked what dropping the defaults costs, the way `internal-logs` was once +# lost that way: nothing in the unused set is a diagnostic.) +async-nats = { version = "0.50", default-features = false, features = [ + "server_2_14", + "nkeys", + "ring", +] } +# base64url for decoding the inbound request JWT. Already in the tree via +# nkeys; named directly because this crate uses it directly. +data-encoding = "2" +# StreamExt::next on the subscription. async-nats returns a Stream, not an +# iterator, and futures is already in the tree. +futures = "0.3" +# nkey seed handling + signing. The primitives (ed25519-dalek, data-encoding) +# are already in the tree, but the nkey *format* - ed25519 + base32 + CRC16 - +# is not, and hand-rolling a key format on an auth path is how you get a +# CRC bug nobody reviews. +nkeys = "0.4" +# NATS JWT claim types + signing. The reply this responder sends is a *signed +# user JWT*, whose `jti` is base32(sha256(claims)) and whose header must say +# `ed25519-nkey` - format details with no feedback loop until the server +# rejects the token. Its own deps (data-encoding, nkeys, serde, serde_json, +# sha2) are already in the tree, so this costs no new transitive weight. +nats-jwt = "0.3" +# The jti digest. Already in the tree via nats-jwt; named directly because +# this crate computes one itself for the response wrapper. +sha2 = "0.10" + +[lints] +workspace = true diff --git a/swarm-nats-auth/src/introspect.rs b/swarm-nats-auth/src/introspect.rs new file mode 100644 index 00000000..5e5ec02c --- /dev/null +++ b/swarm-nats-auth/src/introspect.rs @@ -0,0 +1,109 @@ +//! Validating a presented bearer token against the `IdP`. +//! +//! RFC 7662 token introspection: POST the token to authelia, which answers +//! `{"active": true|false, ...}`. `active` is the whole verdict — this +//! responder does not second-guess it, and does not inspect scopes, because a +//! second place that decides who may connect is a second place to get it +//! wrong. +//! +//! # Everything that is not an explicit `active: true` is a denial +//! +//! Network error, timeout, non-2xx, unparseable body, `active: false` — all +//! deny. That is not defensive coding, it is the only shape that is safe: the +//! failure modes of an HTTP call are exactly the conditions under which an +//! attacker would most like this to fall open. +//! +//! # The timeout is not a tuning knob +//! +//! `swarm-nats.nix` sets `authorization.timeout = "2s"`, so the server stops +//! waiting after two seconds and denies the connection anyway. An +//! introspection call allowed to run longer than that cannot produce a useful +//! answer — it can only hold a task open past the point where the result +//! matters. The budget below is deliberately under the server's. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// Kept under `swarm-nats.nix`'s `authorization.timeout = "2s"` — see the +/// module docs. A slower answer is not a late success, it is a denial that +/// already happened. +pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500); + +/// The one field this responder acts on. Authelia returns more (`sub`, +/// `scope`, `exp`); modelling them would imply we check them. +#[derive(Debug, Deserialize)] +struct IntrospectionResponse { + active: bool, +} + +/// Ask the `IdP` whether `token` is currently valid. +/// +/// Returns `Ok(true)` **only** on a 2xx whose body says `active: true`. +/// Every other outcome is `Ok(false)` with the reason logged, or `Err` when +/// the call could not be made at all — callers must treat both as a denial. +/// +/// The token is never logged, not even truncated: a prefix is enough to +/// correlate across logs, and a credential that is 80% redacted is still a +/// credential in a journal. +pub async fn is_active( + http: &reqwest::Client, + url: &str, + client_id: &str, + client_secret: &str, + token: &str, +) -> Result { + let resp = http + .post(url) + .basic_auth(client_id, Some(client_secret)) + .form(&[("token", token)]) + .timeout(INTROSPECTION_TIMEOUT) + .send() + .await + .context("introspection request")?; + + let status = resp.status(); + if !status.is_success() { + tracing::warn!(%status, "introspection returned non-2xx; denying"); + return Ok(false); + } + let body: IntrospectionResponse = match resp.json().await { + Ok(b) => b, + Err(e) => { + tracing::warn!(error = ?e, "introspection body did not parse; denying"); + return Ok(false); + } + }; + Ok(body.active) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_timeout_stays_under_the_servers() { + // `swarm-nats.nix` sets `authorization.timeout = "2s"`. If someone + // raises this constant past that, the extra time buys nothing: the + // server has already denied the connection. This test is the only + // thing tying the two numbers together, since they live in different + // languages in different files. + assert!( + INTROSPECTION_TIMEOUT < Duration::from_secs(2), + "introspection budget must stay under the server's auth_callout timeout" + ); + } + + #[test] + fn only_active_true_deserializes_to_a_grant() { + let yes: IntrospectionResponse = serde_json::from_str(r#"{"active":true}"#).unwrap(); + assert!(yes.active); + let no: IntrospectionResponse = serde_json::from_str(r#"{"active":false}"#).unwrap(); + assert!(!no.active); + // A body that omits `active` is not a grant with a default - it is a + // response we do not understand, and it must fail to parse rather + // than deserialize to `false` quietly under some future `#[serde(default)]`. + assert!(serde_json::from_str::(r#"{"sub":"someone"}"#).is_err()); + } +} diff --git a/swarm-nats-auth/src/main.rs b/swarm-nats-auth/src/main.rs new file mode 100644 index 00000000..3f68131d --- /dev/null +++ b/swarm-nats-auth/src/main.rs @@ -0,0 +1,179 @@ +//! Auth-callout responder for the swarm's NATS queue. +//! +//! `nix/host-modules/swarm-nats.nix` configures `nats-server` with an +//! `auth_callout` block and no responder, which is the fail-closed state: the +//! server answers `"auth_required":true` and admits nobody. This binary is what +//! makes it able to say *yes*. +//! +//! It connects as the one callout-exempt user (by nkey, never by name — the +//! server refuses to start if that entry carries a username), subscribes to +//! `$SYS.REQ.USER.AUTH`, validates the presented bearer token against +//! authelia's introspection endpoint, and answers with a NATS user JWT signed +//! by the account key. A rejection is answered explicitly: silence is +//! indistinguishable from the responder being down, and the queue is the +//! swarm's control path. +//! +//! # Secrets +//! +//! Every credential is taken as a **path**, never a value. Two reasons, both +//! previously learned the hard way here: a value in nix config is rendered into +//! the world-readable store, and a value in `argv` is readable by anyone via +//! `/proc//cmdline`, which is `0444`. Paths are not secrets, so passing +//! them as flags is fine. + +use std::path::PathBuf; + +use anyhow::Context; +use clap::Parser; +use futures::StreamExt; + +mod introspect; +mod request; +mod respond; + +/// Subject the NATS server publishes authorization requests on. +const AUTH_SUBJECT: &str = "$SYS.REQ.USER.AUTH"; + +#[derive(Debug, Parser)] +#[command( + name = "swarm-nats-auth", + about = "Auth-callout responder for the swarm NATS queue" +)] +struct Args { + /// NATS server to connect to. + #[arg(long, default_value = "nats://127.0.0.1:4222")] + nats_url: String, + + /// Path to the seed of the callout-exempt user this responder connects as. + /// Its public half is `services.hyperhive.swarm.nats.calloutUserPublicKey`. + #[arg(long)] + user_seed_file: PathBuf, + + /// Path to the account signing seed used to sign issued user JWTs. Its + /// public half is `services.hyperhive.swarm.nats.calloutIssuerPublicKey`. + #[arg(long)] + issuer_seed_file: PathBuf, + + /// Authelia's OIDC introspection endpoint. + #[arg(long)] + introspection_url: String, + + /// `OAuth2` client id this responder introspects as. Must match + /// `services.hyperhive.swarm.nats.clientId`, whose default this mirrors. + #[arg(long, default_value = "swarm-nats")] + client_id: String, + + /// Path to this responder's own OIDC client secret. + #[arg(long)] + client_secret_file: PathBuf, +} + +/// Read a secret file and strip surrounding whitespace. +/// +/// The trim matters: an `echo`-created seed file ends in a newline, and an +/// nkey seed with a trailing byte is not a seed — it fails at parse with a +/// message about encoding rather than about the file, which sends you looking +/// in the wrong place. The value is never logged, and the error deliberately +/// names only the path. +fn read_secret(path: &std::path::Path) -> anyhow::Result { + let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("{} is empty", path.display()); + } + Ok(trimmed.to_owned()) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + let args = Args::parse(); + + let client_secret = read_secret(&args.client_secret_file)?; + let http = reqwest::Client::new(); + let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?) + .context("parse the account signing seed")?; + let user_seed = read_secret(&args.user_seed_file)?; + let client = async_nats::ConnectOptions::with_nkey(user_seed) + .name("swarm-nats-auth") + .connect(&args.nats_url) + .await + .with_context(|| format!("connect to {}", args.nats_url))?; + let mut requests = client + .subscribe(AUTH_SUBJECT) + .await + .with_context(|| format!("subscribe to {AUTH_SUBJECT}"))?; + tracing::info!( + nats_url = %args.nats_url, + subject = AUTH_SUBJECT, + "swarm-nats-auth: connected, awaiting authorization requests" + ); + + while let Some(msg) = requests.next().await { + // Decode failures are logged and dropped, never propagated: this loop + // is the swarm's login path, and exiting on one malformed payload + // would let any client take authentication down for everyone. + let req = match request::decode(&msg.payload) { + Ok(req) => req, + Err(e) => { + tracing::warn!(error = ?e, "undecodable auth request, ignoring"); + continue; + } + }; + // No token is a denial, not an error: an anonymous connect is a + // normal thing for a client to attempt and an abnormal thing to + // grant. Introspection is only reached once something was presented. + let granted = match &req.connect_opts.auth_token { + Some(token) => introspect::is_active( + &http, + &args.introspection_url, + &args.client_id, + &client_secret, + token, + ) + .await + // An introspection that could not be *made* is a denial too. The + // failure modes of an HTTP call are exactly the conditions under + // which an attacker would most like this to fall open. + .unwrap_or_else(|e| { + tracing::warn!(error = ?e, "introspection failed; denying"); + false + }), + None => false, + }; + tracing::info!( + user_nkey = %req.user_nkey, + server_id = %req.server_id.id, + granted, + "auth request" + ); + + // Always reply, including on a denial. A server that hears nothing + // cannot tell a refusing responder from a dead one, so silence turns + // every rejection into a 2s timeout and hides an outage inside what + // looks like ordinary denials. + let Some(reply_to) = msg.reply.clone() else { + tracing::warn!("auth request had no reply subject; dropping"); + continue; + }; + let token = if granted { + respond::grant( + &issuer, + &issuer.public_key(), + &req.server_id.id, + &req.user_nkey, + ) + } else { + respond::deny(&issuer, &req.server_id.id, &req.user_nkey) + }; + if let Err(e) = client.publish(reply_to, token.into()).await { + tracing::warn!(error = ?e, "failed to publish auth response"); + } + } + anyhow::bail!("subscription to {AUTH_SUBJECT} ended") +} diff --git a/swarm-nats-auth/src/request.rs b/swarm-nats-auth/src/request.rs new file mode 100644 index 00000000..0c87212a --- /dev/null +++ b/swarm-nats-auth/src/request.rs @@ -0,0 +1,134 @@ +//! Decoding the server's authorization request. +//! +//! The payload on `$SYS.REQ.USER.AUTH` is a NATS JWT: three base64url +//! (no-pad) segments separated by `.`. The middle segment is the claim set, +//! and its `nats` object carries what the client presented. +//! +//! Decoding is deliberately hand-rolled while *minting* is not. The asymmetry +//! is the point: producing a signed artifact wrongly is silent — the server +//! rejects it and the reason is on the far side — whereas mis-parsing an +//! inbound one fails loudly on the very next field. `nats-jwt` is also +//! encode-only, so there is nothing to reuse here. + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +/// The subset of an `authorization_request` claim this responder acts on. +#[derive(Debug, Deserialize)] +pub struct AuthRequest { + /// Everything the connecting client sent in its `CONNECT` line. + pub connect_opts: ConnectOpts, + /// Ephemeral user nkey the server minted for this connection. The user + /// JWT we issue must be addressed to it, not to any name the client + /// chose — a client-supplied identity is a request, not a fact. + pub user_nkey: String, + /// Server this request came from; echoed back in the response so a + /// reply cannot be replayed at a different server in the cluster. + pub server_id: ServerId, +} + +/// The client-controlled half of the request. Every field here is attacker +/// input: it is whatever the connecting client typed, not something the +/// server vouches for. +/// +/// The wire object also carries `user` / `pass` when a client authenticates +/// that way. They are deliberately **not** modelled: the swarm authenticates +/// against the `IdP`, and a field that exists in this struct is a field +/// someone will eventually branch on. +#[derive(Debug, Deserialize)] +pub struct ConnectOpts { + /// Bearer token, when the client presented one. The only field this + /// responder acts on. + #[serde(default)] + pub auth_token: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ServerId { + pub id: String, +} + +/// Split a NATS JWT and deserialize its claim set's `nats` object. +/// +/// The signature is **not** verified here — see the module docs on +/// `main.rs` for why that is a decision and not an oversight. +pub fn decode(payload: &[u8]) -> Result { + /// Only the `nats` object is modelled; `iss`/`sub`/`exp` are the server's + /// business, and claiming to understand fields we do not act on would + /// invite someone to trust them. + #[derive(Deserialize)] + struct Claims { + nats: AuthRequest, + } + + let text = std::str::from_utf8(payload).context("auth request is not utf-8")?; + let mut parts = text.split('.'); + // Exactly three segments: a fourth means this is not the shape we think + // it is, and a lenient split is how a parser starts accepting things the + // producer never meant to send. + let (Some(_header), Some(claims), Some(_sig), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + bail!("auth request is not a three-segment JWT") + }; + let raw = base64_url_decode(claims).context("claim segment is not base64url")?; + + let claims: Claims = + serde_json::from_slice(&raw).context("claim segment is not the expected JSON")?; + Ok(claims.nats) +} + +/// base64url without padding, as JWT uses. Written out rather than pulled in: +/// `data-encoding` is already in the tree via `nkeys`, but its no-pad URL +/// alphabet spec is one line either way and this keeps the direct dependency +/// list honest about what this crate actually needs. +fn base64_url_decode(s: &str) -> Result> { + data_encoding::BASE64URL_NOPAD + .decode(s.as_bytes()) + .context("base64url decode") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn jwt_with(claims: &str) -> String { + let b = |s: &str| data_encoding::BASE64URL_NOPAD.encode(s.as_bytes()); + format!("{}.{}.{}", b("{}"), b(claims), b("sig")) + } + + #[test] + fn decodes_a_token_request() { + let req = decode( + jwt_with( + r#"{"nats":{"connect_opts":{"auth_token":"t0k"}, + "user_nkey":"UABC","server_id":{"id":"NDEADBEEF"}}}"#, + ) + .as_bytes(), + ) + .expect("decode"); + assert_eq!(req.connect_opts.auth_token.as_deref(), Some("t0k")); + assert_eq!(req.user_nkey, "UABC"); + assert_eq!(req.server_id.id, "NDEADBEEF"); + } + + #[test] + fn a_request_without_a_token_is_still_well_formed() { + // An anonymous connect must parse, then be *rejected* by policy — + // not fail to parse. Conflating "malformed" with "unauthorized" + // loses the ability to tell a broken server from an attacker. + let req = decode( + jwt_with(r#"{"nats":{"connect_opts":{},"user_nkey":"U","server_id":{"id":"N"}}}"#) + .as_bytes(), + ) + .expect("decode"); + assert!(req.connect_opts.auth_token.is_none()); + } + + #[test] + fn rejects_a_payload_that_is_not_a_jwt() { + assert!(decode(b"not-a-jwt").is_err()); + assert!(decode(b"two.parts").is_err()); + assert!(decode(b"four.parts.here.too").is_err()); + } +} diff --git a/swarm-nats-auth/src/respond.rs b/swarm-nats-auth/src/respond.rs new file mode 100644 index 00000000..bd01ee2e --- /dev/null +++ b/swarm-nats-auth/src/respond.rs @@ -0,0 +1,272 @@ +//! Minting the reply the server expects on `msg.reply`. +//! +//! Two JWTs are involved and only one of them can be produced by a crate: +//! +//! * the **user JWT** — `nats-jwt` builds and signs it; +//! * the **`authorization_response`** wrapper that carries it — `nats-jwt` +//! cannot. Its `IntoNatsClaims` trait returns the closed enum +//! `NatsClaims { User, Account }`, so a third claim type is inexpressible +//! through it, and its `Claims` struct has no `aud`, which this response +//! requires (`aud` is the server id, so a reply cannot be replayed at a +//! different server in the cluster). +//! +//! So the wrapper is hand-built here, following the algorithm read out of +//! `nats-jwt`'s own `sign()`: serialise the claims with `jti` **empty**, +//! sha256 that, `BASE32HEX_NOPAD` the digest into `jti`, re-serialise, and +//! sign `"."` with the account key. +//! +//! ⚠️ `BASE32HEX`, not `BASE32`. That single word is the kind of thing that +//! produces a token the server rejects with no useful reason, and it is +//! copied from the reference implementation rather than from memory — which +//! is also why [`tests::hand_built_matches_the_reference`] exists: it builds +//! a *user* token both ways and asserts byte equality, so this encoder is +//! checked against `nats-jwt` on the shape that crate does model. Trusting it +//! on the shape `nats-jwt` does **not** model has to rest on something better +//! than my reading of its source. + +use data_encoding::{BASE32HEX_NOPAD, BASE64URL_NOPAD}; +use nkeys::KeyPair; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +/// Header every NATS JWT carries. Byte-identical to `nats-jwt`'s, and the +/// equality test would catch it drifting. +const JWT_HEADER: &str = r#"{"typ":"JWT","alg":"ed25519-nkey"}"#; + +/// A NATS JWT claim set, with `aud` — which the callout response needs and +/// `nats-jwt`'s own `Claims` lacks. +#[derive(Serialize)] +struct Claims { + iat: i64, + iss: String, + jti: String, + sub: String, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + aud: Option, + nats: T, +} + +/// The `nats` object of an `authorization_response`. +/// +/// Exactly one of `jwt` / `error` is set. They are `Option`s rather than an +/// enum so the serialised shape matches the wire format directly, and the +/// constructors below are the only way to build one — a caller cannot +/// accidentally produce a response that carries both, which a server would be +/// free to read either way. +#[derive(Serialize)] +struct AuthResponse { + #[serde(skip_serializing_if = "Option::is_none")] + jwt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(rename = "type")] + kind: &'static str, + version: i64, +} + +/// Sign a claim set the way `nats-jwt` does. `now` is injected so the test +/// can pin it; production passes the wall clock. +fn sign(mut claims: Claims, key: &KeyPair) -> String { + claims.jti = String::new(); + let unhashed = serde_json::to_string(&claims).expect("claims serialisation cannot fail"); + let digest = Sha256::digest(unhashed.as_bytes()); + claims.jti = BASE32HEX_NOPAD.encode(&digest); + + let body = serde_json::to_string(&claims).expect("claims serialisation cannot fail"); + let half = format!( + "{}.{}", + BASE64URL_NOPAD.encode(JWT_HEADER.as_bytes()), + BASE64URL_NOPAD.encode(body.as_bytes()) + ); + let sig = key + .sign(half.as_bytes()) + .expect("ed25519 signing cannot fail"); + format!("{half}.{}", BASE64URL_NOPAD.encode(&sig)) +} + +/// Wrap a decision in an `authorization_response` addressed to the server +/// that asked. +/// +/// A denial is a *signed response carrying an error*, never silence: the +/// server cannot tell an absent responder from a refusing one, so staying +/// quiet turns every rejection into a timeout and hides an outage behind +/// what looks like normal denials. +fn response( + now: i64, + issuer: &KeyPair, + server_id: &str, + user_nkey: &str, + verdict: Result, +) -> String { + let (jwt, error) = match verdict { + Ok(user_jwt) => (Some(user_jwt), None), + Err(reason) => (None, Some(reason)), + }; + sign( + Claims { + iat: now, + iss: issuer.public_key(), + jti: String::new(), + sub: user_nkey.to_owned(), + name: user_nkey.to_owned(), + aud: Some(server_id.to_owned()), + nats: AuthResponse { + jwt, + error, + kind: "authorization_response", + version: 2, + }, + }, + issuer, + ) +} + +/// Current unix seconds, as the JWT `iat`. +fn now_secs() -> i64 { + i64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time is after the unix epoch") + .as_secs(), + ) + .expect("seconds since the epoch fit in an i64") +} + +/// Grant: mint a user JWT in `account` for `user_nkey` and wrap it. +pub fn grant(issuer: &KeyPair, account: &str, server_id: &str, user_nkey: &str) -> String { + let user_jwt = nats_jwt::Token::new_user(account, user_nkey).sign(issuer); + response(now_secs(), issuer, server_id, user_nkey, Ok(user_jwt)) +} + +/// Deny, with a reason the server can log. +/// +/// The reason is deliberately coarse (`"unauthorized"`), not a description of +/// *why*: this string reaches an unauthenticated peer, and a precise one turns +/// the auth path into an oracle for which tokens exist. +pub fn deny(issuer: &KeyPair, server_id: &str, user_nkey: &str) -> String { + response( + now_secs(), + issuer, + server_id, + user_nkey, + Err("unauthorized".to_owned()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirror of what `nats-jwt` puts in a user token, **in its field order**. + /// + /// 🩸 The first version of the equality test round-tripped `nats` through + /// `serde_json::Value` and failed — not because the encoder was wrong, but + /// because `Value` is a `BTreeMap`, so re-serialising sorted the keys and + /// changed the bytes. The gate's *method* was the defect. Worth keeping the + /// scar: `jti` is a hash over serialised claims, so anything that reorders + /// fields between hashing and signing is a live hazard here, not merely a + /// test artefact. + #[derive(serde::Serialize)] + struct UserNats { + #[serde(rename = "type")] + kind: &'static str, + issuer_account: String, + subs: i64, + data: i64, + payload: i64, + bearer_token: bool, + version: i64, + } + + /// The gate that makes the hand-built encoder trustworthy: build a *user* + /// token with `nats-jwt` and with this module's `sign`, and require the + /// bytes to be identical. + /// + /// It works because `nats-jwt`'s `sign()` derives `iat` from the wall + /// clock and everything else from its inputs, so signing the same claims + /// in the same second must produce the same string. If this module's + /// header, field order, `jti` alphabet or signing input ever drifts from + /// the reference, this fails — including the `BASE32HEX`-vs-`BASE32` + /// distinction, which is otherwise invisible until a server refuses a + /// token. + #[test] + fn hand_built_matches_the_reference() { + let account = KeyPair::new_account(); + let user = KeyPair::new_user(); + let reference = + nats_jwt::Token::new_user(account.public_key(), user.public_key()).sign(&account); + + // Mirror what `nats-jwt` puts in a user token **as a struct**, in its + // field order. + // + // 🩸 The first version of this test round-tripped `nats` through + // `serde_json::Value` and failed — not because the encoder was wrong, + // but because `Value` is a `BTreeMap`, so re-serialising sorted the + // keys and changed the bytes. The gate's *method* was the defect, and + // it is worth keeping the scar: `jti` is a hash over serialised + // claims, so anything that reorders fields between hashing and + // signing is a live hazard here, not just a test artefact. + let decoded = decode_claims(&reference); + let mine = sign( + Claims { + iat: decoded["iat"].as_i64().expect("iat"), + iss: account.public_key(), + jti: String::new(), + sub: user.public_key(), + name: user.public_key(), + aud: None, + nats: UserNats { + kind: "user", + issuer_account: account.public_key(), + subs: -1, + data: -1, + payload: -1, + bearer_token: false, + version: 2, + }, + }, + &account, + ); + assert_eq!(mine, reference, "hand-built encoder drifted from nats-jwt"); + } + + #[test] + fn a_denial_is_signed_and_carries_no_jwt() { + let account = KeyPair::new_account(); + let token = deny(&account, "NSERVER", "UCLIENT"); + let claims = decode_claims(&token); + assert_eq!(claims["nats"]["type"], "authorization_response"); + assert_eq!(claims["nats"]["error"], "unauthorized"); + assert!( + claims["nats"]["jwt"].is_null(), + "a denial must not carry a user jwt" + ); + // `aud` binds the reply to the asking server; without it a captured + // response is replayable at any other server in the cluster. + assert_eq!(claims["aud"], "NSERVER"); + } + + #[test] + fn a_grant_carries_a_user_jwt_and_no_error() { + let account = KeyPair::new_account(); + let claims = decode_claims(&grant( + &account, + &account.public_key(), + "NSERVER", + "UCLIENT", + )); + assert!(claims["nats"]["error"].is_null()); + assert!( + claims["nats"]["jwt"] + .as_str() + .is_some_and(|j| j.contains('.')) + ); + } + + fn decode_claims(jwt: &str) -> serde_json::Value { + let body = jwt.split('.').nth(1).expect("claims segment"); + serde_json::from_slice(&BASE64URL_NOPAD.decode(body.as_bytes()).expect("base64url")) + .expect("claims json") + } +} From 207fc4d2a63ce69fcdb0ffcffeb3853057393094 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 21:34:34 +0200 Subject: [PATCH 2/7] wip: nix unit + secret delivery for the callout responder --- flake.nix | 3 + nix/host-modules/swarm-nats.nix | 133 ++++++++++++++++++++++++++++++++ nix/packages/default.nix | 6 ++ 3 files changed, 142 insertions(+) diff --git a/flake.nix b/flake.nix index b439cdf3..1c7a6f53 100644 --- a/flake.nix +++ b/flake.nix @@ -151,6 +151,9 @@ services.hyperhive.swarm.ui.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-ui; + services.hyperhive.swarm.nats.authPackage = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; services.hyperhive.gateway.swaggerUiTheme = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme; diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 73c0b756..242dd1a4 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -16,6 +16,22 @@ let # authorizes. calloutAccount = "AUTH"; clientAccount = "APP"; + + machine = "swarm-nats"; + # Where the responder's credentials live *inside* the container, and the + # host path that resolves to. Two names for one location, because the + # host is the only place both filesystems are addressable. + secretDirInContainer = "/var/lib/swarm-nats-auth"; + inContainer = name: "${secretDirInContainer}/${name}"; + secretDir = "/var/lib/nixos-containers/${machine}${secretDirInContainer}"; + hostPath = name: "${secretDir}/${name}"; + + # The responder needs all three credentials. Gating on them rather than + # on `cfg.enable` keeps a half-configured hive at "queue up, denying + # everyone" instead of "unit crash-looping on a missing file". + responderConfigured = cfg.calloutUserSeedFile != "" && cfg.calloutIssuerSeedFile != ""; + clientSecretSource = "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret"; + introspectionUrl = "${toString autheliaUrl}/api/oidc/introspection"; in { # The swarm's message queue: one NATS server, reached by every hive. @@ -116,6 +132,55 @@ in rather than on purpose, so it fails at eval instead. ''; }; + + authPackage = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-nats-auth"; + description = '' + The auth-callout responder package. + + ⚠️ Named `authPackage`, not `package`, on purpose: this module + deliberately has **no** `package` option for the server itself + (see the note above — upstream's `services.nats` resolves + `pkgs.nats-server` on its own), so a bare `package` here would + read as "the NATS package" and mean something else entirely. + ''; + }; + + calloutUserSeedFile = lib.mkOption { + type = lib.types.str; + default = ""; + example = "/run/secrets/swarm-nats-callout-user.seed"; + description = '' + Absolute host path to the **seed** whose public half is + `calloutUserPublicKey`. The responder authenticates to the queue + with it. + + A `str` rather than a `path`, and the reason is not style: a + `path`-typed literal is hash-copied into the world-readable nix + store at eval time, which is the opposite of what a seed wants. + Same discipline as `otel.headersCredential`. + + Until this is set the responder cannot start, and the queue + stays in its fail-closed state — which is the correct behaviour, + not a gap. + ''; + }; + + calloutIssuerSeedFile = lib.mkOption { + type = lib.types.str; + default = ""; + example = "/run/secrets/swarm-nats-issuer.seed"; + description = '' + Absolute host path to the **account** seed whose public half is + `calloutIssuerPublicKey`. The responder signs the user JWTs it + issues with it, so possession of this file is the authority to + admit anyone to the queue. + + A `str` for the same store-leak reason as + `calloutUserSeedFile`. + ''; + }; }; config = lib.mkIf cfg.enable { @@ -276,6 +341,46 @@ in }; }; + # The auth-callout responder: the half that lets the server + # above say *yes*. Without it the `auth_callout` block is a + # door nobody can open, which is the deliberate interim state. + # + # ⚠️ It is gated on the seeds being configured rather than on + # `cfg.enable`, so a half-configured hive gets a running, + # refusing queue instead of a unit that crash-loops on a + # missing file. A queue that denies everyone is a legible + # failure; a restart loop is not. + systemd.services.swarm-nats-auth = lib.mkIf responderConfigured { + description = "swarm queue auth-callout responder"; + after = [ "nats.service" ]; + requires = [ "nats.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = lib.concatStringsSep " " [ + "${cfg.authPackage}/bin/swarm-nats-auth" + "--nats-url nats://127.0.0.1:${toString cfg.port}" + "--user-seed-file \${CREDENTIALS_DIRECTORY}/callout-user.seed" + "--issuer-seed-file \${CREDENTIALS_DIRECTORY}/issuer.seed" + "--client-secret-file \${CREDENTIALS_DIRECTORY}/oidc-client.secret" + "--client-id ${lib.escapeShellArg cfg.clientId}" + "--introspection-url ${lib.escapeShellArg introspectionUrl}" + ]; + # Every credential arrives by `LoadCredential` and is named + # on the command line only as a **path** — `argv` is + # world-readable via /proc//cmdline, so a value there + # would be readable by every process on the host netns. + LoadCredential = [ + "callout-user.seed:${inContainer "callout-user.seed"}" + "issuer.seed:${inContainer "issuer.seed"}" + "oidc-client.secret:${inContainer "oidc-client.secret"}" + ]; + DynamicUser = true; + Restart = "on-failure"; + RestartSec = "5s"; + SyslogIdentifier = "swarm-nats-auth"; + }; + }; + # The server binary, so an operator with a shell in here can # run `nats-server -t` against the generated config. The unit # resolves ExecStart through the store path and puts nothing @@ -283,5 +388,33 @@ in environment.systemPackages = [ pkgs.nats-server ]; }; }; + + # Deliver the responder's three credentials into the container before + # it starts. Same shape as `hive-matrix-oidc-secret`, and for the same + # reason it is a copy rather than a `bindMounts` entry: nixos-container + # refuses to start when a bind source is missing, so one absent seed + # would take down the **whole container including the queue**, not + # merely the responder. A far larger blast radius than the fault. + systemd.services.swarm-nats-auth-secrets = lib.mkIf responderConfigured { + description = "deliver the swarm queue responder's credentials"; + before = [ "container@swarm-nats.service" ]; + wantedBy = [ "container@swarm-nats.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + SyslogIdentifier = "swarm-nats-auth-secrets"; + }; + path = [ pkgs.coreutils ]; + script = '' + set -euo pipefail + install -d -m 0700 ${lib.escapeShellArg secretDir} + install -m 0400 ${lib.escapeShellArg cfg.calloutUserSeedFile} \ + ${lib.escapeShellArg (hostPath "callout-user.seed")} + install -m 0400 ${lib.escapeShellArg cfg.calloutIssuerSeedFile} \ + ${lib.escapeShellArg (hostPath "issuer.seed")} + install -m 0400 ${lib.escapeShellArg clientSecretSource} \ + ${lib.escapeShellArg (hostPath "oidc-client.secret")} + ''; + }; }; } diff --git a/nix/packages/default.nix b/nix/packages/default.nix index b4cc3bca..9fc145c4 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -150,6 +150,12 @@ in # Uses the same per-bin extractor, just bound on its own. swarm-controller = mkBinPackage "swarm-controller" "hyperhive swarm-level controller daemon"; + # The queue's auth-callout responder. Out of `daemonBins` for the same + # reason as the two above and one more: it runs *inside* the swarm-nats + # container, not on the host, so it belongs in that container's closure + # rather than every hive's. + swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; + # The swarm operator's CLI, out of `daemonBins` for the same reason as # the daemon above: it is installed by the swarm-controller module on # the one host that runs the controller, and belongs in that hive's From 188e27478ad01c42eb61bce6ca65f58ee2ae84e8 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 22:26:02 +0200 Subject: [PATCH 3/7] fix(swarm): name the account by aud, never issuer_account The responder answered granted=true and the server still refused every client: Error non operator mode account "AUTH": attempted to use issuer_account nats_jwt::Token::new_user always sets issuer_account, which is an operator-mode field naming the account when a signing key rather than the account identity key signed the token. In server-config mode - what this module renders - its mere presence is fatal, and the account is named by the claims' aud instead. nats-jwt can express neither aud nor the omission, so the user JWT is now hand-built by the same signer as the response wrapper, and nats-jwt moves to dev-dependencies as the encoder's test oracle. Every unit test passed throughout: they assert fields that must be present, and the defect was a field that must be absent. --- nix/host-modules/swarm-nats.nix | 5 ++ swarm-nats-auth/Cargo.toml | 20 +++-- swarm-nats-auth/src/main.rs | 14 ++-- swarm-nats-auth/src/respond.rs | 141 +++++++++++++++++++++++++++----- 4 files changed, 144 insertions(+), 36 deletions(-) diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 242dd1a4..519b1bbb 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -363,6 +363,11 @@ in "--issuer-seed-file \${CREDENTIALS_DIRECTORY}/issuer.seed" "--client-secret-file \${CREDENTIALS_DIRECTORY}/oidc-client.secret" "--client-id ${lib.escapeShellArg cfg.clientId}" + # The account admitted clients land in, by NAME: in + # server-config mode the server resolves `aud` against its + # own `accounts` block, so this and the block above have to + # be the same string — which is why both come from one let. + "--account ${lib.escapeShellArg clientAccount}" "--introspection-url ${lib.escapeShellArg introspectionUrl}" ]; # Every credential arrives by `LoadCredential` and is named diff --git a/swarm-nats-auth/Cargo.toml b/swarm-nats-auth/Cargo.toml index e9610f82..6677312a 100644 --- a/swarm-nats-auth/Cargo.toml +++ b/swarm-nats-auth/Cargo.toml @@ -39,15 +39,19 @@ futures = "0.3" # is not, and hand-rolling a key format on an auth path is how you get a # CRC bug nobody reviews. nkeys = "0.4" -# NATS JWT claim types + signing. The reply this responder sends is a *signed -# user JWT*, whose `jti` is base32(sha256(claims)) and whose header must say -# `ed25519-nkey` - format details with no feedback loop until the server -# rejects the token. Its own deps (data-encoding, nkeys, serde, serde_json, -# sha2) are already in the tree, so this costs no new transitive weight. -nats-jwt = "0.3" -# The jti digest. Already in the tree via nats-jwt; named directly because -# this crate computes one itself for the response wrapper. +# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs. sha2 = "0.10" +[dev-dependencies] +# A TEST ORACLE, not part of the production path. Neither JWT this crate emits +# is expressible through it - `Claims` has no `aud`, which the response wrapper +# needs (the server id) and the user token needs (the account name), and +# `Token::new_user` always sets `issuer_account`, which a non-operator server +# rejects outright. So both are hand-built, and this crate is what the encoder +# is checked *against*: `respond::tests::hand_built_matches_the_reference` +# builds a user token both ways and requires byte equality, on the one shape +# nats-jwt does model. +nats-jwt = "0.3" + [lints] workspace = true diff --git a/swarm-nats-auth/src/main.rs b/swarm-nats-auth/src/main.rs index 3f68131d..94217902 100644 --- a/swarm-nats-auth/src/main.rs +++ b/swarm-nats-auth/src/main.rs @@ -63,6 +63,13 @@ struct Args { #[arg(long, default_value = "swarm-nats")] client_id: String, + /// Account an admitted client is placed in. Must name an entry in the + /// server's own `accounts` block — in server-config mode the account is + /// resolved by *name*, so a value the server does not know is a grant it + /// refuses. The module passes its `clientAccount`; the default mirrors it. + #[arg(long, default_value = "APP")] + account: String, + /// Path to this responder's own OIDC client secret. #[arg(long)] client_secret_file: PathBuf, @@ -162,12 +169,7 @@ async fn main() -> anyhow::Result<()> { continue; }; let token = if granted { - respond::grant( - &issuer, - &issuer.public_key(), - &req.server_id.id, - &req.user_nkey, - ) + respond::grant(&issuer, &args.account, &req.server_id.id, &req.user_nkey) } else { respond::deny(&issuer, &req.server_id.id, &req.user_nkey) }; diff --git a/swarm-nats-auth/src/respond.rs b/swarm-nats-auth/src/respond.rs index bd01ee2e..8234991f 100644 --- a/swarm-nats-auth/src/respond.rs +++ b/swarm-nats-auth/src/respond.rs @@ -1,16 +1,30 @@ //! Minting the reply the server expects on `msg.reply`. //! -//! Two JWTs are involved and only one of them can be produced by a crate: +//! Two JWTs are involved and **neither** can be produced by `nats-jwt`, for +//! the same underlying reason: its `Claims` struct has no `aud`, and both of +//! these need one. //! -//! * the **user JWT** — `nats-jwt` builds and signs it; -//! * the **`authorization_response`** wrapper that carries it — `nats-jwt` -//! cannot. Its `IntoNatsClaims` trait returns the closed enum -//! `NatsClaims { User, Account }`, so a third claim type is inexpressible -//! through it, and its `Claims` struct has no `aud`, which this response -//! requires (`aud` is the server id, so a reply cannot be replayed at a -//! different server in the cluster). +//! * the **`authorization_response`** wrapper — `aud` is the asking server's +//! id, so a captured reply cannot be replayed at a different server in the +//! cluster. Its `IntoNatsClaims` trait also returns the closed enum +//! `NatsClaims { User, Account }`, so the claim type is inexpressible +//! through it twice over. +//! * the **user JWT** it carries — in **server-config mode** (which is what +//! `nix/host-modules/swarm-nats.nix` renders: `accounts { AUTH, APP }`, +//! no operator), `aud` is the *account name* the admitted client lands in, +//! and `issuer_account` must be **absent**. `nats_jwt::Token::new_user` +//! always sets `issuer_account` and can express no `aud` at all. //! -//! So the wrapper is hand-built here, following the algorithm read out of +//! 🩸 That second one was found by running the thing, not by reading it. The +//! responder answered `granted=true` and the server still refused the client, +//! logging `Error non operator mode account "AUTH": attempted to use +//! issuer_account`. Every unit test passed throughout: they assert the shape +//! this module *intends*, and the field that broke it was one nobody had +//! reason to assert was missing. `issuer_account` is an operator-mode field — +//! it names the account when a *signing* key rather than the account identity +//! key signed the token — and in config mode its mere presence is fatal. +//! +//! So both are hand-built here, following the algorithm read out of //! `nats-jwt`'s own `sign()`: serialise the claims with `jti` **empty**, //! sha256 that, `BASE32HEX_NOPAD` the digest into `jti`, re-serialise, and //! sign `"."` with the account key. @@ -21,8 +35,10 @@ //! is also why [`tests::hand_built_matches_the_reference`] exists: it builds //! a *user* token both ways and asserts byte equality, so this encoder is //! checked against `nats-jwt` on the shape that crate does model. Trusting it -//! on the shape `nats-jwt` does **not** model has to rest on something better -//! than my reading of its source. +//! on the shapes `nats-jwt` does **not** model has to rest on something +//! better than my reading of its source. `nats-jwt` is therefore a +//! **dev-dependency**: it is this module's test oracle, not part of the path +//! that runs in production. use data_encoding::{BASE32HEX_NOPAD, BASE64URL_NOPAD}; use nkeys::KeyPair; @@ -133,10 +149,59 @@ fn now_secs() -> i64 { .expect("seconds since the epoch fit in an i64") } -/// Grant: mint a user JWT in `account` for `user_nkey` and wrap it. +/// The `nats` object of a **user** JWT, server-config mode. +/// +/// Field-for-field what `nats-jwt` emits **minus `issuer_account`**, whose +/// presence a non-operator server rejects outright (see the module docs). The +/// account is named by the enclosing claims' `aud` instead. +/// +/// The `-1`s are NATS' "unlimited": no cap on subscriptions, payload size or +/// total data. Limits belong on the account, which the module owns; putting +/// them here would mean every issued token silently disagreed with the +/// deployed policy the moment that policy changed. +#[derive(Serialize)] +struct UserNats { + #[serde(rename = "type")] + kind: &'static str, + subs: i64, + data: i64, + payload: i64, + bearer_token: bool, + version: i64, +} + +/// Mint the user JWT an admitted client presents. +/// +/// `account` is the account **name** from the server's `accounts` block (the +/// module's `clientAccount`), not a public key — in config mode the server +/// resolves `aud` against its own config rather than against a key. +fn user_jwt(now: i64, issuer: &KeyPair, account: &str, user_nkey: &str) -> String { + sign( + Claims { + iat: now, + iss: issuer.public_key(), + jti: String::new(), + sub: user_nkey.to_owned(), + name: user_nkey.to_owned(), + aud: Some(account.to_owned()), + nats: UserNats { + kind: "user", + subs: -1, + data: -1, + payload: -1, + bearer_token: false, + version: 2, + }, + }, + issuer, + ) +} + +/// Grant: mint a user JWT placing the client in `account` and wrap it. pub fn grant(issuer: &KeyPair, account: &str, server_id: &str, user_nkey: &str) -> String { - let user_jwt = nats_jwt::Token::new_user(account, user_nkey).sign(issuer); - response(now_secs(), issuer, server_id, user_nkey, Ok(user_jwt)) + let now = now_secs(); + let jwt = user_jwt(now, issuer, account, user_nkey); + response(now, issuer, server_id, user_nkey, Ok(jwt)) } /// Deny, with a reason the server can log. @@ -160,6 +225,12 @@ mod tests { /// Mirror of what `nats-jwt` puts in a user token, **in its field order**. /// + /// Deliberately *not* [`super::UserNats`]: this one carries + /// `issuer_account`, because the reference does. Production must not, and + /// keeping the two structs separate is what lets one test pin the encoder + /// against `nats-jwt` while another asserts production diverges from it in + /// exactly one field. + /// /// 🩸 The first version of the equality test round-tripped `nats` through /// `serde_json::Value` and failed — not because the encoder was wrong, but /// because `Value` is a `BTreeMap`, so re-serialising sorted the keys and @@ -168,7 +239,7 @@ mod tests { /// fields between hashing and signing is a live hazard here, not merely a /// test artefact. #[derive(serde::Serialize)] - struct UserNats { + struct ReferenceUserNats { #[serde(rename = "type")] kind: &'static str, issuer_account: String, @@ -216,7 +287,7 @@ mod tests { sub: user.public_key(), name: user.public_key(), aud: None, - nats: UserNats { + nats: ReferenceUserNats { kind: "user", issuer_account: account.public_key(), subs: -1, @@ -250,12 +321,7 @@ mod tests { #[test] fn a_grant_carries_a_user_jwt_and_no_error() { let account = KeyPair::new_account(); - let claims = decode_claims(&grant( - &account, - &account.public_key(), - "NSERVER", - "UCLIENT", - )); + let claims = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT")); assert!(claims["nats"]["error"].is_null()); assert!( claims["nats"]["jwt"] @@ -264,6 +330,37 @@ mod tests { ); } + /// 🩸 THE REGRESSION TEST FOR THE BUG THE UNIT TESTS COULD NOT SEE. + /// + /// The first version of this module minted the user JWT with + /// `nats_jwt::Token::new_user`, which always sets `issuer_account`. Every + /// test passed, the responder logged `granted=true`, and the server still + /// refused every client: `Error non operator mode account "AUTH": + /// attempted to use issuer_account`. + /// + /// The lesson is in what this asserts — an **absence**. The suite was full + /// of assertions about fields that had to be *present*, and a field that + /// must not exist is invisible to all of them. It took running a real + /// server to find, so the cheap check goes here to keep it found. + #[test] + fn the_issued_user_jwt_names_its_account_by_aud_and_sets_no_issuer_account() { + let account = KeyPair::new_account(); + let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT")); + let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt")); + + // In server-config mode the account is named by `aud`... + assert_eq!(user["aud"], "APP", "the user jwt must name its account"); + // ...and `issuer_account` is an operator-mode field whose mere + // presence makes the server reject the client. + assert!( + user["nats"]["issuer_account"].is_null(), + "issuer_account is fatal in non-operator mode" + ); + assert_eq!(user["nats"]["type"], "user"); + assert_eq!(user["sub"], "UCLIENT"); + assert_eq!(user["iss"], account.public_key()); + } + fn decode_claims(jwt: &str) -> serde_json::Value { let body = jwt.split('.').nth(1).expect("claims segment"); serde_json::from_slice(&BASE64URL_NOPAD.decode(body.as_bytes()).expect("base64url")) From b22f0ecaa12552b15aa2b1bf08e3d4d71a74b4c9 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 23:00:49 +0200 Subject: [PATCH 4/7] docs(swarm): trim the respond module doc under the 30-line block lint The discovery narrative belongs in the PR body, which carries it. What stays at the line is what the code cannot say: that issuer_account must be absent and why reaching for Token::new_user reintroduces the bug, the BASE32HEX-vs-BASE32 distinction, and that nats-jwt is a test oracle rather than a runtime dependency. --- swarm-nats-auth/src/respond.rs | 58 +++++++++++++--------------------- 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/swarm-nats-auth/src/respond.rs b/swarm-nats-auth/src/respond.rs index 8234991f..2d0162aa 100644 --- a/swarm-nats-auth/src/respond.rs +++ b/swarm-nats-auth/src/respond.rs @@ -1,44 +1,30 @@ //! Minting the reply the server expects on `msg.reply`. //! -//! Two JWTs are involved and **neither** can be produced by `nats-jwt`, for -//! the same underlying reason: its `Claims` struct has no `aud`, and both of -//! these need one. +//! Both JWTs are hand-built because neither is expressible through +//! `nats-jwt`: its `Claims` has no `aud`, which the `authorization_response` +//! wrapper needs (the asking server's id, so a captured reply cannot be +//! replayed elsewhere in the cluster) and the user token needs (the account +//! name). Its `IntoNatsClaims` also returns a closed +//! `NatsClaims { User, Account }`. //! -//! * the **`authorization_response`** wrapper — `aud` is the asking server's -//! id, so a captured reply cannot be replayed at a different server in the -//! cluster. Its `IntoNatsClaims` trait also returns the closed enum -//! `NatsClaims { User, Account }`, so the claim type is inexpressible -//! through it twice over. -//! * the **user JWT** it carries — in **server-config mode** (which is what -//! `nix/host-modules/swarm-nats.nix` renders: `accounts { AUTH, APP }`, -//! no operator), `aud` is the *account name* the admitted client lands in, -//! and `issuer_account` must be **absent**. `nats_jwt::Token::new_user` -//! always sets `issuer_account` and can express no `aud` at all. +//! ⛔ **`issuer_account` must be ABSENT from the user token.** It is an +//! operator-mode field, and `nix/host-modules/swarm-nats.nix` renders +//! *server-config* mode, where its mere presence makes the server refuse the +//! client — `Error non operator mode account "AUTH": attempted to use +//! issuer_account`, with the responder having answered `granted=true`. +//! `nats_jwt::Token::new_user` always sets it, which is why reaching for that +//! constructor again would reintroduce the bug. //! -//! 🩸 That second one was found by running the thing, not by reading it. The -//! responder answered `granted=true` and the server still refused the client, -//! logging `Error non operator mode account "AUTH": attempted to use -//! issuer_account`. Every unit test passed throughout: they assert the shape -//! this module *intends*, and the field that broke it was one nobody had -//! reason to assert was missing. `issuer_account` is an operator-mode field — -//! it names the account when a *signing* key rather than the account identity -//! key signed the token — and in config mode its mere presence is fatal. +//! Signing follows `nats-jwt`'s own `sign()`: serialise the claims with `jti` +//! **empty**, sha256 that, `BASE32HEX_NOPAD` the digest into `jti`, +//! re-serialise, sign `"."` with the account +//! key. ⚠️ `BASE32HEX`, not `BASE32` — one word, and the only symptom is a +//! token the server rejects without saying why. //! -//! So both are hand-built here, following the algorithm read out of -//! `nats-jwt`'s own `sign()`: serialise the claims with `jti` **empty**, -//! sha256 that, `BASE32HEX_NOPAD` the digest into `jti`, re-serialise, and -//! sign `"."` with the account key. -//! -//! ⚠️ `BASE32HEX`, not `BASE32`. That single word is the kind of thing that -//! produces a token the server rejects with no useful reason, and it is -//! copied from the reference implementation rather than from memory — which -//! is also why [`tests::hand_built_matches_the_reference`] exists: it builds -//! a *user* token both ways and asserts byte equality, so this encoder is -//! checked against `nats-jwt` on the shape that crate does model. Trusting it -//! on the shapes `nats-jwt` does **not** model has to rest on something -//! better than my reading of its source. `nats-jwt` is therefore a -//! **dev-dependency**: it is this module's test oracle, not part of the path -//! that runs in production. +//! `nats-jwt` is therefore a **dev-dependency, not a runtime one**: it is this +//! module's test oracle. [`tests::hand_built_matches_the_reference`] builds a +//! user token both ways and requires byte equality, so the encoder is checked +//! against the reference on the one shape that crate does model. use data_encoding::{BASE32HEX_NOPAD, BASE64URL_NOPAD}; use nkeys::KeyPair; From 5be2918559288e1be18963d67e24713361ad646c Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 23:03:51 +0200 Subject: [PATCH 5/7] docs(swarm): add the swarm-nats-auth package readme Every other crate has one and Cargo.toml already named it. Follows the sibling shape: what it is, why it is a crate rather than more config, the invariant that is easy to break (issuer_account must be absent, and Token::new_user reintroduces it), and what the tests do NOT cover - the introspection endpoint under test is a stub, so nothing here says anything about the real authelia integration. --- swarm-nats-auth/README.md | 66 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 swarm-nats-auth/README.md diff --git a/swarm-nats-auth/README.md b/swarm-nats-auth/README.md new file mode 100644 index 00000000..9f154139 --- /dev/null +++ b/swarm-nats-auth/README.md @@ -0,0 +1,66 @@ +# swarm-nats-auth + +The auth-callout responder for the swarm's NATS queue — the half that lets +the server say *yes*. + +`nix/host-modules/swarm-nats.nix` configures `nats-server` with an +`auth_callout` block. **That block with no responder is the fail-closed +state**, and it is the measured one rather than the obvious one: on the pinned +nats-server, both `authorization { }` and `authorization { users: [] }` answer +PONG to an anonymous client, while an `auth_callout` block sets +`auth_required` and refuses every client no responder has approved. So the +module ships the final config from the start and this crate only adds the +ability to approve. No interim hole is ever opened. + +## Why a crate and not more config + +The reply is a **signed NATS user JWT**. Signing needs the account nkey seed +and the JWT framing, which is program work — which is why the container and +its config landed first and this arrived separately, rather than the pair +being one change. + +## The invariant that is easy to break + +⛔ **`issuer_account` must be absent from the issued user token.** It is an +operator-mode field. The module renders *server-config* mode (`accounts +{ AUTH, APP }`, no operator), where its mere presence makes the server refuse +the client — `Error non operator mode account "AUTH": attempted to use +issuer_account` — while the responder cheerfully reports `granted=true`. The +account is named by the claims' `aud` instead. + +`nats_jwt::Token::new_user` always sets it, so reaching for that constructor +reintroduces the bug. `nats-jwt` is a **dev-dependency**: it cannot express +`aud` on either token, and its role here is as the encoder's *test oracle*, +not part of the path that runs. + +## Rules the code follows + +- **A denial is a signed response carrying an error, never silence.** The + server cannot tell an absent responder from a refusing one, so staying quiet + turns every rejection into a timeout and hides an outage inside what looks + like ordinary denials. +- **Anything that is not an explicit `{"active": true}` denies** — including + an introspection call that could not be *made*. The failure modes of an HTTP + call are exactly the conditions under which an attacker would most like this + to fall open. +- **Every credential is a path, never a value.** A value in nix config lands + in the world-readable store; a value in `argv` is readable via + `/proc//cmdline`, which is `0444`. Paths are not secrets, so passing + them as flags is fine. +- **The introspection timeout is pinned below the server's `authorization. + timeout`**, with a test asserting the relation — a responder that answers + after the server gave up is indistinguishable from one that never answered. + +## What the tests do and do not cover + +Unit tests cover the JWT framing, including byte-equality against `nats-jwt` +on the one shape that crate models. They are **not** sufficient on their own: +this crate's shape is decided by a server that parses what it emits, so the +change was also driven against a real `nats-server` — every refusal repeated +*with the responder live*, because a responder that says yes to everyone +passes "a client can connect" perfectly. That harness lives outside this repo; +the PR that added this crate links it. + +⚠️ **Its introspection endpoint is a stub.** Those runs prove this crate's own +behaviour and nothing about the real authelia integration, which needs a +deployment. From 9b35be2a24c323384dc731ea63e35c9b58194583 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 23:06:47 +0200 Subject: [PATCH 6/7] chore(swarm): move swarm-nats-auth's deps to the workspace Per mara on the PR: all deps go into workspace level so versions stay consistent. async-nats, data-encoding, nkeys and nats-jwt are new [workspace.dependencies] entries; sha2 and futures-util now come from there. The crate had asked for sha2 0.10 while the workspace standard is 0.11, and for the futures facade where the workspace carries futures-util. Both resolved without adding a Cargo.lock entry - each was already in the tree via something else - so the drift was invisible in the lock and would only have surfaced as two versions of a hashing crate in one binary. --- Cargo.lock | 4 ++-- Cargo.toml | 17 +++++++++++++++++ swarm-nats-auth/Cargo.toml | 34 +++++++++------------------------- swarm-nats-auth/src/main.rs | 2 +- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ca8da35..b6b1966c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4564,13 +4564,13 @@ dependencies = [ "async-nats", "clap", "data-encoding", - "futures", + "futures-util", "nats-jwt", "nkeys", "reqwest 0.13.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index cb0b4417..5b85aa74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -140,5 +140,22 @@ matrix-sdk = { version = "0.18", default-features = false, features = [ futures-util = "0.3" hmac = "0.13" sha2 = "0.11" +# The NATS protocol client, for the swarm queue's auth-callout responder. +# `default-features = false` because the default set is broad - jetstream, kv, +# object-store, websockets, service - and a callout responder speaks none of +# them. What is named is the whole requirement: the server generation we +# deploy, nkey auth, and a TLS backend. +async-nats = { version = "0.50", default-features = false, features = [ + "server_2_14", + "nkeys", + "ring", +] } +data-encoding = "2" +# The nkey *format* - ed25519 + base32 + CRC16. The primitives are already in +# the tree; the format is not, and hand-rolling a key format on an auth path +# is how you get a CRC bug nobody reviews. +nkeys = "0.4" +# A TEST ORACLE, not a runtime dependency - see swarm-nats-auth's respond.rs. +nats-jwt = "0.3" utoipa = { version = "5", features = ["axum_extras", "chrono"] } utoipa-axum = "0.2" diff --git a/swarm-nats-auth/Cargo.toml b/swarm-nats-auth/Cargo.toml index 6677312a..d85f10d5 100644 --- a/swarm-nats-auth/Cargo.toml +++ b/swarm-nats-auth/Cargo.toml @@ -10,37 +10,21 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +async-nats.workspace = true clap.workspace = true +# base64url for decoding the inbound request JWT. +data-encoding.workspace = true +# StreamExt::next on the subscription: async-nats returns a Stream. +futures-util.workspace = true +nkeys.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs. +sha2.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -# The NATS protocol client. `default-features = false` because the default set -# is broad - jetstream, kv, object-store, websockets, service - and a callout -# responder speaks none of them. What is named here is the whole requirement: -# the server generation we actually deploy, nkey auth, and a TLS backend. -# (Checked what dropping the defaults costs, the way `internal-logs` was once -# lost that way: nothing in the unused set is a diagnostic.) -async-nats = { version = "0.50", default-features = false, features = [ - "server_2_14", - "nkeys", - "ring", -] } -# base64url for decoding the inbound request JWT. Already in the tree via -# nkeys; named directly because this crate uses it directly. -data-encoding = "2" -# StreamExt::next on the subscription. async-nats returns a Stream, not an -# iterator, and futures is already in the tree. -futures = "0.3" -# nkey seed handling + signing. The primitives (ed25519-dalek, data-encoding) -# are already in the tree, but the nkey *format* - ed25519 + base32 + CRC16 - -# is not, and hand-rolling a key format on an auth path is how you get a -# CRC bug nobody reviews. -nkeys = "0.4" -# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs. -sha2 = "0.10" [dev-dependencies] # A TEST ORACLE, not part of the production path. Neither JWT this crate emits @@ -51,7 +35,7 @@ sha2 = "0.10" # is checked *against*: `respond::tests::hand_built_matches_the_reference` # builds a user token both ways and requires byte equality, on the one shape # nats-jwt does model. -nats-jwt = "0.3" +nats-jwt.workspace = true [lints] workspace = true diff --git a/swarm-nats-auth/src/main.rs b/swarm-nats-auth/src/main.rs index 94217902..fa948747 100644 --- a/swarm-nats-auth/src/main.rs +++ b/swarm-nats-auth/src/main.rs @@ -25,7 +25,7 @@ use std::path::PathBuf; use anyhow::Context; use clap::Parser; -use futures::StreamExt; +use futures_util::StreamExt; mod introspect; mod request; From 32643dae369a365ca6282b77bb6b01ad1488e999 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 15 Aug 2026 00:13:54 +0200 Subject: [PATCH 7/7] fix(swarm): the doc link pointed at a cfg(test) item rustdoc builds without the test cfg, so `tests::hand_built_matches_the_reference` resolves to nothing and broken_intra_doc_links denies it. Plain backticks rather than making the item visible - a lint is not a reason to change an item's visibility. Earned, not lost: this is the first completed rustdoc run on a brand-new crate, and the link was wrong from the first commit. --- swarm-nats-auth/src/respond.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swarm-nats-auth/src/respond.rs b/swarm-nats-auth/src/respond.rs index 2d0162aa..e1473809 100644 --- a/swarm-nats-auth/src/respond.rs +++ b/swarm-nats-auth/src/respond.rs @@ -22,7 +22,7 @@ //! token the server rejects without saying why. //! //! `nats-jwt` is therefore a **dev-dependency, not a runtime one**: it is this -//! module's test oracle. [`tests::hand_built_matches_the_reference`] builds a +//! module's test oracle: `tests::hand_built_matches_the_reference` builds a //! user token both ways and requires byte equality, so the encoder is checked //! against the reference on the one shape that crate does model.