true, 'verboseOutput' => true ]); // 記録したデータを返し、書いたデータを記録するソケット(OpenSSLとの記録を再現する為) final class LibTlsReplay { public $context; public static string $in = ''; public static string $out = ''; private int $pos = 0; public function stream_open(string $path, string $mode, int $options, ?string &$opened): bool { $this->pos = 0; return true; } public function stream_read(int $n): string { $d = (string)substr(self::$in, $this->pos, $n); $this->pos += strlen($d); return $d; } public function stream_write(string $d): int { self::$out .= $d; return strlen($d); } public function stream_eof(): bool { return $this->pos >= strlen(self::$in); } public function stream_close(): void {} public function stream_set_option(int $o, int $a, ?int $b): bool { return false; } } $catch = function(callable $fn): ?\Throwable { try { $fn(); } catch (\Throwable $e) { return $e; } return null; }; $test->describe('TLS 1.3・1.2(PHP純正のクライアントとサーバー)', function($test) use ($catch): void { $ec = TestCert::get('ec'); $rsa = TestCert::get('rsa'); $self = TestCert::get('self'); $srv = MockServer::start('tls-server.php', [$ec['cert'], $ec['key'], 'h2,http/1.1']); $srvRsa = MockServer::start('tls-server.php', [$rsa['cert'], $rsa['key']]); $srvP384 = MockServer::start('tls-server.php', [$ec['cert'], $ec['key'], '', 'P-384']); $srvSelf = MockServer::start('tls-server.php', [$self['cert'], $self['key']]); if (!$srv || !$srvRsa || !$srvP384 || !$srvSelf) { $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効?)'); return; } $open = fn(MockServer $s, array $opt = []) => Tls::connect('127.0.0.1', $s->port, $opt + ['peer_name' => 'localhost', 'cafile' => $ec['ca']], 10); $info = function($s): array { fwrite($s, "INFO\n"); return json_decode((string)fgets($s), true); }; $test->it('接続して送受信出来るはず', function($test) use ($srv, $open): void { $s = $open($srv); fwrite($s, "こんにちは\n"); $test->assertSame("こんにちは\n", fgets($s)); $i = Tls::info($s); $test->assertSame('TLSv1.3', $i['version']); $test->assertSame('TLS_CHACHA20_POLY1305_SHA256', $i['cipher']); $test->assertSame('x25519', $i['group']); $test->assertSame('CN=localhost', $i['peer_certificates'][0]->subject); $test->assertFalse(feof($s)); fclose($s); $test->assertNull(Tls::info(fopen('php://memory', 'r'))); }); $test->it('全部の暗号スイートとグループで繋がるはず', function($test) use ($srv, $open, $info): void { foreach (Connection::SUITES as $id => [, , , $name]) { $s = $open($srv, ['ciphers' => [$id]]); $test->assertSame($name, $info($s)['cipher'], $name); fclose($s); } foreach (array_keys(Connection::GROUPS) as $g) { $s = $open($srv, ['groups' => [$g]]); $test->assertSame($g, $info($s)['group'], $g); fclose($s); } }); $test->it('サーバーが別のグループを求めたら(HelloRetryRequest)送り直すはず', function($test) use ($srvP384, $open, $info, $catch): void { $s = $open($srvP384); $test->assertSame('P-384', Tls::info($s)['group']); $test->assertSame('P-384', $info($s)['group']); fwrite($s, "after hrr\n"); $test->assertSame("after hrr\n", fgets($s)); fclose($s); $e = $catch(fn() => $open($srvP384, ['groups' => ['x25519', 'P-256']])); $test->assertTrue($e instanceof TlsException); $test->assertSame(Connection::ALERT_HANDSHAKE_FAILURE, $e->alert); }); $test->it('RSAの証明書(RSA-PSSの署名)でも繋がるはず', function($test) use ($srvRsa, $open): void { $s = $open($srvRsa); fwrite($s, "rsa\n"); $test->assertSame("rsa\n", fgets($s)); $test->assertSame('localhost', Tls::info($s)['peer_certificates'][0]->subjectAttr('CN')[0]); fclose($s); }); $test->it('ALPNとSNIを送るはず', function($test) use ($srv, $open, $info, $catch): void { $s = $open($srv, ['alpn' => ['http/1.1']]); $test->assertSame('http/1.1', Tls::info($s)['alpn']); $i = $info($s); $test->assertSame('http/1.1', $i['alpn']); $test->assertSame('localhost', $i['server_name']); fclose($s); // IPアドレスにはSNIを送らない $s = $open($srv, ['peer_name' => '127.0.0.1']); $test->assertSame('', $info($s)['server_name']); fclose($s); $e = $catch(fn() => $open($srv, ['alpn' => ['spdy/3']])); $test->assertTrue($e instanceof TlsException); $test->assertSame(Connection::ALERT_NO_APPLICATION_PROTOCOL, $e->alert); }); $test->it('大きいデータを送受信出来るはず(複数のレコード)', function($test) use ($srv, $open): void { $s = $open($srv); fwrite($s, "BIG 300000\n"); $got = ''; while (strlen($got) < 300000 && !feof($s)) $got .= fread($s, 65536); $test->assertSame(str_repeat('x', 300000), $got); $line = str_repeat('あいうえお', 20000)."\n"; fwrite($s, $line); $test->assertSame($line, stream_get_line($s, 1000000, "\n")."\n"); fclose($s); }); $test->it('鍵を更新(KeyUpdate)しても続けられるはず', function($test) use ($srv, $open): void { $s = $open($srv); fwrite($s, "KEYUPDATE\n"); $test->assertSame("updated\n", fgets($s)); fwrite($s, "after\n"); $test->assertSame("after\n", fgets($s)); Stream::connection($s)->keyUpdate(true); fwrite($s, "mine\n"); $test->assertSame("mine\n", fgets($s)); fclose($s); }); $test->it('相手が閉じたら終わりになるはず(close_notify、切断)', function($test) use ($srv, $open): void { $s = $open($srv); fwrite($s, "CLOSE\n"); $test->assertSame('', (string)fread($s, 10)); $test->assertTrue(feof($s)); $test->assertNull(Tls::error($s)); fclose($s); $s = $open($srv); fwrite($s, "DROP\n"); $test->assertSame('', (string)fread($s, 10)); $test->assertTrue(feof($s)); fclose($s); }); $test->it('壊れたレコードを見つけるはず(bad_record_mac)', function($test) use ($srv, $open): void { $s = $open($srv); fwrite($s, "GARBAGE\n"); $test->assertSame('', (string)fread($s, 10)); $test->assertTrue(feof($s)); $test->assertSame(Connection::ALERT_BAD_RECORD_MAC, Tls::error($s)?->alert); fclose($s); }); $test->it('証明書を確認するはず', function($test) use ($srv, $srvSelf, $open, $catch, $self): void { $e = $catch(fn() => $open($srv, ['peer_name' => 'example.com'])); $test->assertTrue($e instanceof TlsException); $test->assertStringContains('ホスト名が一致しません', $e->getMessage()); $test->assertSame(Connection::ALERT_BAD_CERTIFICATE, $e->alert); $e = $catch(fn() => $open($srvSelf)); $test->assertStringContains('自己署名', $e->getMessage()); $test->assertSame(Connection::ALERT_UNKNOWN_CA, $e->alert); // 自己署名を許す、確認しない、ホスト名だけ確認しない fclose($open($srvSelf, ['allow_self_signed' => true])); fclose($open($srvSelf, ['verify_peer' => false])); fclose($open($srv, ['peer_name' => 'example.com', 'verify_peer_name' => false])); // 信頼する証明書として渡す fclose($open($srvSelf, ['cafile' => $self['cert']])); }); $test->it('タイムアウト・接続出来ない場合は例外のはず', function($test) use ($catch): void { // 接続は受け付けるが、何も返さないサーバー $quiet = stream_socket_server('tcp://127.0.0.1:0'); $port = (int)substr(strrchr(stream_socket_get_name($quiet, false), ':'), 1); $t = microtime(true); $e = $catch(fn() => Tls::connect('127.0.0.1', $port, ['peer_name' => 'localhost'], 1)); $test->assertTrue($e instanceof TlsException); $test->assertStringContains('タイムアウト', $e->getMessage()); $test->assertTrue(microtime(true) - $t < 5); fclose($quiet); $e = $catch(fn() => Tls::connect('127.0.0.1', $port, [], 1)); $test->assertStringContains('接続出来ません', $e->getMessage()); }); $test->it('サーバーはTLS 1.1以前と安全でないTLS 1.2のClientHelloを拒否するはず', function($test) use ($srv): void { // 拡張の無いClientHelloを送って、アラートを読む $alert = function(string $version, string $suites) use ($srv): string { $c = stream_socket_client("tcp://127.0.0.1:{$srv->port}", $errno, $errstr, 5); stream_set_timeout($c, 5); $body = $version.random_bytes(32)."\x00".pack('n', strlen($suites)).$suites."\x01\x00"; $hs = "\x01".substr(pack('N', strlen($body)), 1).$body; fwrite($c, "\x16\x03\x01".pack('n', strlen($hs)).$hs); $res = (string)fread($c, 7); fclose($c); return $res; }; // TLS 1.1:protocol_version $test->assertSame("\x15\x03\x03\x00\x02\x02\x46", $alert("\x03\x02", "\xC0\x2F")); // TLS 1.2でもsignature_algorithmsが無い(SHA-1):handshake_failure $test->assertSame("\x15\x03\x03\x00\x02\x02\x28", $alert("\x03\x03", "\xC0\x2B")); // CBC・RSAの鍵交換だけ:handshake_failure $test->assertSame("\x15\x03\x03\x00\x02\x02\x28", $alert("\x03\x03", "\x00\x2F\x00\x35\xC0\x13")); }); $test->it('STARTTLSの様に、既に繋がったソケットでTLSを開始出来るはず', function($test) use ($srv, $ec): void { $c = stream_socket_client("tcp://127.0.0.1:{$srv->port}", $errno, $errstr, 5); stream_set_timeout($c, 5); $s = Tls::enable($c, ['peer_name' => 'localhost', 'cafile' => $ec['ca']]); fwrite($s, "upgraded\n"); $test->assertSame("upgraded\n", fgets($s)); fclose($s); $test->assertFalse(is_resource($c)); }); $test->it('TLS 1.2の全部の暗号スイートで繋がるはず', function($test) use ($srv, $srvRsa, $open, $info, $catch): void { foreach (Connection::SUITES12 as $id => [, , , $name, $keyType]) { $s = $open($keyType === 'ec' ? $srv : $srvRsa, ['max_version' => '1.2', 'ciphers' => [$id]]); $i = $info($s); $test->assertSame('TLSv1.2', $i['version'], $name); $test->assertSame($name, $i['cipher']); $test->assertSame($name, Tls::info($s)['cipher']); fwrite($s, "BIG 40000\n"); $got = ''; while (strlen($got) < 40000 && !feof($s)) $got .= fread($s, 65536); $test->assertSame(40000, strlen($got), $name); fclose($s); } foreach (array_keys(Connection::GROUPS) as $g) { $s = $open($srv, ['max_version' => '1.2', 'groups' => [$g]]); $test->assertSame($g, $info($s)['group'], $g); fclose($s); } $s = $open($srv, ['max_version' => '1.2', 'alpn' => ['http/1.1']]); $test->assertSame('http/1.1', $info($s)['alpn']); fclose($s); // サーバーの鍵(EC)に合う暗号スイートが無い $e = $catch(fn() => $open($srv, ['max_version' => '1.2', 'ciphers' => [0xC02F, 0xCCA8]])); $test->assertTrue($e instanceof TlsException); $test->assertSame(Connection::ALERT_HANDSHAKE_FAILURE, $e->alert); }); $test->it('送ったデータが1つのレコードに入っても、全部届くはず(両方のバージョン)', function($test) use ($srv, $open): void { foreach (['1.2', '1.3'] as $v) { $s = $open($srv, ['max_version' => $v]); fwrite($s, "一行目\n二行目\n三行目\n"); $test->assertSame("一行目\n", fgets($s), $v); $test->assertSame("二行目\n", fgets($s), $v); $test->assertSame("三行目\n", fgets($s), $v); fclose($s); } }); $test->it('バージョンを選べるはず(min_version・max_version)', function($test) use ($srv, $ec, $open, $info, $catch): void { $old = MockServer::start('tls-server.php', [$ec['cert'], $ec['key'], '', '', '', '1.2']); $s = $open($old); $test->assertSame('TLSv1.2', $info($s)['version']); fclose($s); $e = $catch(fn() => $open($old, ['min_version' => '1.3'])); $test->assertTrue($e instanceof TlsException); $test->assertSame(Connection::ALERT_PROTOCOL_VERSION, $e->alert); $old->stop(); $test->assertThrows(fn() => $open($srv, ['min_version' => '1.1']), \InvalidArgumentException::class); $test->assertThrows(fn() => $open($srv, ['min_version' => '1.3', 'max_version' => '1.2']), \InvalidArgumentException::class); }); $test->it('TLS 1.3を使えるのに1.2に下げられたら(ダウングレード)拒否するはず', function($test) use ($ec, $open, $info, $catch): void { $mitm = MockServer::start('tls-server.php', [$ec['cert'], $ec['key'], '', '', 'downgrade']); $e = $catch(fn() => $open($mitm)); $test->assertTrue($e instanceof TlsException); $test->assertStringContains('ダウングレード', $e->getMessage()); $test->assertSame(Connection::ALERT_ILLEGAL_PARAMETER, $e->alert); // TLS 1.2だけを求めたなら、印があっても良い $s = $open($mitm, ['max_version' => '1.2']); $test->assertSame('TLSv1.2', $info($s)['version']); fclose($s); $mitm->stop(); }); $test->it('TLS 1.2の再ネゴシエーションを断って、続けられるはず', function($test) use ($srv, $open, $catch): void { $s = $open($srv, ['max_version' => '1.2']); fwrite($s, "HELLOREQ\n"); $test->assertSame("asked\n", fgets($s)); fwrite($s, "まだ使える\n"); $test->assertSame("まだ使える\n", fgets($s)); $test->assertThrows(fn() => Stream::connection($s)->keyUpdate(), \LogicException::class); fclose($s); }); $test->it('アラートの名前が分かるはず', function($test): void { $test->assertSame('bad_record_mac', Connection::alertName(20)); $test->assertSame('protocol_version', Connection::alertName(70)); $test->assertSame('不明', Connection::alertName(255)); }); $test->it('間違ったFinished・署名を送るサーバーを拒否するはず(両方のバージョン)', function($test) use ($ec, $catch): void { $cases = [ ['bad-finished', '1.3', 'Finishedが不正'], ['bad-cv', '1.3', 'CertificateVerifyの署名が不正'], ['bad-finished', '1.2', 'Finishedが不正'], ['bad-cv', '1.2', 'ServerKeyExchangeの署名が不正'], ]; foreach ($cases as [$mode, $v, $msg]) { $evil = MockServer::start('tls-server.php', [$ec['cert'], $ec['key'], '', '', $mode]); $e = $catch(fn() => Tls::connect('127.0.0.1', $evil->port, ['peer_name' => 'localhost', 'cafile' => $ec['ca'], 'max_version' => $v], 10)); $evil->stop(); $test->assertTrue($e instanceof TlsException, "{$mode} {$v}"); $test->assertStringContains($msg, $e->getMessage()); $test->assertSame(Connection::ALERT_DECRYPT_ERROR, $e->alert); } }); foreach ([$srv, $srvRsa, $srvP384, $srvSelf] as $s) $s->stop(); }); $test->describe('TLS 1.3・1.2(OpenSSLとの記録を再現する)', function($test) use ($catch): void { if (!in_array('lbreplay', stream_get_wrappers(), true)) stream_wrapper_register('lbreplay', LibTlsReplay::class); $pki = __DIR__.'/Helper/pki/'; // 記録と同じ乱数・鍵・時間でハンドシェイクする $replay = function(string $name, ?callable $tamper = null, array $extra = []) use ($pki): array { $rec = json_decode((string)file_get_contents($pki."tls-{$name}.json"), true); $in = hex2bin($rec['s2c']); LibTlsReplay::$in = $tamper ? $tamper($in) : $in; LibTlsReplay::$out = ''; $opt = $extra + [ 'peer_name' => 'www.example.com', 'cafile' => $pki.'root.pem', '_time' => $rec['time'], '_random' => hex2bin($rec['random']), '_session_id' => hex2bin($rec['session_id']), '_keys' => [ 'x25519' => hex2bin($rec['x25519']), 'P-256' => (string)file_get_contents($pki.'p256.key'), 'P-384' => (string)file_get_contents($pki.'p384.key'), ], ]; $s = Tls::enable(fopen('lbreplay://x', 'r+'), $opt); $replies = []; foreach ($rec['lines'] as $l) { fwrite($s, $l."\n"); $replies[] = bin2hex(rtrim((string)fgets($s), "\n")); } $info = Tls::info($s); $error = Tls::error($s); fclose($s); return [$rec, $info, $replies, $error]; }; // レコードの位置 $records = function(string $data): array { $out = []; for ($p = 0; $p + 5 <= strlen($data); $p += 5 + unpack('n', $data, $p + 3)[1]) $out[] = [$p, ord($data[$p])]; return $out; }; $test->it('OpenSSLのサーバーとの通信を同じバイトで再現するはず(AES-128-GCM、x25519)', function($test) use ($replay): void { [$rec, $info, $replies] = $replay('aes'); $test->assertSame($rec['cipher'], $info['cipher']); $test->assertSame('x25519', $info['group']); $test->assertSame($rec['replies_hex'], $replies); $test->assertSame($rec['c2s'], bin2hex(LibTlsReplay::$out)); }); $test->it('HelloRetryRequestの後の通信も再現するはず(ChaCha20-Poly1305、P-256)', function($test) use ($replay): void { [$rec, $info, $replies] = $replay('hrr'); $test->assertSame('TLS_CHACHA20_POLY1305_SHA256', $info['cipher']); $test->assertSame('P-256', $info['group']); $test->assertSame($rec['replies_hex'], $replies); $test->assertSame($rec['c2s'], bin2hex(LibTlsReplay::$out)); }); $test->it('改ざんされたハンドシェイク・データを見つけるはず', function($test) use ($replay, $records, $catch): void { // 暗号化されたハンドシェイク(ServerHello・ChangeCipherSpecの次のレコード) $e = $catch(fn() => $replay('aes', function(string $in) use ($records): string { $r = $records($in); $in[$r[2][0] + 20] = chr(ord($in[$r[2][0] + 20]) ^ 1); return $in; })); $test->assertTrue($e instanceof TlsException); $test->assertSame(Connection::ALERT_BAD_RECORD_MAC, $e->alert); // 最後の返事 [$rec, , $replies, $error] = $replay('aes', function(string $in) use ($records): string { $r = $records($in); $last = end($r)[0]; $in[$last + 10] = chr(ord($in[$last + 10]) ^ 1); return $in; }); $test->assertSame(array_slice($rec['replies_hex'], 0, 2), array_slice($replies, 0, 2)); $test->assertSame('', $replies[2]); $test->assertSame(Connection::ALERT_BAD_RECORD_MAC, $error?->alert); }); $test->it('TLS 1.2の通信も同じバイトで再現するはず(PKCS#1・EMSなし、ECDSA・P-384、RSA-PSS)', function($test) use ($replay): void { $expect = [ '12-rsa-pkcs1' => ['TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', 'x25519'], '12-ecdsa' => ['TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256', 'P-384'], '12-rsa-pss' => ['TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256', 'x25519'], ]; foreach ($expect as $name => [$cipher, $group]) { [$rec, $info, $replies] = $replay($name); $test->assertSame('TLSv1.2', $info['version'], $name); $test->assertSame($cipher, $info['cipher'], $name); $test->assertSame($group, $info['group'], $name); $test->assertSame($rec['replies_hex'], $replies, $name); $test->assertSame($rec['c2s'], bin2hex(LibTlsReplay::$out), $name); } }); $test->it('TLS 1.2の改ざん(ServerKeyExchangeの署名、データ)を見つけるはず', function($test) use ($replay, $records, $catch): void { // ServerKeyExchangeは暗号化されていない:x25519の点(32バイト)の後の署名を壊す $e = $catch(fn() => $replay('12-rsa-pss', function(string $in): string { $p = strpos($in, "\x03\x00\x1d\x20"); $in[$p + 4 + 32 + 4 + 10] = chr(ord($in[$p + 4 + 32 + 4 + 10]) ^ 1); return $in; })); $test->assertTrue($e instanceof TlsException); $test->assertStringContains('ServerKeyExchangeの署名が不正', $e->getMessage()); // 送っていないグループ(x25519をsecp521r1に書き換える) $e = $catch(fn() => $replay('12-rsa-pss', function(string $in): string { $p = strpos($in, "\x03\x00\x1d\x20"); $in[$p + 2] = "\x19"; return $in; })); $test->assertStringContains('送っていない鍵交換のグループ', $e?->getMessage() ?? ''); $test->assertSame(Connection::ALERT_ILLEGAL_PARAMETER, $e->alert); // RSAの証明書なのにECDSAの暗号スイート(ServerHelloのC02FをC02Bに書き換える) $e = $catch(fn() => $replay('12-rsa-pss', function(string $in): string { $sh = 5 + 4 + 2 + 32; $p = $sh + 1 + ord($in[$sh]); $in[$p + 1] = "\x2B"; return $in; })); $test->assertStringContains('証明書の鍵が暗号スイートと合いません', $e?->getMessage() ?? ''); // 早過ぎるChangeCipherSpec(ServerHelloの直後、CVE-2014-0224の様な攻撃) $e = $catch(fn() => $replay('12-rsa-pss', function(string $in) use ($records): string { $r = $records($in); $first = 5 + unpack('n', $in, 3)[1]; return substr($in, 0, $first)."\x14\x03\x03\x00\x01\x01".substr($in, $first); })); $test->assertStringContains('想定外のChangeCipherSpec', $e?->getMessage() ?? ''); // ChangeCipherSpecと暗号化されたFinishedを、暗号化されていないFinishedに替える $e = $catch(fn() => $replay('12-rsa-pss', function(string $in) use ($records): string { $r = $records($in); foreach ($r as $i => [$pos, $type]) { if ($type !== 20) continue; $end = $r[$i + 2][0] ?? strlen($in); return substr($in, 0, $pos)."\x16\x03\x03\x00\x10\x14\x00\x00\x0c".str_repeat("\0", 12).substr($in, $end); } return $in; })); $test->assertStringContains('Finishedを期待しました', $e?->getMessage() ?? ''); // 最後の返事 [$rec, , $replies, $error] = $replay('12-ecdsa', function(string $in) use ($records): string { $r = $records($in); $last = end($r)[0]; $in[$last + 10] = chr(ord($in[$last + 10]) ^ 1); return $in; }); $test->assertSame('', $replies[2]); $test->assertSame(Connection::ALERT_BAD_RECORD_MAC, $error?->alert); }); $test->it('記録の証明書の有効期限・CAを確認するはず', function($test) use ($replay, $catch): void { $e = $catch(fn() => $replay('aes', null, ['_time' => time() + 86400 * 365 * 5])); $test->assertSame(Connection::ALERT_CERTIFICATE_EXPIRED, $e?->alert); // ホスト名はSNIに入る(ClientHelloが変わる)ので、信頼するCAを変える $e = $catch(fn() => $replay('aes', null, ['cafile' => TestCert::get('self')['cert']])); $test->assertSame(Connection::ALERT_UNKNOWN_CA, $e?->alert); }); }); $test->printSummary();