Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
7 changes: 5 additions & 2 deletions doc/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<wec>` SPN is used by default by the WinRM client since Windows Server 2025.

Write the following content in `/etc/openwec.conf.toml`:
<!--
Expand Down
3 changes: 2 additions & 1 deletion doc/tls.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Configuration for TLS authentication

This feature is **EXPERIMENTAL**, please use carefully.
> [!caution]
> This feature is **EXPERIMENTAL**, please use carefully. There are known issues with our TLS implementation (see #291).

This documentation describes how to set up TLS authentication. Certificates need to be generated first, then follow the rest of the documentation in parallel with [getting_started.md](getting_started.md/#configuring-windows-machines).

Expand Down
116 changes: 93 additions & 23 deletions server/src/kerberos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -22,20 +22,34 @@ 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<ServerCtx>,
method: Option<Method>,
}

impl State {
pub fn new(principal: &str) -> Self {
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,
}
}
}
}
Expand All @@ -49,6 +63,7 @@ fn setup_server_ctx(principal: &[u8]) -> Result<ServerCtx, Error> {
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))?;
Expand All @@ -61,6 +76,7 @@ fn setup_server_ctx(principal: &[u8]) -> Result<ServerCtx, Error> {
pub struct AuthenticationData {
principal: String,
token: Option<String>,
method: Method,
}

impl AuthenticationData {
Expand All @@ -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)]
Expand All @@ -92,6 +114,7 @@ pub async fn authenticate(
) -> Result<AuthenticationData, AuthenticationError> {
{
let mut state = conn_state.lock().unwrap();
let method = state.method.clone();
let server_ctx = state
.context
.as_mut()
Expand All @@ -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"))?,
});
}
}
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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,
})
}
}
Expand All @@ -167,7 +202,7 @@ pub async fn authenticate(
.map_err(|e| anyhow!("{}", e))?
}

fn get_boundary(mime: &Mime) -> Result<String> {
fn get_boundary(mime: &Mime, method: &Method) -> Result<String> {
if mime.type_() != "multipart" {
bail!("Top level media type must be multipart");
}
Expand All @@ -177,7 +212,12 @@ fn get_boundary(mime: &Mime) -> Result<String> {
}

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

Expand Down Expand Up @@ -260,18 +300,30 @@ pub async fn get_request_payload(
.to_str()?
.parse::<Mime>()
.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" => {
Expand Down Expand Up @@ -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<Mutex<State>>, boundary: &str) -> Result<String> {
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")
}
}
12 changes: 10 additions & 2 deletions server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 45 additions & 9 deletions server/src/multipart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ use log::debug;
use mime::Mime;
use std::io::{BufReader, Read};

pub fn read_multipart_body<S: Read>(stream: &mut S, boundary: &str) -> Result<Vec<u8>> {
use crate::kerberos;

pub fn read_multipart_body<S: Read>(
stream: &mut S,
boundary: &str,
method: &kerberos::Method,
) -> Result<Vec<u8>> {
let mut reader = BufReader::with_capacity(4096, stream);

let mut buf: Vec<u8> = Vec::new();
Expand Down Expand Up @@ -46,8 +52,17 @@ pub fn read_multipart_body<S: Read>(stream: &mut S, boundary: &str) -> Result<Ve
if mime.type_() != "application" {
bail!("Wrong encapsulated multipart type");
}
if mime.subtype() != "HTTP-Kerberos-session-encrypted" {
bail!("Wrong encapsulated multipart sub type");

let expected_sub_type = match method {
kerberos::Method::Kerberos => "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" {
Expand Down Expand Up @@ -111,16 +126,21 @@ pub fn get_multipart_body(
encrypted_payload: &[u8],
cleartext_payload_len: usize,
boundary: &str,
method: &kerberos::Method,
) -> Vec<u8> {
let mut body = Vec::with_capacity(4096);

let middle_boundary = "--".to_owned() + boundary + "\r\n";
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(
Expand All @@ -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(())
Expand Down