From 7fca619a10b12d8669ca3a9edf5235366d9e33f5 Mon Sep 17 00:00:00 2001 From: Abhay Kumar Date: Sun, 15 Feb 2026 01:30:59 -0800 Subject: [PATCH 1/4] Add hw-rsa feature for hardware RSA offload Add a `hw-rsa` Cargo feature that enables RSA signature verification during TLS handshakes via extern "Rust" callbacks instead of the software `rsa` crate. This allows embedded targets with hardware RSA accelerators (e.g., ESP32) to verify RSA signatures without pulling in the heavy ~60-120KB software RSA implementation. Six extern callbacks cover all RSA signature schemes: - Certificate chain verification (PKCS#1 v1.5): - embedded_tls_verify_rsa_pkcs1v15_sha256 - embedded_tls_verify_rsa_pkcs1v15_sha384 - embedded_tls_verify_rsa_pkcs1v15_sha512 - CertificateVerify signature verification (RSA-PSS): - embedded_tls_verify_rsa_pss_sha256 - embedded_tls_verify_rsa_pss_sha384 - embedded_tls_verify_rsa_pss_sha512 The firmware must provide these symbols at link time. Features `rsa` and `hw-rsa` are mutually exclusive (enforced via compile_error!). Changes: - Cargo.toml: add hw-rsa feature, add heapless to der features (fixes existing upstream build bug), add rsa as dev-dependency for testing - lib.rs: add compile_error guard for rsa+hw-rsa mutual exclusion - config.rs: enable RSA signature schemes when hw-rsa is active - der_certificate.rs: gate RSA OID constants on hw-rsa too - pki.rs: add hw-rsa match arms with extern callback declarations - tests/rustpki_hw_rsa_test.rs: integration test providing software RSA as callback implementations, validates full dispatch path through a real TLS handshake --- Cargo.toml | 4 +- src/config.rs | 2 +- src/der_certificate.rs | 6 +- src/lib.rs | 3 + src/pki.rs | 104 +++++++++++- tests/rustpki_hw_rsa_test.rs | 298 +++++++++++++++++++++++++++++++++++ 6 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 tests/rustpki_hw_rsa_test.rs diff --git a/Cargo.toml b/Cargo.toml index 93794894..cd52aaed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ embedded-io-adapters = { version = "0.7", optional = true } generic-array = { version = "0.14", default-features = false } webpki = { package = "rustls-webpki", version = "0.101.7", default-features = false, optional = true } const-oid = { version = "0.10.1", optional = true } -der = { version = "0.8.0-rc.2", features = ["derive", "oid", "time"], optional = true } +der = { version = "0.8.0-rc.2", features = ["derive", "oid", "time", "heapless"], optional = true } signature = { version = "2.2", default-features = false } ecdsa = { version = "0.16.9", default-features = false } @@ -41,6 +41,7 @@ log = { version = "0.4", optional = true } defmt = { version = "1.0.1", optional = true } [dev-dependencies] +rsa = { version = "0.9.9", features = ["sha2"] } env_logger = "0.11" tokio = { version = "1", features = ["full"] } mio = { version = "0.8.3", features = ["os-poll", "net"] } @@ -61,5 +62,6 @@ alloc = [] webpki = ["dep:webpki"] rustpki = ["dep:der","dep:const-oid"] rsa = ["dep:rsa", "rustpki", "alloc"] +hw-rsa = ["rustpki"] ed25519 = ["dep:ed25519-dalek", "rustpki"] p384 = ["dep:p384", "rustpki"] diff --git a/src/config.rs b/src/config.rs index a77885f4..dc1b3d05 100644 --- a/src/config.rs +++ b/src/config.rs @@ -267,7 +267,7 @@ impl<'a> TlsConfig<'a> { priv_key: &[], }; - if cfg!(feature = "alloc") { + if cfg!(any(feature = "alloc", feature = "hw-rsa")) { config = config.enable_rsa_signatures(); } diff --git a/src/der_certificate.rs b/src/der_certificate.rs index 9563408c..f131533e 100644 --- a/src/der_certificate.rs +++ b/src/der_certificate.rs @@ -31,17 +31,17 @@ pub const ED25519: AlgorithmIdentifier = AlgorithmIdentifier { oid: ObjectIdentifier::new_unwrap("1.3.101.112"), parameters: None, }; -#[cfg(feature = "rsa")] +#[cfg(any(feature = "rsa", feature = "hw-rsa"))] pub const RSA_PKCS1_SHA256: AlgorithmIdentifier = AlgorithmIdentifier { oid: ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.11"), parameters: Some(AnyRef::NULL), }; -#[cfg(feature = "rsa")] +#[cfg(any(feature = "rsa", feature = "hw-rsa"))] pub const RSA_PKCS1_SHA384: AlgorithmIdentifier = AlgorithmIdentifier { oid: ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.12"), parameters: Some(AnyRef::NULL), }; -#[cfg(feature = "rsa")] +#[cfg(any(feature = "rsa", feature = "hw-rsa"))] pub const RSA_PKCS1_SHA512: AlgorithmIdentifier = AlgorithmIdentifier { oid: ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.13"), parameters: Some(AnyRef::NULL), diff --git a/src/lib.rs b/src/lib.rs index 5d7a2e06..320a7963 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,9 @@ async fn main() { ``` */ +#[cfg(all(feature = "rsa", feature = "hw-rsa"))] +compile_error!("Features `rsa` and `hw-rsa` are mutually exclusive. Use `rsa` for software RSA or `hw-rsa` for hardware-accelerated RSA, not both."); + // This mod MUST go first, so that the others see its macros. pub(crate) mod fmt; diff --git a/src/pki.rs b/src/pki.rs index 576003d3..42c7a999 100644 --- a/src/pki.rs +++ b/src/pki.rs @@ -5,7 +5,7 @@ use crate::der_certificate::ECDSA_SHA384; #[cfg(feature = "ed25519")] use crate::der_certificate::ED25519; use crate::der_certificate::{DecodedCertificate, ECDSA_SHA256, Time}; -#[cfg(feature = "rsa")] +#[cfg(any(feature = "rsa", feature = "hw-rsa"))] use crate::der_certificate::{RSA_PKCS1_SHA256, RSA_PKCS1_SHA384, RSA_PKCS1_SHA512}; use crate::extensions::extension_data::signature_algorithms::SignatureScheme; use crate::handshake::{ @@ -259,6 +259,51 @@ fn verify_signature( Signature::try_from(verify.signature).map_err(|_| TlsError::DecodeError)?; verified = verifying_key.verify(message, &signature).is_ok(); } + #[cfg(feature = "hw-rsa")] + SignatureScheme::RsaPssRsaeSha256 => { + unsafe extern "Rust" { + safe fn embedded_tls_verify_rsa_pss_sha256( + pk: *const u8, pk_len: usize, + sig: *const u8, sig_len: usize, + msg: *const u8, msg_len: usize, + ) -> bool; + } + verified = embedded_tls_verify_rsa_pss_sha256( + public_key.as_ptr(), public_key.len(), + verify.signature.as_ptr(), verify.signature.len(), + message.as_ptr(), message.len(), + ); + } + #[cfg(feature = "hw-rsa")] + SignatureScheme::RsaPssRsaeSha384 => { + unsafe extern "Rust" { + safe fn embedded_tls_verify_rsa_pss_sha384( + pk: *const u8, pk_len: usize, + sig: *const u8, sig_len: usize, + msg: *const u8, msg_len: usize, + ) -> bool; + } + verified = embedded_tls_verify_rsa_pss_sha384( + public_key.as_ptr(), public_key.len(), + verify.signature.as_ptr(), verify.signature.len(), + message.as_ptr(), message.len(), + ); + } + #[cfg(feature = "hw-rsa")] + SignatureScheme::RsaPssRsaeSha512 => { + unsafe extern "Rust" { + safe fn embedded_tls_verify_rsa_pss_sha512( + pk: *const u8, pk_len: usize, + sig: *const u8, sig_len: usize, + msg: *const u8, msg_len: usize, + ) -> bool; + } + verified = embedded_tls_verify_rsa_pss_sha512( + public_key.as_ptr(), public_key.len(), + verify.signature.as_ptr(), verify.signature.len(), + message.as_ptr(), message.len(), + ); + } _ => { error!( "InvalidSignatureScheme: {:?} Are you missing a feature?", @@ -466,6 +511,63 @@ fn verify_certificate( verified = verifying_key.verify(certificate_data, &signature).is_ok(); } + #[cfg(feature = "hw-rsa")] + a if a == RSA_PKCS1_SHA256 => { + unsafe extern "Rust" { + safe fn embedded_tls_verify_rsa_pkcs1v15_sha256( + pk: *const u8, pk_len: usize, + sig: *const u8, sig_len: usize, + msg: *const u8, msg_len: usize, + ) -> bool; + } + let sig_bytes = parsed_certificate + .signature + .as_bytes() + .ok_or(TlsError::ParseError(ParseError::InvalidData))?; + verified = embedded_tls_verify_rsa_pkcs1v15_sha256( + ca_public_key.as_ptr(), ca_public_key.len(), + sig_bytes.as_ptr(), sig_bytes.len(), + certificate_data.as_ptr(), certificate_data.len(), + ); + } + #[cfg(feature = "hw-rsa")] + a if a == RSA_PKCS1_SHA384 => { + unsafe extern "Rust" { + safe fn embedded_tls_verify_rsa_pkcs1v15_sha384( + pk: *const u8, pk_len: usize, + sig: *const u8, sig_len: usize, + msg: *const u8, msg_len: usize, + ) -> bool; + } + let sig_bytes = parsed_certificate + .signature + .as_bytes() + .ok_or(TlsError::ParseError(ParseError::InvalidData))?; + verified = embedded_tls_verify_rsa_pkcs1v15_sha384( + ca_public_key.as_ptr(), ca_public_key.len(), + sig_bytes.as_ptr(), sig_bytes.len(), + certificate_data.as_ptr(), certificate_data.len(), + ); + } + #[cfg(feature = "hw-rsa")] + a if a == RSA_PKCS1_SHA512 => { + unsafe extern "Rust" { + safe fn embedded_tls_verify_rsa_pkcs1v15_sha512( + pk: *const u8, pk_len: usize, + sig: *const u8, sig_len: usize, + msg: *const u8, msg_len: usize, + ) -> bool; + } + let sig_bytes = parsed_certificate + .signature + .as_bytes() + .ok_or(TlsError::ParseError(ParseError::InvalidData))?; + verified = embedded_tls_verify_rsa_pkcs1v15_sha512( + ca_public_key.as_ptr(), ca_public_key.len(), + sig_bytes.as_ptr(), sig_bytes.len(), + certificate_data.as_ptr(), certificate_data.len(), + ); + } _ => { error!( "Unsupported signature alg: {:?}", diff --git a/tests/rustpki_hw_rsa_test.rs b/tests/rustpki_hw_rsa_test.rs new file mode 100644 index 00000000..e5af5b21 --- /dev/null +++ b/tests/rustpki_hw_rsa_test.rs @@ -0,0 +1,298 @@ +#![cfg(feature = "hw-rsa")] +//! Integration test for the `hw-rsa` feature. +//! +//! Provides software RSA implementations as the extern "Rust" callback bodies, +//! verifying that the hw-rsa dispatch path works correctly through a real TLS +//! handshake against a local RSA-based TLS server. + +use embedded_io_adapters::tokio_1::FromTokio; +use embedded_tls::pki::CertVerifier; +use embedded_tls::{Aes128GcmSha256, CryptoProvider, SignatureScheme, TlsError, TlsVerifier}; +use rand_core::OsRng; +use rsa::pkcs1::DecodeRsaPublicKey; +use rsa::signature::Verifier; +use signature::SignerMut; +use std::net::SocketAddr; +use std::sync::Once; +use std::time::SystemTime; + +mod tlsserver; + +static LOG_INIT: Once = Once::new(); +static INIT: Once = Once::new(); +static mut ADDR: Option = None; + +// --- hw-rsa callback implementations using software RSA --- + +/// PKCS#1 v1.5 SHA-256 verification callback. +#[unsafe(no_mangle)] +fn embedded_tls_verify_rsa_pkcs1v15_sha256( + pk: *const u8, + pk_len: usize, + sig: *const u8, + sig_len: usize, + msg: *const u8, + msg_len: usize, +) -> bool { + let pk = unsafe { core::slice::from_raw_parts(pk, pk_len) }; + let sig = unsafe { core::slice::from_raw_parts(sig, sig_len) }; + let msg = unsafe { core::slice::from_raw_parts(msg, msg_len) }; + + let Ok(public_key) = rsa::RsaPublicKey::from_pkcs1_der(pk) else { + return false; + }; + let verifying_key = rsa::pkcs1v15::VerifyingKey::::new(public_key); + let Ok(signature) = rsa::pkcs1v15::Signature::try_from(sig) else { + return false; + }; + verifying_key.verify(msg, &signature).is_ok() +} + +/// PKCS#1 v1.5 SHA-384 verification callback. +#[unsafe(no_mangle)] +fn embedded_tls_verify_rsa_pkcs1v15_sha384( + pk: *const u8, + pk_len: usize, + sig: *const u8, + sig_len: usize, + msg: *const u8, + msg_len: usize, +) -> bool { + let pk = unsafe { core::slice::from_raw_parts(pk, pk_len) }; + let sig = unsafe { core::slice::from_raw_parts(sig, sig_len) }; + let msg = unsafe { core::slice::from_raw_parts(msg, msg_len) }; + + let Ok(public_key) = rsa::RsaPublicKey::from_pkcs1_der(pk) else { + return false; + }; + let verifying_key = rsa::pkcs1v15::VerifyingKey::::new(public_key); + let Ok(signature) = rsa::pkcs1v15::Signature::try_from(sig) else { + return false; + }; + verifying_key.verify(msg, &signature).is_ok() +} + +/// PKCS#1 v1.5 SHA-512 verification callback. +#[unsafe(no_mangle)] +fn embedded_tls_verify_rsa_pkcs1v15_sha512( + pk: *const u8, + pk_len: usize, + sig: *const u8, + sig_len: usize, + msg: *const u8, + msg_len: usize, +) -> bool { + let pk = unsafe { core::slice::from_raw_parts(pk, pk_len) }; + let sig = unsafe { core::slice::from_raw_parts(sig, sig_len) }; + let msg = unsafe { core::slice::from_raw_parts(msg, msg_len) }; + + let Ok(public_key) = rsa::RsaPublicKey::from_pkcs1_der(pk) else { + return false; + }; + let verifying_key = rsa::pkcs1v15::VerifyingKey::::new(public_key); + let Ok(signature) = rsa::pkcs1v15::Signature::try_from(sig) else { + return false; + }; + verifying_key.verify(msg, &signature).is_ok() +} + +/// RSA-PSS SHA-256 verification callback. +#[unsafe(no_mangle)] +fn embedded_tls_verify_rsa_pss_sha256( + pk: *const u8, + pk_len: usize, + sig: *const u8, + sig_len: usize, + msg: *const u8, + msg_len: usize, +) -> bool { + let pk = unsafe { core::slice::from_raw_parts(pk, pk_len) }; + let sig = unsafe { core::slice::from_raw_parts(sig, sig_len) }; + let msg = unsafe { core::slice::from_raw_parts(msg, msg_len) }; + + let Ok(public_key) = rsa::RsaPublicKey::from_pkcs1_der(pk) else { + return false; + }; + let verifying_key = rsa::pss::VerifyingKey::::from(public_key); + let Ok(signature) = rsa::pss::Signature::try_from(sig) else { + return false; + }; + verifying_key.verify(msg, &signature).is_ok() +} + +/// RSA-PSS SHA-384 verification callback. +#[unsafe(no_mangle)] +fn embedded_tls_verify_rsa_pss_sha384( + pk: *const u8, + pk_len: usize, + sig: *const u8, + sig_len: usize, + msg: *const u8, + msg_len: usize, +) -> bool { + let pk = unsafe { core::slice::from_raw_parts(pk, pk_len) }; + let sig = unsafe { core::slice::from_raw_parts(sig, sig_len) }; + let msg = unsafe { core::slice::from_raw_parts(msg, msg_len) }; + + let Ok(public_key) = rsa::RsaPublicKey::from_pkcs1_der(pk) else { + return false; + }; + let verifying_key = rsa::pss::VerifyingKey::::from(public_key); + let Ok(signature) = rsa::pss::Signature::try_from(sig) else { + return false; + }; + verifying_key.verify(msg, &signature).is_ok() +} + +/// RSA-PSS SHA-512 verification callback. +#[unsafe(no_mangle)] +fn embedded_tls_verify_rsa_pss_sha512( + pk: *const u8, + pk_len: usize, + sig: *const u8, + sig_len: usize, + msg: *const u8, + msg_len: usize, +) -> bool { + let pk = unsafe { core::slice::from_raw_parts(pk, pk_len) }; + let sig = unsafe { core::slice::from_raw_parts(sig, sig_len) }; + let msg = unsafe { core::slice::from_raw_parts(msg, msg_len) }; + + let Ok(public_key) = rsa::RsaPublicKey::from_pkcs1_der(pk) else { + return false; + }; + let verifying_key = rsa::pss::VerifyingKey::::from(public_key); + let Ok(signature) = rsa::pss::Signature::try_from(sig) else { + return false; + }; + verifying_key.verify(msg, &signature).is_ok() +} + +// --- Provider for hw-rsa (no client cert signing, server-only validation) --- + +#[derive(Default)] +struct HwRsaProvider { + rng: rand::rngs::OsRng, + verifier: CertVerifier, +} + +impl CryptoProvider for HwRsaProvider { + type CipherSuite = Aes128GcmSha256; + type Signature = p256::ecdsa::DerSignature; + + fn rng(&mut self) -> impl embedded_tls::CryptoRngCore { + &mut self.rng + } + + fn verifier(&mut self) -> Result<&mut impl TlsVerifier, TlsError> { + Ok(&mut self.verifier) + } + + fn signer( + &mut self, + _key_der: &[u8], + ) -> Result<(impl SignerMut, SignatureScheme), TlsError> { + // hw-rsa tests don't use client certs, so this is unreachable. + // Provide a dummy that satisfies the type system. + Err::<(DummySigner, SignatureScheme), _>(TlsError::Unimplemented) + } +} + +struct DummySigner; +impl SignerMut for DummySigner { + fn try_sign( + &mut self, + _msg: &[u8], + ) -> Result { + unreachable!() + } +} + +fn init_log() { + LOG_INIT.call_once(|| { + env_logger::init(); + }); +} + +fn setup() -> SocketAddr { + use mio::net::TcpListener; + init_log(); + INIT.call_once(|| { + let addr: SocketAddr = "127.0.0.1:12347".parse().unwrap(); + + let listener = TcpListener::bind(addr).expect("cannot listen on port"); + let addr = listener + .local_addr() + .expect("error retrieving socket address"); + + std::thread::spawn(move || { + use tlsserver::*; + + let versions = &[&rustls::version::TLS13]; + + let test_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests"); + + let certs = load_certs(&test_dir.join("data").join("rsa-server-cert.pem")); + let privkey = load_private_key(&test_dir.join("data").join("rsa-server-key.pem")); + + let config = rustls::ServerConfig::builder() + .with_cipher_suites(rustls::ALL_CIPHER_SUITES) + .with_kx_groups(&rustls::ALL_KX_GROUPS) + .with_protocol_versions(versions) + .unwrap() + .with_no_client_auth() + .with_single_cert(certs, privkey) + .unwrap(); + + run_with_config(listener, config); + }); + #[allow(static_mut_refs)] + unsafe { + ADDR.replace(addr) + }; + }); + unsafe { ADDR.unwrap() } +} + +/// Test that hw-rsa callbacks are correctly dispatched during TLS handshake +/// with an RSA-based server certificate chain. +#[tokio::test] +async fn test_hw_rsa_certificate_validation() { + use embedded_tls::*; + + let addr = setup(); + let pem = include_str!("data/rsa-ca-cert.pem"); + let der = pem_parser::pem_to_der(pem); + + let stream = tokio::net::TcpStream::connect(addr) + .await + .expect("error connecting to server"); + + let mut read_record_buffer = [0; 16640]; + let mut write_record_buffer = [0; 16640]; + + let config = TlsConfig::new() + .with_ca(Certificate::X509(&der[..])) + .with_server_name("localhost"); + + let mut tls = TlsConnection::new( + FromTokio::new(stream), + &mut read_record_buffer, + &mut write_record_buffer, + ); + + let open_fut = tls.open(TlsContext::new( + &config, + HwRsaProvider { + rng: OsRng, + verifier: CertVerifier::new(), + }, + )); + + open_fut.await.expect("error establishing TLS connection"); + + tls.close() + .await + .map_err(|(_, e)| e) + .expect("error closing session"); +} From 12ab42d72f5c29b51c10225b75dc5c817c3d6342 Mon Sep 17 00:00:00 2001 From: Abhay Kumar Date: Tue, 17 Feb 2026 01:26:15 -0800 Subject: [PATCH 2/4] Add SHA-1 PKCS#1 v1.5 signature verification via hw-rsa callback Adds RSA_PKCS1_SHA1 (OID 1.2.840.113549.1.1.5) to the hw-rsa feature's certificate verification dispatch. This enables proper verification of certificates signed with sha1WithRSAEncryption (e.g. Go Daddy Class 2 CA) without bypassing any validation steps. Reverts the earlier trust anchor self-signature skip, which had three security issues: it bypassed time validity checks, could accept rogue self-signed certs from the server, and broke CN extraction for self-signed leaf certs. --- src/config.rs | 2 ++ src/der_certificate.rs | 5 +++++ src/pki.rs | 29 +++++++++++++++++++++++++---- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index dc1b3d05..03796be8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -277,12 +277,14 @@ impl<'a> TlsConfig<'a> { .push(SignatureScheme::EcdsaSecp256r1Sha256) .ok() ); + #[cfg(feature = "p384")] unwrap!( config .signature_schemes .push(SignatureScheme::EcdsaSecp384r1Sha384) .ok() ); + #[cfg(feature = "ed25519")] unwrap!(config.signature_schemes.push(SignatureScheme::Ed25519).ok()); unwrap!(config.named_groups.push(NamedGroup::Secp256r1)); diff --git a/src/der_certificate.rs b/src/der_certificate.rs index f131533e..511f5a41 100644 --- a/src/der_certificate.rs +++ b/src/der_certificate.rs @@ -32,6 +32,11 @@ pub const ED25519: AlgorithmIdentifier = AlgorithmIdentifier { parameters: None, }; #[cfg(any(feature = "rsa", feature = "hw-rsa"))] +pub const RSA_PKCS1_SHA1: AlgorithmIdentifier = AlgorithmIdentifier { + oid: ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.5"), + parameters: Some(AnyRef::NULL), +}; +#[cfg(any(feature = "rsa", feature = "hw-rsa"))] pub const RSA_PKCS1_SHA256: AlgorithmIdentifier = AlgorithmIdentifier { oid: ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.11"), parameters: Some(AnyRef::NULL), diff --git a/src/pki.rs b/src/pki.rs index 42c7a999..66f47c28 100644 --- a/src/pki.rs +++ b/src/pki.rs @@ -6,7 +6,7 @@ use crate::der_certificate::ECDSA_SHA384; use crate::der_certificate::ED25519; use crate::der_certificate::{DecodedCertificate, ECDSA_SHA256, Time}; #[cfg(any(feature = "rsa", feature = "hw-rsa"))] -use crate::der_certificate::{RSA_PKCS1_SHA256, RSA_PKCS1_SHA384, RSA_PKCS1_SHA512}; +use crate::der_certificate::{RSA_PKCS1_SHA1, RSA_PKCS1_SHA256, RSA_PKCS1_SHA384, RSA_PKCS1_SHA512}; use crate::extensions::extension_data::signature_algorithms::SignatureScheme; use crate::handshake::{ certificate::{ @@ -379,9 +379,10 @@ fn verify_certificate( } if let Some(now) = now { - if get_cert_time(parsed_certificate.tbs_certificate.validity.not_before) > now - || get_cert_time(parsed_certificate.tbs_certificate.validity.not_after) < now - { + let not_before = get_cert_time(parsed_certificate.tbs_certificate.validity.not_before); + let not_after = get_cert_time(parsed_certificate.tbs_certificate.validity.not_after); + if not_before > now || not_after < now { + debug!("Cert time invalid: now={} not_before={} not_after={}", now, not_before, not_after); return Err(TlsError::InvalidCertificate); } debug!("Epoch is {} and certificate is valid!", now) @@ -568,6 +569,25 @@ fn verify_certificate( certificate_data.as_ptr(), certificate_data.len(), ); } + #[cfg(feature = "hw-rsa")] + a if a == RSA_PKCS1_SHA1 => { + unsafe extern "Rust" { + safe fn embedded_tls_verify_rsa_pkcs1v15_sha1( + pk: *const u8, pk_len: usize, + sig: *const u8, sig_len: usize, + msg: *const u8, msg_len: usize, + ) -> bool; + } + let sig_bytes = parsed_certificate + .signature + .as_bytes() + .ok_or(TlsError::ParseError(ParseError::InvalidData))?; + verified = embedded_tls_verify_rsa_pkcs1v15_sha1( + ca_public_key.as_ptr(), ca_public_key.len(), + sig_bytes.as_ptr(), sig_bytes.len(), + certificate_data.as_ptr(), certificate_data.len(), + ); + } _ => { error!( "Unsupported signature alg: {:?}", @@ -579,6 +599,7 @@ fn verify_certificate( } if !verified { + debug!("Cert signature verification failed: cn={:?}", common_name); return Err(TlsError::InvalidCertificate); } From d9d00a91bddce6e38226c271f0c5dea1d5e80931 Mon Sep 17 00:00:00 2001 From: Abhay Kumar Date: Sun, 15 Feb 2026 03:10:07 -0800 Subject: [PATCH 3/4] Add system-clock feature for extern time callback When enabled, NoClock::now() calls an extern "Rust" callback (embedded_tls_system_clock_now) to get the current epoch seconds, enabling TLS certificate time validation without modifying reqwless. --- Cargo.toml | 1 + src/config.rs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index cd52aaed..e733cedf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,5 +63,6 @@ webpki = ["dep:webpki"] rustpki = ["dep:der","dep:const-oid"] rsa = ["dep:rsa", "rustpki", "alloc"] hw-rsa = ["rustpki"] +system-clock = [] ed25519 = ["dep:ed25519-dalek", "rustpki"] p384 = ["dep:p384", "rustpki"] diff --git a/src/config.rs b/src/config.rs index 03796be8..7bf0b404 100644 --- a/src/config.rs +++ b/src/config.rs @@ -138,6 +138,15 @@ pub struct NoClock; impl TlsClock for NoClock { fn now() -> Option { + #[cfg(feature = "system-clock")] + { + unsafe extern "Rust" { + fn embedded_tls_system_clock_now() -> u64; + } + let secs = unsafe { embedded_tls_system_clock_now() }; + return if secs == 0 { None } else { Some(secs) }; + } + #[cfg(not(feature = "system-clock"))] None } } From a095342ae2b0850dbcbe2412cfa0ca7ec73bded7 Mon Sep 17 00:00:00 2001 From: Abhay Kumar Date: Wed, 18 Feb 2026 15:50:35 -0800 Subject: [PATCH 4/4] Add SAN hostname verification to rustpki certificate verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rustpki backend only checked CommonName for hostname verification, ignoring Subject Alternative Names. Modern certificates (including wildcard certs from CAs like Cloudflare) rely on SANs — connections to hosts like a.example.com failed because CN=example.com didn't match and SANs were never consulted. - New san_matches_hostname() streams through extensions DER inline, matching each dNSName against the hostname during parsing. Zero allocation, no limit on SAN count, early return on first match. - Follows RFC 6125: SANs take precedence over CN when present. Wildcard matching (*.example.com) is case-insensitive, single-level. - Fail-closed: malformed DER returns Err (rejected), not None (which would silently fall back to CN trust). - Null byte injection in dNSName entries is rejected. - verify_certificate() takes hostname and returns HostnameResult with tri-state: Ok(None) no SANs / Ok(Some) matched / Err bad DER. --- src/pki.rs | 399 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 389 insertions(+), 10 deletions(-) diff --git a/src/pki.rs b/src/pki.rs index 66f47c28..61f84482 100644 --- a/src/pki.rs +++ b/src/pki.rs @@ -24,6 +24,57 @@ use heapless::{String, Vec}; const HOSTNAME_MAXLEN: usize = 64; const COMMON_NAME_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.3"); +/// Result of hostname verification against a certificate. +struct HostnameResult { + common_name: Option>, + /// None = no SANs found, Some(true) = SAN matched, Some(false) = SANs present but no match. + san_match: Option, +} + +impl HostnameResult { + /// Check if a hostname matches this certificate's identity. + /// + /// Per RFC 6125: if SANs are present, only SANs are checked (CN is ignored). + fn matches_hostname(&self, hostname: &str) -> bool { + match self.san_match { + Some(result) => result, + None => self + .common_name + .as_ref() + .map(|cn| hostname_matches(cn.as_str(), hostname)) + .unwrap_or(false), + } + } +} + +/// Check if a certificate name (possibly wildcard) matches a hostname. +/// +/// Case-insensitive per RFC 6125. Supports `*.example.com` matching +/// `foo.example.com` but not `example.com` or `foo.bar.example.com` +/// (single-level wildcard only, per RFC 6125 §6.4.3). +fn hostname_matches(pattern: &str, hostname: &str) -> bool { + if pattern.eq_ignore_ascii_case(hostname) { + return true; + } + // Wildcard: *.example.com + if let Some(suffix) = pattern.strip_prefix("*.") { + // Hostname must be longer than suffix + 1 (at least "x." + suffix) + if hostname.len() > suffix.len() + 1 { + let hostname_suffix = &hostname[hostname.len() - suffix.len()..]; + let hostname_prefix = &hostname[..hostname.len() - suffix.len()]; + // Suffix must match case-insensitively + if hostname_suffix.eq_ignore_ascii_case(suffix) + // Prefix must be "label." (ends with dot, single label) + && hostname_prefix.ends_with('.') + && !hostname_prefix[..hostname_prefix.len() - 1].contains('.') + { + return true; + } + } + } + false +} + pub struct CertificateChain<'a> { prev: Option<&'a CertificateEntryRef<'a>>, chain: &'a ServerCertificate<'a>, @@ -121,16 +172,22 @@ where return Err(TlsError::Unimplemented); }; - let mut cn = None; + let mut result = HostnameResult { + common_name: None, + san_match: None, + }; + let hostname_ref = self.host.as_deref(); for (p, q) in CertificateChain::new(&ca.into(), &cert) { - cn = verify_certificate(p, q, Clock::now())?; + result = verify_certificate(p, q, Clock::now(), hostname_ref)?; } - if self.host.ne(&cn) { - error!( - "Hostname ({:?}) does not match CommonName ({:?})", - self.host, cn - ); - return Err(TlsError::InvalidCertificate); + if let Some(ref hostname) = self.host { + if !result.matches_hostname(hostname.as_str()) { + error!( + "Hostname ({:?}) does not match certificate (CN={:?}, SAN match={:?})", + self.host, result.common_name, result.san_match + ); + return Err(TlsError::InvalidCertificate); + } } self.certificate.replace(cert.try_into()?); @@ -319,6 +376,151 @@ fn verify_signature( Ok(()) } +/// Stream-check hostname against Subject Alternative Name DNS entries. +/// +/// Parses extensions DER bytes to find the SAN extension (OID 2.5.29.17), +/// then evaluates each dNSName entry against the hostname during parsing. +/// No allocation — returns early on first match. +/// +/// Returns: +/// - `Ok(None)` — no SAN extension found (caller should fall back to CN) +/// - `Ok(Some(true))` — a SAN DNS name matched the hostname +/// - `Ok(Some(false))` — SANs present but none matched +/// - `Err(())` — malformed DER (caller must reject the certificate) +fn san_matches_hostname(extensions_bytes: &[u8], hostname: Option<&str>) -> Result, ()> { + // AnyRef::value() gives us the content of the Extensions SEQUENCE (tag+length + // already stripped). We iterate directly over the Extension entries. + let mut pos = 0; + let len = extensions_bytes.len(); + + while pos < len { + let (tag, ext_len, hdr_len) = read_tag_len(extensions_bytes, pos).ok_or(())?; + if pos + hdr_len + ext_len > len { + return Err(()); // Malformed: length exceeds buffer + } + if tag != 0x30 { + pos += hdr_len + ext_len; + continue; + } + let ext_start = pos + hdr_len; + let ext_end = ext_start + ext_len; + pos = ext_start; + + // Read OID + let (oid_tag, oid_len, oid_hdr) = read_tag_len(extensions_bytes, pos).ok_or(())?; + if oid_tag != 0x06 || pos + oid_hdr + oid_len > ext_end { + pos = ext_end; + continue; + } + let oid_bytes = &extensions_bytes[pos + oid_hdr..pos + oid_hdr + oid_len]; + pos += oid_hdr + oid_len; + + // Check if this is the SAN OID (2.5.29.17 = 55 1d 11) + if oid_bytes != [0x55, 0x1d, 0x11] { + pos = ext_end; + continue; + } + + // Found SAN extension — skip optional BOOLEAN (critical flag) + if pos < ext_end { + let (next_tag, _, _) = read_tag_len(extensions_bytes, pos).ok_or(())?; + if next_tag == 0x01 { + let (_, bool_len, bool_hdr) = read_tag_len(extensions_bytes, pos).ok_or(())?; + if pos + bool_hdr + bool_len > ext_end { + return Err(()); + } + pos += bool_hdr + bool_len; + } + } + + // Read OCTET STRING containing the SAN value + if pos >= ext_end { + return Err(()); + } + let (oct_tag, oct_len, oct_hdr) = read_tag_len(extensions_bytes, pos).ok_or(())?; + if oct_tag != 0x04 || pos + oct_hdr + oct_len > ext_end { + return Err(()); + } + let san_start = pos + oct_hdr; + let san_end = san_start + oct_len; + if san_end > len { + return Err(()); // Malformed + } + let san_value = &extensions_bytes[san_start..san_end]; + + // SAN value is a SEQUENCE OF GeneralName + let mut san_pos = 0; + let (san_seq_tag, san_seq_len, san_seq_hdr) = read_tag_len(san_value, san_pos).ok_or(())?; + if san_seq_tag != 0x30 || san_pos + san_seq_hdr + san_seq_len > san_value.len() { + return Err(()); + } + san_pos += san_seq_hdr; + let san_seq_end = san_pos + san_seq_len; + + // Stream through GeneralName entries + while san_pos < san_seq_end { + let (gn_tag, gn_len, gn_hdr) = read_tag_len(san_value, san_pos).ok_or(())?; + if san_pos + gn_hdr + gn_len > san_seq_end { + return Err(()); // Malformed + } + let gn_data = &san_value[san_pos + gn_hdr..san_pos + gn_hdr + gn_len]; + san_pos += gn_hdr + gn_len; + + // Context tag [2] = dNSName (implicit IA5String) + if gn_tag == 0x82 { + if let Ok(name_str) = core::str::from_utf8(gn_data) { + // Reject null bytes (null-byte injection defense) + if name_str.contains('\0') { + continue; + } + if let Some(host) = hostname { + if hostname_matches(name_str, host) { + return Ok(Some(true)); // Match — return early + } + } + } + } + } + + return Ok(Some(false)); // SANs found but none matched + } + + Ok(None) // No SAN extension found +} + +/// Read a DER tag and length at the given position. +/// Returns (tag, content_length, header_length) or None. +fn read_tag_len(data: &[u8], pos: usize) -> Option<(u8, usize, usize)> { + if pos >= data.len() { + return None; + } + let tag = data[pos]; + if pos + 1 >= data.len() { + return None; + } + let first = data[pos + 1]; + if first < 0x80 { + // Short form: length is the byte itself + Some((tag, first as usize, 2)) + } else if first == 0x81 { + // Long form: 1 byte length + if pos + 2 >= data.len() { + return None; + } + Some((tag, data[pos + 2] as usize, 3)) + } else if first == 0x82 { + // Long form: 2 byte length + if pos + 3 >= data.len() { + return None; + } + let len = ((data[pos + 2] as usize) << 8) | (data[pos + 3] as usize); + Some((tag, len, 4)) + } else { + // Unsupported length encoding + None + } +} + fn get_certificate_tlv_bytes<'a>(input: &[u8]) -> der::Result<&[u8]> { use der::{Decode, Reader, SliceReader}; @@ -344,9 +546,11 @@ fn verify_certificate( verifier: &CertificateEntryRef, certificate: &CertificateEntryRef, now: Option, -) -> Result>, TlsError> { + hostname: Option<&str>, +) -> Result { let mut verified = false; let mut common_name = None; + let mut san_match: Option = None; let ca_certificate = if let CertificateEntryRef::X509(verifier) = verifier { DecodedCertificate::from_der(verifier).map_err(|_| TlsError::DecodeError)? @@ -378,6 +582,12 @@ fn verify_certificate( } } + // Stream-check Subject Alternative Names against hostname + if let Some(extensions_any) = &parsed_certificate.tbs_certificate.extensions { + san_match = san_matches_hostname(extensions_any.value(), hostname) + .map_err(|_| TlsError::DecodeError)?; + } + if let Some(now) = now { let not_before = get_cert_time(parsed_certificate.tbs_certificate.validity.not_before); let not_after = get_cert_time(parsed_certificate.tbs_certificate.validity.not_after); @@ -603,5 +813,174 @@ fn verify_certificate( return Err(TlsError::InvalidCertificate); } - Ok(common_name) + Ok(HostnameResult { + common_name, + san_match, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hostname_matches_exact() { + assert!(hostname_matches("example.com", "example.com")); + assert!(hostname_matches("Example.COM", "example.com")); + assert!(!hostname_matches("example.com", "other.com")); + } + + #[test] + fn test_hostname_matches_wildcard() { + assert!(hostname_matches("*.example.com", "foo.example.com")); + assert!(hostname_matches("*.example.com", "relay.example.com")); + assert!(!hostname_matches("*.example.com", "example.com")); + assert!(!hostname_matches("*.example.com", "foo.bar.example.com")); + } + + #[test] + fn test_hostname_result_san_takes_precedence() { + // SANs present and matched — CN is ignored per RFC 6125 + let result = HostnameResult { + common_name: Some(heapless::String::try_from("wrong.com").unwrap()), + san_match: Some(true), + }; + assert!(result.matches_hostname("anything.com")); + + // SANs present but didn't match — CN is still ignored + let result = HostnameResult { + common_name: Some(heapless::String::try_from("wrong.com").unwrap()), + san_match: Some(false), + }; + assert!(!result.matches_hostname("wrong.com")); + } + + #[test] + fn test_hostname_result_cn_fallback() { + // No SANs — fall back to CN + let result = HostnameResult { + common_name: Some(heapless::String::try_from("example.com").unwrap()), + san_match: None, + }; + assert!(result.matches_hostname("example.com")); + assert!(!result.matches_hostname("other.com")); + + // No SANs, no CN — nothing matches + let result = HostnameResult { + common_name: None, + san_match: None, + }; + assert!(!result.matches_hostname("example.com")); + } + + /// Build a minimal DER extensions blob with a SAN extension containing given dNSName entries. + fn build_san_extension(dns_names: &[&[u8]]) -> ([u8; 128], usize) { + let mut der = [0u8; 128]; + let mut i = 0; + + // Calculate GeneralNames content length + let gn_content_len: usize = dns_names.iter().map(|n| 2 + n.len()).sum(); + let oct_len = 2 + gn_content_len; // SEQUENCE tag+len + content + let ext_len = 5 + 2 + oct_len; // OID(5) + OCTET STRING tag+len + content + + // Extension SEQUENCE + der[i] = 0x30; i += 1; + der[i] = ext_len as u8; i += 1; + // OID 2.5.29.17 + der[i] = 0x06; i += 1; + der[i] = 0x03; i += 1; + der[i] = 0x55; i += 1; + der[i] = 0x1d; i += 1; + der[i] = 0x11; i += 1; + // OCTET STRING + der[i] = 0x04; i += 1; + der[i] = oct_len as u8; i += 1; + // GeneralNames SEQUENCE + der[i] = 0x30; i += 1; + der[i] = gn_content_len as u8; i += 1; + // dNSName entries + for name in dns_names { + der[i] = 0x82; i += 1; + der[i] = name.len() as u8; i += 1; + der[i..i + name.len()].copy_from_slice(name); + i += name.len(); + } + + (der, i) + } + + #[test] + fn test_san_matches_hostname_match() { + let (der, len) = build_san_extension(&[b"*.example.com", b"example.com"]); + // Wildcard match + assert_eq!(san_matches_hostname(&der[..len], Some("relay.example.com")), Ok(Some(true))); + // Exact match + assert_eq!(san_matches_hostname(&der[..len], Some("example.com")), Ok(Some(true))); + // No match + assert_eq!(san_matches_hostname(&der[..len], Some("other.com")), Ok(Some(false))); + } + + #[test] + fn test_san_matches_hostname_no_san_extension() { + // Extension with OID 2.5.29.19 (basic constraints), not SAN + let der: &[u8] = &[ + 0x30, 0x0a, + 0x06, 0x03, 0x55, 0x1d, 0x13, + 0x04, 0x03, + 0x30, 0x01, 0x01, + ]; + assert_eq!(san_matches_hostname(der, Some("example.com")), Ok(None)); + } + + #[test] + fn test_san_matches_hostname_no_hostname() { + // When hostname is None, SANs are checked for presence but can't match + let (der, len) = build_san_extension(&[b"example.com"]); + assert_eq!(san_matches_hostname(&der[..len], None), Ok(Some(false))); + } + + #[test] + fn test_san_rejects_null_bytes() { + // SAN with embedded null byte should be skipped + let (der, len) = build_san_extension(&[b"example.com\0.evil.com"]); + assert_eq!(san_matches_hostname(&der[..len], Some("example.com")), Ok(Some(false))); + } + + #[test] + fn test_san_malformed_der_returns_error() { + // Truncated extension — SEQUENCE claims 0x20 bytes but only 3 follow + let der: &[u8] = &[0x30, 0x20, 0x06, 0x03, 0x55]; + assert_eq!(san_matches_hostname(der, Some("example.com")), Err(())); + + // Extension with SAN OID but truncated before OCTET STRING content + let der: &[u8] = &[ + 0x30, 0x08, + 0x06, 0x03, 0x55, 0x1d, 0x11, // SAN OID + 0x04, // OCTET STRING tag — but missing length byte + ]; + assert_eq!(san_matches_hostname(der, Some("example.com")), Err(())); + + // Completely empty — no extensions parsed, no error + assert_eq!(san_matches_hostname(&[], Some("example.com")), Ok(None)); + } + + #[test] + fn test_hostname_matches_case_insensitive() { + assert!(hostname_matches("Example.COM", "example.com")); + assert!(hostname_matches("example.com", "EXAMPLE.COM")); + assert!(hostname_matches("*.Example.COM", "relay.example.com")); + assert!(hostname_matches("*.EXAMPLE.COM", "Relay.Example.Com")); + } + + #[test] + fn test_read_tag_len() { + // Short form + assert_eq!(read_tag_len(&[0x30, 0x05], 0), Some((0x30, 5, 2))); + // Long form 1 byte + assert_eq!(read_tag_len(&[0x30, 0x81, 0x80], 0), Some((0x30, 128, 3))); + // Long form 2 bytes + assert_eq!(read_tag_len(&[0x30, 0x82, 0x01, 0x00], 0), Some((0x30, 256, 4))); + // Too short + assert_eq!(read_tag_len(&[0x30], 0), None); + } }