feat(rdp): Kerberos logon through a configured KDC

An RDP profile can name a Kerberos KDC; CredSSP then authenticates with
Kerberos instead of NTLM, reaching the KDC through a small TCP client
(RFC 4120 length-prefixed framing, 5 s connect / 10 s read timeouts,
replies capped at 1 MiB so a hostile KDC can't force a huge allocation).
UDP KDCs are refused with a clear message. Tested against a fake KDC
(round trip, oversized reply, UDP); not against a real realm.

Smart-card logon is not implemented: it needs PC/SC hardware and a card
to build and test against.
This commit is contained in:
Andy Dixon
2026-10-12 18:30:30 +01:00
parent ae2b07db4b
commit 455d8fa6da
6 changed files with 75 additions and 9 deletions
+1
View File
@@ -691,6 +691,7 @@ impl ProfileEditor {
if c.secret_mode == SecretMode::Saved {
secret(ui, "Password", secrets, SecretKind::RdpPassword, "Leave blank to keep the saved password");
}
text(ui, "Kerberos KDC", &mut c.kerberos_kdc, "kdc.corp.example (empty: NTLM)");
let opts: Vec<_> = HostKeyPolicy::ALL.iter().map(|k| (*k, k.label())).collect();
combo(ui, "Certificate Policy", &mut c.host_key_policy, &opts);
check(
+69 -7
View File
@@ -50,6 +50,8 @@ pub struct Config {
pub allow_tls_only: bool,
/// Play the remote desktop's sound locally.
pub audio: bool,
/// Kerberos instead of NTLM: the KDC as `host` or `host:port` (TCP).
pub kerberos_kdc: Option<String>,
/// A local folder to share as a drive (Unix only), and the name it appears under.
pub shared_folder: Option<(std::path::PathBuf, String)>,
/// Offer a printer; jobs are saved as PostScript in this folder.
@@ -233,14 +235,22 @@ impl Client {
(self.cfg.check_cert)(&fingerprint(&cert))?;
let upgraded = ironrdp_blocking::mark_as_upgraded(should_upgrade, &mut connector);
let mut framed = Framed::new(tls);
let kerberos = match &self.cfg.kerberos_kdc {
Some(kdc) => {
let kdc = if kdc.contains(':') { kdc.clone() } else { format!("{kdc}:88") };
let hostname = std::env::var("HOSTNAME").or_else(|_| std::env::var("COMPUTERNAME")).unwrap_or_else(|_| "nexus".into());
Some(connector::credssp::KerberosConfig::new(Some(format!("tcp://{kdc}")), hostname).map_err(|e| format!("Kerberos: {e}"))?)
}
None => None,
};
let result = ironrdp_blocking::connect_finalize(
upgraded,
connector,
&mut framed,
&mut NoNetwork,
&mut KdcClient,
ServerName::new(self.cfg.host.clone()),
server_public_key(&cert)?,
None,
kerberos,
)
.map_err(|e| format!("RDP logon failed: {}", describe(&e)))?;
Ok((framed, result))
@@ -563,12 +573,36 @@ impl rustls::client::danger::ServerCertVerifier for Pinned {
}
}
/// CredSSP asks for this only for Kerberos (talking to a KDC); NTLM never uses it.
struct NoNetwork;
/// CredSSP's channel to the Kerberos KDC (NTLM never uses it). TCP only, which every KDC serves;
/// replies are capped so a hostile KDC can't force a huge allocation.
struct KdcClient;
impl connector::sspi::network_client::NetworkClient for NoNetwork {
fn send(&self, _: &connector::sspi::NetworkRequest) -> connector::sspi::Result<Vec<u8>> {
Err(connector::sspi::Error::new(connector::sspi::ErrorKind::NoAuthenticatingAuthority, "Kerberos is not supported; use NTLM"))
const MAX_KDC_REPLY: u32 = 1 << 20;
impl connector::sspi::network_client::NetworkClient for KdcClient {
fn send(&self, req: &connector::sspi::NetworkRequest) -> connector::sspi::Result<Vec<u8>> {
use connector::sspi::{Error, ErrorKind, network_client::NetworkProtocol};
use std::io::{Read, Write};
let fail = |e: String| Error::new(ErrorKind::NoAuthenticatingAuthority, e);
if req.protocol != NetworkProtocol::Tcp {
return Err(fail(format!("KDC over {:?} is not supported; use a tcp:// KDC", req.protocol)));
}
let host = req.url.host_str().ok_or_else(|| fail("KDC URL has no host".into()))?;
let port = req.url.port().unwrap_or(88);
let mut s = super::proxy::Proxy::default().connect(host, port, Duration::from_secs(5)).map_err(|e| fail(format!("KDC {host}:{port}: {e}")))?;
s.set_read_timeout(Some(Duration::from_secs(10))).map_err(|e| fail(e.to_string()))?;
// The request is already length-prefixed (RFC 4120 7.2.2); so is the reply.
s.write_all(&req.data).map_err(|e| fail(e.to_string()))?;
let mut len = [0u8; 4];
s.read_exact(&mut len).map_err(|e| fail(e.to_string()))?;
let n = u32::from_be_bytes(len);
if n > MAX_KDC_REPLY {
return Err(fail(format!("KDC reply of {n} bytes is too large")));
}
let mut buf = len.to_vec();
buf.resize(4 + n as usize, 0);
s.read_exact(&mut buf[4..]).map_err(|e| fail(e.to_string()))?;
Ok(buf)
}
}
@@ -591,6 +625,33 @@ fn error_chain(e: &dyn std::error::Error) -> String {
mod tests {
use super::*;
#[test]
fn kdc_client_speaks_length_prefixed_tcp_and_caps_replies() {
use connector::sspi::network_client::{NetworkClient, NetworkProtocol};
use std::io::{Read, Write};
let kdc = |reply: Vec<u8>| {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
let mut req = [0u8; 7];
s.read_exact(&mut req).unwrap();
assert_eq!(req, [0, 0, 0, 3, b'a', b's', b'k']);
s.write_all(&reply).unwrap();
});
connector::sspi::NetworkRequest {
protocol: NetworkProtocol::Tcp,
url: format!("tcp://127.0.0.1:{port}").parse().unwrap(),
data: b"\0\0\0\x03ask".to_vec(),
}
};
assert_eq!(KdcClient.send(&kdc(b"\0\0\0\x02ok".to_vec())).unwrap(), b"\0\0\0\x02ok");
let err = KdcClient.send(&kdc(vec![0xff, 0xff, 0xff, 0xff])).unwrap_err();
assert!(err.to_string().contains("too large"), "{err}");
let udp = connector::sspi::NetworkRequest { protocol: NetworkProtocol::Udp, url: "udp://127.0.0.1:88".parse().unwrap(), data: vec![] };
assert!(KdcClient.send(&udp).is_err());
}
#[test]
fn fingerprints_like_ssh() {
assert_eq!(fingerprint(b"abc"), "SHA256:ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0");
@@ -632,6 +693,7 @@ mod tests {
height: 768,
allow_tls_only: true,
audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(),
kerberos_kdc: None,
shared_folder: std::env::var("NEXUS_RDP_SHARE").ok().map(|p| (p.into(), "nexus".into())),
printer: std::env::var("NEXUS_RDP_PRINT").ok().map(|p| (p.into(), Arc::new(|f: std::path::PathBuf| eprintln!("printed {}", f.display())) as _)),
dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)),
+2
View File
@@ -411,6 +411,8 @@ pub struct RdpConfig {
pub allow_tls_only: bool,
/// Play remote audio locally.
pub audio: bool,
/// Log on with Kerberos through this KDC (`host` or `host:port`, TCP); empty uses NTLM.
pub kerberos_kdc: String,
/// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS.
pub shared_folder: String,
/// Offer a printer; print jobs are saved as PostScript files in Downloads.
+1
View File
@@ -360,6 +360,7 @@ impl Manager {
height,
allow_tls_only: rc.allow_tls_only,
audio: rc.audio,
kerberos_kdc: (!rc.kerberos_kdc.trim().is_empty()).then(|| rc.kerberos_kdc.trim().to_string()),
shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())),
printer: rc.redirect_printer.then(|| {
let (me, sid) = (self.clone(), id.to_string());