Verifying a TLS chain¶
The case most applications actually have: a peer sent a chain, you hold a trust store, and you need to decide whether to believe it.
This example does the whole thing, including the parts path validation does not do.
Getting a chain to work with¶
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
| sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' > chain.pem
The leaf comes first, then the intermediates the server chose to send. Roots are usually not sent.
The script¶
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use SpomkyLabs\Pki\ASN1\Element;
use SpomkyLabs\Pki\CryptoEncoding\PEMBundle;
use SpomkyLabs\Pki\X509\Certificate\CertificateBundle;
use SpomkyLabs\Pki\X509\Certificate\Extension\ExtendedKeyUsageExtension;
use SpomkyLabs\Pki\X509\CertificationPath\CertificationPath;
use SpomkyLabs\Pki\X509\CertificationPath\Exception\PathBuildingException;
use SpomkyLabs\Pki\X509\CertificationPath\Exception\PathValidationException;
use SpomkyLabs\Pki\X509\CertificationPath\PathValidation\PathValidationConfig;
use SpomkyLabs\Pki\X509\GeneralName\GeneralName;
$hostname = 'example.com';
// A real X.509 structure rarely nests deeper than ten levels; 64 is the default.
Element::setMaxNestingDepth(16);
// ---------------------------------------------------------------- the trust store
// Yours, not the system's: the library reads no trust store of its own.
$trustAnchors = CertificateBundle::fromPEMBundle(
PEMBundle::fromFile('/etc/ssl/certs/ca-certificates.crt')
);
// ---------------------------------------------------------------- what the peer sent
$material = file_get_contents('chain.pem');
// Bound the input before you decode it: the library decodes the string you hand it.
if (strlen($material) > 64 * 1024) {
exit("chain too large\n");
}
$sent = PEMBundle::fromString($material);
if (count($sent) > 10) {
exit("too many certificates in the chain\n");
}
$chain = CertificateBundle::fromPEMBundle($sent);
$leaf = $chain->all()[0]; // the peer puts its own certificate first
// ---------------------------------------------------------------- build and validate
$config = PathValidationConfig::defaultConfig()
->withAllowedSignatureAlgorithms(...PathValidationConfig::HARDENED_ALLOWED_SIGNATURE_ALGORITHMS)
->withMinimumRSAKeySize(PathValidationConfig::HARDENED_MINIMUM_RSA_KEY_SIZE);
try {
// The path is headed by a certificate from YOUR bundle. The peer's certificates
// only bridge the gap.
$path = CertificationPath::toTarget($leaf, $trustAnchors, $chain);
} catch (PathBuildingException $e) {
exit('no path to a trust anchor: ' . $e->getMessage() . "\n");
}
try {
$result = $path->validate($config);
} catch (PathValidationException $e) {
exit('path does not validate: ' . $e->getMessage() . "\n");
}
printf("path validates, %d certificates:\n", count($path));
foreach ($path as $i => $certificate) {
printf(" [%d] %s\n", $i, $certificate->tbsCertificate()->subject()->toString());
}
$validated = $result->certificate();
// ---------------------------------------------------------------- what validation did NOT do
// 1. The hostname. Path validation never looks at one.
$extensions = $validated->tbsCertificate()->extensions();
if (! $extensions->hasSubjectAlternativeName()) {
exit("no subjectAltName: cannot identify the server\n");
}
$dnsNames = [];
foreach ($extensions->subjectAlternativeName()->names()->allOf(GeneralName::TAG_DNS_NAME) as $name) {
$dnsNames[] = $name->name();
}
if (! matchesHostname($hostname, $dnsNames)) {
exit(sprintf("certificate is for %s, not %s\n", implode(', ', $dnsNames), $hostname));
}
// 2. Extended key usage. RFC 5280's algorithm says nothing about it.
if (! $extensions->hasExtendedKeyUsage()
|| ! $extensions->extendedKeyUsage()->has(ExtendedKeyUsageExtension::OID_SERVER_AUTH)) {
exit("not a TLS server certificate\n");
}
// 3. Revocation. The library never opens a connection, so a revoked certificate
// validates. This is where YOUR CRL or OCSP check goes.
$tbs = $validated->tbsCertificate();
printf("check revocation for issuer=%s serial=%s\n", $tbs->issuer()->toString(), $tbs->serialNumber());
if ($extensions->hasCRLDistributionPoints()) {
foreach ($extensions->crlDistributionPoints()->distributionPoints() as $point) {
if ($point->hasFullName()) {
foreach ($point->fullName()->names()->allOf(GeneralName::TAG_URI) as $uri) {
printf(" CRL: %s\n", $uri->uri());
}
}
}
}
printf("accepted for %s\n", $hostname);
/**
* A deliberately minimal matcher: exact, or one leading wildcard label.
*
* Real rules are longer — trailing dots, internationalised domains, public suffixes, whether a
* wildcard may appear anywhere but the leftmost label. Use your protocol's.
*
* @param string[] $dnsNames
*/
function matchesHostname(string $hostname, array $dnsNames): bool
{
$hostname = strtolower(rtrim($hostname, '.'));
foreach ($dnsNames as $name) {
$name = strtolower(rtrim($name, '.'));
if ($name === $hostname) {
return true;
}
if (str_starts_with($name, '*.')) {
$suffix = substr($name, 1); // ".example.com"
$labels = substr($hostname, 0, -strlen($suffix));
if (str_ends_with($hostname, $suffix) && $labels !== '' && ! str_contains($labels, '.')) {
return true;
}
}
}
return false;
}
Output, against a real server:
path validates, 4 certificates:
[0] cn=SSL.com TLS ECC Root CA 2022,o=SSL Corporation,c=US
[1] cn=SSL.com TLS Transit ECC CA R2,o=SSL Corporation,c=US
[2] cn=Cloudflare TLS Issuing ECC CA 3,o=SSL Corporation,c=US
[3] cn=example.com
check revocation for issuer=cn=Cloudflare TLS Issuing ECC CA 3,o=SSL Corporation,c=US serial=2569673129085639595562975411803018896
CRL: http://c.cf-i.ssl.com/ae801ed1c55bb579d79208b0d772acfb8cc3a208.crl
accepted for example.com
Whose certificates those are will have changed by the time you run it; the shape of the output is the point.
What each part is for¶
toTarget() and not fromCertificateChain(). The path is headed by a certificate from your trust
store. fromCertificateChain($chain) would produce a path headed by whatever the peer put last, and
would then refuse to validate without an explicit anchor — correctly, because that is not a trust
decision. See
Validating a path.
The peer's certificates as $intermediates. A certificate from that bundle can appear in the middle
of a path, never at its head. Passing the chain there is how you let the peer help you find a path
without letting them choose where it starts.
The hardened configuration. The defaults accept SHA-1 and 1024-bit RSA, for deployments that still need to. A TLS client on the public internet does not. See Configuration.
The size and count bounds. The decoder caps nesting depth; it does not cap the length of the string or the number of blocks in a bundle. See Decoding untrusted input.
The three checks after validation. Hostname, extended key usage, revocation. None of them is part of
RFC 5280 section 6.1, so none of them is part of validate(). The full list is in
What validation does not do.
What this is not¶
This is the certificate layer, not a TLS stack. It does not speak the protocol, negotiate anything, check that the server proved possession of the key during the handshake, or implement the additional requirements TLS itself imposes on certificates. For an actual TLS connection, let the TLS implementation verify the chain; use this when you hold certificates outside a handshake — pinned material, an enrolment flow, an audit, a chain in a token.