rustls-ccm 0.2.0

CCM and CCM-8 cipher suites for rustls (TLS 1.2 and TLS 1.3)
Documentation
//! Interoperability tests against `openssl s_server`.
//!
//! These catch wire-format bugs that loopback (rustls ↔ rustls) tests can't:
//! a mistake made symmetrically on both ends passes loopback but fails here.
//! `s_server -rev` echoes each line reversed, so a successful round trip
//! exercises record protection in both directions.
//!
//! Requires an OpenSSL 3.x binary (`OPENSSL` env var, `openssl` on PATH, or a
//! Homebrew openssl@3 install); tests are skipped with a notice otherwise.
//! macOS's bundled LibreSSL does not support the CCM suites.

use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::time::Duration;

use rustls::pki_types::ServerName;
use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned, SupportedCipherSuite};

fn find_openssl() -> Option<String> {
    let candidates = std::env::var("OPENSSL").ok().into_iter().chain(
        [
            "openssl",
            "/opt/homebrew/opt/openssl@3/bin/openssl",
            "/usr/local/opt/openssl@3/bin/openssl",
        ]
        .iter()
        .map(|s| s.to_string()),
    );
    for candidate in candidates {
        if let Ok(out) = Command::new(&candidate).arg("version").output() {
            if out.status.success() && out.stdout.starts_with(b"OpenSSL 3") {
                return Some(candidate);
            }
        }
    }
    None
}

struct ServerGuard(Child);

impl Drop for ServerGuard {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}

fn interop(suite: SupportedCipherSuite, server_args: &[&str]) {
    let Some(openssl) = find_openssl() else {
        eprintln!("skipping interop test: no OpenSSL 3.x binary found");
        return;
    };

    let kp = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
    let params = rcgen::CertificateParams::new(vec!["localhost".into()]).unwrap();
    let cert = params.self_signed(&kp).unwrap();

    let port = TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap()
        .port();

    let dir =
        std::env::temp_dir().join(format!("rustls-ccm-interop-{}-{port}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let cert_path = dir.join("cert.pem");
    let key_path = dir.join("key.pem");
    std::fs::write(&cert_path, cert.pem()).unwrap();
    std::fs::write(&key_path, kp.serialize_pem()).unwrap();

    let child = Command::new(&openssl)
        .arg("s_server")
        .args(["-accept", &port.to_string()])
        .arg("-cert")
        .arg(&cert_path)
        .arg("-key")
        .arg(&key_path)
        .args(["-rev", "-naccept", "1", "-quiet"])
        .args(server_args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .unwrap();
    let _guard = ServerGuard(child);

    let mut sock = None;
    for _ in 0..100 {
        match TcpStream::connect(("127.0.0.1", port)) {
            Ok(s) => {
                sock = Some(s);
                break;
            }
            Err(_) => std::thread::sleep(Duration::from_millis(50)),
        }
    }
    let sock = sock.expect("openssl s_server did not start listening");
    sock.set_read_timeout(Some(Duration::from_secs(10)))
        .unwrap();

    let mut roots = RootCertStore::empty();
    roots.add(cert.der().clone()).unwrap();

    let mut provider = rustls::crypto::aws_lc_rs::default_provider();
    provider.cipher_suites = vec![suite];
    let versions: &[&rustls::SupportedProtocolVersion] = match suite {
        SupportedCipherSuite::Tls12(_) => &[&rustls::version::TLS12],
        SupportedCipherSuite::Tls13(_) => &[&rustls::version::TLS13],
    };
    let config = ClientConfig::builder_with_provider(Arc::new(provider))
        .with_protocol_versions(versions)
        .unwrap()
        .with_root_certificates(roots)
        .with_no_client_auth();

    let server_name = ServerName::try_from("localhost").unwrap();
    let conn = ClientConnection::new(Arc::new(config), server_name).unwrap();
    let mut tls = StreamOwned::new(conn, sock);

    tls.write_all(b"ping\n").unwrap();
    tls.flush().unwrap();

    let mut reader = BufReader::new(&mut tls);
    let mut line = String::new();
    reader.read_line(&mut line).unwrap();
    assert_eq!(line.trim_end(), "gnip", "bad echo from s_server: {line:?}");

    assert_eq!(
        tls.conn.negotiated_cipher_suite().unwrap().suite(),
        suite.suite()
    );

    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn tls12_aes128_ccm_vs_openssl() {
    interop(
        *rustls_ccm::TLS_ECDHE_ECDSA_WITH_AES_128_CCM,
        &["-tls1_2", "-cipher", "ECDHE-ECDSA-AES128-CCM"],
    );
}

#[test]
fn tls12_aes256_ccm_vs_openssl() {
    interop(
        *rustls_ccm::TLS_ECDHE_ECDSA_WITH_AES_256_CCM,
        &["-tls1_2", "-cipher", "ECDHE-ECDSA-AES256-CCM"],
    );
}

#[test]
fn tls12_aes128_ccm8_vs_openssl() {
    // CCM-8's 64-bit tag is below OpenSSL's default security level.
    interop(
        *rustls_ccm::TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8,
        &["-tls1_2", "-cipher", "ECDHE-ECDSA-AES128-CCM8@SECLEVEL=0"],
    );
}

#[test]
fn tls12_aes256_ccm8_vs_openssl() {
    interop(
        *rustls_ccm::TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8,
        &["-tls1_2", "-cipher", "ECDHE-ECDSA-AES256-CCM8@SECLEVEL=0"],
    );
}

#[test]
fn tls13_aes128_ccm_vs_openssl() {
    interop(
        *rustls_ccm::TLS13_AES_128_CCM_SHA256,
        &["-tls1_3", "-ciphersuites", "TLS_AES_128_CCM_SHA256"],
    );
}

#[test]
fn tls13_aes128_ccm8_vs_openssl() {
    interop(
        *rustls_ccm::TLS13_AES_128_CCM_8_SHA256,
        &[
            "-tls1_3",
            "-ciphersuites",
            "TLS_AES_128_CCM_8_SHA256",
            "-cipher",
            "DEFAULT@SECLEVEL=0",
        ],
    );
}