H-2: separate embed CA from per-instance leaf cert (mTLS)

ober

a74493598593c84c02420708953bdba0e9256a81

diff --git a/jerboa-native-rs/Cargo.toml b/jerboa-native-rs/Cargo.toml
index 5ed7532..5ce063b 100644
--- a/jerboa-native-rs/Cargo.toml
+++ b/jerboa-native-rs/Cargo.toml
@@ -19,7 +19,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg
 rustls-pki-types = { version = "1", optional = true }
 rustls-pemfile = { version = "2", optional = true }
 webpki-roots = { version = "0.26", optional = true }
-rcgen = { version = "0.13", features = ["ring", "pem"], optional = true }
+rcgen = { version = "0.13", features = ["ring", "pem", "x509-parser"], optional = true }
 time = { version = "0.3", optional = true }
 httparse = { version = "1", optional = true }
 
diff --git a/jerboa-native-rs/src/tls.rs b/jerboa-native-rs/src/tls.rs
index a5d7083..93fff16 100644
--- a/jerboa-native-rs/src/tls.rs
+++ b/jerboa-native-rs/src/tls.rs
@@ -610,17 +610,26 @@ pub extern "C" fn jerboa_tls_server_new_mtls_pem(
             return 0;
         }
 
-        // For self-signed mTLS: instead of full PKI chain verification
-        // (which fails with self-signed certs that lack CA:TRUE),
-        // verify the client presents a cert whose SHA-256 matches one
-        // of the trusted CA certs. This is equivalent to certificate
-        // pinning and is stricter than CA-based verification for our
-        // use case (single shared cert).
-        let accepted_hashes: Vec<Vec<u8>> = ca_certs
-            .iter()
-            .map(|c| ring::digest::digest(&ring::digest::SHA256, c.as_ref()).as_ref().to_vec())
-            .collect();
-        let client_verifier = Arc::new(PinnedClientVerifier { accepted_hashes });
+        // H-2: real CA chain verification (was: SHA-256 pin on the CA itself,
+        // which only worked when leaf == CA). Now any leaf signed by the
+        // embed CA is accepted, so per-instance leaves can be rotated
+        // without changing the trust anchor.
+        let mut client_root_store = rustls::RootCertStore::empty();
+        for cert in ca_certs {
+            if let Err(e) = client_root_store.add(cert) {
+                set_last_error(format!("add CA cert to root store: {}", e));
+                return 0;
+            }
+        }
+        let client_verifier = match rustls::server::WebPkiClientVerifier::builder(
+            Arc::new(client_root_store),
+        ).build() {
+            Ok(v) => v,
+            Err(e) => {
+                set_last_error(format!("client verifier build: {}", e));
+                return 0;
+            }
+        };
 
         // Build server config with client auth required
         let config = match ServerConfig::builder()
@@ -894,6 +903,110 @@ pub extern "C" fn jerboa_tls_connect_mtls(
     }
 }
 
