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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 13 additions & 5 deletions crates/ironposh-async/src/connection.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::sync::Arc;

use anyhow::Context;
use futures::{SinkExt, StreamExt, channel::mpsc, join};
use futures::{SinkExt, StreamExt, channel::mpsc, join, try_join};
use ironposh_client_core::{
connector::{
Connector, ConnectorStepResult, UserOperation, WinRmConfig, active_session::UserEvent,
Expand Down Expand Up @@ -302,9 +302,13 @@ where
);

let joined_task = async move {
let res = join!(active_session_task, multiplex_pipeline_task);
// try_join! short-circuits the moment either task errors (e.g. a failed
// handshake in the active session) instead of waiting for the multiplexer,
// which would otherwise block forever on its channels and hang the whole
// connection task — and with it any caller awaiting this future.
let res = try_join!(active_session_task, multiplex_pipeline_task);
let _ = session_event_tx_2.unbounded_send(crate::SessionEvent::Closed);
res.0.and(res.1)
res.map(|_| ())
};

(
Expand Down Expand Up @@ -386,9 +390,13 @@ where
build_pipeline_multiplexer(user_input_tx, server_output_rx, pipeline_input_rx, "Serial");

let joined_task = async move {
let res = join!(active_session_task, multiplex_pipeline_task);
// try_join! short-circuits the moment either task errors (e.g. a failed
// handshake in the active session) instead of waiting for the multiplexer,
// which would otherwise block forever on its channels and hang the whole
// connection task — and with it any caller awaiting this future.
let res = try_join!(active_session_task, multiplex_pipeline_task);
let _ = session_event_tx_2.unbounded_send(crate::SessionEvent::Closed);
res.0.and(res.1)
res.map(|_| ())
};

(
Expand Down
1 change: 1 addition & 0 deletions crates/ironposh-async/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,7 @@ mod tests {
status_code: 200,
headers: vec![],
body: HttpBody::Xml(xml),
peer_cert_der: None,
},
conn_id,
None,
Expand Down
1 change: 1 addition & 0 deletions crates/ironposh-async/src/session_serial/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,7 @@ mod tests {
status_code: 200,
headers: vec![],
body: ironposh_client_core::connector::http::HttpBody::Xml(String::new()),
peer_cert_der: None,
},
ConnectionId::test_new(1),
None,
Expand Down
1 change: 1 addition & 0 deletions crates/ironposh-client-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ rand = "0.8"
aes = "0.9.0-rc.1"
cbc = "0.2.0-rc.1"
cipher = "0.5.0-rc.1"
sha2 = "0.11.0-rc.2"
tracing = "0.1.41"
url = "2.5.7"
whoami = "1.6.1"
Expand Down
53 changes: 41 additions & 12 deletions crates/ironposh-client-core/src/connector/auth_sequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,19 @@ impl<'ctx> SecurityContextBuilderHolder<'ctx> {
}

impl SspiAuthContext {
pub fn new(sspi_config: SspiAuthConfig) -> Result<Self, crate::PwshCoreError> {
pub fn new(
sspi_config: SspiAuthConfig,
channel_binding: Option<Vec<u8>>,
) -> Result<Self, crate::PwshCoreError> {
match sspi_config {
SspiAuthConfig::NTLM {
identity,
target: target_name,
} => SspiContext::new_ntlm(identity, SspiConfig::new(target_name))
.map(SspiAuthContext::Ntlm),
} => SspiContext::new_ntlm(
identity,
SspiConfig::with_channel_binding(target_name, channel_binding),
)
.map(SspiAuthContext::Ntlm),

SspiAuthConfig::Kerberos {
identity,
Expand All @@ -79,7 +85,7 @@ impl SspiAuthContext {
} => SspiContext::new_kerberos(
identity,
kerberos_config.into(),
SspiConfig::new(target_name),
SspiConfig::with_channel_binding(target_name, channel_binding),
)
.map(SspiAuthContext::Kerberos),

Expand All @@ -88,7 +94,7 @@ impl SspiAuthContext {
kerberos_config,
target: target_name,
} => {
let sspi_config = SspiConfig::new(target_name);
let sspi_config = SspiConfig::with_channel_binding(target_name, channel_binding);

let client_computer_name = whoami::fallible::hostname().map_err(|e| {
crate::PwshCoreError::InternalError(format!(
Expand Down Expand Up @@ -162,8 +168,9 @@ impl SspiAuthSequence {
sspi_auth_config: SspiAuthConfig,
require_encryption: bool,
http_builder: HttpBuilder,
channel_binding: Option<Vec<u8>>,
) -> Result<Self, crate::PwshCoreError> {
let context = SspiAuthContext::new(sspi_auth_config)?;
let context = SspiAuthContext::new(sspi_auth_config, channel_binding)?;
Ok(Self {
context,
http_builder,
Expand Down Expand Up @@ -209,9 +216,17 @@ impl SspiAuthSequence {
SspiAuthenticator::resume(generator_holder, kdc_response)
}

/// Drive the next SSPI leg. `operation_body` is `Some` only when sealing is
/// off (HTTPS): in that mode the auth token has no sealed body to ride in, and
/// the server (HTTP.SYS) re-challenges a token-less operation request and
/// closes the connection — so we attach the actual operation SOAP to each
/// challenge leg (RFC 4559 / requests-kerberos `force_preemptive` style). The
/// leg that finally authenticates therefore also carries (and the server
/// processes) the operation, and its 200 is the operation response.
pub(crate) fn process_initialized_sec_context(
&mut self,
sec_context: &crate::connector::authenticator::SecContextInit,
operation_body: Option<&str>,
) -> Result<SecCtxInited, PwshCoreError> {
let res = match &mut self.context {
SspiAuthContext::Ntlm(auth_context) => {
Expand All @@ -228,14 +243,20 @@ impl SspiAuthSequence {
match res {
super::authenticator::ActionReqired::TryInitSecContextAgain { token } => {
self.http_builder.with_auth_header(token.0);
Ok(SecCtxInited::Continue(
self.http_builder.post(HttpBody::empty()),
))
let body =
operation_body.map_or_else(HttpBody::empty, |xml| HttpBody::Xml(xml.to_owned()));
Ok(SecCtxInited::Continue(self.http_builder.post(body)))
}
super::authenticator::ActionReqired::Done { token } => Ok(SecCtxInited::Done(token)),
}
}

/// Whether SSPI message sealing is in effect (HTTP). When false (HTTPS),
/// operation auth must ride the challenge legs instead of a sealed body.
pub(crate) fn require_encryption(&self) -> bool {
self.require_encryption
}

pub fn when_finish(self) -> Authenticated {
let Self {
context,
Expand Down Expand Up @@ -312,11 +333,19 @@ impl BasicAuthSequence {
}

impl AuthSequence {
pub fn new(cfg: &AuthSequenceConfig, http: HttpBuilder) -> Result<Self, PwshCoreError> {
pub fn new(
cfg: &AuthSequenceConfig,
http: HttpBuilder,
channel_binding: Option<Vec<u8>>,
) -> Result<Self, PwshCoreError> {
match &cfg.authenticator_config {
AuthenticatorConfig::Sspi(sspi) => {
let sspi_auth =
SspiAuthSequence::new(sspi.clone(), cfg.require_sspi_sealing, http)?;
let sspi_auth = SspiAuthSequence::new(
sspi.clone(),
cfg.require_sspi_sealing,
http,
channel_binding,
)?;
Ok(Self::Sspi(sspi_auth))
}
AuthenticatorConfig::Basic { username, password } => {
Expand Down
72 changes: 64 additions & 8 deletions crates/ironposh-client-core/src/connector/authenticator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,20 @@ pub type SecurityContextBuilder<'a, P> = InitializeSecurityContext<
#[derive(Debug)]
pub struct SspiConfig {
target_name: String,
/// Pre-formatted `SEC_CHANNEL_BINDINGS` bytes (`tls-server-end-point`) to feed
/// into every `InitializeSecurityContext` leg as a `ChannelBindings` input
/// buffer. `None` for plain HTTP or before the server cert is known.
channel_binding: Option<Vec<u8>>,
}

impl SspiConfig {
pub fn new(mut target: String) -> Self {
pub fn with_channel_binding(mut target: String, channel_binding: Option<Vec<u8>>) -> Self {
if !target.trim().starts_with("HTTP/") {
target = format!("HTTP/{target}");
}
Self {
target_name: target,
channel_binding,
}
}
}
Expand All @@ -55,8 +60,9 @@ pub struct SspiContext<P: Sspi> {
// Box<T> provides a stable heap address; we keep borrows within the same `AuthFurniture`.
cred: Box<P::CredentialsHandle>,
out: [SecurityBuffer; 1],
// Keep the builder + input buffer alive for the duration of the suspension (generator borrows them).
inbuf: Option<[SecurityBuffer; 1]>,
// Keep the builder + input buffers alive for the duration of the suspension (generator borrows them).
// Holds the server Token buffer and, over HTTPS, a ChannelBindings buffer (EPA).
inbuf: Option<Vec<SecurityBuffer>>,
sspi_auth_config: SspiConfig,
}

Expand Down Expand Up @@ -157,12 +163,31 @@ where
}

/// Parse the server's negotiate token (if present) and set `inbuf`.
///
/// Over HTTPS, also attach a `ChannelBindings` input buffer derived from the
/// server's TLS certificate (`tls-server-end-point`, RFC 5929). Servers that
/// enforce Extended Protection for Authentication (EPA) — e.g. a domain
/// controller over 5986 — reject Negotiate/Kerberos without it.
fn take_input(&mut self, response: Option<&HttpResponse>) -> Result<(), PwshCoreError> {
let mut buffers = Vec::new();
if let Some(resp) = response {
let server_token = parse_negotiate_token(&resp.headers)
.ok_or(PwshCoreError::Auth("no Negotiate token"))?;
self.inbuf = Some([SecurityBuffer::new(server_token, BufferType::Token)]);
buffers.push(SecurityBuffer::new(server_token, BufferType::Token));
}
// Channel binding (EPA) is attached to every leg whenever it is known.
// Note it is learned *reactively*: the pool acquires the
// `tls-server-end-point` binding only after a server 401 surfaces the TLS
// certificate, then restarts auth. So the very first attempt's legs carry
// no binding; the binding is present on the restarted sequence onward.
if let Some(cb) = &self.sspi_auth_config.channel_binding {
debug!(
cb_len = cb.len(),
"attaching ChannelBindings input buffer (EPA)"
);
buffers.push(SecurityBuffer::new(cb.clone(), BufferType::ChannelBindings));
}
self.inbuf = (!buffers.is_empty()).then_some(buffers);
Ok(())
}
}
Expand Down Expand Up @@ -219,12 +244,18 @@ impl SspiAuthenticator {
context.clear_for_next_round();
context.take_input(response)?;

// Sign and seal are paired SSPI services. INTEGRITY (sign) is requested
// unconditionally: it makes the handshake produce its message-integrity
// code (NTLM MIC / SPNEGO mechListMIC), which modern servers require to
// accept the context even when the body itself is not sealed. CONFIDENTIALITY
// (seal) is added only when we actually wrap the body (plain HTTP); over
// HTTPS, TLS provides confidentiality so we sign but do not seal.
let flag = if require_encryption {
debug!("encryption required for this session");
debug!("sealing enabled: requesting CONFIDENTIALITY + INTEGRITY");
ClientRequestFlags::CONFIDENTIALITY | ClientRequestFlags::INTEGRITY
} else {
debug!("encryption NOT required for this session");
ClientRequestFlags::empty()
debug!("sealing disabled (TLS transport): requesting INTEGRITY only");
ClientRequestFlags::INTEGRITY
};

// Build the builder; wire inputs/outputs.
Expand All @@ -240,7 +271,7 @@ impl SspiAuthenticator {
.with_output(&mut context.out);

if let Some(input_buffer) = &mut context.inbuf {
isc = isc.with_input(input_buffer);
isc = isc.with_input(input_buffer.as_mut_slice());
}

debug!(?isc, "calling SSPI InitializeSecurityContext");
Expand Down Expand Up @@ -420,6 +451,31 @@ fn token_header_from(bytes: &[u8]) -> Option<String> {
}
}

/// Build a `SEC_CHANNEL_BINDINGS` buffer carrying the `tls-server-end-point`
/// binding for the given DER leaf certificate (RFC 5929).
///
/// `application_data = "tls-server-end-point:" || H(cert)`, where `H` is SHA-256
/// (the hash used for end-point bindings whenever the certificate's signature
/// hash is MD5/SHA-1 or SHA-256 — i.e. every certificate AD WinRM issues). The
/// initiator/acceptor fields are empty; only the application data is populated.
pub(crate) fn tls_server_end_point_channel_bindings(cert_der: &[u8]) -> Vec<u8> {
use sha2::{Digest, Sha256};

// SEC_CHANNEL_BINDINGS: 32-byte header (8 little-endian u32 fields) followed
// by the application data. Only cbApplicationDataLength (offset 24) and
// dwApplicationDataOffset (offset 28) are non-zero.
const HEADER_LEN: usize = 32;

let mut application_data = b"tls-server-end-point:".to_vec();
application_data.extend_from_slice(&Sha256::digest(cert_der));

let mut buf = vec![0u8; HEADER_LEN];
buf[24..28].copy_from_slice(&(application_data.len() as u32).to_le_bytes());
buf[28..32].copy_from_slice(&(HEADER_LEN as u32).to_le_bytes());
buf.extend_from_slice(&application_data);
buf
}

/// Parse the "WWW-Authenticate: Negotiate <b64>" header case-insensitively.
///
/// If multiple `WWW-Authenticate` headers are present, we take the first `Negotiate` one.
Expand Down
Loading