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.
This commit is contained in:
parent
34fed47400
commit
3a75c54bcb
7 changed files with 942 additions and 10 deletions
272
swarm-nats-auth/src/respond.rs
Normal file
272
swarm-nats-auth/src/respond.rs
Normal file
|
|
@ -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 `"<b64url header>.<b64url claims>"` 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<T: Serialize> {
|
||||
iat: i64,
|
||||
iss: String,
|
||||
jti: String,
|
||||
sub: String,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
aud: Option<String>,
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[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<T: Serialize>(mut claims: Claims<T>, 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, String>,
|
||||
) -> 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue