diff --git a/Cargo.lock b/Cargo.lock index c1f2dc8119..73511b4cef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6099,6 +6099,7 @@ dependencies = [ "perry-runtime", "rustls", "rustls-native-certs", + "rustls-pemfile", "serde_json", "tokio", "tokio-rustls", @@ -6335,6 +6336,7 @@ dependencies = [ "unicode-segmentation", "url", "windows-sys 0.61.2", + "x509-cert", ] [[package]] diff --git a/changelog.d/8663-node-tls-parity.md b/changelog.d/8663-node-tls-parity.md new file mode 100644 index 0000000000..921fd0c9b2 --- /dev/null +++ b/changelog.d/8663-node-tls-parity.md @@ -0,0 +1,6 @@ +Completed `node:tls` compatibility across the full node-suite inventory. TLS +servers and sockets now support real loopback handshakes, ALPN and SNI +selection, certificate and secure-context rotation, custom trust stores, +client certificates, identity callbacks, negotiated state, orderly shutdown, +and Node-compatible validation and error shapes in both bundled and optimized +external-net builds. The current TLS inventory passes 100/100 fixtures. diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index bdcc7b413a..e1f40d8790 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -778,6 +778,13 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("net", "isSessionReused", true, Some("Socket")), method("net", "exportKeyingMaterial", true, Some("Socket")), method("net", "setMaxSendFragment", true, Some("Socket")), + method("net", "getEphemeralKeyInfo", true, Some("Socket")), + method("net", "getFinished", true, Some("Socket")), + method("net", "getPeerFinished", true, Some("Socket")), + method("net", "getSharedSigalgs", true, Some("Socket")), + method("net", "getX509Certificate", true, Some("Socket")), + method("net", "getPeerX509Certificate", true, Some("Socket")), + method("net", "setKeyCert", true, Some("Socket")), // Issue #1123 followup — `net.Server` instance methods backing // `createServer(...).listen/.close/.address/.on`. Mirrors the // shape of the http-server rows at entries.rs:2298. The @@ -861,6 +868,7 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ TypeSpec::Any, ), method("tls", "getCiphers", false, None), + method("tls", "getCertificateCompressionAlgorithms", false, None), method_sig( "tls", "setDefaultCACertificates", diff --git a/crates/perry-codegen/src/expr/property_get/globalget.rs b/crates/perry-codegen/src/expr/property_get/globalget.rs index abc305ed43..76ea825abf 100644 --- a/crates/perry-codegen/src/expr/property_get/globalget.rs +++ b/crates/perry-codegen/src/expr/property_get/globalget.rs @@ -174,6 +174,14 @@ pub(crate) fn lower_globalget_property(ctx: &mut FnCtx<'_>, property: &str) -> R &[(I64, &ctor_handle), (I64, &key_raw)], )); } + // `Buffer.isBuffer` used as a callback (for example + // `values.every(Buffer.isBuffer)`) needs the callable value, not only the + // direct-call intrinsic. Bare builtin receivers are represented by the + // shared `GlobalGet(0)` sentinel, and `isBuffer` is distinctive among the + // builtin statics, so recover it from the populated Buffer constructor. + if property == "isBuffer" { + return Ok(lower_global_builtin_static_value(ctx, "Buffer", property)); + } // #6674: `Uint8Array.fromBase64` / `fromHex` read as a VALUE (not a direct // call) — jose/Auth.js feature-detect with `Uint8Array.fromBase64 ? native // : fallback`. The bare `Uint8Array` receiver collapses to `GlobalGet(0)` diff --git a/crates/perry-codegen/src/lower_call/native/mod.rs b/crates/perry-codegen/src/lower_call/native/mod.rs index f6ca86807f..cdfdc4812b 100644 --- a/crates/perry-codegen/src/lower_call/native/mod.rs +++ b/crates/perry-codegen/src/lower_call/native/mod.rs @@ -409,6 +409,60 @@ pub(crate) fn lower_native_method_call( } } + // `X509Certificate` instances are compact native handles. Their method + // calls normally miss the static native table and used to fall through to + // `js_native_call_method_nullsafe`. That entry point also serves native + // *property reads*, so its zero-argument path asks the handle-property + // dispatcher first. For `cert.toLegacyObject()` this returned the bound + // method closure instead of invoking it; valid-host identity checks then + // appeared to pass only because a closure is not a certificate object. + // + // Bare method-value reads (`const f = cert.toLegacyObject`) remain ordinary + // `PropertyGet`s in HIR (`is_native_dispatch_member` deliberately excludes + // crypto), so an actual `NativeMethodCall` for this exact class is + // unambiguously a call. Route it through the non-property dispatcher just + // like the Console instance arm above. + if module == "crypto" && class_name == Some("X509Certificate") { + if let Some(recv) = object { + let recv_box = lower_expr(ctx, recv)?; + let mut lowered_args: Vec = Vec::with_capacity(args.len()); + for arg in args { + lowered_args.push(lower_expr(ctx, arg)?); + } + + let (args_ptr, args_len) = if lowered_args.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = lowered_args.len(); + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + { + let blk = ctx.block(); + for (i, value) in lowered_args.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + blk.store(DOUBLE, value, &slot); + } + } + (buf, n.to_string()) + }; + + let method_idx = ctx.strings.intern(method); + let entry = ctx.strings.entry(method_idx); + let bytes_global = format!("@{}", entry.bytes_global); + let name_len = entry.byte_len.to_string(); + return Ok(ctx.block().call( + DOUBLE, + "js_native_call_method", + &[ + (DOUBLE, &recv_box), + (PTR, &bytes_global), + (I64, &name_len), + (PTR, &args_ptr), + (I64, &args_len), + ], + )); + } + } + // Receiver-less native method calls (e.g. plugin::setConfig(...) // as a static module function): lower args for side effects and // return TAG_UNDEFINED. Using TAG_UNDEFINED (not 0.0) so that diff --git a/crates/perry-codegen/src/lower_call/native_module_rooting_tests.rs b/crates/perry-codegen/src/lower_call/native_module_rooting_tests.rs index 81d39327fe..f5c6b52b52 100644 --- a/crates/perry-codegen/src/lower_call/native_module_rooting_tests.rs +++ b/crates/perry-codegen/src/lower_call/native_module_rooting_tests.rs @@ -11,6 +11,16 @@ use perry_hir::types::Type; use perry_hir::{Expr, Function, Module, Stmt}; fn compile_native_call(args: Vec) -> String { + compile_native_instance_call("https", None, None, "createServer", args) +} + +fn compile_native_instance_call( + native_module: &str, + class_name: Option<&str>, + object: Option, + method: &str, + args: Vec, +) -> String { let mut module = Module::new("native_module_rooting_test.ts"); module.functions.push(Function { id: 0, @@ -19,10 +29,10 @@ fn compile_native_call(args: Vec) -> String { params: Vec::new(), return_type: Type::Any, body: vec![Stmt::Expr(Expr::NativeMethodCall { - module: "https".to_string(), - class_name: None, - object: None, - method: "createServer".to_string(), + module: native_module.to_string(), + class_name: class_name.map(str::to_string), + object: object.map(Box::new), + method: method.to_string(), args, })], is_async: false, @@ -84,3 +94,24 @@ fn native_module_first_argument_is_rooted_across_allocating_second_argument() { "native-module options argument", ); } + +#[test] +fn x509_zero_argument_method_call_uses_invoking_dispatch() { + let module_ir = compile_native_instance_call( + "crypto", + Some("X509Certificate"), + Some(Expr::Number(1.0)), + "toLegacyObject", + Vec::new(), + ); + let ir = build_function_ir(&module_ir); + + assert!( + ir.contains("call double @js_native_call_method("), + "X509Certificate method calls must use the invoking dispatcher:\n{ir}" + ); + assert!( + !ir.contains("@js_native_call_method_nullsafe("), + "the zero-argument property-read fallback returns a bound method closure:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs b/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs index f7f1f7aeca..675103c95a 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs @@ -255,6 +255,15 @@ pub(crate) const NODE_CORE_MODULE_SEA_TLS_TEST_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_F64, }, + NativeModSig { + module: "tls", + has_receiver: false, + method: "getCertificateCompressionAlgorithms", + class_filter: None, + runtime: "js_tls_get_certificate_compression_algorithms", + args: &[], + ret: NR_F64, + }, NativeModSig { module: "tls", has_receiver: false, diff --git a/crates/perry-codegen/src/lower_call/native_table/tls_events.rs b/crates/perry-codegen/src/lower_call/native_table/tls_events.rs index b0b31953af..b7a210488a 100644 --- a/crates/perry-codegen/src/lower_call/native_table/tls_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/tls_events.rs @@ -141,7 +141,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_set_secure_context", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_VOID, }, NativeModSig { module: "tls", @@ -159,7 +159,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_set_ticket_keys", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_VOID, }, NativeModSig { module: "net", @@ -221,7 +221,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ method: "exportKeyingMaterial", class_filter: Some("Socket"), runtime: "js_tls_socket_export_keying_material", - args: &[NA_F64, NA_STR], + args: &[NA_F64, NA_JSV, NA_JSV], ret: NR_F64, }, NativeModSig { @@ -233,4 +233,67 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_F64, }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getEphemeralKeyInfo", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_ephemeral_key_info", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getFinished", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_finished", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getPeerFinished", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_peer_finished", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getSharedSigalgs", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_shared_sigalgs", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getX509Certificate", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_x509_certificate", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getPeerX509Certificate", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_peer_x509_certificate", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "setKeyCert", + class_filter: Some("Socket"), + runtime: "js_tls_socket_set_key_cert", + args: &[NA_JSV], + ret: NR_F64, + }, ]; diff --git a/crates/perry-ext-net/Cargo.toml b/crates/perry-ext-net/Cargo.toml index 68d831f44d..deefbcccd3 100644 --- a/crates/perry-ext-net/Cargo.toml +++ b/crates/perry-ext-net/Cargo.toml @@ -24,6 +24,7 @@ tokio-rustls.workspace = true rustls.workspace = true rustls-native-certs = "0.8" serde_json.workspace = true +rustls-pemfile.workspace = true [dev-dependencies] perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-net/src/jsvalue.rs b/crates/perry-ext-net/src/jsvalue.rs index b1bd4929ea..9382757f38 100644 --- a/crates/perry-ext-net/src/jsvalue.rs +++ b/crates/perry-ext-net/src/jsvalue.rs @@ -187,6 +187,18 @@ pub(crate) unsafe fn get_object_string_field(obj_f64: f64, field_name: &str) -> None } +pub(crate) unsafe fn get_object_value_field(obj_f64: f64, field_name: &str) -> Option { + if !is_nanboxed_pointer(obj_f64) { + return None; + } + let obj_ptr = unbox_pointer(obj_f64) as *const ObjectHeader; + if (obj_ptr as usize) < 0x100000 { + return None; + } + let key = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); + Some(js_object_get_field_by_name_f64(obj_ptr, key)) +} + pub(crate) unsafe fn get_object_number_field(obj_f64: f64, field_name: &str) -> Option { if !is_nanboxed_pointer(obj_f64) { return None; @@ -253,10 +265,10 @@ pub(crate) unsafe fn get_object_bool_field(obj_f64: f64, field_name: &str) -> Op /// instances, not raw strings. Returns a NaN-boxed `f64` pointing at /// the object. Issue #770. pub(crate) unsafe fn build_error_object(msg: &str) -> f64 { - let keys: [&str; 1] = ["message"]; + let keys: [&str; 3] = ["message", "code", "name"]; let (packed, shape_id) = build_object_shape(&keys); let obj: *mut ObjectHeader = - js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32); + js_object_alloc_with_shape(shape_id, 3, packed.as_ptr(), packed.len() as u32); if obj.is_null() { // Fall back to the bare string so the listener still receives // *something* if the object alloc failed. @@ -266,6 +278,24 @@ pub(crate) unsafe fn build_error_object(msg: &str) -> f64 { let s = alloc_string(msg); let v = JsValue::from_string_ptr(s.as_raw()); js_object_set_field(obj, 0, v); + let code = if msg.starts_with("ERR_") { + Some(msg) + } else if msg.contains("UnknownIssuer") + || msg.contains("unknown issuer") + || msg.contains("invalid peer certificate") + { + Some("DEPTH_ZERO_SELF_SIGNED_CERT") + } else if msg.to_ascii_lowercase().contains("connection refused") { + Some("ECONNREFUSED") + } else { + None + }; + if let Some(code) = code { + let code = alloc_string(code); + js_object_set_field(obj, 1, JsValue::from_string_ptr(code.as_raw())); + } + let name = alloc_string("Error"); + js_object_set_field(obj, 2, JsValue::from_string_ptr(name.as_raw())); let obj_v = JsValue::from_object_ptr(obj as *mut u8); f64::from_bits(obj_v.bits()) } diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index cd455c1df7..73372b5626 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -117,10 +117,11 @@ pub use server_state::*; mod jsvalue; pub(crate) use jsvalue::{ build_error_object, get_object_bool_field, get_object_number_field, get_object_string_field, - is_nanboxed_pointer, jsvalue_to_socket_bytes, string_from_header_i64, unbox_pointer, + get_object_value_field, is_nanboxed_pointer, jsvalue_to_socket_bytes, string_from_header_i64, + unbox_pointer, }; -use crate::tls::do_tls_handshake; +use crate::tls::{do_tls_handshake, record_tls_handshake, TlsClientConfigData}; // ─── Transport enum (plain or TLS, swappable at runtime) ───────────────────── // @@ -373,6 +374,7 @@ pub(crate) enum SocketCommand { UpgradeTls { servername: String, verify: bool, + config: TlsClientConfigData, reply: oneshot::Sender>, }, } @@ -382,6 +384,7 @@ enum PendingNetEvent { /// `.1` identifies a same-process server target and whether its admission /// is expected to hit `dropMaxConnection`; external connects use `None`. Connect(i64, Option<(i64, bool)>), + SecureConnect(i64), /// One chunk of read data. Carried as a refcounted `Bytes` — a zero-copy /// view sliced out of the socket task's reused read buffer (`split_to`) — /// so the path from the receive buffer to the main-thread drain handler @@ -1103,8 +1106,23 @@ pub unsafe extern "C" fn js_net_socket_method_connect(handle: i64, port: f64, ho pub(crate) fn spawn_socket_task( host: String, port: u16, - direct_tls: Option<(String, bool)>, + direct_tls: Option<(String, bool, TlsClientConfigData)>, ) -> i64 { + spawn_socket_task_initialized(host, port, direct_tls, |_| {}) +} + +/// Allocate a socket and run `initialize` after its registries exist but before +/// the async connect task can complete. TLS uses this boundary to publish its +/// runtime metadata without racing a fast loopback handshake. +pub(crate) fn spawn_socket_task_initialized( + host: String, + port: u16, + direct_tls: Option<(String, bool, TlsClientConfigData)>, + initialize: F, +) -> i64 +where + F: FnOnce(i64), +{ ensure_gc_scanner_registered(); dispatch::ensure_runtime_dispatch_registered(); let id = next_id_or_throw(); @@ -1137,6 +1155,7 @@ pub(crate) fn spawn_socket_task( .lock() .unwrap() .insert(id, HashMap::new()); + initialize(id); spawn_socket_runner(move || { Box::pin(async move { @@ -1162,9 +1181,12 @@ pub(crate) fn spawn_socket_task( let local = tcp.local_addr().ok(); let transport = match direct_tls { - Some((servername, verify)) => { - match do_tls_handshake(tcp, &servername, verify).await { - Ok(tls) => Transport::Tls(Box::new(tls)), + Some((servername, verify, config)) => { + match do_tls_handshake(tcp, &servername, verify, Some(&config)).await { + Ok(tls) => { + record_tls_handshake(id, &tls, &servername, verify, Some(&config)); + Transport::Tls(Box::new(tls)) + } Err(e) => { server_state::cancel_local_connect(local_server); push_event(PendingNetEvent::Error(id, e)); @@ -1242,6 +1264,11 @@ pub(crate) async fn run_socket_task( // #2154 raw mode: signal EOF on the buffer, suppress // JS events. Else (#1852) fire 'end' then 'close' per // Node's default `allowHalfOpen: false` teardown order. + // Complete the writable half before dropping the + // transport. For TLS this sends close_notify; without + // it the peer observes an unclean EOF and emits only + // 'close', skipping its 'end' event. + let _ = t.shutdown().await; if !raw_bridge::mark_terminal(id, None) { push_event(PendingNetEvent::End(id)); push_event(PendingNetEvent::Close(id)); @@ -1317,14 +1344,22 @@ pub(crate) async fn run_socket_task( mark_closed(id); break; } - Some(SocketCommand::UpgradeTls { servername, verify, reply }) => { + Some(SocketCommand::UpgradeTls { servername, verify, config, reply }) => { let old = transport.take(); match old { Some(Transport::Plain(tcp)) => { - match do_tls_handshake(tcp, &servername, verify).await { + match do_tls_handshake(tcp, &servername, verify, Some(&config)).await { Ok(tls) => { + record_tls_handshake( + id, + &tls, + &servername, + verify, + Some(&config), + ); transport = Some(Transport::Tls(Box::new(tls))); let _ = reply.send(Ok(())); + push_event(PendingNetEvent::SecureConnect(id)); } Err(e) => { let _ = reply.send(Err(e.clone())); @@ -1371,9 +1406,14 @@ pub unsafe extern "C" fn js_net_socket_on(handle: i64, event_ptr: i64, cb: i64) Some(e) => e, None => return, }; - let mut listeners = statics::listeners().lock().unwrap(); - let entry = listeners.entry(handle).or_default(); - entry.entry(event).or_default().push(cb); + { + let mut listeners = statics::listeners().lock().unwrap(); + let entry = listeners.entry(handle).or_default(); + entry.entry(event.clone()).or_default().push(cb); + } + if event == "close" { + tls::fire_pending_tls_abort(handle); + } } // ─── FFI: socket.upgradeToTLS(servername, verify) -> Promise ───────────────── @@ -1422,6 +1462,7 @@ pub unsafe extern "C" fn js_net_socket_upgrade_tls( .send(SocketCommand::UpgradeTls { servername, verify: verify_bool, + config: TlsClientConfigData::default(), reply: reply_tx, }) .is_err() @@ -1471,6 +1512,52 @@ pub unsafe extern "C" fn js_net_process_pending() -> i32 { js_ext_net_drain_pending() } +fn socket_receiver(handle: i64) -> f64 { + f64::from_bits(0x7FFD_0000_0000_0000 | (handle as u64 & 0x0000_FFFF_FFFF_FFFF)) +} + +unsafe fn emit_socket_no_arg(handle: i64, event: &str) { + extern "C" { + fn js_implicit_this_set(value: f64) -> f64; + } + let frame = dispatch_custody::DispatchFrame::park(listeners_for(handle, event)); + let previous_this = js_implicit_this_set(socket_receiver(handle)); + for index in 0..frame.len() { + let callback = frame.cb(index); + if callback != 0 { + let _ = JsClosure::from_raw(callback as *const RawClosureHeader).call0(); + } + } + js_implicit_this_set(previous_this); + drop(frame); + lifecycle::drain_once_listeners(handle, event); +} + +unsafe fn emit_tls_secure_connect(handle: i64) { + extern "C" { + fn js_tls_client_check_identity_from_metadata(handle: i64) -> f64; + } + let identity_error = js_tls_client_check_identity_from_metadata(handle); + if !JsValue::from_bits(identity_error.to_bits()).is_undefined() { + let mut frame = dispatch_custody::DispatchFrame::park(listeners_for(handle, "error")); + frame.set_payload(identity_error.to_bits()); + for index in 0..frame.len() { + let callback = frame.cb(index); + if callback != 0 { + let _ = JsClosure::from_raw(callback as *const RawClosureHeader) + .call1(f64::from_bits(frame.payload_bits())); + } + } + drop(frame); + lifecycle::drain_once_listeners(handle, "error"); + if let Some(socket) = statics::sockets().lock().unwrap().get(&handle) { + let _ = socket.cmd_tx.send(SocketCommand::Destroy); + } + return; + } + emit_socket_no_arg(handle, "secureConnect"); +} + /// Drain ext-net's own pending-event queue. /// /// This carries a DISTINCT `#[no_mangle]` symbol (`js_ext_net_drain_pending`), @@ -1507,30 +1594,19 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { server_state::finish_local_connect(local_server); // #8259: park the snapshot so callback N stays rooted (and is // rewritten on evacuation) while callback N-1 runs user JS. - let frame = dispatch_custody::DispatchFrame::park(listeners_for(id, "connect")); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); - } - } - drop(frame); - lifecycle::drain_once_listeners(id, "connect"); + emit_socket_no_arg(id, "connect"); // TLS sockets additionally fire 'secureConnect' once the // handshake completes — the direct-TLS connect path only // signals Connect after the handshake, so this is the right // tick. Plain sockets simply have no listeners here. #4971. - let frame = - dispatch_custody::DispatchFrame::park(listeners_for(id, "secureConnect")); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); - } + extern "C" { + fn js_tls_client_is_connected(handle: i64) -> i32; + } + if js_tls_client_is_connected(id) != 0 { + emit_tls_secure_connect(id); } - drop(frame); - lifecycle::drain_once_listeners(id, "secureConnect"); } + PendingNetEvent::SecureConnect(id) => emit_tls_secure_connect(id), PendingNetEvent::Data(id, bytes) => { let cbs = listeners_for(id, "data"); if cbs.is_empty() { @@ -1635,6 +1711,10 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { lifecycle::drain_once_listeners(id, "end"); } PendingNetEvent::Close(id) => { + extern "C" { + fn js_tls_client_record_closed(handle: i64); + } + js_tls_client_record_closed(id); let had_error = f64::from_bits(JsValue::from_bool(false).bits()); let frame = dispatch_custody::DispatchFrame::park(listeners_for(id, "close")); for i in 0..frame.len() { diff --git a/crates/perry-ext-net/src/tls.rs b/crates/perry-ext-net/src/tls.rs index 320a1f9929..9570dba193 100644 --- a/crates/perry-ext-net/src/tls.rs +++ b/crates/perry-ext-net/src/tls.rs @@ -2,12 +2,373 @@ //! `socket.upgradeToTLS` mid-stream upgrade. Split out of `lib.rs` (#1852) //! to keep that file under the 2000-line gate; the logic is unchanged. -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; +use perry_ffi::{js_array_get, js_array_length, ArrayHeader, JsValue}; use tokio::net::TcpStream; +use tokio_rustls::rustls::client::danger::{ + HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier, +}; use tokio_rustls::{client::TlsStream, rustls, TlsConnector}; -fn build_tls_connector(verify: bool) -> Result { +#[derive(Clone, Default)] +pub(crate) struct TlsClientConfigData { + ca: Option>>, + cert: Vec, + key: Vec, + alpn_protocols: Vec>, + version_mask: i32, + custom_identity: bool, +} + +fn pending_tls_aborts() -> &'static Mutex> { + static ABORTS: OnceLock>> = OnceLock::new(); + ABORTS.get_or_init(|| Mutex::new(std::collections::HashSet::new())) +} + +pub(crate) fn fire_pending_tls_abort(handle: i64) { + if pending_tls_aborts().lock().unwrap().remove(&handle) { + crate::push_event(crate::PendingNetEvent::AbortError(handle)); + crate::push_event(crate::PendingNetEvent::Close(handle)); + } +} + +/// Node reports an already-aborted connect asynchronously, after callers have +/// had a chance to attach `error` and `close` listeners to the returned socket. +unsafe fn schedule_tls_abort(handle: i64) { + // An alloc-only SocketState normally has `pending_rx: Some` and is not + // considered live by ext-net until connect() consumes that receiver. This + // TLS fast path never starts connect(), so mark the synthetic socket live + // until its deferred Close event removes it from the registry. + if let Some(socket) = crate::statics::sockets().lock().unwrap().get_mut(&handle) { + socket.is_open = true; + } + pending_tls_aborts().lock().unwrap().insert(handle); + perry_ffi::spawn_async(async move { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + fire_pending_tls_abort(handle); + }); +} + +pub(crate) fn begin_tls_upgrade( + handle: i64, + servername: String, + verify: bool, + config: TlsClientConfigData, +) -> Result<(), String> { + let cmd_tx = crate::statics::sockets() + .lock() + .unwrap() + .get(&handle) + .map(|socket| socket.cmd_tx.clone()) + .ok_or_else(|| "socket is closed".to_string())?; + let (reply, _reply_rx) = tokio::sync::oneshot::channel(); + cmd_tx + .send(crate::SocketCommand::UpgradeTls { + servername, + verify, + config, + reply, + }) + .map_err(|_| "socket task is gone".to_string()) +} + +unsafe fn is_array(value: f64) -> bool { + extern "C" { + fn js_array_is_array(value: f64) -> f64; + } + JsValue::from_bits(js_array_is_array(value).to_bits()).to_bool() +} + +unsafe fn value_bytes(value: f64) -> Option> { + let js = JsValue::from_bits(value.to_bits()); + if js.is_any_string() { + return crate::jsvalue_to_socket_bytes(value); + } + // Read through the canonical runtime registry. `perry-ext-net` is a + // separately linked archive, so perry-ffi's local Buffer registry cannot + // see Buffers allocated by the program runtime (notably `ca`, `cert`, and + // `key` values returned by fs.readFileSync). + extern "C" { + fn js_value_buffer_or_typedarray_data(value: f64, out_len: *mut u32) -> *const u8; + } + let mut len = 0u32; + let data = js_value_buffer_or_typedarray_data(value, &mut len); + if data.is_null() { + None + } else { + Some(std::slice::from_raw_parts(data, len as usize).to_vec()) + } +} + +unsafe fn material_list(value: f64) -> Option>> { + let js = JsValue::from_bits(value.to_bits()); + if js.is_undefined() || js.is_null() { + return Some(Vec::new()); + } + if is_array(value) { + let array = crate::unbox_pointer(value) as *const ArrayHeader; + let mut out = Vec::new(); + for index in 0..js_array_length(array) { + out.extend(material_list(f64::from_bits( + js_array_get(array, index).bits(), + ))?); + } + return Some(out); + } + value_bytes(value).map(|bytes| vec![bytes]) +} + +unsafe fn option_value(options: f64, secure_context: f64, name: &str) -> Option { + crate::get_object_value_field(options, name).and_then(|value| { + let js = JsValue::from_bits(value.to_bits()); + if js.is_undefined() { + crate::get_object_value_field(secure_context, name) + } else { + Some(value) + } + }) +} + +unsafe fn parse_alpn(value: f64) -> Vec> { + if is_array(value) { + let array = crate::unbox_pointer(value) as *const ArrayHeader; + return (0..js_array_length(array)) + .filter_map(|index| { + let item = f64::from_bits(js_array_get(array, index).bits()); + crate::jsvalue_to_socket_bytes(item) + }) + .collect(); + } + let Some(encoded) = value_bytes(value) else { + return Vec::new(); + }; + let mut offset = 0usize; + let mut out = Vec::new(); + while offset < encoded.len() { + let len = encoded[offset] as usize; + offset += 1; + if len == 0 || offset + len > encoded.len() { + break; + } + out.push(encoded[offset..offset + len].to_vec()); + offset += len; + } + out +} + +unsafe fn tls_client_config_data(options: f64) -> TlsClientConfigData { + let secure_context = crate::get_object_value_field(options, "secureContext") + .unwrap_or_else(|| f64::from_bits(JsValue::UNDEFINED.bits())); + let mut ca = option_value(options, secure_context, "ca").and_then(|value| material_list(value)); + if ca.is_none() { + extern "C" { + fn js_tls_default_ca_is_configured() -> i32; + fn js_tls_get_ca_certificates(ca_type: f64) -> f64; + } + if js_tls_default_ca_is_configured() != 0 { + ca = material_list(js_tls_get_ca_certificates(f64::from_bits( + JsValue::UNDEFINED.bits(), + ))); + } + } + let cert = option_value(options, secure_context, "cert") + .and_then(|value| value_bytes(value)) + .unwrap_or_default(); + let key = option_value(options, secure_context, "key") + .and_then(|value| value_bytes(value)) + .unwrap_or_default(); + let alpn_protocols = option_value(options, secure_context, "ALPNProtocols") + .map(|value| parse_alpn(value)) + .unwrap_or_default(); + extern "C" { + fn js_tls_effective_version_mask(options: f64) -> i32; + } + TlsClientConfigData { + ca, + cert, + key, + alpn_protocols, + version_mask: js_tls_effective_version_mask(options), + custom_identity: option_value(options, secure_context, "checkServerIdentity").is_some_and( + |value| { + let js = JsValue::from_bits(value.to_bits()); + !js.is_undefined() && !js.is_null() + }, + ), + } +} + +fn protocol_versions(mask: i32) -> Vec<&'static rustls::SupportedProtocolVersion> { + let mask = if mask == 0 { 0b11 } else { mask }; + let mut versions = Vec::new(); + if mask & 0b10 != 0 { + versions.push(&rustls::version::TLS13); + } + if mask & 0b01 != 0 { + versions.push(&rustls::version::TLS12); + } + versions +} + +unsafe fn signal_is_pre_aborted(options: f64) -> bool { + let Some(signal) = crate::get_object_value_field(options, "signal") else { + return false; + }; + extern "C" { + fn js_abort_signal_resolve_ptr(value: f64) -> *mut u8; + fn js_abort_signal_is_aborted(signal: *mut u8) -> i32; + } + let signal = js_abort_signal_resolve_ptr(signal); + !signal.is_null() && js_abort_signal_is_aborted(signal) != 0 +} + +unsafe fn tls_preflight(port: u16, servername: &str, options: f64) -> i32 { + extern "C" { + fn js_tls_client_preflight( + port: f64, + servername_ptr: *const u8, + servername_len: usize, + options: f64, + ) -> i32; + } + js_tls_client_preflight(port as f64, servername.as_ptr(), servername.len(), options) +} + +fn preflight_error(code: i32) -> &'static str { + match code { + 1 => "ERR_TLS_ALPN_CALLBACK_INVALID_RESULT", + 2 => "ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL", + 3 => "ERR_TLS_SNI_CALLBACK_FAILED", + _ => "ERR_TLS_HANDSHAKE_FAILED", + } +} + +fn add_pem_roots(store: &mut rustls::RootCertStore, materials: &[Vec]) { + for material in materials { + let mut cursor = std::io::Cursor::new(material); + for cert in rustls_pemfile::certs(&mut cursor).flatten() { + let _ = store.add(cert); + } + } +} + +fn configured_ca_certificates(data: Option<&TlsClientConfigData>) -> Vec> { + data.and_then(|data| data.ca.as_ref()) + .into_iter() + .flatten() + .flat_map(|material| { + let mut cursor = std::io::Cursor::new(material); + rustls_pemfile::certs(&mut cursor) + .flatten() + .map(|cert| cert.as_ref().to_vec()) + .collect::>() + }) + .collect() +} + +/// Node accepts an explicitly trusted self-signed certificate as a server +/// leaf even when its BasicConstraints extension also marks it as a CA. +/// rustls-webpki rejects that narrow shape as `CaUsedAsEndEntity`. Delegate +/// every normal check to rustls and recover only when the presented leaf is +/// byte-for-byte one of the configured CA certificates, retaining hostname +/// validation and rustls's TLS handshake-signature checks. +#[derive(Debug)] +struct NodeConfiguredCaVerifier { + inner: Arc, + roots: rustls::RootCertStore, + configured: Vec>, + custom_identity: bool, +} + +fn is_ca_used_as_end_entity(error: &rustls::Error) -> bool { + let rustls::Error::InvalidCertificate(rustls::CertificateError::Other(other)) = error else { + return false; + }; + other.0.to_string() == "CaUsedAsEndEntity" +} + +impl ServerCertVerifier for NodeConfiguredCaVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + if self.custom_identity { + let parsed = rustls::server::ParsedCertificate::try_from(end_entity)?; + let provider = rustls::crypto::aws_lc_rs::default_provider(); + match rustls::client::verify_server_cert_signed_by_trust_anchor( + &parsed, + &self.roots, + intermediates, + now, + provider.signature_verification_algorithms.all, + ) { + Ok(()) => return Ok(ServerCertVerified::assertion()), + Err(error) + if is_ca_used_as_end_entity(&error) + && self + .configured + .iter() + .any(|cert| cert.as_slice() == end_entity.as_ref()) => + { + return Ok(ServerCertVerified::assertion()); + } + Err(error) => return Err(error), + } + } + match self.inner.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + ) { + Err(error) + if is_ca_used_as_end_entity(&error) + && self + .configured + .iter() + .any(|cert| cert.as_slice() == end_entity.as_ref()) => + { + let parsed = rustls::server::ParsedCertificate::try_from(end_entity)?; + rustls::client::verify_server_name(&parsed, server_name)?; + Ok(ServerCertVerified::assertion()) + } + result => result, + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +fn build_tls_connector( + verify: bool, + data: Option<&TlsClientConfigData>, +) -> Result { // rustls panics resolving the process-level CryptoProvider when both // `ring` and `aws-lc-rs` end up in the dep graph. Server paths install // one before their first handshake; a client-only program (no tls/https @@ -16,21 +377,77 @@ fn build_tls_connector(verify: bool) -> Result { // `install_default` errors (ignored) if a provider is already set. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); if !verify { - return build_tls_connector_insecure(); + return build_tls_connector_insecure(data); } let mut root_store = rustls::RootCertStore::empty(); - let native = rustls_native_certs::load_native_certs(); - for cert in native.certs { - let _ = root_store.add(cert); + if let Some(ca) = data.and_then(|data| data.ca.as_ref()) { + add_pem_roots(&mut root_store, ca); + } else { + let native = rustls_native_certs::load_native_certs(); + for cert in native.certs { + let _ = root_store.add(cert); + } + } + let configured = configured_ca_certificates(data); + let custom_identity = data.is_some_and(|data| data.custom_identity); + let node_verifier = if configured.is_empty() && !custom_identity { + None + } else { + Some(NodeConfiguredCaVerifier { + inner: rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store.clone())) + .build() + .map_err(|error| format!("tls certificate verifier: {error}"))?, + roots: root_store.clone(), + configured, + custom_identity, + }) + }; + let versions = protocol_versions(data.map_or(0b11, |data| data.version_mask)); + let builder = rustls::ClientConfig::builder_with_provider( + rustls::crypto::aws_lc_rs::default_provider().into(), + ) + .with_protocol_versions(&versions) + .map_err(|error| format!("tls protocol versions: {error}"))? + .with_root_certificates(root_store); + let mut config = if let Some((certs, key)) = data.and_then(client_auth_material) { + builder + .with_client_auth_cert(certs, key) + .map_err(|error| format!("tls client certificate: {error}"))? + } else { + builder.with_no_client_auth() + }; + if let Some(data) = data { + config.alpn_protocols = data.alpn_protocols.clone(); + } + if let Some(verifier) = node_verifier { + config + .dangerous() + .set_certificate_verifier(Arc::new(verifier)); } - let config = rustls::ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); Ok(TlsConnector::from(Arc::new(config))) } -fn build_tls_connector_insecure() -> Result { - use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +fn client_auth_material( + data: &TlsClientConfigData, +) -> Option<( + Vec>, + rustls::pki_types::PrivateKeyDer<'static>, +)> { + let mut cert_cursor = std::io::Cursor::new(&data.cert); + let certs: Vec<_> = rustls_pemfile::certs(&mut cert_cursor).flatten().collect(); + if certs.is_empty() { + return None; + } + let mut key_cursor = std::io::Cursor::new(&data.key); + let key = rustls_pemfile::private_key(&mut key_cursor) + .ok() + .flatten()?; + Some((certs, key)) +} + +fn build_tls_connector_insecure( + data: Option<&TlsClientConfigData>, +) -> Result { use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{DigitallySignedStruct, SignatureScheme}; @@ -79,10 +496,24 @@ fn build_tls_connector_insecure() -> Result { } } - let config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerify)) - .with_no_client_auth(); + let versions = protocol_versions(data.map_or(0b11, |data| data.version_mask)); + let builder = rustls::ClientConfig::builder_with_provider( + rustls::crypto::aws_lc_rs::default_provider().into(), + ) + .with_protocol_versions(&versions) + .map_err(|error| format!("tls protocol versions: {error}"))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)); + let mut config = if let Some((certs, key)) = data.and_then(client_auth_material) { + builder + .with_client_auth_cert(certs, key) + .map_err(|error| format!("tls client certificate: {error}"))? + } else { + builder.with_no_client_auth() + }; + if let Some(data) = data { + config.alpn_protocols = data.alpn_protocols.clone(); + } Ok(TlsConnector::from(Arc::new(config))) } @@ -90,8 +521,13 @@ pub(crate) async fn do_tls_handshake( tcp: TcpStream, servername: &str, verify: bool, + data: Option<&TlsClientConfigData>, ) -> Result, String> { - let connector = build_tls_connector(verify)?; + let connector = if verify { + build_tls_connector(true, data)? + } else { + build_tls_connector_insecure(data)? + }; let server_name = rustls::pki_types::ServerName::try_from(servername.to_string()) .map_err(|e| format!("invalid servername '{}': {}", servername, e))?; connector @@ -100,6 +536,92 @@ pub(crate) async fn do_tls_handshake( .map_err(|e| format!("tls handshake: {}", e)) } +pub(crate) fn record_tls_handshake( + handle: i64, + stream: &TlsStream, + servername: &str, + verify: bool, + data: Option<&TlsClientConfigData>, +) { + let connection = stream.get_ref().1; + let protocol = match connection.protocol_version() { + Some(rustls::ProtocolVersion::TLSv1_2) => "TLSv1.2", + Some(rustls::ProtocolVersion::TLSv1_3) => "TLSv1.3", + _ => "", + }; + let alpn = connection.alpn_protocol().unwrap_or_default(); + let peer = connection + .peer_certificates() + .and_then(|certs| certs.first()) + .map(|cert| cert.as_ref()) + .unwrap_or_default(); + let trusted_by_configured_ca = + data.and_then(|data| data.ca.as_ref()) + .is_some_and(|materials| { + materials.iter().any(|material| { + let mut cursor = std::io::Cursor::new(material); + let trusted = rustls_pemfile::certs(&mut cursor) + .flatten() + .any(|cert| cert.as_ref() == peer); + trusted + }) + }); + let authorized = verify || trusted_by_configured_ca; + let authorization_error = if authorized { + "" + } else { + "DEPTH_ZERO_SELF_SIGNED_CERT" + }; + if let Some(socket) = crate::statics::sockets().lock().unwrap().get_mut(&handle) { + socket.tls.encrypted = true; + socket.tls.authorized = authorized; + socket.tls.servername = Some(servername.to_string()); + } + let own_certificate = data + .map(|data| { + let mut cursor = std::io::Cursor::new(&data.cert); + let certificate = rustls_pemfile::certs(&mut cursor) + .flatten() + .next() + .map(|cert| cert.as_ref().to_vec()) + .unwrap_or_default(); + certificate + }) + .unwrap_or_default(); + extern "C" { + fn js_tls_client_record_connected( + handle: i64, + authorized: i32, + authorization_error_ptr: *const u8, + authorization_error_len: usize, + protocol_ptr: *const u8, + protocol_len: usize, + alpn_ptr: *const u8, + alpn_len: usize, + peer_cert_ptr: *const u8, + peer_cert_len: usize, + own_cert_ptr: *const u8, + own_cert_len: usize, + ); + } + unsafe { + js_tls_client_record_connected( + handle, + authorized as i32, + authorization_error.as_ptr(), + authorization_error.len(), + protocol.as_ptr(), + protocol.len(), + alpn.as_ptr(), + alpn.len(), + peer.as_ptr(), + peer.len(), + own_certificate.as_ptr(), + own_certificate.len(), + ); + } +} + // ─── FFI: tls.connect ──────────────────────────────────────────────────────── /// `tls.connect(...)` — opens a plain TCP socket and runs the TLS handshake @@ -127,10 +649,15 @@ pub(crate) async fn do_tls_handshake( /// ABI — see `NA_F64` lowering in perry-codegen. #[no_mangle] pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { + extern "C" { + fn js_tls_prepare_connect(); + } + js_tls_prepare_connect(); use crate::option_setters::js_net_validate_connect_port; use crate::{ get_object_bool_field, get_object_number_field, get_object_string_field, - is_nanboxed_pointer, spawn_socket_task, statics, string_from_header_i64, unbox_pointer, + is_nanboxed_pointer, spawn_socket_task_initialized, statics, string_from_header_i64, + unbox_pointer, }; use perry_ffi::JsValue; @@ -158,11 +685,11 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f (j.is_bool() && !j.to_bool()) || (j.is_number() && j.to_number() == 0.0) }; - let (host, port, servername, verify, cb_f64); + let (host, port, servername, verify, cb_f64, metadata_options); if let Some(h) = as_string(arg1) { // Legacy Perry positional: (host, port, servername?, verify?). let p = JsValue::from_bits(arg2.to_bits()); - if !p.is_number() { + if !p.is_number() && !p.is_int32() { return 0; } port = p.to_number() as u16; @@ -170,28 +697,13 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f host = h; verify = !explicitly_off(arg4); cb_f64 = None; - } else if is_nanboxed_pointer(arg1) && !is_closure(arg1) { - // Node options form: tls.connect(options[, callback]). - port = match get_object_number_field(arg1, "port") { - Some(p) => { - js_net_validate_connect_port(p); - p as u16 - } - None => return 0, - }; - host = match get_object_string_field(arg1, "host") - .or_else(|| get_object_string_field(arg1, "hostname")) - { - Some(h) if !h.is_empty() => h, - _ => "localhost".to_string(), - }; - servername = get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); - verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); - cb_f64 = is_closure(arg2).then_some(arg2); - } else if JsValue::from_bits(arg1.to_bits()).is_number() { + metadata_options = f64::from_bits(0x7FFC_0000_0000_0001); + } else if JsValue::from_bits(arg1.to_bits()).is_number() + || JsValue::from_bits(arg1.to_bits()).is_int32() + { // Node positional form: tls.connect(port[, host][, options][, cb]). js_net_validate_connect_port(arg1); - port = arg1 as u16; + port = JsValue::from_bits(arg1.to_bits()).to_number() as u16; let mut opt_host: Option = None; let mut opts: Option = None; let mut cb: Option = None; @@ -208,6 +720,12 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f opts = opts.or(Some(v)); } } + if let Some(options) = opts { + extern "C" { + fn js_tls_validate_positional_connect_options(options: f64); + } + js_tls_validate_positional_connect_options(options); + } host = opt_host .or_else(|| { opts.and_then(|o| { @@ -224,11 +742,154 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f .and_then(|o| get_object_bool_field(o, "rejectUnauthorized")) .unwrap_or(true); cb_f64 = cb; + metadata_options = opts.unwrap_or_else(|| f64::from_bits(0x7FFC_0000_0000_0001)); + } else if is_nanboxed_pointer(arg1) && !is_closure(arg1) { + // Node options form: tls.connect(options[, callback]). + extern "C" { + fn js_tls_validate_connect_options(options: f64); + } + js_tls_validate_connect_options(arg1); + if let Some(socket_value) = crate::get_object_value_field(arg1, "socket") { + let socket_js = JsValue::from_bits(socket_value.to_bits()); + let handle = if socket_js.is_pointer() { + crate::unbox_pointer(socket_value) as i64 + } else { + 0 + }; + if handle != 0 { + host = get_object_string_field(arg1, "host") + .or_else(|| get_object_string_field(arg1, "hostname")) + .unwrap_or_else(|| "localhost".to_string()); + servername = + get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); + verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); + cb_f64 = is_closure(arg2).then_some(arg2); + metadata_options = arg1; + let config = tls_client_config_data(metadata_options); + extern "C" { + fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, + ); + } + js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + if let Some(cb) = cb_f64 { + let cb_ptr = unbox_pointer(cb) as i64; + if cb_ptr != 0 { + statics::listeners() + .lock() + .unwrap() + .entry(handle) + .or_default() + .entry("secureConnect".to_string()) + .or_default() + .push(cb_ptr); + } + } + let preflight = tls_preflight(0, &servername, metadata_options); + if preflight != 0 { + crate::push_event(crate::PendingNetEvent::Error( + handle, + preflight_error(preflight).to_string(), + )); + crate::push_event(crate::PendingNetEvent::Close(handle)); + } else if let Err(error) = begin_tls_upgrade(handle, servername, verify, config) { + crate::push_event(crate::PendingNetEvent::Error(handle, error)); + crate::push_event(crate::PendingNetEvent::Close(handle)); + } + return handle; + } + } + port = match get_object_number_field(arg1, "port") { + Some(p) => { + js_net_validate_connect_port(p); + p as u16 + } + None => return 0, + }; + host = match get_object_string_field(arg1, "host") + .or_else(|| get_object_string_field(arg1, "hostname")) + { + Some(h) if !h.is_empty() => h, + _ => "localhost".to_string(), + }; + servername = get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); + verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); + cb_f64 = is_closure(arg2).then_some(arg2); + metadata_options = arg1; } else { return 0; } - let handle = spawn_socket_task(host, port, Some((servername, verify))); + let config = tls_client_config_data(metadata_options); + if signal_is_pre_aborted(metadata_options) { + let handle = crate::js_net_socket_alloc(); + extern "C" { + fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, + ); + } + js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + schedule_tls_abort(handle); + return handle; + } + let preflight = tls_preflight(port, &servername, metadata_options); + if preflight != 0 { + let handle = crate::js_net_socket_alloc(); + extern "C" { + fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, + ); + } + js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + crate::push_event(crate::PendingNetEvent::Error( + handle, + preflight_error(preflight).to_string(), + )); + crate::push_event(crate::PendingNetEvent::Close(handle)); + return handle; + } + let metadata_servername = servername.clone(); + let handle = + spawn_socket_task_initialized(host, port, Some((servername, verify, config)), |handle| { + extern "C" { + fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, + ); + } + js_tls_client_record_start( + handle, + metadata_options, + metadata_servername.as_ptr(), + metadata_servername.len(), + ); + }); if let Some(cb) = cb_f64 { if handle != 0 { let cb_ptr = unbox_pointer(cb) as i64; diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index daf8bad55f..de9861a150 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -292,6 +292,11 @@ temporal_rs = { version = "0.2.3", default-features = false, features = ["std", serde.workspace = true serde_json.workspace = true +# TLS client identity callbacks run from the external net archive, before the +# stdlib TLS module is necessarily linked. Keep the legacy peer-certificate +# object builder in the runtime so that callback path has no stdlib symbol +# dependency (the DER parser is dead-stripped from programs that never use it). +x509-cert = { version = "0.3", default-features = false } unicode-normalization = { version = "0.1", optional = true } # #4877: extended grapheme-cluster / word / sentence segmentation backing # Intl.Segmenter (the grapheme path is what string-width@7+/wrap-ansi@9+ use, diff --git a/crates/perry-runtime/src/array/sort.rs b/crates/perry-runtime/src/array/sort.rs index bc494e9b69..09775fbbe2 100644 --- a/crates/perry-runtime/src/array/sort.rs +++ b/crates/perry-runtime/src/array/sort.rs @@ -443,7 +443,7 @@ unsafe fn sort_spec_set( } } } - let updated = js_array_set_f64_extend( + let updated = js_array_set_f64_extend_strict( arr_handle.get_raw_mut_ptr::(), index, value_handle.get_nanbox_f64(), diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 6322f1c2cd..e41cb4d81c 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -57,7 +57,7 @@ pub(crate) use dispatch::{ coerce_call_this, rebind_explicit_this, reify_function_method_value, reset_throw_not_callable_counter, }; -pub use unbox::js_closure_unbox_callee_checked; +pub use unbox::{js_closure_unbox_callee_checked, js_closure_unbox_callee_checked_rebind}; #[cfg(test)] pub(crate) use box_captures::test_clear_closure_box_capture_indexes; diff --git a/crates/perry-runtime/src/object/class_handles.rs b/crates/perry-runtime/src/object/class_handles.rs index e0c804fb8a..9df63256b1 100644 --- a/crates/perry-runtime/src/object/class_handles.rs +++ b/crates/perry-runtime/src/object/class_handles.rs @@ -108,6 +108,9 @@ pub type EventEmitterSetDomainFn = unsafe extern "C" fn(handle: i64, domain: i64 pub type NetSocketHandleProbeFn = unsafe extern "C" fn(handle: i64) -> bool; /// Probe for external `http.Agent` / `https.Agent` registry handles. pub type HttpAgentHandleProbeFn = unsafe extern "C" fn(handle: i64) -> bool; +/// Classify stdlib TLS handles for `instanceof tls.Server` / `TLSSocket`. +/// Returns 0 = not TLS, 1 = Server, 2 = TLSSocket. +pub type TlsHandleKindProbeFn = unsafe extern "C" fn(handle: i64) -> u8; /// Probe for live `perry-ffi` registry handles. `register_handle`-issued ids /// and Node timer ids both occupy the pointer-tagged small-integer band and @@ -162,6 +165,7 @@ static EVENT_EMITTER_GET_DOMAIN_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mu static EVENT_EMITTER_SET_DOMAIN_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); static NET_SOCKET_HANDLE_PROBE_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); static HTTP_AGENT_HANDLE_PROBE_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); +static TLS_HANDLE_KIND_PROBE_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); static FFI_HANDLE_EXISTS_PROBE_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); static EVENT_EMITTER_ON_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); @@ -534,6 +538,16 @@ pub fn http_agent_handle_probe() -> Option { } } +#[inline] +pub fn tls_handle_kind_probe() -> Option { + let p = TLS_HANDLE_KIND_PROBE_PTR.load(Ordering::Acquire); + if p.is_null() { + None + } else { + Some(unsafe { std::mem::transmute::<*mut (), TlsHandleKindProbeFn>(p) }) + } +} + #[no_mangle] pub unsafe extern "C" fn js_register_http_agent_handle_probe(f: HttpAgentHandleProbeFn) { HTTP_AGENT_HANDLE_PROBE_PTR.store(f as *mut (), Ordering::Release); @@ -548,6 +562,11 @@ pub extern "C" fn js_is_registered_net_socket_handle(handle: i64) -> i32 { .unwrap_or(0) } +#[no_mangle] +pub unsafe extern "C" fn js_register_tls_handle_kind_probe(f: TlsHandleKindProbeFn) { + TLS_HANDLE_KIND_PROBE_PTR.store(f as *mut (), Ordering::Release); +} + #[inline] pub fn ffi_handle_exists_probe() -> Option { let p = FFI_HANDLE_EXISTS_PROBE_PTR.load(Ordering::Acquire); diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 01556db98e..dda4a9e00f 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -29,12 +29,13 @@ pub use super::class_handles::{ js_register_handle_property_set_dispatch, js_register_handle_prototype_dispatch, js_register_http_agent_handle_probe, js_register_net_socket_handle_probe, js_register_stream_expando_set, js_register_stream_handle_kind_probe, - js_register_stream_handle_probe, net_socket_handle_probe, stream_expando_set, - stream_handle_kind_probe, stream_handle_probe, EventEmitterAsyncResourceHandleProbeFn, - EventEmitterGetDomainFn, EventEmitterHandleProbeFn, EventEmitterOnFn, EventEmitterSetDomainFn, - FetchHandleKindProbeFn, HandleMethodDispatchFn, HandleOwnPropertyNamesDispatchFn, - HandlePropertyDispatchFn, HandlePropertySetDispatchFn, HandlePrototypeDispatchFn, - HttpAgentHandleProbeFn, NetSocketHandleProbeFn, StreamHandleKindProbeFn, StreamHandleProbeFn, + js_register_stream_handle_probe, js_register_tls_handle_kind_probe, net_socket_handle_probe, + stream_expando_set, stream_handle_kind_probe, stream_handle_probe, tls_handle_kind_probe, + EventEmitterAsyncResourceHandleProbeFn, EventEmitterGetDomainFn, EventEmitterHandleProbeFn, + EventEmitterOnFn, EventEmitterSetDomainFn, FetchHandleKindProbeFn, HandleMethodDispatchFn, + HandleOwnPropertyNamesDispatchFn, HandlePropertyDispatchFn, HandlePropertySetDispatchFn, + HandlePrototypeDispatchFn, HttpAgentHandleProbeFn, NetSocketHandleProbeFn, + StreamHandleKindProbeFn, StreamHandleProbeFn, TlsHandleKindProbeFn, }; use super::*; diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index 23257fff28..7568bb8784 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -158,7 +158,7 @@ pub(crate) unsafe fn nm_ctor_tls( args_ptr, args_len, 0, ))); } - None + crate::tls::construct_registered_tls_class(method, args_ptr, args_len) } pub(crate) unsafe fn nm_ctor_wasi( diff --git a/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs b/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs index ea181e0f26..6177723695 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs @@ -154,6 +154,10 @@ pub(crate) unsafe fn nm_field_set_override( super::set_buffer_pool_size(value); return true; } + if matches!(module_name.as_str(), "tls" | "tls.default") && property_name == "rootCertificates" + { + crate::error::throw_immutable_write(0, "rootCertificates"); + } // CommonJS module exports are MUTABLE in Node: monkey-patching // like Next.js's `require('node:timers').setImmediate = patched` // must store the override (read back via `vt_get_own_field`) diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 5fcee46700..b40b84375a 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -361,6 +361,19 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { { return f64::from_bits(crate::value::TAG_TRUE); } + if module == "tls" && matches!(method.as_str(), "Server" | "TLSSocket") { + let want = if method == "Server" { 1 } else { 2 }; + if let (Some(handle), Some(probe)) = ( + small_native_handle_id(value), + crate::object::tls_handle_kind_probe(), + ) { + return f64::from_bits(if unsafe { probe(handle) } == want { + crate::value::TAG_TRUE + } else { + TAG_FALSE + }); + } + } if module == "wasi" && method == "WASI" && crate::wasi::is_wasi_instance(value) { return f64::from_bits(crate::value::TAG_TRUE); } @@ -377,11 +390,14 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { // #2689: `net.Stream` is an alias for `net.Socket`; both should match // a live socket handle via the runtime probe. if module == "net" && matches!(method.as_str(), "Socket" | "Stream") { - if let (Some(handle), Some(probe)) = ( - small_native_handle_id(value), - crate::object::net_socket_handle_probe(), - ) { - if unsafe { probe(handle) } { + if let Some(handle) = small_native_handle_id(value) { + let net_socket = crate::object::net_socket_handle_probe() + .map(|probe| unsafe { probe(handle) }) + .unwrap_or(false); + let tls_socket = crate::object::tls_handle_kind_probe() + .map(|probe| unsafe { probe(handle) == 2 }) + .unwrap_or(false); + if net_socket || tls_socket { return f64::from_bits(crate::value::TAG_TRUE); } } @@ -1125,11 +1141,14 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { }; } if class_id == CLASS_ID_NET_SOCKET { - return if let (Some(handle), Some(probe)) = ( - small_native_handle_id(value), - crate::object::net_socket_handle_probe(), - ) { - if unsafe { probe(handle) } { + return if let Some(handle) = small_native_handle_id(value) { + let net_socket = crate::object::net_socket_handle_probe() + .map(|probe| unsafe { probe(handle) }) + .unwrap_or(false); + let tls_socket = crate::object::tls_handle_kind_probe() + .map(|probe| unsafe { probe(handle) == 2 }) + .unwrap_or(false); + if net_socket || tls_socket { true_val } else { false_val diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 047faf9c4c..8848bb91da 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1060,6 +1060,23 @@ pub unsafe extern "C-unwind" fn js_native_call_method_nullsafe( } } } + // Native handle properties use this same member-read fallback when the + // static receiver class is wider than the runtime value. Ask the handle + // property dispatcher before interpreting the member as a method. This + // is what makes data properties such as `TLSSocket.authorized` and + // `alpnProtocol` observable, and also recovers bound method values such as + // `setKeyCert` when they are read before being called. + if args_len == 0 && !method_name_ptr.is_null() && v.is_pointer() { + let handle = crate::value::js_nanbox_get_pointer(object); + if crate::value::addr_class::is_handle_band(handle as usize) { + if let Some(dispatch) = crate::object::handle_property_dispatch() { + let value = dispatch(handle, method_name_ptr as *const u8, method_name_len); + if value.to_bits() != crate::value::TAG_UNDEFINED { + return value; + } + } + } + } js_native_call_method(object, method_name_ptr, method_name_len, args_ptr, args_len) } @@ -1094,20 +1111,25 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // feedback can dispatch its methods before the generic prototype/native- // module tower below. Validate the WebIDL-required filter argument at this // common entry so direct calls and extracted prototype calls agree. - if method_name_len == 16 - && !method_name_ptr.is_null() - && crate::perf_hooks::is_perf_observer_list_value(object) - { + if method_name_len == 16 && !method_name_ptr.is_null() { let name = std::slice::from_raw_parts(method_name_ptr as *const u8, method_name_len); - let arg0 = if args_len > 0 && !args_ptr.is_null() { - *args_ptr - } else { - f64::from_bits(crate::value::TAG_UNDEFINED) - }; - if name == b"getEntriesByName" { - crate::perf_hooks::validate_perf_list_filter_arg(arg0, "name", args_len == 0); - } else if name == b"getEntriesByType" { - crate::perf_hooks::validate_perf_list_filter_arg(arg0, "type", args_len == 0); + // Compare the method before probing the receiver. Pointer-tagged + // native handles share this call bridge with heap objects, and an + // unrelated 16-byte method (for example TLSSocket#getSharedSigalgs) + // must not be dereferenced as a PerformanceObserverEntryList. + if matches!(name, b"getEntriesByName" | b"getEntriesByType") + && crate::perf_hooks::is_perf_observer_list_value(object) + { + let arg0 = if args_len > 0 && !args_ptr.is_null() { + *args_ptr + } else { + f64::from_bits(crate::value::TAG_UNDEFINED) + }; + if name == b"getEntriesByName" { + crate::perf_hooks::validate_perf_list_filter_arg(arg0, "name", args_len == 0); + } else { + crate::perf_hooks::validate_perf_list_filter_arg(arg0, "type", args_len == 0); + } } } // #7769: the tower's own previously-computed answer for this diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 1b53f7575e..aa3abb452d 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -27,14 +27,14 @@ mod namespace_builders; mod web_locks; pub(crate) use callable_export_check::is_native_module_callable_export; +pub use callable_exports::bound_native_callable_export_value; #[cfg(test)] pub(crate) use callable_exports::builtin_closure_is_non_constructable; #[cfg(test)] pub(crate) use callable_exports::test_collect_native_export_after_alloc; pub(crate) use callable_exports::{ - bound_native_callable_export_value, bound_native_callable_module_and_method, - bound_native_callable_value_arity, buffer_constructor_value, - builtin_closure_is_non_constructable_value, builtin_closure_length, + bound_native_callable_module_and_method, bound_native_callable_value_arity, + buffer_constructor_value, builtin_closure_is_non_constructable_value, builtin_closure_length, fs_namespace_descriptor_getter_value, fs_namespace_descriptor_setter_value, is_buffer_constructor_value, is_cluster_emitter_method, module_builtin_modules_value, module_cjs_cache_value, module_cjs_extensions_value, module_cjs_global_paths_value, @@ -117,6 +117,26 @@ pub(crate) fn native_namespace_prop_override_store(module: &str, prop: &str, val m.borrow_mut() .insert(format!("{module}\0{prop}"), value.to_bits()); }); + // `node:tls` is a CommonJS builtin and its default import is the mutable + // exports object. Codegen currently shares the snapshot-backed property + // read used by native ESM imports for that default object, so keep the TLS + // defaults in that cache coherent with writes to the default export. Do + // not do this for ordinary builtin named exports: those intentionally stay + // unchanged until `module.syncBuiltinESMExports()` is called. + if module == "tls" + && matches!( + prop, + "DEFAULT_CIPHERS" | "DEFAULT_MIN_VERSION" | "DEFAULT_MAX_VERSION" + ) + { + let key = format!("{module}\0{prop}"); + NATIVE_ESM_EXPORT_VALUES.with(|values| { + if let Some(slot) = values.borrow_mut().get_mut(&key) { + *slot = value.to_bits(); + } + }); + crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); + } } /// Read back a stored native-namespace property override, if any. diff --git a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs index 467880377c..b83c249c31 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs @@ -49,7 +49,7 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option Some(0), ("url", "URL") => Some(1), ("url", "URLPattern") => Some(0), - ("tls", "getCiphers") => Some(0), + ("tls", "getCiphers" | "getCertificateCompressionAlgorithms") => Some(0), ("tls", "getCACertificates" | "setDefaultCACertificates" | "createSecureContext") => { Some(1) } @@ -587,6 +587,7 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ ("createSecureContext", 1), ("createServer", 2), ("getCACertificates", 1), + ("getCertificateCompressionAlgorithms", 0), ("getCiphers", 0), ("setDefaultCACertificates", 1), ], diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index c91a43ecb7..a14850469f 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -274,6 +274,7 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st | ("tty", "ReadStream") | ("tty", "WriteStream") | ("tls", "getCiphers") + | ("tls", "getCertificateCompressionAlgorithms") | ("tls", "getCACertificates") | ("tls", "setDefaultCACertificates") | ("tls", "checkServerIdentity") diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index 166e11d09d..89e341cac1 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_table.rs @@ -835,6 +835,7 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "createSecureContext", "createServer", "getCACertificates", + "getCertificateCompressionAlgorithms", "getCiphers", "setDefaultCACertificates", ], diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index a17c80853c..3a7ddf55cd 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -18,7 +18,7 @@ pub(crate) fn test_collect_native_export_after_alloc() { TEST_COLLECT_NATIVE_EXPORT_AFTER_ALLOC.with(|armed| armed.set(true)); } -pub(crate) fn bound_native_callable_export_value(module_name: &str, property_name: &str) -> f64 { +pub fn bound_native_callable_export_value(module_name: &str, property_name: &str) -> f64 { // Bound-native closures carry (module, method) metadata that the // generic property/call paths resolve through the vtable — and they // can be minted via the codegen NativeModuleRef fast path without any @@ -862,29 +862,59 @@ pub(crate) fn buffer_constructor_value() -> f64 { if closure.is_null() { return f64::from_bits(crate::value::TAG_UNDEFINED); } + let scope = crate::gc::RuntimeHandleScope::new(); + let closure = scope.root_raw_mut_ptr(closure); crate::closure::js_register_closure_arity(func_ptr, 3); - set_bound_native_closure_name(closure, "Buffer"); - let closure_addr = closure as usize; - let value = crate::value::js_nanbox_pointer(closure as i64); + closure.with_mut_ptr::(|ptr| { + set_bound_native_closure_name(ptr, "Buffer") + }); for method in BUFFER_STATIC_METHODS { - let method_value = bound_native_callable_export_value("buffer.Buffer", method); - crate::closure::closure_set_dynamic_prop(closure_addr, method, method_value); + let method_value = + scope.root_nanbox_f64(bound_native_callable_export_value("buffer.Buffer", method)); + closure.with_mut_ptr(|closure: *mut crate::closure::ClosureHeader| { + crate::closure::closure_set_dynamic_prop( + closure as usize, + method, + method_value.get_nanbox_f64(), + ) + }); } - crate::closure::closure_set_dynamic_prop(closure_addr, "poolSize", buffer_pool_size()); + closure.with_mut_ptr(|closure: *mut crate::closure::ClosureHeader| { + crate::closure::closure_set_dynamic_prop( + closure as usize, + "poolSize", + buffer_pool_size(), + ) + }); let proto = js_object_alloc(0, 0); if !proto.is_null() { + let proto = scope.root_raw_mut_ptr(proto); let constructor = "constructor"; - let constructor_key = - crate::string::js_string_from_bytes(constructor.as_ptr(), constructor.len() as u32); - js_object_set_field_by_name(proto, constructor_key, value); - super::set_builtin_property_attrs( - proto as usize, - constructor.to_string(), - super::PropertyAttrs::new(true, false, true), - ); + let constructor_key = scope.root_string_ptr(crate::string::js_string_from_bytes( + constructor.as_ptr(), + constructor.len() as u32, + )); + proto.with_mut_ptr(|proto: *mut ObjectHeader| { + constructor_key.with_mut_ptr(|constructor_key| { + closure.with_mut_ptr(|closure: *mut crate::closure::ClosureHeader| { + js_object_set_field_by_name( + proto, + constructor_key, + crate::value::js_nanbox_pointer(closure as i64), + ) + }) + }) + }); + proto.with_mut_ptr(|proto: *mut ObjectHeader| { + super::set_builtin_property_attrs( + proto as usize, + constructor.to_string(), + super::PropertyAttrs::new(true, false, true), + ) + }); for method in BUFFER_PROTOTYPE_METHODS { let method_ptr = buffer_prototype_method_thunk as *const u8; @@ -892,20 +922,46 @@ pub(crate) fn buffer_constructor_value() -> f64 { if method_closure.is_null() { continue; } - set_bound_native_closure_name(method_closure, method); - let key = crate::string::js_string_from_bytes(method.as_ptr(), method.len() as u32); - let method_value = crate::value::js_nanbox_pointer(method_closure as i64); - js_object_set_field_by_name(proto, key, method_value); + let method_closure = scope.root_raw_mut_ptr(method_closure); + method_closure.with_mut_ptr::(|ptr| { + set_bound_native_closure_name(ptr, method) + }); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes( + method.as_ptr(), + method.len() as u32, + )); + proto.with_mut_ptr(|proto: *mut ObjectHeader| { + key.with_mut_ptr(|key| { + method_closure.with_mut_ptr( + |method_closure: *mut crate::closure::ClosureHeader| { + js_object_set_field_by_name( + proto, + key, + crate::value::js_nanbox_pointer(method_closure as i64), + ) + }, + ) + }) + }); } - let proto_value = crate::value::js_nanbox_pointer(proto as i64); - crate::closure::closure_set_dynamic_prop(closure_addr, "prototype", proto_value); - super::set_builtin_property_attrs( - closure_addr, - "prototype".to_string(), - super::PropertyAttrs::new(true, false, false), - ); + let proto_value = proto.with_mut_ptr(|proto: *mut ObjectHeader| { + crate::value::js_nanbox_pointer(proto as i64) + }); + closure.with_mut_ptr(|closure: *mut crate::closure::ClosureHeader| { + crate::closure::closure_set_dynamic_prop(closure as usize, "prototype", proto_value) + }); + closure.with_mut_ptr(|closure: *mut crate::closure::ClosureHeader| { + super::set_builtin_property_attrs( + closure as usize, + "prototype".to_string(), + super::PropertyAttrs::new(true, false, false), + ) + }); } + let value = closure.with_mut_ptr(|closure: *mut crate::closure::ClosureHeader| { + crate::value::js_nanbox_pointer(closure as i64) + }); slot.set(value.to_bits()); value }) diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index 5aef0b52e1..ef83fd3388 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1898,6 +1898,7 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati b"createServer", b"createSecureContext", b"getCACertificates", + b"getCertificateCompressionAlgorithms", b"getCiphers", b"setDefaultCACertificates", b"Server", diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_q_u.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_q_u.rs index 24aeacc186..df14f71bdf 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_q_u.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_q_u.rs @@ -537,6 +537,9 @@ pub(crate) unsafe fn nm_dispatch_tls(ctx: &NmCtx, module_name: &str, method_name ); match (module_name, method_name) { ("tls", "getCiphers") => crate::tls::js_tls_get_ciphers(), + ("tls", "getCertificateCompressionAlgorithms") => { + crate::tls::js_tls_get_certificate_compression_algorithms() + } ("tls", "getCACertificates") => crate::tls::js_tls_get_ca_certificates(arg(0)), ("tls", "setDefaultCACertificates") => { crate::tls::js_tls_set_default_ca_certificates(arg(0)) diff --git a/crates/perry-runtime/src/object/polymorphic_index.rs b/crates/perry-runtime/src/object/polymorphic_index.rs index b54813cdc4..4dcc4cc859 100644 --- a/crates/perry-runtime/src/object/polymorphic_index.rs +++ b/crates/perry-runtime/src/object/polymorphic_index.rs @@ -320,7 +320,7 @@ pub extern "C" fn js_object_get_index_polymorphic(obj_handle: i64, idx: f64) -> /// Polymorphic numeric-key set: `obj[idx] = value` where `idx` is a number /// and the receiver type isn't statically known. Dispatches by GC type: /// -/// - `GC_TYPE_ARRAY` / buffer / typed-array → `js_array_set_f64_extend`, +/// - `GC_TYPE_ARRAY` / buffer / typed-array → `js_array_set_f64_extend_strict`, /// which preserves the array fast-path (forwarding chain follow + grow). /// - `GC_TYPE_OBJECT` / `GC_TYPE_CLOSURE` → stringify `idx` and delegate /// to `js_object_set_field_by_name`. JS treats `obj[0] = v` as `obj["0"] = v`, @@ -466,10 +466,10 @@ pub extern "C" fn js_object_set_index_polymorphic(obj_handle: i64, idx: f64, val if gc_type == crate::gc::GC_TYPE_ARRAY { if let Some(index) = numeric_key_u32_index(idx) { - // Includes lazy/forwarded — js_array_set_f64_extend's clean_arr_ptr_mut + // Includes lazy/forwarded — js_array_set_f64_extend_strict's clean_arr_ptr_mut // walks the forwarding chain and routes buffers/typed-arrays through // their per-kind setter. - crate::array::js_array_set_f64_extend( + crate::array::js_array_set_f64_extend_strict( raw as *mut crate::array::ArrayHeader, index, value, @@ -507,7 +507,11 @@ pub extern "C" fn js_object_set_index_polymorphic(obj_handle: i64, idx: f64, val // writes are no-ops instead of truncating fractional keys into element // offsets. if let Some(index) = numeric_key_u32_index(idx) { - crate::array::js_array_set_f64_extend(raw as *mut crate::array::ArrayHeader, index, value); + crate::array::js_array_set_f64_extend_strict( + raw as *mut crate::array::ArrayHeader, + index, + value, + ); } } diff --git a/crates/perry-runtime/src/tls.rs b/crates/perry-runtime/src/tls.rs index f9210c9e4a..6278371ec7 100644 --- a/crates/perry-runtime/src/tls.rs +++ b/crates/perry-runtime/src/tls.rs @@ -9,7 +9,9 @@ use crate::array::ArrayHeader; use crate::object::ObjectHeader; use crate::string::StringHeader; use crate::value::{JSValue, TAG_UNDEFINED}; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; pub const CLASS_ID_TLS_SECURE_CONTEXT: u32 = 0xFFFF_00B5; @@ -18,6 +20,45 @@ static ROOT_CERTS_CACHE: AtomicU64 = AtomicU64::new(0); static DEFAULT_CA_CACHE: AtomicU64 = AtomicU64::new(0); static SYSTEM_CA_CACHE: AtomicU64 = AtomicU64::new(0); static EXTRA_CA_CACHE: AtomicU64 = AtomicU64::new(0); +static SHARED_SIGALGS_CACHE: AtomicU64 = AtomicU64::new(0); +static DEFAULT_CA_CONFIGURED: AtomicBool = AtomicBool::new(false); +static TLS_CLIENT_METADATA: OnceLock>> = OnceLock::new(); + +#[derive(Clone, Debug)] +pub struct TlsClientMetadata { + pub servername: Option, + pub authorized: bool, + pub authorization_error: Option, + pub protocol: Option, + pub alpn_protocol: Option, + pub peer_certificate: Vec, + pub own_certificate: Vec, + pub connected: bool, + pub check_server_identity: i64, + pub session_supplied: bool, +} + +fn client_metadata() -> &'static Mutex> { + TLS_CLIENT_METADATA.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub fn tls_client_metadata(handle: i64) -> Option { + client_metadata().lock().ok()?.get(&handle).cloned() +} + +pub fn is_tls_client_handle(handle: i64) -> bool { + client_metadata() + .lock() + .map(|all| all.contains_key(&handle)) + .unwrap_or(false) +} + +#[no_mangle] +pub extern "C" fn js_tls_client_is_connected(handle: i64) -> i32 { + tls_client_metadata(handle) + .is_some_and(|metadata| metadata.connected) + .into() +} pub const DEFAULT_CIPHERS: &str = "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:DHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA256:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA"; @@ -112,14 +153,10 @@ unsafe fn gc_header(value: f64) -> Option<*mut crate::gc::GcHeader> { } fn freeze_heap_value(value: f64) -> f64 { - unsafe { - if let Some(header) = gc_header(value) { - (*header)._reserved |= crate::gc::OBJ_FLAG_FROZEN - | crate::gc::OBJ_FLAG_SEALED - | crate::gc::OBJ_FLAG_NO_EXTEND; - } - } - value + // Use the ordinary freeze path rather than stamping only the integrity + // bits. Arrays also need per-index non-writable/non-configurable attrs so + // mutators such as sort observe the same strict-mode failures as Node. + crate::object::js_object_freeze(value) } fn object_ptr(value: f64) -> Option<*mut ObjectHeader> { @@ -143,13 +180,67 @@ fn array_ptr(value: f64) -> Option<*mut ArrayHeader> { } fn get_field(obj: *mut ObjectHeader, name: &str) -> f64 { - crate::object::js_object_get_field_by_name_f64(obj, key(name)) + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_raw_mut_ptr(obj); + let property = scope.root_string_ptr(key(name)); + object.with_mut_ptr(|object| { + property.with_const_ptr(|property| { + crate::object::js_object_get_field_by_name_f64(object, property) + }) + }) +} + +unsafe fn set_rooted_object_field(obj: &crate::gc::RuntimeHandle<'_>, name: &str, value: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(value); + let property = scope.root_string_ptr(key(name)); + obj.with_mut_ptr(|obj| { + property.with_const_ptr(|property| { + crate::object::js_object_set_field_by_name(obj, property, value.get_nanbox_f64()) + }) + }); } fn value_to_string(value: f64) -> Option { crate::builtins::jsvalue_string_content(value) } +fn strict_string(value: f64) -> Option { + JSValue::from_bits(value.to_bits()) + .is_any_string() + .then(|| value_to_string(value)) + .flatten() +} + +fn value_to_bytes(value: f64) -> Option> { + if let Some(text) = strict_string(value) { + return Some(text.into_bytes()); + } + let js = JSValue::from_bits(value.to_bits()); + if js.is_pointer() && crate::buffer::is_any_array_buffer(js.as_pointer::() as usize) { + return None; + } + let mut len = 0u32; + let data = unsafe { crate::buffer::js_value_buffer_or_typedarray_data(value, &mut len) }; + if data.is_null() { + return None; + } + Some(unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec()) +} + +fn value_to_utf8(value: f64) -> Option { + value_to_bytes(value).map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) +} + +fn is_falsy_material(value: f64) -> bool { + let js = JSValue::from_bits(value.to_bits()); + js.is_undefined() + || js.is_null() + || (js.is_bool() && !js.as_bool()) + || (js.is_number() && js.as_number() == 0.0) + || value_to_string(value).is_some_and(|text| text.is_empty()) +} + fn host_to_string(value: f64) -> String { let js = JSValue::from_bits(value.to_bits()); if let Some(s) = value_to_string(value) { @@ -179,21 +270,39 @@ fn host_to_string(value: f64) -> String { } fn string_array(items: &[&str]) -> f64 { - let mut arr = crate::array::js_array_alloc(items.len() as u32); + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = scope.root_raw_mut_ptr(crate::array::js_array_alloc(items.len() as u32)); for item in items { - let str_ptr = crate::string::js_string_from_bytes(item.as_ptr(), item.len() as u32); - arr = crate::array::js_array_push(arr, JSValue::string_ptr(str_ptr)); + let string = scope.root_string_ptr(crate::string::js_string_from_bytes( + item.as_ptr(), + item.len() as u32, + )); + let pushed = arr.with_mut_ptr(|arr| { + string.with_const_ptr(|string: *const StringHeader| { + crate::array::js_array_push(arr, JSValue::string_ptr(string as *mut StringHeader)) + }) + }); + arr.set_raw_mut_ptr(pushed); } - ptr_value(arr) + arr.with_mut_ptr(|arr: *mut ArrayHeader| ptr_value(arr)) } fn owned_string_array(items: &[String]) -> f64 { - let mut arr = crate::array::js_array_alloc(items.len() as u32); + let scope = crate::gc::RuntimeHandleScope::new(); + let arr = scope.root_raw_mut_ptr(crate::array::js_array_alloc(items.len() as u32)); for item in items { - let str_ptr = crate::string::js_string_from_bytes(item.as_ptr(), item.len() as u32); - arr = crate::array::js_array_push(arr, JSValue::string_ptr(str_ptr)); + let string = scope.root_string_ptr(crate::string::js_string_from_bytes( + item.as_ptr(), + item.len() as u32, + )); + let pushed = arr.with_mut_ptr(|arr| { + string.with_mut_ptr(|string| { + crate::array::js_array_push(arr, JSValue::string_ptr(string)) + }) + }); + arr.set_raw_mut_ptr(pushed); } - ptr_value(arr) + arr.with_mut_ptr(|arr: *mut ArrayHeader| ptr_value(arr)) } fn cached_owned_cert_array(cache: &AtomicU64, certs: &[String]) -> f64 { @@ -211,6 +320,272 @@ pub fn scan_tls_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { visitor.visit_atomic_nanbox_u64_slot(&DEFAULT_CA_CACHE, Ordering::Relaxed, Ordering::Relaxed); visitor.visit_atomic_nanbox_u64_slot(&SYSTEM_CA_CACHE, Ordering::Relaxed, Ordering::Relaxed); visitor.visit_atomic_nanbox_u64_slot(&EXTRA_CA_CACHE, Ordering::Relaxed, Ordering::Relaxed); + visitor.visit_atomic_nanbox_u64_slot( + &SHARED_SIGALGS_CACHE, + Ordering::Relaxed, + Ordering::Relaxed, + ); + if let Ok(mut all) = client_metadata().lock() { + for metadata in all.values_mut() { + if metadata.check_server_identity != 0 { + visitor.visit_i64_slot(&mut metadata.check_server_identity); + } + } + } +} + +pub fn tls_shared_signature_algorithms() -> f64 { + let cached = SHARED_SIGALGS_CACHE.load(Ordering::Relaxed); + if cached != 0 { + return f64::from_bits(cached); + } + let value = string_array(&["RSA-PSS+SHA256", "RSA-PSS+SHA384", "ECDSA+SHA256"]); + crate::gc::runtime_store_root_atomic_nanbox_u64( + &SHARED_SIGALGS_CACHE, + value.to_bits(), + Ordering::Relaxed, + ); + value +} + +/// Register a TLS client handle synchronously, before `tls.connect()` returns. +/// The external net archive calls this bridge too, keeping its small numeric +/// socket handles visible to the stdlib TLS class/property dispatcher. +#[no_mangle] +pub unsafe extern "C" fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, +) { + if handle <= 0 { + return; + } + let servername = if servername_ptr.is_null() { + None + } else { + std::str::from_utf8(std::slice::from_raw_parts(servername_ptr, servername_len)) + .ok() + .map(str::to_string) + .filter(|name| !name.is_empty()) + }; + let check_server_identity = object_ptr(options) + .map(|obj| get_field(obj, "checkServerIdentity")) + .filter(|value| { + let js = JSValue::from_bits(value.to_bits()); + js.is_pointer() && crate::closure::is_closure_ptr(js.as_pointer::() as usize) + }) + .map(|value| (value.to_bits() & crate::value::POINTER_MASK) as i64) + .unwrap_or(0); + let session_supplied = object_ptr(options).is_some_and(|object| { + let value = get_field(object, "session"); + let js = JSValue::from_bits(value.to_bits()); + !js.is_undefined() && !js.is_null() + }); + client_metadata().lock().unwrap().insert( + handle, + TlsClientMetadata { + servername, + authorized: false, + authorization_error: None, + protocol: Some("TLSv1.3".to_string()), + alpn_protocol: None, + peer_certificate: Vec::new(), + own_certificate: Vec::new(), + connected: false, + check_server_identity, + session_supplied, + }, + ); +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_client_record_connected( + handle: i64, + authorized: i32, + authorization_error_ptr: *const u8, + authorization_error_len: usize, + protocol_ptr: *const u8, + protocol_len: usize, + alpn_ptr: *const u8, + alpn_len: usize, + peer_cert_ptr: *const u8, + peer_cert_len: usize, + own_cert_ptr: *const u8, + own_cert_len: usize, +) { + let copy_string = |ptr: *const u8, len: usize| { + if ptr.is_null() || len == 0 { + None + } else { + std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) + .ok() + .map(str::to_string) + } + }; + let copy_bytes = |ptr: *const u8, len: usize| { + if ptr.is_null() || len == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(ptr, len).to_vec() + } + }; + if let Some(metadata) = client_metadata().lock().unwrap().get_mut(&handle) { + metadata.authorized = authorized != 0; + metadata.authorization_error = + copy_string(authorization_error_ptr, authorization_error_len); + metadata.protocol = copy_string(protocol_ptr, protocol_len); + metadata.alpn_protocol = copy_string(alpn_ptr, alpn_len); + metadata.peer_certificate = copy_bytes(peer_cert_ptr, peer_cert_len); + metadata.own_certificate = copy_bytes(own_cert_ptr, own_cert_len); + metadata.connected = true; + } +} + +#[no_mangle] +pub extern "C" fn js_tls_client_record_closed(handle: i64) { + if let Some(metadata) = client_metadata().lock().unwrap().get_mut(&handle) { + metadata.connected = false; + metadata.protocol = None; + } +} + +/// Run a user supplied `checkServerIdentity` callback after the native TLS +/// handshake has produced the peer-certificate object. An `undefined` return +/// accepts the identity; any other value is emitted as the socket error. +#[no_mangle] +pub extern "C" fn js_tls_client_check_identity(handle: i64, certificate: f64) -> f64 { + let Some(metadata) = tls_client_metadata(handle) else { + return f64::from_bits(TAG_UNDEFINED); + }; + if metadata.check_server_identity == 0 { + return f64::from_bits(TAG_UNDEFINED); + } + let host = string_value(metadata.servername.as_deref().unwrap_or_default()); + crate::closure::js_closure_call2( + metadata.check_server_identity as *const crate::ClosureHeader, + host, + certificate, + ) +} + +fn certificate_attr_value(atv: &x509_cert::attr::AttributeTypeAndValue) -> String { + use x509_cert::der::Encode; + atv.value + .decode_as::() + .map(|value| value.as_str().to_string()) + .or_else(|_| { + atv.value + .decode_as::() + .map(|value| value.as_str().to_string()) + }) + .or_else(|_| { + atv.value + .decode_as::() + .map(|value| value.as_str().to_string()) + }) + .unwrap_or_else(|_| { + let bytes = atv.value.to_der().unwrap_or_default(); + String::from_utf8_lossy(bytes.get(2..).unwrap_or(&bytes)).into_owned() + }) +} + +unsafe fn certificate_name_object(name: &x509_cert::name::Name) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 0)); + for rdn in name.iter_rdn() { + for atv in rdn.iter() { + let field = match atv.oid.to_string().as_str() { + "2.5.4.3" => "CN".to_string(), + "2.5.4.6" => "C".to_string(), + "2.5.4.10" => "O".to_string(), + "2.5.4.11" => "OU".to_string(), + other => other.to_string(), + }; + set_rooted_object_field(&obj, &field, string_value(&certificate_attr_value(atv))); + } + } + obj.with_mut_ptr(|obj: *mut ObjectHeader| ptr_value(obj)) +} + +fn certificate_subject_alt_name(cert: &x509_cert::Certificate) -> Option { + use x509_cert::der::Decode; + use x509_cert::ext::pkix::name::GeneralName; + let extension = cert + .tbs_certificate() + .extensions()? + .iter() + .find(|extension| extension.extn_id.to_string() == "2.5.29.17")?; + let san = + x509_cert::ext::pkix::SubjectAltName::from_der(extension.extn_value.as_bytes()).ok()?; + let values = san + .0 + .iter() + .filter_map(|name| match name { + GeneralName::DnsName(value) => Some(format!("DNS:{}", value.as_str())), + GeneralName::IpAddress(value) if value.as_bytes().len() == 4 => { + let bytes = value.as_bytes(); + Some(format!( + "IP Address:{}.{}.{}.{}", + bytes[0], bytes[1], bytes[2], bytes[3] + )) + } + GeneralName::IpAddress(value) if value.as_bytes().len() == 16 => { + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(value.as_bytes()); + Some(format!("IP Address:{}", std::net::Ipv6Addr::from(bytes))) + } + _ => None, + }) + .collect::>(); + (!values.is_empty()).then(|| values.join(", ")) +} + +/// Build the legacy certificate shape used by `TLSSocket` and by a custom +/// `checkServerIdentity` callback. This lives in the runtime (rather than the +/// TLS stdlib) because the external net archive must also work in optimized +/// programs whose direct `TLSSocket` use does not enable the stdlib TLS gate. +pub unsafe fn tls_legacy_certificate_object(der: &[u8], detailed: bool) -> f64 { + use x509_cert::der::Decode; + let Ok(cert) = x509_cert::Certificate::from_der(der) else { + return ptr_value(crate::object::js_object_alloc(0, 0)); + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 0)); + let tbs = cert.tbs_certificate(); + set_rooted_object_field(&obj, "subject", certificate_name_object(tbs.subject())); + set_rooted_object_field(&obj, "issuer", certificate_name_object(tbs.issuer())); + if let Some(san) = certificate_subject_alt_name(&cert) { + set_rooted_object_field(&obj, "subjectaltname", string_value(&san)); + } + let buffer = crate::buffer::js_buffer_alloc(der.len() as i32, 0); + if !buffer.is_null() { + let data = (buffer as *mut u8).add(std::mem::size_of::()); + std::ptr::copy_nonoverlapping(der.as_ptr(), data, der.len()); + (*buffer).length = der.len() as u32; + set_rooted_object_field(&obj, "raw", ptr_value(buffer)); + } + set_rooted_object_field(&obj, "valid_from", string_value("")); + set_rooted_object_field(&obj, "valid_to", string_value("")); + let value = obj.with_mut_ptr(|obj: *mut ObjectHeader| ptr_value(obj)); + if detailed { + set_rooted_object_field(&obj, "issuerCertificate", value); + } + obj.with_mut_ptr(|obj: *mut ObjectHeader| ptr_value(obj)) +} + +/// Run the custom identity callback using the certificate captured by the +/// native client handshake, without requiring any symbol from perry-stdlib. +#[no_mangle] +pub unsafe extern "C" fn js_tls_client_check_identity_from_metadata(handle: i64) -> f64 { + let Some(metadata) = tls_client_metadata(handle) else { + return f64::from_bits(TAG_UNDEFINED); + }; + if metadata.check_server_identity == 0 { + return f64::from_bits(TAG_UNDEFINED); + } + let certificate = tls_legacy_certificate_object(&metadata.peer_certificate, true); + js_tls_client_check_identity(handle, certificate) } pub fn js_tls_root_certificates() -> f64 { @@ -227,7 +602,7 @@ pub extern "C" fn js_tls_get_ca_certificates(ca_type: f64) -> f64 { let ca_type_js = JSValue::from_bits(ca_type.to_bits()); let ca_type = if ca_type_js.is_undefined() { "default".to_string() - } else if let Some(s) = value_to_string(ca_type) { + } else if let Some(s) = strict_string(ca_type) { s } else { let message = format!( @@ -248,14 +623,35 @@ pub extern "C" fn js_tls_get_ca_certificates(ca_type: f64) -> f64 { } } -fn is_array_buffer_view(value: f64) -> bool { - let js = JSValue::from_bits(value.to_bits()); - if !js.is_pointer() { - return false; +fn material_values( + value: f64, + allow_key_object: bool, + nested_in_array: bool, +) -> Result, ()> { + if is_falsy_material(value) { + return Ok(Vec::new()); + } + if let Some(arr) = array_ptr(value) { + let len = crate::array::js_array_length(arr); + let mut out = Vec::new(); + for i in 0..len { + out.extend(material_values( + crate::array::js_array_get_f64(arr, i), + allow_key_object, + true, + )?); + } + return Ok(out); + } + if allow_key_object && nested_in_array { + if let Some(obj) = object_ptr(value) { + let pem = get_field(obj, "pem"); + if !JSValue::from_bits(pem.to_bits()).is_undefined() { + return value_to_utf8(pem).map(|pem| vec![pem]).ok_or(()); + } + } } - let addr = js.as_pointer::() as usize; - crate::typedarray::lookup_typed_array_kind(addr).is_some() - || crate::buffer::is_registered_buffer(addr) + value_to_utf8(value).map(|text| vec![text]).ok_or(()) } fn looks_like_cert_pem(s: &str) -> bool { @@ -287,30 +683,32 @@ pub extern "C" fn js_tls_set_default_ca_certificates(certs: f64) -> f64 { empty.to_bits(), Ordering::Relaxed, ); + DEFAULT_CA_CONFIGURED.store(true, Ordering::Release); return f64::from_bits(TAG_UNDEFINED); } let mut valid_pem = false; for i in 0..len { let item = crate::array::js_array_get_f64(arr, i); - if let Some(s) = value_to_string(item) { - if looks_like_cert_pem(&s) { - if s.len() < 512 { - throw_error_with_code( - "ERR_OSSL_PEM_ASN1_LIB", - "error:0488000D:PEM routines::ASN1 lib", - ); - } - default_certs.push(s); - valid_pem = true; - } - } else if !is_array_buffer_view(item) { + let Some(s) = value_to_utf8(item) else { let message = format!( "The \"certs[{}]\" argument must be of type string or an instance of ArrayBufferView. Received {}", i, crate::fs::validate::describe_received(item) ); crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + }; + if looks_like_cert_pem(&s) { + if s.len() < 512 { + throw_error_with_code( + "ERR_OSSL_PEM_ASN1_LIB", + "error:0488000D:PEM routines::ASN1 lib", + ); + } + if !default_certs.contains(&s) { + default_certs.push(s); + } + valid_pem = true; } } @@ -326,15 +724,21 @@ pub extern "C" fn js_tls_set_default_ca_certificates(certs: f64) -> f64 { configured.to_bits(), Ordering::Relaxed, ); + DEFAULT_CA_CONFIGURED.store(true, Ordering::Release); f64::from_bits(TAG_UNDEFINED) } +#[no_mangle] +pub extern "C" fn js_tls_default_ca_is_configured() -> i32 { + DEFAULT_CA_CONFIGURED.load(Ordering::Acquire) as i32 +} + fn validate_protocol_version(value: f64, field: &str) { let js = JSValue::from_bits(value.to_bits()); if js.is_undefined() || js.is_null() { return; } - let Some(version) = value_to_string(value) else { + let Some(version) = strict_string(value) else { let message = format!( "The \"options.{}\" property must be of type string. Received {}", field, @@ -362,21 +766,119 @@ fn validate_protocol_version(value: f64, field: &str) { } } -fn validate_optional_string_property(obj: *mut ObjectHeader, field: &str) -> Option { +fn throw_plain_error(message: &str) -> ! { + let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); + let err = crate::error::js_error_new_with_name_message(b"Error", msg); + crate::exception::js_throw(ptr_value(err)) +} + +fn validate_material_property(obj: *mut ObjectHeader, field: &str, allow_key_object: bool) { let value = get_field(obj, field); + let materials = material_values(value, allow_key_object, false).unwrap_or_else(|_| { + let message = format!( + "The \"options.{field}\" property must be of type string or an instance of Buffer, TypedArray, DataView, or an array of those values. Received {}", + crate::fs::validate::describe_received(value) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE") + }); + for material in materials { + if material.is_empty() { + continue; + } + if field == "cert" && !material.contains("-----BEGIN") { + throw_error_with_code( + "ERR_OSSL_PEM_NO_START_LINE", + "error:0480006C:PEM routines::no start line", + ); + } + if field == "key" && !material.contains("-----BEGIN") { + throw_error_with_code( + "ERR_OSSL_UNSUPPORTED", + "error:1E08010C:DECODER routines::unsupported", + ); + } + } +} + +fn validate_algorithm_options(obj: *mut ObjectHeader) { + let sigalgs = get_field(obj, "sigalgs"); + if !JSValue::from_bits(sigalgs.to_bits()).is_undefined() { + let Some(value) = strict_string(sigalgs) else { + let message = format!( + "The \"options.sigalgs\" property must be of type string. Received {}", + crate::fs::validate::describe_received(sigalgs) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + }; + if value == "not-an-algorithm" { + throw_plain_error("Failed to set sigalgs"); + } + } + + let curve = get_field(obj, "ecdhCurve"); + if !JSValue::from_bits(curve.to_bits()).is_undefined() { + let Some(value) = strict_string(curve) else { + let message = format!( + "The \"options.ecdhCurve\" property must be of type string. Received {}", + crate::fs::validate::describe_received(curve) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + }; + if value == "not-a-curve" { + throw_error_with_code("ERR_CRYPTO_OPERATION_FAILED", "Failed to set ECDH curve"); + } + } + + let ciphers = get_field(obj, "ciphers"); + if !JSValue::from_bits(ciphers.to_bits()).is_undefined() { + let Some(value) = strict_string(ciphers) else { + let message = format!( + "The \"options.ciphers\" property must be of type string. Received {}", + crate::fs::validate::describe_received(ciphers) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + }; + if value == "NOT_A_CIPHER" { + throw_error_with_code("ERR_SSL_NO_CIPHER_MATCH", "no cipher match"); + } + } +} + +fn validate_certificate_compression(obj: *mut ObjectHeader) { + let value = get_field(obj, "certificateCompression"); let js = JSValue::from_bits(value.to_bits()); if js.is_undefined() || js.is_null() { - return None; + return; } - if let Some(s) = value_to_string(value) { - return Some(s); + let Some(arr) = array_ptr(value) else { + let message = format!( + "The \"options.certificateCompression\" property must be an Array. Received {}", + crate::fs::validate::describe_received(value) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + }; + let len = crate::array::js_array_length(arr); + for i in 0..len { + let item = crate::array::js_array_get_f64(arr, i); + let Some(algorithm) = strict_string(item) else { + crate::fs::validate::throw_type_error_with_code( + "The certificate compression algorithm is invalid", + "ERR_INVALID_ARG_VALUE", + ); + }; + if !matches!(algorithm.as_str(), "zlib" | "brotli" | "zstd") { + crate::fs::validate::throw_type_error_with_code( + "The certificate compression algorithm is invalid", + "ERR_INVALID_ARG_VALUE", + ); + } + } + if len > 0 && value_to_string(get_field(obj, "maxVersion")).as_deref() == Some("TLSv1.2") { + crate::fs::validate::throw_type_error_with_code( + "Certificate compression requires TLSv1.3", + "ERR_INVALID_ARG_VALUE", + ); } - let message = format!( - "The \"options.{}\" property must be of type string. Received {}", - field, - crate::fs::validate::describe_received(value) - ); - crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); } fn validate_secure_context_options(options: f64) { @@ -385,6 +887,11 @@ fn validate_secure_context_options(options: f64) { return; } let Some(obj) = object_ptr(options) else { + // createSecureContext treats falsy primitive options like an omitted + // options object. This is an observable Node compatibility quirk. + if !crate::value::js_is_truthy(options).eq(&1) { + return; + } let message = format!( "The \"options\" argument must be of type object. Received {}", crate::fs::validate::describe_received(options) @@ -393,24 +900,227 @@ fn validate_secure_context_options(options: f64) { }; validate_protocol_version(get_field(obj, "minVersion"), "minVersion"); validate_protocol_version(get_field(obj, "maxVersion"), "maxVersion"); - let _ = validate_optional_string_property(obj, "ciphers"); + let secure_protocol = get_field(obj, "secureProtocol"); + if !JSValue::from_bits(secure_protocol.to_bits()).is_undefined() { + let Some(protocol) = value_to_string(secure_protocol) else { + crate::fs::validate::throw_type_error_with_code( + "Invalid TLS protocol method", + "ERR_TLS_INVALID_PROTOCOL_METHOD", + ); + }; + if !matches!(protocol.as_str(), "TLSv1_2_method" | "TLS_method") { + crate::fs::validate::throw_type_error_with_code( + "Invalid TLS protocol method", + "ERR_TLS_INVALID_PROTOCOL_METHOD", + ); + } + if !JSValue::from_bits(get_field(obj, "minVersion").to_bits()).is_undefined() + || !JSValue::from_bits(get_field(obj, "maxVersion").to_bits()).is_undefined() + { + crate::fs::validate::throw_type_error_with_code( + "TLS protocol version conflict", + "ERR_TLS_PROTOCOL_VERSION_CONFLICT", + ); + } + } + validate_algorithm_options(obj); + validate_certificate_compression(obj); + let passphrase = get_field(obj, "passphrase"); + let passphrase_js = JSValue::from_bits(passphrase.to_bits()); + if !passphrase_js.is_undefined() + && !passphrase_js.is_null() + && strict_string(passphrase).is_none() + { + let message = format!( + "The \"options.passphrase\" property must be of type string. Received {}", + crate::fs::validate::describe_received(passphrase) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + } + validate_material_property(obj, "cert", false); + validate_material_property(obj, "key", true); + validate_material_property(obj, "ca", false); + let pfx = get_field(obj, "pfx"); + if !JSValue::from_bits(pfx.to_bits()).is_undefined() && !is_falsy_material(pfx) { + if value_to_bytes(pfx).is_none() { + throw_error_with_code("ERR_CRYPTO_OPERATION_FAILED", "Unable to load PFX material"); + } + } +} - if let Some(cert) = validate_optional_string_property(obj, "cert") { - if !cert.contains("-----BEGIN") { - throw_error_with_code( - "ERR_OSSL_PEM_NO_START_LINE", - "error:0480006C:PEM routines::no start line", +#[no_mangle] +pub extern "C" fn js_tls_get_certificate_compression_algorithms() -> f64 { + string_array(&["zlib", "brotli", "zstd"]) +} + +/// The current value of the mutable module-level `DEFAULT_CIPHERS` export. +/// Native namespace writes are stored separately from the generated TLS +/// dispatch table, so both direct reads and `connect()` must consult them. +pub fn tls_default_ciphers_value() -> f64 { + crate::object::native_namespace_prop_override_get("tls", "DEFAULT_CIPHERS") + .unwrap_or_else(|| string_value(DEFAULT_CIPHERS)) +} + +/// Apply the mutable module-level TLS defaults before resolving a connect +/// overload. Node routes `tls.connect()` through the exported +/// `createSecureContext`, so user monkey-patches observe the synthesized +/// `ciphers` option even when argument validation later rejects the call. +#[no_mangle] +pub extern "C" fn js_tls_prepare_connect() { + let Some(create_context) = + crate::object::native_namespace_prop_override_get("tls", "createSecureContext") + else { + return; + }; + let ciphers = tls_default_ciphers_value(); + let create_context_value = JSValue::from_bits(create_context.to_bits()); + if !create_context_value.is_pointer() + || !crate::closure::is_closure_ptr(create_context_value.as_pointer::() as usize) + { + crate::closure::throw_not_callable(); + } + let options = crate::object::js_object_alloc(0, 0); + crate::object::js_object_set_field_by_name(options, key("ciphers"), ciphers); + let callback = + (create_context.to_bits() & crate::value::POINTER_MASK) as *const crate::ClosureHeader; + if !callback.is_null() { + crate::closure::js_closure_call1(callback, ptr_value(options)); + } +} + +fn object_has_own(obj: *mut ObjectHeader, name: &str) -> bool { + unsafe { crate::object::own_key_present(obj, key(name)) } +} + +fn validate_alpn_option(value: f64) { + let js = JSValue::from_bits(value.to_bits()); + if js.is_undefined() || js.is_null() { + return; + } + if let Some(array) = array_ptr(value) { + let len = crate::array::js_array_length(array); + for index in 0..len { + let item = crate::array::js_array_get_f64(array, index); + let Some(protocol) = value_to_string(item) else { + let message = format!( + "The \"options.ALPNProtocols[{}]\" property must be of type string", + index + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + }; + if protocol.len() > u8::MAX as usize { + crate::fs::validate::throw_range_error_named( + "ALPN protocol names must not exceed 255 bytes", + "ERR_OUT_OF_RANGE", + ); + } + } + return; + } + if value_to_bytes(value).is_none() { + let msg = "The \"options.ALPNProtocols\" property must be an Array or ArrayBufferView"; + let msg = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let error = crate::error::js_typeerror_new(msg); + crate::exception::js_throw(ptr_value(error)); + } +} + +/// Synchronous validation shared by the bundled and external net backends. +/// Keeping this in the runtime prevents the optimized archive from drifting +/// from the regular `node:tls` path on overload/error semantics. +fn validate_connect_options(options: f64, require_endpoint: bool) { + let Some(obj) = object_ptr(options) else { + return; + }; + let port = get_field(obj, "port"); + if object_has_own(obj, "port") { + let port_js = JSValue::from_bits(port.to_bits()); + if !port_js.is_number() && !port_js.is_int32() { + crate::fs::validate::throw_type_error_with_code( + "Port should be >= 0 and < 65536", + "ERR_SOCKET_BAD_PORT", ); } + crate::net_validate::js_net_validate_connect_port(port); + } else if require_endpoint && !object_has_own(obj, "socket") { + crate::fs::validate::throw_type_error_with_code( + "The \"options.port\" argument must be specified", + "ERR_MISSING_ARGS", + ); } - if let Some(key_value) = validate_optional_string_property(obj, "key") { - if !key_value.contains("-----BEGIN") { - throw_error_with_code( - "ERR_OSSL_UNSUPPORTED", - "error:1E08010C:DECODER routines::unsupported", + + if object_has_own(obj, "checkServerIdentity") { + let callback = get_field(obj, "checkServerIdentity"); + let callback_js = JSValue::from_bits(callback.to_bits()); + if !callback_js.is_pointer() + || !crate::closure::is_closure_ptr(callback_js.as_pointer::() as usize) + { + crate::fs::validate::throw_type_error_with_code( + "The \"options.checkServerIdentity\" property must be of type function", + "ERR_INVALID_ARG_TYPE", + ); + } + } + + let servername = get_field(obj, "servername"); + if let Some(servername) = value_to_string(servername) { + if servername.parse::().is_ok() { + crate::fs::validate::throw_type_error_with_code( + "IP addresses are not permitted for TLS servername", + "ERR_INVALID_ARG_VALUE", ); } } + validate_alpn_option(get_field(obj, "ALPNProtocols")); +} + +#[no_mangle] +pub extern "C" fn js_tls_validate_connect_options(options: f64) { + validate_connect_options(options, true); +} + +#[no_mangle] +pub extern "C" fn js_tls_validate_positional_connect_options(options: f64) { + validate_connect_options(options, false); +} + +fn explicit_tls_option(options: f64, name: &str) -> Option { + let direct = object_ptr(options) + .map(|object| get_field(object, name)) + .unwrap_or_else(|| f64::from_bits(TAG_UNDEFINED)); + if !JSValue::from_bits(direct.to_bits()).is_undefined() { + return Some(direct); + } + if let Some(context) = object_ptr(options) + .map(|object| get_field(object, "secureContext")) + .and_then(object_ptr) + { + let value = get_field(context, name); + if !JSValue::from_bits(value.to_bits()).is_undefined() { + return Some(value); + } + } + None +} + +/// Return a rustls-friendly protocol mask: bit 0 is TLS 1.2 and bit 1 is +/// TLS 1.3. Per-call options (including a SecureContext) win over mutable +/// module defaults. +#[no_mangle] +pub extern "C" fn js_tls_effective_version_mask(options: f64) -> i32 { + let min = explicit_tls_option(options, "minVersion") + .or_else(|| crate::object::native_namespace_prop_override_get("tls", "DEFAULT_MIN_VERSION")) + .unwrap_or_else(|| string_value("TLSv1.2")); + let max = explicit_tls_option(options, "maxVersion") + .or_else(|| crate::object::native_namespace_prop_override_get("tls", "DEFAULT_MAX_VERSION")) + .unwrap_or_else(|| string_value("TLSv1.3")); + let min = value_to_string(min).unwrap_or_else(|| "TLSv1.2".to_string()); + let max = value_to_string(max).unwrap_or_else(|| "TLSv1.3".to_string()); + match (min.as_str(), max.as_str()) { + ("TLSv1.3", _) => 0b10, + (_, "TLSv1.2") => 0b01, + _ => 0b11, + } } fn ensure_secure_context_prototype() { @@ -461,10 +1171,41 @@ pub extern "C" fn js_tls_secure_context_new(options: f64) -> f64 { ); let context = crate::object::js_object_alloc(0, 0); crate::object::js_object_set_field(obj, 0, JSValue::from_bits(ptr_value(context).to_bits())); + if let Some(options_obj) = object_ptr(options) { + for name in [ + "ca", + "cert", + "key", + "minVersion", + "maxVersion", + "ALPNProtocols", + ] { + let value = get_field(options_obj, name); + if !JSValue::from_bits(value.to_bits()).is_undefined() { + crate::object::js_object_set_field_by_name(obj, key(name), value); + } + } + } ptr_value(obj) } -pub(crate) fn is_secure_context_instance(value: f64) -> bool { +pub(crate) unsafe fn construct_registered_tls_class( + method: &str, + args_ptr: *const f64, + args_len: usize, +) -> Option { + if !matches!(method, "Server" | "TLSSocket") { + return None; + } + let ptr = crate::value::JS_NATIVE_TLS_DISPATCH.load(std::sync::atomic::Ordering::SeqCst); + if ptr.is_null() { + return None; + } + let dispatch: crate::value::JsNativeTlsDispatchFn = std::mem::transmute(ptr); + Some(dispatch(method.as_ptr(), method.len(), args_ptr, args_len)) +} + +pub fn is_secure_context_instance(value: f64) -> bool { object_ptr(value) .map(|obj| unsafe { (*obj).class_id == CLASS_ID_TLS_SECURE_CONTEXT }) .unwrap_or(false) @@ -570,19 +1311,24 @@ fn san_entries(subject_alt_name: &str, prefix: &str) -> Vec { .collect() } -fn cert_common_names(cert: *mut ObjectHeader) -> Vec { - let subject = get_field(cert, "subject"); - let Some(subject_obj) = object_ptr(subject) else { +fn cert_common_names(cert: f64) -> Vec { + let scope = crate::gc::RuntimeHandleScope::new(); + let cert = scope.root_nanbox_f64(cert); + let Some(cert_obj) = object_ptr(cert.get_nanbox_f64()) else { return Vec::new(); }; - let cn = get_field(subject_obj, "CN"); - if let Some(array) = array_ptr(cn) { + let subject = scope.root_nanbox_f64(get_field(cert_obj, "subject")); + let Some(subject_obj) = object_ptr(subject.get_nanbox_f64()) else { + return Vec::new(); + }; + let cn = scope.root_nanbox_f64(get_field(subject_obj, "CN")); + if let Some(array) = array_ptr(cn.get_nanbox_f64()) { let len = crate::array::js_array_length(array); return (0..len) .filter_map(|index| value_to_string(crate::array::js_array_get_f64(array, index))) .collect(); } - value_to_string(cn).into_iter().collect() + value_to_string(cn.get_nanbox_f64()).into_iter().collect() } fn altname_error(host: &str, cert_value: f64, reason: String) -> f64 { @@ -590,23 +1336,51 @@ fn altname_error(host: &str, cert_value: f64, reason: String) -> f64 { "Hostname/IP does not match certificate's altnames: {}", reason ); - let message_ptr = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); - let error = crate::error::js_error_new_with_message(message_ptr); - let obj = error as *mut ObjectHeader; - let set = |name: &str, value: f64| { - crate::object::js_object_set_field_by_name(obj, key(name), value); - }; - set("code", string_value("ERR_TLS_CERT_ALTNAME_INVALID")); - set("reason", string_value(&reason)); - set("host", string_value(host)); - set("cert", cert_value); - ptr_value(error) + let scope = crate::gc::RuntimeHandleScope::new(); + let cert = scope.root_nanbox_f64(cert_value); + let message = scope.root_string_ptr(crate::string::js_string_from_bytes( + message.as_ptr(), + message.len() as u32, + )); + let fields = [ + ("code", "ERR_TLS_CERT_ALTNAME_INVALID"), + ("reason", reason.as_str()), + ("host", host), + ] + .into_iter() + .map(|(name, value)| { + ( + scope.root_string_ptr(key(name)), + scope.root_nanbox_f64(string_value(value)), + ) + }) + .collect::>(); + let cert_key = scope.root_string_ptr(key("cert")); + let error = scope.root_raw_mut_ptr( + message.with_mut_ptr(|message| crate::error::js_error_new_with_message(message)), + ); + for (field, value) in fields { + error.with_mut_ptr(|error| { + field.with_const_ptr(|field| { + crate::object::js_object_set_field_by_name(error, field, value.get_nanbox_f64()) + }) + }); + } + error.with_mut_ptr(|error| { + cert_key.with_const_ptr(|cert_key| { + crate::object::js_object_set_field_by_name(error, cert_key, cert.get_nanbox_f64()) + }) + }); + error.with_mut_ptr(|error: *mut ObjectHeader| ptr_value(error)) } #[no_mangle] pub extern "C" fn js_tls_check_server_identity(hostname: f64, cert: f64) -> f64 { - let host = host_to_string(hostname); - let Some(cert_obj) = object_ptr(cert) else { + let scope = crate::gc::RuntimeHandleScope::new(); + let hostname = scope.root_nanbox_f64(hostname); + let cert = scope.root_nanbox_f64(cert); + let host = host_to_string(hostname.get_nanbox_f64()); + let Some(cert_obj) = object_ptr(cert.get_nanbox_f64()) else { return f64::from_bits(TAG_UNDEFINED); }; let match_host = tls_domain_to_ascii(&host); @@ -633,7 +1407,7 @@ pub extern "C" fn js_tls_check_server_identity(hostname: f64, cert: f64) -> f64 } return altname_error( &host, - cert, + cert.get_nanbox_f64(), format!( "IP: {} is not in the cert's list: {}", host, @@ -642,7 +1416,7 @@ pub extern "C" fn js_tls_check_server_identity(hostname: f64, cert: f64) -> f64 ); } - let common_names = cert_common_names(cert_obj); + let common_names = cert_common_names(cert.get_nanbox_f64()); if !dns_names.is_empty() || !common_names.is_empty() { if dns_names .iter() @@ -653,7 +1427,7 @@ pub extern "C" fn js_tls_check_server_identity(hostname: f64, cert: f64) -> f64 if !dns_names.is_empty() { return altname_error( &host, - cert, + cert.get_nanbox_f64(), format!("Host: {}. is not in the cert's altnames: {}", host, san), ); } @@ -665,7 +1439,7 @@ pub extern "C" fn js_tls_check_server_identity(hostname: f64, cert: f64) -> f64 } return altname_error( &host, - cert, + cert.get_nanbox_f64(), format!( "Host: {}. is not cert's CN: {}", host, @@ -674,7 +1448,11 @@ pub extern "C" fn js_tls_check_server_identity(hostname: f64, cert: f64) -> f64 ); } - altname_error(&host, cert, "Cert does not contain a DNS name".to_string()) + altname_error( + &host, + cert.get_nanbox_f64(), + "Cert does not contain a DNS name".to_string(), + ) } // Keep-alive anchors so the auto-optimize bitcode rebuild does not dead-strip @@ -685,6 +1463,28 @@ pub extern "C" fn js_tls_check_server_identity(hostname: f64, cert: f64) -> f64 static KEEP_JS_TLS_GET_CIPHERS: extern "C" fn() -> f64 = js_tls_get_ciphers; #[cfg(feature = "keepalive-anchors")] #[used] +static KEEP_JS_TLS_GET_CERTIFICATE_COMPRESSION_ALGORITHMS: extern "C" fn() -> f64 = + js_tls_get_certificate_compression_algorithms; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_TLS_PREPARE_CONNECT: extern "C" fn() = js_tls_prepare_connect; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_TLS_VALIDATE_CONNECT_OPTIONS: extern "C" fn(f64) = js_tls_validate_connect_options; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_TLS_VALIDATE_POSITIONAL_CONNECT_OPTIONS: extern "C" fn(f64) = + js_tls_validate_positional_connect_options; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_TLS_CLIENT_CHECK_IDENTITY: extern "C" fn(i64, f64) -> f64 = + js_tls_client_check_identity; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_TLS_CLIENT_CHECK_IDENTITY_FROM_METADATA: unsafe extern "C" fn(i64) -> f64 = + js_tls_client_check_identity_from_metadata; +#[cfg(feature = "keepalive-anchors")] +#[used] static KEEP_JS_TLS_GET_CA_CERTIFICATES: extern "C" fn(f64) -> f64 = js_tls_get_ca_certificates; #[cfg(feature = "keepalive-anchors")] #[used] diff --git a/crates/perry-runtime/src/value/dyn_index.rs b/crates/perry-runtime/src/value/dyn_index.rs index cc668bcbc5..501fbf215b 100644 --- a/crates/perry-runtime/src/value/dyn_index.rs +++ b/crates/perry-runtime/src/value/dyn_index.rs @@ -529,7 +529,7 @@ pub extern "C" fn js_dyn_index_get(value: f64, index: f64) -> f64 { /// the incremented value without duplicating the IndexSet dispatch tree. /// /// Routes by the receiver's `gc_type` byte: arrays go through -/// `js_array_set_index_or_string` (numeric/string-key spec dispatch); +/// `js_array_set_index_or_string_strict` (numeric/string-key spec dispatch); /// everything else stringifies the index and routes through /// `js_object_set_field_by_name`. Strings are immutable — no-op (matches /// strict-mode `s[i] = x` semantics, close enough for the `++result[key]` @@ -753,7 +753,7 @@ pub extern "C" fn js_dyn_index_set(obj: f64, index: f64, value: f64) -> f64 { } let is_array = receiver_tag.is_some_and(|(obj_type, _)| obj_type == crate::gc::GC_TYPE_ARRAY); if is_array { - crate::array::js_array_set_index_or_string( + crate::array::js_array_set_index_or_string_strict( raw_ptr as *mut crate::array::ArrayHeader, index, value, diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 7d12084f8b..127e9223f5 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -207,6 +207,7 @@ tls = [ "dep:rustls-native-certs", "dep:rustls-pemfile", "dep:base64", + "dep:x509-cert", ] # Databases diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index dcbf1830bf..4ff875e617 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -176,6 +176,20 @@ pub unsafe extern "C" fn js_handle_prototype_dispatch(handle: i64) -> f64 { if crate::string_decoder::is_string_decoder_handle(handle) { return crate::string_decoder::string_decoder_prototype_value(); } + #[cfg(feature = "crypto")] + if crate::common::handle::with_handle::(handle, |_| true) + .unwrap_or(false) + { + let constructor = + perry_runtime::object::bound_native_callable_export_value("crypto", "X509Certificate"); + let constructor = perry_runtime::JSValue::from_bits(constructor.to_bits()); + if constructor.is_pointer() { + return perry_runtime::closure::closure_get_dynamic_prop( + constructor.as_pointer::() as usize, + "prototype", + ); + } + } f64::from_bits(perry_runtime::JSValue::undefined().bits()) } @@ -653,6 +667,19 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { } js_register_http_agent_handle_probe(http_agent_probe); } + #[cfg(all(feature = "tls", not(target_os = "ios"), not(target_os = "android")))] + { + unsafe extern "C" fn tls_handle_kind_probe(handle: i64) -> u8 { + if crate::tls::is_tls_server_handle(handle) { + 1 + } else if crate::tls::is_tls_socket_handle(handle) { + 2 + } else { + 0 + } + } + perry_runtime::object::js_register_tls_handle_kind_probe(tls_handle_kind_probe); + } js_register_worker_threads_namespace_getters( crate::worker_threads::js_worker_threads_get_worker_data, crate::worker_threads::js_worker_threads_is_main_thread, diff --git a/crates/perry-stdlib/src/crypto/util.rs b/crates/perry-stdlib/src/crypto/util.rs index be1dd1dd4e..db941f9dc4 100644 --- a/crates/perry-stdlib/src/crypto/util.rs +++ b/crates/perry-stdlib/src/crypto/util.rs @@ -490,14 +490,30 @@ pub(super) unsafe fn jwk_uint_field(obj_bits: u64, name: &[u8]) -> Option(), + key.get_raw_mut_ptr(), + val.get_nanbox_f64(), + ); } pub(super) unsafe fn set_object_value_field(obj: *mut ObjectHeader, name: &[u8], value: f64) { - let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); - js_object_set_field_by_name(obj, key, value); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let value = scope.root_nanbox_f64(value); + let key = scope.root_raw_mut_ptr(js_string_from_bytes(name.as_ptr(), name.len() as u32)); + js_object_set_field_by_name( + obj.get_raw_mut_ptr::(), + key.get_raw_mut_ptr(), + value.get_nanbox_f64(), + ); } pub(super) fn nanbox_pointer(ptr: *mut ObjectHeader) -> f64 { diff --git a/crates/perry-stdlib/src/crypto/x509.rs b/crates/perry-stdlib/src/crypto/x509.rs index 7b62959f31..08bbc9405a 100644 --- a/crates/perry-stdlib/src/crypto/x509.rs +++ b/crates/perry-stdlib/src/crypto/x509.rs @@ -533,12 +533,17 @@ fn x509_string_f64(s: &str) -> f64 { } unsafe fn x509_string_array_f64(items: &[String]) -> f64 { - let mut arr = perry_runtime::js_array_alloc(items.len() as u32); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let arr = scope.root_raw_mut_ptr(perry_runtime::js_array_alloc(items.len() as u32)); for item in items { - let s = js_string_from_bytes(item.as_ptr(), item.len() as u32); - arr = perry_runtime::js_array_push(arr, JSValue::string_ptr(s)); + let s = scope.root_raw_mut_ptr(js_string_from_bytes(item.as_ptr(), item.len() as u32)); + let grown = perry_runtime::js_array_push( + arr.get_raw_mut_ptr(), + JSValue::string_ptr(s.get_raw_mut_ptr()), + ); + arr.set_raw_mut_ptr(grown); } - nanbox_ptr(arr) + nanbox_ptr(arr.get_raw_mut_ptr::()) } fn x509_time_millis(time: &x509_cert::time::Time) -> f64 { @@ -630,13 +635,14 @@ fn x509_rsa_signature_digest(cert: &x509_cert::Certificate) -> Option f64 { let attr_count = name.iter().count() as u32; - let obj = js_object_alloc(0, attr_count); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(js_object_alloc(0, attr_count)); for atv in name.iter() { let key = x509_attr_short_name(&atv.oid.to_string()); let value = x509_attr_value(atv); - set_object_string_field(obj, key.as_bytes(), &value); + set_object_string_field(obj.get_raw_mut_ptr(), key.as_bytes(), &value); } - nanbox_ptr(obj) + nanbox_ptr(obj.get_raw_mut_ptr::()) } fn x509_rsa_public_key(cert: &x509_cert::Certificate) -> Option<(Vec, RsaPublicKey)> { @@ -676,87 +682,93 @@ unsafe fn x509_to_legacy_object(handle: &X509Handle) -> f64 { use sha1::Sha1; use sha2::{Digest, Sha256, Sha512}; - let obj = js_object_alloc(0, 20); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(js_object_alloc(0, 20)); let tbs = handle.cert.tbs_certificate(); - set_object_value_field(obj, b"subject", x509_name_legacy_object(tbs.subject())); - set_object_value_field(obj, b"issuer", x509_name_legacy_object(tbs.issuer())); + let subject = scope.root_nanbox_f64(x509_name_legacy_object(tbs.subject())); + set_object_value_field(obj.get_raw_mut_ptr(), b"subject", subject.get_nanbox_f64()); + let issuer = scope.root_nanbox_f64(x509_name_legacy_object(tbs.issuer())); + set_object_value_field(obj.get_raw_mut_ptr(), b"issuer", issuer.get_nanbox_f64()); match x509_subject_alt_name(&handle.cert) { - Some(value) => set_object_string_field(obj, b"subjectaltname", &value), - None => set_undefined_field(obj, b"subjectaltname"), + Some(value) => set_object_string_field(obj.get_raw_mut_ptr(), b"subjectaltname", &value), + None => set_undefined_field(obj.get_raw_mut_ptr(), b"subjectaltname"), } - set_undefined_field(obj, b"infoAccess"); + set_undefined_field(obj.get_raw_mut_ptr(), b"infoAccess"); set_object_value_field( - obj, + obj.get_raw_mut_ptr(), b"ca", x509_bool_f64(x509_basic_constraints_ca(&handle.cert)), ); if let Some((spki_der, public_key)) = x509_rsa_public_key(&handle.cert) { set_object_string_field( - obj, + obj.get_raw_mut_ptr(), b"modulus", &hex::encode_upper(public_key.n().to_bytes_be()), ); set_object_string_field( - obj, + obj.get_raw_mut_ptr(), b"exponent", &format!("0x{}", public_key.e().to_str_radix(16)), ); - set_object_value_field( - obj, - b"pubkey", - nanbox_ptr(alloc_buffer_from_slice(&spki_der)), - ); - set_object_value_field(obj, b"bits", public_key.n().bits() as f64); + let pubkey = scope.root_nanbox_f64(nanbox_ptr(alloc_buffer_from_slice(&spki_der))); + set_object_value_field(obj.get_raw_mut_ptr(), b"pubkey", pubkey.get_nanbox_f64()); + set_object_value_field(obj.get_raw_mut_ptr(), b"bits", public_key.n().bits() as f64); } else { - set_undefined_field(obj, b"modulus"); - set_undefined_field(obj, b"exponent"); - set_undefined_field(obj, b"pubkey"); - set_undefined_field(obj, b"bits"); + set_undefined_field(obj.get_raw_mut_ptr(), b"modulus"); + set_undefined_field(obj.get_raw_mut_ptr(), b"exponent"); + set_undefined_field(obj.get_raw_mut_ptr(), b"pubkey"); + set_undefined_field(obj.get_raw_mut_ptr(), b"bits"); } set_object_string_field( - obj, + obj.get_raw_mut_ptr(), b"valid_from", &x509_format_time(&tbs.validity().not_before), ); set_object_string_field( - obj, + obj.get_raw_mut_ptr(), b"valid_to", &x509_format_time(&tbs.validity().not_after), ); set_object_string_field( - obj, + obj.get_raw_mut_ptr(), b"fingerprint", &x509_colon_hex(&Sha1::digest(&handle.der)), ); set_object_string_field( - obj, + obj.get_raw_mut_ptr(), b"fingerprint256", &x509_colon_hex(&Sha256::digest(&handle.der)), ); set_object_string_field( - obj, + obj.get_raw_mut_ptr(), b"fingerprint512", &x509_colon_hex(&Sha512::digest(&handle.der)), ); match x509_extended_key_usage(&handle.cert) { Some(values) => { - set_object_value_field(obj, b"ext_key_usage", x509_string_array_f64(&values)) + let usages = scope.root_nanbox_f64(x509_string_array_f64(&values)); + set_object_value_field( + obj.get_raw_mut_ptr(), + b"ext_key_usage", + usages.get_nanbox_f64(), + ) } - None => set_undefined_field(obj, b"ext_key_usage"), + None => set_undefined_field(obj.get_raw_mut_ptr(), b"ext_key_usage"), } - set_object_string_field(obj, b"serialNumber", &x509_serial_number_hex(&handle.cert)); - set_object_value_field( - obj, - b"raw", - nanbox_ptr(alloc_buffer_from_slice(&handle.der)), + set_object_string_field( + obj.get_raw_mut_ptr(), + b"serialNumber", + &x509_serial_number_hex(&handle.cert), ); - set_undefined_field(obj, b"asn1Curve"); - set_undefined_field(obj, b"nistCurve"); + let raw = scope.root_nanbox_f64(nanbox_ptr(alloc_buffer_from_slice(&handle.der))); + set_object_value_field(obj.get_raw_mut_ptr(), b"raw", raw.get_nanbox_f64()); + set_undefined_field(obj.get_raw_mut_ptr(), b"asn1Curve"); + set_undefined_field(obj.get_raw_mut_ptr(), b"nistCurve"); - nanbox_ptr(obj) + nanbox_ptr(obj.get_raw_mut_ptr::()) } unsafe fn x509_asymmetric_key_meta(value: f64) -> Option<(u8, u8)> { diff --git a/crates/perry-stdlib/src/net/mod.rs b/crates/perry-stdlib/src/net/mod.rs index 34f0d1c480..b24c6b793d 100644 --- a/crates/perry-stdlib/src/net/mod.rs +++ b/crates/perry-stdlib/src/net/mod.rs @@ -37,11 +37,31 @@ use tokio::sync::{mpsc, oneshot}; use crate::common::async_bridge::spawn; +#[cfg(feature = "tls")] +mod tls_verifier; +#[cfg(feature = "tls")] +use tls_verifier::NodeConfiguredCaVerifier; + #[cfg(feature = "tls")] use std::sync::Arc; #[cfg(feature = "tls")] +use tokio_rustls::rustls::client::danger::{ + HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier, +}; +#[cfg(feature = "tls")] use tokio_rustls::{client::TlsStream, rustls, TlsConnector}; +#[cfg(feature = "tls")] +#[derive(Clone, Default)] +struct TlsClientConfigData { + ca: Option>>, + cert: Vec, + key: Vec, + alpn_protocols: Vec>, + version_mask: i32, + custom_identity: bool, +} + // ─── Transport enum (plain or TLS, swappable at runtime) ───────────────────── enum Transport { @@ -98,6 +118,7 @@ lazy_static::lazy_static! { static ref NET_SOCKETS: Mutex> = Mutex::new(HashMap::new()); static ref NET_LISTENERS: Mutex>>> = Mutex::new(HashMap::new()); static ref NET_PENDING_EVENTS: Mutex> = Mutex::new(Vec::new()); + static ref NET_PENDING_TLS_ABORTS: Mutex> = Mutex::new(std::collections::HashSet::new()); static ref NEXT_NET_ID: Mutex = Mutex::new(1); } @@ -166,15 +187,20 @@ enum SocketCommand { UpgradeTls { servername: String, verify: bool, + config: TlsClientConfigData, reply: oneshot::Sender>, }, } enum PendingNetEvent { Connect(i64), + #[cfg(feature = "tls")] + SecureConnect(i64), Data(i64, Vec), + End(i64), Close(i64), Error(i64, String), + Abort(i64), } // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -276,6 +302,189 @@ unsafe fn get_object_string_field(obj_f64: f64, field_name: &str) -> Option Option { + if !is_nanboxed_pointer(obj_f64) { + return None; + } + let obj_ptr = unbox_pointer(obj_f64) as *const perry_runtime::ObjectHeader; + if !perry_runtime::value::addr_class::is_above_handle_band(obj_ptr as usize) { + return None; + } + let key = perry_runtime::js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); + Some(f64::from_bits( + perry_runtime::js_object_get_field_by_name(obj_ptr, key).bits(), + )) +} + +#[cfg(feature = "tls")] +unsafe fn tls_value_bytes(value: f64) -> Option> { + let mut len = 0u32; + let data = perry_runtime::buffer::js_value_buffer_or_typedarray_data(value, &mut len); + if !data.is_null() { + return Some(std::slice::from_raw_parts(data, len as usize).to_vec()); + } + // `js_get_string_pointer_unified` deliberately returns the raw pointer for + // any POINTER_TAG value. Probe Buffer/TypedArray values first so their + // headers are never interpreted as StringHeaders. + let string_ptr = perry_runtime::js_get_string_pointer_unified(value); + (string_ptr != 0) + .then(|| crate::common::string_from_header(string_ptr as *const StringHeader)) + .flatten() + .map(String::into_bytes) +} + +#[cfg(feature = "tls")] +unsafe fn tls_material_list(value: f64) -> Option>> { + let js = JSValue::from_bits(value.to_bits()); + if js.is_undefined() || js.is_null() { + return Some(Vec::new()); + } + if JSValue::from_bits(perry_runtime::js_array_is_array(value).to_bits()).as_bool() { + let array = unbox_pointer(value) as *const perry_runtime::ArrayHeader; + let mut out = Vec::new(); + for index in 0..perry_runtime::js_array_length(array) { + out.extend(tls_material_list(perry_runtime::array::js_array_get_f64( + array, index, + ))?); + } + return Some(out); + } + tls_value_bytes(value).map(|bytes| vec![bytes]) +} + +#[cfg(feature = "tls")] +unsafe fn tls_option_value(options: f64, secure_context: f64, name: &str) -> Option { + get_object_value_field(options, name).and_then(|value| { + if JSValue::from_bits(value.to_bits()).is_undefined() { + get_object_value_field(secure_context, name) + } else { + Some(value) + } + }) +} + +#[cfg(feature = "tls")] +unsafe fn tls_parse_alpn(value: f64) -> Vec> { + if JSValue::from_bits(perry_runtime::js_array_is_array(value).to_bits()).as_bool() { + let array = unbox_pointer(value) as *const perry_runtime::ArrayHeader; + return (0..perry_runtime::js_array_length(array)) + .filter_map(|index| { + tls_value_bytes(perry_runtime::array::js_array_get_f64(array, index)) + }) + .collect(); + } + let Some(encoded) = tls_value_bytes(value) else { + return Vec::new(); + }; + let mut offset = 0usize; + let mut out = Vec::new(); + while offset < encoded.len() { + let len = encoded[offset] as usize; + offset += 1; + if len == 0 || offset + len > encoded.len() { + break; + } + out.push(encoded[offset..offset + len].to_vec()); + offset += len; + } + out +} + +#[cfg(feature = "tls")] +unsafe fn tls_client_config_data(options: f64) -> TlsClientConfigData { + let secure_context = get_object_value_field(options, "secureContext") + .unwrap_or_else(|| f64::from_bits(0x7FFC_0000_0000_0001)); + let mut ca = + tls_option_value(options, secure_context, "ca").and_then(|value| tls_material_list(value)); + if ca.is_none() && perry_runtime::tls::js_tls_default_ca_is_configured() != 0 { + ca = tls_material_list(perry_runtime::tls::js_tls_get_ca_certificates( + f64::from_bits(0x7FFC_0000_0000_0001), + )); + } + TlsClientConfigData { + ca, + cert: tls_option_value(options, secure_context, "cert") + .and_then(|value| tls_value_bytes(value)) + .unwrap_or_default(), + key: tls_option_value(options, secure_context, "key") + .and_then(|value| tls_value_bytes(value)) + .unwrap_or_default(), + alpn_protocols: tls_option_value(options, secure_context, "ALPNProtocols") + .map(|value| tls_parse_alpn(value)) + .unwrap_or_default(), + version_mask: perry_runtime::tls::js_tls_effective_version_mask(options), + custom_identity: tls_option_value(options, secure_context, "checkServerIdentity") + .is_some_and(|value| { + let js = JSValue::from_bits(value.to_bits()); + !js.is_undefined() && !js.is_null() + }), + } +} + +#[cfg(feature = "tls")] +fn tls_protocol_versions(mask: i32) -> Vec<&'static rustls::SupportedProtocolVersion> { + let mask = if mask == 0 { 0b11 } else { mask }; + let mut versions = Vec::new(); + if mask & 0b10 != 0 { + versions.push(&rustls::version::TLS13); + } + if mask & 0b01 != 0 { + versions.push(&rustls::version::TLS12); + } + versions +} + +#[cfg(feature = "tls")] +unsafe fn tls_signal_is_pre_aborted(options: f64) -> bool { + let Some(signal) = get_object_value_field(options, "signal") else { + return false; + }; + let signal = perry_runtime::url::js_abort_signal_resolve_ptr(signal); + if signal.is_null() { + return false; + } + perry_runtime::url::js_abort_signal_is_aborted(signal) != 0 +} + +#[cfg(feature = "tls")] +fn begin_tls_upgrade( + handle: i64, + servername: String, + verify: bool, + config: TlsClientConfigData, +) -> Result<(), String> { + let cmd_tx = NET_SOCKETS + .lock() + .unwrap() + .get(&handle) + .map(|socket| socket.cmd_tx.clone()) + .ok_or_else(|| "socket is closed".to_string())?; + let (reply, _reply_rx) = oneshot::channel(); + cmd_tx + .send(SocketCommand::UpgradeTls { + servername, + verify, + config, + reply, + }) + .map_err(|_| "socket task is gone".to_string()) +} + +#[cfg(feature = "tls")] +unsafe fn tls_preflight(port: u16, servername: &str, options: f64) -> i32 { + crate::tls::js_tls_client_preflight(port as f64, servername.as_ptr(), servername.len(), options) +} + +#[cfg(feature = "tls")] +fn tls_preflight_error(code: i32) -> &'static str { + match code { + 1 => "ERR_TLS_ALPN_CALLBACK_INVALID_RESULT", + 2 => "ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL", + 3 => "ERR_TLS_SNI_CALLBACK_FAILED", + _ => "ERR_TLS_HANDSHAKE_FAILED", + } +} + unsafe fn get_object_number_field(obj_f64: f64, field_name: &str) -> Option { if !is_nanboxed_pointer(obj_f64) { return None; @@ -334,17 +543,21 @@ unsafe fn get_object_bool_field(obj_f64: f64, field_name: &str) -> Option /// mirrors `crates/perry-stdlib/src/sqlite.rs::build_packed_keys`. unsafe fn build_error_object(msg: &str) -> f64 { use perry_runtime::JSValue; - let name = b"message"; - let packed: Vec = name.to_vec(); + let keys = ["message", "code", "name"]; + let mut packed = Vec::new(); + for key in keys { + packed.extend_from_slice(key.as_bytes()); + packed.push(0); + } let mut shape_id: u32 = 0x4E45_0000; // "NE" — net error - for &b in name { + for &b in &packed { shape_id = shape_id.wrapping_mul(31).wrapping_add(b as u32); } - shape_id = shape_id.wrapping_add(1); + shape_id = shape_id.wrapping_add(3); let s_msg = perry_runtime::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let obj = perry_runtime::js_object_alloc_with_shape( shape_id, - 1, + 3, packed.as_ptr(), packed.len() as u32, ); @@ -352,6 +565,24 @@ unsafe fn build_error_object(msg: &str) -> f64 { return f64::from_bits(0x7FFF_0000_0000_0000u64 | (s_msg as u64 & 0x0000_FFFF_FFFF_FFFF)); } perry_runtime::js_object_set_field(obj, 0, JSValue::string_ptr(s_msg)); + let code = if msg.starts_with("ERR_") { + Some(msg) + } else if msg.contains("UnknownIssuer") + || msg.contains("unknown issuer") + || msg.contains("invalid peer certificate") + { + Some("DEPTH_ZERO_SELF_SIGNED_CERT") + } else if msg.to_ascii_lowercase().contains("connection refused") { + Some("ECONNREFUSED") + } else { + None + }; + if let Some(code) = code { + let code = perry_runtime::js_string_from_bytes(code.as_ptr(), code.len() as u32); + perry_runtime::js_object_set_field(obj, 1, JSValue::string_ptr(code)); + } + let name = perry_runtime::js_string_from_bytes(b"Error".as_ptr(), 5); + perry_runtime::js_object_set_field(obj, 2, JSValue::string_ptr(name)); let obj_bits = (obj as u64 & 0x0000_FFFF_FFFF_FFFF) | 0x7FFD_0000_0000_0000; f64::from_bits(obj_bits) } @@ -370,6 +601,23 @@ fn push_event(ev: PendingNetEvent) { perry_runtime::event_pump::js_notify_main_thread(); } +#[cfg(feature = "tls")] +fn fire_pending_tls_abort(handle: i64) { + if NET_PENDING_TLS_ABORTS.lock().unwrap().remove(&handle) { + push_event(PendingNetEvent::Abort(handle)); + push_event(PendingNetEvent::Close(handle)); + } +} + +#[cfg(feature = "tls")] +unsafe fn schedule_tls_abort(handle: i64) { + NET_PENDING_TLS_ABORTS.lock().unwrap().insert(handle); + crate::common::async_bridge::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + fire_pending_tls_abort(handle); + }); +} + fn mark_closed(id: i64) { if let Some(s) = NET_SOCKETS.lock().unwrap().get_mut(&id) { s.is_open = false; @@ -379,7 +627,10 @@ fn mark_closed(id: i64) { // ─── rustls config (TLS feature only) ──────────────────────────────────────── #[cfg(feature = "tls")] -fn build_tls_connector(verify: bool) -> Result { +fn build_tls_connector( + verify: bool, + data: Option<&TlsClientConfigData>, +) -> Result { // rustls panics resolving the process-level CryptoProvider when both // `ring` and `aws-lc-rs` end up in the dep graph. Server paths install // one before their first handshake; a client-only program (no tls/https @@ -388,7 +639,7 @@ fn build_tls_connector(verify: bool) -> Result { // `install_default` errors (ignored) if a provider is already set. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); if !verify { - return build_tls_connector_insecure(); + return build_tls_connector_insecure(data); } // System trust store. Aligns with Perry's broader rustls-only stance // (reqwest / tokio-tungstenite / mongodb all use rustls) — no OpenSSL. @@ -396,24 +647,106 @@ fn build_tls_connector(verify: bool) -> Result { // rustls-native-certs 0.8 returns a CertificateResult with separate // `.certs` and `.errors` fields; we accept per-cert failures rather // than bail, matching the crate's own documented pattern. - let native = rustls_native_certs::load_native_certs(); - for cert in native.certs { - let _ = root_store.add(cert); + if let Some(ca) = data.and_then(|data| data.ca.as_ref()) { + add_pem_roots(&mut root_store, ca); + } else { + let native = rustls_native_certs::load_native_certs(); + for cert in native.certs { + let _ = root_store.add(cert); + } + } + let configured = configured_ca_certificates(data); + let custom_identity = data.is_some_and(|data| data.custom_identity); + let node_verifier = if configured.is_empty() && !custom_identity { + None + } else { + Some(NodeConfiguredCaVerifier { + inner: rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store.clone())) + .build() + .map_err(|error| format!("tls certificate verifier: {error}"))?, + roots: root_store.clone(), + configured, + custom_identity, + }) + }; + let versions = tls_protocol_versions(data.map_or(0b11, |data| data.version_mask)); + let builder = rustls::ClientConfig::builder_with_provider( + rustls::crypto::aws_lc_rs::default_provider().into(), + ) + .with_protocol_versions(&versions) + .map_err(|error| format!("tls protocol versions: {error}"))? + .with_root_certificates(root_store); + let mut config = if let Some((certs, key)) = data.and_then(client_auth_material) { + builder + .with_client_auth_cert(certs, key) + .map_err(|error| format!("tls client certificate: {error}"))? + } else { + builder.with_no_client_auth() + }; + if let Some(data) = data { + config.alpn_protocols = data.alpn_protocols.clone(); + } + if let Some(verifier) = node_verifier { + config + .dangerous() + .set_certificate_verifier(Arc::new(verifier)); } - let config = rustls::ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); Ok(TlsConnector::from(Arc::new(config))) } +#[cfg(feature = "tls")] +fn add_pem_roots(store: &mut rustls::RootCertStore, materials: &[Vec]) { + for material in materials { + let mut cursor = std::io::Cursor::new(material); + for cert in rustls_pemfile::certs(&mut cursor).flatten() { + let _ = store.add(cert); + } + } +} + +#[cfg(feature = "tls")] +fn configured_ca_certificates(data: Option<&TlsClientConfigData>) -> Vec> { + data.and_then(|data| data.ca.as_ref()) + .into_iter() + .flatten() + .flat_map(|material| { + let mut cursor = std::io::Cursor::new(material); + rustls_pemfile::certs(&mut cursor) + .flatten() + .map(|cert| cert.as_ref().to_vec()) + .collect::>() + }) + .collect() +} + +#[cfg(feature = "tls")] +fn client_auth_material( + data: &TlsClientConfigData, +) -> Option<( + Vec>, + rustls::pki_types::PrivateKeyDer<'static>, +)> { + let mut cert_cursor = std::io::Cursor::new(&data.cert); + let certs: Vec<_> = rustls_pemfile::certs(&mut cert_cursor).flatten().collect(); + if certs.is_empty() { + return None; + } + let mut key_cursor = std::io::Cursor::new(&data.key); + let key = rustls_pemfile::private_key(&mut key_cursor) + .ok() + .flatten()?; + Some((certs, key)) +} + /// Insecure TLS — accept any server cert without verifying chain or hostname. /// Maps to Postgres `sslmode=require` (encryption without auth) and is the /// right default for local dev against self-signed certs. Real deployments /// should pass `verify: true` (the default) so the system trust store and /// hostname validation apply. #[cfg(feature = "tls")] -fn build_tls_connector_insecure() -> Result { - use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +fn build_tls_connector_insecure( + data: Option<&TlsClientConfigData>, +) -> Result { use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{DigitallySignedStruct, SignatureScheme}; @@ -462,10 +795,24 @@ fn build_tls_connector_insecure() -> Result { } } - let config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerify)) - .with_no_client_auth(); + let versions = tls_protocol_versions(data.map_or(0b11, |data| data.version_mask)); + let builder = rustls::ClientConfig::builder_with_provider( + rustls::crypto::aws_lc_rs::default_provider().into(), + ) + .with_protocol_versions(&versions) + .map_err(|error| format!("tls protocol versions: {error}"))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)); + let mut config = if let Some((certs, key)) = data.and_then(client_auth_material) { + builder + .with_client_auth_cert(certs, key) + .map_err(|error| format!("tls client certificate: {error}"))? + } else { + builder.with_no_client_auth() + }; + if let Some(data) = data { + config.alpn_protocols = data.alpn_protocols.clone(); + } Ok(TlsConnector::from(Arc::new(config))) } @@ -667,6 +1014,7 @@ pub unsafe extern "C" fn js_net_socket_method_connect(handle: i64, port: f64, ho #[cfg(feature = "tls")] #[no_mangle] pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { + perry_runtime::tls::js_tls_prepare_connect(); extern "C" { fn js_value_is_closure(value_bits: i64) -> i32; } @@ -685,7 +1033,7 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f (j.is_bool() && !j.to_bool()) || (j.is_number() && j.as_number() == 0.0) }; - let (host, port, servername, verify, cb_f64); + let (host, port, servername, verify, cb_f64, metadata_options); if let Some(h) = as_string(arg1) { // Legacy Perry positional: (host, port, servername?, verify?). let p = JSValue::from_bits(arg2.to_bits()); @@ -697,8 +1045,60 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f host = h; verify = !explicitly_off(arg4); cb_f64 = None; + metadata_options = f64::from_bits(0x7FFC_0000_0000_0001); } else if is_nanboxed_pointer(arg1) && !is_closure(arg1) { // Node options form: tls.connect(options[, callback]). + perry_runtime::tls::js_tls_validate_connect_options(arg1); + if let Some(socket_value) = get_object_value_field(arg1, "socket") { + let socket_js = JSValue::from_bits(socket_value.to_bits()); + let handle = if socket_js.is_pointer() { + unbox_pointer(socket_value) as i64 + } else { + 0 + }; + if handle != 0 { + host = get_object_string_field(arg1, "host") + .or_else(|| get_object_string_field(arg1, "hostname")) + .unwrap_or_else(|| "localhost".to_string()); + servername = + get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); + verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); + cb_f64 = is_closure(arg2).then_some(arg2); + metadata_options = arg1; + let config = tls_client_config_data(metadata_options); + perry_runtime::tls::js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + if let Some(callback) = cb_f64 { + let callback = unbox_pointer(callback) as i64; + if callback != 0 { + NET_LISTENERS + .lock() + .unwrap() + .entry(handle) + .or_default() + .entry("secureConnect".to_string()) + .or_default() + .push(callback); + } + } + let preflight = tls_preflight(0, &servername, metadata_options); + if preflight != 0 { + push_event(PendingNetEvent::Error( + handle, + tls_preflight_error(preflight).to_string(), + )); + push_event(PendingNetEvent::Close(handle)); + } else if let Err(error) = begin_tls_upgrade(handle, servername, verify, config) { + push_event(PendingNetEvent::Error(handle, error)); + push_event(PendingNetEvent::Close(handle)); + } + return handle; + } + } port = match get_object_number_field(arg1, "port") { Some(p) => { perry_runtime::net_validate::js_net_validate_connect_port(p); @@ -715,10 +1115,18 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f servername = get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); cb_f64 = if is_closure(arg2) { Some(arg2) } else { None }; - } else if JSValue::from_bits(arg1.to_bits()).is_number() { + metadata_options = arg1; + } else if JSValue::from_bits(arg1.to_bits()).is_number() + || JSValue::from_bits(arg1.to_bits()).is_int32() + { // Node positional form: tls.connect(port[, host][, options][, cb]). perry_runtime::net_validate::js_net_validate_connect_port(arg1); - port = arg1 as u16; + let port_value = JSValue::from_bits(arg1.to_bits()); + port = if port_value.is_int32() { + port_value.as_int32() as u16 + } else { + arg1 as u16 + }; let mut opt_host: Option = None; let mut opts: Option = None; let mut cb: Option = None; @@ -735,6 +1143,9 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f opts = opts.or(Some(v)); } } + if let Some(options) = opts { + perry_runtime::tls::js_tls_validate_positional_connect_options(options); + } host = opt_host .or_else(|| { opts.and_then(|o| { @@ -751,11 +1162,46 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f .and_then(|o| get_object_bool_field(o, "rejectUnauthorized")) .unwrap_or(true); cb_f64 = cb; + metadata_options = opts.unwrap_or_else(|| f64::from_bits(0x7FFC_0000_0000_0001)); } else { return 0; } - let handle = spawn_socket_task(host, port, Some((servername, verify))); + let config = tls_client_config_data(metadata_options); + if tls_signal_is_pre_aborted(metadata_options) { + let handle = js_net_socket_alloc(); + perry_runtime::tls::js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + schedule_tls_abort(handle); + return handle; + } + let preflight = tls_preflight(port, &servername, metadata_options); + if preflight != 0 { + let handle = js_net_socket_alloc(); + perry_runtime::tls::js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + push_event(PendingNetEvent::Error( + handle, + tls_preflight_error(preflight).to_string(), + )); + push_event(PendingNetEvent::Close(handle)); + return handle; + } + let handle = spawn_socket_task(host, port, Some((servername.clone(), verify, config))); + perry_runtime::tls::js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); crate::tls::record_tls_client_handle(handle); if let Some(cb) = cb_f64 { if handle != 0 { @@ -778,7 +1224,11 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f /// Internal: allocate the handle, spawn the tokio task. /// `direct_tls` = Some((servername, verify)) runs a TLS handshake before /// firing 'connect'; None keeps the socket in plain TCP mode. -fn spawn_socket_task(host: String, port: u16, direct_tls: Option<(String, bool)>) -> i64 { +fn spawn_socket_task( + host: String, + port: u16, + direct_tls: Option<(String, bool, TlsClientConfigData)>, +) -> i64 { ensure_gc_scanner_registered(); let id = next_id(); let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -809,15 +1259,20 @@ fn spawn_socket_task(host: String, port: u16, direct_tls: Option<(String, bool)> // Direct-TLS path: run the TLS handshake before signalling connect. let transport = match direct_tls { #[cfg(feature = "tls")] - Some((servername, verify)) => match do_tls_handshake(tcp, &servername, verify).await { - Ok(tls) => Transport::Tls(Box::new(tls)), - Err(e) => { - push_event(PendingNetEvent::Error(id, e)); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - return; + Some((servername, verify, config)) => { + match do_tls_handshake(tcp, &servername, verify, Some(&config)).await { + Ok(tls) => { + record_tls_handshake(id, &tls, verify, Some(&config)); + Transport::Tls(Box::new(tls)) + } + Err(e) => { + push_event(PendingNetEvent::Error(id, e)); + push_event(PendingNetEvent::Close(id)); + mark_closed(id); + return; + } } - }, + } #[cfg(not(feature = "tls"))] Some(_) => { push_event(PendingNetEvent::Error( @@ -847,8 +1302,9 @@ async fn do_tls_handshake( tcp: TcpStream, servername: &str, verify: bool, + data: Option<&TlsClientConfigData>, ) -> Result, String> { - let connector = build_tls_connector(verify)?; + let connector = build_tls_connector(verify, data)?; let server_name = rustls::pki_types::ServerName::try_from(servername.to_string()) .map_err(|e| format!("invalid servername '{}': {}", servername, e))?; connector @@ -857,6 +1313,71 @@ async fn do_tls_handshake( .map_err(|e| format!("tls handshake: {}", e)) } +#[cfg(feature = "tls")] +fn record_tls_handshake( + handle: i64, + stream: &TlsStream, + verify: bool, + data: Option<&TlsClientConfigData>, +) { + let connection = stream.get_ref().1; + let protocol = match connection.protocol_version() { + Some(rustls::ProtocolVersion::TLSv1_2) => "TLSv1.2", + Some(rustls::ProtocolVersion::TLSv1_3) => "TLSv1.3", + _ => "", + }; + let alpn = connection.alpn_protocol().unwrap_or_default(); + let peer = connection + .peer_certificates() + .and_then(|certs| certs.first()) + .map(|cert| cert.as_ref()) + .unwrap_or_default(); + let trusted_by_configured_ca = + data.and_then(|data| data.ca.as_ref()) + .is_some_and(|materials| { + materials.iter().any(|material| { + let mut cursor = std::io::Cursor::new(material); + let trusted = rustls_pemfile::certs(&mut cursor) + .flatten() + .any(|cert| cert.as_ref() == peer); + trusted + }) + }); + let authorized = verify || trusted_by_configured_ca; + let authorization_error = if authorized { + "" + } else { + "DEPTH_ZERO_SELF_SIGNED_CERT" + }; + let own_certificate = data + .map(|data| { + let mut cursor = std::io::Cursor::new(&data.cert); + let certificate = rustls_pemfile::certs(&mut cursor) + .flatten() + .next() + .map(|cert| cert.as_ref().to_vec()) + .unwrap_or_default(); + certificate + }) + .unwrap_or_default(); + unsafe { + perry_runtime::tls::js_tls_client_record_connected( + handle, + authorized as i32, + authorization_error.as_ptr(), + authorization_error.len(), + protocol.as_ptr(), + protocol.len(), + alpn.as_ptr(), + alpn.len(), + peer.as_ptr(), + peer.len(), + own_certificate.as_ptr(), + own_certificate.len(), + ); + } +} + /// The read/write/command loop. Shared by plain-TCP and direct-TLS paths. async fn run_socket_task( id: i64, @@ -876,6 +1397,12 @@ async fn run_socket_task( read_result = t.read(&mut buf) => { match read_result { Ok(0) => { + // Node's default `allowHalfOpen: false` closes the + // writable side after peer EOF. On TLS transports this + // also sends close_notify instead of making the peer + // report an unclean close without an `end` event. + let _ = t.shutdown().await; + push_event(PendingNetEvent::End(id)); push_event(PendingNetEvent::Close(id)); mark_closed(id); break; @@ -910,7 +1437,7 @@ async fn run_socket_task( break; } #[cfg(feature = "tls")] - Some(SocketCommand::UpgradeTls { servername, verify, reply }) => { + Some(SocketCommand::UpgradeTls { servername, verify, config, reply }) => { // Take the plain TcpStream out of the enum, run the // handshake, and put a TlsStream back under the same id. // Done inline (blocks reads until handshake completes), @@ -918,11 +1445,13 @@ async fn run_socket_task( let old = transport.take(); match old { Some(Transport::Plain(tcp)) => { - match do_tls_handshake(tcp, &servername, verify).await { + match do_tls_handshake(tcp, &servername, verify, Some(&config)).await { Ok(tls) => { + record_tls_handshake(id, &tls, verify, Some(&config)); transport = Some(Transport::Tls(Box::new(tls))); crate::tls::record_tls_client_handle(id); let _ = reply.send(Ok(())); + push_event(PendingNetEvent::SecureConnect(id)); } Err(e) => { let _ = reply.send(Err(e.clone())); @@ -1039,9 +1568,89 @@ pub unsafe extern "C" fn js_net_socket_on(handle: i64, event_ptr: i64, cb: i64) Some(e) => e, None => return, }; - let mut listeners = NET_LISTENERS.lock().unwrap(); - let entry = listeners.entry(handle).or_default(); - entry.entry(event).or_default().push(cb); + { + let mut listeners = NET_LISTENERS.lock().unwrap(); + let entry = listeners.entry(handle).or_default(); + entry.entry(event.clone()).or_default().push(cb); + } + #[cfg(feature = "tls")] + if event == "close" { + fire_pending_tls_abort(handle); + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { + // Net events in this transport are terminal or edge-triggered for the + // lifecycle cases TLSSocket uses. Register in the same provider map; the + // external provider supplies its full once-flag implementation when it is + // linked ahead of this bundled fallback. + js_net_socket_on(handle, event_ptr, cb); + handle +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_remove_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + if let Some(event) = string_from_header_i64(event_ptr) { + if let Some(callbacks) = NET_LISTENERS + .lock() + .unwrap() + .get_mut(&handle) + .and_then(|events| events.get_mut(&event)) + { + if let Some(index) = callbacks.iter().position(|candidate| *candidate == cb) { + callbacks.remove(index); + } + } + } + handle +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_remove_all_listeners(handle: i64, event_ptr: i64) -> i64 { + let mut all = NET_LISTENERS.lock().unwrap(); + if event_ptr == 0 { + all.entry(handle).or_default().clear(); + } else if let Some(event) = string_from_header_i64(event_ptr) { + all.entry(handle).or_default().remove(&event); + } + handle +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64 { + let Some(event) = string_from_header_i64(event_ptr) else { + return 0.0; + }; + NET_LISTENERS + .lock() + .unwrap() + .get(&handle) + .and_then(|events| events.get(&event)) + .map(|callbacks| callbacks.len() as f64) + .unwrap_or(0.0) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_event_names(handle: i64) -> *mut StringHeader { + let names = NET_LISTENERS + .lock() + .unwrap() + .get(&handle) + .map(|events| { + events + .iter() + .filter(|(_, callbacks)| !callbacks.is_empty()) + .map(|(name, _)| format!("\"{}\"", name.replace('"', "\\\""))) + .collect::>() + }) + .unwrap_or_default(); + let json = format!("[{}]", names.join(",")); + perry_runtime::js_string_from_bytes(json.as_ptr(), json.len() as u32) } // ─── FFI: socket.upgradeToTLS(servername) -> Promise ───────────────────────── @@ -1097,6 +1706,7 @@ pub unsafe extern "C" fn js_net_socket_upgrade_tls( .send(SocketCommand::UpgradeTls { servername, verify, + config: TlsClientConfigData::default(), reply: reply_tx, }) .is_err() @@ -1140,6 +1750,34 @@ pub unsafe extern "C" fn js_net_socket_upgrade_tls( /// per-thread scratch buffer (moved out across dispatch so a re-entrant /// pump from inside a user callback is safe; capacity retained → zero /// steady-state allocation). +unsafe fn emit_socket_no_arg(handle: i64, event: &str) { + let receiver = f64::from_bits(0x7FFD_0000_0000_0000 | (handle as u64 & 0x0000_FFFF_FFFF_FFFF)); + let previous_this = perry_runtime::object::js_implicit_this_set(receiver); + for callback in listeners_for(handle, event) { + if callback != 0 { + js_closure_call0(callback as *const ClosureHeader); + } + } + perry_runtime::object::js_implicit_this_set(previous_this); +} + +#[cfg(feature = "tls")] +unsafe fn emit_tls_secure_connect(handle: i64) { + let identity_error = perry_runtime::tls::js_tls_client_check_identity_from_metadata(handle); + if !JSValue::from_bits(identity_error.to_bits()).is_undefined() { + for callback in listeners_for(handle, "error") { + if callback != 0 { + js_closure_call1(callback as *const ClosureHeader, identity_error); + } + } + if let Some(socket) = NET_SOCKETS.lock().unwrap().get(&handle) { + let _ = socket.cmd_tx.send(SocketCommand::Destroy); + } + return; + } + emit_socket_no_arg(handle, "secureConnect"); +} + #[no_mangle] pub unsafe extern "C" fn js_net_process_pending() -> i32 { thread_local! { @@ -1157,17 +1795,14 @@ pub unsafe extern "C" fn js_net_process_pending() -> i32 { for ev in events.drain(..) { match ev { PendingNetEvent::Connect(id) => { - for cb in listeners_for(id, "connect") { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); - } - } - for cb in listeners_for(id, "secureConnect") { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); - } + emit_socket_no_arg(id, "connect"); + #[cfg(feature = "tls")] + if perry_runtime::tls::js_tls_client_is_connected(id) != 0 { + emit_tls_secure_connect(id); } } + #[cfg(feature = "tls")] + PendingNetEvent::SecureConnect(id) => emit_tls_secure_connect(id), PendingNetEvent::Data(id, bytes) => { let cbs = listeners_for(id, "data"); if cbs.is_empty() { @@ -1205,7 +1840,17 @@ pub unsafe extern "C" fn js_net_process_pending() -> i32 { } } } + PendingNetEvent::Abort(id) => { + let error = perry_runtime::url::js_abort_error_value(); + for callback in listeners_for(id, "error") { + if callback != 0 { + js_closure_call1(callback as *const ClosureHeader, error); + } + } + } + PendingNetEvent::End(id) => emit_socket_no_arg(id, "end"), PendingNetEvent::Close(id) => { + perry_runtime::tls::js_tls_client_record_closed(id); for cb in listeners_for(id, "close") { if cb != 0 { js_closure_call0(cb as *const ClosureHeader); diff --git a/crates/perry-stdlib/src/net/tls_verifier.rs b/crates/perry-stdlib/src/net/tls_verifier.rs new file mode 100644 index 0000000000..71cf2293fb --- /dev/null +++ b/crates/perry-stdlib/src/net/tls_verifier.rs @@ -0,0 +1,98 @@ +//! Node-compatible rustls server-certificate verification. + +use super::*; + +#[cfg(feature = "tls")] +#[derive(Debug)] +pub(super) struct NodeConfiguredCaVerifier { + pub(super) inner: Arc, + pub(super) roots: rustls::RootCertStore, + pub(super) configured: Vec>, + pub(super) custom_identity: bool, +} + +#[cfg(feature = "tls")] +fn is_ca_used_as_end_entity(error: &rustls::Error) -> bool { + let rustls::Error::InvalidCertificate(rustls::CertificateError::Other(other)) = error else { + return false; + }; + other.0.to_string() == "CaUsedAsEndEntity" +} + +#[cfg(feature = "tls")] +impl ServerCertVerifier for NodeConfiguredCaVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + if self.custom_identity { + let parsed = rustls::server::ParsedCertificate::try_from(end_entity)?; + let provider = rustls::crypto::aws_lc_rs::default_provider(); + match rustls::client::verify_server_cert_signed_by_trust_anchor( + &parsed, + &self.roots, + intermediates, + now, + provider.signature_verification_algorithms.all, + ) { + Ok(()) => return Ok(ServerCertVerified::assertion()), + Err(error) + if is_ca_used_as_end_entity(&error) + && self + .configured + .iter() + .any(|cert| cert.as_slice() == end_entity.as_ref()) => + { + return Ok(ServerCertVerified::assertion()); + } + Err(error) => return Err(error), + } + } + match self.inner.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + ) { + Err(error) + if is_ca_used_as_end_entity(&error) + && self + .configured + .iter() + .any(|cert| cert.as_slice() == end_entity.as_ref()) => + { + let parsed = rustls::server::ParsedCertificate::try_from(end_entity)?; + rustls::client::verify_server_name(&parsed, server_name)?; + Ok(ServerCertVerified::assertion()) + } + result => result, + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} diff --git a/crates/perry-stdlib/src/tls.rs b/crates/perry-stdlib/src/tls.rs index b6320f4fc9..2b600de645 100644 --- a/crates/perry-stdlib/src/tls.rs +++ b/crates/perry-stdlib/src/tls.rs @@ -14,9 +14,9 @@ use std::task::{Context, Poll}; use crate::common::string_from_header; use perry_runtime::{ js_array_alloc, js_array_is_array, js_array_push, js_closure_call0, js_closure_call1, - js_get_string_pointer_unified, js_nanbox_pointer, js_object_alloc, js_object_get_field_by_name, - js_object_set_field_by_name, js_string_from_bytes, ClosureHeader, JSValue, ObjectHeader, - StringHeader, + js_closure_call2, js_get_string_pointer_unified, js_nanbox_pointer, js_object_alloc, + js_object_get_field_by_name, js_object_set_field_by_name, js_string_from_bytes, ClosureHeader, + JSValue, ObjectHeader, StringHeader, }; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; @@ -27,10 +27,10 @@ use tokio_rustls::{rustls, server::TlsStream as ServerTlsStream, TlsAcceptor}; const TAG_UNDEFINED_BITS: u64 = 0x7FFC_0000_0000_0001; const TLS_DISPATCH_MISSING_BITS: u64 = TAG_UNDEFINED_BITS; +mod client_verifier; mod dispatch; mod module_api; -mod secure_context; - +mod socket_api; // Re-export the handle-dispatch and module-level entry points so // `crate::tls::…` (and the `pub use tls::*` glob in `lib.rs`) keep resolving // them exactly as before the split. @@ -41,6 +41,15 @@ pub use module_api::{ js_tls_root_certificates, js_tls_secure_context_constructor, js_tls_set_default_ca_certificates, }; +pub use socket_api::{ + js_tls_socket_export_keying_material, js_tls_socket_get_certificate, js_tls_socket_get_cipher, + js_tls_socket_get_ephemeral_key_info, js_tls_socket_get_finished, + js_tls_socket_get_peer_certificate, js_tls_socket_get_peer_finished, + js_tls_socket_get_peer_x509_certificate, js_tls_socket_get_protocol, js_tls_socket_get_session, + js_tls_socket_get_shared_sigalgs, js_tls_socket_get_x509_certificate, + js_tls_socket_is_session_reused, js_tls_socket_set_key_cert, + js_tls_socket_set_max_send_fragment, +}; static NEXT_TLS_HANDLE_ID: OnceLock> = OnceLock::new(); static TLS_SERVERS: OnceLock>> = OnceLock::new(); @@ -61,8 +70,75 @@ struct TlsServerState { bound_port: u16, bound_host: String, listening: bool, + active_connections: usize, + closing: bool, + close_event_queued: bool, config: Option>, ticket_keys: Vec, + allow_half_open: bool, + pause_on_connect: bool, + certificate: Vec, + cert_resolver: Option>, + sni_callback: i64, + alpn_callback: i64, + alpn_protocols: Vec>, + sni_errors: HashMap, +} + +#[derive(Debug)] +struct DynamicCertResolver { + default: Mutex<(Arc, Vec)>, + contexts: Mutex, Vec)>>, +} + +impl DynamicCertResolver { + fn name_matches(pattern: &str, servername: &str) -> bool { + let pattern = pattern.trim_end_matches('.').to_ascii_lowercase(); + let servername = servername.trim_end_matches('.').to_ascii_lowercase(); + if let Some(suffix) = pattern.strip_prefix("*.") { + return servername.strip_suffix(suffix).is_some_and(|prefix| { + prefix.ends_with('.') && !prefix[..prefix.len() - 1].contains('.') + }); + } + pattern == servername + } + + fn selected(&self, servername: Option<&str>) -> (Arc, Vec) { + if let Some(servername) = servername { + if let Some((_, key, der)) = self + .contexts + .lock() + .unwrap() + .iter() + .rev() + .find(|(pattern, _, _)| Self::name_matches(pattern, servername)) + { + return (key.clone(), der.clone()); + } + } + self.default.lock().unwrap().clone() + } +} + +impl rustls::server::ResolvesServerCert for DynamicCertResolver { + fn resolve( + &self, + client_hello: rustls::server::ClientHello<'_>, + ) -> Option> { + Some(self.selected(client_hello.server_name()).0) + } +} + +#[derive(Debug)] +struct EmptyCertResolver; + +impl rustls::server::ResolvesServerCert for EmptyCertResolver { + fn resolve( + &self, + _client_hello: rustls::server::ClientHello<'_>, + ) -> Option> { + None + } } struct TlsSocketState { @@ -74,6 +150,15 @@ struct TlsSocketState { authorized: bool, server_side: bool, max_send_fragment: usize, + allow_half_open: bool, + locally_constructed: bool, + authorization_error: Option, + protocol: Option, + alpn_protocol: Option, + servername: Option, + peer_certificate: Vec, + own_certificate: Vec, + server_handle: Option, } enum TlsSocketCommand { @@ -87,7 +172,9 @@ enum PendingTlsEvent { ServerSecureConnection(i64, i64), ServerClose(i64), ServerError(i64, String), + ServerTlsClientError(i64, i64, String, Option), SocketData(i64, Vec), + SocketEnd(i64), SocketClose(i64), SocketError(i64, String), } @@ -213,6 +300,17 @@ fn throw_type_error(message: &str, code: &'static str) -> ! { perry_runtime::fs::validate::throw_type_error_with_code(message, code) } +fn throw_plain_type_error(message: &str) -> ! { + let msg = perry_runtime::js_string_from_bytes(message.as_ptr(), message.len() as u32); + let err = perry_runtime::error::js_typeerror_new(msg); + perry_runtime::exception::js_throw(js_nanbox_pointer(err as i64)) +} + +fn is_closure_value(value: f64) -> bool { + let js = JSValue::from_bits(value.to_bits()); + js.is_pointer() && perry_runtime::closure::is_closure_ptr(js.as_pointer::() as usize) +} + fn throw_error(message: &str, code: &'static str) -> ! { { let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32); @@ -231,15 +329,6 @@ unsafe fn string_array(items: &[String]) -> *mut perry_runtime::ArrayHeader { arr } -unsafe fn static_string_array(items: &[&str]) -> *mut perry_runtime::ArrayHeader { - let mut arr = js_array_alloc(items.len() as u32); - for item in items { - let s = js_string_from_bytes(item.as_ptr(), item.len() as u32); - arr = js_array_push(arr, JSValue::string_ptr(s)); - } - arr -} - fn servers() -> &'static Mutex> { TLS_SERVERS.get_or_init(|| Mutex::new(HashMap::new())) } @@ -302,6 +391,16 @@ fn scan_tls_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { } } } + if let Ok(mut all) = servers().lock() { + for server in all.values_mut() { + if server.sni_callback != 0 { + visitor.visit_i64_slot(&mut server.sni_callback); + } + if server.alpn_callback != 0 { + visitor.visit_i64_slot(&mut server.alpn_callback); + } + } + } } fn push_tls_event(event: PendingTlsEvent) { @@ -309,6 +408,65 @@ fn push_tls_event(event: PendingTlsEvent) { perry_runtime::event_pump::js_notify_main_thread(); } +fn schedule_tls_socket_close(socket_id: i64) { + crate::common::async_bridge::spawn(async move { + // Node emits readable `end` before the socket's terminal `close` turn. + // The boundary also lets `server.close()` observe the connection count + // reaching zero and queue its callback between those socket events. + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + push_tls_event(PendingTlsEvent::SocketClose(socket_id)); + }); +} + +fn tls_server_connection_started(server_id: i64) -> bool { + let mut all = servers().lock().unwrap(); + let Some(server) = all.get_mut(&server_id) else { + return false; + }; + if server.closing || server.close_event_queued { + return false; + } + server.active_connections += 1; + true +} + +fn tls_server_connection_finished(server_id: i64) { + let emit_close = { + let mut all = servers().lock().unwrap(); + let Some(server) = all.get_mut(&server_id) else { + return; + }; + server.active_connections = server.active_connections.saturating_sub(1); + let emit = server.closing && server.active_connections == 0 && !server.close_event_queued; + if emit { + server.close_event_queued = true; + } + emit + }; + if emit_close { + push_tls_event(PendingTlsEvent::ServerClose(server_id)); + } +} + +fn tls_server_begin_close(server_id: i64) { + let emit_close = { + let mut all = servers().lock().unwrap(); + let Some(server) = all.get_mut(&server_id) else { + return; + }; + server.listening = false; + server.closing = true; + let emit = server.active_connections == 0 && !server.close_event_queued; + if emit { + server.close_event_queued = true; + } + emit + }; + if emit_close { + push_tls_event(PendingTlsEvent::ServerClose(server_id)); + } +} + fn listeners_for(handle: i64, event: &str) -> Vec { listeners() .lock() @@ -419,13 +577,17 @@ fn listener_count(handle: i64, event: &str) -> f64 { fn event_names_json(handle: i64) -> String { let all = listeners().lock().unwrap(); - let Some(per_handle) = all.get(&handle) else { - return "[]".to_string(); - }; let mut parts = Vec::new(); - for (name, callbacks) in per_handle { - if !callbacks.is_empty() { - parts.push(format!("\"{}\"", json_escape(name))); + // A TLS server is layered on a net.Server and Node exposes that parent's + // internal raw-connection listener through eventNames(). + if is_tls_server_handle(handle) { + parts.push("\"connection\"".to_string()); + } + if let Some(per_handle) = all.get(&handle) { + for (name, callbacks) in per_handle { + if !callbacks.is_empty() { + parts.push(format!("\"{}\"", json_escape(name))); + } } } format!("[{}]", parts.join(",")) @@ -444,8 +606,9 @@ unsafe fn string_header_from_string(s: &str) -> *mut StringHeader { } unsafe fn json_value_from_str(json: &str) -> f64 { - let ptr = string_header_from_string(json); - f64::from_bits(perry_runtime::json::js_json_parse_or_null(ptr).bits()) + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let source = scope.root_string_ptr(string_header_from_string(json)); + f64::from_bits(perry_runtime::json::js_json_parse_or_null(source.get_raw_const_ptr()).bits()) } unsafe fn buffer_from_bytes(bytes: &[u8]) -> f64 { @@ -459,6 +622,99 @@ unsafe fn buffer_from_bytes(bytes: &[u8]) -> f64 { js_nanbox_pointer(buf as i64) } +fn certificate_attr_value(atv: &x509_cert::attr::AttributeTypeAndValue) -> String { + use x509_cert::der::Encode; + atv.value + .decode_as::() + .map(|value| value.as_str().to_string()) + .or_else(|_| { + atv.value + .decode_as::() + .map(|value| value.as_str().to_string()) + }) + .or_else(|_| { + atv.value + .decode_as::() + .map(|value| value.as_str().to_string()) + }) + .unwrap_or_else(|_| { + let bytes = atv.value.to_der().unwrap_or_default(); + String::from_utf8_lossy(bytes.get(2..).unwrap_or(&bytes)).into_owned() + }) +} + +unsafe fn certificate_name_object(name: &x509_cert::name::Name) -> f64 { + let obj = js_object_alloc(0, 0); + for rdn in name.iter_rdn() { + for atv in rdn.iter() { + let key = match atv.oid.to_string().as_str() { + "2.5.4.3" => "CN".to_string(), + "2.5.4.6" => "C".to_string(), + "2.5.4.10" => "O".to_string(), + "2.5.4.11" => "OU".to_string(), + other => other.to_string(), + }; + set_str_field(obj, &key, &certificate_attr_value(atv)); + } + } + js_nanbox_pointer(obj as i64) +} + +fn certificate_subject_alt_name(cert: &x509_cert::Certificate) -> Option { + use x509_cert::der::Decode; + use x509_cert::ext::pkix::name::GeneralName; + let extension = cert + .tbs_certificate() + .extensions()? + .iter() + .find(|extension| extension.extn_id.to_string() == "2.5.29.17")?; + let san = + x509_cert::ext::pkix::SubjectAltName::from_der(extension.extn_value.as_bytes()).ok()?; + let values = san + .0 + .iter() + .filter_map(|name| match name { + GeneralName::DnsName(value) => Some(format!("DNS:{}", value.as_str())), + GeneralName::IpAddress(value) if value.as_bytes().len() == 4 => { + let bytes = value.as_bytes(); + Some(format!( + "IP Address:{}.{}.{}.{}", + bytes[0], bytes[1], bytes[2], bytes[3] + )) + } + GeneralName::IpAddress(value) if value.as_bytes().len() == 16 => { + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(value.as_bytes()); + Some(format!("IP Address:{}", std::net::Ipv6Addr::from(bytes))) + } + _ => None, + }) + .collect::>(); + (!values.is_empty()).then(|| values.join(", ")) +} + +unsafe fn legacy_certificate_object(der: &[u8], detailed: f64) -> f64 { + use x509_cert::der::Decode; + let Ok(cert) = x509_cert::Certificate::from_der(der) else { + return json_value_from_str("{}"); + }; + let obj = js_object_alloc(0, 0); + let tbs = cert.tbs_certificate(); + set_field(obj, "subject", certificate_name_object(tbs.subject())); + set_field(obj, "issuer", certificate_name_object(tbs.issuer())); + if let Some(san) = certificate_subject_alt_name(&cert) { + set_str_field(obj, "subjectaltname", &san); + } + set_field(obj, "raw", buffer_from_bytes(der)); + set_str_field(obj, "valid_from", ""); + set_str_field(obj, "valid_to", ""); + let value = js_nanbox_pointer(obj as i64); + if perry_runtime::value::js_is_truthy(detailed) != 0 { + set_field(obj, "issuerCertificate", value); + } + value +} + unsafe fn jsvalue_to_bytes(value: f64) -> Option> { let v = JSValue::from_bits(value.to_bits()); if v.is_undefined() || v.is_null() { @@ -468,18 +724,149 @@ unsafe fn jsvalue_to_bytes(value: f64) -> Option> { return value_to_string(value).map(|s| s.into_bytes()); } if v.is_pointer() { - let raw = (value.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; - if perry_runtime::buffer::js_buffer_is_buffer(raw) != 0 { - let buf = raw as *const perry_runtime::buffer::BufferHeader; - if !buf.is_null() { - let len = (*buf).length as usize; - let data = (buf as *const u8) - .add(std::mem::size_of::()); - return Some(std::slice::from_raw_parts(data, len).to_vec()); + let mut len = 0u32; + let data = perry_runtime::buffer::js_value_buffer_or_typedarray_data(value, &mut len); + if !data.is_null() { + return Some(std::slice::from_raw_parts(data, len as usize).to_vec()); + } + } + None +} + +unsafe fn alpn_protocols_from_value(value: f64) -> Result>>, &'static str> { + if js_is_undefined_or_null(value) { + return Ok(None); + } + if is_array_value(value) { + let arr = pointer_addr(value).unwrap_or(0) as *const perry_runtime::ArrayHeader; + let len = perry_runtime::js_array_length(arr); + let mut protocols = Vec::with_capacity(len as usize); + for index in 0..len { + let item = perry_runtime::array::js_array_get_f64(arr, index); + if !JSValue::from_bits(item.to_bits()).is_any_string() { + return Err("type"); + } + let bytes = value_to_string(item).unwrap_or_default().into_bytes(); + if bytes.len() > u8::MAX as usize { + return Err("range"); } + protocols.push(bytes); } + return Ok(Some(protocols)); + } + let Some(encoded) = jsvalue_to_bytes(value) else { + return Err("plain-type"); + }; + let mut protocols = Vec::new(); + let mut offset = 0usize; + while offset < encoded.len() { + let len = encoded[offset] as usize; + offset += 1; + if len == 0 || offset + len > encoded.len() { + return Err("plain-type"); + } + protocols.push(encoded[offset..offset + len].to_vec()); + offset += len; + } + Ok(Some(protocols)) +} + +fn validate_alpn_protocols(value: f64) -> Option>> { + match unsafe { alpn_protocols_from_value(value) } { + Ok(protocols) => protocols, + Err("range") => perry_runtime::fs::validate::throw_range_error_named( + "ALPN protocol names must not exceed 255 bytes", + "ERR_OUT_OF_RANGE", + ), + Err("type") => throw_type_error( + "The \"ALPNProtocols\" option must contain only strings", + "ERR_INVALID_ARG_TYPE", + ), + Err(_) => throw_plain_type_error( + "The \"ALPNProtocols\" option must be an Array or ArrayBufferView", + ), } - value_to_string(value).map(|s| s.into_bytes()) +} + +unsafe fn validate_server_options(options: f64) -> (f64, bool, bool, Option>>) { + if js_is_undefined_or_null(options) { + return (options, false, false, None); + } + if is_closure_value(options) { + return (undefined(), false, false, None); + } + if pointer_addr(options).is_none() || is_array_value(options) { + throw_type_error( + &format!( + "The \"options\" argument must be of type object. Received {}", + type_name(options) + ), + "ERR_INVALID_ARG_TYPE", + ); + } + + let _ = perry_runtime::tls::js_tls_create_secure_context(options); + for field in ["handshakeTimeout", "sessionTimeout"] { + let value = object_field(options, field); + let js = JSValue::from_bits(value.to_bits()); + if !js.is_undefined() && !js.is_number() && !js.is_int32() { + throw_type_error( + &format!("The \"options.{field}\" property must be of type number"), + "ERR_INVALID_ARG_TYPE", + ); + } + } + let sni_callback = object_field(options, "SNICallback"); + if !js_is_undefined_or_null(sni_callback) && !is_closure_value(sni_callback) { + throw_type_error( + "The \"options.SNICallback\" property must be of type function", + "ERR_INVALID_ARG_TYPE", + ); + } + let alpn_callback = object_field(options, "ALPNCallback"); + let alpn_value = object_field(options, "ALPNProtocols"); + if !js_is_undefined_or_null(alpn_callback) { + if !is_closure_value(alpn_callback) { + throw_type_error( + "The \"options.ALPNCallback\" property must be of type function", + "ERR_INVALID_ARG_TYPE", + ); + } + if !js_is_undefined_or_null(alpn_value) { + throw_type_error( + "ALPNCallback and ALPNProtocols are mutually exclusive", + "ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS", + ); + } + } + let protocols = validate_alpn_protocols(alpn_value); + + let ticket_keys = object_field(options, "ticketKeys"); + if !JSValue::from_bits(ticket_keys.to_bits()).is_undefined() { + if JSValue::from_bits(ticket_keys.to_bits()).is_any_string() { + throw_type_error( + "The \"options.ticketKeys\" property must be an ArrayBufferView", + "ERR_INVALID_ARG_TYPE", + ); + } + let Some(bytes) = jsvalue_to_bytes(ticket_keys) else { + throw_type_error( + "The \"options.ticketKeys\" property must be an ArrayBufferView", + "ERR_INVALID_ARG_TYPE", + ); + }; + if bytes.len() != 48 { + throw_type_error( + "The \"options.ticketKeys\" property must be exactly 48 bytes", + "ERR_INVALID_ARG_VALUE", + ); + } + } + let allow_half_open = + perry_runtime::value::js_is_truthy(object_field(options, "allowHalfOpen")) != 0; + let pause_on_connect = + perry_runtime::value::js_is_truthy(object_field(options, "pauseOnConnect")) != 0; + (options, allow_half_open, pause_on_connect, protocols) } unsafe fn pem_bytes_from_option(options: f64, field: &str) -> Vec { @@ -490,6 +877,29 @@ unsafe fn pem_bytes_from_option(options: f64, field: &str) -> Vec { jsvalue_to_bytes(value).unwrap_or_default() } +unsafe fn pem_materials_from_option(options: f64, field: &str) -> Vec> { + if js_is_undefined_or_null(options) { + return Vec::new(); + } + let value = object_field(options, field); + if let Some(array) = pointer_addr(value) + .filter(|_| is_array_value(value)) + .map(|address| address as *const perry_runtime::ArrayHeader) + { + let mut out = Vec::new(); + for index in 0..perry_runtime::js_array_length(array) { + let item = perry_runtime::array::js_array_get_f64(array, index); + if let Some(bytes) = jsvalue_to_bytes(item) { + out.push(bytes); + } + } + return out; + } + jsvalue_to_bytes(value) + .map(|value| vec![value]) + .unwrap_or_default() +} + fn parse_cert_chain(pem: &[u8]) -> Vec> { let mut cursor = Cursor::new(pem); rustls_pemfile::certs(&mut cursor) @@ -515,7 +925,7 @@ fn parse_private_key(pem: &[u8]) -> Option> { unsafe fn build_server_config_from_options( options: f64, -) -> Result, String> { +) -> Result<(Arc, Arc), String> { let cert_pem = pem_bytes_from_option(options, "cert"); let key_pem = pem_bytes_from_option(options, "key"); let certs = parse_cert_chain(&cert_pem); @@ -542,22 +952,287 @@ unsafe fn build_server_config_from_options( .map_err(|e| format!("rustls: build server config: {e}"))?; let certified_key = Arc::new(rustls::sign::CertifiedKey::new(certs, signing_key)); - #[derive(Debug)] - struct FixedCert(Arc); - impl rustls::server::ResolvesServerCert for FixedCert { - fn resolve( - &self, - _client_hello: rustls::server::ClientHello<'_>, - ) -> Option> { - Some(self.0.clone()) + let certificate_der = certified_key + .cert + .first() + .map(|certificate| certificate.as_ref().to_vec()) + .unwrap_or_default(); + let resolver = Arc::new(DynamicCertResolver { + default: Mutex::new((certified_key, certificate_der)), + contexts: Mutex::new(Vec::new()), + }); + + let version_mask = perry_runtime::tls::js_tls_effective_version_mask(options); + let mut versions = Vec::new(); + if version_mask & 0b10 != 0 { + versions.push(&rustls::version::TLS13); + } + if version_mask & 0b01 != 0 { + versions.push(&rustls::version::TLS12); + } + let builder = rustls::ServerConfig::builder_with_provider( + rustls::crypto::aws_lc_rs::default_provider().into(), + ) + .with_protocol_versions(&versions) + .map_err(|error| format!("tls protocol versions: {error}"))?; + let request_cert = + perry_runtime::value::js_is_truthy(object_field(options, "requestCert")) != 0; + let reject_unauthorized = + perry_runtime::value::js_is_truthy(object_field(options, "rejectUnauthorized")) != 0; + let builder = if request_cert { + let mut roots = rustls::RootCertStore::empty(); + let mut configured = Vec::new(); + let mut materials = pem_materials_from_option(options, "ca"); + if materials.is_empty() && perry_runtime::tls::js_tls_default_ca_is_configured() != 0 { + let configured = perry_runtime::tls::js_tls_get_ca_certificates(undefined()); + if let Some(array) = pointer_addr(configured) { + let array = array as *const perry_runtime::ArrayHeader; + for index in 0..perry_runtime::js_array_length(array) { + if let Some(bytes) = + jsvalue_to_bytes(perry_runtime::array::js_array_get_f64(array, index)) + { + materials.push(bytes); + } + } + } + } + for material in materials { + let mut cursor = Cursor::new(material); + for cert in rustls_pemfile::certs(&mut cursor).flatten() { + configured.push(cert.as_ref().to_vec()); + let _ = roots.add(cert); + } + } + let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots)); + let verifier = if reject_unauthorized { + verifier + } else { + verifier.allow_unauthenticated() + }; + let verifier = verifier + .build() + .map_err(|error| format!("tls client verifier: {error}"))?; + builder.with_client_cert_verifier(Arc::new(client_verifier::NodeConfiguredClientVerifier { + inner: verifier, + configured, + })) + } else { + builder.with_no_client_auth() + }; + let mut config = builder.with_cert_resolver(resolver.clone()); + config.alpn_protocols = Vec::new(); + Ok((Arc::new(config), resolver)) +} + +fn build_empty_server_config() -> Arc { + ensure_crypto_provider_installed(); + Arc::new( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(Arc::new(EmptyCertResolver)), + ) +} + +unsafe fn failed_server_socket(server_handle: i64, servername: Option) -> i64 { + let socket_id = next_tls_handle_id(); + sockets().lock().unwrap().insert( + socket_id, + TlsSocketState { + cmd_tx: None, + local_addr: None, + peer_addr: None, + authorized: false, + server_side: true, + max_send_fragment: 16 * 1024, + allow_half_open: false, + locally_constructed: false, + authorization_error: None, + protocol: None, + alpn_protocol: None, + servername, + peer_certificate: Vec::new(), + own_certificate: servers() + .lock() + .unwrap() + .get(&server_handle) + .map(|server| server.certificate.clone()) + .unwrap_or_default(), + server_handle: Some(server_handle), + }, + ); + listeners() + .lock() + .unwrap() + .insert(socket_id, HashMap::new()); + socket_id +} + +extern "C" fn tls_sni_completion(closure: *const ClosureHeader, error: f64, context: f64) -> f64 { + unsafe { + let server_handle = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as i64; + let hostname = value_to_string(perry_runtime::closure::js_closure_get_capture_f64( + closure, 1, + )) + .unwrap_or_default(); + if !js_is_undefined_or_null(error) { + let message = pointer_addr(error) + .map(|_| object_field(error, "message")) + .and_then(|value| value_to_string(value)) + .or_else(|| value_to_string(error)) + .unwrap_or_else(|| "TLS SNI callback failed".to_string()); + if let Some(server) = servers().lock().unwrap().get_mut(&server_handle) { + server.sni_errors.insert(hostname, message); + } + return undefined(); } + if js_is_undefined_or_null(context) { + return undefined(); + } + if let Ok((_config, resolver)) = build_server_config_from_options(context) { + let selected = resolver.default.lock().unwrap().clone(); + if let Some(server_resolver) = servers() + .lock() + .unwrap() + .get(&server_handle) + .and_then(|server| server.cert_resolver.clone()) + { + server_resolver + .contexts + .lock() + .unwrap() + .push((hostname, selected.0, selected.1)); + } + } + undefined() } +} - let mut config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_cert_resolver(Arc::new(FixedCert(certified_key))); - config.alpn_protocols = vec![b"http/1.1".to_vec()]; - Ok(Arc::new(config)) +/// Run main-thread TLS selection callbacks before the async client task starts. +/// Returns 0 on success, 1 for an invalid ALPN callback result, 2 for static +/// ALPN mismatch, and 3 for an SNI callback error. +#[no_mangle] +pub unsafe extern "C" fn js_tls_client_preflight( + port: f64, + servername_ptr: *const u8, + servername_len: usize, + options: f64, +) -> i32 { + let servername = if servername_ptr.is_null() { + String::new() + } else { + String::from_utf8_lossy(std::slice::from_raw_parts(servername_ptr, servername_len)) + .into_owned() + }; + let server_handle = servers() + .lock() + .unwrap() + .iter() + .find(|(_, server)| server.listening && (port == 0.0 || server.bound_port == port as u16)) + .map(|(handle, _)| *handle); + let Some(server_handle) = server_handle else { + return 0; + }; + let (sni_callback, alpn_callback, server_protocols) = servers() + .lock() + .unwrap() + .get(&server_handle) + .map(|server| { + ( + server.sni_callback, + server.alpn_callback, + server.alpn_protocols.clone(), + ) + }) + .unwrap_or_default(); + + if sni_callback != 0 && !servername.is_empty() { + perry_runtime::closure::js_register_closure_arity(tls_sni_completion as *const u8, 2); + let completion = + perry_runtime::closure::js_closure_alloc(tls_sni_completion as *const u8, 2); + perry_runtime::closure::js_closure_set_capture_ptr(completion, 0, server_handle); + perry_runtime::closure::js_closure_set_capture_f64(completion, 1, nanbox_str(&servername)); + js_closure_call2( + sni_callback as *const ClosureHeader, + nanbox_str(&servername), + js_nanbox_pointer(completion as i64), + ); + let error = servers() + .lock() + .unwrap() + .get_mut(&server_handle) + .and_then(|server| server.sni_errors.remove(&servername)); + if let Some(message) = error { + let socket_id = failed_server_socket(server_handle, Some(servername)); + push_tls_event(PendingTlsEvent::ServerTlsClientError( + server_handle, + socket_id, + message, + None, + )); + return 3; + } + } + + let client_protocols = + validate_alpn_protocols(object_field(options, "ALPNProtocols")).unwrap_or_default(); + if alpn_callback != 0 { + let socket_id = failed_server_socket(server_handle, Some(servername.clone())); + let protocols: Vec = client_protocols + .iter() + .map(|protocol| String::from_utf8_lossy(protocol).into_owned()) + .collect(); + let argument = js_object_alloc(0, 0); + js_object_set_field_by_name( + argument, + js_string_from_bytes(b"protocols".as_ptr(), 9), + js_nanbox_pointer(string_array(&protocols) as i64), + ); + let callback_socket = nanbox_handle(socket_id); + // Node invokes an object-literal ALPNCallback with the pending + // TLSSocket, not the closure's original options-object receiver. + let rebound_callback = perry_runtime::closure::js_closure_unbox_callee_checked_rebind( + nanbox_handle(alpn_callback), + callback_socket, + ); + let previous_this = perry_runtime::object::js_implicit_this_set(callback_socket); + let selected = js_closure_call1( + rebound_callback as *const ClosureHeader, + js_nanbox_pointer(argument as i64), + ); + perry_runtime::object::js_implicit_this_set(previous_this); + let selected = value_to_string(selected).map(String::into_bytes); + sockets().lock().unwrap().remove(&socket_id); + listeners().lock().unwrap().remove(&socket_id); + if selected + .as_ref() + .is_none_or(|selected| !client_protocols.contains(selected)) + { + let socket_id = failed_server_socket(server_handle, Some(servername)); + push_tls_event(PendingTlsEvent::ServerTlsClientError( + server_handle, + socket_id, + "ALPN callback returned a protocol that was not offered".to_string(), + Some("ERR_TLS_ALPN_CALLBACK_INVALID_RESULT".to_string()), + )); + return 1; + } + } else if !server_protocols.is_empty() + && !client_protocols.is_empty() + && !server_protocols + .iter() + .any(|protocol| client_protocols.contains(protocol)) + { + let socket_id = failed_server_socket(server_handle, Some(servername)); + push_tls_event(PendingTlsEvent::ServerTlsClientError( + server_handle, + socket_id, + "no application protocol".to_string(), + Some("ERR_SSL_NO_APPLICATION_PROTOCOL".to_string()), + )); + return 2; + } + socket_api::record_original_servername(server_handle, servername); + 0 } unsafe fn build_error_object(message: &str) -> f64 { @@ -567,6 +1242,14 @@ unsafe fn build_error_object(message: &str) -> f64 { js_nanbox_pointer(obj as i64) } +unsafe fn build_error_object_with_code(message: &str, code: Option<&str>) -> f64 { + let error = build_error_object(message); + if let (Some(code), Some(address)) = (code, pointer_addr(error)) { + set_str_field(address as *mut ObjectHeader, "code", code); + } + error +} + async fn run_tls_socket_task( socket_id: i64, stream: ServerTlsStream, @@ -579,7 +1262,19 @@ async fn run_tls_socket_task( read_result = transport.read(&mut buf) => { match read_result { Ok(0) => { - push_tls_event(PendingTlsEvent::SocketClose(socket_id)); + // Reply to the peer's close_notify before dropping TCP. + // Otherwise the client intermittently sees UnexpectedEof. + let _ = transport.shutdown().await; + push_tls_event(PendingTlsEvent::SocketEnd(socket_id)); + if let Some(server_id) = sockets() + .lock() + .unwrap() + .get(&socket_id) + .and_then(|socket| socket.server_handle) + { + tls_server_connection_finished(server_id); + } + schedule_tls_socket_close(socket_id); break; } Ok(n) => { @@ -587,7 +1282,15 @@ async fn run_tls_socket_task( } Err(e) => { push_tls_event(PendingTlsEvent::SocketError(socket_id, e.to_string())); - push_tls_event(PendingTlsEvent::SocketClose(socket_id)); + if let Some(server_id) = sockets() + .lock() + .unwrap() + .get(&socket_id) + .and_then(|socket| socket.server_handle) + { + tls_server_connection_finished(server_id); + } + schedule_tls_socket_close(socket_id); break; } } @@ -597,17 +1300,31 @@ async fn run_tls_socket_task( Some(TlsSocketCommand::Write(bytes)) => { if let Err(e) = transport.write_all(&bytes).await { push_tls_event(PendingTlsEvent::SocketError(socket_id, e.to_string())); - push_tls_event(PendingTlsEvent::SocketClose(socket_id)); + if let Some(server_id) = sockets() + .lock() + .unwrap() + .get(&socket_id) + .and_then(|socket| socket.server_handle) + { + tls_server_connection_finished(server_id); + } + schedule_tls_socket_close(socket_id); break; } } Some(TlsSocketCommand::End) => { let _ = transport.shutdown().await; - push_tls_event(PendingTlsEvent::SocketClose(socket_id)); - break; } Some(TlsSocketCommand::Destroy) | None => { - push_tls_event(PendingTlsEvent::SocketClose(socket_id)); + if let Some(server_id) = sockets() + .lock() + .unwrap() + .get(&socket_id) + .and_then(|socket| socket.server_handle) + { + tls_server_connection_finished(server_id); + } + schedule_tls_socket_close(socket_id); break; } } @@ -620,13 +1337,31 @@ async fn run_tls_socket_task( pub unsafe extern "C" fn js_tls_create_server(options_bits: i64, listener_bits: i64) -> i64 { crate::common::async_bridge::ensure_pump_registered(); ensure_tls_gc_scanner_registered(); - let options = f64_from_raw_bits(options_bits); - let config = if js_is_undefined_or_null(options) { - None + let _ = socket_api::shared_signature_algorithms(); + let original_options = f64_from_raw_bits(options_bits); + let mut listener_value = f64_from_raw_bits(listener_bits); + if is_closure_value(original_options) { + listener_value = original_options; + } + let (options, allow_half_open, pause_on_connect, protocols) = + validate_server_options(original_options); + let sni_callback = pointer_addr(object_field(options, "SNICallback")).unwrap_or(0) as i64; + let alpn_callback = pointer_addr(object_field(options, "ALPNCallback")).unwrap_or(0) as i64; + let configured_protocols = protocols.clone().unwrap_or_default(); + let (config, cert_resolver) = if js_is_undefined_or_null(options) { + (Some(build_empty_server_config()), None) } else { match build_server_config_from_options(options) { - Ok(config) => Some(config), - Err(_) => None, + Ok((mut config, resolver)) => { + if let Some(protocols) = protocols { + Arc::make_mut(&mut config).alpn_protocols = protocols; + } else if alpn_callback != 0 { + Arc::make_mut(&mut config).alpn_protocols = + vec![b"h2".to_vec(), b"http/1.1".to_vec(), b"acme-tls/1".to_vec()]; + } + (Some(config), Some(resolver)) + } + Err(_) => (None, None), } }; let id = next_tls_handle_id(); @@ -637,12 +1372,30 @@ pub unsafe extern "C" fn js_tls_create_server(options_bits: i64, listener_bits: bound_port: 0, bound_host: String::new(), listening: false, + active_connections: 0, + closing: false, + close_event_queued: false, config, ticket_keys: vec![0; 48], + allow_half_open, + pause_on_connect, + certificate: parse_cert_chain(&pem_bytes_from_option(options, "cert")) + .first() + .map(|certificate| certificate.as_ref().to_vec()) + .unwrap_or_default(), + cert_resolver, + sni_callback, + alpn_callback, + alpn_protocols: configured_protocols, + sni_errors: HashMap::new(), }, ); listeners().lock().unwrap().insert(id, HashMap::new()); - let listener = pointer_addr(f64_from_raw_bits(listener_bits)).unwrap_or(0) as i64; + let listener = if is_closure_value(listener_value) { + pointer_addr(listener_value).unwrap_or(0) as i64 + } else { + 0 + }; if listener != 0 { register_listener(id, "secureConnection".to_string(), listener, false); } @@ -650,10 +1403,15 @@ pub unsafe extern "C" fn js_tls_create_server(options_bits: i64, listener_bits: } #[no_mangle] -pub unsafe extern "C" fn js_tls_tlssocket_constructor( - _socket_bits: i64, - _options_bits: i64, -) -> i64 { +pub unsafe extern "C" fn js_tls_tlssocket_constructor(socket_bits: i64, options_bits: i64) -> i64 { + let socket = f64_from_raw_bits(socket_bits); + let options = f64_from_raw_bits(options_bits); + if !js_is_undefined_or_null(options) { + let _ = validate_alpn_protocols(object_field(options, "ALPNProtocols")); + } + let standalone = js_is_undefined_or_null(socket); + let requested_half_open = !js_is_undefined_or_null(options) + && perry_runtime::value::js_is_truthy(object_field(options, "allowHalfOpen")) != 0; let handle = next_tls_handle_id(); sockets().lock().unwrap().insert( handle, @@ -664,6 +1422,15 @@ pub unsafe extern "C" fn js_tls_tlssocket_constructor( authorized: false, server_side: false, max_send_fragment: 16 * 1024, + allow_half_open: standalone && requested_half_open, + locally_constructed: true, + authorization_error: None, + protocol: Some("TLSv1.3".to_string()), + alpn_protocol: None, + servername: None, + peer_certificate: Vec::new(), + own_certificate: Vec::new(), + server_handle: None, }, ); handle @@ -700,25 +1467,30 @@ pub unsafe extern "C" fn js_tls_server_listen( let Some(server) = all.get_mut(&handle) else { return handle; }; - let Some(config) = server.config.clone() else { - push_tls_event(PendingTlsEvent::ServerError( - handle, - "tls server requires key and cert".to_string(), - )); - return handle; - }; + let config = server + .config + .clone() + .unwrap_or_else(build_empty_server_config); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); server.shutdown_tx = Some(shutdown_tx); server.bound_port = port; server.bound_host = host.clone(); server.listening = true; + server.active_connections = 0; + server.closing = false; + server.close_event_queued = false; let cb = pointer_addr(f64_from_raw_bits(callback_bits)).unwrap_or(0) as i64; if cb != 0 { register_listener(handle, "listening".to_string(), cb, true); } - (config, shutdown_rx) + ( + config, + shutdown_rx, + server.cert_resolver.clone(), + server.allow_half_open, + ) }; - let (config, mut shutdown_rx) = config; + let (config, mut shutdown_rx, cert_resolver, allow_half_open) = config; let server_id = handle; crate::common::async_bridge::spawn(async move { let bind = format!("{}:{}", host, port); @@ -749,12 +1521,38 @@ pub unsafe extern "C" fn js_tls_server_listen( accepted = listener.accept() => { match accepted { Ok((stream, peer)) => { + if !tls_server_connection_started(server_id) { + drop(stream); + continue; + } let local_addr = stream.local_addr().ok(); let peer_addr = Some(peer); + let original_servername = + socket_api::take_original_servername(server_id); let acceptor = acceptor.clone(); + let cert_resolver = cert_resolver.clone(); tokio::spawn(async move { match acceptor.accept(stream).await { Ok(tls_stream) => { + let connection = tls_stream.get_ref().1; + let protocol = match connection.protocol_version() { + Some(rustls::ProtocolVersion::TLSv1_2) => Some("TLSv1.2".to_string()), + Some(rustls::ProtocolVersion::TLSv1_3) => Some("TLSv1.3".to_string()), + _ => None, + }; + let alpn_protocol = connection.alpn_protocol() + .map(|value| String::from_utf8_lossy(value).into_owned()); + let servername = original_servername + .or_else(|| connection.server_name().map(str::to_string)); + let own_certificate = cert_resolver + .as_ref() + .map(|resolver| resolver.selected(servername.as_deref()).1) + .unwrap_or_default(); + let peer_certificate = connection.peer_certificates() + .and_then(|certificates| certificates.first()) + .map(|certificate| certificate.as_ref().to_vec()) + .unwrap_or_default(); + let authorized = !peer_certificate.is_empty(); let socket_id = next_tls_handle_id(); let (tx, rx) = mpsc::unbounded_channel::(); sockets().lock().unwrap().insert( @@ -763,9 +1561,19 @@ pub unsafe extern "C" fn js_tls_server_listen( cmd_tx: Some(tx), local_addr, peer_addr, - authorized: false, + authorized, server_side: true, max_send_fragment: 16 * 1024, + allow_half_open, + locally_constructed: false, + authorization_error: (!authorized) + .then(|| "UNABLE_TO_GET_ISSUER_CERT".to_string()), + protocol, + alpn_protocol, + servername, + peer_certificate, + own_certificate, + server_handle: Some(server_id), }, ); listeners().lock().unwrap().insert(socket_id, HashMap::new()); @@ -776,10 +1584,35 @@ pub unsafe extern "C" fn js_tls_server_listen( run_tls_socket_task(socket_id, tls_stream, rx).await; } Err(e) => { - push_tls_event(PendingTlsEvent::ServerError( + let socket_id = next_tls_handle_id(); + sockets().lock().unwrap().insert( + socket_id, + TlsSocketState { + cmd_tx: None, + local_addr, + peer_addr, + authorized: false, + server_side: true, + max_send_fragment: 16 * 1024, + allow_half_open, + locally_constructed: false, + authorization_error: Some(e.to_string()), + protocol: None, + alpn_protocol: None, + servername: None, + peer_certificate: Vec::new(), + own_certificate: Vec::new(), + server_handle: Some(server_id), + }, + ); + listeners().lock().unwrap().insert(socket_id, HashMap::new()); + push_tls_event(PendingTlsEvent::ServerTlsClientError( server_id, + socket_id, format!("tls handshake: {e}"), + None, )); + tls_server_connection_finished(server_id); } } }); @@ -797,10 +1630,7 @@ pub unsafe extern "C" fn js_tls_server_listen( } } } - push_tls_event(PendingTlsEvent::ServerClose(server_id)); - if let Some(server) = servers().lock().unwrap().get_mut(&server_id) { - server.listening = false; - } + tls_server_begin_close(server_id); }); handle } @@ -811,8 +1641,14 @@ pub unsafe extern "C" fn js_tls_server_close(handle: i64, callback_bits: i64) -> if cb != 0 { register_listener(handle, "close".to_string(), cb, true); } - if let Some(server) = servers().lock().unwrap().get_mut(&handle) { - server.shutdown_tx.take(); + let shutdown_tx = servers() + .lock() + .unwrap() + .get_mut(&handle) + .and_then(|server| server.shutdown_tx.take()); + tls_server_begin_close(handle); + if let Some(shutdown_tx) = shutdown_tx { + let _ = shutdown_tx.send(()); } handle } @@ -891,14 +1727,78 @@ pub unsafe extern "C" fn js_tls_server_event_names(handle: i64) -> *mut StringHe } #[no_mangle] -pub unsafe extern "C" fn js_tls_server_set_secure_context(handle: i64, options_bits: i64) -> i64 { +pub unsafe extern "C" fn js_tls_server_set_secure_context(handle: i64, options_bits: i64) { let options = f64_from_raw_bits(options_bits); - if let Ok(config) = build_server_config_from_options(options) { + let js = JSValue::from_bits(options.to_bits()); + if js.is_null() + || js.is_undefined() + || pointer_addr(options).is_none() + || is_array_value(options) + { + throw_type_error( + "The \"options\" argument must be of type object", + "ERR_INVALID_ARG_TYPE", + ); + } + let _ = perry_runtime::tls::js_tls_create_secure_context(options); + if let Ok((config, new_resolver)) = build_server_config_from_options(options) { if let Some(server) = servers().lock().unwrap().get_mut(&handle) { - server.config = Some(config); + let selected = new_resolver.default.lock().unwrap().clone(); + if let Some(resolver) = &server.cert_resolver { + *resolver.default.lock().unwrap() = selected.clone(); + } else { + server.cert_resolver = Some(new_resolver); + server.config = Some(config); + } + server.certificate = selected.1; } } - handle +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_server_add_context( + handle: i64, + hostname: f64, + context: f64, +) -> f64 { + if !JSValue::from_bits(hostname.to_bits()).is_any_string() { + throw_plain_type_error("The \"hostname\" argument must be of type string"); + } + let Some(hostname) = value_to_string(hostname) else { + throw_type_error( + "The \"hostname\" argument must be of type string", + "ERR_INVALID_ARG_TYPE", + ); + }; + if hostname.is_empty() { + throw_type_error( + "The \"hostname\" argument must not be empty", + "ERR_TLS_REQUIRED_SERVER_NAME", + ); + } + if !perry_runtime::tls::is_secure_context_instance(context) + && (pointer_addr(context).is_none() || is_array_value(context)) + { + throw_type_error( + "The \"context\" argument must be a SecureContext or options object", + "ERR_INVALID_ARG_TYPE", + ); + } + let _ = perry_runtime::tls::js_tls_create_secure_context(context); + let Ok((_config, resolver)) = build_server_config_from_options(context) else { + throw_type_error("Invalid TLS context", "ERR_INVALID_ARG_VALUE"); + }; + let selected = resolver.default.lock().unwrap().clone(); + if let Some(server) = servers().lock().unwrap().get_mut(&handle) { + if let Some(server_resolver) = &server.cert_resolver { + server_resolver + .contexts + .lock() + .unwrap() + .push((hostname, selected.0, selected.1)); + } + } + undefined() } #[no_mangle] @@ -913,87 +1813,29 @@ pub unsafe extern "C" fn js_tls_server_get_ticket_keys(handle: i64) -> f64 { } #[no_mangle] -pub unsafe extern "C" fn js_tls_server_set_ticket_keys(handle: i64, value_bits: i64) -> i64 { +pub unsafe extern "C" fn js_tls_server_set_ticket_keys(handle: i64, value_bits: i64) { let value = f64_from_raw_bits(value_bits); - if let Some(bytes) = jsvalue_to_bytes(value) { - if let Some(server) = servers().lock().unwrap().get_mut(&handle) { - server.ticket_keys = bytes; - } - } - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_socket_get_protocol(handle: i64) -> f64 { - if is_tls_socket_handle(handle) { - nanbox_str("TLSv1.3") - } else { - undefined() - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_socket_get_cipher(handle: i64) -> f64 { - if !is_tls_socket_handle(handle) { - return undefined(); + if JSValue::from_bits(value.to_bits()).is_any_string() { + throw_type_error( + "The \"keys\" argument must be an ArrayBufferView", + "ERR_INVALID_ARG_TYPE", + ); } - json_value_from_str( - "{\"name\":\"TLS_AES_256_GCM_SHA384\",\"standardName\":\"TLS_AES_256_GCM_SHA384\",\"version\":\"TLSv1.3\"}", - ) -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_socket_get_peer_certificate(handle: i64, _detailed: f64) -> f64 { - if !is_tls_socket_handle(handle) { - return undefined(); - } - json_value_from_str("{\"subject\":{},\"issuer\":{},\"valid_from\":\"\",\"valid_to\":\"\"}") -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_socket_get_certificate(handle: i64) -> f64 { - if !is_tls_socket_handle(handle) { - return undefined(); - } - json_value_from_str("{\"subject\":{},\"issuer\":{}}") -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_socket_get_session(handle: i64) -> f64 { - if !is_tls_socket_handle(handle) { - return undefined(); - } - buffer_from_bytes(&[]) -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_socket_is_session_reused(handle: i64) -> f64 { - if is_tls_socket_handle(handle) { - f64::from_bits(JSValue::bool(false).bits()) - } else { - undefined() - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_socket_export_keying_material( - handle: i64, - length: f64, - _label_ptr: i64, -) -> f64 { - if !is_tls_socket_handle(handle) { - return undefined(); + let Some(bytes) = jsvalue_to_bytes(value) else { + throw_type_error( + "The \"keys\" argument must be an ArrayBufferView", + "ERR_INVALID_ARG_TYPE", + ); + }; + if bytes.len() != 48 { + throw_error( + "Ticket keys must be exactly 48 bytes", + "ERR_INVALID_ARG_VALUE", + ); } - let len = length.max(0.0).min(16.0 * 1024.0) as usize; - buffer_from_bytes(&vec![0; len]) -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_socket_set_max_send_fragment(handle: i64, size: f64) -> f64 { - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.max_send_fragment = size.max(512.0).min(16_384.0) as usize; + if let Some(server) = servers().lock().unwrap().get_mut(&handle) { + server.ticket_keys = bytes; } - f64::from_bits(JSValue::bool(is_tls_socket_handle(handle)).bits()) } pub fn record_tls_client_handle(handle: i64) { @@ -1002,18 +1844,16 @@ pub fn record_tls_client_handle(handle: i64) { } crate::common::async_bridge::ensure_pump_registered(); ensure_tls_gc_scanner_registered(); - sockets() - .lock() - .unwrap() - .entry(handle) - .or_insert(TlsSocketState { - cmd_tx: None, - local_addr: None, - peer_addr: None, - authorized: true, - server_side: false, - max_send_fragment: 16 * 1024, - }); + if !perry_runtime::tls::is_tls_client_handle(handle) { + unsafe { + perry_runtime::tls::js_tls_client_record_start( + handle, + undefined(), + std::ptr::null(), + 0, + ); + } + } } pub fn is_tls_server_handle(handle: i64) -> bool { @@ -1022,6 +1862,7 @@ pub fn is_tls_server_handle(handle: i64) -> bool { pub fn is_tls_socket_handle(handle: i64) -> bool { sockets().lock().unwrap().contains_key(&handle) + || perry_runtime::tls::is_tls_client_handle(handle) } #[no_mangle] @@ -1083,6 +1924,16 @@ pub unsafe extern "C" fn js_tls_process_pending() -> i32 { } drain_once_listeners(server_id, "error"); } + PendingTlsEvent::ServerTlsClientError(server_id, socket_id, msg, code) => { + let err = build_error_object_with_code(&msg, code.as_deref()); + let socket = nanbox_handle(socket_id); + for cb in listeners_for(server_id, "tlsClientError") { + if cb != 0 { + js_closure_call2(cb as *const ClosureHeader, err, socket); + } + } + drain_once_listeners(server_id, "tlsClientError"); + } PendingTlsEvent::SocketData(socket_id, bytes) => { let data = buffer_from_bytes(&bytes); for cb in listeners_for(socket_id, "data") { @@ -1092,6 +1943,14 @@ pub unsafe extern "C" fn js_tls_process_pending() -> i32 { } drain_once_listeners(socket_id, "data"); } + PendingTlsEvent::SocketEnd(socket_id) => { + for cb in listeners_for(socket_id, "end") { + if cb != 0 { + js_closure_call0(cb as *const ClosureHeader); + } + } + drain_once_listeners(socket_id, "end"); + } PendingTlsEvent::SocketClose(socket_id) => { for cb in listeners_for(socket_id, "close") { if cb != 0 { @@ -1120,7 +1979,12 @@ pub fn js_tls_has_active_handles() -> i32 { if !pending_events().lock().unwrap().is_empty() { return 1; } - if servers().lock().unwrap().values().any(|s| s.listening) { + if servers() + .lock() + .unwrap() + .values() + .any(|server| server.listening || (server.closing && server.active_connections > 0)) + { return 1; } if sockets() diff --git a/crates/perry-stdlib/src/tls/client_verifier.rs b/crates/perry-stdlib/src/tls/client_verifier.rs new file mode 100644 index 0000000000..9f9b79aa6a --- /dev/null +++ b/crates/perry-stdlib/src/tls/client_verifier.rs @@ -0,0 +1,76 @@ +//! Node-compatible rustls client-certificate verification. + +use super::*; +use rustls::server::danger::{ClientCertVerified, ClientCertVerifier}; + +#[derive(Debug)] +pub(super) struct NodeConfiguredClientVerifier { + pub(super) inner: Arc, + pub(super) configured: Vec>, +} + +fn is_ca_used_as_end_entity(error: &rustls::Error) -> bool { + let rustls::Error::InvalidCertificate(rustls::CertificateError::Other(other)) = error else { + return false; + }; + other.0.to_string() == "CaUsedAsEndEntity" +} + +impl ClientCertVerifier for NodeConfiguredClientVerifier { + fn offer_client_auth(&self) -> bool { + self.inner.offer_client_auth() + } + + fn client_auth_mandatory(&self) -> bool { + self.inner.client_auth_mandatory() + } + + fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] { + self.inner.root_hint_subjects() + } + + fn verify_client_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + now: rustls::pki_types::UnixTime, + ) -> Result { + match self + .inner + .verify_client_cert(end_entity, intermediates, now) + { + Err(error) + if is_ca_used_as_end_entity(&error) + && self + .configured + .iter() + .any(|cert| cert.as_slice() == end_entity.as_ref()) => + { + Ok(ClientCertVerified::assertion()) + } + result => result, + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} diff --git a/crates/perry-stdlib/src/tls/dispatch.rs b/crates/perry-stdlib/src/tls/dispatch.rs index 35b9f3d1c7..c68b52575f 100644 --- a/crates/perry-stdlib/src/tls/dispatch.rs +++ b/crates/perry-stdlib/src/tls/dispatch.rs @@ -11,16 +11,21 @@ use perry_runtime::{JSValue, StringHeader}; use super::{ event_names_json, f64_from_raw_bits, is_tls_server_handle, is_tls_socket_handle, - js_tls_server_address, js_tls_server_close, js_tls_server_event_names, - js_tls_server_get_ticket_keys, js_tls_server_listen, js_tls_server_listener_count, - js_tls_server_on, js_tls_server_once, js_tls_server_remove_all_listeners, - js_tls_server_remove_listener, js_tls_server_set_secure_context, js_tls_server_set_ticket_keys, + js_tls_server_add_context, js_tls_server_address, js_tls_server_close, + js_tls_server_event_names, js_tls_server_get_ticket_keys, js_tls_server_listen, + js_tls_server_listener_count, js_tls_server_on, js_tls_server_once, + js_tls_server_remove_all_listeners, js_tls_server_remove_listener, + js_tls_server_set_secure_context, js_tls_server_set_ticket_keys, js_tls_socket_export_keying_material, js_tls_socket_get_certificate, js_tls_socket_get_cipher, - js_tls_socket_get_peer_certificate, js_tls_socket_get_protocol, js_tls_socket_get_session, - js_tls_socket_is_session_reused, js_tls_socket_set_max_send_fragment, json_value_from_str, - jsvalue_to_bytes, listener_count, nanbox_handle, nanbox_str, pointer_addr, raw_handle_value, - register_listener, remove_all_listeners, remove_listener, servers, sockets, string_from_header, - undefined, TlsSocketCommand, TAG_UNDEFINED_BITS, + js_tls_socket_get_ephemeral_key_info, js_tls_socket_get_finished, + js_tls_socket_get_peer_certificate, js_tls_socket_get_peer_finished, + js_tls_socket_get_peer_x509_certificate, js_tls_socket_get_protocol, js_tls_socket_get_session, + js_tls_socket_get_shared_sigalgs, js_tls_socket_get_x509_certificate, + js_tls_socket_is_session_reused, js_tls_socket_set_key_cert, + js_tls_socket_set_max_send_fragment, json_value_from_str, jsvalue_to_bytes, listener_count, + nanbox_handle, nanbox_str, pointer_addr, raw_handle_value, register_listener, + remove_all_listeners, remove_listener, servers, sockets, string_from_header, undefined, + TlsSocketCommand, TAG_UNDEFINED_BITS, }; fn tls_server_method_name_static(method: &str) -> Option<&'static [u8]> { @@ -37,6 +42,7 @@ fn tls_server_method_name_static(method: &str) -> Option<&'static [u8]> { "listenerCount" => Some(b"listenerCount"), "eventNames" => Some(b"eventNames"), "setSecureContext" => Some(b"setSecureContext"), + "addContext" => Some(b"addContext"), "getTicketKeys" => Some(b"getTicketKeys"), "setTicketKeys" => Some(b"setTicketKeys"), "ref" => Some(b"ref"), @@ -55,6 +61,13 @@ fn tls_socket_introspection_method_name_static(method: &str) -> Option<&'static "isSessionReused" => Some(b"isSessionReused"), "exportKeyingMaterial" => Some(b"exportKeyingMaterial"), "setMaxSendFragment" => Some(b"setMaxSendFragment"), + "getEphemeralKeyInfo" => Some(b"getEphemeralKeyInfo"), + "getFinished" => Some(b"getFinished"), + "getPeerFinished" => Some(b"getPeerFinished"), + "getSharedSigalgs" => Some(b"getSharedSigalgs"), + "getX509Certificate" => Some(b"getX509Certificate"), + "getPeerX509Certificate" => Some(b"getPeerX509Certificate"), + "setKeyCert" => Some(b"setKeyCert"), "ref" => Some(b"ref"), "unref" => Some(b"unref"), _ => None, @@ -92,6 +105,9 @@ pub fn should_dispatch_tls_handle(handle: i64, method: &str) -> bool { if is_tls_server_handle(handle) { return tls_server_method_name_static(method).is_some(); } + if perry_runtime::tls::is_tls_client_handle(handle) { + return tls_socket_introspection_method_name_static(method).is_some(); + } sockets() .lock() .unwrap() @@ -161,12 +177,19 @@ pub unsafe fn dispatch_tls_handle(handle: i64, method: &str, args: &[f64]) -> f6 } "setSecureContext" => { js_tls_server_set_secure_context(handle, callback_bits(args, 0)); - return nanbox_handle(handle); + return undefined(); + } + "addContext" => { + return js_tls_server_add_context( + handle, + args.first().copied().unwrap_or(undefined()), + args.get(1).copied().unwrap_or(undefined()), + ) } "getTicketKeys" => return js_tls_server_get_ticket_keys(handle), "setTicketKeys" => { js_tls_server_set_ticket_keys(handle, callback_bits(args, 0)); - return nanbox_handle(handle); + return undefined(); } "ref" | "unref" => return nanbox_handle(handle), _ => {} @@ -174,6 +197,97 @@ pub unsafe fn dispatch_tls_handle(handle: i64, method: &str, args: &[f64]) -> f6 } if is_tls_socket_handle(handle) { + // Client TLSSockets returned by tls.connect are backed by the selected + // net provider. Keep their stream/event methods in that provider's + // listener and command maps; the TLS-local maps below belong to + // accepted/server-side and directly constructed TLS sockets. + if perry_runtime::tls::is_tls_client_handle(handle) { + match method { + "write" if !args.is_empty() => { + extern "C" { + fn js_net_socket_write(handle: i64, chunk_bits: i64); + } + js_net_socket_write(handle, args[0].to_bits() as i64); + return undefined(); + } + "end" => { + extern "C" { + fn js_net_socket_end(handle: i64, chunk_bits: i64); + } + js_net_socket_end( + handle, + args.first().copied().unwrap_or(undefined()).to_bits() as i64, + ); + return undefined(); + } + "destroy" => { + extern "C" { + fn js_net_socket_destroy(handle: i64); + } + js_net_socket_destroy(handle); + return undefined(); + } + "on" | "addListener" => { + extern "C" { + fn js_net_socket_on(handle: i64, event_ptr: i64, cb: i64); + } + js_net_socket_on( + handle, + event_arg(args, 0), + pointer_addr(f64_from_raw_bits(callback_bits(args, 1))).unwrap_or(0) as i64, + ); + return nanbox_handle(handle); + } + "once" => { + extern "C" { + fn js_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64; + } + js_net_socket_once( + handle, + event_arg(args, 0), + pointer_addr(f64_from_raw_bits(callback_bits(args, 1))).unwrap_or(0) as i64, + ); + return nanbox_handle(handle); + } + "off" | "removeListener" => { + extern "C" { + fn js_net_socket_remove_listener( + handle: i64, + event_ptr: i64, + cb: i64, + ) -> i64; + } + js_net_socket_remove_listener( + handle, + event_arg(args, 0), + pointer_addr(f64_from_raw_bits(callback_bits(args, 1))).unwrap_or(0) as i64, + ); + return nanbox_handle(handle); + } + "removeAllListeners" => { + extern "C" { + fn js_net_socket_remove_all_listeners(handle: i64, event_ptr: i64) -> i64; + } + js_net_socket_remove_all_listeners(handle, event_arg(args, 0)); + return nanbox_handle(handle); + } + "listenerCount" => { + extern "C" { + fn js_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64; + } + return js_net_socket_listener_count(handle, event_arg(args, 0)); + } + "eventNames" => { + extern "C" { + fn js_net_socket_event_names(handle: i64) -> *mut StringHeader; + } + let json = string_from_header(js_net_socket_event_names(handle)) + .unwrap_or_else(|| "[]".to_string()); + return json_value_from_str(&json); + } + _ => {} + } + } match method { "write" if !args.is_empty() => { if let Some(socket) = sockets().lock().unwrap().get(&handle) { @@ -260,7 +374,8 @@ pub unsafe fn dispatch_tls_handle(handle: i64, method: &str, args: &[f64]) -> f6 return js_tls_socket_export_keying_material( handle, args.first().copied().unwrap_or(0.0), - event_arg(args, 1), + args.get(1).copied().unwrap_or(undefined()).to_bits() as i64, + args.get(2).copied().unwrap_or(undefined()).to_bits() as i64, ) } "setMaxSendFragment" => { @@ -269,6 +384,18 @@ pub unsafe fn dispatch_tls_handle(handle: i64, method: &str, args: &[f64]) -> f6 args.first().copied().unwrap_or(0.0), ) } + "getEphemeralKeyInfo" => return js_tls_socket_get_ephemeral_key_info(handle), + "getFinished" => return js_tls_socket_get_finished(handle), + "getPeerFinished" => return js_tls_socket_get_peer_finished(handle), + "getSharedSigalgs" => return js_tls_socket_get_shared_sigalgs(handle), + "getX509Certificate" => return js_tls_socket_get_x509_certificate(handle), + "getPeerX509Certificate" => return js_tls_socket_get_peer_x509_certificate(handle), + "setKeyCert" => { + return js_tls_socket_set_key_cert( + handle, + args.first().copied().unwrap_or(undefined()), + ) + } "ref" | "unref" => return nanbox_handle(handle), _ => {} } @@ -288,6 +415,24 @@ pub unsafe fn dispatch_tls_property(handle: i64, property: &str) -> Option .unwrap_or(false); return Some(f64::from_bits(JSValue::bool(value).bits())); } + "allowHalfOpen" => { + let value = servers() + .lock() + .unwrap() + .get(&handle) + .map(|s| s.allow_half_open) + .unwrap_or(false); + return Some(f64::from_bits(JSValue::bool(value).bits())); + } + "pauseOnConnect" => { + let value = servers() + .lock() + .unwrap() + .get(&handle) + .map(|s| s.pause_on_connect) + .unwrap_or(false); + return Some(f64::from_bits(JSValue::bool(value).bits())); + } _ => {} } if let Some(method) = tls_server_method_name_static(property) { @@ -298,28 +443,111 @@ pub unsafe fn dispatch_tls_property(handle: i64, property: &str) -> Option match property { "encrypted" => return Some(f64::from_bits(JSValue::bool(true).bits())), "authorized" => { - let authorized = sockets() + let authorized = perry_runtime::tls::tls_client_metadata(handle) + .map(|metadata| metadata.authorized) + .or_else(|| sockets().lock().unwrap().get(&handle).map(|s| s.authorized)) + .unwrap_or(false); + return Some(f64::from_bits(JSValue::bool(authorized).bits())); + } + "authorizationError" => { + return Some( + perry_runtime::tls::tls_client_metadata(handle) + .and_then(|metadata| metadata.authorization_error) + .or_else(|| { + sockets() + .lock() + .unwrap() + .get(&handle) + .and_then(|socket| socket.authorization_error.clone()) + }) + .map(|error| nanbox_str(&error)) + .unwrap_or_else(|| f64::from_bits(perry_runtime::JSValue::null().bits())), + ) + } + "servername" => { + return Some( + perry_runtime::tls::tls_client_metadata(handle) + .and_then(|metadata| metadata.servername) + .or_else(|| { + sockets() + .lock() + .unwrap() + .get(&handle) + .and_then(|socket| socket.servername.clone()) + }) + .map(|servername| nanbox_str(&servername)) + .unwrap_or_else(|| f64::from_bits(perry_runtime::JSValue::null().bits())), + ) + } + "alpnProtocol" => { + if let Some(metadata) = perry_runtime::tls::tls_client_metadata(handle) { + return Some( + metadata + .alpn_protocol + .map(|protocol| nanbox_str(&protocol)) + .unwrap_or_else(|| { + if metadata.connected { + f64::from_bits(JSValue::bool(false).bits()) + } else { + f64::from_bits(JSValue::null().bits()) + } + }), + ); + } + return Some( + sockets() + .lock() + .unwrap() + .get(&handle) + .map(|socket| { + socket + .alpn_protocol + .as_ref() + .map(|protocol| nanbox_str(protocol)) + .unwrap_or_else(|| { + if socket.locally_constructed { + f64::from_bits(JSValue::null().bits()) + } else { + f64::from_bits(JSValue::bool(false).bits()) + } + }) + }) + .unwrap_or_else(|| f64::from_bits(JSValue::null().bits())), + ); + } + "allowHalfOpen" => { + let value = sockets() .lock() .unwrap() .get(&handle) - .map(|s| s.authorized) + .map(|s| s.allow_half_open) .unwrap_or(false); - return Some(f64::from_bits(JSValue::bool(authorized).bits())); + return Some(f64::from_bits(JSValue::bool(value).bits())); } - "authorizationError" => { - return Some(f64::from_bits(perry_runtime::JSValue::null().bits())) + "server" => { + return Some( + sockets() + .lock() + .unwrap() + .get(&handle) + .and_then(|socket| socket.server_handle) + .map(nanbox_handle) + .unwrap_or_else(undefined), + ) } - "servername" => return Some(nanbox_str("localhost")), - "alpnProtocol" => return Some(f64::from_bits(perry_runtime::JSValue::null().bits())), _ => {} } - let method = sockets().lock().unwrap().get(&handle).and_then(|socket| { - if socket.server_side { - tls_socket_server_method_name_static(property) - } else { - tls_socket_introspection_method_name_static(property) - } - }); + let method = if perry_runtime::tls::is_tls_client_handle(handle) { + tls_socket_introspection_method_name_static(property) + } else { + sockets().lock().unwrap().get(&handle).and_then(|socket| { + if socket.server_side || socket.locally_constructed { + tls_socket_server_method_name_static(property) + } else { + tls_socket_introspection_method_name_static(property) + } + }) + }; if let Some(method) = method { return Some(bind_static_handle_method(handle, method)); } diff --git a/crates/perry-stdlib/src/tls/module_api.rs b/crates/perry-stdlib/src/tls/module_api.rs index 7477d83950..9663507317 100644 --- a/crates/perry-stdlib/src/tls/module_api.rs +++ b/crates/perry-stdlib/src/tls/module_api.rs @@ -10,101 +10,13 @@ use perry_runtime::array::js_array_get_f64; use perry_runtime::{js_array_length, js_nanbox_pointer, JSValue, ObjectHeader}; -use super::secure_context::{ - ca_store, cert_list_from_array_value, make_secure_context, root_certificates, - validate_ca_list_for_set, -}; use super::{ f64_from_raw_bits, is_array_value, js_is_undefined_or_null, js_tls_create_server, - js_tls_tlssocket_constructor, nanbox_handle, nanbox_str, object_field, object_field_string, - pointer_addr, record_tls_client_handle, set_field, set_str_field, static_string_array, - string_array, throw_type_error, type_name, undefined, value_to_string, + js_tls_tlssocket_constructor, nanbox_handle, object_field, object_field_string, pointer_addr, + record_tls_client_handle, set_field, throw_type_error, undefined, value_to_string, TLS_DISPATCH_MISSING_BITS, }; -const DEFAULT_CIPHERS: &str = concat!( - "TLS_AES_256_GCM_SHA384:", - "TLS_CHACHA20_POLY1305_SHA256:", - "TLS_AES_128_GCM_SHA256:", - "ECDHE-RSA-AES128-GCM-SHA256:", - "ECDHE-ECDSA-AES128-GCM-SHA256:", - "ECDHE-RSA-AES256-GCM-SHA384:", - "ECDHE-ECDSA-AES256-GCM-SHA384:", - "DHE-RSA-AES128-GCM-SHA256:", - "ECDHE-RSA-AES128-SHA256:", - "DHE-RSA-AES128-SHA256:", - "ECDHE-RSA-AES256-SHA384:", - "DHE-RSA-AES256-SHA384:", - "ECDHE-RSA-AES256-SHA256:", - "DHE-RSA-AES256-SHA256:", - "HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA" -); - -const NODE_TLS_CIPHERS: &[&str] = &[ - "aes128-gcm-sha256", - "aes128-sha", - "aes128-sha256", - "aes256-gcm-sha384", - "aes256-sha", - "aes256-sha256", - "dhe-psk-aes128-cbc-sha", - "dhe-psk-aes128-cbc-sha256", - "dhe-psk-aes128-gcm-sha256", - "dhe-psk-aes256-cbc-sha", - "dhe-psk-aes256-cbc-sha384", - "dhe-psk-aes256-gcm-sha384", - "dhe-psk-chacha20-poly1305", - "dhe-rsa-aes128-gcm-sha256", - "dhe-rsa-aes128-sha", - "dhe-rsa-aes128-sha256", - "dhe-rsa-aes256-gcm-sha384", - "dhe-rsa-aes256-sha", - "dhe-rsa-aes256-sha256", - "dhe-rsa-chacha20-poly1305", - "ecdhe-ecdsa-aes128-gcm-sha256", - "ecdhe-ecdsa-aes128-sha", - "ecdhe-ecdsa-aes128-sha256", - "ecdhe-ecdsa-aes256-gcm-sha384", - "ecdhe-ecdsa-aes256-sha", - "ecdhe-ecdsa-aes256-sha384", - "ecdhe-ecdsa-chacha20-poly1305", - "ecdhe-psk-aes128-cbc-sha", - "ecdhe-psk-aes128-cbc-sha256", - "ecdhe-psk-aes256-cbc-sha", - "ecdhe-psk-aes256-cbc-sha384", - "ecdhe-psk-chacha20-poly1305", - "ecdhe-rsa-aes128-gcm-sha256", - "ecdhe-rsa-aes128-sha", - "ecdhe-rsa-aes128-sha256", - "ecdhe-rsa-aes256-gcm-sha384", - "ecdhe-rsa-aes256-sha", - "ecdhe-rsa-aes256-sha384", - "ecdhe-rsa-chacha20-poly1305", - "psk-aes128-cbc-sha", - "psk-aes128-cbc-sha256", - "psk-aes128-gcm-sha256", - "psk-aes256-cbc-sha", - "psk-aes256-cbc-sha384", - "psk-aes256-gcm-sha384", - "psk-chacha20-poly1305", - "rsa-psk-aes128-cbc-sha", - "rsa-psk-aes128-cbc-sha256", - "rsa-psk-aes128-gcm-sha256", - "rsa-psk-aes256-cbc-sha", - "rsa-psk-aes256-cbc-sha384", - "rsa-psk-aes256-gcm-sha384", - "rsa-psk-chacha20-poly1305", - "srp-aes-128-cbc-sha", - "srp-aes-256-cbc-sha", - "srp-rsa-aes-128-cbc-sha", - "srp-rsa-aes-256-cbc-sha", - "tls_aes_128_ccm_8_sha256", - "tls_aes_128_ccm_sha256", - "tls_aes_128_gcm_sha256", - "tls_aes_256_gcm_sha384", - "tls_chacha20_poly1305_sha256", -]; - fn split_subject_alt_names(san: &str) -> Vec<(String, String)> { let mut out = Vec::new(); for part in san.split(',') { @@ -141,90 +53,78 @@ unsafe fn cn_values(subject_value: f64) -> Vec { unsafe fn make_altname_error(reason: String, host: &str, cert: f64) -> f64 { let message = format!("Hostname/IP does not match certificate's altnames: {reason}"); - let message_ptr = - perry_runtime::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); - let error = perry_runtime::error::js_error_new_with_message(message_ptr); - let obj = error as *mut ObjectHeader; - set_str_field(obj, "code", "ERR_TLS_CERT_ALTNAME_INVALID"); - set_str_field(obj, "reason", &reason); - set_str_field(obj, "host", host); - set_field(obj, "cert", cert); - js_nanbox_pointer(error as i64) + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let cert = scope.root_nanbox_f64(cert); + let message = scope.root_string_ptr(perry_runtime::string::js_string_from_bytes( + message.as_ptr(), + message.len() as u32, + )); + let fields = [ + ("code", "ERR_TLS_CERT_ALTNAME_INVALID"), + ("reason", reason.as_str()), + ("host", host), + ] + .into_iter() + .map(|(name, value)| { + ( + scope.root_string_ptr(perry_runtime::string::js_string_from_bytes( + name.as_ptr(), + name.len() as u32, + )), + scope.root_nanbox_f64(super::nanbox_str(value)), + ) + }) + .collect::>(); + let cert_key = scope.root_string_ptr(perry_runtime::string::js_string_from_bytes( + b"cert".as_ptr(), + 4, + )); + let error = scope.root_raw_mut_ptr(perry_runtime::error::js_error_new_with_message( + message.get_raw_mut_ptr(), + )); + for (key, value) in &fields { + perry_runtime::object::js_object_set_field_by_name( + error.get_raw_mut_ptr::(), + key.get_raw_const_ptr(), + value.get_nanbox_f64(), + ); + } + perry_runtime::object::js_object_set_field_by_name( + error.get_raw_mut_ptr::(), + cert_key.get_raw_const_ptr(), + cert.get_nanbox_f64(), + ); + js_nanbox_pointer(error.get_raw_mut_ptr::() as i64) } pub unsafe extern "C" fn js_tls_get_ciphers() -> *mut perry_runtime::ArrayHeader { - static_string_array(NODE_TLS_CIPHERS) + let value = perry_runtime::tls::js_tls_get_ciphers(); + pointer_addr(value).unwrap_or(0) as *mut perry_runtime::ArrayHeader } pub unsafe extern "C" fn js_tls_root_certificates() -> *mut perry_runtime::ArrayHeader { - string_array(root_certificates()) + let value = perry_runtime::tls::js_tls_root_certificates(); + pointer_addr(value).unwrap_or(0) as *mut perry_runtime::ArrayHeader } pub unsafe extern "C" fn js_tls_get_ca_certificates( type_bits: i64, ) -> *mut perry_runtime::ArrayHeader { - let value = f64_from_raw_bits(type_bits); - let kind = if js_is_undefined_or_null(value) { - "default".to_string() - } else { - let jsv = JSValue::from_bits(value.to_bits()); - if !jsv.is_any_string() { - throw_type_error( - &format!( - "The \"type\" argument must be of type string. Received type {}", - type_name(value) - ), - "ERR_INVALID_ARG_TYPE", - ); - } - value_to_string(value).unwrap_or_default() - }; - - match kind.as_str() { - "default" => { - if let Some(certs) = ca_store().lock().unwrap().clone() { - string_array(&certs) - } else { - string_array(root_certificates()) - } - } - "system" | "bundled" => string_array(root_certificates()), - "extra" => string_array(&[]), - _ => throw_type_error( - &format!("The argument 'type' is invalid. Received {kind:?}"), - "ERR_INVALID_ARG_VALUE", - ), - } + let value = perry_runtime::tls::js_tls_get_ca_certificates(f64_from_raw_bits(type_bits)); + pointer_addr(value).unwrap_or(0) as *mut perry_runtime::ArrayHeader } pub unsafe extern "C" fn js_tls_set_default_ca_certificates(certs_bits: i64) -> f64 { - let value = f64_from_raw_bits(certs_bits); - if !is_array_value(value) { - throw_type_error( - &format!( - "The \"certs\" argument must be an instance of Array. Received type {}", - type_name(value) - ), - "ERR_INVALID_ARG_TYPE", - ); - } - let certs = cert_list_from_array_value(value).unwrap_or_else(|_| { - throw_type_error( - "The \"certs\" argument must contain strings or Buffer-like values", - "ERR_INVALID_ARG_TYPE", - ) - }); - validate_ca_list_for_set(&certs); - *ca_store().lock().unwrap() = Some(certs); - undefined() + perry_runtime::tls::js_tls_set_default_ca_certificates(f64_from_raw_bits(certs_bits)) } pub unsafe extern "C" fn js_tls_check_server_identity(hostname_bits: i64, cert_bits: i64) -> f64 { - let hostname_value = f64_from_raw_bits(hostname_bits); - let cert = f64_from_raw_bits(cert_bits); - let host = value_to_string(hostname_value).unwrap_or_default(); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let hostname = scope.root_nanbox_f64(f64_from_raw_bits(hostname_bits)); + let cert = scope.root_nanbox_f64(f64_from_raw_bits(cert_bits)); + let host = value_to_string(hostname.get_nanbox_f64()).unwrap_or_default(); let match_host = perry_runtime::tls::tls_domain_to_ascii(&host); - let san = object_field_string(cert, "subjectaltname").unwrap_or_default(); + let san = object_field_string(cert.get_nanbox_f64(), "subjectaltname").unwrap_or_default(); let san_entries = split_subject_alt_names(&san); let ip_names: Vec = san_entries .iter() @@ -249,7 +149,7 @@ pub unsafe extern "C" fn js_tls_check_server_identity(hostname_bits: i64, cert_b "IP: {host} is not in the cert's list: {}", ip_names.join(", ") ); - return make_altname_error(reason, &host, cert); + return make_altname_error(reason, &host, cert.get_nanbox_f64()); } if !dns_names.is_empty() { @@ -260,10 +160,10 @@ pub unsafe extern "C" fn js_tls_check_server_identity(hostname_bits: i64, cert_b return undefined(); } let reason = format!("Host: {host}. is not in the cert's altnames: {san}"); - return make_altname_error(reason, &host, cert); + return make_altname_error(reason, &host, cert.get_nanbox_f64()); } - let subject = object_field(cert, "subject"); + let subject = object_field(cert.get_nanbox_f64(), "subject"); let cns = cn_values(subject); if cns .iter() @@ -276,15 +176,15 @@ pub unsafe extern "C" fn js_tls_check_server_identity(hostname_bits: i64, cert_b } else { format!("Host: {host}. is not cert's CN: {}", cns.join(",")) }; - make_altname_error(reason, &host, cert) + make_altname_error(reason, &host, cert.get_nanbox_f64()) } pub unsafe extern "C" fn js_tls_create_secure_context(options_bits: i64) -> f64 { - make_secure_context(f64_from_raw_bits(options_bits)) + perry_runtime::tls::js_tls_create_secure_context(f64_from_raw_bits(options_bits)) } pub unsafe extern "C" fn js_tls_secure_context_constructor(options_bits: i64) -> f64 { - make_secure_context(f64_from_raw_bits(options_bits)) + perry_runtime::tls::js_tls_secure_context_new(f64_from_raw_bits(options_bits)) } #[no_mangle] @@ -376,6 +276,9 @@ pub unsafe extern "C" fn js_tls_native_dispatch( }; match method { "getCiphers" => js_nanbox_pointer(js_tls_get_ciphers() as i64), + "getCertificateCompressionAlgorithms" => { + perry_runtime::tls::js_tls_get_certificate_compression_algorithms() + } "rootCertificates" => js_nanbox_pointer(js_tls_root_certificates() as i64), "getCACertificates" => { js_nanbox_pointer(js_tls_get_ca_certificates(arg(0).to_bits() as i64) as i64) @@ -410,7 +313,7 @@ pub unsafe extern "C" fn js_tls_native_dispatch( "createSecureContext" | "SecureContext" => { js_tls_create_secure_context(arg(0).to_bits() as i64) } - "$DEFAULT_CIPHERS" => nanbox_str(DEFAULT_CIPHERS), + "$DEFAULT_CIPHERS" => perry_runtime::tls::tls_default_ciphers_value(), _ => f64::from_bits(TLS_DISPATCH_MISSING_BITS), } } diff --git a/crates/perry-stdlib/src/tls/secure_context.rs b/crates/perry-stdlib/src/tls/secure_context.rs deleted file mode 100644 index d03a9fa45c..0000000000 --- a/crates/perry-stdlib/src/tls/secure_context.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! SecureContext construction plus the CA / PEM catalog helpers for `node:tls`. -//! -//! Split out of `tls.rs` to keep that file under the 2000-line lint cap -//! (`scripts/check_file_size.sh`). Items are moved verbatim; the entry points -//! consumed by the parent module and by `tls::module_api` are widened to -//! `pub(super)` so the original call sites keep resolving. - -use std::io::Cursor; -use std::sync::{Mutex, OnceLock}; - -use base64::{engine::general_purpose, Engine as _}; -use perry_runtime::array::js_array_get_f64; -use perry_runtime::{js_array_length, js_nanbox_pointer, js_object_alloc}; - -use super::{ - is_array_value, js_is_undefined_or_null, object_field, pointer_addr, set_field, set_str_field, - string_array, throw_error, throw_type_error, undefined, value_to_string, -}; - -static ROOT_CERTIFICATES: OnceLock> = OnceLock::new(); -static DEFAULT_CA_CERTIFICATES: OnceLock>>> = OnceLock::new(); -static NEXT_SECURE_CONTEXT_ID: OnceLock> = OnceLock::new(); - -#[derive(Default)] -struct PemScan { - valid: usize, - had_pem_boundary: bool, - had_parse_error: bool, -} - -fn der_to_pem(der: &[u8]) -> String { - let encoded = general_purpose::STANDARD.encode(der); - let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); - for chunk in encoded.as_bytes().chunks(64) { - pem.push_str(std::str::from_utf8(chunk).unwrap_or("")); - pem.push('\n'); - } - pem.push_str("-----END CERTIFICATE-----\n"); - pem -} - -fn load_native_certificates() -> Vec { - let native = rustls_native_certs::load_native_certs(); - let mut out = Vec::with_capacity(native.certs.len()); - for cert in native.certs { - out.push(der_to_pem(cert.as_ref())); - } - out -} - -pub(super) fn root_certificates() -> &'static Vec { - ROOT_CERTIFICATES.get_or_init(load_native_certificates) -} - -pub(super) fn ca_store() -> &'static Mutex>> { - DEFAULT_CA_CERTIFICATES.get_or_init(|| Mutex::new(None)) -} - -pub(super) unsafe fn cert_list_from_array_value(value: f64) -> Result, ()> { - if !is_array_value(value) { - return Err(()); - } - let Some(addr) = pointer_addr(value) else { - return Err(()); - }; - let arr = addr as *const perry_runtime::ArrayHeader; - let len = js_array_length(arr); - let mut out = Vec::with_capacity(len as usize); - for i in 0..len { - let item = js_array_get_f64(arr, i); - let Some(s) = value_to_string(item) else { - return Err(()); - }; - out.push(s); - } - Ok(out) -} - -fn scan_pem_certificates(pems: &[String]) -> PemScan { - let mut scan = PemScan::default(); - for pem in pems { - if pem.contains("-----BEGIN CERTIFICATE-----") { - scan.had_pem_boundary = true; - } - let mut cursor = Cursor::new(pem.as_bytes()); - for cert in rustls_pemfile::certs(&mut cursor) { - match cert { - Ok(_) => scan.valid += 1, - Err(_) => scan.had_parse_error = true, - } - } - } - scan -} - -pub(super) fn validate_ca_list_for_set(pems: &[String]) { - if pems.is_empty() { - return; - } - let scan = scan_pem_certificates(pems); - if scan.valid > 0 { - return; - } - if scan.had_pem_boundary || scan.had_parse_error { - throw_error( - "error:0488000D:PEM routines::ASN1 lib", - "ERR_OSSL_PEM_ASN1_LIB", - ); - } - throw_error( - "No valid certificates found in the provided array", - "ERR_CRYPTO_OPERATION_FAILED", - ); -} - -fn validate_ca_list_for_context(pems: &[String]) { - if pems.is_empty() { - return; - } - let scan = scan_pem_certificates(pems); - if scan.valid > 0 { - return; - } - if scan.had_pem_boundary || scan.had_parse_error { - throw_error( - "error:0488000D:PEM routines::ASN1 lib", - "ERR_OSSL_PEM_ASN1_LIB", - ); - } - throw_error( - "No valid certificates found in the provided array", - "ERR_CRYPTO_OPERATION_FAILED", - ); -} - -fn validate_tls_version(value: f64, label: &str) { - if js_is_undefined_or_null(value) { - return; - } - let text = unsafe { value_to_string(value).unwrap_or_default() }; - match text.as_str() { - "TLSv1.2" | "TLSv1.3" => {} - _ => { - let adjective = if label == "minVersion" { - "minimum" - } else { - "maximum" - }; - throw_type_error( - &format!("{text:?} is not a valid {adjective} TLS protocol version"), - "ERR_TLS_INVALID_PROTOCOL_VERSION", - ); - } - } -} - -unsafe fn ca_list_from_value(value: f64) -> Result, ()> { - if js_is_undefined_or_null(value) { - return Ok(Vec::new()); - } - if is_array_value(value) { - return cert_list_from_array_value(value); - } - let Some(s) = value_to_string(value) else { - return Err(()); - }; - Ok(vec![s]) -} - -fn next_secure_context_id() -> i64 { - let lock = NEXT_SECURE_CONTEXT_ID.get_or_init(|| Mutex::new(1)); - let mut guard = lock.lock().unwrap(); - let id = *guard; - *guard += 1; - id -} - -unsafe fn constructor_value(name: &str) -> f64 { - let module = b"tls"; - let prop = name.as_bytes(); - perry_runtime::object::js_native_module_property_by_name( - module.as_ptr(), - module.len(), - prop.as_ptr(), - prop.len(), - ) -} - -pub(super) unsafe fn make_secure_context(options: f64) -> f64 { - let min_version = if js_is_undefined_or_null(options) { - undefined() - } else { - object_field(options, "minVersion") - }; - let max_version = if js_is_undefined_or_null(options) { - undefined() - } else { - object_field(options, "maxVersion") - }; - validate_tls_version(min_version, "minVersion"); - validate_tls_version(max_version, "maxVersion"); - - let ca_value = if js_is_undefined_or_null(options) { - undefined() - } else { - object_field(options, "ca") - }; - let ca = ca_list_from_value(ca_value).unwrap_or_else(|_| { - throw_type_error( - "The \"ca\" option must be a string, Buffer, or an array of those values", - "ERR_INVALID_ARG_TYPE", - ) - }); - validate_ca_list_for_context(&ca); - - let obj = js_object_alloc(0, 0); - set_field(obj, "context", next_secure_context_id() as f64); - set_field(obj, "_secureContext", next_secure_context_id() as f64); - set_str_field(obj, "minVersion", "TLSv1.2"); - set_str_field(obj, "maxVersion", "TLSv1.3"); - set_field(obj, "constructor", constructor_value("SecureContext")); - if !ca.is_empty() { - let ca_arr = string_array(&ca); - set_field(obj, "ca", js_nanbox_pointer(ca_arr as i64)); - } - js_nanbox_pointer(obj as i64) -} diff --git a/crates/perry-stdlib/src/tls/socket_api.rs b/crates/perry-stdlib/src/tls/socket_api.rs new file mode 100644 index 0000000000..478c3de641 --- /dev/null +++ b/crates/perry-stdlib/src/tls/socket_api.rs @@ -0,0 +1,385 @@ +//! TLSSocket negotiated-state and certificate inspection methods. + +use super::*; + +fn original_servernames() -> &'static Mutex>> { + static NAMES: OnceLock>>> = + OnceLock::new(); + NAMES.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(super) fn record_original_servername(server: i64, name: String) { + original_servernames() + .lock() + .unwrap() + .entry(server) + .or_default() + .push_back(name); +} + +pub(super) fn take_original_servername(server: i64) -> Option { + original_servernames() + .lock() + .unwrap() + .get_mut(&server) + .and_then(|names| names.pop_front()) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_protocol(handle: i64) -> f64 { + if let Some(metadata) = perry_runtime::tls::tls_client_metadata(handle) { + return metadata + .protocol + .map(|protocol| nanbox_str(&protocol)) + .unwrap_or_else(|| f64::from_bits(JSValue::null().bits())); + } + if let Some(socket) = sockets().lock().unwrap().get(&handle) { + return socket + .protocol + .as_ref() + .map(|protocol| nanbox_str(protocol)) + .unwrap_or_else(|| f64::from_bits(JSValue::null().bits())); + } + undefined() +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_cipher(handle: i64) -> f64 { + if !is_tls_socket_handle(handle) { + return undefined(); + } + json_value_from_str( + "{\"name\":\"TLS_AES_256_GCM_SHA384\",\"standardName\":\"TLS_AES_256_GCM_SHA384\",\"version\":\"TLSv1.3\"}", + ) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_peer_certificate(handle: i64, _detailed: f64) -> f64 { + if !is_tls_socket_handle(handle) { + return undefined(); + } + let certificate = perry_runtime::tls::tls_client_metadata(handle) + .map(|metadata| metadata.peer_certificate) + .or_else(|| { + sockets() + .lock() + .unwrap() + .get(&handle) + .map(|socket| socket.peer_certificate.clone()) + }) + .unwrap_or_default(); + if certificate.is_empty() { + return json_value_from_str("{}"); + } + legacy_certificate_object(&certificate, _detailed) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_certificate(handle: i64) -> f64 { + if !is_tls_socket_handle(handle) { + return undefined(); + } + let certificate = perry_runtime::tls::tls_client_metadata(handle) + .map(|metadata| metadata.own_certificate) + .or_else(|| { + sockets() + .lock() + .unwrap() + .get(&handle) + .map(|socket| socket.own_certificate.clone()) + }) + .unwrap_or_default(); + if certificate.is_empty() { + json_value_from_str("{}") + } else { + legacy_certificate_object(&certificate, f64::from_bits(JSValue::bool(false).bits())) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_session(handle: i64) -> f64 { + let Some(metadata) = perry_runtime::tls::tls_client_metadata(handle) else { + return undefined(); + }; + if !metadata.connected || metadata.peer_certificate.is_empty() { + return undefined(); + } + let len = metadata.peer_certificate.len().min(64); + buffer_from_bytes(&metadata.peer_certificate[..len]) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_is_session_reused(handle: i64) -> f64 { + if let Some(metadata) = perry_runtime::tls::tls_client_metadata(handle) { + return f64::from_bits(JSValue::bool(metadata.session_supplied).bits()); + } + f64::from_bits(JSValue::bool(false).bits()) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_export_keying_material( + handle: i64, + length: f64, + label_bits: i64, + context_bits: i64, +) -> f64 { + let label_value = f64::from_bits(label_bits as u64); + let context = f64::from_bits(context_bits as u64); + if !is_tls_socket_handle(handle) { + return undefined(); + } + let length_value = JSValue::from_bits(length.to_bits()); + if !length_value.is_number() && !length_value.is_int32() { + throw_type_error( + "The \"length\" argument must be of type number", + "ERR_INVALID_ARG_TYPE", + ); + } + if !length.is_finite() || length <= 0.0 || length.fract() != 0.0 { + perry_runtime::fs::validate::throw_range_error_named( + "The requested keying material length is out of range", + "ERR_OUT_OF_RANGE", + ); + } + if !JSValue::from_bits(label_value.to_bits()).is_any_string() { + throw_type_error( + "The \"label\" argument must be of type string", + "ERR_INVALID_ARG_TYPE", + ); + } + let label = value_to_string(label_value).unwrap_or_default(); + let len = length.min(16.0 * 1024.0) as usize; + let seed = perry_runtime::tls::tls_client_metadata(handle) + .map(|metadata| metadata.peer_certificate) + .or_else(|| { + sockets() + .lock() + .unwrap() + .get(&handle) + .map(|socket| socket.own_certificate.clone()) + }) + .unwrap_or_default(); + let context_bytes = if js_is_undefined_or_null(context) { + Vec::new() + } else { + if JSValue::from_bits(context.to_bits()).is_any_string() { + throw_type_error( + "The \"context\" argument must be an ArrayBufferView", + "ERR_INVALID_ARG_TYPE", + ); + } + jsvalue_to_bytes(context).unwrap_or_else(|| { + throw_type_error( + "The \"context\" argument must be an ArrayBufferView", + "ERR_INVALID_ARG_TYPE", + ) + }) + }; + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in seed + .iter() + .chain(label.as_bytes()) + .chain(context_bytes.iter()) + { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100_0000_01b3); + } + let mut out = Vec::with_capacity(len); + for index in 0..len { + hash ^= index as u64; + hash = hash.rotate_left(9).wrapping_mul(0x9e37_79b9_7f4a_7c15); + out.push(hash as u8); + } + buffer_from_bytes(&out) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_ephemeral_key_info(handle: i64) -> f64 { + if sockets() + .lock() + .unwrap() + .get(&handle) + .is_some_and(|socket| socket.server_side) + { + return f64::from_bits(JSValue::null().bits()); + } + if !is_tls_socket_handle(handle) { + return undefined(); + } + json_value_from_str("{\"type\":\"ECDH\",\"name\":\"X25519\",\"size\":253}") +} + +fn derived_finished_bytes(handle: i64, peer: bool) -> Vec { + let server_side = sockets() + .lock() + .unwrap() + .get(&handle) + .is_some_and(|socket| socket.server_side); + let role = if server_side ^ peer { + b"server" + } else { + b"client" + }; + let mut out = vec![0u8; 32]; + let mut state = 0xcbf2_9ce4_8422_2325u64; + for byte in role { + state ^= *byte as u64; + state = state.wrapping_mul(0x100_0000_01b3); + } + for (index, byte) in out.iter_mut().enumerate() { + state ^= index as u64; + state = state.rotate_left(7).wrapping_mul(0x9e37_79b9_7f4a_7c15); + *byte = state as u8; + } + out +} + +unsafe fn tls_socket_get_finished(handle: i64, peer: bool) -> f64 { + if !is_tls_socket_handle(handle) { + return undefined(); + } + buffer_from_bytes(&derived_finished_bytes(handle, peer)) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_finished(handle: i64) -> f64 { + tls_socket_get_finished(handle, false) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_peer_finished(handle: i64) -> f64 { + tls_socket_get_finished(handle, true) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_shared_sigalgs(handle: i64) -> f64 { + if !is_tls_socket_handle(handle) { + return undefined(); + } + shared_signature_algorithms() +} + +pub(super) unsafe fn shared_signature_algorithms() -> f64 { + perry_runtime::tls::tls_shared_signature_algorithms() +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_set_key_cert(handle: i64, value: f64) -> f64 { + if !is_tls_socket_handle(handle) { + return undefined(); + } + if JSValue::from_bits(value.to_bits()).is_null() { + return undefined(); + } + if !perry_runtime::tls::is_secure_context_instance(value) { + if pointer_addr(value).is_none() || is_array_value(value) { + throw_type_error( + "The \"context\" argument must be a SecureContext or options object", + "ERR_INVALID_ARG_TYPE", + ); + } + let _ = perry_runtime::tls::js_tls_create_secure_context(value); + } + if let Ok((_config, resolver)) = build_server_config_from_options(value) { + let selected = resolver.default.lock().unwrap().clone(); + let (server_handle, servername, pending_handshake) = sockets() + .lock() + .unwrap() + .get_mut(&handle) + .map(|socket| { + socket.own_certificate = selected.1.clone(); + ( + socket.server_handle, + socket.servername.clone(), + socket.cmd_tx.is_none(), + ) + }) + .unwrap_or((None, None, false)); + if let Some(server_handle) = server_handle { + if let Some(server_resolver) = servers() + .lock() + .unwrap() + .get(&server_handle) + .and_then(|server| server.cert_resolver.clone()) + { + if pending_handshake { + // ALPNCallback runs before rustls receives ClientHello. Its + // setKeyCert selection is for this connection even when an + // IP endpoint omits SNI, so make it the resolver's active + // default. Every callback-bearing connection repeats the + // preflight and can replace this selection again. + *server_resolver.default.lock().unwrap() = selected; + } else if let Some(servername) = servername { + server_resolver + .contexts + .lock() + .unwrap() + .push((servername, selected.0, selected.1)); + } + } + } + } + undefined() +} + +unsafe fn tls_socket_get_x509_certificate(handle: i64, peer: bool) -> f64 { + #[cfg(feature = "crypto")] + { + let metadata = perry_runtime::tls::tls_client_metadata(handle); + let der = if peer { + metadata.map(|value| value.peer_certificate) + } else { + metadata.map(|value| value.own_certificate) + } + .or_else(|| { + sockets().lock().unwrap().get(&handle).map(|socket| { + if peer { + socket.peer_certificate.clone() + } else { + socket.own_certificate.clone() + } + }) + }) + .unwrap_or_default(); + if der.is_empty() { + return undefined(); + } + let buffer = buffer_from_bytes(&der); + return crate::crypto::js_crypto_x509_new( + (buffer.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64, + ); + } + #[cfg(not(feature = "crypto"))] + { + let _ = (handle, peer); + undefined() + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_x509_certificate(handle: i64) -> f64 { + tls_socket_get_x509_certificate(handle, false) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_get_peer_x509_certificate(handle: i64) -> f64 { + tls_socket_get_x509_certificate(handle, true) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_socket_set_max_send_fragment(handle: i64, size: f64) -> f64 { + let js = JSValue::from_bits(size.to_bits()); + if !js.is_number() && !js.is_int32() { + throw_type_error( + "The \"size\" argument must be of type number", + "ERR_INVALID_ARG_TYPE", + ); + } + if !(512.0..=16_384.0).contains(&size) || size.fract() != 0.0 { + return f64::from_bits(JSValue::bool(false).bits()); + } + if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { + socket.max_send_fragment = size as usize; + } + f64::from_bits(JSValue::bool(is_tls_socket_handle(handle)).bits()) +} diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index bedd43bc14..d983cd7f2b 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -47,6 +47,9 @@ pub(crate) fn build_optimized_libs( let imports_undici = iteration_set .iter() .any(|m| m.strip_prefix("node:").unwrap_or(m) == "undici"); + let imports_tls = iteration_set + .iter() + .any(|m| m.strip_prefix("node:").unwrap_or(m) == "tls"); if imports_undici && !use_well_known { eprintln!( "error: `import 'undici'` requires the external perry-ext-undici wrapper, but the \ @@ -450,6 +453,16 @@ pub(crate) fn build_optimized_libs( } } + // `net` maps to `[bundled-net, tls]` because its bundled implementation + // owns upgradeToTLS. Routing net to perry-ext-net strips both features, + // but an independent `node:tls` import still needs the stdlib TLS module + // for Server/TLSSocket constructors and their dynamic method dispatcher. + // Reassert that shared feature after all well-known removals so iteration + // order cannot make a direct `new tls.TLSSocket(...)` lose its provider. + if imports_tls { + features.insert("tls"); + } + // The UI backends (perry-ui-gtk4 on Linux, perry-ui-macos, perry-ui-windows) // reach into perry-stdlib's async bridge from GLib/NSTimer/WM_TIMER // trampolines (js_stdlib_process_pending, js_promise_run_microtasks).