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") + } +}