diff --git a/docs/overview.md b/docs/overview.md index ef6a7cf..6e903f4 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -147,7 +147,7 @@ Use Nexus Terminal when you need to: - 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). - 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. ### VNC capabilities diff --git a/docs/user-manual.md b/docs/user-manual.md index b167e9f..4bdbfe9 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -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. - **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. diff --git a/rust/app/src/profile_editor.rs b/rust/app/src/profile_editor.rs index acc71e5..7683884 100644 --- a/rust/app/src/profile_editor.rs +++ b/rust/app/src/profile_editor.rs @@ -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( diff --git a/rust/core/src/clients/rdp.rs b/rust/core/src/clients/rdp.rs index 20189f6..ca94811 100644 --- a/rust/core/src/clients/rdp.rs +++ b/rust/core/src/clients/rdp.rs @@ -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, /// 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> { - 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> { + 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| { + 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))?)), diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index 2fdbf5b..37a64fc 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -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. diff --git a/rust/core/src/sessions/protocols.rs b/rust/core/src/sessions/protocols.rs index 02446a4..cea8152 100644 --- a/rust/core/src/sessions/protocols.rs +++ b/rust/core/src/sessions/protocols.rs @@ -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());