Skip to content

Commit 5ca12ed

Browse files
authored
fix(ext/node): extract cert/key from pfx in tls SecureContext (#34383)
`tls.createServer({ pfx, passphrase, ... })` and `tls.connect({ pfx, ... })` were validating the PFX but never extracting its contents, so the SecureContext ended up with no `cert`/`key`. The server installed `NoCertResolver` and the TLS handshake aborted with `ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE` / client-side `ECONNRESET`, even though both peers had `rejectUnauthorized: false` — making `pfx`-only configs unusable. Replace `op_node_validate_pfx` with `op_node_load_pfx`, which decrypts the PFX with the passphrase and returns the leaf cert, the private key (PKCS#8) and any additional certs as PEM. `_tls_common.ts` falls back to those values when explicit `cert`/`key`/`ca` aren't provided, so an explicit `cert` still takes precedence over the PFX leaf. Also map the server-side `authorizationError` for a self-signed leaf with no CA configured to `DEPTH_ZERO_SELF_SIGNED_CERT` instead of always reporting `UNABLE_TO_GET_ISSUER_CERT`, matching Node/OpenSSL. p12 0.6's MAC verification only knows SHA-1 and panics via `debug_assert!` on newer SHA-256 PFX files, so wrap the call in `catch_unwind` and treat any non-SHA-1 MAC as unverifiable rather than a hard failure — PBE-decryption of the bags provides the real integrity check. Fixes #34202
1 parent 7aadfe8 commit 5ca12ed

11 files changed

Lines changed: 231 additions & 44 deletions

File tree

ext/node/ops/tls_wrap.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3450,6 +3450,11 @@ fn der_content_len(element: &[u8]) -> Option<usize> {
34503450
/// matching. Returns an error code string if the chain cannot be built.
34513451
/// Returns `Ok(())` if the chain reaches a trusted root, or
34523452
/// `Err(code)` with a Node/OpenSSL error code if it does not.
3453+
fn is_self_signed(cert_der: &[u8]) -> bool {
3454+
extract_issuer_and_subject(cert_der)
3455+
.is_some_and(|(issuer, subject)| issuer == subject)
3456+
}
3457+
34533458
fn verify_chain_structure(
34543459
end_entity: &[u8],
34553460
intermediates: &[rustls::pki_types::CertificateDer<'_>],
@@ -4206,14 +4211,22 @@ impl rustls::server::danger::ClientCertVerifier
42064211

42074212
fn verify_client_cert(
42084213
&self,
4209-
_end_entity: &rustls::pki_types::CertificateDer<'_>,
4210-
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
4214+
end_entity: &rustls::pki_types::CertificateDer<'_>,
4215+
intermediates: &[rustls::pki_types::CertificateDer<'_>],
42114216
_now: rustls::pki_types::UnixTime,
42124217
) -> Result<rustls::server::danger::ClientCertVerified, rustls::Error> {
4213-
// No root CAs — we cannot verify anything. Store an error for
4214-
// verifyError() and let the JS layer decide via `authorized`.
4218+
// No root CAs, so we cannot establish a trust chain. Match Node's
4219+
// OpenSSL-derived error codes: a self-signed leaf (no intermediates,
4220+
// subject == issuer) reports DEPTH_ZERO_SELF_SIGNED_CERT; everything
4221+
// else falls back to UNABLE_TO_GET_ISSUER_CERT.
4222+
let code =
4223+
if intermediates.is_empty() && is_self_signed(end_entity.as_ref()) {
4224+
"DEPTH_ZERO_SELF_SIGNED_CERT"
4225+
} else {
4226+
"UNABLE_TO_GET_ISSUER_CERT"
4227+
};
42154228
*self.verify_error.lock().unwrap_or_else(|p| p.into_inner()) =
4216-
Some("UNABLE_TO_GET_ISSUER_CERT".to_string());
4229+
Some(code.to_string());
42174230
Ok(rustls::server::danger::ClientCertVerified::assertion())
42184231
}
42194232

ext/node/polyfills/_tls_common.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const { isArrayBufferView } = core.loadExtScript(
2222
const { validateString } = core.loadExtScript(
2323
"ext:deno_node/internal/validators.mjs",
2424
);
25-
const { op_node_validate_crl, op_node_validate_pfx } = core.ops;
25+
const { op_node_validate_crl, op_node_load_pfx } = core.ops;
2626
const { createPrivateKey } = core.loadExtScript(
2727
"ext:deno_node/internal/crypto/keys.ts",
2828
);
@@ -496,10 +496,19 @@ class SecureContext {
496496
validateKeyCertOption(options.key, "options.key", true);
497497
validateKeyCertOption(options.ca, "options.ca", false);
498498

499-
// Validate PFX / PKCS#12 data. Node accepts both a single
500-
// <string>|<Buffer> and an <Array<string|Buffer|{ buf, passphrase? }>>;
501-
// an empty array (which playwright passes when no PFX is configured)
502-
// must be a no-op rather than feeding an empty buffer to the parser.
499+
// Load PFX / PKCS#12 data: extract the cert + private key so they can
500+
// be used by the underlying TLS implementation. Any additional certs
501+
// present in the PFX are merged into `ca`. Caller-supplied `cert`/`key`
502+
// (and `ca`) take precedence, matching Node, which loads PFX first and
503+
// then layers explicit cert/key on top.
504+
//
505+
// Node accepts both a single <string>|<Buffer> and an
506+
// <Array<string|Buffer|{ buf, passphrase? }>>; an empty array (which
507+
// playwright passes when no PFX is configured) must be a no-op rather
508+
// than feeding an empty buffer to the parser.
509+
let pfxCert: string | undefined;
510+
let pfxKey: string | undefined;
511+
let pfxCa: string[] | undefined;
503512
if (options.pfx != null) {
504513
const pfxItems = globalThis.Array.isArray(options.pfx)
505514
? options.pfx
@@ -522,7 +531,15 @@ class SecureContext {
522531
if (buf == null) continue;
523532
const pfxData = toUint8Array(buf);
524533
const pfxPassphrase = passphrase != null ? String(passphrase) : null;
525-
op_node_validate_pfx(pfxData, pfxPassphrase);
534+
const loaded = op_node_load_pfx(pfxData, pfxPassphrase);
535+
if (pfxCert === undefined) {
536+
pfxCert = loaded.cert;
537+
pfxKey = loaded.key;
538+
}
539+
if (loaded.ca?.length) {
540+
if (pfxCa === undefined) pfxCa = [];
541+
pfxCa.push(...loaded.ca);
542+
}
526543
}
527544
}
528545

@@ -544,12 +561,13 @@ class SecureContext {
544561

545562
const { minVersion, maxVersion } = getProtocolRange(options);
546563

547-
const useDefaultCA = !options.ca;
564+
const effectiveCa = options.ca != null ? options.ca : pfxCa;
565+
const useDefaultCA = !effectiveCa;
548566
this.context = {
549-
ca: useDefaultCA ? undefined : normalizeCertValue(options.ca),
567+
ca: useDefaultCA ? undefined : normalizeCertValue(effectiveCa),
550568
useDefaultCA,
551-
cert: normalizeCertPem(options.cert),
552-
key: normalizeKeyPem(options.key, options.passphrase),
569+
cert: normalizeCertPem(options.cert) ?? pfxCert,
570+
key: normalizeKeyPem(options.key, options.passphrase) ?? pfxKey,
553571
minVersion,
554572
maxVersion,
555573
ciphers: options.ciphers,

ext/node_crypto/keys.rs

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4156,13 +4156,48 @@ pub fn op_node_derive_public_key_from_private_key(
41564156
}
41574157

41584158
#[derive(Debug, thiserror::Error, deno_error::JsError)]
4159-
pub enum PfxValidationError {
4159+
pub enum PfxLoadError {
41604160
#[class(generic)]
41614161
#[error("not enough data")]
41624162
NotEnoughData,
41634163
#[class(generic)]
41644164
#[error("mac verify failure")]
41654165
MacVerifyFailure,
4166+
#[class(generic)]
4167+
#[error("PFX contains no usable certificate")]
4168+
NoCert,
4169+
#[class(generic)]
4170+
#[error("PFX contains no usable private key")]
4171+
NoKey,
4172+
#[class(generic)]
4173+
#[error("failed to encode PFX contents: {0}")]
4174+
Encode(String),
4175+
}
4176+
4177+
#[derive(Debug, serde::Serialize)]
4178+
#[serde(rename_all = "camelCase")]
4179+
pub struct LoadPfxResult {
4180+
pub cert: String,
4181+
pub key: String,
4182+
pub ca: Vec<String>,
4183+
}
4184+
4185+
fn der_to_pem(label: &str, der: &[u8]) -> String {
4186+
use base64::engine::general_purpose::STANDARD;
4187+
let body = STANDARD.encode(der);
4188+
let mut out = String::with_capacity(body.len() + 64);
4189+
out.push_str("-----BEGIN ");
4190+
out.push_str(label);
4191+
out.push_str("-----\n");
4192+
for chunk in body.as_bytes().chunks(64) {
4193+
// SAFETY: base64 output is ASCII.
4194+
out.push_str(std::str::from_utf8(chunk).unwrap());
4195+
out.push('\n');
4196+
}
4197+
out.push_str("-----END ");
4198+
out.push_str(label);
4199+
out.push_str("-----\n");
4200+
out
41664201
}
41674202

41684203
// Cap on the iteration count for the PKCS#12 MAC PBKDF, since an attacker
@@ -4176,39 +4211,65 @@ pub enum PfxValidationError {
41764211
const PFX_MAC_ITERATIONS_CAP: u64 = 600_000;
41774212

41784213
#[op2]
4179-
pub fn op_node_validate_pfx(
4214+
#[serde]
4215+
pub fn op_node_load_pfx(
41804216
#[buffer] pfx: &[u8],
41814217
#[string] passphrase: Option<String>,
4182-
) -> Result<(), PfxValidationError> {
4183-
let parsed =
4184-
Pkcs12::parse(pfx).map_err(|_| PfxValidationError::NotEnoughData)?;
4218+
) -> Result<LoadPfxResult, PfxLoadError> {
4219+
let parsed = Pkcs12::parse(pfx).map_err(|_| PfxLoadError::NotEnoughData)?;
41854220
let password = passphrase.as_deref().unwrap_or("");
41864221
let bmp_password = bmp_string(password);
4187-
// If no MAC is present, the file is considered valid.
4188-
let Some(mac_data) = &parsed.mac_data else {
4189-
return Ok(());
4190-
};
4191-
let iterations = u64::from(mac_data.iterations);
4192-
if iterations > PFX_MAC_ITERATIONS_CAP {
4193-
return Err(PfxValidationError::MacVerifyFailure);
4222+
// If a MAC is present, verify it. Absent MACs are accepted (matches
4223+
// OpenSSL/Node behaviour).
4224+
if let Some(mac_data) = &parsed.mac_data {
4225+
let iterations = u64::from(mac_data.iterations);
4226+
if iterations > PFX_MAC_ITERATIONS_CAP {
4227+
return Err(PfxLoadError::MacVerifyFailure);
4228+
}
4229+
let data = parsed
4230+
.auth_safe
4231+
.data(&bmp_password)
4232+
.ok_or(PfxLoadError::MacVerifyFailure)?;
4233+
let ok = verify_pkcs12_mac(
4234+
&mac_data.mac.digest_algorithm,
4235+
&mac_data.mac.digest,
4236+
&mac_data.salt,
4237+
iterations,
4238+
&data,
4239+
&bmp_password,
4240+
)
4241+
.ok_or(PfxLoadError::MacVerifyFailure)?;
4242+
if !ok {
4243+
return Err(PfxLoadError::MacVerifyFailure);
4244+
}
41944245
}
4195-
let data = parsed
4196-
.auth_safe
4197-
.data(&bmp_password)
4198-
.ok_or(PfxValidationError::MacVerifyFailure)?;
4199-
let ok = verify_pkcs12_mac(
4200-
&mac_data.mac.digest_algorithm,
4201-
&mac_data.mac.digest,
4202-
&mac_data.salt,
4203-
iterations,
4204-
&data,
4205-
&bmp_password,
4206-
)
4207-
.ok_or(PfxValidationError::MacVerifyFailure)?;
4208-
if !ok {
4209-
return Err(PfxValidationError::MacVerifyFailure);
4246+
4247+
let cert_ders = parsed
4248+
.cert_bags(password)
4249+
.map_err(|e| PfxLoadError::Encode(e.to_string()))?;
4250+
let key_ders = parsed
4251+
.key_bags(password)
4252+
.map_err(|e| PfxLoadError::Encode(e.to_string()))?;
4253+
4254+
if cert_ders.is_empty() {
4255+
return Err(PfxLoadError::NoCert);
42104256
}
4211-
Ok(())
4257+
if key_ders.is_empty() {
4258+
return Err(PfxLoadError::NoKey);
4259+
}
4260+
4261+
// The first cert bag is taken as the leaf; any additional bags are
4262+
// returned as CA/chain certificates.
4263+
let mut iter = cert_ders.into_iter();
4264+
let leaf = iter.next().unwrap();
4265+
let cert = der_to_pem("CERTIFICATE", &leaf);
4266+
let ca: Vec<String> =
4267+
iter.map(|der| der_to_pem("CERTIFICATE", &der)).collect();
4268+
4269+
// p12 returns PKCS#8 DER for shrouded key bags.
4270+
let key = der_to_pem("PRIVATE KEY", &key_ders[0]);
4271+
4272+
Ok(LoadPfxResult { cert, key, ca })
42124273
}
42134274

42144275
// Convert a UTF-8 password to the PKCS#12 BMPString form (RFC 7292

ext/node_crypto/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ deno_core::extension!(
161161
keys::op_node_get_symmetric_key_size,
162162
keys::op_node_key_equals,
163163
keys::op_node_key_type,
164-
keys::op_node_validate_pfx,
164+
keys::op_node_load_pfx,
165165
keys::op_node_validate_crl,
166166
x509::op_node_x509_parse,
167167
x509::op_node_x509_ca,

tests/testdata/tls/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,36 @@ each of the supported hash algorithms. All use the passphrase `secret`.
6464
```shell
6565
for alg in sha1 sha256 sha384 sha512; do
6666
openssl pkcs12 -export -macalg "$alg" \
67+
-keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES \
6768
-inkey localhost.key -in localhost.crt \
6869
-passout pass:secret \
6970
-out "localhost_${alg}.pfx"
7071
done
7172
```
7273

74+
`-keypbe`/`-certpbe` pin bag encryption to legacy PBE-SHA1-3DES because the
75+
`p12` crate used by `op_node_load_pfx` does not yet support PBES2/AES-256-CBC,
76+
which is OpenSSL 3.x's default.
77+
7378
- `localhost_sha1.pfx` — RFC 7292 default MAC algorithm
7479
- `localhost_sha256.pfx` — OpenSSL 3.x default MAC algorithm
7580
- `localhost_sha384.pfx`
7681
- `localhost_sha512.pfx`
82+
83+
A separate self-signed bundle exercises the `DEPTH_ZERO_SELF_SIGNED_CERT` path
84+
in the TLS handshake. Generated with:
85+
86+
```shell
87+
openssl req -x509 -nodes -newkey rsa:2048 \
88+
-keyout localhost_ss.key -out localhost_ss.crt \
89+
-days 36135 -sha256 \
90+
-subj "/C=US/CN=localhost" \
91+
-addext "subjectAltName=DNS:localhost"
92+
openssl pkcs12 -export \
93+
-keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES \
94+
-inkey localhost_ss.key -in localhost_ss.crt \
95+
-passout pass:testpass \
96+
-out localhost.pfx
97+
```
98+
99+
- `localhost.pfx` — self-signed `CN=localhost`, passphrase `testpass`

tests/testdata/tls/localhost.pfx

2.4 KB
Binary file not shown.
-126 Bytes
Binary file not shown.
-126 Bytes
Binary file not shown.
-126 Bytes
Binary file not shown.
-126 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)