master
ブランチ master
LittleBeast / src / Std / Lib / Crypto / X509.php
22.9 KB、552行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, 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
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace 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 */
48class 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}