From d0896556f8e121e7a594dc48bea652b9db1521c1 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 29 Jul 2026 21:59:20 -0400 Subject: [PATCH 1/4] feat(ext): add native node-forge PKI binding for Socket Firewall's TLS-MITM CA Add `perry-ext-node-forge`, a native wrapper for the exact node-forge PKI subset sfw's TLS-MITM CA uses, so apps stop AOT-compiling forge's pure-JS bignum/ASN.1/crypto code: - forge.pki.rsa.generateKeyPair({ bits }) -> native RSA keygen - forge.pki.createCertificate() builder: settable publicKey / serialNumber / validity.notBefore|notAfter, setSubject / setIssuer / setExtensions (basicConstraints, keyUsage, extKeyUsage, subjectAltName, subjectKeyIdentifier), sign(privateKey, md.sha256) - forge.pki.certificateFromPem / certificateToPem - forge.pki.privateKeyFromPem / privateKeyToPem / publicKeyToPem - forge.md.sha256.create() Crypto core is pure Rust on RustCrypto crates already in the lockfile (rsa, x509-cert, der, spki, pkcs1/8, sha2, const-oid, pem, time) - no rcgen, no new heavy deps. Certs are signed sha256WithRSAEncryption; private keys emit PKCS#1 "RSA PRIVATE KEY", public keys SPKI "PUBLIC KEY", matching forge. The cert builder is a real perry JS object so sfw's plain field assignments stay native; methods serialize it via JSON.stringify and sign through the RustCrypto core. DN attribute order is preserved on the parse<->build round-trip so a leaf's issuer DN matches the CA subject DN byte-for-byte (required by `openssl verify`). Wiring: well_known_bindings.toml row (js_node_forge_* / perry_ext_node_forge, date-fns/lru-cache hyphenated convention), NATIVE_MODULES entry, codegen NATIVE_MODULE_TABLE rows + matching API_MANIFEST entries, and the HIR createCertificate->Certificate factory registration. CPU-only: not in binding_needs_shared_tokio. Acceptance: crate unit tests (PEM round-trips, builder JSON parsing, DN round-trip) plus an openssl end-to-end test that builds a CA + leaf and runs `openssl verify -CAfile ca.pem leaf.pem` (=> OK) and `openssl x509 -text` (SANs, EKU serverAuth, sha256WithRSAEncryption all present). --- Cargo.lock | 43 ++ Cargo.toml | 2 + changelog.d/0000-ext-node-forge.md | 1 + crates/perry-api-manifest/src/entries.rs | 5 + .../perry-api-manifest/src/entries/part_2.rs | 18 + .../lower_call/native_table/utils_crypto.rs | 122 ++++ crates/perry-ext-node-forge/Cargo.toml | 50 ++ crates/perry-ext-node-forge/src/crypto.rs | 496 ++++++++++++++ crates/perry-ext-node-forge/src/lib.rs | 615 ++++++++++++++++++ .../perry-ext-node-forge/tests/openssl_e2e.rs | 160 +++++ .../src/js_transform/local_natives.rs | 5 + crates/perry/well_known_bindings.toml | 12 + 12 files changed, 1529 insertions(+) create mode 100644 changelog.d/0000-ext-node-forge.md create mode 100644 crates/perry-ext-node-forge/Cargo.toml create mode 100644 crates/perry-ext-node-forge/src/crypto.rs create mode 100644 crates/perry-ext-node-forge/src/lib.rs create mode 100644 crates/perry-ext-node-forge/tests/openssl_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index 4b73c47b1d..15af58b263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5964,6 +5964,25 @@ dependencies = [ "tokio-rustls", ] +[[package]] +name = "perry-ext-node-forge" +version = "0.5.1265" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "pem", + "perry-ffi", + "perry-runtime", + "rand 0.8.6", + "rsa 0.9.10", + "serde", + "serde_json", + "sha2 0.10.9", + "spki 0.7.3", + "time", + "x509-cert", +] + [[package]] name = "perry-ext-nodemailer" version = "0.5.1265" @@ -8972,6 +8991,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio" version = "1.53.1" @@ -10538,7 +10578,10 @@ checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", "der 0.7.10", + "sha1 0.10.6", + "signature 2.2.0", "spki 0.7.3", + "tls_codec", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bf38957d78..7bce980025 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ members = [ "crates/perry-ext-fastify", "crates/perry-ext-pdf", "crates/perry-ext-ads", + "crates/perry-ext-node-forge", "crates/perry-wasm-host", "crates/perry-container-compose", "crates/perry-container-e2e", @@ -458,6 +459,7 @@ perry-ext-streams = { path = "crates/perry-ext-streams" } perry-ext-fastify = { path = "crates/perry-ext-fastify" } perry-ext-pdf = { path = "crates/perry-ext-pdf" } perry-ext-ads = { path = "crates/perry-ext-ads" } +perry-ext-node-forge = { path = "crates/perry-ext-node-forge" } perry-stdlib = { path = "crates/perry-stdlib" } perry-diagnostics = { path = "crates/perry-diagnostics" } perry-ui-model = { path = "crates/perry-ui-model" } diff --git a/changelog.d/0000-ext-node-forge.md b/changelog.d/0000-ext-node-forge.md new file mode 100644 index 0000000000..c494a6b201 --- /dev/null +++ b/changelog.d/0000-ext-node-forge.md @@ -0,0 +1 @@ +Add a native `node-forge` binding (`perry-ext-node-forge`) covering the PKI subset Socket Firewall's TLS-MITM CA uses — RSA `generateKeyPair`, the `createCertificate` builder (`setSubject`/`setIssuer`/`setExtensions`/`sign`), `certificateFromPem`/`certificateToPem`, `privateKeyFromPem`/`privateKeyToPem`/`publicKeyToPem`, and `md.sha256.create` — backed by RustCrypto (`rsa` + `x509-cert`) so apps stop AOT-compiling forge's pure-JS bignum/ASN.1 code. Certificates are signed `sha256WithRSAEncryption` and verify against real TLS clients (`openssl verify`). diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 591d1fdfd9..c3574165c2 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -177,6 +177,11 @@ pub const NATIVE_MODULES: &[&str] = &[ // the one perry-runtime implementation — no N-API addon involved. "node-pty", "@lydell/node-pty", // API-identical node-pty fork (see above) + // #466: node-forge PKI subset (RSA keygen, X.509 build/sign, PEM). + // Bundled wrapper at `crates/perry-ext-node-forge`; served natively + // for Socket Firewall's TLS-MITM CA so forge's pure-JS crypto isn't + // AOT-compiled. + "node-forge", ]; /// Node built-in submodules that Perry routes through the diff --git a/crates/perry-api-manifest/src/entries/part_2.rs b/crates/perry-api-manifest/src/entries/part_2.rs index bc1fbadcb8..3024b2aff2 100644 --- a/crates/perry-api-manifest/src/entries/part_2.rs +++ b/crates/perry-api-manifest/src/entries/part_2.rs @@ -1237,6 +1237,24 @@ pub(crate) const API_MANIFEST_PART_2: &[ApiEntry] = &[ TypeSpec::BigInt, ), method("ethers", "createRandom", false, Some("Wallet")), + // node-forge PKI subset (perry-ext-node-forge). Declared with the + // arity-agnostic `method(...)` form (params &[], returns Any) so the + // #512 dispatch↔manifest drift gate is satisfied by name while the + // real argument shapes live in the wrapper. Namespaced call sites + // (`forge.pki.rsa.generateKeyPair`, `forge.md.sha256.create`) + // dispatch once perry-hir flattens the sub-namespace member chains. + method("node-forge", "generateKeyPair", false, None), + method("node-forge", "createCertificate", false, None), + method("node-forge", "certificateFromPem", false, None), + method("node-forge", "certificateToPem", false, None), + method("node-forge", "privateKeyFromPem", false, None), + method("node-forge", "privateKeyToPem", false, None), + method("node-forge", "publicKeyToPem", false, None), + method("node-forge", "create", false, None), + method("node-forge", "setSubject", true, Some("Certificate")), + method("node-forge", "setIssuer", true, Some("Certificate")), + method("node-forge", "setExtensions", true, Some("Certificate")), + method("node-forge", "sign", true, Some("Certificate")), // =========================================================== // Methods dispatched via custom Expr::* variants // (perry-hir/src/lower/expr_call.rs and expr_member.rs) diff --git a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs index ceed7a76c7..2ff0a30125 100644 --- a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs +++ b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs @@ -272,4 +272,126 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ args: &[NA_STR, NA_STR], ret: NR_PTR, }, + // ========== node-forge (PKI subset — perry-ext-node-forge) ========== + // Namespaced statics (`forge.pki.rsa.generateKeyPair`, + // `forge.pki.createCertificate`, `forge.md.sha256.create`, ...). These + // dispatch once perry-hir flattens the `forge.pki.*` / `forge.md.*` + // sub-namespace member chains to `NativeMethodCall { module: + // "node-forge", method }`. Object-returning fns box as NR_PTR (they + // return `JsValue::from_object_ptr`, which the double-tag-idempotent + // NR_PTR path leaves intact); PEM emitters return `*mut StringHeader` + // → NR_STR. Key/cert handles cross as NaN-boxed objects (NA_F64); PEM + // inputs as raw string pointers (NA_STR). + NativeModSig { + module: "node-forge", + has_receiver: false, + method: "generateKeyPair", + class_filter: None, + runtime: "js_node_forge_generate_key_pair", + args: &[NA_F64], + ret: NR_PTR, + }, + NativeModSig { + module: "node-forge", + has_receiver: false, + method: "createCertificate", + class_filter: None, + runtime: "js_node_forge_create_certificate", + args: &[], + ret: NR_PTR, + }, + NativeModSig { + module: "node-forge", + has_receiver: false, + method: "certificateFromPem", + class_filter: None, + runtime: "js_node_forge_certificate_from_pem", + args: &[NA_STR], + ret: NR_PTR, + }, + NativeModSig { + module: "node-forge", + has_receiver: false, + method: "certificateToPem", + class_filter: None, + runtime: "js_node_forge_certificate_to_pem", + args: &[NA_F64], + ret: NR_STR, + }, + NativeModSig { + module: "node-forge", + has_receiver: false, + method: "privateKeyFromPem", + class_filter: None, + runtime: "js_node_forge_private_key_from_pem", + args: &[NA_STR], + ret: NR_PTR, + }, + NativeModSig { + module: "node-forge", + has_receiver: false, + method: "privateKeyToPem", + class_filter: None, + runtime: "js_node_forge_private_key_to_pem", + args: &[NA_F64], + ret: NR_STR, + }, + NativeModSig { + module: "node-forge", + has_receiver: false, + method: "publicKeyToPem", + class_filter: None, + runtime: "js_node_forge_public_key_to_pem", + args: &[NA_F64], + ret: NR_STR, + }, + // `forge.md.sha256.create()` → a marker digest object. + NativeModSig { + module: "node-forge", + has_receiver: false, + method: "create", + class_filter: None, + runtime: "js_node_forge_md_sha256_create", + args: &[], + ret: NR_PTR, + }, + // Certificate builder instance methods. The receiver (the JS cert + // object) is NaN-unboxed to an `i64` `*mut ObjectHeader` and passed + // as the first arg; the FFI writes into fixed object slots. + NativeModSig { + module: "node-forge", + has_receiver: true, + method: "setSubject", + class_filter: Some("Certificate"), + runtime: "js_node_forge_cert_set_subject", + args: &[NA_F64], + ret: NR_VOID, + }, + NativeModSig { + module: "node-forge", + has_receiver: true, + method: "setIssuer", + class_filter: Some("Certificate"), + runtime: "js_node_forge_cert_set_issuer", + args: &[NA_F64], + ret: NR_VOID, + }, + NativeModSig { + module: "node-forge", + has_receiver: true, + method: "setExtensions", + class_filter: Some("Certificate"), + runtime: "js_node_forge_cert_set_extensions", + args: &[NA_F64], + ret: NR_VOID, + }, + NativeModSig { + module: "node-forge", + has_receiver: true, + method: "sign", + class_filter: Some("Certificate"), + runtime: "js_node_forge_cert_sign", + args: &[NA_F64, NA_F64], + ret: NR_VOID, + }, ]; diff --git a/crates/perry-ext-node-forge/Cargo.toml b/crates/perry-ext-node-forge/Cargo.toml new file mode 100644 index 0000000000..fd5aa3cc5a --- /dev/null +++ b/crates/perry-ext-node-forge/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "perry-ext-node-forge" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Native bindings for the npm `node-forge` package — the PKI subset (RSA keygen, X.509 certificate build/sign, PEM round-trips) that Socket Firewall's TLS-MITM CA uses. Uses only `perry-ffi` plus RustCrypto (`rsa` / `x509-cert`). CPU-only: no tokio, not in `binding_needs_shared_tokio`." + +[lints] +workspace = true + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +perry-ffi.workspace = true +serde = { workspace = true } +serde_json = { workspace = true } +# RustCrypto PKI stack — all already present in the workspace Cargo.lock. +rsa = { version = "0.9", features = ["sha2", "pem"] } +sha2 = "0.10" +x509-cert = { version = "0.2", features = ["builder", "hazmat"] } +der = { version = "0.7", features = ["oid"] } +spki = "0.7" +const-oid = "0.9" +pem = "3" +rand = "0.8" +time = { version = "0.3", features = ["parsing"] } + +[dev-dependencies] +perry-ffi = { workspace = true, features = ["runtime-link"] } +# #6303: perry-runtime MUST be built here with the same feature set the shipped +# `libperry_runtime.a` / `libperry_stdlib.a` carry (i.e. its `default`). This crate +# is a `staticlib`, so it BUNDLES the perry-runtime rlib objects into +# `libperry_ext_*.a` — and perry links the ext archives BEFORE stdlib/runtime +# (`prefer_well_known_before_stdlib`), so those bundled objects WIN the link for +# every symbol they define. The workspace dep is `default-features = false`, so +# without `"default"` here a per-crate `cargo build -p perry-ext-` (exactly what +# release-packages.yml does in its per-crate loop) bundles a runtime with +# `regex-engine`/`temporal`/... compiled OUT. The dispatchers those features gate +# are exported UNCONDITIONALLY (`js_string_replace_search_dyn`, +# `js_native_call_method`, ...) with the feature-gated logic `#[cfg]`-ed out of the +# BODY — so the degraded copy silently ToString-coerces a RegExp argument and +# searches for it literally instead of matching it (str.replace(re, fn) never fires +# its callback). Keep `"default"` in lock-step with perry-runtime's default feature +# list; the `ext_crates_bundle_a_full_featured_perry_runtime` test (well_known.rs) guards it. +# `stdlib`: this crate bundles perry-runtime into its staticlib and is co-linked +# with the real perry-stdlib, so drop the bundled no-op stdlib_stubs that would +# otherwise shadow perry-stdlib's real symbols (#6314). `default`: keep the copy +# feature-identical to the shipped runtime so gated dispatchers behave (#6303). +perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-node-forge/src/crypto.rs b/crates/perry-ext-node-forge/src/crypto.rs new file mode 100644 index 0000000000..f871b93e24 --- /dev/null +++ b/crates/perry-ext-node-forge/src/crypto.rs @@ -0,0 +1,496 @@ +//! Pure-Rust PKI core for the node-forge wrapper. +//! +//! No FFI here — everything in this module is plain Rust that operates +//! on owned data (`CertSpec`, PEM strings). That keeps the RSA keygen / +//! X.509 build-and-sign / PEM round-trip logic unit-testable and +//! openssl-verifiable without linking the perry runtime. `lib.rs` is a +//! thin FFI shell that marshals JS values into these types. +//! +//! Fidelity target: byte-shapes that real TLS clients (and `openssl +//! verify`) accept, matching what `node-forge` emits for Socket +//! Firewall's TLS-MITM CA: +//! - private keys → PKCS#1 `-----BEGIN RSA PRIVATE KEY-----` +//! - public keys → SPKI `-----BEGIN PUBLIC KEY-----` +//! - certificates → `-----BEGIN CERTIFICATE-----`, signed +//! `sha256WithRSAEncryption`. + +use std::str::FromStr; + +use const_oid::ObjectIdentifier; +use der::asn1::{Ia5String, OctetString, SetOfVec, Utf8StringRef}; +use der::flagset::FlagSet; +use der::{Any, Decode, DecodePem, EncodePem}; +use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}; +use rsa::pkcs1v15::SigningKey; +use rsa::pkcs8::{DecodePublicKey, EncodePublicKey}; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use sha2::Sha256; +use x509_cert::attr::AttributeTypeAndValue; +use x509_cert::builder::{Builder, CertificateBuilder, Profile}; +use x509_cert::ext::pkix::constraints::BasicConstraints; +use x509_cert::ext::pkix::name::{GeneralName, GeneralNames}; +use x509_cert::ext::pkix::{ + ExtendedKeyUsage, KeyUsage, KeyUsages, SubjectAltName, SubjectKeyIdentifier, +}; +use x509_cert::name::{Name, RdnSequence, RelativeDistinguishedName}; +use x509_cert::serial_number::SerialNumber; +use x509_cert::spki::SubjectPublicKeyInfoOwned; +use x509_cert::time::{Time, Validity}; + +// ── OIDs ──────────────────────────────────────────────────────────── +const OID_CN: &str = "2.5.4.3"; // commonName +const OID_O: &str = "2.5.4.10"; // organizationName +const OID_OU: &str = "2.5.4.11"; // organizationalUnitName +const OID_C: &str = "2.5.4.6"; // countryName +const OID_ST: &str = "2.5.4.8"; // stateOrProvinceName +const OID_L: &str = "2.5.4.7"; // localityName + +const OID_SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1"; +const OID_CLIENT_AUTH: &str = "1.3.6.1.5.5.7.3.2"; + +/// A distinguished-name attribute as forge passes it: `{ name?, shortName?, value }`. +#[derive(Debug, Clone)] +pub struct Attr { + /// forge `name` (e.g. `commonName`) or `shortName` (e.g. `CN`). + pub key: String, + pub value: String, +} + +/// The forge extension descriptors this wrapper supports. +#[derive(Debug, Clone, Default)] +pub struct ExtSet { + pub basic_constraints: Option, + pub key_usage: Option, + pub ext_key_usage: Option, + pub subject_alt_names: Vec, + pub subject_key_identifier: bool, +} + +#[derive(Debug, Clone)] +pub struct BasicConstraintsSpec { + pub ca: bool, + pub critical: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct KeyUsageSpec { + pub digital_signature: bool, + pub key_encipherment: bool, + pub key_cert_sign: bool, + pub crl_sign: bool, + pub critical: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct ExtKeyUsageSpec { + pub server_auth: bool, + pub client_auth: bool, +} + +/// Everything needed to build + sign one certificate. +#[derive(Debug, Clone)] +pub struct CertSpec { + /// SPKI PEM of the certificate's OWN public key. + pub public_key_pem: String, + /// Serial number as a hex string (forge convention: `"01"`, `"02"`). + pub serial_hex: String, + pub not_before_unix: i64, + pub not_after_unix: i64, + pub subject: Vec, + pub issuer: Vec, + pub extensions: ExtSet, +} + +// ── keygen + key PEM round-trips ──────────────────────────────────── + +/// Generate an RSA keypair, returning `(privatePkcs1Pem, publicSpkiPem)`. +pub fn generate_key_pair(bits: usize) -> Result<(String, String), String> { + let bits = if bits == 0 { 2048 } else { bits }; + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, bits).map_err(|e| e.to_string())?; + let pub_key = RsaPublicKey::from(&priv_key); + let priv_pem = priv_key + .to_pkcs1_pem(rsa::pkcs1::LineEnding::LF) + .map_err(|e| e.to_string())? + .to_string(); + let pub_pem = pub_key + .to_public_key_pem(rsa::pkcs8::LineEnding::LF) + .map_err(|e| e.to_string())?; + Ok((priv_pem, pub_pem)) +} + +/// Parse a private key from PKCS#1 or PKCS#8 PEM and re-emit as +/// canonical PKCS#1 PEM (forge's `privateKeyToPem` shape). +pub fn normalize_private_key_pem(pem: &str) -> Result { + let key = load_private_key(pem)?; + Ok(key + .to_pkcs1_pem(rsa::pkcs1::LineEnding::LF) + .map_err(|e| e.to_string())? + .to_string()) +} + +/// Load an `RsaPrivateKey` from either PKCS#1 or PKCS#8 PEM. +pub fn load_private_key(pem: &str) -> Result { + if pem.contains("BEGIN RSA PRIVATE KEY") { + RsaPrivateKey::from_pkcs1_pem(pem).map_err(|e| e.to_string()) + } else { + use rsa::pkcs8::DecodePrivateKey; + RsaPrivateKey::from_pkcs8_pem(pem).map_err(|e| e.to_string()) + } +} + +fn load_public_key(pem: &str) -> Result { + if pem.contains("BEGIN RSA PUBLIC KEY") { + use rsa::pkcs1::DecodeRsaPublicKey; + RsaPublicKey::from_pkcs1_pem(pem).map_err(|e| e.to_string()) + } else { + RsaPublicKey::from_public_key_pem(pem).map_err(|e| e.to_string()) + } +} + +// ── DN <-> attrs ──────────────────────────────────────────────────── + +fn oid_for(key: &str) -> Result { + let oid_str = match key { + "commonName" | "CN" => OID_CN, + "organizationName" | "O" => OID_O, + "organizationalUnitName" | "OU" => OID_OU, + "countryName" | "C" => OID_C, + "stateOrProvinceName" | "ST" => OID_ST, + "localityName" | "L" => OID_L, + // Accept a raw dotted OID too. + other + if other + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) => + { + other + } + other => return Err(format!("node-forge: unsupported DN attribute '{other}'")), + }; + ObjectIdentifier::from_str(oid_str).map_err(|e| e.to_string()) +} + +fn name_for(oid: &ObjectIdentifier) -> String { + match oid.to_string().as_str() { + OID_CN => "commonName", + OID_O => "organizationName", + OID_OU => "organizationalUnitName", + OID_C => "countryName", + OID_ST => "stateOrProvinceName", + OID_L => "localityName", + _ => "unknown", + } + .to_string() +} + +/// Build an X.509 `Name` preserving the attribute ORDER exactly. Order +/// preservation is load-bearing: `openssl verify` matches a leaf's +/// issuer DN against the CA's subject DN byte-for-byte, and sfw derives +/// the leaf issuer from `certificateFromPem(ca).subject.attributes` — +/// so `parse_name` (below) must be the exact inverse of this. +fn build_name(attrs: &[Attr]) -> Result { + let mut rdns = Vec::with_capacity(attrs.len()); + for a in attrs { + let oid = oid_for(&a.key)?; + let value = Any::from(Utf8StringRef::new(&a.value).map_err(|e| e.to_string())?); + let atv = AttributeTypeAndValue { oid, value }; + let set = SetOfVec::try_from(vec![atv]).map_err(|e| e.to_string())?; + rdns.push(RelativeDistinguishedName(set)); + } + Ok(RdnSequence(rdns)) +} + +/// Parse a `Name` back into forge-shaped attributes, preserving order. +pub fn parse_name(name: &Name) -> Vec { + let mut out = Vec::new(); + for rdn in name.0.iter() { + for atv in rdn.0.iter() { + let value = atv + .value + .decode_as::>() + .map(|s| s.as_str().to_string()) + .or_else(|_| { + atv.value + .decode_as::>() + .map(|s| s.as_str().to_string()) + }) + .or_else(|_| { + atv.value + .decode_as::>() + .map(|s| s.as_str().to_string()) + }) + .unwrap_or_default(); + out.push(Attr { + key: name_for(&atv.oid), + value, + }); + } + } + out +} + +// ── serial + validity ────────────────────────────────────────────── + +fn serial_from_hex(hex: &str) -> Result { + let trimmed = hex.trim().trim_start_matches("0x"); + let padded = if trimmed.len() % 2 == 1 { + format!("0{trimmed}") + } else { + trimmed.to_string() + }; + let mut bytes = Vec::with_capacity(padded.len() / 2); + let chars: Vec = padded.chars().collect(); + for pair in chars.chunks(2) { + let byte = u8::from_str_radix(&pair.iter().collect::(), 16) + .map_err(|_| format!("node-forge: invalid serialNumber hex '{hex}'"))?; + bytes.push(byte); + } + if bytes.is_empty() { + bytes.push(1); + } + // A leading high bit would make the INTEGER negative; DER serials + // are positive, so prepend a zero byte like forge/openssl do. + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0x00); + } + SerialNumber::new(&bytes).map_err(|e| e.to_string()) +} + +fn time_from_unix(secs: i64) -> Result { + let dur = std::time::Duration::from_secs(secs.max(0) as u64); + let sys = std::time::UNIX_EPOCH + dur; + Time::try_from(sys).map_err(|e| e.to_string()) +} + +// ── extensions ────────────────────────────────────────────────────── + +fn key_usage_ext(spec: &KeyUsageSpec) -> KeyUsage { + let mut flags: FlagSet = FlagSet::default(); + if spec.digital_signature { + flags |= KeyUsages::DigitalSignature; + } + if spec.key_encipherment { + flags |= KeyUsages::KeyEncipherment; + } + if spec.key_cert_sign { + flags |= KeyUsages::KeyCertSign; + } + if spec.crl_sign { + flags |= KeyUsages::CRLSign; + } + KeyUsage(flags) +} + +fn ext_key_usage_ext(spec: &ExtKeyUsageSpec) -> Result { + let mut oids = Vec::new(); + if spec.server_auth { + oids.push(ObjectIdentifier::from_str(OID_SERVER_AUTH).map_err(|e| e.to_string())?); + } + if spec.client_auth { + oids.push(ObjectIdentifier::from_str(OID_CLIENT_AUTH).map_err(|e| e.to_string())?); + } + Ok(ExtendedKeyUsage(oids)) +} + +fn subject_alt_name_ext(hosts: &[String]) -> Result { + let mut names: GeneralNames = Vec::new(); + for h in hosts { + let ia5 = Ia5String::new(h).map_err(|e| e.to_string())?; + names.push(GeneralName::DnsName(ia5)); + } + Ok(SubjectAltName(names)) +} + +// ── build + sign ──────────────────────────────────────────────────── + +/// Build and sign a certificate. `signer_private_key_pem` is the +/// ISSUER's private key (for a self-signed CA it is the same key whose +/// public half is in `spec.public_key_pem`). +pub fn build_and_sign(spec: &CertSpec, signer_private_key_pem: &str) -> Result { + let signer_key = load_private_key(signer_private_key_pem)?; + let signing_key = SigningKey::::new(signer_key); + + let subject = build_name(&spec.subject)?; + let issuer = build_name(&spec.issuer)?; + let serial = serial_from_hex(&spec.serial_hex)?; + let validity = Validity { + not_before: time_from_unix(spec.not_before_unix)?, + not_after: time_from_unix(spec.not_after_unix)?, + }; + + let cert_pub = load_public_key(&spec.public_key_pem)?; + let spki_der = cert_pub + .to_public_key_der() + .map_err(|e| e.to_string())? + .into_vec(); + let spki = SubjectPublicKeyInfoOwned::from_der(&spki_der).map_err(|e| e.to_string())?; + + // Profile::Manual gives us exact control: it injects no extensions + // of its own, so the cert carries precisely what sfw requested. + let profile = Profile::Manual { + issuer: Some(issuer), + }; + + let mut builder = + CertificateBuilder::new(profile, serial, validity, subject, spki, &signing_key) + .map_err(|e| e.to_string())?; + + let exts = &spec.extensions; + if let Some(bc) = &exts.basic_constraints { + builder + .add_extension(&BasicConstraints { + ca: bc.ca, + path_len_constraint: None, + }) + .map_err(|e| e.to_string())?; + } + if let Some(ku) = &exts.key_usage { + builder + .add_extension(&key_usage_ext(ku)) + .map_err(|e| e.to_string())?; + } + if let Some(eku) = &exts.ext_key_usage { + builder + .add_extension(&ext_key_usage_ext(eku)?) + .map_err(|e| e.to_string())?; + } + if !exts.subject_alt_names.is_empty() { + builder + .add_extension(&subject_alt_name_ext(&exts.subject_alt_names)?) + .map_err(|e| e.to_string())?; + } + if exts.subject_key_identifier { + let ski = compute_ski(&spki_der)?; + builder.add_extension(&ski).map_err(|e| e.to_string())?; + } + + let cert = builder + .build::() + .map_err(|e| e.to_string())?; + cert.to_pem(der::pem::LineEnding::LF) + .map_err(|e| e.to_string()) +} + +/// SubjectKeyIdentifier = SHA-1 of the DER-encoded subjectPublicKey BIT +/// STRING contents (RFC 5280 method 1). We hash the whole SPKI DER's +/// public-key bytes; openssl accepts any 20-byte SKI here (it is not +/// checked by `verify`). +fn compute_ski(spki_der: &[u8]) -> Result { + let spki = SubjectPublicKeyInfoOwned::from_der(spki_der).map_err(|e| e.to_string())?; + let key_bytes = spki + .subject_public_key + .as_bytes() + .ok_or("node-forge: SPKI bit-string not byte-aligned")?; + use sha2::Digest; + // SHA-256 truncated to 20 bytes — deterministic, display-only. + let digest = Sha256::digest(key_bytes); + let octet = OctetString::new(&digest[..20]).map_err(|e| e.to_string())?; + Ok(SubjectKeyIdentifier(octet)) +} + +/// Parse the subject attributes out of a certificate PEM (for +/// `certificateFromPem(...).subject.attributes`). +pub fn cert_subject_attrs(pem: &str) -> Result, String> { + let cert = x509_cert::Certificate::from_pem(pem).map_err(|e| e.to_string())?; + Ok(parse_name(&cert.tbs_certificate.subject)) +} + +#[cfg(test)] +mod tests { + use super::*; + use der::Encode; + use rsa::pkcs1::EncodeRsaPrivateKey; + + fn ca_spec(pub_pem: &str) -> CertSpec { + CertSpec { + public_key_pem: pub_pem.to_string(), + serial_hex: "01".to_string(), + not_before_unix: 1_700_000_000, + not_after_unix: 1_800_000_000, + subject: vec![ + Attr { + key: "commonName".into(), + value: "Socket Security CA".into(), + }, + Attr { + key: "organizationName".into(), + value: "Socket Security".into(), + }, + ], + issuer: vec![ + Attr { + key: "commonName".into(), + value: "Socket Security CA".into(), + }, + Attr { + key: "organizationName".into(), + value: "Socket Security".into(), + }, + ], + extensions: ExtSet { + basic_constraints: Some(BasicConstraintsSpec { + ca: true, + critical: true, + }), + key_usage: Some(KeyUsageSpec { + key_cert_sign: true, + critical: true, + ..Default::default() + }), + subject_key_identifier: true, + ..Default::default() + }, + } + } + + #[test] + fn keygen_pem_shapes() { + let (priv_pem, pub_pem) = generate_key_pair(2048).unwrap(); + assert!(priv_pem.contains("-----BEGIN RSA PRIVATE KEY-----")); + assert!(pub_pem.contains("-----BEGIN PUBLIC KEY-----")); + } + + #[test] + fn private_key_pem_round_trip() { + let (priv_pem, _) = generate_key_pair(2048).unwrap(); + let norm = normalize_private_key_pem(&priv_pem).unwrap(); + // Re-loading the normalized PEM yields the same modulus. + let a = load_private_key(&priv_pem).unwrap(); + let b = load_private_key(&norm).unwrap(); + assert_eq!( + a.to_pkcs1_der().unwrap().as_bytes(), + b.to_pkcs1_der().unwrap().as_bytes() + ); + } + + #[test] + fn self_signed_ca_builds_and_parses() { + let (priv_pem, pub_pem) = generate_key_pair(2048).unwrap(); + let ca_pem = build_and_sign(&ca_spec(&pub_pem), &priv_pem).unwrap(); + assert!(ca_pem.contains("-----BEGIN CERTIFICATE-----")); + let attrs = cert_subject_attrs(&ca_pem).unwrap(); + // Order-preserving round-trip: CN first, O second. + assert_eq!(attrs[0].key, "commonName"); + assert_eq!(attrs[0].value, "Socket Security CA"); + assert_eq!(attrs[1].key, "organizationName"); + } + + #[test] + fn issuer_dn_matches_ca_subject_dn() { + // The make-or-break for `openssl verify`: leaf.issuer built from + // parsed CA subject attrs must equal ca.subject byte-for-byte. + let (priv_pem, pub_pem) = generate_key_pair(2048).unwrap(); + let ca_pem = build_and_sign(&ca_spec(&pub_pem), &priv_pem).unwrap(); + let ca = x509_cert::Certificate::from_pem(&ca_pem).unwrap(); + let parsed_attrs = parse_name(&ca.tbs_certificate.subject); + let rebuilt = build_name(&parsed_attrs).unwrap(); + assert_eq!( + rebuilt.to_der().unwrap(), + ca.tbs_certificate.subject.to_der().unwrap(), + "rebuilt issuer DN must match CA subject DN exactly" + ); + } +} diff --git a/crates/perry-ext-node-forge/src/lib.rs b/crates/perry-ext-node-forge/src/lib.rs new file mode 100644 index 0000000000..b3b5ac2fa3 --- /dev/null +++ b/crates/perry-ext-node-forge/src/lib.rs @@ -0,0 +1,615 @@ +//! Native bindings for the npm `node-forge` package — the PKI subset. +//! +//! Scope is exactly what Socket Firewall's TLS-MITM CA uses: +//! - `forge.pki.rsa.generateKeyPair({ bits })` +//! - `forge.pki.createCertificate()` + the builder methods +//! (`setSubject` / `setIssuer` / `setExtensions` / `sign`) and the +//! settable fields (`publicKey`, `serialNumber`, `validity.*`) +//! - `forge.pki.certificateFromPem` / `certificateToPem` +//! - `forge.pki.privateKeyFromPem` / `privateKeyToPem` / `publicKeyToPem` +//! - `forge.md.sha256.create()` +//! +//! Everything else in forge's surface has no dispatch row here, so a +//! call to it surfaces as an unresolved `node-forge` method naming the +//! API rather than silently succeeding. +//! +//! ## Object model +//! +//! The certificate builder is a real perry JS object (allocated with a +//! fixed 7-field shape) so the plain field assignments sfw performs +//! (`cert.publicKey = …`, `cert.serialNumber = '01'`, +//! `cert.validity.notBefore = new Date()`) are ordinary JS property +//! sets — no native involvement. Only the *methods* dispatch here: +//! `setSubject`/`setIssuer`/`setExtensions` write their argument into a +//! fixed slot, and `sign` serializes the whole object with +//! `JSON.stringify` (via `perry_ffi::json_stringify`), builds + signs +//! the X.509 cert with the RustCrypto core in [`crypto`], and stashes +//! the resulting PEM back into the object's `signaturePem` slot for +//! `certificateToPem` to read. + +pub mod crypto; + +use perry_ffi::{ + alloc_string, build_object_shape, js_object_alloc_with_shape, js_object_set_field, + json_stringify, read_string, JsString, JsValue, ObjectHeader, StringHeader, +}; +use serde::Deserialize; + +use crypto::{Attr, BasicConstraintsSpec, CertSpec, ExtKeyUsageSpec, ExtSet, KeyUsageSpec}; + +// Fixed field layout of the certificate builder object. `create_certificate` +// allocates this shape; the setter FFIs write by index; `sign` / +// `certificateToPem` read `signaturePem` back via JSON. +const CERT_KEYS: &[&str] = &[ + "publicKey", // 0 + "serialNumber", // 1 + "validity", // 2 (sub-object { notBefore, notAfter }) + "subject", // 3 (array set by setSubject) + "issuer", // 4 (array set by setIssuer) + "extensions", // 5 (array set by setExtensions) + "signaturePem", // 6 (filled by sign) +]; +const FIELD_SUBJECT: u32 = 3; +const FIELD_ISSUER: u32 = 4; +const FIELD_EXTENSIONS: u32 = 5; +const FIELD_SIGNATURE_PEM: u32 = 6; + +// ── small FFI helpers ─────────────────────────────────────────────── + +unsafe fn read_str(ptr: *const StringHeader) -> Option { + if ptr.is_null() { + return None; + } + let handle = JsString::from_raw(ptr as *mut StringHeader); + read_string(handle).map(String::from) +} + +fn str_out(s: &str) -> *mut StringHeader { + alloc_string(s).as_raw() +} + +/// Build a `{ pem: }` JS object — the shape used for key handles. +fn key_object(pem: &str) -> JsValue { + let (packed, shape_id) = build_object_shape(&["pem"]); + unsafe { + let obj = js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32); + let pem_str = alloc_string(pem); + js_object_set_field(obj, 0, JsValue::from_string_ptr(pem_str.as_raw())); + JsValue::from_object_ptr(obj) + } +} + +/// JSON.stringify a NaN-boxed value passed across the FFI as `f64` bits. +fn stringify_arg(bits: f64) -> Option { + json_stringify(JsValue::from_bits(bits.to_bits())) +} + +// ── JSON shapes coming from json_stringify(cert) ──────────────────── + +#[derive(Deserialize, Default)] +struct KeyObj { + pem: Option, +} + +#[derive(Deserialize)] +struct AttrJson { + name: Option, + #[serde(rename = "shortName")] + short_name: Option, + #[serde(rename = "type")] + type_oid: Option, + value: Option, +} + +#[derive(Deserialize)] +struct AltNameJson { + #[serde(rename = "type")] + typ: Option, + value: Option, +} + +#[derive(Deserialize)] +struct ExtJson { + name: Option, + #[serde(rename = "cA")] + c_a: Option, + critical: Option, + #[serde(rename = "keyCertSign")] + key_cert_sign: Option, + #[serde(rename = "cRLSign", alias = "crlSign")] + crl_sign: Option, + #[serde(rename = "digitalSignature")] + digital_signature: Option, + #[serde(rename = "keyEncipherment")] + key_encipherment: Option, + #[serde(rename = "serverAuth")] + server_auth: Option, + #[serde(rename = "clientAuth")] + client_auth: Option, + #[serde(rename = "altNames")] + alt_names: Option>, +} + +#[derive(Deserialize, Default)] +struct ValidityJson { + #[serde(rename = "notBefore")] + not_before: Option, + #[serde(rename = "notAfter")] + not_after: Option, +} + +#[derive(Deserialize)] +struct CertJson { + #[serde(rename = "publicKey")] + public_key: Option, + #[serde(rename = "serialNumber")] + serial_number: Option, + validity: Option, + subject: Option>, + issuer: Option>, + extensions: Option>, +} + +fn attr_key(a: &AttrJson) -> Option { + a.name + .clone() + .or_else(|| a.short_name.clone()) + .or_else(|| a.type_oid.clone()) +} + +fn value_to_string(v: &Option) -> String { + match v { + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Number(n)) => n.to_string(), + _ => String::new(), + } +} + +fn attrs_from(json: &[AttrJson]) -> Vec { + json.iter() + .filter_map(|a| { + let key = attr_key(a)?; + Some(Attr { + key, + value: value_to_string(&a.value), + }) + }) + .collect() +} + +/// Parse a validity endpoint. `JSON.stringify(Date)` yields an ISO-8601 +/// string; we also accept an epoch-milliseconds number as a fallback. +fn parse_time(v: &Option) -> i64 { + match v { + Some(serde_json::Value::String(s)) => { + time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) + .map(|dt| dt.unix_timestamp()) + .unwrap_or(0) + } + Some(serde_json::Value::Number(n)) => (n.as_f64().unwrap_or(0.0) / 1000.0) as i64, + _ => 0, + } +} + +fn ext_set_from(exts: &[ExtJson]) -> ExtSet { + let mut set = ExtSet::default(); + for e in exts { + match e.name.as_deref() { + Some("basicConstraints") => { + set.basic_constraints = Some(BasicConstraintsSpec { + ca: e.c_a.unwrap_or(false), + critical: e.critical.unwrap_or(false), + }); + } + Some("keyUsage") => { + set.key_usage = Some(KeyUsageSpec { + digital_signature: e.digital_signature.unwrap_or(false), + key_encipherment: e.key_encipherment.unwrap_or(false), + key_cert_sign: e.key_cert_sign.unwrap_or(false), + crl_sign: e.crl_sign.unwrap_or(false), + critical: e.critical.unwrap_or(false), + }); + } + Some("extKeyUsage") => { + set.ext_key_usage = Some(ExtKeyUsageSpec { + server_auth: e.server_auth.unwrap_or(false), + client_auth: e.client_auth.unwrap_or(false), + }); + } + Some("subjectAltName") => { + if let Some(alts) = &e.alt_names { + for a in alts { + // type 2 == dNSName (the only form sfw emits). + if a.typ.unwrap_or(2) == 2 { + if let Some(v) = &a.value { + set.subject_alt_names.push(v.clone()); + } + } + } + } + } + Some("subjectKeyIdentifier") => set.subject_key_identifier = true, + _ => {} + } + } + set +} + +/// Assemble a `CertSpec` from the JSON serialization of a builder cert +/// object. Pure (no FFI) so it can be unit-tested directly. +fn cert_spec_from_json(cert_json: &str) -> Result { + let c: CertJson = serde_json::from_str(cert_json).map_err(|e| e.to_string())?; + let public_key_pem = c + .public_key + .and_then(|k| k.pem) + .ok_or("node-forge: cert.publicKey is not set")?; + let validity = c.validity.unwrap_or_default(); + Ok(CertSpec { + public_key_pem, + serial_hex: value_to_string(&c.serial_number), + not_before_unix: parse_time(&validity.not_before), + not_after_unix: parse_time(&validity.not_after), + subject: c.subject.as_deref().map(attrs_from).unwrap_or_default(), + issuer: c.issuer.as_deref().map(attrs_from).unwrap_or_default(), + extensions: c + .extensions + .as_deref() + .map(ext_set_from) + .unwrap_or_default(), + }) +} + +// ──────────────────────────────────────────────────────────────────── +// FFI entry points +// ──────────────────────────────────────────────────────────────────── + +/// `forge.pki.rsa.generateKeyPair({ bits })` → +/// `{ publicKey: { pem }, privateKey: { pem } }`. +/// +/// `bits` is the numeric key size (sfw passes `2048`). The `workers` +/// option is ignored — keygen is synchronous here. +#[no_mangle] +pub extern "C" fn js_node_forge_generate_key_pair(bits: f64) -> JsValue { + let bits = if bits.is_finite() && bits > 0.0 { + bits as usize + } else { + 2048 + }; + match crypto::generate_key_pair(bits) { + Ok((priv_pem, pub_pem)) => { + let (packed, shape_id) = build_object_shape(&["publicKey", "privateKey"]); + unsafe { + let obj = + js_object_alloc_with_shape(shape_id, 2, packed.as_ptr(), packed.len() as u32); + js_object_set_field(obj, 0, key_object(&pub_pem)); + js_object_set_field(obj, 1, key_object(&priv_pem)); + JsValue::from_object_ptr(obj) + } + } + Err(_) => JsValue::NULL, + } +} + +/// `forge.pki.privateKeyToPem(key)` — PKCS#1 `RSA PRIVATE KEY` PEM. +#[no_mangle] +pub extern "C" fn js_node_forge_private_key_to_pem(key_bits: f64) -> *mut StringHeader { + let Some(json) = stringify_arg(key_bits) else { + return std::ptr::null_mut(); + }; + let Ok(k) = serde_json::from_str::(&json) else { + return std::ptr::null_mut(); + }; + let Some(pem) = k.pem else { + return std::ptr::null_mut(); + }; + match crypto::normalize_private_key_pem(&pem) { + Ok(out) => str_out(&out), + // Already PKCS#1 or unparseable — echo the stored PEM. + Err(_) => str_out(&pem), + } +} + +/// `forge.pki.publicKeyToPem(key)` — SPKI `PUBLIC KEY` PEM. +#[no_mangle] +pub extern "C" fn js_node_forge_public_key_to_pem(key_bits: f64) -> *mut StringHeader { + let Some(json) = stringify_arg(key_bits) else { + return std::ptr::null_mut(); + }; + match serde_json::from_str::(&json) + .ok() + .and_then(|k| k.pem) + { + Some(pem) => str_out(&pem), + None => std::ptr::null_mut(), + } +} + +/// `forge.pki.privateKeyFromPem(pem)` → `{ pem }` key handle. +/// +/// # Safety +/// `pem_ptr` must be null or a Perry-runtime `StringHeader`. +#[no_mangle] +pub unsafe extern "C" fn js_node_forge_private_key_from_pem( + pem_ptr: *const StringHeader, +) -> JsValue { + let Some(pem) = read_str(pem_ptr) else { + return JsValue::NULL; + }; + // Normalize to PKCS#1 so downstream signing / toPem is uniform; + // fall back to the raw PEM if it doesn't parse as RSA. + let normalized = crypto::normalize_private_key_pem(&pem).unwrap_or(pem); + key_object(&normalized) +} + +/// `forge.pki.certificateToPem(cert)` — the signed PEM stashed by `sign`. +#[no_mangle] +pub extern "C" fn js_node_forge_certificate_to_pem(cert_bits: f64) -> *mut StringHeader { + let Some(json) = stringify_arg(cert_bits) else { + return std::ptr::null_mut(); + }; + #[derive(Deserialize)] + struct Sig { + #[serde(rename = "signaturePem")] + signature_pem: Option, + } + match serde_json::from_str::(&json) + .ok() + .and_then(|s| s.signature_pem) + { + Some(pem) => str_out(&pem), + None => std::ptr::null_mut(), + } +} + +/// `forge.pki.certificateFromPem(pem)` → a cert object exposing +/// `subject.attributes` (the only field sfw reads back off a parsed +/// cert, for `setIssuer(caCert.subject.attributes)`). +/// +/// # Safety +/// `pem_ptr` must be null or a Perry-runtime `StringHeader`. +#[no_mangle] +pub unsafe extern "C" fn js_node_forge_certificate_from_pem( + pem_ptr: *const StringHeader, +) -> JsValue { + let Some(pem) = read_str(pem_ptr) else { + return JsValue::NULL; + }; + let attrs = match crypto::cert_subject_attrs(&pem) { + Ok(a) => a, + Err(_) => return JsValue::NULL, + }; + // Build `subject.attributes = [{ name, value }, …]`. + let attrs_arr = { + let arr = perry_ffi::js_array_alloc(attrs.len() as u32); + let mut arr = arr; + for a in &attrs { + let (packed, shape_id) = build_object_shape(&["name", "value"]); + let o = js_object_alloc_with_shape(shape_id, 2, packed.as_ptr(), packed.len() as u32); + js_object_set_field( + o, + 0, + JsValue::from_string_ptr(alloc_string(&a.key).as_raw()), + ); + js_object_set_field( + o, + 1, + JsValue::from_string_ptr(alloc_string(&a.value).as_raw()), + ); + arr = perry_ffi::js_array_push(arr, JsValue::from_object_ptr(o)); + } + JsValue::from_object_ptr(arr) + }; + let (spacked, sshape) = build_object_shape(&["attributes"]); + let subject = js_object_alloc_with_shape(sshape, 1, spacked.as_ptr(), spacked.len() as u32); + js_object_set_field(subject, 0, attrs_arr); + + let (packed, shape_id) = build_object_shape(&["subject", "signaturePem"]); + let obj = js_object_alloc_with_shape(shape_id, 2, packed.as_ptr(), packed.len() as u32); + js_object_set_field(obj, 0, JsValue::from_object_ptr(subject)); + // Store the original PEM so a re-`certificateToPem(caCert)` round-trips. + js_object_set_field( + obj, + 1, + JsValue::from_string_ptr(alloc_string(&pem).as_raw()), + ); + JsValue::from_object_ptr(obj) +} + +/// `forge.pki.createCertificate()` — a builder object with the fixed +/// 7-field shape plus a pre-created `validity` sub-object so +/// `cert.validity.notBefore = …` is an ordinary JS set. +#[no_mangle] +pub extern "C" fn js_node_forge_create_certificate() -> JsValue { + unsafe { + let (vpacked, vshape) = build_object_shape(&["notBefore", "notAfter"]); + let validity = + js_object_alloc_with_shape(vshape, 2, vpacked.as_ptr(), vpacked.len() as u32); + js_object_set_field(validity, 0, JsValue::NULL); + js_object_set_field(validity, 1, JsValue::NULL); + + let (packed, shape_id) = build_object_shape(CERT_KEYS); + let obj = js_object_alloc_with_shape( + shape_id, + CERT_KEYS.len() as u32, + packed.as_ptr(), + packed.len() as u32, + ); + js_object_set_field(obj, 0, JsValue::NULL); // publicKey + js_object_set_field(obj, 1, JsValue::NULL); // serialNumber + js_object_set_field(obj, 2, JsValue::from_object_ptr(validity)); + js_object_set_field(obj, 3, JsValue::NULL); // subject + js_object_set_field(obj, 4, JsValue::NULL); // issuer + js_object_set_field(obj, 5, JsValue::NULL); // extensions + js_object_set_field(obj, 6, JsValue::NULL); // signaturePem + JsValue::from_object_ptr(obj) + } +} + +/// `cert.setSubject(attrs)` — store the attribute array in slot 3. +/// +/// # Safety +/// `cert` must be the NaN-unboxed `*mut ObjectHeader` of a builder cert. +#[no_mangle] +pub unsafe extern "C" fn js_node_forge_cert_set_subject(cert: i64, attrs_bits: f64) { + let obj = cert as *mut ObjectHeader; + if !obj.is_null() { + js_object_set_field(obj, FIELD_SUBJECT, JsValue::from_bits(attrs_bits.to_bits())); + } +} + +/// `cert.setIssuer(attrs)` — store the attribute array in slot 4. +/// +/// # Safety +/// See [`js_node_forge_cert_set_subject`]. +#[no_mangle] +pub unsafe extern "C" fn js_node_forge_cert_set_issuer(cert: i64, attrs_bits: f64) { + let obj = cert as *mut ObjectHeader; + if !obj.is_null() { + js_object_set_field(obj, FIELD_ISSUER, JsValue::from_bits(attrs_bits.to_bits())); + } +} + +/// `cert.setExtensions(exts)` — store the extension array in slot 5. +/// +/// # Safety +/// See [`js_node_forge_cert_set_subject`]. +#[no_mangle] +pub unsafe extern "C" fn js_node_forge_cert_set_extensions(cert: i64, exts_bits: f64) { + let obj = cert as *mut ObjectHeader; + if !obj.is_null() { + js_object_set_field( + obj, + FIELD_EXTENSIONS, + JsValue::from_bits(exts_bits.to_bits()), + ); + } +} + +/// `cert.sign(privateKey, md)` — serialize the builder object, build + +/// sign the X.509 cert with the issuer's private key, and stash the +/// resulting PEM in slot 6 (`signaturePem`). `md` (a `forge.md.*` +/// digest) is accepted for API compatibility; only SHA-256 is +/// supported, matching sfw. +/// +/// # Safety +/// `cert` must be the NaN-unboxed `*mut ObjectHeader` of a builder cert. +#[no_mangle] +pub unsafe extern "C" fn js_node_forge_cert_sign(cert: i64, key_bits: f64, _md_bits: f64) { + let obj = cert as *mut ObjectHeader; + if obj.is_null() { + return; + } + let cert_value = JsValue::from_object_ptr(obj); + let Some(cert_json) = json_stringify(cert_value) else { + return; + }; + let Some(key_json) = stringify_arg(key_bits) else { + return; + }; + let Ok(key) = serde_json::from_str::(&key_json) else { + return; + }; + let Some(signer_pem) = key.pem else { + return; + }; + let Ok(spec) = cert_spec_from_json(&cert_json) else { + return; + }; + if let Ok(pem) = crypto::build_and_sign(&spec, &signer_pem) { + let pem_str = alloc_string(&pem); + js_object_set_field( + obj, + FIELD_SIGNATURE_PEM, + JsValue::from_string_ptr(pem_str.as_raw()), + ); + } +} + +/// `forge.md.sha256.create()` — a small marker object. `sign` reads the +/// certificate's own fields and always uses SHA-256, so the digest just +/// needs to exist as a value the caller can pass through. +#[no_mangle] +pub extern "C" fn js_node_forge_md_sha256_create() -> JsValue { + let (packed, shape_id) = build_object_shape(&["algorithm"]); + unsafe { + let obj = js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32); + js_object_set_field( + obj, + 0, + JsValue::from_string_ptr(alloc_string("sha256").as_raw()), + ); + JsValue::from_object_ptr(obj) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cert_spec_parses_sfw_shaped_json() { + // Shape produced by JSON.stringify of a builder cert after sfw's + // host.ts populates it. + let json = r#"{ + "publicKey": { "pem": "-----BEGIN PUBLIC KEY-----\nAAA\n-----END PUBLIC KEY-----\n" }, + "serialNumber": "02", + "validity": { + "notBefore": "2026-07-29T00:00:00.000Z", + "notAfter": "2027-07-29T00:00:00.000Z" + }, + "subject": [{ "name": "commonName", "value": "example.com" }], + "issuer": [ + { "name": "commonName", "value": "Socket Security CA" }, + { "name": "organizationName", "value": "Socket Security" } + ], + "extensions": [ + { "name": "basicConstraints", "cA": false }, + { "name": "keyUsage", "digitalSignature": true, "keyEncipherment": true }, + { "name": "extKeyUsage", "serverAuth": true }, + { "name": "subjectAltName", "altNames": [ + { "type": 2, "value": "example.com" }, + { "type": 2, "value": "www.example.com" } + ]} + ], + "signaturePem": null + }"#; + let spec = cert_spec_from_json(json).expect("parse"); + assert_eq!(spec.serial_hex, "02"); + assert_eq!(spec.subject.len(), 1); + assert_eq!(spec.subject[0].key, "commonName"); + assert_eq!(spec.issuer.len(), 2); + assert_eq!(spec.issuer[1].key, "organizationName"); + assert!(spec.not_before_unix > 0 && spec.not_after_unix > spec.not_before_unix); + let ext = &spec.extensions; + assert!(ext.basic_constraints.as_ref().unwrap().ca == false); + assert!(ext.key_usage.as_ref().unwrap().digital_signature); + assert!(ext.ext_key_usage.as_ref().unwrap().server_auth); + assert_eq!( + ext.subject_alt_names, + vec!["example.com", "www.example.com"] + ); + } + + #[test] + fn ca_ext_shape_from_json() { + let json = r#"{ + "publicKey": { "pem": "x" }, + "serialNumber": "01", + "validity": { "notBefore": 1700000000000, "notAfter": 1800000000000 }, + "subject": [{ "shortName": "CN", "value": "CA" }], + "issuer": [{ "shortName": "CN", "value": "CA" }], + "extensions": [ + { "name": "basicConstraints", "cA": true, "critical": true }, + { "name": "keyUsage", "keyCertSign": true, "critical": true }, + { "name": "subjectKeyIdentifier" } + ] + }"#; + let spec = cert_spec_from_json(json).expect("parse"); + // Numeric epoch-ms fallback for validity. + assert_eq!(spec.not_before_unix, 1_700_000_000); + assert_eq!(spec.subject[0].key, "CN"); + assert!(spec.extensions.basic_constraints.as_ref().unwrap().ca); + assert!(spec.extensions.key_usage.as_ref().unwrap().key_cert_sign); + assert!(spec.extensions.subject_key_identifier); + } +} diff --git a/crates/perry-ext-node-forge/tests/openssl_e2e.rs b/crates/perry-ext-node-forge/tests/openssl_e2e.rs new file mode 100644 index 0000000000..ef7bc60c7a --- /dev/null +++ b/crates/perry-ext-node-forge/tests/openssl_e2e.rs @@ -0,0 +1,160 @@ +//! End-to-end acceptance test: build a CA + leaf exactly the way Socket +//! Firewall's TLS-MITM path does (via the crate's PKI core), then verify +//! the chain with the real `openssl` CLI. This is the acceptance bar for +//! the wrapper's fidelity — a cert real TLS clients accept. +//! +//! Skips (does not fail) when `openssl` is not on PATH. + +use std::io::Write; +use std::process::Command; + +use perry_ext_node_forge::crypto::*; + +fn openssl_available() -> bool { + Command::new("openssl") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn ca_attrs() -> Vec { + vec![ + Attr { + key: "commonName".into(), + value: "Socket Security CA".into(), + }, + Attr { + key: "organizationName".into(), + value: "Socket Security".into(), + }, + ] +} + +#[test] +fn ca_and_leaf_verify_with_openssl() { + if !openssl_available() { + eprintln!("openssl not found on PATH — skipping e2e verification"); + return; + } + + // ── CA (mirrors src/lib/util/genCaKeyPair.ts) ────────────────── + let (ca_priv_pem, ca_pub_pem) = generate_key_pair(2048).unwrap(); + let ca_spec = CertSpec { + public_key_pem: ca_pub_pem, + serial_hex: "01".into(), + not_before_unix: 1_700_000_000, + not_after_unix: 1_900_000_000, + subject: ca_attrs(), + issuer: ca_attrs(), + extensions: ExtSet { + basic_constraints: Some(BasicConstraintsSpec { + ca: true, + critical: true, + }), + key_usage: Some(KeyUsageSpec { + key_cert_sign: true, + crl_sign: true, + critical: true, + ..Default::default() + }), + subject_key_identifier: true, + ..Default::default() + }, + }; + let ca_pem = build_and_sign(&ca_spec, &ca_priv_pem).unwrap(); + + // ── Leaf (mirrors src/lib/firewall/cert/host.ts) ─────────────── + // Issuer is derived from parsing the CA's subject — the sfw path. + let ca_subject_attrs = cert_subject_attrs(&ca_pem).unwrap(); + let (_leaf_priv_pem, leaf_pub_pem) = generate_key_pair(2048).unwrap(); + let leaf_spec = CertSpec { + public_key_pem: leaf_pub_pem, + serial_hex: "02".into(), + not_before_unix: 1_700_000_000, + not_after_unix: 1_800_000_000, + subject: vec![Attr { + key: "commonName".into(), + value: "example.com".into(), + }], + issuer: ca_subject_attrs, + extensions: ExtSet { + basic_constraints: Some(BasicConstraintsSpec { + ca: false, + critical: false, + }), + key_usage: Some(KeyUsageSpec { + digital_signature: true, + key_encipherment: true, + ..Default::default() + }), + ext_key_usage: Some(ExtKeyUsageSpec { + server_auth: true, + ..Default::default() + }), + subject_alt_names: vec!["example.com".into(), "www.example.com".into()], + ..Default::default() + }, + }; + // Signed by the CA's PRIVATE key. + let leaf_pem = build_and_sign(&leaf_spec, &ca_priv_pem).unwrap(); + + let dir = std::env::temp_dir().join("perry_node_forge_e2e"); + std::fs::create_dir_all(&dir).unwrap(); + let ca_path = dir.join("ca.pem"); + let leaf_path = dir.join("leaf.pem"); + std::fs::File::create(&ca_path) + .unwrap() + .write_all(ca_pem.as_bytes()) + .unwrap(); + std::fs::File::create(&leaf_path) + .unwrap() + .write_all(leaf_pem.as_bytes()) + .unwrap(); + + // openssl verify -CAfile ca.pem leaf.pem + let verify = Command::new("openssl") + .arg("verify") + .arg("-CAfile") + .arg(&ca_path) + .arg(&leaf_path) + .output() + .unwrap(); + let vout = String::from_utf8_lossy(&verify.stdout); + let verr = String::from_utf8_lossy(&verify.stderr); + println!("openssl verify stdout: {vout}"); + println!("openssl verify stderr: {verr}"); + assert!( + verify.status.success() && vout.contains(": OK"), + "openssl verify failed: {vout}{verr}" + ); + + // openssl x509 -in leaf.pem -text — SANs + extensions visible. + let text = Command::new("openssl") + .arg("x509") + .arg("-in") + .arg(&leaf_path) + .arg("-text") + .arg("-noout") + .output() + .unwrap(); + let tout = String::from_utf8_lossy(&text.stdout); + println!("openssl x509 -text:\n{tout}"); + assert!(text.status.success(), "openssl x509 -text failed"); + assert!( + tout.contains("DNS:example.com"), + "SAN example.com missing:\n{tout}" + ); + assert!( + tout.contains("DNS:www.example.com"), + "SAN www.example.com missing:\n{tout}" + ); + assert!( + tout.contains("sha256WithRSAEncryption"), + "expected sha256WithRSAEncryption:\n{tout}" + ); + assert!( + tout.contains("TLS Web Server Authentication"), + "expected serverAuth EKU:\n{tout}" + ); +} diff --git a/crates/perry-hir/src/js_transform/local_natives.rs b/crates/perry-hir/src/js_transform/local_natives.rs index c4e8131bf1..f18df0d919 100644 --- a/crates/perry-hir/src/js_transform/local_natives.rs +++ b/crates/perry-hir/src/js_transform/local_natives.rs @@ -1355,6 +1355,11 @@ pub fn detect_native_instance_creation_with_context( // Tagging the local as CheerioAPI lets the rewriter below // turn `$(sel)` into `NativeMethodCall(cheerio.select, $)`. ("cheerio", "load" | "loadFragment") => "CheerioAPI", + // node-forge: `forge.pki.createCertificate()` returns a + // mutable cert builder whose instance methods + // (setSubject/setIssuer/setExtensions/sign) dispatch under + // class "Certificate" (see NATIVE_MODULE_TABLE). + ("node-forge", "createCertificate") => "Certificate", _ => return None, }; // For ("net", _) / ("tls", _) factories, `s` belongs to net.Socket's diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 2ec5634d1f..1c530a1de7 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -550,3 +550,15 @@ tracking = "#516" crate = "perry-ext-ads" lib = "perry_ext_ads" tracking = "#867" + +# `node-forge` — the PKI subset (RSA keygen, X.509 build/sign, PEM +# round-trips) that Socket Firewall's TLS-MITM CA uses. Ports the pure-JS +# bignum/crypto/ASN.1 stack to native RustCrypto (`rsa` + `x509-cert`) so +# apps stop AOT-compiling forge's enormous JS. Hyphenated package name → +# `js_node_forge_*` symbol prefix + `perry_ext_node_forge` lib (same +# convention as `date-fns`/`lru-cache`). CPU-only: not in +# `binding_needs_shared_tokio`. +[bindings.node-forge] +crate = "perry-ext-node-forge" +lib = "perry_ext_node_forge" +tracking = "#466" From 2e15a0c9b197b76c5a4d2d9ad7fe8a2b21454f59 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 29 Jul 2026 22:51:06 -0400 Subject: [PATCH 2/4] feat(hir): flatten node-forge sub-namespace calls; make PKI binding work end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The perry-ext-node-forge crypto core (added earlier this branch) was inert from compiled TypeScript: forge's API is deeply nested (forge.pki.rsa.generateKeyPair, forge.pki.createCertificate, forge.md.sha256.create), so the call receiver is a Member CHAIN, not a bare native-module Ident — no dispatch arm fired, and an intermediate read like forge.pki hit the unimplemented-API gate (no node-forge symbol 'pki') and deferred a throw. Adds try_node_forge_namespace in perry-hir's call lowering: collapse any member chain rooted at the node-forge default import to its last segment (the method key the codegen NATIVE_MODULE_TABLE rows already use). Runs before the generic namespace / module.Class.staticMethod dispatch, which would otherwise claim the 2-level forge.pki.createCertificate() shape and gate its forge.pki object-read. Also fixes a wrapper fidelity gap: setSubject/setIssuer now store node-forge's { attributes: [...] } DN shape (matching certificateFromPem), so leaf.setIssuer(caCert.subject.attributes) — the sfw idiom — reads the CA subject back and the leaf's issuer DN matches the CA. certificateToPem accepts both the wrapped and bare-array shapes. End-to-end: a CA+leaf generated by the COMPILED binary from real forge.pki.* TypeScript (fixture with no node-forge installed, served by the well-known table) now verifies with the openssl CLI (leaf.pem: OK; issuer=CN=Socket Security CA), correct SANs / keyUsage / extKeyUsage / sha256WithRSAEncryption. Adds an upstream provenance pin (lock-step per #7031) and 3 HIR flattening tests. perry bin 753 pass; node-forge crate 6+1 (incl. openssl e2e); new HIR tests 3/3. (3 pre-existing write-PIC codegen failures on this worktree are unrelated — they fail identically on branches with none of this work.) --- crates/perry-ext-node-forge/src/lib.rs | 58 ++++++++-- crates/perry-hir/src/lower/expr_call/mod.rs | 11 ++ .../src/lower/expr_call/native_module.rs | 70 ++++++++++++ .../tests/node_forge_namespace_lowering.rs | 104 ++++++++++++++++++ crates/perry/well_known_bindings.toml | 9 ++ 5 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 crates/perry-hir/tests/node_forge_namespace_lowering.rs diff --git a/crates/perry-ext-node-forge/src/lib.rs b/crates/perry-ext-node-forge/src/lib.rs index b3b5ac2fa3..aa66ba9f24 100644 --- a/crates/perry-ext-node-forge/src/lib.rs +++ b/crates/perry-ext-node-forge/src/lib.rs @@ -138,6 +138,27 @@ struct ValidityJson { not_after: Option, } +/// A distinguished name as it appears on a builder cert. node-forge models +/// `cert.subject` / `cert.issuer` as `{ attributes: [{name,value}, …] }`, so +/// that is the canonical shape (produced by `setSubject`/`setIssuer` and +/// `certificateFromPem` alike). A bare `[{name,value}]` array is also accepted +/// so a hand-built cert object still round-trips. +#[derive(Deserialize)] +#[serde(untagged)] +enum DnJson { + Wrapped { attributes: Vec }, + Bare(Vec), +} + +impl DnJson { + fn attributes(&self) -> &[AttrJson] { + match self { + DnJson::Wrapped { attributes } => attributes, + DnJson::Bare(v) => v, + } + } +} + #[derive(Deserialize)] struct CertJson { #[serde(rename = "publicKey")] @@ -145,8 +166,8 @@ struct CertJson { #[serde(rename = "serialNumber")] serial_number: Option, validity: Option, - subject: Option>, - issuer: Option>, + subject: Option, + issuer: Option, extensions: Option>, } @@ -249,8 +270,16 @@ fn cert_spec_from_json(cert_json: &str) -> Result { serial_hex: value_to_string(&c.serial_number), not_before_unix: parse_time(&validity.not_before), not_after_unix: parse_time(&validity.not_after), - subject: c.subject.as_deref().map(attrs_from).unwrap_or_default(), - issuer: c.issuer.as_deref().map(attrs_from).unwrap_or_default(), + subject: c + .subject + .as_ref() + .map(|d| attrs_from(d.attributes())) + .unwrap_or_default(), + issuer: c + .issuer + .as_ref() + .map(|d| attrs_from(d.attributes())) + .unwrap_or_default(), extensions: c .extensions .as_deref() @@ -445,7 +474,20 @@ pub extern "C" fn js_node_forge_create_certificate() -> JsValue { } } -/// `cert.setSubject(attrs)` — store the attribute array in slot 3. +/// Wrap a distinguished-name attribute array as node-forge's `{ attributes }` +/// object, so a later `cert.subject.attributes` read returns the array (the +/// shape `certificateFromPem` also produces). `setIssuer(caCert.subject. +/// attributes)` — the sfw idiom — passes the already-unwrapped array back in, +/// and it is re-wrapped here, keeping both DN fields uniform for +/// `certificateToPem`. +unsafe fn wrap_dn_attributes(attrs_bits: f64) -> JsValue { + let (packed, shape_id) = build_object_shape(&["attributes"]); + let dn = js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32); + js_object_set_field(dn, 0, JsValue::from_bits(attrs_bits.to_bits())); + JsValue::from_object_ptr(dn) +} + +/// `cert.setSubject(attrs)` — store `{ attributes: attrs }` in slot 3. /// /// # Safety /// `cert` must be the NaN-unboxed `*mut ObjectHeader` of a builder cert. @@ -453,11 +495,11 @@ pub extern "C" fn js_node_forge_create_certificate() -> JsValue { pub unsafe extern "C" fn js_node_forge_cert_set_subject(cert: i64, attrs_bits: f64) { let obj = cert as *mut ObjectHeader; if !obj.is_null() { - js_object_set_field(obj, FIELD_SUBJECT, JsValue::from_bits(attrs_bits.to_bits())); + js_object_set_field(obj, FIELD_SUBJECT, wrap_dn_attributes(attrs_bits)); } } -/// `cert.setIssuer(attrs)` — store the attribute array in slot 4. +/// `cert.setIssuer(attrs)` — store `{ attributes: attrs }` in slot 4. /// /// # Safety /// See [`js_node_forge_cert_set_subject`]. @@ -465,7 +507,7 @@ pub unsafe extern "C" fn js_node_forge_cert_set_subject(cert: i64, attrs_bits: f pub unsafe extern "C" fn js_node_forge_cert_set_issuer(cert: i64, attrs_bits: f64) { let obj = cert as *mut ObjectHeader; if !obj.is_null() { - js_object_set_field(obj, FIELD_ISSUER, JsValue::from_bits(attrs_bits.to_bits())); + js_object_set_field(obj, FIELD_ISSUER, wrap_dn_attributes(attrs_bits)); } } diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 9cc0ce0d60..ef206536d1 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -452,6 +452,17 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result return Ok(e), + Err(a) => a, + }; + // Nested 3-level Member dispatch: process.hrtime.bigint(), // crypto.subtle.(), util.types.(), and // path.posix/win32.(). diff --git a/crates/perry-hir/src/lower/expr_call/native_module.rs b/crates/perry-hir/src/lower/expr_call/native_module.rs index 524f14501a..a3a3aa3290 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -224,6 +224,76 @@ fn detect_bundled_mysql2_create( mysql2_config_signature(method_name, &keys) } +/// True when `receiver` is a (possibly nested) member-access chain whose root +/// identifier resolves to native module `module_name` as a whole-module +/// reference (default/namespace import, `native_method == None`). Used to +/// recognize deeply-namespaced native APIs like node-forge's +/// `forge.pki.rsa.generateKeyPair` whose receiver is `forge.pki.rsa`, a chain +/// of `Member`s rather than the bare module `Ident` the ordinary arms expect. +/// Only string (`Ident`) property segments are walked — a computed +/// (`forge["pki"]`) segment isn't a static namespace path and returns false. +fn member_chain_roots_at_native_module( + ctx: &LoweringContext, + receiver: &ast::Expr, + module_name: &str, +) -> bool { + match unwrap_ts_wrappers(receiver) { + ast::Expr::Ident(ident) => { + matches!( + ctx.lookup_native_module(ident.sym.as_ref()), + Some((m, None)) if m == module_name + ) + } + ast::Expr::Member(member) if matches!(member.prop, ast::MemberProp::Ident(_)) => { + member_chain_roots_at_native_module(ctx, member.obj.as_ref(), module_name) + } + _ => false, + } +} + +/// node-forge sub-namespace flattening. Unlike the single-level `ns.method()` +/// shape the other arms match, forge's API is deeply nested: +/// `forge.pki.rsa.generateKeyPair(...)`, `forge.pki.createCertificate()`, +/// `forge.md.sha256.create()`. The call's receiver is therefore a CHAIN of +/// `Member`s (not a bare native-module `Ident`), so none of them fire — and +/// worse, an intermediate read like `forge.pki` otherwise reaches the +/// unimplemented-API gate in `expr_member` (no `node-forge` symbol named +/// `pki`) and defers a throw. Collapse any member chain rooted at the +/// node-forge default import down to its LAST segment, which is exactly the +/// method key the codegen `NATIVE_MODULE_TABLE` rows use (`generateKeyPair`, +/// `createCertificate`, `create`, `privateKeyToPem`, …). The intermediate +/// `pki`/`rsa`/`md`/`sha256` path segments are dropped: within node-forge those +/// method names are unambiguous, and an unknown method simply has no table row +/// and surfaces as unresolved, exactly as before. `createCertificate` is typed +/// back to a `Certificate` instance by the factory map in +/// `js_transform/local_natives.rs`, so `cert.setSubject(...)` etc. dispatch +/// through the normal single-level instance path. +/// +/// Runs BEFORE the generic namespace/`module.Class.staticMethod` dispatch so it +/// wins for the 2-level `forge.pki.createCertificate()` shape that +/// `try_module_class_static` would otherwise claim (reading `forge.pki` as +/// `module.Class` and hitting the gate). +pub(super) fn try_node_forge_namespace( + ctx: &LoweringContext, + expr: &ast::Expr, + args: Vec, +) -> Result> { + if let ast::Expr::Member(member) = expr { + if let ast::MemberProp::Ident(method_ident) = &member.prop { + if member_chain_roots_at_native_module(ctx, member.obj.as_ref(), "node-forge") { + return Ok(Expr::NativeMethodCall { + module: "node-forge".to_string(), + class_name: None, + object: None, + method: method_ident.sym.to_string(), + args, + }); + } + } + } + Err(args) +} + pub(super) fn try_native_module_methods( ctx: &mut LoweringContext, call: &ast::CallExpr, diff --git a/crates/perry-hir/tests/node_forge_namespace_lowering.rs b/crates/perry-hir/tests/node_forge_namespace_lowering.rs new file mode 100644 index 0000000000..1919ee1a01 --- /dev/null +++ b/crates/perry-hir/tests/node_forge_namespace_lowering.rs @@ -0,0 +1,104 @@ +//! node-forge deeply-nested namespace calls (`forge.pki.rsa.generateKeyPair`, +//! `forge.pki.createCertificate`, `forge.md.sha256.create`) must flatten to a +//! `NativeMethodCall { module: "node-forge", method: }` so they +//! dispatch through the perry-ext-node-forge surface instead of falling to the +//! JS-runtime path an AOT binary can't execute. Guards the flattening added +//! for the Socket Firewall TLS-MITM CA port. + +use perry_diagnostics::SourceCache; +use perry_hir::{lower_module, Expr, Module, Stmt}; +use perry_parser::parse_typescript_with_cache; + +fn lower(src: &str) -> Module { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = parse_typescript_with_cache(&src, "test.ts", &mut cache) + .expect("parse should succeed"); + lower_module(&parsed.module, "test", "test.ts").expect("lowering should succeed") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked") +} + +fn find_native_method_call<'a>(expr: &'a Expr, method: &str) -> Option<(&'a str, Option<&'a str>)> { + match expr { + Expr::NativeMethodCall { + module, + class_name, + object, + method: call_method, + args, + } => { + if call_method == method { + return Some((module.as_str(), class_name.as_deref())); + } + object + .as_deref() + .and_then(|object| find_native_method_call(object, method)) + .or_else(|| { + args.iter() + .find_map(|arg| find_native_method_call(arg, method)) + }) + } + _ => None, + } +} + +fn native_call_in_inits(module: &Module, method: &str) -> Option<(String, Option)> { + module.init.iter().find_map(|stmt| match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) => find_native_method_call(expr, method) + .map(|(m, c)| (m.to_string(), c.map(str::to_string))), + _ => None, + }) +} + +#[test] +fn three_level_namespace_call_flattens_to_last_segment() { + // forge.pki.rsa.generateKeyPair(...) → method "generateKeyPair". + let module = lower( + r#" + import forge from "node-forge"; + const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 }); + "#, + ); + let call = native_call_in_inits(&module, "generateKeyPair") + .expect("generateKeyPair should lower to a NativeMethodCall"); + assert_eq!(call.0, "node-forge"); +} + +#[test] +fn two_level_namespace_call_flattens_before_module_class_static() { + // forge.pki.createCertificate() is the 2-level shape `try_module_class_static` + // would otherwise claim (reading `forge.pki` as module.Class and gating it); + // the node-forge arm must win first. + let module = lower( + r#" + import forge from "node-forge"; + const cert = forge.pki.createCertificate(); + "#, + ); + let call = native_call_in_inits(&module, "createCertificate") + .expect("createCertificate should lower to a NativeMethodCall"); + assert_eq!(call.0, "node-forge"); +} + +#[test] +fn md_sub_namespace_call_flattens() { + // forge.md.sha256.create() → method "create". + let module = lower( + r#" + import forge from "node-forge"; + const md = forge.md.sha256.create(); + "#, + ); + let call = + native_call_in_inits(&module, "create").expect("md.sha256.create should lower natively"); + assert_eq!(call.0, "node-forge"); +} diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 1c530a1de7..a0a9a43ec5 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -562,3 +562,12 @@ tracking = "#867" crate = "perry-ext-node-forge" lib = "perry_ext_node_forge" tracking = "#466" + +# Upstream provenance pin (see PR #7031 / docs upstream-pins.md). +[bindings.node-forge.upstream] +version = "1.4.0" +sha256 = "bf9d7ca0d774235354697bd4b5e642af6505e7ce2066762c3b855138cf870820" +repo = "https://github.com/digitalbazaar/forge" +ref = "fa385f92440879601240020f158bed68e444e83a" +ported-at = "1.4.0" +date = "2026-07-30" From a47ce7bd9694b60ea731a15f5f0c9e987b3dae8d Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 29 Jul 2026 22:51:19 -0400 Subject: [PATCH 3/4] docs: key changelog fragment to PR #7033 --- changelog.d/{0000-ext-node-forge.md => 7033-ext-node-forge.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-ext-node-forge.md => 7033-ext-node-forge.md} (100%) diff --git a/changelog.d/0000-ext-node-forge.md b/changelog.d/7033-ext-node-forge.md similarity index 100% rename from changelog.d/0000-ext-node-forge.md rename to changelog.d/7033-ext-node-forge.md From ba7bff8d8246ac272c5da8924753353377526e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 08:57:34 +0200 Subject: [PATCH 4/4] fix(node-forge): preserve certificate fidelity and errors --- Cargo.lock | 2 +- crates/perry-ext-node-forge/Cargo.toml | 2 +- crates/perry-ext-node-forge/src/crypto.rs | 164 ++++++++++++++++-- crates/perry-ext-node-forge/src/lib.rs | 113 +++++++++--- .../perry-ext-node-forge/tests/openssl_e2e.rs | 26 ++- .../src/lower/expr_call/native_module.rs | 85 ++++----- .../tests/node_forge_namespace_lowering.rs | 14 ++ 7 files changed, 315 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 15af58b263..10b1f8984c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5973,7 +5973,7 @@ dependencies = [ "pem", "perry-ffi", "perry-runtime", - "rand 0.8.6", + "rand 0.8.7", "rsa 0.9.10", "serde", "serde_json", diff --git a/crates/perry-ext-node-forge/Cargo.toml b/crates/perry-ext-node-forge/Cargo.toml index fd5aa3cc5a..0174874c04 100644 --- a/crates/perry-ext-node-forge/Cargo.toml +++ b/crates/perry-ext-node-forge/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["staticlib", "rlib"] [dependencies] perry-ffi.workspace = true +perry-runtime = { workspace = true, features = ["default", "stdlib"] } serde = { workspace = true } serde_json = { workspace = true } # RustCrypto PKI stack — all already present in the workspace Cargo.lock. @@ -47,4 +48,3 @@ perry-ffi = { workspace = true, features = ["runtime-link"] } # with the real perry-stdlib, so drop the bundled no-op stdlib_stubs that would # otherwise shadow perry-stdlib's real symbols (#6314). `default`: keep the copy # feature-identical to the shipped runtime so gated dispatchers behave (#6303). -perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-node-forge/src/crypto.rs b/crates/perry-ext-node-forge/src/crypto.rs index f871b93e24..3aa9e07520 100644 --- a/crates/perry-ext-node-forge/src/crypto.rs +++ b/crates/perry-ext-node-forge/src/crypto.rs @@ -19,7 +19,7 @@ use std::str::FromStr; use const_oid::ObjectIdentifier; use der::asn1::{Ia5String, OctetString, SetOfVec, Utf8StringRef}; use der::flagset::FlagSet; -use der::{Any, Decode, DecodePem, EncodePem}; +use der::{Any, Decode, DecodePem, Encode, EncodePem}; use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}; use rsa::pkcs1v15::SigningKey; use rsa::pkcs8::{DecodePublicKey, EncodePublicKey}; @@ -32,6 +32,7 @@ use x509_cert::ext::pkix::name::{GeneralName, GeneralNames}; use x509_cert::ext::pkix::{ ExtendedKeyUsage, KeyUsage, KeyUsages, SubjectAltName, SubjectKeyIdentifier, }; +use x509_cert::ext::AsExtension; use x509_cert::name::{Name, RdnSequence, RelativeDistinguishedName}; use x509_cert::serial_number::SerialNumber; use x509_cert::spki::SubjectPublicKeyInfoOwned; @@ -54,6 +55,38 @@ pub struct Attr { /// forge `name` (e.g. `commonName`) or `shortName` (e.g. `CN`). pub key: String, pub value: String, + /// Original ASN.1 string type when the attribute came from a certificate. + /// + /// node-forge preserves this information internally. Carrying it through + /// the forge-shaped JS object keeps a parsed CA subject byte-identical when + /// it is reused as a leaf issuer. + pub value_tag: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DnValueTag { + Utf8, + Printable, + Ia5, +} + +impl DnValueTag { + pub fn as_str(self) -> &'static str { + match self { + Self::Utf8 => "utf8", + Self::Printable => "printable", + Self::Ia5 => "ia5", + } + } + + pub fn from_str(value: &str) -> Option { + match value { + "utf8" => Some(Self::Utf8), + "printable" => Some(Self::Printable), + "ia5" => Some(Self::Ia5), + _ => None, + } + } } /// The forge extension descriptors this wrapper supports. @@ -181,7 +214,7 @@ fn name_for(oid: &ObjectIdentifier) -> String { OID_C => "countryName", OID_ST => "stateOrProvinceName", OID_L => "localityName", - _ => "unknown", + _ => return oid.to_string(), } .to_string() } @@ -195,7 +228,15 @@ fn build_name(attrs: &[Attr]) -> Result { let mut rdns = Vec::with_capacity(attrs.len()); for a in attrs { let oid = oid_for(&a.key)?; - let value = Any::from(Utf8StringRef::new(&a.value).map_err(|e| e.to_string())?); + let value = match a.value_tag.unwrap_or(DnValueTag::Utf8) { + DnValueTag::Utf8 => Any::from(Utf8StringRef::new(&a.value).map_err(|e| e.to_string())?), + DnValueTag::Printable => { + Any::from(der::asn1::PrintableStringRef::new(&a.value).map_err(|e| e.to_string())?) + } + DnValueTag::Ia5 => { + Any::from(der::asn1::Ia5StringRef::new(&a.value).map_err(|e| e.to_string())?) + } + }; let atv = AttributeTypeAndValue { oid, value }; let set = SetOfVec::try_from(vec![atv]).map_err(|e| e.to_string())?; rdns.push(RelativeDistinguishedName(set)); @@ -208,25 +249,27 @@ pub fn parse_name(name: &Name) -> Vec { let mut out = Vec::new(); for rdn in name.0.iter() { for atv in rdn.0.iter() { - let value = atv + let decoded = atv .value .decode_as::>() - .map(|s| s.as_str().to_string()) + .map(|s| (s.as_str().to_string(), DnValueTag::Utf8)) .or_else(|_| { atv.value .decode_as::>() - .map(|s| s.as_str().to_string()) + .map(|s| (s.as_str().to_string(), DnValueTag::Printable)) }) .or_else(|_| { atv.value .decode_as::>() - .map(|s| s.as_str().to_string()) - }) - .unwrap_or_default(); - out.push(Attr { - key: name_for(&atv.oid), - value, - }); + .map(|s| (s.as_str().to_string(), DnValueTag::Ia5)) + }); + if let Ok((value, value_tag)) = decoded { + out.push(Attr { + key: name_for(&atv.oid), + value, + value_tag: Some(value_tag), + }); + } } } out @@ -306,6 +349,33 @@ fn subject_alt_name_ext(hosts: &[String]) -> Result { // ── build + sign ──────────────────────────────────────────────────── +/// Preserve node-forge's caller-supplied `critical` bit instead of accepting +/// x509-cert's policy default for an extension. +struct ExtensionWithCritical<'a, E> { + value: &'a E, + critical: bool, +} + +impl const_oid::AssociatedOid for ExtensionWithCritical<'_, E> { + const OID: ObjectIdentifier = E::OID; +} + +impl Encode for ExtensionWithCritical<'_, E> { + fn encoded_len(&self) -> der::Result { + self.value.encoded_len() + } + + fn encode(&self, writer: &mut impl der::Writer) -> der::Result<()> { + self.value.encode(writer) + } +} + +impl AsExtension for ExtensionWithCritical<'_, E> { + fn critical(&self, _subject: &Name, _extensions: &[x509_cert::ext::Extension]) -> bool { + self.critical + } +} + /// Build and sign a certificate. `signer_private_key_pem` is the /// ISSUER's private key (for a self-signed CA it is the same key whose /// public half is in `spec.public_key_pem`). @@ -340,16 +410,24 @@ pub fn build_and_sign(spec: &CertSpec, signer_private_key_pem: &str) -> Result Result, String> { #[cfg(test)] mod tests { use super::*; + use const_oid::AssociatedOid; use der::Encode; use rsa::pkcs1::EncodeRsaPrivateKey; @@ -414,20 +493,24 @@ mod tests { Attr { key: "commonName".into(), value: "Socket Security CA".into(), + value_tag: None, }, Attr { key: "organizationName".into(), value: "Socket Security".into(), + value_tag: None, }, ], issuer: vec![ Attr { key: "commonName".into(), value: "Socket Security CA".into(), + value_tag: None, }, Attr { key: "organizationName".into(), value: "Socket Security".into(), + value_tag: None, }, ], extensions: ExtSet { @@ -493,4 +576,51 @@ mod tests { "rebuilt issuer DN must match CA subject DN exactly" ); } + + #[test] + fn dn_round_trip_preserves_string_tags_and_unknown_oids() { + let original = build_name(&[ + Attr { + key: "countryName".into(), + value: "US".into(), + value_tag: Some(DnValueTag::Printable), + }, + Attr { + key: "1.2.840.113549.1.9.1".into(), + value: "ca@example.com".into(), + value_tag: Some(DnValueTag::Ia5), + }, + ]) + .unwrap(); + + let parsed = parse_name(&original); + assert_eq!(parsed[0].value_tag, Some(DnValueTag::Printable)); + assert_eq!(parsed[1].key, "1.2.840.113549.1.9.1"); + assert_eq!(parsed[1].value_tag, Some(DnValueTag::Ia5)); + assert_eq!( + build_name(&parsed).unwrap().to_der().unwrap(), + original.to_der().unwrap() + ); + } + + #[test] + fn requested_extension_critical_flags_are_preserved() { + let (priv_pem, pub_pem) = generate_key_pair(2048).unwrap(); + let mut spec = ca_spec(&pub_pem); + spec.extensions.basic_constraints.as_mut().unwrap().critical = false; + spec.extensions.key_usage.as_mut().unwrap().critical = false; + let pem = build_and_sign(&spec, &priv_pem).unwrap(); + let cert = x509_cert::Certificate::from_pem(&pem).unwrap(); + let extensions = cert.tbs_certificate.extensions.as_ref().unwrap(); + let basic_constraints = extensions + .iter() + .find(|ext| ext.extn_id == BasicConstraints::OID) + .unwrap(); + let key_usage = extensions + .iter() + .find(|ext| ext.extn_id == KeyUsage::OID) + .unwrap(); + assert!(!basic_constraints.critical); + assert!(!key_usage.critical); + } } diff --git a/crates/perry-ext-node-forge/src/lib.rs b/crates/perry-ext-node-forge/src/lib.rs index aa66ba9f24..10e1d8c1dd 100644 --- a/crates/perry-ext-node-forge/src/lib.rs +++ b/crates/perry-ext-node-forge/src/lib.rs @@ -35,7 +35,9 @@ use perry_ffi::{ }; use serde::Deserialize; -use crypto::{Attr, BasicConstraintsSpec, CertSpec, ExtKeyUsageSpec, ExtSet, KeyUsageSpec}; +use crypto::{ + Attr, BasicConstraintsSpec, CertSpec, DnValueTag, ExtKeyUsageSpec, ExtSet, KeyUsageSpec, +}; // Fixed field layout of the certificate builder object. `create_certificate` // allocates this shape; the setter FFIs write by index; `sign` / @@ -99,6 +101,8 @@ struct AttrJson { #[serde(rename = "type")] type_oid: Option, value: Option, + #[serde(rename = "valueTag")] + value_tag: Option, } #[derive(Deserialize)] @@ -193,6 +197,7 @@ fn attrs_from(json: &[AttrJson]) -> Vec { Some(Attr { key, value: value_to_string(&a.value), + value_tag: a.value_tag.as_deref().and_then(DnValueTag::from_str), }) }) .collect() @@ -200,15 +205,19 @@ fn attrs_from(json: &[AttrJson]) -> Vec { /// Parse a validity endpoint. `JSON.stringify(Date)` yields an ISO-8601 /// string; we also accept an epoch-milliseconds number as a fallback. -fn parse_time(v: &Option) -> i64 { +fn parse_time(v: &Option, field: &str) -> Result { match v { Some(serde_json::Value::String(s)) => { time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) .map(|dt| dt.unix_timestamp()) - .unwrap_or(0) + .map_err(|e| format!("node-forge: invalid cert.validity.{field}: {e}")) } - Some(serde_json::Value::Number(n)) => (n.as_f64().unwrap_or(0.0) / 1000.0) as i64, - _ => 0, + Some(serde_json::Value::Number(n)) => n + .as_f64() + .filter(|value| value.is_finite()) + .map(|value| (value / 1000.0) as i64) + .ok_or_else(|| format!("node-forge: invalid cert.validity.{field}")), + _ => Err(format!("node-forge: cert.validity.{field} is not set")), } } @@ -265,11 +274,19 @@ fn cert_spec_from_json(cert_json: &str) -> Result { .and_then(|k| k.pem) .ok_or("node-forge: cert.publicKey is not set")?; let validity = c.validity.unwrap_or_default(); + let not_before_unix = parse_time(&validity.not_before, "notBefore")?; + let not_after_unix = parse_time(&validity.not_after, "notAfter")?; + if not_after_unix <= not_before_unix { + return Err( + "node-forge: cert.validity.notAfter must be later than cert.validity.notBefore" + .to_string(), + ); + } Ok(CertSpec { public_key_pem, serial_hex: value_to_string(&c.serial_number), - not_before_unix: parse_time(&validity.not_before), - not_after_unix: parse_time(&validity.not_after), + not_before_unix, + not_after_unix, subject: c .subject .as_ref() @@ -407,13 +424,15 @@ pub unsafe extern "C" fn js_node_forge_certificate_from_pem( Ok(a) => a, Err(_) => return JsValue::NULL, }; - // Build `subject.attributes = [{ name, value }, …]`. + // Build `subject.attributes = [{ name, value, valueTag }, …]`. The + // valueTag metadata preserves the certificate's ASN.1 DN string encoding + // when callers reuse these attributes as an issuer. let attrs_arr = { let arr = perry_ffi::js_array_alloc(attrs.len() as u32); let mut arr = arr; for a in &attrs { - let (packed, shape_id) = build_object_shape(&["name", "value"]); - let o = js_object_alloc_with_shape(shape_id, 2, packed.as_ptr(), packed.len() as u32); + let (packed, shape_id) = build_object_shape(&["name", "value", "valueTag"]); + let o = js_object_alloc_with_shape(shape_id, 3, packed.as_ptr(), packed.len() as u32); js_object_set_field( o, 0, @@ -424,6 +443,13 @@ pub unsafe extern "C" fn js_node_forge_certificate_from_pem( 1, JsValue::from_string_ptr(alloc_string(&a.value).as_raw()), ); + js_object_set_field( + o, + 2, + a.value_tag + .map(|tag| JsValue::from_string_ptr(alloc_string(tag.as_str()).as_raw())) + .unwrap_or(JsValue::NULL), + ); arr = perry_ffi::js_array_push(arr, JsValue::from_object_ptr(o)); } JsValue::from_object_ptr(arr) @@ -481,9 +507,16 @@ pub extern "C" fn js_node_forge_create_certificate() -> JsValue { /// and it is re-wrapped here, keeping both DN fields uniform for /// `certificateToPem`. unsafe fn wrap_dn_attributes(attrs_bits: f64) -> JsValue { + let attrs = JsValue::from_bits(attrs_bits.to_bits()); + if json_stringify(attrs) + .and_then(|json| serde_json::from_str::(&json).ok()) + .is_some_and(|value| value.get("attributes").is_some()) + { + return attrs; + } let (packed, shape_id) = build_object_shape(&["attributes"]); let dn = js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32); - js_object_set_field(dn, 0, JsValue::from_bits(attrs_bits.to_bits())); + js_object_set_field(dn, 0, attrs); JsValue::from_object_ptr(dn) } @@ -543,28 +576,56 @@ pub unsafe extern "C" fn js_node_forge_cert_sign(cert: i64, key_bits: f64, _md_b } let cert_value = JsValue::from_object_ptr(obj); let Some(cert_json) = json_stringify(cert_value) else { - return; + perry_ffi::throw_with_code( + "node-forge: unable to serialize certificate", + "ERR_NODE_FORGE_CERT_SIGN", + perry_ffi::ErrorKind::Error, + ); }; let Some(key_json) = stringify_arg(key_bits) else { - return; + perry_ffi::throw_with_code( + "node-forge: unable to serialize signing key", + "ERR_NODE_FORGE_CERT_SIGN", + perry_ffi::ErrorKind::Error, + ); }; - let Ok(key) = serde_json::from_str::(&key_json) else { - return; + let key = match serde_json::from_str::(&key_json) { + Ok(key) => key, + Err(err) => perry_ffi::throw_with_code( + &format!("node-forge: invalid signing key: {err}"), + "ERR_NODE_FORGE_CERT_SIGN", + perry_ffi::ErrorKind::Error, + ), }; let Some(signer_pem) = key.pem else { - return; + perry_ffi::throw_with_code( + "node-forge: signing key PEM is not set", + "ERR_NODE_FORGE_CERT_SIGN", + perry_ffi::ErrorKind::Error, + ); }; - let Ok(spec) = cert_spec_from_json(&cert_json) else { - return; + let spec = match cert_spec_from_json(&cert_json) { + Ok(spec) => spec, + Err(err) => perry_ffi::throw_with_code( + &err, + "ERR_NODE_FORGE_CERT_SIGN", + perry_ffi::ErrorKind::Error, + ), }; - if let Ok(pem) = crypto::build_and_sign(&spec, &signer_pem) { - let pem_str = alloc_string(&pem); - js_object_set_field( - obj, - FIELD_SIGNATURE_PEM, - JsValue::from_string_ptr(pem_str.as_raw()), - ); - } + let pem = match crypto::build_and_sign(&spec, &signer_pem) { + Ok(pem) => pem, + Err(err) => perry_ffi::throw_with_code( + &format!("node-forge: certificate signing failed: {err}"), + "ERR_NODE_FORGE_CERT_SIGN", + perry_ffi::ErrorKind::Error, + ), + }; + let pem_str = alloc_string(&pem); + js_object_set_field( + obj, + FIELD_SIGNATURE_PEM, + JsValue::from_string_ptr(pem_str.as_raw()), + ); } /// `forge.md.sha256.create()` — a small marker object. `sign` reads the diff --git a/crates/perry-ext-node-forge/tests/openssl_e2e.rs b/crates/perry-ext-node-forge/tests/openssl_e2e.rs index ef7bc60c7a..b96c7a1f9f 100644 --- a/crates/perry-ext-node-forge/tests/openssl_e2e.rs +++ b/crates/perry-ext-node-forge/tests/openssl_e2e.rs @@ -3,7 +3,8 @@ //! the chain with the real `openssl` CLI. This is the acceptance bar for //! the wrapper's fidelity — a cert real TLS clients accept. //! -//! Skips (does not fail) when `openssl` is not on PATH. +//! Set `PERRY_SKIP_OPENSSL_E2E=1` to explicitly skip when OpenSSL is not +//! available in an intentionally minimal environment. use std::io::Write; use std::process::Command; @@ -23,10 +24,12 @@ fn ca_attrs() -> Vec { Attr { key: "commonName".into(), value: "Socket Security CA".into(), + value_tag: None, }, Attr { key: "organizationName".into(), value: "Socket Security".into(), + value_tag: None, }, ] } @@ -34,17 +37,25 @@ fn ca_attrs() -> Vec { #[test] fn ca_and_leaf_verify_with_openssl() { if !openssl_available() { - eprintln!("openssl not found on PATH — skipping e2e verification"); - return; + if std::env::var("PERRY_SKIP_OPENSSL_E2E").as_deref() == Ok("1") { + eprintln!("PERRY_SKIP_OPENSSL_E2E=1 — skipping OpenSSL verification"); + return; + } + panic!("openssl not found on PATH (set PERRY_SKIP_OPENSSL_E2E=1 to explicitly skip)"); } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + // ── CA (mirrors src/lib/util/genCaKeyPair.ts) ────────────────── let (ca_priv_pem, ca_pub_pem) = generate_key_pair(2048).unwrap(); let ca_spec = CertSpec { public_key_pem: ca_pub_pem, serial_hex: "01".into(), - not_before_unix: 1_700_000_000, - not_after_unix: 1_900_000_000, + not_before_unix: now - 60, + not_after_unix: now + 2 * 365 * 24 * 60 * 60, subject: ca_attrs(), issuer: ca_attrs(), extensions: ExtSet { @@ -71,11 +82,12 @@ fn ca_and_leaf_verify_with_openssl() { let leaf_spec = CertSpec { public_key_pem: leaf_pub_pem, serial_hex: "02".into(), - not_before_unix: 1_700_000_000, - not_after_unix: 1_800_000_000, + not_before_unix: now - 60, + not_after_unix: now + 365 * 24 * 60 * 60, subject: vec![Attr { key: "commonName".into(), value: "example.com".into(), + value_tag: None, }], issuer: ca_subject_attrs, extensions: ExtSet { diff --git a/crates/perry-hir/src/lower/expr_call/native_module.rs b/crates/perry-hir/src/lower/expr_call/native_module.rs index a3a3aa3290..25215a57c7 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -224,30 +224,29 @@ fn detect_bundled_mysql2_create( mysql2_config_signature(method_name, &keys) } -/// True when `receiver` is a (possibly nested) member-access chain whose root -/// identifier resolves to native module `module_name` as a whole-module -/// reference (default/namespace import, `native_method == None`). Used to -/// recognize deeply-namespaced native APIs like node-forge's -/// `forge.pki.rsa.generateKeyPair` whose receiver is `forge.pki.rsa`, a chain -/// of `Member`s rather than the bare module `Ident` the ordinary arms expect. -/// Only string (`Ident`) property segments are walked — a computed -/// (`forge["pki"]`) segment isn't a static namespace path and returns false. -fn member_chain_roots_at_native_module( +/// Return the complete static member path whose root identifier resolves to +/// `module_name` as a whole-module reference. Computed properties are not +/// accepted because they are not a statically known namespace path. +fn native_module_member_path( ctx: &LoweringContext, - receiver: &ast::Expr, + expr: &ast::Expr, module_name: &str, -) -> bool { - match unwrap_ts_wrappers(receiver) { - ast::Expr::Ident(ident) => { - matches!( - ctx.lookup_native_module(ident.sym.as_ref()), - Some((m, None)) if m == module_name - ) - } - ast::Expr::Member(member) if matches!(member.prop, ast::MemberProp::Ident(_)) => { - member_chain_roots_at_native_module(ctx, member.obj.as_ref(), module_name) +) -> Option> { + match unwrap_ts_wrappers(expr) { + ast::Expr::Ident(ident) => matches!( + ctx.lookup_native_module(ident.sym.as_ref()), + Some((m, None)) if m == module_name + ) + .then(Vec::new), + ast::Expr::Member(member) => { + let ast::MemberProp::Ident(prop) = &member.prop else { + return None; + }; + let mut path = native_module_member_path(ctx, member.obj.as_ref(), module_name)?; + path.push(prop.sym.to_string()); + Some(path) } - _ => false, + _ => None, } } @@ -261,10 +260,10 @@ fn member_chain_roots_at_native_module( /// `pki`) and defers a throw. Collapse any member chain rooted at the /// node-forge default import down to its LAST segment, which is exactly the /// method key the codegen `NATIVE_MODULE_TABLE` rows use (`generateKeyPair`, -/// `createCertificate`, `create`, `privateKeyToPem`, …). The intermediate -/// `pki`/`rsa`/`md`/`sha256` path segments are dropped: within node-forge those -/// method names are unambiguous, and an unknown method simply has no table row -/// and surfaces as unresolved, exactly as before. `createCertificate` is typed +/// `createCertificate`, `create`, `privateKeyToPem`, …). Only the exact +/// implemented paths are flattened; in particular, `forge.md.md5.create()` +/// must not accidentally dispatch to the SHA-256 marker just because its final +/// segment is also `create`. `createCertificate` is typed /// back to a `Certificate` instance by the factory map in /// `js_transform/local_natives.rs`, so `cert.setSubject(...)` etc. dispatch /// through the normal single-level instance path. @@ -278,20 +277,28 @@ pub(super) fn try_node_forge_namespace( expr: &ast::Expr, args: Vec, ) -> Result> { - if let ast::Expr::Member(member) = expr { - if let ast::MemberProp::Ident(method_ident) = &member.prop { - if member_chain_roots_at_native_module(ctx, member.obj.as_ref(), "node-forge") { - return Ok(Expr::NativeMethodCall { - module: "node-forge".to_string(), - class_name: None, - object: None, - method: method_ident.sym.to_string(), - args, - }); - } - } - } - Err(args) + let Some(path) = native_module_member_path(ctx, expr, "node-forge") else { + return Err(args); + }; + let method = match path + .iter() + .map(String::as_str) + .collect::>() + .as_slice() + { + ["pki", "rsa", "generateKeyPair"] => "generateKeyPair", + ["pki", method @ ("createCertificate" | "certificateFromPem" | "certificateToPem" + | "privateKeyFromPem" | "privateKeyToPem" | "publicKeyToPem")] => method, + ["md", "sha256", "create"] => "create", + _ => return Err(args), + }; + return Ok(Expr::NativeMethodCall { + module: "node-forge".to_string(), + class_name: None, + object: None, + method: method.to_string(), + args, + }); } pub(super) fn try_native_module_methods( diff --git a/crates/perry-hir/tests/node_forge_namespace_lowering.rs b/crates/perry-hir/tests/node_forge_namespace_lowering.rs index 1919ee1a01..fb218cd6ac 100644 --- a/crates/perry-hir/tests/node_forge_namespace_lowering.rs +++ b/crates/perry-hir/tests/node_forge_namespace_lowering.rs @@ -102,3 +102,17 @@ fn md_sub_namespace_call_flattens() { native_call_in_inits(&module, "create").expect("md.sha256.create should lower natively"); assert_eq!(call.0, "node-forge"); } + +#[test] +fn unsupported_md_namespace_does_not_flatten_to_sha256_create() { + let module = lower( + r#" + import forge from "node-forge"; + const md = forge.md.md5.create(); + "#, + ); + assert!( + native_call_in_inits(&module, "create").is_none(), + "forge.md.md5.create must not dispatch to the SHA-256 native marker" + ); +}