PKI & Certificate Chains
Certificate failures are rarely cryptographic. They are bookkeeping: a chain the server forgot to send, a name the verifier short-circuited past, a revocation check that was never a check, a pin computed over the wrong bytes. This module reads the two libraries that decide — OpenSSL's X.509 verifier and BoringSSL's TLS transport — which disagree: the same parameter, depth, permits a chain one certificate longer in one than the other. Which tree a fact came from is the whole difference.
What you'll explore
- 01
What the Verifier Reads, and in What Order
A certificate is parsed long before it is trusted, and OpenSSL caches the conclusions: ossl_x509v3_cache_extensions() walks the extensions once and sets flag bits, so EXFLAG_CA means basicConstraints said cA was true and EXFLAG_BCONS means the extension was present at all — two different facts that folklore merges into 'it is a CA certificate'. Three fields then decide the ordinary outcomes. The first is the name, and here the rule is a short-circuit rather than a preference: do_x509_check() collects the subjectAltName entries, and if any SAN of the requested type was present it returns the SAN result and never looks at the subject at all, unless the caller passes X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT. The san_present flag is the whole mechanism. So the common sentence 'SAN is preferred over CN' understates it — a certificate with one irrelevant dNSName and the right hostname in its Common Name fails, and it fails without the Common Name ever being read. The second is time, and it is the narrowest check in the module: ossl_x509_check_cert_time() compares notBefore and notAfter against now and nothing else. There is no maximum-lifetime check anywhere in either tree — neither library caps a certificate's lifetime, and grepping both for a ceiling constant returns nothing — so a ten-year certificate verifies cleanly in OpenSSL and is rejected by a browser. Every lifetime limit you have ever hit is policy: the CA/Browser Forum maximum of 398 days applies to certificates issued on or after 2020-09-01, later ballots have scheduled further reductions, and none of it is in the code. The third is the key, where the sizes are exact and settle an argument people usually have with adjectives. An RSA signature is the modulus (RSA_size, BoringSSL include/openssl/rsa.h:477-479): 256 bytes at 2048 bits, 512 bytes at 4096. An ECDSA signature is DER-wrapped, so ECDSA_SIG_max_len() gives at most 72 bytes at P-256 and 104 bytes at P-384. Ed25519 is fixed at ED25519_SIGNATURE_LEN = 64 bytes with a 32-byte key. So the wire cost spans an order of magnitude: Ed25519's 64 bytes is an eighth of RSA-4096's 512, P-256's 72 a seventh, P-384's 104 a fifth — and all of them sign far more cheaply than RSA. Verification runs the other way: RSA's short public exponent makes verifying the cheap direction, while an ECDSA check needs two scalar multiplications. That asymmetry is the answer to 'which is faster' — it depends entirely on which side of the handshake you are, because the server signs once per connection and every client verifies.
- 02
Two Libraries, One Word, Different Arithmetic
Chain building is a search, not a lookup, and the two trees in this module disagree about how deep it may go — in a comment each, which is the cleanest demonstration in the repo that a citation without a tree is unresolvable. OpenSSL's build_chain() sets max_depth = ctx->param->depth + 1 and says why: it builds one longer than the limit so it can report X509_V_ERR_CERT_CHAIN_TOO_LONG rather than silently stopping. BoringSSL computes max_chain = param->depth + 2 and explains the difference in its own words — depth 'does not include the leaf certificate or the trust anchor'. Same parameter name, same nominal meaning, one more certificate allowed. What depth counts is also not what most people assume: OpenSSL's comment spells out that with verify_depth = 0 the callback reports errors at depth 1 when the leaf's immediate issuer is not a trust anchor, so 0 is a legal and meaningful setting — a leaf signed directly by a root — and the count is of intermediates, not of certificates. Then there is what the server actually sends. BoringSSL's ssl_parse_cert_chain() parses the peer's list as presented; nothing in the TLS layer fetches a missing link, and an authorityInfoAccess URL is a hint the application may chase, not something the handshake does. So the single most common certificate incident — works in curl, fails in a Java client, works in a browser — is usually one missing intermediate plus three verifiers with different willingness to paper over it, and OpenSSL's failure is X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY: the issuer was not in the store and not in what you sent. Cross-signing is the case where the search really is a search. An intermediate signed by two roots gives two paths to two anchors, and OpenSSL will try the second only if alternate-chain retry is enabled: build_chain() sets the S_DOALTERNATE bit and reruns with a pruned chain, and X509_V_FLAG_NO_ALT_CHAINS turns exactly that off. So 'cross-signing means it keeps working when a root is distrusted' holds only if the verifier retries — which is the honest version of the lesson, because the famous cross-signing outages were verifiers that picked the expired path first and stopped.
- 03
The Check the Verifier Does Not Do
Start with the finding that reorganises this whole topic: OpenSSL's chain verifier performs no OCSP at all. check_revocation() returns success immediately unless X509_V_FLAG_CRL_CHECK is set, and the string OCSP does not appear even once in the entire x509_vfy.c. The verifier implements CRLs and only CRLs — and even then, not over the whole chain: X509_V_FLAG_CRL_CHECK_ALL is what extends the walk past the leaf (:1047-1048), so in the ordinary configuration the loop runs over chain index 0 alone and a revoked INTERMEDIATE passes. That is the case a CA compromise produces, which makes it the one worth knowing. check_crl() looks the serial up and raises X509_V_ERR_CERT_REVOKED if it is listed, with one deliberate exception — reason removeFromCRL returns 2, a certificate being un-revoked. Everything else people call 'revocation checking' is the application's work, driven through a separate API, and that is why the four settings on this control are not four settings of one knob: 'none' is the library's own default behaviour, 'CRL' is a flag on the verifier, and both OCSP options are code the caller has to write. OpenSSL gives the caller two pieces. OCSP_resp_find_status() finds the single response matching a certificate ID and hands back status, revocation time and the thisUpdate/nextUpdate pair. OCSP_check_validity() then judges freshness, and its signature is the interesting part: nsec allows for clock skew and maxsec bounds how old a thisUpdate may be — and maxsec is optional. A responder that stopped updating still returns a signed, structurally valid, affirmatively good response for as long as the caller is willing to accept it, so the staleness policy is the entire security of the check. Stapling (RFC 6066) removes the round trip and nothing else. BoringSSL hands the bytes over with the warning twice in its own header — SSL_get0_ocsp_response() returns data that is 'not guaranteed to be well formed' — because the TLS layer carries the response and never parses it. BoringSSL has no OpenSSL-compatible OCSP_* API at all — a whole-tree grep for OCSP_resp_find_status, OCSP_basic_verify and OCSP_check_validity returns nothing, so a citation of that name to BoringSSL points at a function that is not there. What BoringSSL does have is the same capability under a different name in a different layer: bssl::CheckOCSP() in the modern bssl::pki certificate stack (include/openssl/pki/ocsp.h:115, implemented in pki/ocsp.cc), which takes a DER response plus the certificate and its issuer and returns GOOD, REVOKED or UNKNOWN. That is the honest version, and it is a sharper lesson than an absence: one library ships two certificate stacks, and 'does BoringSSL do OCSP' has no answer until you say which one you mean. Note what the two agree on, because it is the operational point — BoringSSL's max_age_seconds is a std::optional, exactly as OpenSSL's maxsec is optional, so in both libraries a caller who sets no bound accepts an affirmatively-good response of any age. The header also gives the operational advice worth repeating: treat a stapled response like any other server credential and refresh it eagerly, so a briefly offline responder is not an outage.
- 04
A Promise From a Log You Have to Already Trust
An SCT is a log's signed statement that it has seen a certificate, and the first surprise is when that happened: during ISSUANCE, before the certificate existed. A certificate cannot contain a signature over itself, so the CA submits a PRECERTIFICATE — the same content plus a poison extension that makes it unusable for TLS — each log returns an SCT promising to incorporate it, and the CA embeds those SCTs in the certificate it then issues. That is a branch in OpenSSL's own code rather than folklore: SCT_CTX_set1_cert() looks the extension up by NID_ct_precert_poison (crypto/ct/ct_sct_ctx.c:125) under the comment 'If *cert doesn't have a poison extension, it isn't a precert' (:131-132), and it rejects a certificate carrying both an SCT extension and a poison extension (:148-153), because the two are the before and after of one issuance. So the log entries predate your handshake AND predate the certificate you are serving — the logging was a step in issuance, not something you do afterwards. Verifying one, by contrast, is a signature check with a prerequisite that does more work than the signature. OpenSSL's SCT_validate() refuses twice before it ever looks at the signature: an unrecognised version gives SCT_VALIDATION_STATUS_UNKNOWN_VERSION, and a log id that is not in the store gives SCT_VALIDATION_STATUS_UNKNOWN_LOG. An SCT from a log you do not know about is not invalid — it is unverifiable, which is a different state and a much more common one. Only with a known log does it build a context and call SCT_CTX_verify(), which re-checks that the SCT's log id matches that log's key hash, rejects a timestamp in the future, and then verifies the signature with SHA2-256 over the reconstructed log entry. So the trust anchor for CT is the log list, and OpenSSL does not ship one: the CTLOG_STORE is the caller's to populate, which means a deployment's CT posture is a file somebody maintains. Two things follow that are easy to get backwards. First, validity is per-SCT and the policy is not: how many SCTs are required, from how many independent operators, and over what period, is a browser rule layered on top of these functions — Chrome's requirement is not something OpenSSL or BoringSSL can be said to implement. Second, BoringSSL's role here is narrower still. It carries the SignedCertificateTimestampList as a TLS extension and hands it over verbatim; SSL_get0_signed_cert_timestamp_list() comes with the same warning as the stapling accessor, that the returned data is 'not guaranteed to be well formed', and includes the two leading length bytes. So in this module's two trees the division is total: BoringSSL transports SCTs, OpenSSL validates them against a log list, and nobody enforces a policy. The practical consequence is that 'CT is enabled' is three separate claims — the certificate carries SCTs, the client can verify them, and something decides how many are enough — and a deployment can satisfy any two.
- 05
Hashing the Wrong Bytes
A public-key pin is SHA-256 over the DER SubjectPublicKeyInfo — the structure that carries both the AlgorithmIdentifier and the subjectPublicKey BIT STRING. OpenSSL has a function whose name reads exactly like that operation, and it computes something else. X509_pubkey_digest() hashes X509_get0_pubkey_bitstr(), and that accessor returns key->public_key: the BIT STRING alone, with no AlgorithmIdentifier wrapped around it. The digest is therefore over fewer bytes than a pin is, and a pin computed with it matches nothing any browser or peer would compute. There is no bug here and no deprecation warning — the function does what its name says if you already know that 'pubkey' means the bit string in this part of the API, and the whole trap is that the name is also a perfect description of the wrong thing. The correct bytes come from serialising the X509_PUBKEY itself, which is what the familiar openssl x509 -pubkey | openssl pkey -pubin -outform der | sha256 pipeline does. Then there is the question of what to pin to, which is a rotation question rather than a cryptographic one. A leaf pin breaks at the first renewal that generates a new key, so it survives only if the key is deliberately reused across renewals — and reusing a key for years to protect a pin is its own exposure. An intermediate pin survives every leaf rotation and dies the day the CA introduces a new issuing intermediate, which CAs do on their own schedule and rarely announce to subscribers. A root pin lasts longest and becomes ambiguous exactly when cross-signing is in play, because which root terminates the chain is then a property of the verifier's search rather than of the certificate. Finally, the honest seam, and it took two attempts to state narrowly enough. Neither library implements HPKP: there is no HTTP response header, no per-origin pin store and no max-age, because that protocol is a browser's job and neither tree is a browser (HPKP, pin-sha256 and Public-Key-Pins return nothing in either). ⚠️ What neither library lacks is the PRIMITIVE. OpenSSL takes an expected SPKI hash from the application and fails verification when it does not match — that is DANE-EE(3): SSL_dane_tlsa_add() (OpenSSL include/openssl/ssl.h.in:1849) supplies the TLSA records, dane_i2d() (OpenSSL crypto/x509/x509_vfy.c:2857) serialises the whole SubjectPublicKeyInfo under the SPKI selector (:2870) — the same bytes a published pin hashes — dane_match_cert() (:2890) digests and compares them for every DANE-EE record (:3122, :3141), and a mismatch is X509_V_ERR_DANE_NO_MATCH (:3252). So the real difference is where the pin COMES FROM: DNS, signed, instead of an HTTP header the site itself sent. BoringSSL is the tree with neither. BoringSSL is the tree whose absence survives a search for the capability rather than the vocabulary; the nearest things in it are Trust Anchor Identifiers, which SELECT a credential the peer already trusts, and a client-certificate hash kept only to shrink a session cache. So HPKP-style enforcement lives in the application — or, for the web, it largely does not live anywhere any more, since HPKP was withdrawn after years of sites locking themselves out, and the surviving deployments are mobile apps and machine-to-machine clients where one party controls both ends.
Validated against Two trees, split by LAYER, and the paths below do not say which — every kernel callout opens with the name of the library that owns it, because both trees ship a crypto/x509/x509_vfy file and they disagree on depth. OpenSSL (openssl-3.5.7) owns X.509 and PKI proper, and all of crypto/x509/, crypto/ocsp/ and crypto/ct/ below are its: chain building, verify depth, validity, SAN matching, CRLs, OCSP status and SCT validation. BoringSSL (eada60b7) owns what the TLS handshake CARRIES, and ssl/, include/openssl/ and crypto/ecdsa/ below are its — with one exception worth naming, because it is this module's own premise recurring on a second path family: OpenSSL's generated public headers are .h.in at this pin, so include/openssl/ssl.h.in is OPENSSL's and include/openssl/ssl.h is BORINGSSL's. The extension is the only thing that distinguishes them, which is why every citation of either opens with its library name. BoringSSL's are: the chain a peer sends and parses, the OCSP-stapling and SCT-list extensions, and the signature sizes. Neither library implements HPKP: there is no HTTP response header, no per-origin pin store and no max-age, because that protocol is a browser's job and neither tree is a browser (HPKP, pin-sha256 and Public-Key-Pins return nothing in either). ⚠️ What neither library lacks is the PRIMITIVE. OpenSSL takes an expected SPKI hash from the application and fails verification when it does not match — that is DANE-EE(3): SSL_dane_tlsa_add() (OpenSSL include/openssl/ssl.h.in:1849) supplies the TLSA records, dane_i2d() (OpenSSL crypto/x509/x509_vfy.c:2857) serialises the whole SubjectPublicKeyInfo under the SPKI selector (:2870) — the same bytes a published pin hashes — dane_match_cert() (:2890) digests and compares them for every DANE-EE record (:3122, :3141), and a mismatch is X509_V_ERR_DANE_NO_MATCH (:3252). So the real difference is where the pin COMES FROM: DNS, signed, instead of an HTTP header the site itself sent. BoringSSL is the tree with neither. And neither caps a certificate's lifetime, for which there genuinely is no code in either tree. openssl-3.5.7 (X.509/PKI) + boringssl eada60b7 (TLS transport) — crypto/x509/x509_vfy.c and friends. Every simulated behavior cites a real source function, and a server-side correlation engine computes how each layer's state cascades into the next. See the methodology →
This module is part of zerohop Pro — it unlocks alongside the full kata library, progress tracking, and weak-area analysis.