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.
355 lines
14 KiB
Rust
355 lines
14 KiB
Rust
//! Minting the reply the server expects on `msg.reply`.
|
|
//!
|
|
//! 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 }`.
|
|
//!
|
|
//! ⛔ **`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.
|
|
//!
|
|
//! Signing follows `nats-jwt`'s own `sign()`: serialise the claims with `jti`
|
|
//! **empty**, sha256 that, `BASE32HEX_NOPAD` the digest into `jti`,
|
|
//! re-serialise, sign `"<b64url header>.<b64url claims>"` with the account
|
|
//! key. ⚠️ `BASE32HEX`, not `BASE32` — one word, and the only symptom is a
|
|
//! 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
|
|
//! 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;
|
|
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")
|
|
}
|
|
|
|
/// 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 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.
|
|
///
|
|
/// 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**.
|
|
///
|
|
/// 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
|
|
/// 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 ReferenceUserNats {
|
|
#[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: ReferenceUserNats {
|
|
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, "APP", "NSERVER", "UCLIENT"));
|
|
assert!(claims["nats"]["error"].is_null());
|
|
assert!(
|
|
claims["nats"]["jwt"]
|
|
.as_str()
|
|
.is_some_and(|j| j.contains('.'))
|
|
);
|
|
}
|
|
|
|
/// 🩸 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"))
|
|
.expect("claims json")
|
|
}
|
|
}
|