[AEAD, 鍵の長さ, ハッシュ, 名前] public const SUITES = [ 0x1303 => ['chacha', 32, 'sha256', 'TLS_CHACHA20_POLY1305_SHA256'], 0x1301 => ['aes', 16, 'sha256', 'TLS_AES_128_GCM_SHA256'], 0x1302 => ['aes', 32, 'sha384', 'TLS_AES_256_GCM_SHA384'], ]; // TLS 1.2の暗号スイート(ECDHE+AEADだけ) => [AEAD, 鍵の長さ, PRFのハッシュ, 名前, 証明書の鍵] public const SUITES12 = [ 0xCCA9 => ['chacha', 32, 'sha256', 'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256', 'ec'], 0xCCA8 => ['chacha', 32, 'sha256', 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256', 'rsa'], 0xC02B => ['aes', 16, 'sha256', 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256', 'ec'], 0xC02F => ['aes', 16, 'sha256', 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256', 'rsa'], 0xC02C => ['aes', 32, 'sha384', 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384', 'ec'], 0xC030 => ['aes', 32, 'sha384', 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', 'rsa'], ]; public const VERSIONS = ['1.2' => 0x0303, '1.3' => 0x0304]; // 鍵交換のグループ public const GROUPS = ['x25519' => 0x001D, 'P-256' => 0x0017, 'P-384' => 0x0018]; // 署名の方式 => [種類, ハッシュ, 曲線] private const SIG_SCHEMES = [ 0x0403 => ['ec', 'sha256', 'P-256'], 0x0503 => ['ec', 'sha384', 'P-384'], 0x0603 => ['ec', 'sha512', 'P-521'], 0x0804 => ['pss', 'sha256', null], 0x0805 => ['pss', 'sha384', null], 0x0806 => ['pss', 'sha512', null], 0x0401 => ['pkcs1', 'sha256', null], 0x0501 => ['pkcs1', 'sha384', null], 0x0601 => ['pkcs1', 'sha512', null], ]; // アラート public const ALERT_CLOSE_NOTIFY = 0; public const ALERT_UNEXPECTED_MESSAGE = 10; public const ALERT_BAD_RECORD_MAC = 20; public const ALERT_RECORD_OVERFLOW = 22; public const ALERT_HANDSHAKE_FAILURE = 40; public const ALERT_BAD_CERTIFICATE = 42; public const ALERT_CERTIFICATE_EXPIRED = 45; public const ALERT_CERTIFICATE_UNKNOWN = 46; public const ALERT_ILLEGAL_PARAMETER = 47; public const ALERT_UNKNOWN_CA = 48; public const ALERT_DECODE_ERROR = 50; public const ALERT_DECRYPT_ERROR = 51; public const ALERT_PROTOCOL_VERSION = 70; public const ALERT_INTERNAL_ERROR = 80; public const ALERT_USER_CANCELED = 90; public const ALERT_NO_RENEGOTIATION = 100; public const ALERT_MISSING_EXTENSION = 109; public const ALERT_UNSUPPORTED_EXTENSION = 110; public const ALERT_UNRECOGNIZED_NAME = 112; public const ALERT_NO_APPLICATION_PROTOCOL = 120; private const HRR_RANDOM = "\xCF\x21\xAD\x74\xE5\x9A\x61\x11\xBE\x1D\x8C\x02\x1E\x65\xB8\x91\xC2\xA2\x11\x16\x7A\xBB\x8C\x5E\x07\x9E\x09\xE2\xC8\xA8\x33\x9C"; private const MAX_PLAIN = 16384; private const DOWNGRADE_12 = "DOWNGRD\x01"; /** @var resource */ private $sock; private bool $server; private array $opt; // レコード層 private string $in = ''; private ?object $readAead = null; private ?object $writeAead = null; private string $readIv = ''; private string $writeIv = ''; private int $readSeq = 0; private int $writeSeq = 0; private string $readSecret = ''; private string $writeSecret = ''; private string $hs = ''; private string $app = ''; /** TLS 1.2:ChangeCipherSpecで使い始める鍵 */ private ?array $pendingRead = null; private ?array $pendingWrite = null; // 状態 private bool $established = false; private bool $peerClosed = false; private bool $closed = false; private bool $rawEof = false; private bool $timedOut = false; private ?TlsException $error = null; // ハンドシェイク private int $version = 0x0304; private int $minVersion; private int $maxVersion; private string $hash = 'sha256'; private string $transcript = ''; private int $suite = 0; private string $group = ''; private string $alpn = ''; private array $peerCerts = []; private string $sni = ''; /** * @param resource $sock 接続済みのソケット * @param array $opt オプション(Std\Lib\Tlsを参照) * @param bool $server サーバー側かどうか */ public function __construct($sock, array $opt, bool $server) { $this->sock = $sock; $this->opt = $opt; $this->server = $server; $this->minVersion = self::VERSIONS[(string)($opt['min_version'] ?? '1.2')] ?? throw new \InvalidArgumentException('TLS:min_versionは1.2か1.3'); $this->maxVersion = self::VERSIONS[(string)($opt['max_version'] ?? '1.3')] ?? throw new \InvalidArgumentException('TLS:max_versionは1.2か1.3'); if ($this->minVersion > $this->maxVersion) throw new \InvalidArgumentException('TLS:min_versionがmax_versionより大きい'); } /** 下のソケット */ public function socket() { return $this->sock; } /** * ハンドシェイクを行う * * @return void * @throws TlsException 失敗した場合(相手にもアラートを送る) */ public function handshake(): void { try { $this->server ? $this->serverHandshake() : $this->clientHandshake(); $this->established = true; } catch (TlsException $e) { if (!$e->received) $this->sendAlert($e->alert); $this->error = $e; throw $e; } catch (\Throwable $e) { $this->sendAlert(self::ALERT_INTERNAL_ERROR); throw $this->error = new TlsException('TLS:内部エラー:'.$e->getMessage(), self::ALERT_INTERNAL_ERROR, false, $e); } } /** * 接続の情報 * * @return array version、cipher、group、alpn、peer_certificates(X509[])、server_name(サーバー側) */ public function info(): array { return [ 'version' => $this->version === 0x0303 ? 'TLSv1.2' : 'TLSv1.3', 'cipher' => ($this->version === 0x0303 ? self::SUITES12 : self::SUITES)[$this->suite][3] ?? '', 'group' => $this->group, 'alpn' => $this->alpn, 'peer_certificates' => $this->peerCerts, 'server_name' => $this->sni, ]; } /** * アプリケーションデータを読む * * @param int $len 最大のバイト数 * @return string データ、終わり又はタイムアウトの場合は空 * @throws TlsException */ public function read(int $len): string { while ($this->app === '' && !$this->eof()) { if (!$this->readRecordInto()) break; } $out = substr($this->app, 0, $len); $this->app = (string)substr($this->app, strlen($out)); return $out; } /** * アプリケーションデータを書く * * @param string $data * @return int 書いたバイト数 * @throws TlsException */ public function write(string $data): int { if ($this->closed || !$this->established) throw new TlsException('TLS:接続が閉じています', self::ALERT_INTERNAL_ERROR); $this->writeRecord(self::APPDATA, $data); return strlen($data); } /** データの終わりかどうか */ public function eof(): bool { return $this->app === '' && ($this->peerClosed || $this->rawEof || $this->error !== null); } /** 復号済み又は受信済みのデータが残っているかどうか(stream_selectでは分からない) */ public function pending(): bool { return $this->app !== '' || $this->in !== ''; } /** 最後の読み込みがタイムアウトしたかどうか */ public function timedOut(): bool { return $this->timedOut; } /** 読み込み中のエラー(ストリームでは例外を投げられないので) */ public function lastError(): ?TlsException { return $this->error; } /** * close_notifyを送って閉じる(下のソケットも閉じる) * * @return void */ public function close(): void { if ($this->closed) return; if ($this->established && $this->error === null) $this->sendAlert(self::ALERT_CLOSE_NOTIFY, 1); $this->closed = true; if (is_resource($this->sock)) @fclose($this->sock); } /////////////// // クライアント private function clientHandshake(): void { $host = (string)($this->opt['peer_name'] ?? ''); $v13 = $this->maxVersion >= 0x0304; $v12 = $this->minVersion <= 0x0303; $s13 = $v13 ? $this->allowedSuites(self::SUITES) : []; $s12 = $v12 ? $this->allowedSuites(self::SUITES12) : []; if (empty($s13) && empty($s12)) throw new TlsException('TLS:暗号スイートがありません', self::ALERT_INTERNAL_ERROR); $suites = array_merge($s13, $s12); $groups = $this->allowedGroups(); // _random・_session_id・_keysはテスト用(記録したハンドシェイクを再現する) $random = $this->opt['_random'] ?? random_bytes(32); $sid = $this->opt['_session_id'] ?? random_bytes(32); $first = $groups[0]; $keys = $v13 ? [$first => $this->newShare($first)] : []; $cookie = ''; $hello = $this->clientHello($random, $sid, $suites, $groups, $keys, $host, $cookie, $v13, $v12); $this->writeRecord(self::HANDSHAKE, $hello, 0x0301); $this->transcript = $hello; [$type, $sh] = $this->readHandshake(); if ($type !== self::SERVER_HELLO) throw new TlsException('TLS:ServerHelloを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $s = $this->parseServerHello($sh, $v13, $v12); if ($s['version'] === 0x0303) { if (!in_array($s['suite'], $s12, true)) throw new TlsException('TLS:不正なServerHello(暗号スイート)', self::ALERT_ILLEGAL_PARAMETER); $this->clientHandshake12($s, $sh, $random, $host, $groups); return; } if ($s['echo'] !== $sid || !in_array($s['suite'], $s13, true)) throw new TlsException('TLS:不正なServerHello', self::ALERT_ILLEGAL_PARAMETER); if ($s['random'] === self::HRR_RANDOM) { // HelloRetryRequest:別のグループで鍵を送り直す $g = $s['hrr_group']; if ($g === null || !in_array($g, $groups, true) || isset($keys[$g])) throw new TlsException('TLS:不正なHelloRetryRequest', self::ALERT_ILLEGAL_PARAMETER); $this->hash = self::SUITES[$s['suite']][2]; $this->transcript = $this->messageHash($hello).$this->msg(self::SERVER_HELLO, $sh); $keys = [$g => $this->newShare($g)]; $cookie = $s['cookie']; // 2回目のClientHelloは鍵とcookie以外を変えない(RFC 8446 4.1.2) $hello = $this->clientHello($random, $sid, $suites, $groups, $keys, $host, $cookie, $v13, $v12); $this->writeCcs(); $ccsSent = true; $this->writeRecord(self::HANDSHAKE, $hello); $this->transcript .= $hello; [$type, $sh] = $this->readHandshake(); if ($type !== self::SERVER_HELLO) throw new TlsException('TLS:ServerHelloを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $s2 = $this->parseServerHello($sh, true, false); if ($s2['random'] === self::HRR_RANDOM) throw new TlsException('TLS:2回目のHelloRetryRequest', self::ALERT_UNEXPECTED_MESSAGE); if ($s2['echo'] !== $sid || $s2['suite'] !== $s['suite']) throw new TlsException('TLS:不正なServerHello', self::ALERT_ILLEGAL_PARAMETER); $s = $s2; } if ($s['share'] === null || !isset($keys[$s['share'][0]])) throw new TlsException('TLS:サーバーの鍵が不正です', self::ALERT_ILLEGAL_PARAMETER); $this->suite = $s['suite']; $this->hash = self::SUITES[$this->suite][2]; $this->transcript .= $this->msg(self::SERVER_HELLO, $sh); $this->group = (string)array_search($s['share'][0], self::GROUPS, true); $shared = $this->sharedSecret($keys[$s['share'][0]], $s['share'][0], $s['share'][1]); [$cHs, $sHs, $master] = $this->handshakeSecrets($shared); $this->setReadKey($sHs); // EncryptedExtensions [$type, $body] = $this->readHandshake(); if ($type !== self::ENCRYPTED_EXTENSIONS) throw new TlsException('TLS:EncryptedExtensionsを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $this->transcript .= $this->msg($type, $body); $ee = $this->parseExtensions($this->vec($body, 0, 2, $end)); if ($end !== strlen($body)) throw new TlsException('TLS:不正なEncryptedExtensions', self::ALERT_DECODE_ERROR); if (isset($ee[self::EXT_ALPN])) $this->alpn = $this->alpnReply($ee[self::EXT_ALPN]); // CertificateRequest(任意)、Certificate、CertificateVerify [$type, $body] = $this->readHandshake(); $certRequest = null; if ($type === self::CERTIFICATE_REQUEST) { $this->transcript .= $this->msg($type, $body); $certRequest = $this->vec($body, 0, 1); [$type, $body] = $this->readHandshake(); } if ($type !== self::CERTIFICATE) throw new TlsException('TLS:Certificateを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $this->transcript .= $this->msg($type, $body); $this->peerCerts = $this->parseCertificate($body); if (empty($this->peerCerts)) throw new TlsException('TLS:サーバーの証明書がありません', self::ALERT_DECODE_ERROR); [$type, $body] = $this->readHandshake(); if ($type !== self::CERTIFICATE_VERIFY) throw new TlsException('TLS:CertificateVerifyを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $this->checkCertificateVerify($body, $this->peerCerts[0], 'TLS 1.3, server CertificateVerify'); $this->transcript .= $this->msg($type, $body); $this->verifyPeer($host); // サーバーのFinished [$type, $body] = $this->readHandshake(); if ($type !== self::FINISHED) throw new TlsException('TLS:Finishedを期待しました', self::ALERT_UNEXPECTED_MESSAGE); if (!hash_equals($this->finished($sHs), $body)) throw new TlsException('TLS:サーバーのFinishedが不正です', self::ALERT_DECRYPT_ERROR); $this->transcript .= $this->msg($type, $body); [$cAp, $sAp] = $this->appSecrets($master); if ($this->hs !== '') throw new TlsException('TLS:鍵の変更の前に余計なデータ', self::ALERT_UNEXPECTED_MESSAGE); // クライアントのFinished(互換性のためのChangeCipherSpecは1回だけ) if (empty($ccsSent)) $this->writeCcs(); $this->setWriteKey($cHs); if ($certRequest !== null) { $cert = $this->msg(self::CERTIFICATE, $this->vec2($certRequest, 1)."\x00\x00\x00"); $this->transcript .= $cert; $this->writeRecord(self::HANDSHAKE, $cert); } $fin = $this->msg(self::FINISHED, $this->finished($cHs)); $this->writeRecord(self::HANDSHAKE, $fin); $this->transcript .= $fin; $this->setReadKey($sAp); $this->setWriteKey($cAp); } /** 使う暗号スイート(ciphersで絞る) */ private function allowedSuites(array $table): array { $only = $this->opt['ciphers'] ?? null; return array_values(array_filter(array_keys($table), fn(int $id): bool => $only === null || in_array($id, $only, true))); } /** 使う鍵交換のグループ(groupsの順) */ private function allowedGroups(): array { $out = []; foreach ($this->opt['groups'] ?? array_keys(self::GROUPS) as $g) { if (isset(self::GROUPS[$g])) $out[] = self::GROUPS[$g]; } if (empty($out)) throw new TlsException('TLS:鍵交換のグループがありません', self::ALERT_INTERNAL_ERROR); return $out; } private function clientHello(string $random, string $sid, array $suites, array $groups, array $keys, string $host, string $cookie, bool $v13, bool $v12): string { $ext = ''; if ($host !== '' && ($this->opt['SNI_enabled'] ?? true) && @inet_pton(trim($host, '[]')) === false) { $name = "\x00".$this->vec2($host, 2); $ext .= $this->ext(self::EXT_SERVER_NAME, $this->vec2($name, 2)); } $ext .= $this->ext(self::EXT_SUPPORTED_GROUPS, $this->vec2(pack('n*', ...$groups), 2)); $ext .= $this->ext(self::EXT_SIGNATURE_ALGORITHMS, $this->vec2(pack('n*', ...array_keys(self::SIG_SCHEMES)), 2)); if ($v13) { $ext .= $this->ext(self::EXT_SUPPORTED_VERSIONS, $this->vec2($v12 ? "\x03\x04\x03\x03" : "\x03\x04", 1)); $shares = ''; foreach ($keys as $g => $k) $shares .= pack('n', $g).$this->vec2($k['pub'], 2); $ext .= $this->ext(self::EXT_KEY_SHARE, $this->vec2($shares, 2)); } if ($v12) { // TLS 1.2用:圧縮しない点、Extended Master Secret、安全な再ネゴシエーションの印(再ネゴシエーションはしない) $ext .= $this->ext(self::EXT_EC_POINT_FORMATS, "\x01\x00"); $ext .= $this->ext(self::EXT_EXTENDED_MASTER_SECRET, ''); $ext .= $this->ext(self::EXT_RENEGOTIATION_INFO, "\x00"); } if (!empty($this->opt['alpn'])) { $list = ''; foreach ($this->opt['alpn'] as $p) $list .= $this->vec2($p, 1); $ext .= $this->ext(self::EXT_ALPN, $this->vec2($list, 2)); } if ($cookie !== '') $ext .= $this->ext(self::EXT_COOKIE, $cookie); $body = "\x03\x03".$random.$this->vec2($sid, 1).$this->vec2(pack('n*', ...$suites), 2)."\x01\x00".$this->vec2($ext, 2); return $this->msg(self::CLIENT_HELLO, $body); } private function parseServerHello(string $b, bool $v13, bool $v12): array { $pos = 2; if (substr($b, 0, 2) !== "\x03\x03") throw new TlsException('TLS:サーバーがTLS 1.2以上に対応していません', self::ALERT_PROTOCOL_VERSION); $random = substr($b, $pos, 32); $pos += 32; $echo = $this->vec($b, $pos, 1, $pos); $suite = $this->u16($b, $pos); $pos += 2; $comp = ord($b[$pos] ?? "\1"); $pos++; $ext = $this->parseExtensions($this->vec($b, $pos, 2, $pos)); if ($pos !== strlen($b) || strlen($random) !== 32) throw new TlsException('TLS:不正なServerHello', self::ALERT_DECODE_ERROR); if ($comp !== 0) throw new TlsException('TLS:不正なServerHello(圧縮)', self::ALERT_ILLEGAL_PARAMETER); // TLS 1.3の印(supported_versions)が無ければTLS 1.2 if (isset($ext[self::EXT_SUPPORTED_VERSIONS])) { if ($ext[self::EXT_SUPPORTED_VERSIONS] !== "\x03\x04" || !$v13) throw new TlsException('TLS:不正なバージョン', self::ALERT_ILLEGAL_PARAMETER); $version = 0x0304; } else { if (!$v12) throw new TlsException('TLS:サーバーがTLS 1.3に対応していません', self::ALERT_PROTOCOL_VERSION); $version = 0x0303; if (isset($ext[self::EXT_KEY_SHARE]) || $random === self::HRR_RANDOM) throw new TlsException('TLS:不正なServerHello', self::ALERT_ILLEGAL_PARAMETER); } // TLS 1.2以下に下げる攻撃の印(RFC 8446 4.1.3):TLS 1.3を送ったのに1.2になった $tail = substr($random, -8); if (($version === 0x0303 && $v13 && $tail === self::DOWNGRADE_12) || $tail === "DOWNGRD\x00") { throw new TlsException('TLS:ダウングレードの攻撃(サーバーはTLS 1.3に対応している)', self::ALERT_ILLEGAL_PARAMETER); } $res = ['version' => $version, 'random' => $random, 'echo' => $echo, 'suite' => $suite, 'ext' => $ext, 'share' => null, 'hrr_group' => null, 'cookie' => '']; if ($version === 0x0303) return $res; if (isset($ext[self::EXT_KEY_SHARE])) { $k = $ext[self::EXT_KEY_SHARE]; if ($random === self::HRR_RANDOM) { if (strlen($k) !== 2) throw new TlsException('TLS:不正なHelloRetryRequest', self::ALERT_DECODE_ERROR); $res['hrr_group'] = $this->u16($k, 0); } else { $p = 2; $res['share'] = [$this->u16($k, 0), $this->vec($k, $p, 2, $p)]; if ($p !== strlen($k)) throw new TlsException('TLS:不正なkey_share', self::ALERT_DECODE_ERROR); } } else if ($random !== self::HRR_RANDOM) { throw new TlsException('TLS:key_shareがありません', self::ALERT_MISSING_EXTENSION); } if (isset($ext[self::EXT_COOKIE])) $res['cookie'] = $ext[self::EXT_COOKIE]; return $res; } /** * TLS 1.2のハンドシェイク(クライアント、ServerHelloの後) * * Certificate、ServerKeyExchange(ECDHE、署名を確認する)、ServerHelloDone、 * ClientKeyExchange、ChangeCipherSpec、Finished、相手のChangeCipherSpec・Finished */ private function clientHandshake12(array $s, string $sh, string $random, string $host, array $groups): void { $this->version = 0x0303; $this->suite = $s['suite']; $this->hash = self::SUITES12[$this->suite][2]; $this->transcript .= $this->msg(self::SERVER_HELLO, $sh); // 送っていない拡張は拒否する $ext = $s['ext']; foreach (array_keys($ext) as $t) { if (!in_array($t, [self::EXT_SERVER_NAME, self::EXT_EC_POINT_FORMATS, self::EXT_ALPN, self::EXT_EXTENDED_MASTER_SECRET, self::EXT_RENEGOTIATION_INFO], true)) { throw new TlsException("TLS:送っていない拡張:{$t}", self::ALERT_UNSUPPORTED_EXTENSION); } } if (isset($ext[self::EXT_RENEGOTIATION_INFO]) && $ext[self::EXT_RENEGOTIATION_INFO] !== "\x00") { throw new TlsException('TLS:不正なrenegotiation_info', self::ALERT_HANDSHAKE_FAILURE); } if (isset($ext[self::EXT_EC_POINT_FORMATS]) && !str_contains($this->vec($ext[self::EXT_EC_POINT_FORMATS], 0, 1), "\x00")) { throw new TlsException('TLS:圧縮しない点に対応していません', self::ALERT_ILLEGAL_PARAMETER); } $ems = isset($ext[self::EXT_EXTENDED_MASTER_SECRET]); if ($ems && $ext[self::EXT_EXTENDED_MASTER_SECRET] !== '') throw new TlsException('TLS:不正なextended_master_secret', self::ALERT_DECODE_ERROR); if (isset($ext[self::EXT_ALPN])) $this->alpn = $this->alpnReply($ext[self::EXT_ALPN]); // Certificate [$type, $body] = $this->readHandshake(); if ($type !== self::CERTIFICATE) throw new TlsException('TLS:Certificateを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $this->transcript .= $this->msg($type, $body); $this->peerCerts = $this->parseCertificate12($body); $key = $this->peerKey(); if ((self::SUITES12[$this->suite][4] === 'ec') !== ($key instanceof Ec)) { throw new TlsException('TLS:証明書の鍵が暗号スイートと合いません', self::ALERT_BAD_CERTIFICATE); } // ServerKeyExchange(名前付き曲線のECDHE) [$type, $body] = $this->readHandshake(); if ($type !== self::SERVER_KEY_EXCHANGE) throw new TlsException('TLS:ServerKeyExchangeを期待しました', self::ALERT_UNEXPECTED_MESSAGE); if (($body[0] ?? '') !== "\x03") throw new TlsException('TLS:名前付き曲線ではありません', self::ALERT_ILLEGAL_PARAMETER); $group = $this->u16($body, 1); $point = $this->vec($body, 3, 1, $p); $params = substr($body, 0, $p); $scheme = $this->u16($body, $p); $sig = $this->vec($body, $p + 2, 2, $end); if ($end !== strlen($body)) throw new TlsException('TLS:不正なServerKeyExchange', self::ALERT_DECODE_ERROR); if (!in_array($group, $groups, true)) throw new TlsException('TLS:送っていない鍵交換のグループ', self::ALERT_ILLEGAL_PARAMETER); if (!$this->verify12($key, $scheme, $random.$s['random'].$params, $sig)) { throw new TlsException('TLS:ServerKeyExchangeの署名が不正です', self::ALERT_DECRYPT_ERROR); } $this->transcript .= $this->msg($type, $body); $this->verifyPeer($host); // CertificateRequest(任意)、ServerHelloDone [$type, $body] = $this->readHandshake(); $certRequest = false; if ($type === self::CERTIFICATE_REQUEST) { $this->transcript .= $this->msg($type, $body); $certRequest = true; [$type, $body] = $this->readHandshake(); } if ($type !== self::SERVER_HELLO_DONE || $body !== '') throw new TlsException('TLS:ServerHelloDoneを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $this->transcript .= $this->msg($type, $body); // ClientKeyExchange(求められたら空の証明書) $this->group = (string)array_search($group, self::GROUPS, true); $mine = $this->newShare($group); $pms = $this->sharedSecret($mine, $group, $point); $out = ''; if ($certRequest) { $out .= $m = $this->msg(self::CERTIFICATE, "\x00\x00\x00"); $this->transcript .= $m; } $out .= $m = $this->msg(self::CLIENT_KEY_EXCHANGE, $this->vec2($mine['pub'], 1)); $this->transcript .= $m; $master = $this->masterSecret($pms, $random, $s['random'], $ems); $this->keys12($master, $random, $s['random']); $this->writeRecord(self::HANDSHAKE, $out); // ChangeCipherSpecとFinished $this->writeCcs(); $this->activateWrite12(); $fin = $this->msg(self::FINISHED, $this->finished12($master, 'client finished')); $this->writeRecord(self::HANDSHAKE, $fin); $this->transcript .= $fin; // サーバーのChangeCipherSpec(readRecordで鍵を変える)とFinished [$type, $body] = $this->readHandshake(); if ($this->readAead === null || $type !== self::FINISHED) throw new TlsException('TLS:Finishedを期待しました', self::ALERT_UNEXPECTED_MESSAGE); if (!hash_equals($this->finished12($master, 'server finished'), $body)) throw new TlsException('TLS:サーバーのFinishedが不正です', self::ALERT_DECRYPT_ERROR); $this->transcript .= $this->msg($type, $body); if ($this->hs !== '') throw new TlsException('TLS:Finishedの後に余計なデータ', self::ALERT_UNEXPECTED_MESSAGE); } /** ALPNの返事(送った物の1つ) */ private function alpnReply(string $data): string { $list = $this->vec($data, 0, 2); $p = $this->vec($list, 0, 1, $end); if ($end !== strlen($list) || !in_array($p, $this->opt['alpn'] ?? [], true)) throw new TlsException('TLS:不正なALPN', self::ALERT_ILLEGAL_PARAMETER); return $p; } /** サーバーの証明書の公開鍵 */ private function peerKey(): Rsa|Ec { if (empty($this->peerCerts)) throw new TlsException('TLS:サーバーの証明書がありません', self::ALERT_DECODE_ERROR); try { return $this->peerCerts[0]->publicKey(); } catch (\Throwable $e) { throw new TlsException('TLS:未対応の証明書の鍵', self::ALERT_BAD_CERTIFICATE, false, $e); } } /** 証明書の確認(verify_peer) */ private function verifyPeer(string $host): void { if (!($this->opt['verify_peer'] ?? true)) return; $name = ($this->opt['verify_peer_name'] ?? true) ? $host : null; if ($name === '') throw new TlsException('TLS:peer_nameがありません(証明書を確認出来ません)', self::ALERT_INTERNAL_ERROR); try { X509::verifyChain($this->peerCerts, $name, [ 'cafile' => $this->opt['cafile'] ?? null, 'allow_self_signed' => (bool)($this->opt['allow_self_signed'] ?? false), 'time' => $this->opt['_time'] ?? null, ]); } catch (\RuntimeException $e) { $alert = str_contains($e->getMessage(), '期限') ? self::ALERT_CERTIFICATE_EXPIRED : (str_contains($e->getMessage(), '信頼') || str_contains($e->getMessage(), '自己署名') ? self::ALERT_UNKNOWN_CA : self::ALERT_BAD_CERTIFICATE); throw new TlsException('TLS:証明書の確認に失敗:'.$e->getMessage(), $alert, false, $e); } } /////////////// // サーバー private function serverHandshake(): void { [$certs, $key] = $this->serverIdentity(); $groups = $this->allowedGroups(); [$type, $body] = $this->readHandshake(); if ($type !== self::CLIENT_HELLO) throw new TlsException('TLS:ClientHelloを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $ch = $this->parseClientHello($body); $this->transcript = $this->msg($type, $body); $this->sni = $ch['sni']; // バージョン:両方が対応している一番新しい物 $offers13 = in_array(0x0304, $ch['versions'], true); $offers12 = in_array(0x0303, $ch['versions'], true) || (empty($ch['versions']) && $ch['legacy'] >= 0x0303); if (!($offers13 && $this->maxVersion >= 0x0304)) { if ($offers12 && $this->minVersion <= 0x0303) { $this->serverHandshake12($ch, $certs, $key, $groups); return; } throw new TlsException('TLS:クライアントが対応しているバージョンがありません', self::ALERT_PROTOCOL_VERSION); } if ($ch['sigs'] === null || $ch['groups'] === null || $ch['shares'] === null) { throw new TlsException('TLS:必要な拡張がありません', self::ALERT_MISSING_EXTENSION); } $suites = $this->allowedSuites(self::SUITES); $suite = null; foreach ($ch['suites'] as $s) { if (in_array($s, $suites, true)) { $suite = $s; break; } } if ($suite === null) throw new TlsException('TLS:共通の暗号スイートがありません', self::ALERT_HANDSHAKE_FAILURE); $this->suite = $suite; $this->hash = self::SUITES[$suite][2]; $scheme = $this->chooseScheme($key, $ch['sigs']); // 鍵交換:クライアントが送った鍵、無ければHelloRetryRequest $share = null; foreach ($groups as $g) { if (isset($ch['shares'][$g])) { $share = [$g, $ch['shares'][$g]]; break; } } if ($share === null) { $g = null; foreach ($groups as $x) { if (in_array($x, $ch['groups'], true)) { $g = $x; break; } } if ($g === null) throw new TlsException('TLS:共通の鍵交換のグループがありません', self::ALERT_HANDSHAKE_FAILURE); $hrr = $this->msg(self::SERVER_HELLO, "\x03\x03".self::HRR_RANDOM.$this->vec2($ch['sid'], 1).pack('n', $suite)."\x00" .$this->vec2($this->ext(self::EXT_SUPPORTED_VERSIONS, "\x03\x04").$this->ext(self::EXT_KEY_SHARE, pack('n', $g)), 2)); $this->transcript = $this->messageHash($this->transcript).$hrr; $this->writeRecord(self::HANDSHAKE, $hrr); if ($ch['sid'] !== '') $this->writeCcs(); [$type, $body] = $this->readHandshake(); if ($type !== self::CLIENT_HELLO) throw new TlsException('TLS:ClientHelloを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $ch2 = $this->parseClientHello($body); if (!isset($ch2['shares'][$g]) || count($ch2['shares']) !== 1 || !in_array($suite, $ch2['suites'], true) || !in_array(0x0304, $ch2['versions'], true)) { throw new TlsException('TLS:2回目のClientHelloが不正です', self::ALERT_ILLEGAL_PARAMETER); } $this->transcript .= $this->msg($type, $body); $share = [$g, $ch2['shares'][$g]]; $ch['alpn'] = $ch2['alpn']; $hrrSent = true; } $this->group = (string)array_search($share[0], self::GROUPS, true); $mine = $this->newShare($share[0]); $shared = $this->sharedSecret($mine, $share[0], $share[1]); $this->chooseAlpn($ch['alpn']); $sh = $this->msg(self::SERVER_HELLO, "\x03\x03".random_bytes(32).$this->vec2($ch['sid'], 1).pack('n', $suite)."\x00" .$this->vec2($this->ext(self::EXT_SUPPORTED_VERSIONS, "\x03\x04").$this->ext(self::EXT_KEY_SHARE, pack('n', $share[0]).$this->vec2($mine['pub'], 2)), 2)); $this->writeRecord(self::HANDSHAKE, $sh); $this->transcript .= $sh; if ($ch['sid'] !== '' && empty($hrrSent)) $this->writeCcs(); [$cHs, $sHs, $master] = $this->handshakeSecrets($shared); $this->setWriteKey($sHs); $out = ''; $ee = $this->alpn !== '' ? $this->ext(self::EXT_ALPN, $this->vec2($this->vec2($this->alpn, 1), 2)) : ''; $out .= $m = $this->msg(self::ENCRYPTED_EXTENSIONS, $this->vec2($ee, 2)); $this->transcript .= $m; $list = ''; foreach ($certs as $c) $list .= $this->vec2($c->der, 3)."\x00\x00"; $out .= $m = $this->msg(self::CERTIFICATE, "\x00".$this->vec2($list, 3)); $this->transcript .= $m; $out .= $m = $this->msg(self::CERTIFICATE_VERIFY, pack('n', $scheme).$this->vec2($this->signTranscript($key, $scheme, 'TLS 1.3, server CertificateVerify'), 2)); $this->transcript .= $m; $out .= $m = $this->msg(self::FINISHED, $this->finished($sHs)); $this->transcript .= $m; $this->writeRecord(self::HANDSHAKE, $out); [$cAp, $sAp] = $this->appSecrets($master); $this->setWriteKey($sAp); $this->setReadKey($cHs); [$type, $body] = $this->readHandshake(); if ($type === self::CERTIFICATE) throw new TlsException('TLS:クライアント証明書を求めていません', self::ALERT_UNEXPECTED_MESSAGE); if ($type !== self::FINISHED) throw new TlsException('TLS:Finishedを期待しました', self::ALERT_UNEXPECTED_MESSAGE); if (!hash_equals($this->finished($cHs), $body)) throw new TlsException('TLS:クライアントのFinishedが不正です', self::ALERT_DECRYPT_ERROR); $this->transcript .= $this->msg($type, $body); if ($this->hs !== '') throw new TlsException('TLS:鍵の変更の前に余計なデータ', self::ALERT_UNEXPECTED_MESSAGE); $this->setReadKey($cAp); } /** * TLS 1.2のハンドシェイク(サーバー、ClientHelloの後) */ private function serverHandshake12(array $ch, array $certs, Rsa|Ec $key, array $groups): void { $this->version = 0x0303; $keyType = $key instanceof Rsa ? 'rsa' : 'ec'; $allowed = $this->allowedSuites(self::SUITES12); $suite = null; foreach ($ch['suites'] as $id) { if (in_array($id, $allowed, true) && self::SUITES12[$id][4] === $keyType) { $suite = $id; break; } } if ($suite === null) throw new TlsException('TLS:共通の暗号スイートがありません', self::ALERT_HANDSHAKE_FAILURE); $this->suite = $suite; $this->hash = self::SUITES12[$suite][2]; // 鍵交換のグループ(supported_groupsが無ければsecp256r1) $group = null; foreach ($ch['groups'] ?? [self::GROUPS['P-256']] as $g) { if (in_array($g, $groups, true)) { $group = $g; break; } } if ($group === null) throw new TlsException('TLS:共通の鍵交換のグループがありません', self::ALERT_HANDSHAKE_FAILURE); $this->group = (string)array_search($group, self::GROUPS, true); if (isset($ch['ext'][self::EXT_EC_POINT_FORMATS]) && !str_contains($this->vec($ch['ext'][self::EXT_EC_POINT_FORMATS], 0, 1), "\x00")) { throw new TlsException('TLS:圧縮しない点に対応していません', self::ALERT_ILLEGAL_PARAMETER); } // signature_algorithmsが無い(SHA-1を意味する)クライアントは拒否する if ($ch['sigs'] === null) throw new TlsException('TLS:signature_algorithmsがありません', self::ALERT_HANDSHAKE_FAILURE); $scheme = $this->chooseScheme12($key, $ch['sigs']); $ems = isset($ch['ext'][self::EXT_EXTENDED_MASTER_SECRET]); $this->chooseAlpn($ch['alpn']); // ServerHello(TLS 1.3に対応しているなら、ダウングレードの印を入れる) $sr = random_bytes(24).($this->maxVersion >= 0x0304 ? self::DOWNGRADE_12 : random_bytes(8)); $ext = ''; if (isset($ch['ext'][self::EXT_RENEGOTIATION_INFO])) { if ($ch['ext'][self::EXT_RENEGOTIATION_INFO] !== "\x00") throw new TlsException('TLS:不正なrenegotiation_info', self::ALERT_HANDSHAKE_FAILURE); $ext .= $this->ext(self::EXT_RENEGOTIATION_INFO, "\x00"); } else if (in_array(0x00FF, $ch['suites'], true)) { $ext .= $this->ext(self::EXT_RENEGOTIATION_INFO, "\x00"); // TLS_EMPTY_RENEGOTIATION_INFO_SCSV } if ($ems) $ext .= $this->ext(self::EXT_EXTENDED_MASTER_SECRET, ''); $ext .= $this->ext(self::EXT_EC_POINT_FORMATS, "\x01\x00"); if ($this->alpn !== '') $ext .= $this->ext(self::EXT_ALPN, $this->vec2($this->vec2($this->alpn, 1), 2)); $out = $m = $this->msg(self::SERVER_HELLO, "\x03\x03".$sr."\x00".pack('n', $suite)."\x00".$this->vec2($ext, 2)); $this->transcript .= $m; $list = ''; foreach ($certs as $c) $list .= $this->vec2($c->der, 3); $out .= $m = $this->msg(self::CERTIFICATE, $this->vec2($list, 3)); $this->transcript .= $m; $mine = $this->newShare($group); $params = "\x03".pack('n', $group).$this->vec2($mine['pub'], 1); $out .= $m = $this->msg(self::SERVER_KEY_EXCHANGE, $params.pack('n', $scheme).$this->vec2($this->signKeyExchange($key, $scheme, $ch['random'].$sr.$params), 2)); $this->transcript .= $m; $out .= $m = $this->msg(self::SERVER_HELLO_DONE, ''); $this->transcript .= $m; $this->writeRecord(self::HANDSHAKE, $out); // ClientKeyExchange [$type, $body] = $this->readHandshake(); if ($type !== self::CLIENT_KEY_EXCHANGE) throw new TlsException('TLS:ClientKeyExchangeを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $point = $this->vec($body, 0, 1, $end); if ($end !== strlen($body)) throw new TlsException('TLS:不正なClientKeyExchange', self::ALERT_DECODE_ERROR); $pms = $this->sharedSecret($mine, $group, $point); $this->transcript .= $this->msg($type, $body); $master = $this->masterSecret($pms, $ch['random'], $sr, $ems); $this->keys12($master, $ch['random'], $sr); // クライアントのChangeCipherSpecとFinished [$type, $body] = $this->readHandshake(); if ($this->readAead === null || $type !== self::FINISHED) throw new TlsException('TLS:Finishedを期待しました', self::ALERT_UNEXPECTED_MESSAGE); if (!hash_equals($this->finished12($master, 'client finished'), $body)) throw new TlsException('TLS:クライアントのFinishedが不正です', self::ALERT_DECRYPT_ERROR); $this->transcript .= $this->msg($type, $body); if ($this->hs !== '') throw new TlsException('TLS:Finishedの後に余計なデータ', self::ALERT_UNEXPECTED_MESSAGE); $this->writeCcs(); $this->activateWrite12(); $fin = $this->msg(self::FINISHED, $this->finished12($master, 'server finished')); $this->writeRecord(self::HANDSHAKE, $fin); $this->transcript .= $fin; } /** ALPN(サーバー):自分の順で、クライアントも対応している物 */ private function chooseAlpn(array $offered): void { if (empty($offered) || empty($this->opt['alpn'])) return; foreach ($this->opt['alpn'] as $p) { if (in_array($p, $offered, true)) { $this->alpn = $p; return; } } throw new TlsException('TLS:共通のアプリケーションプロトコルがありません', self::ALERT_NO_APPLICATION_PROTOCOL); } /** サーバーの証明書と秘密鍵(cert・key、又はlocal_cert・local_pkのPEM又はファイル) */ private function serverIdentity(): array { $load = function(string $v): string { return str_contains($v, '-----BEGIN') ? $v : (string)@file_get_contents($v); }; $certPem = $load((string)($this->opt['cert'] ?? $this->opt['local_cert'] ?? '')); $keyPem = $load((string)($this->opt['key'] ?? $this->opt['local_pk'] ?? '')); if ($keyPem === '') $keyPem = $certPem; // 証明書と鍵が同じファイル $certs = X509::allFromPem($certPem); if (empty($certs)) throw new TlsException('TLS:サーバーの証明書がありません', self::ALERT_INTERNAL_ERROR); $key = str_contains($keyPem, 'EC PRIVATE KEY') ? Ec::fromPem($keyPem) : null; if ($key === null) { try { $key = Rsa::fromPem($keyPem); } catch (\InvalidArgumentException $e) { $key = Ec::fromPem($keyPem); } } if (!$key->isPrivate()) throw new TlsException('TLS:サーバーの秘密鍵がありません', self::ALERT_INTERNAL_ERROR); if ($key->publicDer() !== $certs[0]->publicKey()->publicDer()) throw new TlsException('TLS:証明書と秘密鍵が合いません', self::ALERT_INTERNAL_ERROR); return [$certs, $key]; } protected function parseClientHello(string $b): array { $pos = 2; $random = substr($b, $pos, 32); $pos += 32; $sid = $this->vec($b, $pos, 1, $pos); $suites = array_values(unpack('n*', $this->vec($b, $pos, 2, $pos)) ?: []); $comp = $this->vec($b, $pos, 1, $pos); $ext = $this->parseExtensions($pos < strlen($b) ? $this->vec($b, $pos, 2, $pos) : ''); if ($pos !== strlen($b) || strlen($random) !== 32 || strlen($sid) > 32) throw new TlsException('TLS:不正なClientHello', self::ALERT_DECODE_ERROR); if (!str_contains($comp, "\x00")) throw new TlsException('TLS:不正なClientHello', self::ALERT_ILLEGAL_PARAMETER); $versions = isset($ext[self::EXT_SUPPORTED_VERSIONS]) ? array_values(unpack('n*', $this->vec($ext[self::EXT_SUPPORTED_VERSIONS], 0, 1)) ?: []) : []; $shares = null; if (isset($ext[self::EXT_KEY_SHARE])) { $shares = []; $ks = $this->vec($ext[self::EXT_KEY_SHARE], 0, 2); for ($p = 0; $p < strlen($ks); ) { $g = $this->u16($ks, $p); $p += 2; if (isset($shares[$g])) throw new TlsException('TLS:重複したkey_share', self::ALERT_ILLEGAL_PARAMETER); $shares[$g] = $this->vec($ks, $p, 2, $p); } } $alpn = []; if (isset($ext[self::EXT_ALPN])) { $l = $this->vec($ext[self::EXT_ALPN], 0, 2); for ($p = 0; $p < strlen($l); ) $alpn[] = $this->vec($l, $p, 1, $p); } $sni = ''; if (isset($ext[self::EXT_SERVER_NAME])) { $l = $this->vec($ext[self::EXT_SERVER_NAME], 0, 2); if (($l[0] ?? '') === "\x00") $sni = $this->vec($l, 1, 2); } $list = fn(int $t): ?array => isset($ext[$t]) ? array_values(unpack('n*', $this->vec($ext[$t], 0, 2)) ?: []) : null; return [ 'legacy' => $this->u16($b, 0), 'versions' => $versions, 'random' => $random, 'sid' => $sid, 'suites' => $suites, 'groups' => $list(self::EXT_SUPPORTED_GROUPS), 'sigs' => $list(self::EXT_SIGNATURE_ALGORITHMS), 'shares' => $shares, 'alpn' => $alpn, 'sni' => $sni, 'ext' => $ext, ]; } /** サーバーの鍵とクライアントの署名の方式から選ぶ */ private function chooseScheme(Rsa|Ec $key, array $sigs): int { foreach ($sigs as $s) { $d = self::SIG_SCHEMES[$s] ?? null; if ($d === null) continue; if ($key instanceof Rsa && $d[0] === 'pss') return $s; if ($key instanceof Ec && $d[0] === 'ec' && $d[2] === $key->curve) return $s; } throw new TlsException('TLS:共通の署名の方式がありません', self::ALERT_HANDSHAKE_FAILURE); } /** TLS 1.2の署名の方式(クライアントの順、ECDSAはどの曲線でも良い) */ private function chooseScheme12(Rsa|Ec $key, array $sigs): int { foreach ($sigs as $s) { $d = self::SIG_SCHEMES[$s] ?? null; if ($d === null) continue; if ($key instanceof Rsa && ($d[0] === 'pss' || $d[0] === 'pkcs1')) return $s; if ($key instanceof Ec && $d[0] === 'ec') return $s; } throw new TlsException('TLS:共通の署名の方式がありません', self::ALERT_HANDSHAKE_FAILURE); } /////////////// // TLS 1.2の鍵・署名(RFC 5246、RFC 7627) /** PRF(P_hash) */ private function prf(string $secret, string $label, string $seed, int $len): string { $seed = $label.$seed; $a = $seed; $out = ''; while (strlen($out) < $len) { $a = hash_hmac($this->hash, $a, $secret, true); $out .= hash_hmac($this->hash, $a.$seed, $secret, true); } return substr($out, 0, $len); } /** マスターシークレット(相手が対応していればExtended Master Secret) */ private function masterSecret(string $pms, string $cr, string $sr, bool $ems): string { return $ems ? $this->prf($pms, 'extended master secret', hash($this->hash, $this->transcript, true), 48) : $this->prf($pms, 'master secret', $cr.$sr, 48); } /** 鍵を作る(書く方は自分のChangeCipherSpecの後、読む方は相手のChangeCipherSpecで使い始める) */ private function keys12(string $master, string $cr, string $sr): void { [$type, $keyLen] = self::SUITES12[$this->suite]; $ivLen = $type === 'aes' ? 4 : 12; $kb = $this->prf($master, 'key expansion', $sr.$cr, 2 * $keyLen + 2 * $ivLen); $c = [substr($kb, 0, $keyLen), substr($kb, 2 * $keyLen, $ivLen)]; $s = [substr($kb, $keyLen, $keyLen), substr($kb, 2 * $keyLen + $ivLen, $ivLen)]; $make = fn(array $k): array => [$type === 'aes' ? new Aes($k[0]) : new ChaCha20($k[0]), $k[1]]; $this->pendingRead = $make($this->server ? $c : $s); $this->pendingWrite = $make($this->server ? $s : $c); } private function activateWrite12(): void { [$this->writeAead, $this->writeIv] = $this->pendingWrite; $this->pendingWrite = null; $this->writeSeq = 0; } /** TLS 1.2のFinished */ protected function finished12(string $master, string $label): string { return $this->prf($master, $label, hash($this->hash, $this->transcript, true), 12); } /** ServerKeyExchangeの署名 */ protected function signKeyExchange(Rsa|Ec $key, int $scheme, string $data): string { [$type, $hash] = self::SIG_SCHEMES[$scheme]; return $type === 'pss' ? $key->signPss($data, $hash) : $key->sign($data, $hash); } /** ServerKeyExchangeの署名を確認する(送った方式だけ、鍵の種類も合う事) */ private function verify12(Rsa|Ec $key, int $scheme, string $data, string $sig): bool { $d = self::SIG_SCHEMES[$scheme] ?? null; return match (true) { $d === null => false, $d[0] === 'pss' && $key instanceof Rsa => $key->verifyPss($data, $sig, $d[1]), $d[0] === 'pkcs1' && $key instanceof Rsa => $key->verify($data, $sig, $d[1]), $d[0] === 'ec' && $key instanceof Ec => $key->verify($data, $sig, $d[1]), default => false, }; } /** TLS 1.2のCertificate(拡張なし) */ private function parseCertificate12(string $body): array { $list = $this->vec($body, 0, 3, $end); if ($end !== strlen($body)) throw new TlsException('TLS:不正なCertificate', self::ALERT_DECODE_ERROR); $certs = []; for ($i = 0; $i < strlen($list); ) { try { $certs[] = new X509($this->vec($list, $i, 3, $i)); } catch (\UnexpectedValueException $e) { throw new TlsException('TLS:壊れた証明書:'.$e->getMessage(), self::ALERT_BAD_CERTIFICATE, false, $e); } } return $certs; } /////////////// // 鍵交換・署名 private function newShare(int $group): array { $name = array_search($group, self::GROUPS, true); $fixed = $this->opt['_keys'][$name] ?? null; if ($group === self::GROUPS['x25519']) { $priv = $fixed ?? random_bytes(32); return ['priv' => $priv, 'pub' => X25519::publicKey($priv)]; } $k = $fixed !== null ? Ec::fromPem($fixed) : Ec::generate($name); return ['priv' => $k, 'pub' => $k->publicPoint()]; } private function sharedSecret(array $mine, int $group, string $peer): string { try { if ($group === self::GROUPS['x25519']) return X25519::sharedSecret($mine['priv'], $peer); return $mine['priv']->ecdh($peer); } catch (\InvalidArgumentException $e) { throw new TlsException('TLS:不正な鍵:'.$e->getMessage(), self::ALERT_ILLEGAL_PARAMETER, false, $e); } } private function signedContent(string $context): string { return str_repeat(' ', 64).$context."\x00".hash($this->hash, $this->transcript, true); } protected function signTranscript(Rsa|Ec $key, int $scheme, string $context): string { [$type, $hash] = self::SIG_SCHEMES[$scheme]; $content = $this->signedContent($context); return $type === 'pss' ? $key->signPss($content, $hash) : $key->sign($content, $hash); } private function checkCertificateVerify(string $body, X509 $cert, string $context): void { $scheme = $this->u16($body, 0); $p = 2; $sig = $this->vec($body, $p, 2, $p); if ($p !== strlen($body)) throw new TlsException('TLS:不正なCertificateVerify', self::ALERT_DECODE_ERROR); $d = self::SIG_SCHEMES[$scheme] ?? null; // TLS 1.3のCertificateVerifyにPKCS#1 v1.5は使えない if ($d === null || $d[0] === 'pkcs1') throw new TlsException('TLS:不正な署名の方式', self::ALERT_ILLEGAL_PARAMETER); try { $key = $cert->publicKey(); } catch (\Throwable $e) { throw new TlsException('TLS:未対応の証明書の鍵', self::ALERT_BAD_CERTIFICATE, false, $e); } $content = $this->signedContent($context); $ok = match (true) { $d[0] === 'pss' && $key instanceof Rsa => $key->verifyPss($content, $sig, $d[1]), $d[0] === 'ec' && $key instanceof Ec && $key->curve === $d[2] => $key->verify($content, $sig, $d[1]), default => false, }; if (!$ok) throw new TlsException('TLS:CertificateVerifyの署名が不正です', self::ALERT_DECRYPT_ERROR); } private function parseCertificate(string $body): array { $p = 0; $this->vec($body, $p, 1, $p); // certificate_request_context $list = $this->vec($body, $p, 3, $p); if ($p !== strlen($body)) throw new TlsException('TLS:不正なCertificate', self::ALERT_DECODE_ERROR); $certs = []; for ($i = 0; $i < strlen($list); ) { $der = $this->vec($list, $i, 3, $i); $this->vec($list, $i, 2, $i); // 拡張 try { $certs[] = new X509($der); } catch (\UnexpectedValueException $e) { throw new TlsException('TLS:壊れた証明書:'.$e->getMessage(), self::ALERT_BAD_CERTIFICATE, false, $e); } } return $certs; } /////////////// // 鍵のスケジュール(RFC 8446 7.1) private function handshakeSecrets(string $shared): array { $len = strlen(hash($this->hash, '', true)); $zero = str_repeat("\0", $len); $early = hash_hmac($this->hash, $zero, $zero, true); $hs = hash_hmac($this->hash, $shared, $this->deriveSecret($early, 'derived', ''), true); $th = hash($this->hash, $this->transcript, true); return [ $this->expandLabel($hs, 'c hs traffic', $th, $len), $this->expandLabel($hs, 's hs traffic', $th, $len), hash_hmac($this->hash, $zero, $this->deriveSecret($hs, 'derived', ''), true), ]; } private function appSecrets(string $master): array { $len = strlen(hash($this->hash, '', true)); $th = hash($this->hash, $this->transcript, true); return [$this->expandLabel($master, 'c ap traffic', $th, $len), $this->expandLabel($master, 's ap traffic', $th, $len)]; } private function deriveSecret(string $secret, string $label, string $messages): string { return $this->expandLabel($secret, $label, hash($this->hash, $messages, true), strlen(hash($this->hash, '', true))); } /** HKDF-Expand-Label */ public function expandLabel(string $secret, string $label, string $context, int $len): string { $info = pack('n', $len).$this->vec2('tls13 '.$label, 1).$this->vec2($context, 1); $out = ''; $t = ''; for ($i = 1; strlen($out) < $len; $i++) { $t = hash_hmac($this->hash, $t.$info.chr($i), $secret, true); $out .= $t; } return substr($out, 0, $len); } protected function finished(string $base): string { $key = $this->expandLabel($base, 'finished', '', strlen(hash($this->hash, '', true))); return hash_hmac($this->hash, hash($this->hash, $this->transcript, true), $key, true); } /** HelloRetryRequestの後の最初のClientHelloの代わり */ private function messageHash(string $hello): string { $h = hash($this->hash, $hello, true); return chr(self::MESSAGE_HASH)."\x00\x00".chr(strlen($h)).$h; } private function aead(string $secret): array { [$type, $keyLen] = self::SUITES[$this->suite]; $key = $this->expandLabel($secret, 'key', '', $keyLen); $iv = $this->expandLabel($secret, 'iv', '', 12); return [$type === 'aes' ? new Aes($key) : new ChaCha20($key), $iv]; } private function setReadKey(string $secret): void { if ($this->hs !== '') throw new TlsException('TLS:鍵の変更の前に余計なデータ', self::ALERT_UNEXPECTED_MESSAGE); [$this->readAead, $this->readIv] = $this->aead($secret); $this->readSecret = $secret; $this->readSeq = 0; } private function setWriteKey(string $secret): void { [$this->writeAead, $this->writeIv] = $this->aead($secret); $this->writeSecret = $secret; $this->writeSeq = 0; } private function nonce(string $iv, int $seq): string { return $iv ^ (str_repeat("\0", 4).pack('J', $seq)); } /////////////// // レコード層 /** * レコードを書く(16KiB毎に分ける) * * @param int $legacy レコードの古いバージョン(最初のClientHelloは0x0301) */ protected function writeRecord(int $type, string $data, int $legacy = 0x0303): void { $out = ''; $pos = 0; do { $chunk = substr($data, $pos, self::MAX_PLAIN); $pos += self::MAX_PLAIN; if ($this->writeAead === null) { $out .= chr($type).pack('n', $legacy).pack('n', strlen($chunk)).$chunk; continue; } if ($this->version === 0x0303) { // TLS 1.2:AADは順序番号・種類・バージョン・長さ。AES-GCMは明示的なノンス(順序番号)を付ける(RFC 5288)、 // ChaCha20はTLS 1.3と同じノンス(RFC 7905) $seq = pack('J', $this->writeSeq); $aad = $seq.chr($type)."\x03\x03".pack('n', strlen($chunk)); $sealed = $this->writeAead instanceof Aes ? $seq.$this->writeAead->seal($this->writeIv.$seq, $chunk, $aad) : $this->writeAead->seal($this->nonce($this->writeIv, $this->writeSeq), $chunk, $aad); $out .= chr($type)."\x03\x03".pack('n', strlen($sealed)).$sealed; $this->writeSeq++; continue; } $inner = $chunk.chr($type); $header = chr(self::APPDATA)."\x03\x03".pack('n', strlen($inner) + 16); $out .= $header.$this->writeAead->seal($this->nonce($this->writeIv, $this->writeSeq), $inner, $header); $this->writeSeq++; } while ($pos < strlen($data)); $this->send($out); } private function writeCcs(): void { $this->send("\x14\x03\x03\x00\x01\x01"); } private function send(string $raw): void { for ($w = 0, $len = strlen($raw); $w < $len; ) { $n = @fwrite($this->sock, substr($raw, $w, 65536)); if ($n === false || $n === 0) { $this->rawEof = true; throw new TlsException('TLS:送信に失敗(接続が切れました)', self::ALERT_INTERNAL_ERROR, true); } $w += $n; } } private function sendAlert(int $alert, int $level = 2): void { if ($this->closed || !is_resource($this->sock)) return; try { $this->writeRecord(self::ALERT, chr($level).chr($alert)); } catch (\Throwable $e) { // 送れなくても構わない } } /** ソケットから$nバイトを溜める(タイムアウト・終わりならfalse) */ private function fill(int $n): bool { while (strlen($this->in) < $n) { $chunk = @fread($this->sock, max(8192, $n - strlen($this->in))); if ($chunk === false || $chunk === '') { if (!is_resource($this->sock) || feof($this->sock)) { $this->rawEof = true; return false; } if (stream_get_meta_data($this->sock)['timed_out'] ?? false) { $this->timedOut = true; return false; } continue; } $this->in .= $chunk; } return true; } /** * レコードを1つ読む * * @return array|null [種類, 中身]、タイムアウト・終わりならnull * @throws TlsException */ private function readRecord(): ?array { $this->timedOut = false; if (!$this->fill(5)) return null; $type = ord($this->in[0]); $len = $this->u16($this->in, 3); if ($len > self::MAX_PLAIN + ($this->version === 0x0303 ? 2048 : 256)) throw new TlsException('TLS:レコードが大き過ぎます', self::ALERT_RECORD_OVERFLOW); if (!in_array($type, [self::CCS, self::ALERT, self::HANDSHAKE, self::APPDATA], true)) { throw new TlsException('TLS:不正なレコード', self::ALERT_UNEXPECTED_MESSAGE); } if (!$this->fill(5 + $len)) return null; $header = substr($this->in, 0, 5); $body = substr($this->in, 5, $len); $this->in = (string)substr($this->in, 5 + $len); if ($type === self::CCS) { if ($this->established || $body !== "\x01") throw new TlsException('TLS:不正なChangeCipherSpec', self::ALERT_UNEXPECTED_MESSAGE); if ($this->version === 0x0303) { // TLS 1.2:ここから相手の鍵を使う(ハンドシェイクのメッセージの途中では駄目) if ($this->pendingRead === null || $this->hs !== '') throw new TlsException('TLS:想定外のChangeCipherSpec', self::ALERT_UNEXPECTED_MESSAGE); [$this->readAead, $this->readIv] = $this->pendingRead; $this->pendingRead = null; $this->readSeq = 0; } // TLS 1.3:互換性のためのChangeCipherSpecは無視する return $this->readRecord(); } if ($this->readAead === null) { if ($type === self::APPDATA) throw new TlsException('TLS:暗号化前のアプリケーションデータ', self::ALERT_UNEXPECTED_MESSAGE); if ($len > self::MAX_PLAIN) throw new TlsException('TLS:レコードが大き過ぎます', self::ALERT_RECORD_OVERFLOW); return [$type, $body]; } if ($this->version === 0x0303) return $this->decrypt12($type, $body); if ($type !== self::APPDATA) { if ($type === self::ALERT) return [$type, $body]; // 暗号化前のアラート(相手がこちらの鍵を持つ前に失敗した) throw new TlsException('TLS:暗号化されていないレコード', self::ALERT_UNEXPECTED_MESSAGE); } $plain = $this->readAead->open($this->nonce($this->readIv, $this->readSeq), $body, $header); if ($plain === null) throw new TlsException('TLS:レコードの復号に失敗(改ざん?)', self::ALERT_BAD_RECORD_MAC); $this->readSeq++; $plain = rtrim($plain, "\0"); if ($plain === '') throw new TlsException('TLS:レコードの種類がありません', self::ALERT_UNEXPECTED_MESSAGE); $inner = ord(substr($plain, -1)); $plain = substr($plain, 0, -1); if (strlen($plain) > self::MAX_PLAIN) throw new TlsException('TLS:レコードが大き過ぎます', self::ALERT_RECORD_OVERFLOW); return [$inner, $plain]; } /** TLS 1.2のレコードを復号する */ private function decrypt12(int $type, string $body): array { $seq = pack('J', $this->readSeq); if ($this->readAead instanceof Aes) { if (strlen($body) < 24) throw new TlsException('TLS:レコードが短過ぎます', self::ALERT_BAD_RECORD_MAC); $ct = substr($body, 8); $plain = $this->readAead->open($this->readIv.substr($body, 0, 8), $ct, $seq.chr($type)."\x03\x03".pack('n', strlen($ct) - 16)); } else { if (strlen($body) < 16) throw new TlsException('TLS:レコードが短過ぎます', self::ALERT_BAD_RECORD_MAC); $plain = $this->readAead->open($this->nonce($this->readIv, $this->readSeq), $body, $seq.chr($type)."\x03\x03".pack('n', strlen($body) - 16)); } if ($plain === null) throw new TlsException('TLS:レコードの復号に失敗(改ざん?)', self::ALERT_BAD_RECORD_MAC); $this->readSeq++; if (strlen($plain) > self::MAX_PLAIN) throw new TlsException('TLS:レコードが大き過ぎます', self::ALERT_RECORD_OVERFLOW); return [$type, $plain]; } /** * ハンドシェイクのメッセージを1つ読む * * @return array [種類, 中身] * @throws TlsException */ private function readHandshake(): array { while (true) { if (strlen($this->hs) >= 4) { $len = unpack('N', "\0".substr($this->hs, 1, 3))[1]; if ($len > 0x10000 * 4) throw new TlsException('TLS:ハンドシェイクのメッセージが大き過ぎます', self::ALERT_DECODE_ERROR); if (strlen($this->hs) >= 4 + $len) { $type = ord($this->hs[0]); $body = substr($this->hs, 4, $len); $this->hs = (string)substr($this->hs, 4 + $len); return [$type, $body]; } } $rec = $this->readRecord(); if ($rec === null) { throw new TlsException($this->timedOut ? 'TLS:ハンドシェイクがタイムアウトしました' : 'TLS:ハンドシェイク中に接続が切れました', self::ALERT_INTERNAL_ERROR, true); } [$type, $body] = $rec; if ($type === self::ALERT) $this->alert($body); if ($type !== self::HANDSHAKE || $body === '') throw new TlsException('TLS:ハンドシェイクを期待しました', self::ALERT_UNEXPECTED_MESSAGE); $this->hs .= $body; } } /** 接続後のレコードを1つ処理する(データが来たらtrue、タイムアウト・終わりならfalse) */ private function readRecordInto(): bool { try { $rec = $this->readRecord(); if ($rec === null) return false; [$type, $body] = $rec; if ($type === self::APPDATA) { $this->app .= $body; return true; } if ($type === self::ALERT) { $this->alert($body); return true; } // 接続後のハンドシェイク $this->hs .= $body; while (strlen($this->hs) >= 4) { $len = unpack('N', "\0".substr($this->hs, 1, 3))[1]; if (strlen($this->hs) < 4 + $len) break; $mt = ord($this->hs[0]); $mb = substr($this->hs, 4, $len); $this->hs = (string)substr($this->hs, 4 + $len); if ($this->version === 0x0303) { // TLS 1.2:再ネゴシエーションは断る(警告のno_renegotiation) if (($mt === self::HELLO_REQUEST && !$this->server) || ($mt === self::CLIENT_HELLO && $this->server)) { $this->sendAlert(self::ALERT_NO_RENEGOTIATION, 1); continue; } throw new TlsException('TLS:想定外のハンドシェイクのメッセージ', self::ALERT_UNEXPECTED_MESSAGE); } if ($mt === self::NEW_SESSION_TICKET && !$this->server) continue; // 再開は未対応 if ($mt === self::KEY_UPDATE && strlen($mb) === 1 && ord($mb) <= 1) { if ($this->hs !== '') throw new TlsException('TLS:KeyUpdateの後に余計なデータ', self::ALERT_UNEXPECTED_MESSAGE); $len2 = strlen(hash($this->hash, '', true)); $this->setReadKey($this->expandLabel($this->readSecret, 'traffic upd', '', $len2)); if (ord($mb) === 1) { $this->writeRecord(self::HANDSHAKE, $this->msg(self::KEY_UPDATE, "\x00")); $this->setWriteKey($this->expandLabel($this->writeSecret, 'traffic upd', '', $len2)); } continue; } throw new TlsException('TLS:想定外のハンドシェイクのメッセージ', self::ALERT_UNEXPECTED_MESSAGE); } return true; } catch (TlsException $e) { if (!$e->received) $this->sendAlert($e->alert); $this->error = $e; return false; } } /** * 鍵を更新する(KeyUpdate) * * @param bool $requestPeer 相手にも更新を求めるかどうか * @return void */ public function keyUpdate(bool $requestPeer = false): void { if ($this->version !== 0x0304) throw new \LogicException('TLS:KeyUpdateはTLS 1.3だけ'); $this->writeRecord(self::HANDSHAKE, $this->msg(self::KEY_UPDATE, $requestPeer ? "\x01" : "\x00")); $this->setWriteKey($this->expandLabel($this->writeSecret, 'traffic upd', '', strlen(hash($this->hash, '', true)))); } /** 受け取ったアラート */ private function alert(string $body): void { if (strlen($body) !== 2) throw new TlsException('TLS:不正なアラート', self::ALERT_DECODE_ERROR); $code = ord($body[1]); if ($code === self::ALERT_CLOSE_NOTIFY) { $this->peerClosed = true; return; } if ($code === self::ALERT_USER_CANCELED) return; // TLS 1.2の警告(no_renegotiation等)は無視する if ($this->version === 0x0303 && ord($body[0]) === 1) return; if ($code === self::ALERT_PROTOCOL_VERSION && !$this->established) { throw new TlsException('TLS:相手が対応しているバージョンがありません(protocol_version)', $code, true); } throw new TlsException("TLS:相手からのアラート:{$code}(".self::alertName($code).')', $code, true); } /** アラートの名前 */ public static function alertName(int $code): string { static $names = null; if ($names === null) { $names = []; foreach ((new \ReflectionClass(self::class))->getConstants() as $k => $v) { if (str_starts_with($k, 'ALERT_')) $names[$v] = strtolower(substr($k, 6)); } } return $names[$code] ?? '不明'; } /////////////// // 符号化 private function msg(int $type, string $body): string { return chr($type).substr(pack('N', strlen($body)), 1).$body; } private function ext(int $type, string $data): string { return pack('n', $type).$this->vec2($data, 2); } /** 長さ($bytesバイト)付きのデータ */ private function vec2(string $data, int $bytes): string { return substr(pack('N', strlen($data)), 4 - $bytes).$data; } /** 長さ付きのデータを読む */ private function vec(string $b, int $pos, int $bytes, ?int &$end = null): string { if ($pos + $bytes > strlen($b)) throw new TlsException('TLS:メッセージが短過ぎます', self::ALERT_DECODE_ERROR); $len = unpack('N', str_repeat("\0", 4 - $bytes).substr($b, $pos, $bytes))[1]; if ($pos + $bytes + $len > strlen($b)) throw new TlsException('TLS:メッセージが短過ぎます', self::ALERT_DECODE_ERROR); $end = $pos + $bytes + $len; return (string)substr($b, $pos + $bytes, $len); } private function u16(string $b, int $pos): int { if ($pos + 2 > strlen($b)) throw new TlsException('TLS:メッセージが短過ぎます', self::ALERT_DECODE_ERROR); return unpack('n', $b, $pos)[1]; } /** 拡張の一覧 => [種類 => 中身] */ private function parseExtensions(string $data): array { $out = []; for ($p = 0; $p < strlen($data); ) { $t = $this->u16($data, $p); $p += 2; if (isset($out[$t])) throw new TlsException('TLS:重複した拡張', self::ALERT_ILLEGAL_PARAMETER); $out[$t] = $this->vec($data, $p, 2, $p); } return $out; } }