From 3a75c54bcb945522d104c2a30d99bdd6b6e156c0 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 21:02:26 +0200 Subject: [PATCH] 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") + } +}