Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,29 @@ Everything else inherited from [Uint8Array](https://developer.mozilla.org/en-US/

[createServer](https://nodejs.org/api/net.html#netcreateserveroptions-connectionlistener)

## tls

> [!WARNING]
> These APIs uses native streams that is not 100% compatible with the Node.js Streams API.

[connect](https://nodejs.org/api/tls.html#tlsconnectoptions-callback)

[createSecureContext](https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions)

[getCiphers](https://nodejs.org/api/tls.html#tlsgetciphers)

[rootCertificates](https://nodejs.org/api/tls.html#tlsrootcertificates)

[checkServerIdentity](https://nodejs.org/api/tls.html#tlscheckserveridentityhostname-cert)

[DEFAULT_MIN_VERSION](https://nodejs.org/api/tls.html#tlsdefault_min_version)

[DEFAULT_MAX_VERSION](https://nodejs.org/api/tls.html#tlsdefault_max_version)

[TLSSocket](https://nodejs.org/api/tls.html#class-tlstlssocket)

[SecureContext](https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions)

## os

[arch](https://nodejs.org/api/os.html#osarch)
Expand Down
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ const ES_BUILD_OPTIONS = {
"net",
"node:net",
"os",
"tls",
"node:tls",
"node:os",
"path",
"node:path",
Expand Down
4 changes: 4 additions & 0 deletions llrt_modules/src/module_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ impl Default for ModuleBuilder {
.with_global(crate::modules::timers::init)
.with_module(crate::modules::timers::TimersModule);
}
#[cfg(feature = "tls")]
{
builder = builder.with_module(crate::modules::tls::TlsModule);
}
#[cfg(feature = "tty")]
{
builder = builder.with_module(crate::modules::tty::TtyModule);
Expand Down
1 change: 1 addition & 0 deletions modules/llrt_http/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ impl Agent {
let config = llrt_tls::build_client_config(llrt_tls::BuildClientConfigOptions {
reject_unauthorized,
ca,
..Default::default()
})
.or_throw_msg(&ctx, "Failed to build TLS config")?;
let client =
Expand Down
17 changes: 11 additions & 6 deletions modules/llrt_net/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ mod socket;

use self::{server::Server, socket::Socket};

const LOCALHOST: &str = "localhost";
/// Localhost constant shared across socket implementations
pub const LOCALHOST: &str = "localhost";

#[allow(dead_code)]
enum ReadyState {
/// Socket ready state, shared between net::Socket and tls::TLSSocket
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReadyState {
Opening,
Open,
Closed,
Expand Down Expand Up @@ -103,11 +105,13 @@ impl Listener {
}
}

fn get_hostname(host: &str, port: u16) -> String {
/// Build a hostname:port string from host and port
pub fn get_hostname(host: &str, port: u16) -> String {
[host, itoa::Buffer::new().format(port)].join(":")
}

fn get_address_parts(
/// Extract address parts (ip, port, family) from a socket address result
pub fn get_address_parts(
ctx: &Ctx,
addr: StdResult<SocketAddr, std::io::Error>,
) -> Result<(String, u16, String)> {
Expand All @@ -119,7 +123,8 @@ fn get_address_parts(
))
}

async fn rw_join(
/// Wait for both readable and writable streams to complete
pub async fn rw_join(
ctx: &Ctx<'_>,
readable_done: Receiver<bool>,
writable_done: Receiver<bool>,
Expand Down
18 changes: 17 additions & 1 deletion modules/llrt_tls/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,30 @@ webpki-roots = ["dep:webpki-roots"]
native-roots = ["dep:rustls-native-certs"]

[dependencies]
base64-simd = { version = "0.8", features = ["alloc"], default-features = false }
itoa = { version = "1", default-features = false }
llrt_buffer = { version = "0.7.0-beta", path = "../llrt_buffer" }
llrt_context = { version = "0.7.0-beta", path = "../../libs/llrt_context" }
llrt_encoding = { version = "0.7.0-beta", path = "../../libs/llrt_encoding" }
llrt_events = { version = "0.7.0-beta", path = "../llrt_events" }
llrt_net = { version = "0.7.0-beta", path = "../llrt_net" }
llrt_stream = { version = "0.7.0-beta", path = "../llrt_stream" }
llrt_utils = { version = "0.7.0-beta", path = "../../libs/llrt_utils", default-features = false }
once_cell = { version = "1", features = ["std"], default-features = false }
rquickjs = { git = "https://github.com/DelSkayn/rquickjs.git", version = "0.10.0", default-features = false }
rustls = { version = "0.23", features = [
"std",
"ring",
"tls12",
], default-features = false }
webpki-roots = { version = "1", default-features = false, optional = true }
rustls-native-certs = { version = "0.8", default-features = false, optional = true }
rustls-pki-types = { version = "1", default-features = false }
tokio = { version = "1", features = ["net"], default-features = false }
tokio-rustls = { version = "0.26", default-features = false }
tracing = { version = "0.1", default-features = false }
webpki-roots = { version = "1", default-features = false, optional = true }

[dev-dependencies]
llrt_test = { path = "../../libs/llrt_test" }
rand = { version = "0.10.0-rc.5", features = ["alloc"], default-features = false }
tokio = { version = "1", features = ["macros", "rt"] }
189 changes: 172 additions & 17 deletions modules/llrt_tls/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,95 @@ use std::sync::{Arc, OnceLock};
use once_cell::sync::Lazy;
use rustls::{
crypto::ring,
pki_types::{pem::PemObject, CertificateDer},
ClientConfig, RootCertStore, SupportedProtocolVersion,
pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer},
CipherSuite, ClientConfig, RootCertStore, SupportedCipherSuite, SupportedProtocolVersion,
};
#[cfg(feature = "webpki-roots")]
use webpki_roots::TLS_SERVER_ROOTS;

use crate::no_verification::NoCertificateVerification;

/// Parse TLS version string to rustls SupportedProtocolVersion
fn parse_tls_version(version: &str) -> Option<&'static SupportedProtocolVersion> {
match version {
"TLSv1.2" | "TLSv1_2" => Some(&rustls::version::TLS12),
"TLSv1.3" | "TLSv1_3" => Some(&rustls::version::TLS13),
_ => None,
}
}

/// Get TLS versions filtered by min/max version options
fn get_filtered_tls_versions(
min_version: Option<&str>,
max_version: Option<&str>,
) -> Option<Vec<&'static SupportedProtocolVersion>> {
// All supported versions in order (oldest to newest)
const ALL_VERSIONS: [&SupportedProtocolVersion; 2] =
[&rustls::version::TLS12, &rustls::version::TLS13];

let min_idx = min_version
.and_then(parse_tls_version)
.and_then(|v| ALL_VERSIONS.iter().position(|&x| std::ptr::eq(x, v)))
.unwrap_or(0);

let max_idx = max_version
.and_then(parse_tls_version)
.and_then(|v| ALL_VERSIONS.iter().position(|&x| std::ptr::eq(x, v)))
.unwrap_or(ALL_VERSIONS.len() - 1);

if min_idx > max_idx {
return None; // Invalid range
}

let versions: Vec<_> = ALL_VERSIONS[min_idx..=max_idx].to_vec();
if versions.is_empty() {
None
} else {
Some(versions)
}
}

/// Parse OpenSSL-style cipher name to rustls CipherSuite
fn openssl_name_to_cipher_suite(name: &str) -> Option<CipherSuite> {
use CipherSuite::*;
match name.trim() {
// TLS 1.3 cipher suites
"TLS_AES_256_GCM_SHA384" => Some(TLS13_AES_256_GCM_SHA384),
"TLS_AES_128_GCM_SHA256" => Some(TLS13_AES_128_GCM_SHA256),
"TLS_CHACHA20_POLY1305_SHA256" => Some(TLS13_CHACHA20_POLY1305_SHA256),
// TLS 1.2 cipher suites
"ECDHE-ECDSA-AES256-GCM-SHA384" => Some(TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384),
"ECDHE-ECDSA-AES128-GCM-SHA256" => Some(TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256),
"ECDHE-ECDSA-CHACHA20-POLY1305" => Some(TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256),
"ECDHE-RSA-AES256-GCM-SHA384" => Some(TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384),
"ECDHE-RSA-AES128-GCM-SHA256" => Some(TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256),
"ECDHE-RSA-CHACHA20-POLY1305" => Some(TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256),
_ => None,
}
}

/// Filter cipher suites based on OpenSSL-style cipher string
fn filter_cipher_suites(
cipher_string: &str,
available: &[SupportedCipherSuite],
) -> Vec<SupportedCipherSuite> {
// Parse cipher string (colon or comma separated)
let requested: Vec<CipherSuite> = cipher_string
.split([':', ','])
.filter_map(openssl_name_to_cipher_suite)
.collect();

if requested.is_empty() {
return available.to_vec();
}

// Filter available suites to only those requested, preserving requested order
requested
.iter()
.filter_map(|&suite| available.iter().find(|s| s.suite() == suite).copied())
.collect()
}

static EXTRA_CA_CERTS: OnceLock<Vec<CertificateDer<'static>>> = OnceLock::new();

pub fn set_extra_ca_certs(certs: Vec<CertificateDer<'static>>) {
Expand Down Expand Up @@ -44,40 +125,95 @@ pub fn get_tls_versions() -> Option<Vec<&'static SupportedProtocolVersion>> {
}

pub static TLS_CONFIG: Lazy<Result<ClientConfig, Box<dyn std::error::Error + Send + Sync>>> =
Lazy::new(|| {
build_client_config(BuildClientConfigOptions {
reject_unauthorized: true,
ca: None,
})
});
Lazy::new(|| build_client_config(BuildClientConfigOptions::default()));

/// Unified TLS client configuration options.
/// Used by SecureContext, tls.connect(), and HTTP agent.
pub struct BuildClientConfigOptions {
/// Whether to reject unauthorized certificates (default: true)
pub reject_unauthorized: bool,
/// Custom CA certificates in PEM format
pub ca: Option<Vec<Vec<u8>>>,
/// Client certificate in PEM format for mTLS
pub cert: Option<Vec<u8>>,
/// Client private key in PEM format for mTLS
pub key: Option<Vec<u8>>,
/// Key log callback for debugging TLS connections
pub key_log: Option<Arc<dyn rustls::KeyLog>>,
/// Cipher suites in OpenSSL format (colon or comma separated)
/// e.g., "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384"
pub ciphers: Option<String>,
/// Minimum TLS version: "TLSv1.2" or "TLSv1.3"
pub min_version: Option<String>,
/// Maximum TLS version: "TLSv1.2" or "TLSv1.3"
pub max_version: Option<String>,
}

impl Default for BuildClientConfigOptions {
fn default() -> Self {
Self {
reject_unauthorized: true, // Secure by default
ca: None,
cert: None,
key: None,
key_log: None,
ciphers: None,
min_version: None,
max_version: None,
}
}
}

pub fn build_client_config(
options: BuildClientConfigOptions,
) -> Result<ClientConfig, Box<dyn std::error::Error + Send + Sync>> {
let provider = Arc::new(ring::default_provider());
let default_provider = ring::default_provider();

// Filter cipher suites if specified
let provider = if let Some(ref cipher_string) = options.ciphers {
let filtered = filter_cipher_suites(cipher_string, &default_provider.cipher_suites);
if filtered.is_empty() {
Arc::new(default_provider)
} else {
Arc::new(rustls::crypto::CryptoProvider {
cipher_suites: filtered,
..default_provider
})
}
} else {
Arc::new(default_provider)
};

let builder = ClientConfig::builder_with_provider(provider.clone());

// TLS versions
let builder = match get_tls_versions() {
Some(versions) => builder.with_protocol_versions(&versions),
None => builder.with_safe_default_protocol_versions(),
}?;
// TLS versions - check options first, then global setting, then defaults
let builder = if options.min_version.is_some() || options.max_version.is_some() {
// Use per-connection version filtering
match get_filtered_tls_versions(
options.min_version.as_deref(),
options.max_version.as_deref(),
) {
Some(versions) => builder.with_protocol_versions(&versions)?,
None => builder.with_safe_default_protocol_versions()?,
}
} else {
// Fall back to global TLS version setting
match get_tls_versions() {
Some(versions) => builder.with_protocol_versions(&versions)?,
None => builder.with_safe_default_protocol_versions()?,
}
};

// Certificate verification
let builder = if !options.reject_unauthorized {
builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification::new(provider)))
} else if let Some(ca) = options.ca {
} else if let Some(ca) = &options.ca {
let mut root_certificates = RootCertStore::empty();

for cert in ca {
root_certificates.add(CertificateDer::from_pem_slice(&cert)?)?;
root_certificates.add(CertificateDer::from_pem_slice(cert)?)?;
}
builder.with_root_certificates(root_certificates)
} else {
Expand Down Expand Up @@ -109,5 +245,24 @@ pub fn build_client_config(
builder.with_root_certificates(root_certificates)
};

Ok(builder.with_no_client_auth())
// Client authentication (mTLS)
let mut config = if let (Some(cert_pem), Some(key_pem)) = (&options.cert, &options.key) {
// Parse client certificate chain
let certs: Vec<CertificateDer<'static>> =
CertificateDer::pem_slice_iter(cert_pem).collect::<std::result::Result<Vec<_>, _>>()?;

// Parse private key
let key = PrivateKeyDer::from_pem_slice(key_pem)?;

builder.with_client_auth_cert(certs, key)?
} else {
builder.with_no_client_auth()
};

// Set key log if provided
if let Some(key_log) = options.key_log {
config.key_log = key_log;
}

Ok(config)
}
Loading
Loading