new X509($der), $ders); * X509::verifyChain($chain, 'example.com'); // 失敗すると\RuntimeException * $cert = X509::issue($key, 'localhost', ['sans' => ['localhost', '127.0.0.1']]); // 自己署名 */ class X509 { // 署名のアルゴリズム => [鍵の種類, ハッシュ] private const SIG_ALGS = [ '1.2.840.113549.1.1.5' => ['rsa', 'sha1'], '1.2.840.113549.1.1.14' => ['rsa', 'sha224'], '1.2.840.113549.1.1.11' => ['rsa', 'sha256'], '1.2.840.113549.1.1.12' => ['rsa', 'sha384'], '1.2.840.113549.1.1.13' => ['rsa', 'sha512'], '1.2.840.113549.1.1.10' => ['pss', null], '1.2.840.10045.4.1' => ['ec', 'sha1'], '1.2.840.10045.4.3.2' => ['ec', 'sha256'], '1.2.840.10045.4.3.3' => ['ec', 'sha384'], '1.2.840.10045.4.3.4' => ['ec', 'sha512'], ]; private const HASH_OIDS = [ '1.3.14.3.2.26' => 'sha1', '2.16.840.1.101.3.4.2.4' => 'sha224', '2.16.840.1.101.3.4.2.1' => 'sha256', '2.16.840.1.101.3.4.2.2' => 'sha384', '2.16.840.1.101.3.4.2.3' => 'sha512', ]; private const NAMES = [ '2.5.4.3' => 'CN', '2.5.4.6' => 'C', '2.5.4.7' => 'L', '2.5.4.8' => 'ST', '2.5.4.10' => 'O', '2.5.4.11' => 'OU', '2.5.4.5' => 'serialNumber', '1.2.840.113549.1.9.1' => 'emailAddress', '0.9.2342.19200300.100.1.25' => 'DC', ]; // 理解している拡張(重要でも拒否しない) private const KNOWN_EXT = ['2.5.29.15', '2.5.29.17', '2.5.29.19', '2.5.29.30', '2.5.29.37', '2.5.29.32', '2.5.29.36', '2.5.29.54', '2.5.29.14', '2.5.29.35']; private const EKU_SERVER = '1.3.6.1.5.5.7.3.1'; private const EKU_ANY = '2.5.29.37.0'; private const MAX_DEPTH = 10; /** 証明書の場所(TLS_CAFILEが無い場合、順に探す) */ public const CA_FILES = [ '/etc/ssl/cert.pem', // OpenBSD、macOS、Alpine(chrootの中なら/var/www/etc/ssl/cert.pem) '/etc/ssl/certs/ca-certificates.crt', // Debian、Ubuntu '/etc/pki/tls/certs/ca-bundle.crt', // RHEL、Fedora '/etc/ssl/ca-bundle.pem', // openSUSE '/usr/local/share/certs/ca-root-nss.crt', // FreeBSD ]; private static array $stores = []; public readonly string $der; public readonly string $tbs; public readonly int $version; public readonly string $serial; public readonly string $issuerRaw; public readonly string $subjectRaw; /** 「CN=…, O=…」の形 */ public readonly string $issuer; public readonly string $subject; public readonly int $notBefore; public readonly int $notAfter; public readonly string $spki; public readonly string $sigAlg; private array $sigParams; private string $signature; /** 拡張:OID => ['critical' => bool, 'value' => DER] */ public readonly array $extensions; private ?array $subjectAttrs = null; /** * @param string $der DER(PEMはfromPem()) * @throws \UnexpectedValueException 壊れた証明書の場合 */ public function __construct(string $der) { $this->der = $der; $c = Asn1::expect(Asn1::parse($der), Asn1::SEQUENCE, 3); $this->tbs = $c[0]['raw']; $alg = Asn1::expect($c[1], Asn1::SEQUENCE, 1); $this->sigAlg = Asn1::toOid($alg[0]); $this->sigParams = array_slice($alg, 1); $this->signature = Asn1::toBits($c[2]); $t = Asn1::expect($c[0], Asn1::SEQUENCE, 6); $i = 0; $version = 1; if ($t[0]['tag'] === 0xA0) { $version = gmp_intval(Asn1::toInt(Asn1::children($t[0])[0])) + 1; $i = 1; } $this->version = $version; $this->serial = gmp_strval(Asn1::toInt($t[$i]), 16); $this->issuerRaw = $t[$i + 2]['raw']; $validity = Asn1::expect($t[$i + 3], Asn1::SEQUENCE, 2); $this->notBefore = Asn1::toTime($validity[0]); $this->notAfter = Asn1::toTime($validity[1]); $this->subjectRaw = $t[$i + 4]['raw']; $this->spki = $t[$i + 5]['raw']; $this->issuer = self::nameString($t[$i + 2]); $this->subject = self::nameString($t[$i + 4]); $ext = []; foreach (array_slice($t, $i + 6) as $x) { if ($x['tag'] !== 0xA3) continue; foreach (Asn1::children(Asn1::children($x)[0]) as $e) { $ec = Asn1::children($e); $oid = Asn1::toOid($ec[0]); $crit = count($ec) === 3 && $ec[1]['tag'] === Asn1::BOOLEAN && $ec[1]['value'] !== "\0"; if (isset($ext[$oid])) throw new \UnexpectedValueException("X509:重複した拡張:{$oid}"); $ext[$oid] = ['critical' => $crit, 'value' => end($ec)['value']]; } } $this->extensions = $ext; } /** * PEMから読む(最初の証明書) * * @throws \UnexpectedValueException */ public static function fromPem(string $pem): self { foreach (Asn1::unpem($pem) as $b) { if ($b['label'] === 'CERTIFICATE') return new self($b['der']); } throw new \UnexpectedValueException('X509:証明書が見つからない'); } /** * PEMの中の証明書を全部読む(チェーン) * * @return self[] */ public static function allFromPem(string $pem): array { $out = []; foreach (Asn1::unpem($pem) as $b) { if ($b['label'] === 'CERTIFICATE') $out[] = new self($b['der']); } return $out; } public function pem(): string { return Asn1::pem($this->der, 'CERTIFICATE'); } /** 公開鍵 */ public function publicKey(): Rsa|Ec { $alg = Asn1::toOid(Asn1::children(Asn1::children(Asn1::parse($this->spki))[0])[0]); return match ($alg) { Rsa::OID, Rsa::OID_PSS => Rsa::fromDer($this->spki), Ec::OID => Ec::fromDer($this->spki), default => throw new \UnexpectedValueException("X509:未対応の鍵:{$alg}"), }; } /** * この証明書の署名を発行者の鍵で確認する * * @param Rsa|Ec $key 発行者の公開鍵 * @param bool $allowSha1 SHA-1を許すかどうか * @return bool */ public function verifySignature(Rsa|Ec $key, bool $allowSha1 = false): bool { [$type, $hash] = self::SIG_ALGS[$this->sigAlg] ?? [null, null]; if ($type === null) return false; if ($type === 'pss') { [$hash, $salt] = self::pssParams($this->sigParams[0] ?? null); if ($hash === null || !($key instanceof Rsa)) return false; return $key->verifyPss($this->tbs, $this->signature, $hash, $salt); } if ($hash === 'sha1' && !$allowSha1) return false; if ($type === 'rsa') return $key instanceof Rsa && $key->bits >= 1024 && $key->verify($this->tbs, $this->signature, $hash); return $key instanceof Ec && $key->verify($this->tbs, $this->signature, $hash); } /** RSASSA-PSS-params からハッシュとソルトの長さ */ private static function pssParams(?array $node): array { $hash = 'sha1'; $salt = 20; if ($node === null) return [$hash, $salt]; foreach (Asn1::children($node) as $p) { $v = Asn1::children($p)[0] ?? null; if ($v === null) continue; if ($p['tag'] === 0xA0) $hash = self::HASH_OIDS[Asn1::toOid(Asn1::children($v)[0])] ?? null; if ($p['tag'] === 0xA1) { $mgf = Asn1::children($v); $mgfHash = isset($mgf[1]) ? (self::HASH_OIDS[Asn1::toOid(Asn1::children($mgf[1])[0])] ?? null) : 'sha1'; if (Asn1::toOid($mgf[0]) !== '1.2.840.113549.1.1.8' || $mgfHash !== $hash) return [null, 0]; } if ($p['tag'] === 0xA2) $salt = gmp_intval(Asn1::toInt($v)); } if ($hash === 'sha1') return [null, 0]; return [$hash, $salt]; } /////////////// // 拡張 /** subjectAltNameのdNSNameとiPAddress */ public function altNames(): array { $out = ['dns' => [], 'ip' => []]; if (!isset($this->extensions['2.5.29.17'])) return $out; foreach (Asn1::children(Asn1::parse($this->extensions['2.5.29.17']['value'])) as $n) { if ($n['tag'] === 0x82) $out['dns'][] = strtolower($n['value']); if ($n['tag'] === 0x87) $out['ip'][] = $n['value']; } return $out; } /** 主体の属性(CN等)の値 */ public function subjectAttr(string $name): array { if ($this->subjectAttrs === null) { $this->subjectAttrs = []; foreach (Asn1::children(Asn1::parse($this->subjectRaw)) as $rdn) { foreach (Asn1::children($rdn) as $atv) { $a = Asn1::children($atv); $this->subjectAttrs[self::NAMES[Asn1::toOid($a[0])] ?? Asn1::toOid($a[0])][] = Asn1::toString($a[1]); } } } return $this->subjectAttrs[$name] ?? []; } /** basicConstraints:[CAかどうか, pathLen(無ければnull)] */ public function basicConstraints(): array { if (!isset($this->extensions['2.5.29.19'])) return [false, null]; $c = Asn1::children(Asn1::parse($this->extensions['2.5.29.19']['value'])); $ca = isset($c[0]) && $c[0]['tag'] === Asn1::BOOLEAN && $c[0]['value'] !== "\0"; $len = null; foreach ($c as $x) if ($x['tag'] === Asn1::INTEGER) $len = gmp_intval(Asn1::toInt($x)); return [$ca, $len]; } /** keyUsageのビット(0=digitalSignature、5=keyCertSign)、拡張が無ければnull */ public function keyUsage(): ?array { if (!isset($this->extensions['2.5.29.15'])) return null; $bits = Asn1::toBits(Asn1::parse($this->extensions['2.5.29.15']['value'])); $out = []; for ($i = 0; $i < 8 * strlen($bits); $i++) { if (ord($bits[$i >> 3]) & (0x80 >> ($i & 7))) $out[] = $i; } return $out; } /** extendedKeyUsageのOID、拡張が無ければnull */ public function extKeyUsage(): ?array { if (!isset($this->extensions['2.5.29.37'])) return null; return array_map([Asn1::class, 'toOid'], Asn1::children(Asn1::parse($this->extensions['2.5.29.37']['value']))); } public function isSelfIssued(): bool { return $this->issuerRaw === $this->subjectRaw; } /////////////// // 確認 /** * ホスト名(又はIPアドレス)がこの証明書に合うかどうか(RFC 6125) * * subjectAltNameが無い場合だけ、CNを見る。ワイルドカードは一番左のラベルの「*」だけ。 * * @param string $host * @return bool */ public function matchesHost(string $host): bool { $host = strtolower(rtrim(trim($host, '[]'), '.')); $alt = $this->altNames(); $ip = @inet_pton($host); if ($ip !== false) { foreach ($alt['ip'] as $a) if ($a === $ip) return true; if (empty($alt['ip']) && empty($alt['dns'])) { foreach ($this->subjectAttr('CN') as $cn) if (@inet_pton($cn) === $ip) return true; } return false; } $names = $alt['dns'] ?: (empty($alt['ip']) ? array_map('strtolower', $this->subjectAttr('CN')) : []); foreach ($names as $n) { $n = rtrim($n, '.'); if ($n === $host) return true; if (str_starts_with($n, '*.') && substr_count($n, '.') >= 2) { $dot = strpos($host, '.'); if ($dot !== false && $dot > 0 && substr($host, $dot) === substr($n, 1)) return true; } } return false; } /** * サーバーの証明書のチェーンを確認する * * @param self[] $chain サーバーから受け取った証明書([0]がサーバーの証明書) * @param string|null $host 確認するホスト名(nullなら確認しない) * @param array $opt cafile(信頼する証明書のPEMファイル)、trusted(self[]、cafileの代わり)、 * allow_self_signed(自己署名の証明書を許す)、time(確認する時間) * @return void * @throws \RuntimeException 確認に失敗した場合(理由はメッセージ) */ public static function verifyChain(array $chain, ?string $host, array $opt = []): void { if (empty($chain)) throw new \RuntimeException('証明書がありません'); $now = (int)($opt['time'] ?? time()); $leaf = $chain[0]; if ($now < $leaf->notBefore) throw new \RuntimeException("証明書はまだ有効ではありません({$leaf->subject})"); if ($now > $leaf->notAfter) throw new \RuntimeException("証明書の有効期限が切れています({$leaf->subject})"); if ($host !== null && !$leaf->matchesHost($host)) throw new \RuntimeException("証明書のホスト名が一致しません({$host}、{$leaf->subject})"); $eku = $leaf->extKeyUsage(); if ($eku !== null && !in_array(self::EKU_SERVER, $eku, true) && !in_array(self::EKU_ANY, $eku, true)) { throw new \RuntimeException('証明書はサーバー用ではありません'); } self::checkCritical($leaf); if (!empty($opt['allow_self_signed']) && $leaf->isSelfIssued()) { if ($leaf->verifySignature($leaf->publicKey(), true)) return; } $trusted = $opt['trusted'] ?? self::store($opt['cafile'] ?? null); $names = $leaf->altNames(); if (empty($names['dns']) && empty($names['ip'])) $names['dns'] = array_map('strtolower', $leaf->subjectAttr('CN')); $cur = $leaf; $used = [0 => true]; for ($depth = 0; $depth <= self::MAX_DEPTH; $depth++) { // 信頼する証明書そのもの foreach ($trusted[$cur->subjectRaw] ?? [] as $t) { if ($t->der === $cur->der) return; } // 信頼する証明書が発行した foreach ($trusted[$cur->issuerRaw] ?? [] as $t) { if ($now > $t->notAfter || $now < $t->notBefore) continue; if ($cur->verifySignature($t->publicKey())) { self::checkNameConstraints($t, $names); return; } } // 受け取った中間証明書 $next = null; foreach ($chain as $i => $c) { if (isset($used[$i]) || $c->subjectRaw !== $cur->issuerRaw) continue; if (!$cur->verifySignature($c->publicKey())) continue; $next = $i; break; } if ($next === null) { throw new \RuntimeException($cur->isSelfIssued() ? "自己署名の証明書です({$cur->subject})" : "信頼出来ない証明書です(発行者:{$cur->issuer})"); } $ca = $chain[$next]; [$isCa, $pathLen] = $ca->basicConstraints(); if (!$isCa) throw new \RuntimeException("中間証明書がCAではありません({$ca->subject})"); if ($pathLen !== null && $depth > $pathLen) throw new \RuntimeException("中間証明書のpathLenを超えました({$ca->subject})"); $ku = $ca->keyUsage(); if ($ku !== null && !in_array(5, $ku, true)) throw new \RuntimeException("中間証明書は証明書に署名出来ません({$ca->subject})"); if ($now > $ca->notAfter || $now < $ca->notBefore) throw new \RuntimeException("中間証明書の有効期限が切れています({$ca->subject})"); self::checkCritical($ca); self::checkNameConstraints($ca, $names); $used[$next] = true; $cur = $ca; } throw new \RuntimeException('証明書のチェーンが長過ぎます'); } /** 理解していない重要な拡張があれば拒否する */ private static function checkCritical(self $c): void { foreach ($c->extensions as $oid => $e) { if ($e['critical'] && !in_array($oid, self::KNOWN_EXT, true)) throw new \RuntimeException("未対応の重要な拡張:{$oid}"); } } /** nameConstraints(dNSNameとiPAddressだけ) */ private static function checkNameConstraints(self $ca, array $names): void { if (!isset($ca->extensions['2.5.29.30'])) return; $sets = ['permitted' => ['dns' => [], 'ip' => []], 'excluded' => ['dns' => [], 'ip' => []]]; foreach (Asn1::children(Asn1::parse($ca->extensions['2.5.29.30']['value'])) as $part) { $kind = $part['tag'] === 0xA0 ? 'permitted' : 'excluded'; foreach (Asn1::children($part) as $subtree) { $base = Asn1::children($subtree)[0]; if ($base['tag'] === 0x82) $sets[$kind]['dns'][] = strtolower(ltrim($base['value'], '.')); if ($base['tag'] === 0x87) $sets[$kind]['ip'][] = $base['value']; } } $dnsIn = fn(string $n, string $c): bool => $c === '' || $n === $c || str_ends_with($n, '.'.$c); $ipIn = function(string $ip, string $c): bool { $l = strlen($ip); if (strlen($c) !== 2 * $l) return false; return ($ip & substr($c, $l)) === (substr($c, 0, $l) & substr($c, $l)); }; foreach (['dns' => $dnsIn, 'ip' => $ipIn] as $type => $in) { foreach ($names[$type] as $n) { $n = $type === 'dns' ? ltrim($n, '*.') : $n; foreach ($sets['excluded'][$type] as $c) if ($in($n, $c)) throw new \RuntimeException("名前の制約に違反します({$ca->subject})"); if (!empty($sets['permitted'][$type])) { $ok = false; foreach ($sets['permitted'][$type] as $c) if ($in($n, $c)) $ok = true; if (!$ok) throw new \RuntimeException("名前の制約に違反します({$ca->subject})"); } } } } /** * 信頼する証明書を読む(主体のDER => self[]、プロセスの中でキャッシュする) * * @param string|null $file PEMファイル(nullならTLS_CAFILE、無ければCA_FILESを探す) * @return array * @throws \RuntimeException ファイルが見つからない場合 */ public static function store(?string $file = null): array { if ($file === null || $file === '') { $file = defined('TLS_CAFILE') && TLS_CAFILE !== '' ? (string)TLS_CAFILE : null; if ($file === null) { foreach (self::CA_FILES as $f) { if (@is_file($f)) { $file = $f; break; } } } if ($file === null) throw new \RuntimeException('信頼する証明書のファイルが見つかりません(TLS_CAFILEを設定して下さい)'); } if (isset(self::$stores[$file])) return self::$stores[$file]; $pem = @file_get_contents($file); if ($pem === false) throw new \RuntimeException("信頼する証明書のファイルを読めません:{$file}"); $out = []; foreach (Asn1::unpem($pem) as $b) { if ($b['label'] !== 'CERTIFICATE' && $b['label'] !== 'TRUSTED CERTIFICATE') continue; try { $c = new self($b['der']); } catch (\Throwable $e) { continue; } $out[$c->subjectRaw][] = $c; } return self::$stores[$file] = $out; } /** * 証明書の配列を、verifyChain()のtrustedの形にする * * @param self[] $certs * @return array */ public static function trust(array $certs): array { $out = []; foreach ($certs as $c) $out[$c->subjectRaw][] = $c; return $out; } /////////////// // 作成 /** * 証明書を作る(テスト、MySQL等の内部のサーバー用) * * @param Rsa|Ec $key この証明書の鍵(自己署名なら秘密鍵) * @param string $cn 主体のCN * @param array $opt sans(DNS名・IPアドレスの配列、省略するとCN)、days(365)、notBefore、notAfter、 * ca(bool)、pathLen、issuer([self 発行者の証明書, Rsa|Ec 発行者の秘密鍵]、省略すると自己署名) * @return self */ public static function issue(Rsa|Ec $key, string $cn, array $opt = []): self { [$issuerCert, $issuerKey] = $opt['issuer'] ?? [null, $key]; if (!$issuerKey->isPrivate()) throw new \InvalidArgumentException('X509:署名に秘密鍵が必要'); $name = Asn1::seq(Asn1::set(Asn1::seq(Asn1::oid('2.5.4.3'), Asn1::utf8($cn)))); $notBefore = $opt['notBefore'] ?? time() - 300; $notAfter = $opt['notAfter'] ?? $notBefore + 86400 * ($opt['days'] ?? 365); $sans = ''; foreach ($opt['sans'] ?? [$cn] as $s) { $ip = @inet_pton($s); $sans .= $ip !== false ? Asn1::tlv(0x87, $ip) : Asn1::tlv(0x82, $s); } $isCa = !empty($opt['ca']); $bc = $isCa ? Asn1::seq(Asn1::bool(true), ...(isset($opt['pathLen']) ? [Asn1::int($opt['pathLen'])] : [])) : Asn1::seq(); $ku = $isCa ? "\x03\x02\x01\x06" : "\x03\x02\x05\xA0"; // keyCertSign・cRLSign、又はdigitalSignature・keyEncipherment $exts = Asn1::seq(Asn1::oid('2.5.29.19'), Asn1::bool(true), Asn1::octets($bc)) .Asn1::seq(Asn1::oid('2.5.29.15'), Asn1::bool(true), Asn1::octets($ku)) .($isCa ? '' : Asn1::seq(Asn1::oid('2.5.29.17'), Asn1::octets(Asn1::seq($sans)))); [$algOid, $hash] = $issuerKey instanceof Rsa ? ['1.2.840.113549.1.1.11', 'sha256'] : ($issuerKey->curve === 'P-384' ? ['1.2.840.10045.4.3.3', 'sha384'] : ['1.2.840.10045.4.3.2', 'sha256']); $alg = $issuerKey instanceof Rsa ? Asn1::seq(Asn1::oid($algOid), Asn1::null()) : Asn1::seq(Asn1::oid($algOid)); $serial = gmp_import(random_bytes(16)); $tbs = Asn1::seq( Asn1::explicit(0, Asn1::int(2)), Asn1::int($serial), $alg, $issuerCert ? $issuerCert->subjectRaw : $name, Asn1::seq(Asn1::time($notBefore), Asn1::time($notAfter)), $name, $key->publicDer(), Asn1::explicit(3, Asn1::seq($exts)), ); return new self(Asn1::seq($tbs, $alg, Asn1::bits($issuerKey->sign($tbs, $hash)))); } /** 名前を「CN=…, O=…」の文字列にする */ private static function nameString(array $node): string { $parts = []; foreach (Asn1::children($node) as $rdn) { foreach (Asn1::children($rdn) as $atv) { $a = Asn1::children($atv); $oid = Asn1::toOid($a[0]); $parts[] = (self::NAMES[$oid] ?? $oid).'='.Asn1::toString($a[1]); } } return implode(', ', $parts); } }