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:
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))?)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user