446 lines
15 KiB
Rust
446 lines
15 KiB
Rust
//! Independent SCRAM-PLUS client against the actual STARTTLS listener.
|
|
use base64::{Engine, engine::general_purpose::STANDARD as B64};
|
|
use hmac::{Hmac, Mac};
|
|
use rust_xmpp::{auth, wire::Framer};
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
collections::BTreeMap,
|
|
fs,
|
|
io::BufReader,
|
|
process::{Child, Command, Stdio},
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
use tokio::{
|
|
io::{AsyncRead, AsyncWrite, AsyncWriteExt},
|
|
net::TcpStream,
|
|
};
|
|
use tokio_rustls::{
|
|
TlsConnector,
|
|
rustls::{self, RootCertStore, pki_types::ServerName},
|
|
};
|
|
|
|
const STREAM: &str = "<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' to='localhost' version='1.0'>";
|
|
const SASL1: &str = "urn:ietf:params:xml:ns:xmpp-sasl";
|
|
const SASL2: &str = "urn:xmpp:sasl:2";
|
|
const CB_NS: &str = "urn:xmpp:sasl-cb:0";
|
|
const WAIT: Duration = Duration::from_secs(5);
|
|
const PASSWORD: &str = "loopback-plus-password";
|
|
|
|
struct Process(Child);
|
|
impl Drop for Process {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
|
|
struct Server {
|
|
process: Process,
|
|
_dir: tempfile::TempDir,
|
|
port: u16,
|
|
cert: rustls::pki_types::CertificateDer<'static>,
|
|
}
|
|
impl Server {
|
|
async fn start(disable_plus: bool) -> Self {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let log = fs::File::create(dir.path().join("openssl.log")).unwrap();
|
|
let mut openssl = Process(
|
|
Command::new("openssl")
|
|
.current_dir(dir.path())
|
|
.args([
|
|
"req",
|
|
"-x509",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-sha256",
|
|
"-nodes",
|
|
"-days",
|
|
"1",
|
|
"-keyout",
|
|
"key.pem",
|
|
"-out",
|
|
"cert.pem",
|
|
"-subj",
|
|
"/CN=localhost",
|
|
"-addext",
|
|
"subjectAltName=DNS:localhost",
|
|
"-addext",
|
|
"basicConstraints=critical,CA:FALSE",
|
|
"-addext",
|
|
"extendedKeyUsage=serverAuth",
|
|
])
|
|
.stdout(Stdio::null())
|
|
.stderr(log)
|
|
.spawn()
|
|
.expect("openssl must be installed"),
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
if let Some(status) = openssl.0.try_wait().unwrap() {
|
|
assert!(
|
|
status.success(),
|
|
"openssl failed: {}",
|
|
fs::read_to_string(dir.path().join("openssl.log")).unwrap()
|
|
);
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "openssl timed out");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
let cert = rustls_pemfile::certs(&mut BufReader::new(
|
|
fs::File::open(dir.path().join("cert.pem")).unwrap(),
|
|
))
|
|
.next()
|
|
.unwrap()
|
|
.unwrap();
|
|
let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = reservation.local_addr().unwrap().port();
|
|
let data = dir.path().join("data");
|
|
auth::create(&data, "localhost", "alice", PASSWORD, "SHA-256").unwrap();
|
|
let mut config = serde_json::json!({
|
|
"global": {"data_path": data, "interfaces": ["127.0.0.1"], "c2s_ports": [port],
|
|
"s2s_ports": [], "http_ports": [], "https_ports": [],
|
|
"modules_enabled": ["tls", "saslauth", "sasl2", "sasl_ssdp"]},
|
|
"hosts": {"localhost": {"options": {"authentication": "internal_hashed", "password_hash": "SHA-256",
|
|
"ssl": {"key": dir.path().join("key.pem"), "certificate": dir.path().join("cert.pem")}}}}
|
|
});
|
|
if disable_plus {
|
|
config["hosts"]["localhost"]["options"]["disable_sasl_mechanisms"] =
|
|
serde_json::json!(["SCRAM-SHA-256-PLUS"]);
|
|
}
|
|
let path = dir.path().join("config.json");
|
|
fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap();
|
|
let log = fs::File::create(dir.path().join("server.log")).unwrap();
|
|
drop(reservation);
|
|
let process = Process(
|
|
Command::new(env!("CARGO_BIN_EXE_rust-xmpp"))
|
|
.arg("serve")
|
|
.arg(path)
|
|
.arg("--experimental")
|
|
.stdout(log.try_clone().unwrap())
|
|
.stderr(log)
|
|
.spawn()
|
|
.unwrap(),
|
|
);
|
|
let mut server = Self {
|
|
process,
|
|
_dir: dir,
|
|
port,
|
|
cert,
|
|
};
|
|
tokio::time::timeout(Duration::from_secs(10), async {
|
|
loop {
|
|
assert!(
|
|
server.process.0.try_wait().unwrap().is_none(),
|
|
"server exited: {}",
|
|
server.logs()
|
|
);
|
|
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or_else(|_| panic!("server startup timed out: {}", server.logs()));
|
|
server
|
|
}
|
|
fn logs(&self) -> String {
|
|
fs::read_to_string(self._dir.path().join("server.log")).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
async fn send<S: AsyncWrite + Unpin>(stream: &mut S, xml: &str) {
|
|
tokio::time::timeout(WAIT, stream.write_all(xml.as_bytes()))
|
|
.await
|
|
.expect("write timeout")
|
|
.unwrap();
|
|
}
|
|
async fn next<S: AsyncRead + Unpin>(framer: &mut Framer, stream: &mut S) -> String {
|
|
tokio::time::timeout(WAIT, framer.next(stream))
|
|
.await
|
|
.expect("read timeout")
|
|
.unwrap()
|
|
}
|
|
async fn features<S: AsyncRead + AsyncWrite + Unpin>(
|
|
stream: &mut S,
|
|
framer: &mut Framer,
|
|
) -> String {
|
|
send(stream, STREAM).await;
|
|
loop {
|
|
let xml = next(framer, stream).await;
|
|
if xml.contains("<stream:features") {
|
|
return framer.wrap(&xml);
|
|
}
|
|
}
|
|
}
|
|
fn mac(key: &[u8], data: &[u8]) -> Vec<u8> {
|
|
let mut h = Hmac::<Sha256>::new_from_slice(key).unwrap();
|
|
h.update(data);
|
|
h.finalize().into_bytes().to_vec()
|
|
}
|
|
|
|
async fn exchange(
|
|
server: &Server,
|
|
tls13: bool,
|
|
binding_name: &str,
|
|
sasl2: bool,
|
|
bad_binding: bool,
|
|
) {
|
|
let mut tcp = tokio::time::timeout(WAIT, TcpStream::connect(("127.0.0.1", server.port)))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let mut f = Framer::default();
|
|
let plain = features(&mut tcp, &mut f).await;
|
|
assert!(plain.contains("starttls"), "{plain}");
|
|
assert!(
|
|
!plain.contains("SCRAM-SHA-256-PLUS"),
|
|
"PLUS must not be offered before TLS"
|
|
);
|
|
send(
|
|
&mut tcp,
|
|
"<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>",
|
|
)
|
|
.await;
|
|
let proceed = next(&mut f, &mut tcp).await;
|
|
assert!(proceed.contains("proceed"), "{proceed}");
|
|
assert!(f.buffer.is_empty());
|
|
let mut roots = RootCertStore::empty();
|
|
roots.add(server.cert.clone()).unwrap();
|
|
let versions = if tls13 {
|
|
&[&rustls::version::TLS13][..]
|
|
} else {
|
|
&[&rustls::version::TLS12][..]
|
|
};
|
|
let config = rustls::ClientConfig::builder_with_provider(Arc::new(
|
|
rustls::crypto::ring::default_provider(),
|
|
))
|
|
.with_protocol_versions(versions)
|
|
.unwrap()
|
|
.with_root_certificates(roots)
|
|
.with_no_client_auth();
|
|
let mut tls = tokio::time::timeout(
|
|
WAIT,
|
|
TlsConnector::from(Arc::new(config))
|
|
.connect(ServerName::try_from("localhost").unwrap(), tcp),
|
|
)
|
|
.await
|
|
.expect("TLS timeout")
|
|
.unwrap();
|
|
assert_eq!(
|
|
tls.get_ref().1.protocol_version(),
|
|
Some(if tls13 {
|
|
rustls::ProtocolVersion::TLSv1_3
|
|
} else {
|
|
rustls::ProtocolVersion::TLSv1_2
|
|
})
|
|
);
|
|
let plus = binding_name != "none";
|
|
let mechanism = if plus {
|
|
"SCRAM-SHA-256-PLUS"
|
|
} else {
|
|
"SCRAM-SHA-256"
|
|
};
|
|
let mut binding = if !plus {
|
|
Vec::new()
|
|
} else if binding_name == "tls-exporter" {
|
|
tls.get_ref()
|
|
.1
|
|
.export_keying_material([0u8; 32], b"EXPORTER-Channel-Binding", Some(b""))
|
|
.unwrap()
|
|
.to_vec()
|
|
} else {
|
|
Sha256::digest(server.cert.as_ref()).to_vec()
|
|
};
|
|
let mut f = Framer::default();
|
|
let advertised = features(&mut tls, &mut f).await;
|
|
let doc = roxmltree::Document::parse(&advertised).unwrap();
|
|
let mechanism_ns = if sasl2 { SASL2 } else { SASL1 };
|
|
let mut mechanisms: Vec<_> = doc
|
|
.descendants()
|
|
.filter(|n| n.has_tag_name((mechanism_ns, "mechanism")))
|
|
.map(|n| n.text().unwrap().to_owned())
|
|
.collect();
|
|
mechanisms.sort();
|
|
mechanisms.dedup();
|
|
assert!(mechanisms.iter().any(|m| m == mechanism));
|
|
assert_eq!(mechanisms.iter().any(|m| m == "SCRAM-SHA-256-PLUS"), plus);
|
|
let mut bindings: Vec<_> = doc
|
|
.descendants()
|
|
.filter(|n| n.has_tag_name((CB_NS, "channel-binding")))
|
|
.map(|n| n.attribute("type").unwrap().to_owned())
|
|
.collect();
|
|
bindings.sort();
|
|
bindings.dedup();
|
|
assert_eq!(
|
|
bindings,
|
|
if !plus {
|
|
vec![]
|
|
} else if tls13 {
|
|
vec!["tls-exporter", "tls-server-end-point"]
|
|
} else {
|
|
vec!["tls-server-end-point"]
|
|
}
|
|
);
|
|
let gs2 = if plus {
|
|
format!("p={binding_name},,")
|
|
} else {
|
|
"y,,".to_owned()
|
|
};
|
|
let bare = "n=alice,r=independent-client-nonce";
|
|
let first = B64.encode(format!("{gs2}{bare}"));
|
|
let ns = mechanism_ns;
|
|
if sasl2 {
|
|
send(&mut tls, &format!("<authenticate xmlns='{ns}' mechanism='{mechanism}'><initial-response>{first}</initial-response></authenticate>")).await;
|
|
} else {
|
|
send(
|
|
&mut tls,
|
|
&format!("<auth xmlns='{ns}' mechanism='{mechanism}'>{first}</auth>"),
|
|
)
|
|
.await;
|
|
}
|
|
let xml = next(&mut f, &mut tls).await;
|
|
let doc = roxmltree::Document::parse(&xml).unwrap();
|
|
assert!(doc.root_element().has_tag_name((ns, "challenge")), "{xml}");
|
|
let challenge =
|
|
String::from_utf8(B64.decode(doc.root_element().text().unwrap()).unwrap()).unwrap();
|
|
let attrs: BTreeMap<_, _> = challenge
|
|
.split(',')
|
|
.map(|a| a.split_once('=').unwrap())
|
|
.collect();
|
|
assert!(attrs["r"].starts_with("independent-client-nonce"));
|
|
assert!(attrs["r"].len() > "independent-client-nonce".len());
|
|
let ssdp_source = if bindings.is_empty() {
|
|
mechanisms.join("\x1e")
|
|
} else {
|
|
format!("{}\x1f{}", mechanisms.join("\x1e"), bindings.join("\x1e"))
|
|
};
|
|
assert_eq!(
|
|
attrs.get("h").copied(),
|
|
Some(B64.encode(Sha256::digest(ssdp_source.as_bytes())).as_str()),
|
|
"SSDP must authenticate the entire advertised mechanism and binding lists"
|
|
);
|
|
let iterations: u32 = attrs["i"].parse().unwrap();
|
|
assert!((1..=1_000_000).contains(&iterations));
|
|
let mut salted = [0u8; 32];
|
|
pbkdf2::pbkdf2_hmac::<Sha256>(
|
|
PASSWORD.as_bytes(),
|
|
&B64.decode(attrs["s"]).unwrap(),
|
|
iterations,
|
|
&mut salted,
|
|
);
|
|
if bad_binding {
|
|
binding[0] ^= 1;
|
|
}
|
|
let mut cbind = gs2.into_bytes();
|
|
cbind.extend_from_slice(&binding);
|
|
let without = format!("c={},r={}", B64.encode(cbind), attrs["r"]);
|
|
let auth_message = format!("{bare},{challenge},{without}");
|
|
let client_key = mac(&salted, b"Client Key");
|
|
let signature = mac(&Sha256::digest(&client_key), auth_message.as_bytes());
|
|
let proof: Vec<_> = client_key
|
|
.iter()
|
|
.zip(signature)
|
|
.map(|(a, b)| a ^ b)
|
|
.collect();
|
|
// Even the tampered-binding exchange has a valid password proof for its transcript.
|
|
let final_message = format!("{without},p={}", B64.encode(proof));
|
|
send(
|
|
&mut tls,
|
|
&format!(
|
|
"<response xmlns='{ns}'>{}</response>",
|
|
B64.encode(final_message)
|
|
),
|
|
)
|
|
.await;
|
|
let xml = next(&mut f, &mut tls).await;
|
|
let doc = roxmltree::Document::parse(&xml).unwrap();
|
|
let result = doc.root_element();
|
|
if bad_binding {
|
|
assert!(
|
|
result.has_tag_name((ns, "failure")),
|
|
"tampered binding authenticated: {xml}"
|
|
);
|
|
assert!(
|
|
result
|
|
.descendants()
|
|
.any(|n| n.has_tag_name((SASL1, "not-authorized"))),
|
|
"{xml}"
|
|
);
|
|
return;
|
|
}
|
|
assert!(result.has_tag_name((ns, "success")), "{xml}");
|
|
let final_data = if sasl2 {
|
|
assert_eq!(
|
|
result
|
|
.children()
|
|
.find(|n| n.has_tag_name((SASL2, "authorization-identifier")))
|
|
.and_then(|n| n.text()),
|
|
Some("alice@localhost")
|
|
);
|
|
result
|
|
.children()
|
|
.find(|n| n.has_tag_name((SASL2, "additional-data")))
|
|
.unwrap()
|
|
.text()
|
|
.unwrap()
|
|
} else {
|
|
result.text().unwrap()
|
|
};
|
|
let expected = format!(
|
|
"v={}",
|
|
B64.encode(mac(&mac(&salted, b"Server Key"), auth_message.as_bytes()))
|
|
);
|
|
assert_eq!(
|
|
String::from_utf8(B64.decode(final_data).unwrap()).unwrap(),
|
|
expected,
|
|
"verify the full server signature"
|
|
);
|
|
if !sasl2 {
|
|
f = Framer::default();
|
|
}
|
|
let post_auth = if sasl2 {
|
|
let xml = next(&mut f, &mut tls).await;
|
|
f.wrap(&xml)
|
|
} else {
|
|
features(&mut tls, &mut f).await
|
|
};
|
|
assert!(
|
|
post_auth.contains("urn:ietf:params:xml:ns:xmpp-bind"),
|
|
"{post_auth}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn starttls_scram_plus_binds_real_tls_and_authenticates_ssdp_sasl1_and_sasl2() {
|
|
tokio::time::timeout(Duration::from_secs(90), async {
|
|
let server = Server::start(false).await;
|
|
for (tls13, binding) in [
|
|
(true, "tls-exporter"),
|
|
(true, "tls-server-end-point"),
|
|
(false, "tls-server-end-point"),
|
|
] {
|
|
for sasl2 in [false, true] {
|
|
exchange(&server, tls13, binding, sasl2, false).await;
|
|
exchange(&server, tls13, binding, sasl2, true).await;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("bounded STARTTLS integration test");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn disabling_plus_hides_bindings_and_accepts_scram_y_with_mechanisms_only_ssdp() {
|
|
tokio::time::timeout(Duration::from_secs(45), async {
|
|
let server = Server::start(true).await;
|
|
for sasl2 in [false, true] {
|
|
exchange(&server, true, "none", sasl2, false).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("bounded no-PLUS STARTTLS integration test");
|
|
}
|