From 741986ae1dd8ca4a1a967a1e09846aa11ee33e93 Mon Sep 17 00:00:00 2001 From: Vincent Ruello <5345986+vruello@users.noreply.github.com> Date: Fri, 21 Nov 2025 21:45:25 +0100 Subject: [PATCH 1/3] Support SPNEGO authentication method --- CHANGELOG.md | 1 + server/src/kerberos.rs | 116 ++++++++++++++++++++++++++++++++-------- server/src/lib.rs | 12 ++++- server/src/multipart.rs | 54 +++++++++++++++---- 4 files changed, 149 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c988f8a3..4a4a32ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `max_elements` subscription parameter (#185) - Add an optional Prometheus endpoint that exposes metrics (#190) - Optionally wrap TCP stream in a TLS session in TCP driver (#203) +- Support for SPNEGO authentication (#307) ## [v0.3.0] diff --git a/server/src/kerberos.rs b/server/src/kerberos.rs index 8393a084..ec9e9cb1 100644 --- a/server/src/kerberos.rs +++ b/server/src/kerberos.rs @@ -10,7 +10,7 @@ use libgssapi::{ credential::{Cred, CredUsage}, error::Error, name::Name, - oid::{OidSet, GSS_MECH_KRB5, GSS_NT_KRB5_PRINCIPAL}, + oid::{OidSet, GSS_MECH_KRB5, GSS_MECH_SPNEGO, GSS_NT_KRB5_PRINCIPAL}, util::{GssIov, GssIovType}, }; use log::{debug, error}; @@ -22,9 +22,17 @@ use thiserror::Error; use crate::multipart; use crate::sldc; +#[derive(Debug, Clone, PartialEq)] +pub enum Method { + Kerberos, + #[allow(clippy::upper_case_acronyms)] + SPNEGO, +} + #[derive(Debug)] pub struct State { context: Option, + method: Option, } impl State { @@ -32,10 +40,16 @@ impl State { let context = setup_server_ctx(principal.as_bytes()); match context { - Ok(ctx) => State { context: Some(ctx) }, + Ok(ctx) => State { + context: Some(ctx), + method: None, + }, Err(e) => { error!("Could not setup Kerberos server context: {:?}", e); - State { context: None } + State { + context: None, + method: None, + } } } } @@ -49,6 +63,7 @@ fn setup_server_ctx(principal: &[u8]) -> Result { let desired_mechs = { let mut s = OidSet::new()?; s.add(&GSS_MECH_KRB5)?; + s.add(&GSS_MECH_SPNEGO)?; s }; let name = Name::new(principal, Some(&GSS_NT_KRB5_PRINCIPAL))?; @@ -61,6 +76,7 @@ fn setup_server_ctx(principal: &[u8]) -> Result { pub struct AuthenticationData { principal: String, token: Option, + method: Method, } impl AuthenticationData { @@ -71,12 +87,18 @@ impl AuthenticationData { pub fn token(&self) -> Option<&String> { self.token.as_ref() } + + pub fn method(&self) -> &Method { + &self.method + } } #[derive(Error, Debug)] pub enum AuthenticationError { #[error("Client request does not contain authorization header")] MissingAuthorizationHeader, + #[error("Client request authorization header is invalid")] + InvalidAuthorizationHeader, #[error(transparent)] Gssapi(#[from] libgssapi::error::Error), #[error(transparent)] @@ -92,6 +114,7 @@ pub async fn authenticate( ) -> Result { { let mut state = conn_state.lock().unwrap(); + let method = state.method.clone(); let server_ctx = state .context .as_mut() @@ -102,6 +125,7 @@ pub async fn authenticate( return Ok(AuthenticationData { principal: server_ctx.source_name()?.to_string(), token: None, + method: method.ok_or_else(|| anyhow!("GSSAPI method is not set"))?, }); } } @@ -116,10 +140,19 @@ pub async fn authenticate( let cloned_conn_state = conn_state.clone(); tokio::task::spawn_blocking(move || { - let b64_token = auth_header - .strip_prefix("Kerberos ") - .ok_or_else(|| anyhow!("Authorization header does not start with 'Kerberos '"))?; + let (b64_token_opt, method) = if auth_header.starts_with("Kerberos ") { + (auth_header.strip_prefix("Kerberos "), Method::Kerberos) + } else if auth_header.starts_with("Negotiate ") { + (auth_header.strip_prefix("Negotiate "), Method::SPNEGO) + } else { + return Err(AuthenticationError::InvalidAuthorizationHeader); + }; + + let b64_token = b64_token_opt + .ok_or_else(|| anyhow!("Authorization header is invalid: {}", auth_header))?; + let mut state = cloned_conn_state.lock().unwrap(); + state.method = Some(method.clone()); let server_ctx = state .context .as_mut() @@ -136,6 +169,7 @@ pub async fn authenticate( None => Ok(AuthenticationData { principal: server_ctx.source_name()?.to_string(), token: None, + method, }), Some(step) => { // TODO: support multiple steps @@ -159,6 +193,7 @@ pub async fn authenticate( Ok(AuthenticationData { principal: server_ctx.source_name()?.to_string(), token: Some(base64::engine::general_purpose::STANDARD.encode(&*step)), + method, }) } } @@ -167,7 +202,7 @@ pub async fn authenticate( .map_err(|e| anyhow!("{}", e))? } -fn get_boundary(mime: &Mime) -> Result { +fn get_boundary(mime: &Mime, method: &Method) -> Result { if mime.type_() != "multipart" { bail!("Top level media type must be multipart"); } @@ -177,7 +212,12 @@ fn get_boundary(mime: &Mime) -> Result { } match mime.get_param("protocol") { - Some(protocol) if protocol == "application/HTTP-Kerberos-session-encrypted" => {} + Some(protocol) + if *method == Method::Kerberos + && protocol == "application/HTTP-Kerberos-session-encrypted" => {} + Some(protocol) + if *method == Method::SPNEGO + && protocol == "application/HTTP-SPNEGO-session-encrypted" => {} _ => bail!("Invalid or missing parameter 'protocol' in Content-Type"), } @@ -260,18 +300,30 @@ pub async fn get_request_payload( .to_str()? .parse::() .context("Could not parse Content-Type header")?; - let boundary = get_boundary(&mime).context("Could not get multipart boundaries")?; - let encrypted_payload = multipart::read_multipart_body(&mut &*data, &boundary) + let method = { + let state = conn_state.lock().unwrap(); + state + .method + .as_ref() + .ok_or_else(|| anyhow!("Unknown GSSAPI method"))? + .clone() + }; + + let boundary = + get_boundary(&mime, &method).context("Could not get multipart boundaries")?; + let encrypted_payload = multipart::read_multipart_body(&mut &*data, &boundary, &method) .context("Could not retrieve encrypted payload")?; - let mut state = conn_state.lock().unwrap(); - let server_ctx = state - .context - .as_mut() - .ok_or_else(|| anyhow!("Kerberos server context is empty"))?; - let decrypted_message = - decrypt_payload(encrypted_payload, server_ctx).context("Could not decrypt payload")?; + let decrypted_message = { + let mut state = conn_state.lock().unwrap(); + let server_ctx = state + .context + .as_mut() + .ok_or_else(|| anyhow!("Kerberos server context is empty"))?; + + decrypt_payload(encrypted_payload, server_ctx).context("Could not decrypt payload")? + }; let message = match parts.headers.get("Content-Encoding") { Some(value) if value == "SLDC" => { @@ -302,18 +354,36 @@ pub async fn get_response_payload( let cleartext_payload_len = payload.len(); - let mut state = conn_state.lock().unwrap(); - let server_ctx = &mut state - .context - .as_mut() - .ok_or_else(|| anyhow!("Kerberos server context is empty"))?; - payload = encrypt_payload(payload, server_ctx).context("Failed to encrypt payload")?; + let (payload, method) = { + let mut state = conn_state.lock().unwrap(); + let method = state + .method + .as_ref() + .ok_or_else(|| anyhow!("Unknown GSSAPI method"))? + .clone(); + let server_ctx = &mut state + .context + .as_mut() + .ok_or_else(|| anyhow!("Kerberos server context is empty"))?; + payload = encrypt_payload(payload, server_ctx).context("Failed to encrypt payload")?; + (payload, method) + }; Ok(multipart::get_multipart_body( &payload, cleartext_payload_len, &boundary, + &method, )) }) .await? } + +pub fn get_response_content_type(conn_state: Arc>, boundary: &str) -> Result { + let state = conn_state.lock().unwrap(); + match state.method { + Some(Method::Kerberos) => Ok("multipart/encrypted;protocol=\"application/HTTP-Kerberos-session-encrypted\";boundary=\"".to_owned() + boundary + "\""), + Some(Method::SPNEGO)=> Ok("multipart/encrypted;protocol=\"application/HTTP-SPNEGO-session-encrypted\";boundary=\"".to_owned() + boundary + "\""), + _ => bail!("Invalid GSSAPI Method") + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 679c59dc..7b932113 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -258,7 +258,10 @@ async fn create_response( AuthenticationContext::Kerberos(conn_state) => { let boundary = "Encrypted Boundary".to_owned(); if payload.is_some() { - response = response.header(CONTENT_TYPE, "multipart/encrypted;protocol=\"application/HTTP-Kerberos-session-encrypted\";boundary=\"".to_owned() + &boundary + "\""); + let content_type = + kerberos::get_response_content_type(conn_state.clone(), &boundary)?; + debug!("Response Content-Type header value is: {}", content_type); + response = response.header(CONTENT_TYPE, content_type); } let body = match payload { None => empty(), @@ -328,7 +331,11 @@ async fn authenticate( let mut response = Response::builder(); if let Some(token) = auth_result.token() { - response = response.header(WWW_AUTHENTICATE, format!("Kerberos {}", token)) + let auth_method = match auth_result.method() { + kerberos::Method::Kerberos => "Kerberos", + kerberos::Method::SPNEGO => "Negotiate", + }; + response = response.header(WWW_AUTHENTICATE, format!("{} {}", auth_method, token)) } Ok((auth_result.principal().to_owned(), response)) } @@ -508,6 +515,7 @@ async fn handle( return Ok(Response::builder() .status(status) .header(WWW_AUTHENTICATE, "Kerberos") + .header(WWW_AUTHENTICATE, "Negotiate") .body(empty()) .expect("Failed to build HTTP response")); } else { diff --git a/server/src/multipart.rs b/server/src/multipart.rs index 5f0d23af..356fe985 100644 --- a/server/src/multipart.rs +++ b/server/src/multipart.rs @@ -5,7 +5,13 @@ use log::debug; use mime::Mime; use std::io::{BufReader, Read}; -pub fn read_multipart_body(stream: &mut S, boundary: &str) -> Result> { +use crate::kerberos; + +pub fn read_multipart_body( + stream: &mut S, + boundary: &str, + method: &kerberos::Method, +) -> Result> { let mut reader = BufReader::with_capacity(4096, stream); let mut buf: Vec = Vec::new(); @@ -46,8 +52,17 @@ pub fn read_multipart_body(stream: &mut S, boundary: &str) -> Result "HTTP-Kerberos-session-encrypted", + kerberos::Method::SPNEGO => "HTTP-SPNEGO-session-encrypted", + }; + if mime.subtype() != expected_sub_type { + bail!( + "Wrong encapsulated multipart sub type. Expected \"{}\", found \"{}\"", + expected_sub_type, + mime.subtype() + ); } } if header.name == "OriginalContent" { @@ -111,6 +126,7 @@ pub fn get_multipart_body( encrypted_payload: &[u8], cleartext_payload_len: usize, boundary: &str, + method: &kerberos::Method, ) -> Vec { let mut body = Vec::with_capacity(4096); @@ -118,9 +134,13 @@ pub fn get_multipart_body( let end_boundary = "--".to_owned() + boundary + "--\r\n"; body.extend_from_slice(middle_boundary.as_bytes()); - body.extend_from_slice( - "Content-Type: application/HTTP-Kerberos-session-encrypted\r\n".as_bytes(), - ); + let content_type = match method { + kerberos::Method::Kerberos => { + "Content-Type: application/HTTP-Kerberos-session-encrypted\r\n" + } + kerberos::Method::SPNEGO => "Content-Type: application/HTTP-SPNEGO-session-encrypted\r\n", + }; + body.extend_from_slice(content_type.as_bytes()); let mut buffer = itoa::Buffer::new(); body.extend_from_slice( @@ -143,14 +163,30 @@ mod tests { use super::*; #[test] - fn test_multipart() -> Result<()> { + fn test_multipart_kerberos() -> Result<()> { let payload = "this is a very good payload".to_owned(); let length = payload.len(); let boundary = "super cool boundary"; + let method = kerberos::Method::Kerberos; + + let body = get_multipart_body(&payload.as_bytes(), length, boundary, &method); + + let received_payload = read_multipart_body(&mut &*body, boundary, &method)?; + assert_eq!(payload.as_bytes(), received_payload); + + Ok(()) + } + + #[test] + fn test_multipart_spnego() -> Result<()> { + let payload = "this is a very bad payload".to_owned(); + let length = payload.len(); + let boundary = "super cool boundary"; + let method = kerberos::Method::SPNEGO; - let body = get_multipart_body(&payload.as_bytes(), length, boundary); + let body = get_multipart_body(&payload.as_bytes(), length, boundary, &method); - let received_payload = read_multipart_body(&mut &*body, boundary)?; + let received_payload = read_multipart_body(&mut &*body, boundary, &method)?; assert_eq!(payload.as_bytes(), received_payload); Ok(()) From f087941f88f448c528acf8963312b9d9fa8dbc75 Mon Sep 17 00:00:00 2001 From: Vincent Ruello <5345986+vruello@users.noreply.github.com> Date: Sat, 22 Nov 2025 20:04:28 +0100 Subject: [PATCH 2/3] Add "HOST/" SPN in documentation --- doc/getting_started.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/getting_started.md b/doc/getting_started.md index 0bde1101..cb94974f 100644 --- a/doc/getting_started.md +++ b/doc/getting_started.md @@ -29,8 +29,11 @@ In an Active Directory domain `DC=windomain,DC=local`, let's configure OpenWEC o Requirements: * A DNS entry for `wec.windomain.local` * Authorise connections from your Windows machines to `wec.windomain.local` on TCP/5985 -* An Active Directory account for OpenWEC with `http/wec.windomain.local@WINDOMAIN.LOCAL` Service Principal Name. -* A keytab file containing keys for `http/wec.windomain.local@WINDOMAIN.LOCAL` SPN, available in `/etc/wec.windomain.local.keytab`. +* An Active Directory account for OpenWEC with `http/wec.windomain.local@WINDOMAIN.LOCAL` **and** `host/wec.windomain.local@WINDOMAIN.LOCAL` Service Principal Name. +* A keytab file containing keys for `http/wec.windomain.local@WINDOMAIN.LOCAL` **or** `host/wec.windomain.local@WINDOMAIN.LOCAL` SPN, available in `/etc/wec.windomain.local.keytab`. + +> [!note] +> The `host/` SPN is used by default by the WinRM client since Windows Server 2025. Write the following content in `/etc/openwec.conf.toml`: