Repository: denoland/deno
Pull request: fix(ext/node): improve X509Certificate Node.js compatibility

## Summary
- Fixes multiple compatibility issues with `crypto.X509Certificate` to pass the Node.js `test-crypto-x509.js` test suite
- Adds `isX509Certificate()`, `signatureAlgorithm`, `signatureAlgorithmOid` getters
- Fixes `emailAddress` label, date timezone format, `toLegacyObject()` output (Buffer raw, null-prototype objects, infoAccess as structured object, RSA modulus/exponent format)
- Adds structured clone support for X509Certificate via MessagePort

Depends on #32672

## Test plan
- [x] `./x test-compat test-crypto-x509` passes
- [ ] CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/internal/crypto/x509.ts
patch: @@ -18,6 +18,8 @@ import {
   op_node_x509_get_issuer,
   op_node_x509_get_raw,
   op_node_x509_get_serial_number,
+  op_node_x509_get_signature_algorithm_name,
+  op_node_x509_get_signature_algorithm_oid,
   op_node_x509_get_subject,
   op_node_x509_get_subject_alt_name,
   op_node_x509_get_valid_from,
@@ -51,6 +53,8 @@ import { inspect } from "node:util";
 import { customInspectSymbol as kInspect } from "ext:deno_node/internal/util.mjs";
 import type { InspectOptions } from "node:util";
 
+const core = globalThis.Deno.core;
+
 // deno-lint-ignore no-explicit-any
 export type PeerCertificate = any;
 
@@ -134,6 +138,12 @@ export class X509Certificate {
     }
 
     this.#handle = op_node_x509_parse(buffer);
+    // deno-lint-ignore no-this-alias
+    const self = this;
+    this[core.hostObjectBrand] = () => ({
+      type: "X509Certificate",
+      data: op_node_x509_get_raw(self.#handle),
+    });
   }
 
   [kInspect](depth: number, options: InspectOptions) {
@@ -282,6 +292,14 @@ export class X509Certificate {
     return op_node_x509_get_serial_number(this.#handle);
   }
 
+  get signatureAlgorithm(): string | undefined {
+    return op_node_x509_get_signature_algorithm_name(this.#handle) ?? undefined;
+  }
+
+  get signatureAlgorithmOid(): string {
+    return op_node_x509_get_signature_algorithm_oid(this.#handle);
+  }
+
   get subject(): string {
     return op_node_x509_get_subject(this.#handle) || undefined;
   }
@@ -295,7 +313,20 @@ export class X509Certificate {
   }
 
   toLegacyObject(): PeerCertificate {
-    return op_node_x509_to_legacy_object(this.#handle);
+    const obj = op_node_x509_to_legacy_object(this.#handle);
+    if (obj.raw) {
+      obj.raw = Buffer.from(obj.raw);
+    }
+    if (obj.subject) {
+      obj.subject = Object.assign({ __proto__: null }, obj.subject);
+    }
+    if (obj.issuer) {
+      obj.issuer = Object.assign({ __proto__: null }, obj.issuer);
+    }
+    if (obj.infoAccess) {
+      obj.infoAccess = Object.assign({ __proto__: null }, obj.infoAccess);
+    }
+    return obj;
   }
 
   toString(): string {
@@ -337,6 +368,16 @@ export class X509Certificate {
   }
 }
 
+function isX509Certificate(value: unknown): value is X509Certificate {
+  return value instanceof X509Certificate;
+}
+
+core.registerCloneableResource(
+  "X509Certificate",
+  (data: { data: ArrayBuffer }) => new X509Certificate(Buffer.from(data.data)),
+);
+
 export default {
   X509Certificate,
+  isX509Certificate,
 };

filename: ext/node_crypto/lib.rs
patch: @@ -173,6 +173,8 @@ deno_core::extension!(
     x509::op_node_x509_check_private_key,
     x509::op_node_x509_verify,
     x509::op_node_x509_get_info_access,
+    x509::op_node_x509_get_signature_algorithm_name,
+    x509::op_node_x509_get_signature_algorithm_oid,
     x509::op_node_x509_to_legacy_object,
   ],
   objects = [digest::Hasher,],

filename: ext/node_crypto/x509.rs
patch: @@ -1,5 +1,6 @@
 // Copyright 2018-2026 the Deno authors. MIT license.
 
+use std::collections::HashMap;
 use std::net::IpAddr;
 use std::net::Ipv4Addr;
 use std::net::Ipv6Addr;
@@ -41,6 +42,8 @@ struct SubjectOrIssuer {
   ou: Option<String>,
   #[serde(skip_serializing_if = "Option::is_none")]
   cn: Option<String>,
+  #[serde(rename = "emailAddress", skip_serializing_if = "Option::is_none")]
+  email_address: Option<String>,
 }
 
 #[derive(serde::Serialize)]
@@ -57,6 +60,8 @@ pub struct CertificateObject {
   fingerprint256: String,
   fingerprint512: String,
   subjectaltname: String,
+  #[serde(rename = "infoAccess", skip_serializing_if = "Option::is_none")]
+  info_access: Option<HashMap<String, Vec<String>>>,
   // RSA key fields
   #[serde(skip_serializing_if = "Option::is_none")]
   bits: Option<u32>,
@@ -143,8 +148,16 @@ impl Certificate {
       CertificateSources::Der(der) => der.to_vec(),
     };
 
-    let valid_from = cert.validity().not_before.to_string();
-    let valid_to = cert.validity().not_after.to_string();
+    let valid_from = cert
+      .validity()
+      .not_before
+      .to_string()
+      .replace("+00:00", "GMT");
+    let valid_to = cert
+      .validity()
+      .not_after
+      .to_string()
+      .replace("+00:00", "GMT");
 
     let mut serial_number = cert.serial.to_str_radix(16);
     serial_number.make_ascii_uppercase();
@@ -154,6 +167,7 @@ impl Certificate {
     let fingerprint512 = self.fingerprint::<sha2::Sha512>().unwrap_or_default();
 
     let subjectaltname = get_subject_alt_name(cert).unwrap_or_default();
+    let info_access = get_info_access_object(cert);
 
     let subject = extract_subject_or_issuer(cert.subject());
     let issuer = extract_subject_or_issuer(cert.issuer());
@@ -179,6 +193,7 @@ impl Certificate {
       fingerprint256,
       fingerprint512,
       subjectaltname,
+      info_access,
       bits,
       exponent,
       modulus,
@@ -383,6 +398,9 @@ fn extract_subject_or_issuer(name: &X509Name) -> SubjectOrIssuer {
           oid if oid == &x509_parser::oid_registry::OID_X509_COMMON_NAME => {
             result.cn = Some(value_str);
           }
+          oid if oid == &x509_parser::oid_registry::OID_PKCS9_EMAIL_ADDRESS => {
+            result.email_address = Some(value_str);
+          }
           _ => {}
         }
       }
@@ -435,9 +453,15 @@ fn x509name_to_string(
         let val_str =
           attribute_value_to_string(attr.attr_value(), attr.attr_type())?;
         // look ABBREV, and if not found, use shortname
-        let abbrev = match oid2abbrev(attr.attr_type(), oid_registry) {
-          Ok(s) => String::from(s),
-          _ => format!("{:?}", attr.attr_type()),
+        let abbrev = if *attr.attr_type()
+          == x509_parser::oid_registry::OID_PKCS9_EMAIL_ADDRESS
+        {
+          String::from("emailAddress")
+        } else {
+          match oid2abbrev(attr.attr_type(), oid_registry) {
+            Ok(s) => String::from(s),
+            _ => format!("{:?}", attr.attr_type()),
+          }
         };
         let rdn = format!("{}={}", abbrev, val_str);
         match acc2.len() {
@@ -456,14 +480,22 @@ fn x509name_to_string(
 #[string]
 pub fn op_node_x509_get_valid_from(#[cppgc] cert: &Certificate) -> String {
   let cert = cert.inner.get().deref();
-  cert.validity().not_before.to_string()
+  cert
+    .validity()
+    .not_before
+    .to_string()
+    .replace("+00:00", "GMT")
 }
 
 #[op2]
 #[string]
 pub fn op_node_x509_get_valid_to(#[cppgc] cert: &Certificate) -> String {
   let cert = cert.inner.get().deref();
-  cert.validity().not_after.to_string()
+  cert
+    .validity()
+    .not_after
+    .to_string()
+    .replace("+00:00", "GMT")
 }
 
 #[op2]
@@ -509,9 +541,25 @@ fn extract_key_info(spki: &x509_parser::x509::SubjectPublicKeyInfo) -> KeyInfo {
       let modulus_bytes = key.modulus;
       let exponent_bytes = key.exponent;
 
-      let bits = Some((modulus_bytes.len() * 8) as u32);
-      let modulus = Some(data_encoding::HEXUPPER.encode(modulus_bytes));
-      let exponent = Some(data_encoding::HEXUPPER.encode(exponent_bytes));
+      // Strip leading zero byte used for ASN.1 positive integer encoding
+      let modulus_trimmed = if modulus_bytes.first() == Some(&0) {
+        &modulus_bytes[1..]
+      } else {
+        modulus_bytes
+      };
+      let bits = Some((modulus_trimmed.len() * 8) as u32);
+      let modulus = Some(data_encoding::HEXUPPER.encode(modulus_trimmed));
+      // Format exponent as "0x" + hex without leading zeros (e.g., "0x10001")
+      let exp_hex = data_encoding::HEXLOWER.encode(exponent_bytes);
+      let exp_trimmed = exp_hex.trim_start_matches('0');
+      let exponent = Some(format!(
+        "0x{}",
+        if exp_trimmed.is_empty() {
+          "0"
+        } else {
+          exp_trimmed
+        }
+      ));
       let pubkey = Some(spki.raw.to_vec());
 
       KeyInfo {
@@ -643,6 +691,61 @@ fn get_subject_alt_name(cert: &X509Certificate) -> Option<String> {
   }
 }
 
+fn get_info_access_object(
+  cert: &X509Certificate,
+) -> Option<HashMap<String, Vec<String>>> {
+  let oid_aia = Oid::from(&[1, 3, 6, 1, 5, 5, 7, 1, 1]).ok()?;
+  let oid_ocsp = Oid::from(&[1, 3, 6, 1, 5, 5, 7, 48, 1]).ok()?;
+  let oid_ca_issuers = Oid::from(&[1, 3, 6, 1, 5, 5, 7, 48, 2]).ok()?;
+
+  let ext = cert.extensions().iter().find(|e| e.oid == oid_aia)?;
+
+  let data = ext.value;
+  let (_, seq) =
+    x509_parser::der_parser::asn1_rs::Sequence::from_der(data).ok()?;
+
+  let mut result: HashMap<String, Vec<String>> = HashMap::new();
+  let mut remaining = seq.content.as_ref();
+
+  while !remaining.is_empty() {
+    let (rest, access_desc) =
+      x509_parser::der_parser::asn1_rs::Sequence::from_der(remaining).ok()?;
+    remaining = rest;
+
+    let (general_name_data, method_oid) =
+      Oid::from_der(access_desc.content.as_ref()).ok()?;
+
+    let method_name = if method_oid == oid_ocsp {
+      "OCSP - URI"
+    } else if method_oid == oid_ca_issuers {
+      "CA Issuers - URI"
+    } else {
+      continue;
+    };
+
+    if !general_name_data.is_empty() {
+      let (_, any) =
+        x509_parser::der_parser::asn1_rs::Any::from_der(general_name_data)
+          .ok()?;
+      if any.class() == x509_parser::der_parser::asn1_rs::Class::ContextSpecific
+        && any.tag().0 == 6
+        && let Ok(uri) = std::str::from_utf8(any.data)
+      {
+        result
+          .entry(method_name.to_string())
+          .or_default()
+          .push(uri.to_string());
+      }
+    }
+  }
+
+  if result.is_empty() {
+    None
+  } else {
+    Some(result)
+  }
+}
+
 #[op2]
 #[string]
 pub fn op_node_x509_to_string(#[cppgc] cert: &Certificate) -> String {
@@ -1087,6 +1190,44 @@ pub fn op_node_x509_verify(
   }
 }
 
+/// Map well-known signature algorithm OIDs to their OpenSSL names.
+fn sig_alg_oid_to_name(oid: &str) -> Option<&'static str> {
+  match oid {
+    "1.2.840.113549.1.1.4" => Some("md5WithRSAEncryption"),
+    "1.2.840.113549.1.1.5" => Some("sha1WithRSAEncryption"),
+    "1.2.840.113549.1.1.11" => Some("sha256WithRSAEncryption"),
+    "1.2.840.113549.1.1.12" => Some("sha384WithRSAEncryption"),
+    "1.2.840.113549.1.1.13" => Some("sha512WithRSAEncryption"),
+    "1.2.840.113549.1.1.10" => Some("rsassaPss"),
+    "1.2.840.10045.4.1" => Some("ecdsa-with-SHA1"),
+    "1.2.840.10045.4.3.2" => Some("ecdsa-with-SHA256"),
+    "1.2.840.10045.4.3.3" => Some("ecdsa-with-SHA384"),
+    "1.2.840.10045.4.3.4" => Some("ecdsa-with-SHA512"),
+    "1.3.101.112" => Some("ED25519"),
+    "1.3.101.113" => Some("ED448"),
+    _ => None,
+  }
+}
+
+#[op2]
+#[string]
+pub fn op_node_x509_get_signature_algorithm_name(
+  #[cppgc] cert: &Certificate,
+) -> Option<String> {
+  let cert = cert.inner.get().deref();
+  let oid = cert.signature_algorithm.algorithm.to_id_string();
+  sig_alg_oid_to_name(&oid).map(|s| s.to_string())
+}
+
+#[op2]
+#[string]
+pub fn op_node_x509_get_signature_algorithm_oid(
+  #[cppgc] cert: &Certificate,
+) -> String {
+  let cert = cert.inner.get().deref();
+  cert.signature_algorithm.algorithm.to_id_string()
+}
+
 #[op2]
 #[string]
 pub fn op_node_x509_get_info_access(
@@ -1132,8 +1273,8 @@ pub fn op_node_x509_get_info_access(
       let (_, any) =
         x509_parser::der_parser::asn1_rs::Any::from_der(general_name_data)
           .ok()?;
-      // Tag 6 is context-specific for URI in GeneralName
-      if any.tag().0 == 6
+      if any.class() == x509_parser::der_parser::asn1_rs::Class::ContextSpecific
+        && any.tag().0 == 6
         && let Ok(uri) = std::str::from_utf8(any.data)
       {
         entries.push(format!("{}:{}", method_name, uri));

filename: tests/unit_node/crypto/crypto_key_test.ts
patch: @@ -787,13 +787,19 @@ Deno.test("X509Certificate inspect", async function () {
   assertEquals(
     trimmedStdout,
     `X509Certificate {
-  subject: 'C=US\\nST=CA\\nL=SF\\nO=Joyent\\nOU=Node.js\\nCN=agent1\\nEmail=ry@tinyclouds.org',
+  subject: 'C=US\\n' +
+    'ST=CA\\n' +
+    'L=SF\\n' +
+    'O=Joyent\\n' +
+    'OU=Node.js\\n' +
+    'CN=agent1\\n' +
+    'emailAddress=ry@tinyclouds.org',
   subjectAltName: undefined,
-  issuer: 'C=US\\nST=CA\\nL=SF\\nO=Joyent\\nOU=Node.js\\nCN=ca1\\nEmail=ry@tinyclouds.org',
+  issuer: 'C=US\\nST=CA\\nL=SF\\nO=Joyent\\nOU=Node.js\\nCN=ca1\\nemailAddress=ry@tinyclouds.org',
   infoAccess: 'OCSP - URI:http://ocsp.nodejs.org/\\n' +
     'CA Issuers - URI:http://ca.nodejs.org/ca.cert',
-  validFrom: 'Sep  3 21:40:37 2022 +00:00',
-  validTo: 'Jun 17 21:40:37 2296 +00:00',
+  validFrom: 'Sep  3 21:40:37 2022 GMT',
+  validTo: 'Jun 17 21:40:37 2296 GMT',
   validFromDate: 2022-09-03T21:40:37.000Z,
   validToDate: 2296-06-17T21:40:37.000Z,
   fingerprint: '8B:89:16:C4:99:87:D2:13:1A:64:94:36:38:A5:32:01:F0:95:3B:53',