+/// H-2: mTLS connect with in-memory cert/key AND a separate in-memory CA.
+/// The server's cert is chain-verified against ca_pem. The client
+/// presents cert_pem/key_pem as its identity. Returns handle ID (>0) on
+/// success, 0 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_tls_connect_mtls_pem_ca(
+    host: *const u8,
+    host_len: usize,
+    port: u16,
+    cert_pem: *const u8,
+    cert_pem_len: usize,
+    key_pem: *const u8,
+    key_pem_len: usize,
+    ca_pem: *const u8,
+    ca_pem_len: usize,
+) -> u64 {
+    match std::panic::catch_unwind(|| {
+        if host.is_null() || cert_pem.is_null() || key_pem.is_null() || ca_pem.is_null() {
+            set_last_error("null argument".to_string());
+            return 0;
+        }
+        let host_str = unsafe {
+            match std::str::from_utf8(std::slice::from_raw_parts(host, host_len)) {
+                Ok(s) => s.to_string(),
+                Err(_) => { set_last_error("invalid UTF-8 hostname".to_string()); return 0; }
+            }
+        };
+        let cert_data = unsafe { std::slice::from_raw_parts(cert_pem, cert_pem_len) };
+        let key_data = unsafe { std::slice::from_raw_parts(key_pem, key_pem_len) };
+        let ca_data = unsafe { std::slice::from_raw_parts(ca_pem, ca_pem_len) };
+
+        let client_certs: Vec<CertificateDer<'static>> =
+            rustls_pemfile::certs(&mut std::io::BufReader::new(cert_data))
+                .filter_map(|r| r.ok())
+                .collect();
+        if client_certs.is_empty() {
+            set_last_error("no client certificates found in PEM data".to_string());
+            return 0;
+        }
+
+        let client_key = match rustls_pemfile::private_key(&mut std::io::BufReader::new(key_data)) {
+            Ok(Some(k)) => k,
+            Ok(None) => { set_last_error("no client private key found in PEM data".to_string()); return 0; }
+            Err(e) => { set_last_error(format!("read client key PEM: {}", e)); return 0; }
+        };
+
+        let ca_certs: Vec<CertificateDer<'static>> =
+            rustls_pemfile::certs(&mut std::io::BufReader::new(ca_data))
+                .filter_map(|r| r.ok())
+                .collect();
+        if ca_certs.is_empty() {
+            set_last_error("no CA certificates found in CA PEM data".to_string());
+            return 0;
+        }
+
+        let mut root_store = rustls::RootCertStore::empty();
+        for cert in ca_certs {
+            if let Err(e) = root_store.add(cert) {
+                set_last_error(format!("add CA cert to root store: {}", e));
+                return 0;
+            }
+        }
+
+        let config = match ClientConfig::builder()
+            .with_root_certificates(root_store)
+            .with_client_auth_cert(client_certs, PrivateKeyDer::from(client_key))
+        {
+            Ok(c) => c,
+            Err(e) => { set_last_error(format!("client config: {}", e)); return 0; }
+        };
+
+        let server_name = match ServerName::try_from(host_str.clone()) {
+            Ok(sn) => sn,
+            Err(e) => { set_last_error(format!("invalid server name: {}", e)); return 0; }
+        };
+
+        let conn = match ClientConnection::new(Arc::new(config), server_name) {
+            Ok(c) => c,
+            Err(e) => { set_last_error(format!("TLS client init: {}", e)); return 0; }
+        };
+
+        let addr = format!("{}:{}", host_str, port);
+        let tcp = match TcpStream::connect(&addr) {
+            Ok(s) => s,
+            Err(e) => { set_last_error(format!("TCP connect {}: {}", addr, e)); return 0; }
+        };
+
+        let stream = StreamOwned::new(conn, tcp);
+        let fd = stream.get_ref().as_raw_fd();
+        let handle = next_handle();
+        conns().lock().unwrap().insert(handle, ConnEntry {
+            inner: Arc::new(Mutex::new(TlsConn::Client(stream))),
+            fd,
+        });
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => {
+            set_last_error("panic in tls_connect_mtls_pem_ca".to_string());
+            0
+        }
+    }
+}
+
 /// mTLS connect using in-memory cert/key data (no file paths).
 /// Avoids /proc/self/fd/ which Android SELinux may block.
 /// Returns handle ID (>0) on success, 0 on error.
diff --git a/jerboa-native-rs/src/x509.rs b/jerboa-native-rs/src/x509.rs
index 1f16094..7c419fb 100644
--- a/jerboa-native-rs/src/x509.rs
+++ b/jerboa-native-rs/src/x509.rs
@@ -1,5 +1,8 @@
 use crate::panic::{ffi_wrap, set_last_error};
-use rcgen::{CertificateParams, Ia5String, KeyPair, SanType, PKCS_ECDSA_P256_SHA256};
+use rcgen::{
+    BasicConstraints, CertificateParams, ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyPair,
+    KeyUsagePurpose, SanType, PKCS_ECDSA_P256_SHA256,
+};
 use ring::digest;
 use std::net::IpAddr;
 use time::{Duration, OffsetDateTime};
@@ -82,6 +85,11 @@ pub extern "C" fn jerboa_x509_generate_self_signed(
         params.subject_alt_names = sans;
         params.not_before = OffsetDateTime::now_utc();
         params.not_after = OffsetDateTime::now_utc() + Duration::days(validity_days as i64);
+        // H-2: emit the embed cert as a real CA so per-instance leaves can
+        // chain to it. Without is_ca, rustls rejects the trust anchor.
+        params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
+        params.key_usages.push(KeyUsagePurpose::KeyCertSign);
+        params.key_usages.push(KeyUsagePurpose::CrlSign);
 
         // Generate self-signed certificate
         let cert = match params.self_signed(&key_pair) {
@@ -191,6 +199,10 @@ pub extern "C" fn jerboa_x509_generate_self_signed_mem(
         params.subject_alt_names = sans;
         params.not_before = OffsetDateTime::now_utc();
         params.not_after = OffsetDateTime::now_utc() + Duration::days(validity_days as i64);
+        // H-2: emit the embed cert as a real CA.
+        params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
+        params.key_usages.push(KeyUsagePurpose::KeyCertSign);
+        params.key_usages.push(KeyUsagePurpose::CrlSign);
 
         let cert = match params.self_signed(&key_pair) {
             Ok(c) => c,
@@ -231,6 +243,168 @@ pub extern "C" fn jerboa_x509_generate_self_signed_mem(
     })
 }
 
+/// Generate an ECDSA P-256 leaf certificate signed by the given CA.
+///
+/// ca_cert_pem / ca_key_pem: PEM-encoded CA cert and key (caller-owned bytes).
+/// ip_addrs_csv: comma-separated SANs (IPs or DNS names) for the leaf.
+/// validity_days: leaf certificate lifetime.
+/// cert_out / key_out: caller-provided buffers; leaf PEM cert and key written
+/// here. The lengths actually written are stored in cert_out_len / key_out_len.
+///
+/// Returns 0 on success, -1 on error (call jerboa_last_error for details).
+#[no_mangle]
+pub extern "C" fn jerboa_x509_generate_signed_by_ca_mem(
+    ca_cert_pem: *const u8,
+    ca_cert_pem_len: usize,
+    ca_key_pem: *const u8,
+    ca_key_pem_len: usize,
+    ip_addrs_csv: *const u8,
+    ip_addrs_len: usize,
+    validity_days: i32,
+    cert_out: *mut u8,
+    cert_out_max: usize,
+    cert_out_len: *mut usize,
+    key_out: *mut u8,
+    key_out_max: usize,
+    key_out_len: *mut usize,
+) -> i32 {
+    ffi_wrap(|| {
+        if ca_cert_pem.is_null() || ca_key_pem.is_null() || ip_addrs_csv.is_null()
+            || cert_out.is_null() || key_out.is_null()
+            || cert_out_len.is_null() || key_out_len.is_null()
+        {
+            set_last_error("null pointer argument".to_string());
+            return -1;
+        }
+        if validity_days <= 0 {
+            set_last_error("validity_days must be positive".to_string());
+            return -1;
+        }
+
+        let ca_cert_str = unsafe {
+            match std::str::from_utf8(std::slice::from_raw_parts(ca_cert_pem, ca_cert_pem_len)) {
+                Ok(s) => s,
+                Err(e) => {
+                    set_last_error(format!("ca cert is not valid UTF-8: {}", e));
+                    return -1;
+                }
+            }
+        };
+        let ca_key_str = unsafe {
+            match std::str::from_utf8(std::slice::from_raw_parts(ca_key_pem, ca_key_pem_len)) {
+                Ok(s) => s,
+                Err(e) => {
+                    set_last_error(format!("ca key is not valid UTF-8: {}", e));
+                    return -1;
+                }
+            }
+        };
+        let ip_str = unsafe { std::str::from_utf8_unchecked(
+            std::slice::from_raw_parts(ip_addrs_csv, ip_addrs_len)) };
+
+        // Reconstruct the CA certificate + keypair from PEM.
+        let ca_params = match CertificateParams::from_ca_cert_pem(ca_cert_str) {
+            Ok(p) => p,
+            Err(e) => {
+                set_last_error(format!("failed to parse CA cert PEM: {}", e));
+                return -1;
+            }
+        };
+        let ca_kp = match KeyPair::from_pem(ca_key_str) {
+            Ok(k) => k,
+            Err(e) => {
+                set_last_error(format!("failed to parse CA key PEM: {}", e));
+                return -1;
+            }
+        };
+        let ca_cert = match ca_params.self_signed(&ca_kp) {
+            Ok(c) => c,
+            Err(e) => {
+                set_last_error(format!("failed to reconstruct CA cert: {}", e));
+                return -1;
+            }
+        };
+
+        // Parse leaf SANs (IPs or DNS names).
+        let mut sans: Vec<SanType> = Vec::new();
+        for addr_str in ip_str.split(',') {
+            let trimmed = addr_str.trim();
+            if trimmed.is_empty() {
+                continue;
+            }
+            match trimmed.parse::<IpAddr>() {
+                Ok(ip) => sans.push(SanType::IpAddress(ip)),
+                Err(_) => match Ia5String::try_from(trimmed) {
+                    Ok(name) => sans.push(SanType::DnsName(name)),
+                    Err(e) => {
+                        set_last_error(format!("invalid hostname '{}': {}", trimmed, e));
+                        return -1;
+                    }
+                },
+            }
+        }
+        if sans.is_empty() {
+            set_last_error("at least one IP or hostname is required".to_string());
+            return -1;
+        }
+
+        // Build leaf parameters.
+        let mut leaf_params = CertificateParams::default();
+        leaf_params.subject_alt_names = sans;
+        leaf_params.not_before = OffsetDateTime::now_utc();
+        leaf_params.not_after = OffsetDateTime::now_utc() + Duration::days(validity_days as i64);
+        leaf_params.use_authority_key_identifier_extension = true;
+        leaf_params.key_usages.push(KeyUsagePurpose::DigitalSignature);
+        leaf_params.extended_key_usages.push(ExtendedKeyUsagePurpose::ServerAuth);
+        leaf_params.extended_key_usages.push(ExtendedKeyUsagePurpose::ClientAuth);
+
+        let leaf_kp = match KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) {
+            Ok(k) => k,
+            Err(e) => {
+                set_last_error(format!("leaf key generation failed: {}", e));
+                return -1;
+            }
+        };
+
+        let leaf_cert = match leaf_params.signed_by(&leaf_kp, &ca_cert, &ca_kp) {
+            Ok(c) => c,
+            Err(e) => {
+                set_last_error(format!("leaf cert signing failed: {}", e));
+                return -1;
+            }
+        };
+
+        let cert_pem = leaf_cert.pem();
+        let key_pem = leaf_kp.serialize_pem();
+
+        if cert_pem.len() > cert_out_max {
+            set_last_error(format!(
+                "cert buffer too small: need {} have {}",
+                cert_pem.len(),
+                cert_out_max
+            ));
+            return -1;
+        }
+        if key_pem.len() > key_out_max {
+            set_last_error(format!(
+                "key buffer too small: need {} have {}",
+                key_pem.len(),
+                key_out_max
+            ));
+            return -1;
+        }
+
+        unsafe {
+            std::ptr::copy_nonoverlapping(cert_pem.as_ptr(), cert_out, cert_pem.len());
+            *cert_out_len = cert_pem.len();
+            std::ptr::copy_nonoverlapping(key_pem.as_ptr(), key_out, key_pem.len());
+            *key_out_len = key_pem.len();
+        }
+
+        0
+    })
+}
+
 /// Compute SHA-256 fingerprint of a PEM certificate file.
 ///
 /// Reads the PEM file, extracts the DER certificate, and computes SHA-256.