Watch
0
0
Fork
You've already forked hyperhive
0

swarm-nats-auth: refuse an auth request the server did not seal

Every nats-server this module starts renders `auth_callout.xkey` or does
not start, and the responder serves only that server, in the same
container and generation. A request without `Nats-Server-Xkey` therefore
comes from no supported server, so the plaintext framing is gone: such a
request is logged at warn and gets no reply, and the server that sent it
times out the connect as a denial. A signed denial would have had to go
out unsealed, which is the path this removes.

Also scopes two claims to what is true: the xkey's public half is read by
the swarm-nats-auth policy only, and the metadata-delete gap in the
replace-key procedure is about the two xkey paths, not all of
`secret/metadata/`.
This commit is contained in:
atlas 2026-10-05 22:46:52 +02:00 • committed by mara
commit 923554cd62
5 changed files with 78 additions and 79 deletions

View file

@ -137,54 +137,34 @@ fn from_seed(seed: &str) -> Result<XKey> {
XKey::from_seed(seed.trim()).map_err(|_| anyhow!("{SEED_PATH} does not hold a curve seed"))
}
/// How one request arrived, which is how its reply has to leave.
pub enum Framing {
/// No [`SERVER_XKEY_HEADER`]: the server reads the reply as it is.
Plain,
/// Sealed to this responder by the server key the header named.
Sealed(XKey),
/// `payload` opened with `own`, and the server key `server_key` names, which
/// the reply is sealed to.
///
/// # Errors
/// When there is no [`SERVER_XKEY_HEADER`], when it is not a public xkey, and
/// when the payload does not open with `own`.
pub fn open(own: &XKey, server_key: Option<&str>, payload: &[u8]) -> Result<(XKey, Vec<u8>)> {
let server_key = server_key
.with_context(|| format!("the request is not sealed: no {SERVER_XKEY_HEADER}"))?;
let server = XKey::from_public_key(server_key)
.map_err(|_| anyhow!("{SERVER_XKEY_HEADER} is not a public xkey"))?;
let opened = own
.open(payload, &server)
.map_err(|_| anyhow!("the request does not open with this responder's xkey"))?;
Ok((server, opened))
}
impl Framing {
/// `payload` as the server sent it, opened when `server_key` says it was
/// sealed.
///
/// # Errors
/// When the header is not a public xkey, or the payload does not open
/// with `own`.
pub fn open(own: &XKey, server_key: Option<&str>, payload: &[u8]) -> Result<(Self, Vec<u8>)> {
let Some(server_key) = server_key else {
return Ok((Self::Plain, payload.to_vec()));
};
let server = XKey::from_public_key(server_key)
.map_err(|_| anyhow!("{SERVER_XKEY_HEADER} is not a public xkey"))?;
let opened = own
.open(payload, &server)
.map_err(|_| anyhow!("the request does not open with this responder's xkey"))?;
Ok((Self::Sealed(server), opened))
}
/// `reply` framed the way the request was.
///
/// # Errors
/// When sealing fails, which it does only for a key with no seed.
pub fn seal(&self, own: &XKey, reply: Vec<u8>) -> Result<Vec<u8>> {
match self {
Self::Plain => Ok(reply),
Self::Sealed(server) => own
.seal(&reply, server)
.map_err(|_| anyhow!("sealing the reply")),
}
}
/// Whether the reply leaves sealed.
pub fn is_sealed(&self) -> bool {
matches!(self, Self::Sealed(_))
}
/// `reply` sealed by `own` to `server`.
///
/// # Errors
/// When sealing fails, which it does only for a key with no seed.
pub fn seal(own: &XKey, server: &XKey, reply: &[u8]) -> Result<Vec<u8>> {
own.seal(reply, server)
.map_err(|_| anyhow!("sealing the reply"))
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
@ -335,7 +315,7 @@ mod tests {
assert_eq!(key.public_key(), existing.public_key());
}
fn request_jwt() -> Vec<u8> {
pub(crate) fn request_jwt() -> Vec<u8> {
let b = |s: &str| data_encoding::BASE64URL_NOPAD.encode(s.as_bytes());
format!(
"{}.{}.{}",
@ -356,9 +336,8 @@ mod tests {
let own_public = XKey::from_public_key(&own.public_key()).expect("public");
let sealed = server.seal(&request_jwt(), &own_public).expect("sealed");
let (framing, opened) =
Framing::open(&own, Some(&server.public_key()), &sealed).expect("opens");
assert!(framing.is_sealed());
let (server_key, opened) = open(&own, Some(&server.public_key()), &sealed).expect("opens");
assert_eq!(server_key.public_key(), server.public_key());
let req = crate::request::decode(&opened).expect("a request");
assert_eq!(req.user_nkey, "UCLIENT");
@ -374,9 +353,7 @@ mod tests {
&req.user_nkey,
&permissions,
);
let reply = framing
.seal(&own, token.clone().into_bytes())
.expect("sealed");
let reply = seal(&own, &server_key, token.as_bytes()).expect("sealed");
assert_ne!(reply, token.as_bytes(), "the reply leaves sealed");
let read_by_server = server
.open(&reply, &own_public)
@ -384,16 +361,11 @@ mod tests {
assert_eq!(read_by_server, token.as_bytes());
}
/// A server with no xkey configured sends no header and reads the reply
/// as it is.
#[test]
fn a_request_without_the_header_is_answered_in_plaintext() {
fn a_request_without_the_header_is_refused() {
let own = XKey::new();
let (framing, opened) = Framing::open(&own, None, &request_jwt()).expect("plain");
assert!(!framing.is_sealed());
assert_eq!(opened, request_jwt());
let reply = framing.seal(&own, b"a.b.c".to_vec()).expect("plain");
assert_eq!(reply, b"a.b.c");
let e = open(&own, None, &request_jwt()).expect_err("no header");
assert!(format!("{e:#}").contains(SERVER_XKEY_HEADER), "{e:#}");
}
#[test]
@ -402,9 +374,9 @@ mod tests {
let server = XKey::new();
let someone_else = XKey::from_public_key(&XKey::new().public_key()).expect("public");
let sealed = server.seal(&request_jwt(), &someone_else).expect("sealed");
assert!(Framing::open(&own, Some(&server.public_key()), &sealed).is_err());
assert!(open(&own, Some(&server.public_key()), &sealed).is_err());
// The header present over a payload that was never sealed.
assert!(Framing::open(&own, Some(&server.public_key()), &request_jwt()).is_err());
assert!(Framing::open(&own, Some("not-a-key"), &sealed).is_err());
assert!(open(&own, Some(&server.public_key()), &request_jwt()).is_err());
assert!(open(&own, Some("not-a-key"), &sealed).is_err());
}
}