H-2: separate embed CA from per-instance leaf cert (mTLS)
ober
a74493598593c84c02420708953bdba0e9256a81
--- 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 } --- 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. --- 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.