| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, and/or sell copies of the Software, subject to the following conditions: |
| 10 | |
| 11 | 1. **Origin Attribution**: |
| 12 | - You must not misrepresent the origin of the Software; you must not claim |
| 13 | you created the original Software. |
| 14 | - If the Software is used in a product, you must either: |
| 15 | a. Provide clear attribution in the product's documentation, user interface, |
| 16 | or other visible areas, **OR** |
| 17 | b. Pay the original developers a fee they specify in writing. |
| 18 | 2. **Usage Restriction**: |
| 19 | - The Software, or any derivative works, dependencies, or libraries |
| 20 | incorporating it, must not be used for censorship or to suppress freedom of |
| 21 | speech, expression, or creativity. Prohibited uses include, but are not |
| 22 | limited to: |
| 23 | - Censorship of so-called "hate speech", visuals, non-mainstream opinions, |
| 24 | ideas, or objective reality. |
| 25 | - Tools or systems designed to restrict access to information or |
| 26 | artistic works. |
| 27 | 3. **Notice Preservation**: |
| 28 | - This license and the above copyright notice must remain intact in all copies |
| 29 | of the source code. |
| 30 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Crypto; |
| 39 | |
| 40 | /** |
| 41 | * X.509の証明書:読み込み、署名・有効期限・ホスト名の確認、信頼出来る証明書までの経路の確認、作成 |
| 42 | * |
| 43 | * 例: |
| 44 | * $chain = array_map(fn($der) => new X509($der), $ders); |
| 45 | * X509::verifyChain($chain, 'example.com'); // 失敗すると\RuntimeException |
| 46 | * $cert = X509::issue($key, 'localhost', ['sans' => ['localhost', '127.0.0.1']]); // 自己署名 |
| 47 | */ |
| 48 | class X509 { |
| 49 | // 署名のアルゴリズム => [鍵の種類, ハッシュ] |
| 50 | private const SIG_ALGS = [ |
| 51 | '1.2.840.113549.1.1.5' => ['rsa', 'sha1'], |
| 52 | '1.2.840.113549.1.1.14' => ['rsa', 'sha224'], |
| 53 | '1.2.840.113549.1.1.11' => ['rsa', 'sha256'], |
| 54 | '1.2.840.113549.1.1.12' => ['rsa', 'sha384'], |
| 55 | '1.2.840.113549.1.1.13' => ['rsa', 'sha512'], |
| 56 | '1.2.840.113549.1.1.10' => ['pss', null], |
| 57 | '1.2.840.10045.4.1' => ['ec', 'sha1'], |
| 58 | '1.2.840.10045.4.3.2' => ['ec', 'sha256'], |
| 59 | '1.2.840.10045.4.3.3' => ['ec', 'sha384'], |
| 60 | '1.2.840.10045.4.3.4' => ['ec', 'sha512'], |
| 61 | ]; |
| 62 | private const HASH_OIDS = [ |
| 63 | '1.3.14.3.2.26' => 'sha1', |
| 64 | '2.16.840.1.101.3.4.2.4' => 'sha224', |
| 65 | '2.16.840.1.101.3.4.2.1' => 'sha256', |
| 66 | '2.16.840.1.101.3.4.2.2' => 'sha384', |
| 67 | '2.16.840.1.101.3.4.2.3' => 'sha512', |
| 68 | ]; |
| 69 | private const NAMES = [ |
| 70 | '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', |
| 71 | '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', |
| 72 | ]; |
| 73 | // 理解している拡張(重要でも拒否しない) |
| 74 | 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']; |
| 75 | private const EKU_SERVER = '1.3.6.1.5.5.7.3.1'; |
| 76 | private const EKU_ANY = '2.5.29.37.0'; |
| 77 | private const MAX_DEPTH = 10; |
| 78 | |
| 79 | /** 証明書の場所(TLS_CAFILEが無い場合、順に探す) */ |
| 80 | public const CA_FILES = [ |
| 81 | '/etc/ssl/cert.pem', // OpenBSD、macOS、Alpine(chrootの中なら/var/www/etc/ssl/cert.pem) |
| 82 | '/etc/ssl/certs/ca-certificates.crt', // Debian、Ubuntu |
| 83 | '/etc/pki/tls/certs/ca-bundle.crt', // RHEL、Fedora |
| 84 | '/etc/ssl/ca-bundle.pem', // openSUSE |
| 85 | '/usr/local/share/certs/ca-root-nss.crt', // FreeBSD |
| 86 | ]; |
| 87 | |
| 88 | private static array $stores = []; |
| 89 | |
| 90 | public readonly string $der; |
| 91 | public readonly string $tbs; |
| 92 | public readonly int $version; |
| 93 | public readonly string $serial; |
| 94 | public readonly string $issuerRaw; |
| 95 | public readonly string $subjectRaw; |
| 96 | /** 「CN=…, O=…」の形 */ |
| 97 | public readonly string $issuer; |
| 98 | public readonly string $subject; |
| 99 | public readonly int $notBefore; |
| 100 | public readonly int $notAfter; |
| 101 | public readonly string $spki; |
| 102 | public readonly string $sigAlg; |
| 103 | private array $sigParams; |
| 104 | private string $signature; |
| 105 | /** 拡張:OID => ['critical' => bool, 'value' => DER] */ |
| 106 | public readonly array $extensions; |
| 107 | private ?array $subjectAttrs = null; |
| 108 | |
| 109 | /** |
| 110 | * @param string $der DER(PEMはfromPem()) |
| 111 | * @throws \UnexpectedValueException 壊れた証明書の場合 |
| 112 | */ |
| 113 | public function __construct(string $der) { |
| 114 | $this->der = $der; |
| 115 | $c = Asn1::expect(Asn1::parse($der), Asn1::SEQUENCE, 3); |
| 116 | $this->tbs = $c[0]['raw']; |
| 117 | $alg = Asn1::expect($c[1], Asn1::SEQUENCE, 1); |
| 118 | $this->sigAlg = Asn1::toOid($alg[0]); |
| 119 | $this->sigParams = array_slice($alg, 1); |
| 120 | $this->signature = Asn1::toBits($c[2]); |
| 121 | |
| 122 | $t = Asn1::expect($c[0], Asn1::SEQUENCE, 6); |
| 123 | $i = 0; |
| 124 | $version = 1; |
| 125 | if ($t[0]['tag'] === 0xA0) { |
| 126 | $version = gmp_intval(Asn1::toInt(Asn1::children($t[0])[0])) + 1; |
| 127 | $i = 1; |
| 128 | } |
| 129 | $this->version = $version; |
| 130 | $this->serial = gmp_strval(Asn1::toInt($t[$i]), 16); |
| 131 | $this->issuerRaw = $t[$i + 2]['raw']; |
| 132 | $validity = Asn1::expect($t[$i + 3], Asn1::SEQUENCE, 2); |
| 133 | $this->notBefore = Asn1::toTime($validity[0]); |
| 134 | $this->notAfter = Asn1::toTime($validity[1]); |
| 135 | $this->subjectRaw = $t[$i + 4]['raw']; |
| 136 | $this->spki = $t[$i + 5]['raw']; |
| 137 | $this->issuer = self::nameString($t[$i + 2]); |
| 138 | $this->subject = self::nameString($t[$i + 4]); |
| 139 | |
| 140 | $ext = []; |
| 141 | foreach (array_slice($t, $i + 6) as $x) { |
| 142 | if ($x['tag'] !== 0xA3) continue; |
| 143 | foreach (Asn1::children(Asn1::children($x)[0]) as $e) { |
| 144 | $ec = Asn1::children($e); |
| 145 | $oid = Asn1::toOid($ec[0]); |
| 146 | $crit = count($ec) === 3 && $ec[1]['tag'] === Asn1::BOOLEAN && $ec[1]['value'] !== "\0"; |
| 147 | if (isset($ext[$oid])) throw new \UnexpectedValueException("X509:重複した拡張:{$oid}"); |
| 148 | $ext[$oid] = ['critical' => $crit, 'value' => end($ec)['value']]; |
| 149 | } |
| 150 | } |
| 151 | $this->extensions = $ext; |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * PEMから読む(最初の証明書) |
| 156 | * |
| 157 | * @throws \UnexpectedValueException |
| 158 | */ |
| 159 | public static function fromPem(string $pem): self { |
| 160 | foreach (Asn1::unpem($pem) as $b) { |
| 161 | if ($b['label'] === 'CERTIFICATE') return new self($b['der']); |
| 162 | } |
| 163 | throw new \UnexpectedValueException('X509:証明書が見つからない'); |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * PEMの中の証明書を全部読む(チェーン) |
| 168 | * |
| 169 | * @return self[] |
| 170 | */ |
| 171 | public static function allFromPem(string $pem): array { |
| 172 | $out = []; |
| 173 | foreach (Asn1::unpem($pem) as $b) { |
| 174 | if ($b['label'] === 'CERTIFICATE') $out[] = new self($b['der']); |
| 175 | } |
| 176 | return $out; |
| 177 | } |
| 178 | |
| 179 | public function pem(): string { |
| 180 | return Asn1::pem($this->der, 'CERTIFICATE'); |
| 181 | } |
| 182 | |
| 183 | /** 公開鍵 */ |
| 184 | public function publicKey(): Rsa|Ec { |
| 185 | $alg = Asn1::toOid(Asn1::children(Asn1::children(Asn1::parse($this->spki))[0])[0]); |
| 186 | return match ($alg) { |
| 187 | Rsa::OID, Rsa::OID_PSS => Rsa::fromDer($this->spki), |
| 188 | Ec::OID => Ec::fromDer($this->spki), |
| 189 | default => throw new \UnexpectedValueException("X509:未対応の鍵:{$alg}"), |
| 190 | }; |
| 191 | } |
| 192 | |
| 193 | /** |
| 194 | * この証明書の署名を発行者の鍵で確認する |
| 195 | * |
| 196 | * @param Rsa|Ec $key 発行者の公開鍵 |
| 197 | * @param bool $allowSha1 SHA-1を許すかどうか |
| 198 | * @return bool |
| 199 | */ |
| 200 | public function verifySignature(Rsa|Ec $key, bool $allowSha1 = false): bool { |
| 201 | [$type, $hash] = self::SIG_ALGS[$this->sigAlg] ?? [null, null]; |
| 202 | if ($type === null) return false; |
| 203 | |
| 204 | if ($type === 'pss') { |
| 205 | [$hash, $salt] = self::pssParams($this->sigParams[0] ?? null); |
| 206 | if ($hash === null || !($key instanceof Rsa)) return false; |
| 207 | return $key->verifyPss($this->tbs, $this->signature, $hash, $salt); |
| 208 | } |
| 209 | if ($hash === 'sha1' && !$allowSha1) return false; |
| 210 | if ($type === 'rsa') return $key instanceof Rsa && $key->bits >= 1024 && $key->verify($this->tbs, $this->signature, $hash); |
| 211 | return $key instanceof Ec && $key->verify($this->tbs, $this->signature, $hash); |
| 212 | } |
| 213 | |
| 214 | /** RSASSA-PSS-params からハッシュとソルトの長さ */ |
| 215 | private static function pssParams(?array $node): array { |
| 216 | $hash = 'sha1'; |
| 217 | $salt = 20; |
| 218 | if ($node === null) return [$hash, $salt]; |
| 219 | foreach (Asn1::children($node) as $p) { |
| 220 | $v = Asn1::children($p)[0] ?? null; |
| 221 | if ($v === null) continue; |
| 222 | if ($p['tag'] === 0xA0) $hash = self::HASH_OIDS[Asn1::toOid(Asn1::children($v)[0])] ?? null; |
| 223 | if ($p['tag'] === 0xA1) { |
| 224 | $mgf = Asn1::children($v); |
| 225 | $mgfHash = isset($mgf[1]) ? (self::HASH_OIDS[Asn1::toOid(Asn1::children($mgf[1])[0])] ?? null) : 'sha1'; |
| 226 | if (Asn1::toOid($mgf[0]) !== '1.2.840.113549.1.1.8' || $mgfHash !== $hash) return [null, 0]; |
| 227 | } |
| 228 | if ($p['tag'] === 0xA2) $salt = gmp_intval(Asn1::toInt($v)); |
| 229 | } |
| 230 | if ($hash === 'sha1') return [null, 0]; |
| 231 | return [$hash, $salt]; |
| 232 | } |
| 233 | |
| 234 | /////////////// |
| 235 | // 拡張 |
| 236 | |
| 237 | /** subjectAltNameのdNSNameとiPAddress */ |
| 238 | public function altNames(): array { |
| 239 | $out = ['dns' => [], 'ip' => []]; |
| 240 | if (!isset($this->extensions['2.5.29.17'])) return $out; |
| 241 | foreach (Asn1::children(Asn1::parse($this->extensions['2.5.29.17']['value'])) as $n) { |
| 242 | if ($n['tag'] === 0x82) $out['dns'][] = strtolower($n['value']); |
| 243 | if ($n['tag'] === 0x87) $out['ip'][] = $n['value']; |
| 244 | } |
| 245 | return $out; |
| 246 | } |
| 247 | |
| 248 | /** 主体の属性(CN等)の値 */ |
| 249 | public function subjectAttr(string $name): array { |
| 250 | if ($this->subjectAttrs === null) { |
| 251 | $this->subjectAttrs = []; |
| 252 | foreach (Asn1::children(Asn1::parse($this->subjectRaw)) as $rdn) { |
| 253 | foreach (Asn1::children($rdn) as $atv) { |
| 254 | $a = Asn1::children($atv); |
| 255 | $this->subjectAttrs[self::NAMES[Asn1::toOid($a[0])] ?? Asn1::toOid($a[0])][] = Asn1::toString($a[1]); |
| 256 | } |
| 257 | } |
| 258 | } |
| 259 | return $this->subjectAttrs[$name] ?? []; |
| 260 | } |
| 261 | |
| 262 | /** basicConstraints:[CAかどうか, pathLen(無ければnull)] */ |
| 263 | public function basicConstraints(): array { |
| 264 | if (!isset($this->extensions['2.5.29.19'])) return [false, null]; |
| 265 | $c = Asn1::children(Asn1::parse($this->extensions['2.5.29.19']['value'])); |
| 266 | $ca = isset($c[0]) && $c[0]['tag'] === Asn1::BOOLEAN && $c[0]['value'] !== "\0"; |
| 267 | $len = null; |
| 268 | foreach ($c as $x) if ($x['tag'] === Asn1::INTEGER) $len = gmp_intval(Asn1::toInt($x)); |
| 269 | return [$ca, $len]; |
| 270 | } |
| 271 | |
| 272 | /** keyUsageのビット(0=digitalSignature、5=keyCertSign)、拡張が無ければnull */ |
| 273 | public function keyUsage(): ?array { |
| 274 | if (!isset($this->extensions['2.5.29.15'])) return null; |
| 275 | $bits = Asn1::toBits(Asn1::parse($this->extensions['2.5.29.15']['value'])); |
| 276 | $out = []; |
| 277 | for ($i = 0; $i < 8 * strlen($bits); $i++) { |
| 278 | if (ord($bits[$i >> 3]) & (0x80 >> ($i & 7))) $out[] = $i; |
| 279 | } |
| 280 | return $out; |
| 281 | } |
| 282 | |
| 283 | /** extendedKeyUsageのOID、拡張が無ければnull */ |
| 284 | public function extKeyUsage(): ?array { |
| 285 | if (!isset($this->extensions['2.5.29.37'])) return null; |
| 286 | return array_map([Asn1::class, 'toOid'], Asn1::children(Asn1::parse($this->extensions['2.5.29.37']['value']))); |
| 287 | } |
| 288 | |
| 289 | public function isSelfIssued(): bool { |
| 290 | return $this->issuerRaw === $this->subjectRaw; |
| 291 | } |
| 292 | |
| 293 | /////////////// |
| 294 | // 確認 |
| 295 | |
| 296 | /** |
| 297 | * ホスト名(又はIPアドレス)がこの証明書に合うかどうか(RFC 6125) |
| 298 | * |
| 299 | * subjectAltNameが無い場合だけ、CNを見る。ワイルドカードは一番左のラベルの「*」だけ。 |
| 300 | * |
| 301 | * @param string $host |
| 302 | * @return bool |
| 303 | */ |
| 304 | public function matchesHost(string $host): bool { |
| 305 | $host = strtolower(rtrim(trim($host, '[]'), '.')); |
| 306 | $alt = $this->altNames(); |
| 307 | $ip = @inet_pton($host); |
| 308 | |
| 309 | if ($ip !== false) { |
| 310 | foreach ($alt['ip'] as $a) if ($a === $ip) return true; |
| 311 | if (empty($alt['ip']) && empty($alt['dns'])) { |
| 312 | foreach ($this->subjectAttr('CN') as $cn) if (@inet_pton($cn) === $ip) return true; |
| 313 | } |
| 314 | return false; |
| 315 | } |
| 316 | |
| 317 | $names = $alt['dns'] ?: (empty($alt['ip']) ? array_map('strtolower', $this->subjectAttr('CN')) : []); |
| 318 | foreach ($names as $n) { |
| 319 | $n = rtrim($n, '.'); |
| 320 | if ($n === $host) return true; |
| 321 | if (str_starts_with($n, '*.') && substr_count($n, '.') >= 2) { |
| 322 | $dot = strpos($host, '.'); |
| 323 | if ($dot !== false && $dot > 0 && substr($host, $dot) === substr($n, 1)) return true; |
| 324 | } |
| 325 | } |
| 326 | return false; |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * サーバーの証明書のチェーンを確認する |
| 331 | * |
| 332 | * @param self[] $chain サーバーから受け取った証明書([0]がサーバーの証明書) |
| 333 | * @param string|null $host 確認するホスト名(nullなら確認しない) |
| 334 | * @param array $opt cafile(信頼する証明書のPEMファイル)、trusted(self[]、cafileの代わり)、 |
| 335 | * allow_self_signed(自己署名の証明書を許す)、time(確認する時間) |
| 336 | * @return void |
| 337 | * @throws \RuntimeException 確認に失敗した場合(理由はメッセージ) |
| 338 | */ |
| 339 | public static function verifyChain(array $chain, ?string $host, array $opt = []): void { |
| 340 | if (empty($chain)) throw new \RuntimeException('証明書がありません'); |
| 341 | $now = (int)($opt['time'] ?? time()); |
| 342 | $leaf = $chain[0]; |
| 343 | |
| 344 | if ($now < $leaf->notBefore) throw new \RuntimeException("証明書はまだ有効ではありません({$leaf->subject})"); |
| 345 | if ($now > $leaf->notAfter) throw new \RuntimeException("証明書の有効期限が切れています({$leaf->subject})"); |
| 346 | if ($host !== null && !$leaf->matchesHost($host)) throw new \RuntimeException("証明書のホスト名が一致しません({$host}、{$leaf->subject})"); |
| 347 | $eku = $leaf->extKeyUsage(); |
| 348 | if ($eku !== null && !in_array(self::EKU_SERVER, $eku, true) && !in_array(self::EKU_ANY, $eku, true)) { |
| 349 | throw new \RuntimeException('証明書はサーバー用ではありません'); |
| 350 | } |
| 351 | self::checkCritical($leaf); |
| 352 | |
| 353 | if (!empty($opt['allow_self_signed']) && $leaf->isSelfIssued()) { |
| 354 | if ($leaf->verifySignature($leaf->publicKey(), true)) return; |
| 355 | } |
| 356 | |
| 357 | $trusted = $opt['trusted'] ?? self::store($opt['cafile'] ?? null); |
| 358 | $names = $leaf->altNames(); |
| 359 | if (empty($names['dns']) && empty($names['ip'])) $names['dns'] = array_map('strtolower', $leaf->subjectAttr('CN')); |
| 360 | |
| 361 | $cur = $leaf; |
| 362 | $used = [0 => true]; |
| 363 | for ($depth = 0; $depth <= self::MAX_DEPTH; $depth++) { |
| 364 | // 信頼する証明書そのもの |
| 365 | foreach ($trusted[$cur->subjectRaw] ?? [] as $t) { |
| 366 | if ($t->der === $cur->der) return; |
| 367 | } |
| 368 | // 信頼する証明書が発行した |
| 369 | foreach ($trusted[$cur->issuerRaw] ?? [] as $t) { |
| 370 | if ($now > $t->notAfter || $now < $t->notBefore) continue; |
| 371 | if ($cur->verifySignature($t->publicKey())) { |
| 372 | self::checkNameConstraints($t, $names); |
| 373 | return; |
| 374 | } |
| 375 | } |
| 376 | // 受け取った中間証明書 |
| 377 | $next = null; |
| 378 | foreach ($chain as $i => $c) { |
| 379 | if (isset($used[$i]) || $c->subjectRaw !== $cur->issuerRaw) continue; |
| 380 | if (!$cur->verifySignature($c->publicKey())) continue; |
| 381 | $next = $i; |
| 382 | break; |
| 383 | } |
| 384 | if ($next === null) { |
| 385 | throw new \RuntimeException($cur->isSelfIssued() ? "自己署名の証明書です({$cur->subject})" : "信頼出来ない証明書です(発行者:{$cur->issuer})"); |
| 386 | } |
| 387 | $ca = $chain[$next]; |
| 388 | [$isCa, $pathLen] = $ca->basicConstraints(); |
| 389 | if (!$isCa) throw new \RuntimeException("中間証明書がCAではありません({$ca->subject})"); |
| 390 | if ($pathLen !== null && $depth > $pathLen) throw new \RuntimeException("中間証明書のpathLenを超えました({$ca->subject})"); |
| 391 | $ku = $ca->keyUsage(); |
| 392 | if ($ku !== null && !in_array(5, $ku, true)) throw new \RuntimeException("中間証明書は証明書に署名出来ません({$ca->subject})"); |
| 393 | if ($now > $ca->notAfter || $now < $ca->notBefore) throw new \RuntimeException("中間証明書の有効期限が切れています({$ca->subject})"); |
| 394 | self::checkCritical($ca); |
| 395 | self::checkNameConstraints($ca, $names); |
| 396 | $used[$next] = true; |
| 397 | $cur = $ca; |
| 398 | } |
| 399 | throw new \RuntimeException('証明書のチェーンが長過ぎます'); |
| 400 | } |
| 401 | |
| 402 | /** 理解していない重要な拡張があれば拒否する */ |
| 403 | private static function checkCritical(self $c): void { |
| 404 | foreach ($c->extensions as $oid => $e) { |
| 405 | if ($e['critical'] && !in_array($oid, self::KNOWN_EXT, true)) throw new \RuntimeException("未対応の重要な拡張:{$oid}"); |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | /** nameConstraints(dNSNameとiPAddressだけ) */ |
| 410 | private static function checkNameConstraints(self $ca, array $names): void { |
| 411 | if (!isset($ca->extensions['2.5.29.30'])) return; |
| 412 | $sets = ['permitted' => ['dns' => [], 'ip' => []], 'excluded' => ['dns' => [], 'ip' => []]]; |
| 413 | foreach (Asn1::children(Asn1::parse($ca->extensions['2.5.29.30']['value'])) as $part) { |
| 414 | $kind = $part['tag'] === 0xA0 ? 'permitted' : 'excluded'; |
| 415 | foreach (Asn1::children($part) as $subtree) { |
| 416 | $base = Asn1::children($subtree)[0]; |
| 417 | if ($base['tag'] === 0x82) $sets[$kind]['dns'][] = strtolower(ltrim($base['value'], '.')); |
| 418 | if ($base['tag'] === 0x87) $sets[$kind]['ip'][] = $base['value']; |
| 419 | } |
| 420 | } |
| 421 | $dnsIn = fn(string $n, string $c): bool => $c === '' || $n === $c || str_ends_with($n, '.'.$c); |
| 422 | $ipIn = function(string $ip, string $c): bool { |
| 423 | $l = strlen($ip); |
| 424 | if (strlen($c) !== 2 * $l) return false; |
| 425 | return ($ip & substr($c, $l)) === (substr($c, 0, $l) & substr($c, $l)); |
| 426 | }; |
| 427 | foreach (['dns' => $dnsIn, 'ip' => $ipIn] as $type => $in) { |
| 428 | foreach ($names[$type] as $n) { |
| 429 | $n = $type === 'dns' ? ltrim($n, '*.') : $n; |
| 430 | foreach ($sets['excluded'][$type] as $c) if ($in($n, $c)) throw new \RuntimeException("名前の制約に違反します({$ca->subject})"); |
| 431 | if (!empty($sets['permitted'][$type])) { |
| 432 | $ok = false; |
| 433 | foreach ($sets['permitted'][$type] as $c) if ($in($n, $c)) $ok = true; |
| 434 | if (!$ok) throw new \RuntimeException("名前の制約に違反します({$ca->subject})"); |
| 435 | } |
| 436 | } |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | /** |
| 441 | * 信頼する証明書を読む(主体のDER => self[]、プロセスの中でキャッシュする) |
| 442 | * |
| 443 | * @param string|null $file PEMファイル(nullならTLS_CAFILE、無ければCA_FILESを探す) |
| 444 | * @return array |
| 445 | * @throws \RuntimeException ファイルが見つからない場合 |
| 446 | */ |
| 447 | public static function store(?string $file = null): array { |
| 448 | if ($file === null || $file === '') { |
| 449 | $file = defined('TLS_CAFILE') && TLS_CAFILE !== '' ? (string)TLS_CAFILE : null; |
| 450 | if ($file === null) { |
| 451 | foreach (self::CA_FILES as $f) { |
| 452 | if (@is_file($f)) { |
| 453 | $file = $f; |
| 454 | break; |
| 455 | } |
| 456 | } |
| 457 | } |
| 458 | if ($file === null) throw new \RuntimeException('信頼する証明書のファイルが見つかりません(TLS_CAFILEを設定して下さい)'); |
| 459 | } |
| 460 | if (isset(self::$stores[$file])) return self::$stores[$file]; |
| 461 | |
| 462 | $pem = @file_get_contents($file); |
| 463 | if ($pem === false) throw new \RuntimeException("信頼する証明書のファイルを読めません:{$file}"); |
| 464 | $out = []; |
| 465 | foreach (Asn1::unpem($pem) as $b) { |
| 466 | if ($b['label'] !== 'CERTIFICATE' && $b['label'] !== 'TRUSTED CERTIFICATE') continue; |
| 467 | try { |
| 468 | $c = new self($b['der']); |
| 469 | } catch (\Throwable $e) { |
| 470 | continue; |
| 471 | } |
| 472 | $out[$c->subjectRaw][] = $c; |
| 473 | } |
| 474 | return self::$stores[$file] = $out; |
| 475 | } |
| 476 | |
| 477 | /** |
| 478 | * 証明書の配列を、verifyChain()のtrustedの形にする |
| 479 | * |
| 480 | * @param self[] $certs |
| 481 | * @return array |
| 482 | */ |
| 483 | public static function trust(array $certs): array { |
| 484 | $out = []; |
| 485 | foreach ($certs as $c) $out[$c->subjectRaw][] = $c; |
| 486 | return $out; |
| 487 | } |
| 488 | |
| 489 | /////////////// |
| 490 | // 作成 |
| 491 | |
| 492 | /** |
| 493 | * 証明書を作る(テスト、MySQL等の内部のサーバー用) |
| 494 | * |
| 495 | * @param Rsa|Ec $key この証明書の鍵(自己署名なら秘密鍵) |
| 496 | * @param string $cn 主体のCN |
| 497 | * @param array $opt sans(DNS名・IPアドレスの配列、省略するとCN)、days(365)、notBefore、notAfter、 |
| 498 | * ca(bool)、pathLen、issuer([self 発行者の証明書, Rsa|Ec 発行者の秘密鍵]、省略すると自己署名) |
| 499 | * @return self |
| 500 | */ |
| 501 | public static function issue(Rsa|Ec $key, string $cn, array $opt = []): self { |
| 502 | [$issuerCert, $issuerKey] = $opt['issuer'] ?? [null, $key]; |
| 503 | if (!$issuerKey->isPrivate()) throw new \InvalidArgumentException('X509:署名に秘密鍵が必要'); |
| 504 | |
| 505 | $name = Asn1::seq(Asn1::set(Asn1::seq(Asn1::oid('2.5.4.3'), Asn1::utf8($cn)))); |
| 506 | $notBefore = $opt['notBefore'] ?? time() - 300; |
| 507 | $notAfter = $opt['notAfter'] ?? $notBefore + 86400 * ($opt['days'] ?? 365); |
| 508 | |
| 509 | $sans = ''; |
| 510 | foreach ($opt['sans'] ?? [$cn] as $s) { |
| 511 | $ip = @inet_pton($s); |
| 512 | $sans .= $ip !== false ? Asn1::tlv(0x87, $ip) : Asn1::tlv(0x82, $s); |
| 513 | } |
| 514 | $isCa = !empty($opt['ca']); |
| 515 | $bc = $isCa ? Asn1::seq(Asn1::bool(true), ...(isset($opt['pathLen']) ? [Asn1::int($opt['pathLen'])] : [])) : Asn1::seq(); |
| 516 | $ku = $isCa ? "\x03\x02\x01\x06" : "\x03\x02\x05\xA0"; // keyCertSign・cRLSign、又はdigitalSignature・keyEncipherment |
| 517 | $exts = Asn1::seq(Asn1::oid('2.5.29.19'), Asn1::bool(true), Asn1::octets($bc)) |
| 518 | .Asn1::seq(Asn1::oid('2.5.29.15'), Asn1::bool(true), Asn1::octets($ku)) |
| 519 | .($isCa ? '' : Asn1::seq(Asn1::oid('2.5.29.17'), Asn1::octets(Asn1::seq($sans)))); |
| 520 | |
| 521 | [$algOid, $hash] = $issuerKey instanceof Rsa |
| 522 | ? ['1.2.840.113549.1.1.11', 'sha256'] |
| 523 | : ($issuerKey->curve === 'P-384' ? ['1.2.840.10045.4.3.3', 'sha384'] : ['1.2.840.10045.4.3.2', 'sha256']); |
| 524 | $alg = $issuerKey instanceof Rsa ? Asn1::seq(Asn1::oid($algOid), Asn1::null()) : Asn1::seq(Asn1::oid($algOid)); |
| 525 | |
| 526 | $serial = gmp_import(random_bytes(16)); |
| 527 | $tbs = Asn1::seq( |
| 528 | Asn1::explicit(0, Asn1::int(2)), |
| 529 | Asn1::int($serial), |
| 530 | $alg, |
| 531 | $issuerCert ? $issuerCert->subjectRaw : $name, |
| 532 | Asn1::seq(Asn1::time($notBefore), Asn1::time($notAfter)), |
| 533 | $name, |
| 534 | $key->publicDer(), |
| 535 | Asn1::explicit(3, Asn1::seq($exts)), |
| 536 | ); |
| 537 | return new self(Asn1::seq($tbs, $alg, Asn1::bits($issuerKey->sign($tbs, $hash)))); |
| 538 | } |
| 539 | |
| 540 | /** 名前を「CN=…, O=…」の文字列にする */ |
| 541 | private static function nameString(array $node): string { |
| 542 | $parts = []; |
| 543 | foreach (Asn1::children($node) as $rdn) { |
| 544 | foreach (Asn1::children($rdn) as $atv) { |
| 545 | $a = Asn1::children($atv); |
| 546 | $oid = Asn1::toOid($a[0]); |
| 547 | $parts[] = (self::NAMES[$oid] ?? $oid).'='.Asn1::toString($a[1]); |
| 548 | } |
| 549 | } |
| 550 | return implode(', ', $parts); |
| 551 | } |
| 552 | } |