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 -1
View File
@@ -147,7 +147,7 @@ Use Nexus Terminal when you need to:
- Direct, SOCKS5, HTTP CONNECT, SSH-tunnelled or (experimental) RD Gateway connections. - Direct, SOCKS5, HTTP CONNECT, SSH-tunnelled or (experimental) RD Gateway connections.
- Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control). - Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control).
- Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; text clipboard sharing in both directions. - Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; text clipboard sharing in both directions.
- Password storage modes and domain logon. - Password storage modes, domain logon, and Kerberos through a configured KDC.
- Remote audio playback, a printer whose jobs are saved as PostScript files, and a shared folder redirected as a drive (Linux and macOS), confined to that folder. - Remote audio playback, a printer whose jobs are saved as PostScript files, and a shared folder redirected as a drive (Linux and macOS), confined to that folder.
### VNC capabilities ### VNC capabilities
+1 -1
View File
@@ -171,7 +171,7 @@ Use SSH tunnelling for VNC whenever possible. If a VNC profile has no tunnel, Ne
- **Devices**: **Play Remote Audio**; **Redirect Printer**, which adds a *Nexus Terminal* printer on the remote desktop whose jobs are saved as PostScript files in your Downloads folder (up to 256 MiB per job); and on Linux and macOS a **Shared Folder** that appears on the remote desktop as a drive named Nexus. The server can read and change everything in that folder and nothing outside it: paths that try to leave it (`..`, or symlinks pointing elsewhere) are refused. - **Devices**: **Play Remote Audio**; **Redirect Printer**, which adds a *Nexus Terminal* printer on the remote desktop whose jobs are saved as PostScript files in your Downloads folder (up to 256 MiB per job); and on Linux and macOS a **Shared Folder** that appears on the remote desktop as a drive named Nexus. The server can read and change everything in that folder and nothing outside it: paths that try to leave it (`..`, or symlinks pointing elsewhere) are refused.
- **SSH Tunnel**: carry RDP through SSH, as for VNC. Use either a proxy or a tunnel, not both. - **SSH Tunnel**: carry RDP through SSH, as for VNC. Use either a proxy or a tunnel, not both.
Nexus Terminal logs on with Network Level Authentication (CredSSP, NTLM), so your password is checked before a session is set up. Servers without NLA, such as xrdp, need **Allow servers without NLA**; the password is then passed to the server's own logon. Kerberos and smart cards are not supported. Nexus Terminal logs on with Network Level Authentication (CredSSP), so your password is checked before a session is set up. It uses NTLM unless you set a **Kerberos KDC** (`host` or `host:port`), in which case it gets a Kerberos ticket from that KDC over TCP; the KDC must be reachable directly (not through the profile's proxy or tunnel). Use your Kerberos realm as the domain. Servers without NLA, such as xrdp, need **Allow servers without NLA**; the password is then passed to the server's own logon. Smart cards are not supported.
RDP servers usually present self-signed certificates, so the certificate is pinned like an SSH host key: its SHA-256 fingerprint is shown the first time and stored in **Known Hosts**, and a changed certificate offers Reject, Accept Once or Accept & Update. The check happens before any credentials are sent. RDP servers usually present self-signed certificates, so the certificate is pinned like an SSH host key: its SHA-256 fingerprint is shown the first time and stored in **Known Hosts**, and a changed certificate offers Reject, Accept Once or Accept & Update. The check happens before any credentials are sent.
+1
View File
@@ -691,6 +691,7 @@ impl ProfileEditor {
if c.secret_mode == SecretMode::Saved { if c.secret_mode == SecretMode::Saved {
secret(ui, "Password", secrets, SecretKind::RdpPassword, "Leave blank to keep the saved password"); 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(); let opts: Vec<_> = HostKeyPolicy::ALL.iter().map(|k| (*k, k.label())).collect();
combo(ui, "Certificate Policy", &mut c.host_key_policy, &opts); combo(ui, "Certificate Policy", &mut c.host_key_policy, &opts);
check( check(
+69 -7
View File
@@ -50,6 +50,8 @@ pub struct Config {
pub allow_tls_only: bool, pub allow_tls_only: bool,
/// Play the remote desktop's sound locally. /// Play the remote desktop's sound locally.
pub audio: bool, 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. /// A local folder to share as a drive (Unix only), and the name it appears under.
pub shared_folder: Option<(std::path::PathBuf, String)>, pub shared_folder: Option<(std::path::PathBuf, String)>,
/// Offer a printer; jobs are saved as PostScript in this folder. /// Offer a printer; jobs are saved as PostScript in this folder.
@@ -233,14 +235,22 @@ impl Client {
(self.cfg.check_cert)(&fingerprint(&cert))?; (self.cfg.check_cert)(&fingerprint(&cert))?;
let upgraded = ironrdp_blocking::mark_as_upgraded(should_upgrade, &mut connector); let upgraded = ironrdp_blocking::mark_as_upgraded(should_upgrade, &mut connector);
let mut framed = Framed::new(tls); 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( let result = ironrdp_blocking::connect_finalize(
upgraded, upgraded,
connector, connector,
&mut framed, &mut framed,
&mut NoNetwork, &mut KdcClient,
ServerName::new(self.cfg.host.clone()), ServerName::new(self.cfg.host.clone()),
server_public_key(&cert)?, server_public_key(&cert)?,
None, kerberos,
) )
.map_err(|e| format!("RDP logon failed: {}", describe(&e)))?; .map_err(|e| format!("RDP logon failed: {}", describe(&e)))?;
Ok((framed, result)) 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. /// CredSSP's channel to the Kerberos KDC (NTLM never uses it). TCP only, which every KDC serves;
struct NoNetwork; /// replies are capped so a hostile KDC can't force a huge allocation.
struct KdcClient;
impl connector::sspi::network_client::NetworkClient for NoNetwork { const MAX_KDC_REPLY: u32 = 1 << 20;
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")) 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 { mod tests {
use super::*; 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] #[test]
fn fingerprints_like_ssh() { fn fingerprints_like_ssh() {
assert_eq!(fingerprint(b"abc"), "SHA256:ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0"); assert_eq!(fingerprint(b"abc"), "SHA256:ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0");
@@ -632,6 +693,7 @@ mod tests {
height: 768, height: 768,
allow_tls_only: true, allow_tls_only: true,
audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(), 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())), 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 _)), 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))?)), 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, pub allow_tls_only: bool,
/// Play remote audio locally. /// Play remote audio locally.
pub audio: bool, 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. /// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS.
pub shared_folder: String, pub shared_folder: String,
/// Offer a printer; print jobs are saved as PostScript files in Downloads. /// Offer a printer; print jobs are saved as PostScript files in Downloads.
+1
View File
@@ -360,6 +360,7 @@ impl Manager {
height, height,
allow_tls_only: rc.allow_tls_only, allow_tls_only: rc.allow_tls_only,
audio: rc.audio, 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())), shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())),
printer: rc.redirect_printer.then(|| { printer: rc.redirect_printer.then(|| {
let (me, sid) = (self.clone(), id.to_string()); let (me, sid) = (self.clone(), id.to_string());