Why is Rust OpenSSL suddenly making invalid SANs?

Posted on Oct 5, 2024

If you have a Rust app that uses the openssl crate to generate certificates (or certificate signing requests), and one day out of the blue those certs. or CSRs are being rejected for malformed subject alternative names (SANs) with confusing errors like:

certificate is valid for example.com, www.example.com, not example.com

or:

Cannot issue for "example.com, dns:www.example.com": Domain name contains an invalid character

Then the root cause is almost certainly that your app was relying on an API quirk in the openssl crate that went away with a bugfix that landed in 2023.

The short story

While I find the longer story interesting, if you just want to fix your app immediately you should:

  • Check if you, or a transitive dependency, updated to openssl >= 0.10.48.
  • Replace any invocations of SubjectAlternativeName builder fns that were provided comma separated values to use one builder invocation per value instead:
// Bad:
let example = SubjectAlternativeName::new()
    .dns("example.com, DNS:www.example.com")
    .ip("127.0.0.1, IP:8.8.8.8")
    .build(...)

// Good:
let example = SubjectAlternativeName::new()
    .dns("example.com")
    .dns("www.example.com")
    .ip("127.0.0.1")
    .ip("8.8.8.8")
    .build(...)

If you haven’t updated openssl past that point then I’m afraid this story won’t help you fix your bug (and you are missing security fixes for vulnerabilities!).

If you’re interested in gory OpenSSL themed horror, read on.

The long story

I first bumped into this situation after rustls#1292 was created by a user confused by an error emitted by Rustls when talking to a server using a certificate generated with the Rust openssl crate. I bumped into it again this week after helping a friend debug a problem with a Rust ACME client, prompting the idea to write this stuff down :)

Beware OpenSSL text

Often the first thing folks reach for in these cases is the openssl command line tool to dump a textual representation of a problematic PEM encoded X.509 certificate to check its subject alternative names:

openssl x509 -in $PATH_TO_PEM_CERT -noout -text | \
  grep --after-context=2 "Subject Alternative Name:"

Which in the case of the cert provided in issue 1292, printed:

    X509v3 Subject Alternative Name:
        DNS:localhost, IP:127.0.0.1, DNS:localhost

The duplicate "localhost" dNSName1 type SAN stands out, but I’ll spoil the surprise a bit and say it’s a red herring. The real issue is that OpenSSL’s text output is a pretty crummy tool for this sort of debugging. It’s deceived us by not providing any delimiters around each SAN’s GeneralName values! What appears to be three SANs is actually just two.

  1. One dNSName type general name with the value "localhost, IP:127.0.0.1"
  2. One dNSName type general name with the value "localhost"

You can verify this with a more capable low-level tool like der-ascii:

der2ascii -pem -i $PATH_TO_PEM_CERT

This will print a lot decoded ASN.1 data2, but most importantly, the subjectAltName extension where the invalid SAN problem is easily visible:

<snipped>
SEQUENCE {
  # subjectAltName
  OBJECT_IDENTIFIER { 2.5.29.17 }
  OCTET_STRING {
    SEQUENCE {
      [2 PRIMITIVE] { "localhost, IP:127.0.0.1" }
      [2 PRIMITIVE] { "localhost" }
    }
  }
}
<snipped>

A valid certificate for both localhost and 127.0.0.1 should instead have a SAN extension like3:

<snipped>
SEQUENCE {
  # subjectAltName
  OBJECT_IDENTIFIER { 2.5.29.17 }
  OCTET_STRING {
    SEQUENCE {
      [2 PRIMITIVE] { "localhost" }
      [7 PRIMITIVE] { `7f000001` }
    }
  }
}
<snipped>

If we make that change and then run the text back through ascii2der we can see how openssl x509 would display the proper encoding in textual form. The difference is quite subtle!

ascii2der -i $PATH_TO_EDITED_TXT | \
  openssl x509 -inform der -noout -text | \
    grep --after-context=1 "Subject Alternative Name: "
DNS:localhost, IP Address:127.0.0.1

The observant will note the prefix of the IP address SAN is now shown as “IP Address:”, not “IP:” like we saw with the malformed cert.

OpenSSL also has a way to dump a more accurate ASN.1 representation of the certificate DER (openssl asn1parse) but it’s lackluster compared to der-ascii and only shows the whole SAN extension as a hex encoded octet string. It’s also perhaps not a great idea to be piping data that might be malformed in some way through a tool written in C with a history of memory safety vulns in its parsing code… In contrast, der-ascii is written in Go.

In either case I think we can all agree this certificate is busted: it has a clearly invalid dNSName SAN and no iPAddress SAN at all.

What changed?

Knowing the problem with the certificate doesn’t explain why certificate generation code that used to produce valid certificates is now producing certificates with freak-show conjoined SANs.

In the case of issue 1292 the generation code in question used the openssl crate and was building the SubjectAlternativeName as follows:

    let subject_alt_name = SubjectAlternativeName::new()
        .dns("localhost, IP:127.0.0.1")
        .build(&cert_builder.x509v3_context(Some(&ca_cert_x590), None))?;

This pointed to the root cause being a change in the way the dns() fn handled values. It’s not a large leap to theorize it must have previously allowed specifying multiple comma-separated values and now is treating it as one domain name value.

The old behaviour does seem confusing: it was using dns() but also providing an IP: prefixed SAN value. Shouldn’t it have used the ip() fn for that? Changing dns() to disallow that kind of mixed usage seems like great sense. With this insight in hand it didn’t take long to backtrack to rust-openssl#1854, “Fix a series of security issues”.

but wait… Security issues? I thought we were just chasing down a benign API change……..

Horror Shows

The change in question was to fix RUSTSEC-2023-0023, a bug reported by David Benjamin that says:

SubjectAlternativeName and ExtendedKeyUsage arguments were parsed using the OpenSSL function X509V3_EXT_nconf. This function parses all input using an OpenSSL mini-language which can perform arbitrary file reads.

😱 “an OpenSSL mini-language”.

😱😱 “which can perform arbitrary file reads”.

I believe this situation was correctly summarized by Alex Gaynor as a horror show and certainly seemed perfect for a spooky October blog post.

Stop the Madness

So now we understand why the certificate is invalid, when & why the openssl crate changed its SubjectAlternativeName builder behaviour, and how OpenSSL continues to provide new and exciting ways to shoot your feet off.

I’d be remiss if I didn’t close this story by suggesting it might be time to reconsider your OpenSSL dependencies.

For certificate generation needs consider rcgen for simpler situations, or the Rust Crypto project’s x509-cert crate if you have more complex needs. For TLS, consider rustls. It’s safer, and faster too.

Your sanity deserves it.


  1. The odd looking casing for dNSName and iPAddress is a quirk of RFC 5280. ↩︎

  2. Check out A Warm Welcome to ASN.1 and DER and RFC 5280 if you’re curious about understanding the full der-ascii output. ↩︎

  3. Note that 0x7f000001 is the hex encoding of the network-byte-order IPv4 “127.0.0.1” address. ↩︎