<!-- task=tls_support plan=0 tag=TLS -->

# Python·TypeScript·Kotlin TLS 지원 추가

## 이 파일을 읽는 구현 에이전트에게

각 항목의 체크리스트를 완료 표시하고, 중간 검증 명령을 실행한 뒤 출력을 CODE_REVIEW.md의 `검증 결과` 섹션에 붙여 넣는다. 최종 검증까지 완료한 후 CODE_REVIEW.md의 각 항목을 `[x]`로 체크한다. **의존 관계 및 구현 순서** 섹션에 따라 항목 순서를 지킨다.

## 배경

Go와 Dart는 TLS TCP와 WSS를 모두 지원하며 crosstest에서 검증된다. Python과 TypeScript는 구현 완료(`Available`) 상태이지만 TLS 코드가 전혀 없다. Kotlin은 WSS 클라이언트(`dialWss`)만 지원하고 TCP TLS가 없다. 이번 작업으로 세 언어에 TLS 지원을 추가해 Go/Dart와 동등한 수준의 TLS 커버리지를 갖춘다.

**테스트 인증서 전략:** `dart/test/certs/server.crt`(CN=localhost, SAN IP:127.0.0.1)와 `server.key`(PKCS#8 RSA)가 이미 존재한다. 각 언어의 테스트 디렉토리에 복사해 사용한다.

---

## [TLS-1] Python TLS TCP

### 문제

`python/toki_socket/tcp_client.py` — TLS 연결 함수 없음.
`python/toki_socket/tcp_server.py` — `TcpServer.__init__`와 `start()`에 `ssl` 파라미터 없음.

### 해결 방법

**`tcp_client.py`:** `connect_tcp_tls()` 함수 추가. `asyncio.open_connection(ssl=ssl_context)` 사용.

```python
# 추가 import
import ssl

async def connect_tcp_tls(
    host: str,
    port: int,
    ssl_context: ssl.SSLContext,
    interval_sec: int,
    wait_sec: int,
    parser_map: ParserMap,
) -> TcpClient:
    reader, writer = await asyncio.open_connection(host, port, ssl=ssl_context)
    return TcpClient(reader, writer, interval_sec, wait_sec, parser_map)
```

**`tcp_server.py`:** `TcpServer.__init__`에 `ssl_context: ssl.SSLContext | None = None` 파라미터 추가, `start()`에서 `asyncio.start_server(ssl=self._ssl_context)` 전달.

**Before (`tcp_server.py:13-18`):**
```python
class TcpServer:
    def __init__(
        self,
        host: str,
        port: int,
        new_client: Callable[[asyncio.StreamReader, asyncio.StreamWriter], TcpClient] | None = None,
        interval_sec: int = 0,
        wait_sec: int = 0,
        parser_map: ParserMap | None = None,
    ) -> None:
```

**After:**
```python
class TcpServer:
    def __init__(
        self,
        host: str,
        port: int,
        new_client: Callable[[asyncio.StreamReader, asyncio.StreamWriter], TcpClient] | None = None,
        interval_sec: int = 0,
        wait_sec: int = 0,
        parser_map: ParserMap | None = None,
        ssl_context: ssl.SSLContext | None = None,
    ) -> None:
```

그리고 `__init__` 바디에 `self._ssl_context = ssl_context` 추가.

**Before (`tcp_server.py` `start()`):**
```python
    async def start(self) -> None:
        self._server = await asyncio.start_server(self._handle_connection, self._host, self._port)
```

**After:**
```python
    async def start(self) -> None:
        self._server = await asyncio.start_server(
            self._handle_connection, self._host, self._port, ssl=self._ssl_context
        )
```

### 수정 파일 및 체크리스트

- `python/toki_socket/tcp_client.py`
  - [x] `import ssl` 추가
  - [x] `connect_tcp_tls()` 함수 추가
- `python/toki_socket/tcp_server.py`
  - [x] `import ssl` 추가
  - [x] `__init__` 파라미터에 `ssl_context: ssl.SSLContext | None = None` 추가
  - [x] `__init__` 바디에 `self._ssl_context = ssl_context` 추가
  - [x] `start()` 바디에 `ssl=self._ssl_context` 전달

### 테스트 작성

파일: `python/test/test_tcp.py`
- 테스트 이름: `test_tcp_tls_send_receive`, `test_tcp_tls_request_response`
- 인증서: `python/test/certs/server.crt`, `python/test/certs/server.key`를 복사 (dart/test/certs/ 에서)

```python
def _make_server_ssl_context() -> ssl.SSLContext:
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    certs_dir = Path(__file__).parent / "certs"
    ctx.load_cert_chain(certs_dir / "server.crt", certs_dir / "server.key")
    return ctx

def _make_client_ssl_context() -> ssl.SSLContext:
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    certs_dir = Path(__file__).parent / "certs"
    ctx.load_verify_locations(certs_dir / "server.crt")
    ctx.check_hostname = False
    return ctx
```

### 중간 검증

```
$ cd python && python3 -m py_compile toki_socket/tcp_client.py toki_socket/tcp_server.py
(exit 0)

$ cd python && python3 -m pytest test/test_tcp.py -q
(전체 PASS)
```

---

## [TLS-2] Python WSS

### 문제

`python/toki_socket/ws_client.py` — WSS 연결 함수 없음.
`python/toki_socket/ws_server.py` — `WsServer.__init__`와 `start()`에 `ssl` 파라미터 없음.

### 해결 방법

**`ws_client.py`:** `connect_wss()` 함수 추가. `ws_connect("wss://...", ssl=ssl_context)`.

```python
import ssl

async def connect_wss(
    host: str,
    port: int,
    path: str,
    ssl_context: ssl.SSLContext,
    interval_sec: int,
    wait_sec: int,
    parser_map: ParserMap,
) -> WsClient:
    uri = f"wss://{host}:{port}{path}"
    ws = await ws_connect(uri, ssl=ssl_context)
    return WsClient(ws, interval_sec, wait_sec, parser_map)
```

**`ws_server.py`:** `WsServer.__init__`에 `ssl_context: ssl.SSLContext | None = None` 파라미터 추가, `start()`에서 `ws_serve(handler, ssl=self._ssl_context)` 전달.

기존 `start()`의 `await ws_serve(handler, self._host, self._port)` 호출을 `await ws_serve(handler, self._host, self._port, ssl=self._ssl_context)`로 변경. 두 API 경로(`_NEW_WEBSOCKETS_API`, legacy) 모두 변경.

### 수정 파일 및 체크리스트

- `python/toki_socket/ws_client.py`
  - [x] `import ssl` 추가
  - [x] `connect_wss()` 함수 추가
- `python/toki_socket/ws_server.py`
  - [x] `import ssl` 추가
  - [x] `__init__` 파라미터에 `ssl_context: ssl.SSLContext | None = None` 추가
  - [x] `__init__` 바디에 `self._ssl_context = ssl_context` 추가
  - [x] `start()` — `ws_serve()` 호출에 `ssl=self._ssl_context` 추가 (new API + legacy API 두 경로 모두)

### 테스트 작성

파일: `python/test/test_ws.py`
- 테스트 이름: `test_ws_tls_send_receive`, `test_ws_tls_request_response`
- TLS-1에서 만든 헬퍼 함수 재사용 (같은 certs 디렉토리)

### 중간 검증

```
$ cd python && python3 -m py_compile toki_socket/ws_client.py toki_socket/ws_server.py
(exit 0)

$ cd python && python3 -m pytest test/test_ws.py -q
(전체 PASS)
```

---

## [TLS-3] TypeScript TLS TCP

### 문제

`typescript/src/tcp_client.ts` — TLS 연결 함수 없음.
`typescript/src/tcp_server.ts` — `TcpServer`에 TLS 옵션 없음.

### 해결 방법

**`tcp_client.ts`:** `connectTcpTls()` 추가. `tls.TLSSocket extends net.Socket`이므로 `TcpClient` 생성자에 직접 전달 가능.

```typescript
import * as tls from "node:tls";

export async function connectTcpTls(
  host: string,
  port: number,
  tlsOptions: tls.ConnectionOptions,
  intervalSec: number,
  waitSec: number,
  parserMap: ParserMap,
): Promise<TcpClient> {
  return new Promise<TcpClient>((resolve, reject) => {
    const socket = tls.connect({ ...tlsOptions, host, port });
    const onSecureConnect = () => {
      cleanup();
      resolve(new TcpClient(socket, intervalSec, waitSec, parserMap));
    };
    const onError = (err: Error) => {
      cleanup();
      reject(err);
    };
    const cleanup = () => {
      socket.off("secureConnect", onSecureConnect);
      socket.off("error", onError);
    };
    socket.once("secureConnect", onSecureConnect);
    socket.once("error", onError);
  });
}
```

**`tcp_server.ts`:** `TcpServer` 생성자에 선택적 `tlsOptions?: tls.TlsOptions` 파라미터 추가. `server` 필드 타입을 `net.Server | tls.Server`로 변경하고, 생성자에서 `tlsOptions` 유무로 분기.

**Before (`tcp_server.ts`):**
```typescript
import * as net from "node:net";
// ...
export class TcpServer {
  private readonly server: net.Server;
  // ...
  constructor(
    private readonly host: string,
    private readonly listenPort: number,
    private readonly newClient: (socket: net.Socket) => TcpClient,
  ) {
    this.server = net.createServer((socket) => {
      const client = this.newClient(socket);
      // ...
    });
  }
```

**After:**
```typescript
import * as net from "node:net";
import * as tls from "node:tls";
// ...
export class TcpServer {
  private readonly server: net.Server;
  // ...
  constructor(
    private readonly host: string,
    private readonly listenPort: number,
    private readonly newClient: (socket: net.Socket) => TcpClient,
    tlsOptions?: tls.TlsOptions,
  ) {
    const handleSocket = (socket: net.Socket): void => {
      const client = this.newClient(socket);
      this.clients.add(client);
      client.addDisconnectListener(() => { this.removeClient(client); });
      this.onClientConnected(client);
    };
    this.server = tlsOptions
      ? tls.createServer(tlsOptions, handleSocket)
      : net.createServer(handleSocket);
  }
```

기존 `net.createServer` 콜백 바디를 `handleSocket`으로 추출해서 중복 없이 두 경로에 사용한다.

**`index.ts`:** `connectTcpTls` export 추가.

### 수정 파일 및 체크리스트

- `typescript/src/tcp_client.ts`
  - [x] `import * as tls from "node:tls"` 추가
  - [x] `connectTcpTls()` 함수 추가
- `typescript/src/tcp_server.ts`
  - [x] `import * as tls from "node:tls"` 추가
  - [x] `server` 필드 타입 `net.Server | tls.Server`로 변경
  - [x] 생성자에 `tlsOptions?: tls.TlsOptions` 파라미터 추가
  - [x] 생성자 바디: `handleSocket` 헬퍼 추출 후 분기 처리
- `typescript/src/index.ts`
  - [x] `connectTcpTls` 포함 (tcp_client.ts re-export로 자동 포함되는지 확인)

### 테스트 작성

파일: `typescript/test/tcp.test.ts`
- 테스트 이름: `"connectTcpTls sends and receives TestData"`, `"TLS sendRequest/response roundtrip"`
- 인증서: `typescript/test/certs/server.crt`, `server.key`(dart/test/certs/ 에서 복사)
- 헬퍼:
  ```typescript
  import * as fs from "node:fs";
  import * as path from "node:path";
  const certsDir = path.join(import.meta.dirname, "certs");
  const cert = fs.readFileSync(path.join(certsDir, "server.crt"));
  const key = fs.readFileSync(path.join(certsDir, "server.key"));
  ```
- `TcpServer` TLS 옵션: `{ cert, key }`
- `connectTcpTls` 옵션: `{ ca: cert, servername: "localhost" }`

### 중간 검증

```
$ cd typescript && npx tsc --noEmit
(exit 0)

$ cd typescript && npx vitest run test/tcp.test.ts
(전체 PASS)
```

---

## [TLS-4] TypeScript WSS

### 문제

`typescript/src/ws_client.ts` — WSS 연결 함수 없음.
`typescript/src/ws_server.ts` — `WsServer`에 TLS 옵션 없음.

### 해결 방법

**`ws_client.ts`:** `connectWss()` 추가. `WebSocket.ClientOptions`를 사용해 CA 인증서 등을 전달.

```typescript
export async function connectWss(
  host: string,
  port: number,
  path: string,
  wsOptions: WebSocket.ClientOptions,
  intervalSec: number,
  waitSec: number,
  parserMap: ParserMap,
): Promise<WsClient> {
  return new Promise<WsClient>((resolve, reject) => {
    const ws = new WebSocket(`wss://${host}:${port}${path}`, wsOptions);
    const onOpen = () => { cleanup(); resolve(new WsClient(ws, intervalSec, waitSec, parserMap)); };
    const onError = (err: Error) => { cleanup(); reject(err); };
    const cleanup = () => { ws.off("open", onOpen); ws.off("error", onError); };
    ws.once("open", onOpen);
    ws.once("error", onError);
  });
}
```

**`ws_server.ts`:** `WsServer`에 `tlsOptions?: https.ServerOptions` 파라미터 추가. `tlsOptions` 유무로 `ws` vs `wss` 경로를 분기. `httpsServer`를 인스턴스 필드로 보관해 `stop()`에서 함께 종료.

**추가 import:**
```typescript
import * as https from "node:https";
```

**생성자 시그니처:**
```typescript
constructor(
  private readonly host: string,
  private readonly listenPort: number,
  private readonly path: string,
  private readonly newClient: (ws: WebSocket) => WsClient,
  private readonly tlsOptions?: https.ServerOptions,
) {}
```

**`start()` 변경 포인트:**
- `tlsOptions`가 있으면: `httpsServer = https.createServer(this.tlsOptions)`, `wss = new WebSocketServer({ server: httpsServer, path })`, `httpsServer.listen(port, host)` 후 `httpsServer`의 `listening` 이벤트 대기
- 없으면: 기존 `WebSocketServer({ host, port, path })` 경로 유지

**`stop()` 변경 포인트:**
- TLS 모드: `await closeServer(wss)` 후 `httpsServer.close()`도 닫는다
- plain 모드: 변경 없음

**`index.ts`:** `connectWss` re-export 확인 (ws_client.ts export가 자동 포함되는지 확인).

### 수정 파일 및 체크리스트

- `typescript/src/ws_client.ts`
  - [x] `connectWss()` 함수 추가
- `typescript/src/ws_server.ts`
  - [x] `import * as https from "node:https"` 추가
  - [x] `httpsServer: https.Server | null = null` 인스턴스 필드 추가
  - [x] 생성자에 `tlsOptions?: https.ServerOptions` 파라미터 추가
  - [x] `start()` 분기 처리 (TLS vs plain)
  - [x] `stop()` — TLS 모드 시 `httpsServer.close()` 호출 추가

### 테스트 작성

파일: `typescript/test/ws.test.ts`
- 테스트 이름: `"connectWss sends and receives TestData"`, `"WSS sendRequest/response roundtrip"`
- TLS-3에서 만든 `cert`, `key` 재사용
- `WsServer` TLS 옵션: `{ cert, key }`
- `connectWss` 옵션: `{ ca: cert }` (rejectUnauthorized 기본값 true 유지)

### 중간 검증

```
$ cd typescript && npx tsc --noEmit
(exit 0)

$ cd typescript && npx vitest run test/ws.test.ts
(전체 PASS)
```

---

## [TLS-5] Kotlin TLS TCP

### 문제

`kotlin/src/main/kotlin/.../TcpClient.kt` — `dialTcpTls()` top-level 함수 없음.
`kotlin/src/main/kotlin/.../TcpServer.kt` — TLS `ServerSocket` 생성 경로 없음.

### 해결 방법

**`TcpClient.kt`:** `dialTcpTls()` top-level 함수 추가. `SSLContext.socketFactory.createSocket()` 사용.

```kotlin
import javax.net.ssl.SSLContext

fun dialTcpTls(
    host: String,
    port: Int,
    sslContext: SSLContext,
    intervalSec: Int = 30,
    waitSec: Int = 10,
    parserMap: ParserMap,
): TcpClient {
    val socket = sslContext.socketFactory.createSocket(host, port)
    return TcpClient.fromSocket(socket, intervalSec, waitSec, parserMap)
}
```

**`TcpServer.kt`:** 생성자에 `sslContext: SSLContext? = null` 파라미터 추가. `start()` 내 `ServerSocket()` 생성 부분을 분기.

**Before (`TcpServer.kt` `start()`):**
```kotlin
        val server = ServerSocket()
        server.reuseAddress = true
        server.bind(InetSocketAddress(host, port))
        serverSocket = server
```

**After:**
```kotlin
        val server: ServerSocket = if (sslContext != null) {
            sslContext.serverSocketFactory.createServerSocket()
        } else {
            ServerSocket()
        }
        server.reuseAddress = true
        server.bind(InetSocketAddress(host, port))
        serverSocket = server
```

`TcpServer` 생성자 파라미터 추가:
```kotlin
class TcpServer(
    private val host: String,
    private val port: Int,
    private val newClient: (java.net.Socket) -> TcpClient,
    private val sslContext: SSLContext? = null,
) {
```

**`port()` getter 추가** (테스트에서 port=0 사용 시 필요):
```kotlin
fun port(): Int = serverSocket?.localPort ?: port
```

### 수정 파일 및 체크리스트

- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpClient.kt`
  - [x] `import javax.net.ssl.SSLContext` 추가 (중복 확인)
  - [x] `dialTcpTls()` top-level 함수 추가
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpServer.kt`
  - [x] `import javax.net.ssl.SSLContext` 추가
  - [x] 생성자 파라미터에 `sslContext: SSLContext? = null` 추가
  - [x] `start()` 분기 처리 (TLS `SSLServerSocket` vs 일반 `ServerSocket`)
  - [x] `fun port(): Int` getter 추가 (필요 시)

### 테스트 작성

파일: `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TlsTcpTest.kt` (신규)
- 인증서: `kotlin/src/test/resources/server.crt`, `server.key` (dart/test/certs/ 에서 복사)
- `TestHelpers.kt`에 `createTestSslContexts(certPath, keyPath): Pair<SSLContext, SSLContext>` 추가:
  ```kotlin
  fun createTestSslContexts(certPath: String, keyPath: String): Pair<SSLContext, SSLContext> {
      val certFactory = java.security.cert.CertificateFactory.getInstance("X.509")
      val cert = java.io.File(certPath).inputStream().use {
          certFactory.generateCertificate(it) as java.security.cert.X509Certificate
      }
      val keyPem = java.io.File(keyPath).readText()
          .replace("-----BEGIN PRIVATE KEY-----", "")
          .replace("-----END PRIVATE KEY-----", "")
          .replace("\\s+".toRegex(), "")
      val keyBytes = java.util.Base64.getDecoder().decode(keyPem)
      val privateKey = java.security.KeyFactory.getInstance("RSA")
          .generatePrivate(java.security.spec.PKCS8EncodedKeySpec(keyBytes))
      val ks = java.security.KeyStore.getInstance("PKCS12")
      ks.load(null, null)
      ks.setKeyEntry("toki", privateKey, CharArray(0), arrayOf(cert))
      val kmf = javax.net.ssl.KeyManagerFactory.getInstance(
          javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm()
      )
      kmf.init(ks, CharArray(0))
      val serverCtx = SSLContext.getInstance("TLS")
      serverCtx.init(kmf.keyManagers, null, null)
      val ts = java.security.KeyStore.getInstance("PKCS12")
      ts.load(null, null)
      ts.setCertificateEntry("toki", cert)
      val tmf = javax.net.ssl.TrustManagerFactory.getInstance(
          javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()
      )
      tmf.init(ts)
      val clientCtx = SSLContext.getInstance("TLS")
      clientCtx.init(null, tmf.trustManagers, null)
      return Pair(serverCtx, clientCtx)
  }
  ```
- 테스트: `testTlsTcpSendReceive`, `testTlsTcpRequestResponse`
- 인증서 경로 로드: `Thread.currentThread().contextClassLoader.getResource("server.crt")!!.path`

### 중간 검증

```
$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew test --tests "*.TlsTcpTest"
BUILD SUCCESSFUL
```

---

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `python/toki_socket/tcp_client.py` | TLS-1 |
| `python/toki_socket/tcp_server.py` | TLS-1 |
| `python/toki_socket/ws_client.py` | TLS-2 |
| `python/toki_socket/ws_server.py` | TLS-2 |
| `python/test/test_tcp.py` | TLS-1 |
| `python/test/test_ws.py` | TLS-2 |
| `python/test/certs/server.crt` | TLS-1, TLS-2 (신규, 복사) |
| `python/test/certs/server.key` | TLS-1, TLS-2 (신규, 복사) |
| `typescript/src/tcp_client.ts` | TLS-3 |
| `typescript/src/tcp_server.ts` | TLS-3 |
| `typescript/src/ws_client.ts` | TLS-4 |
| `typescript/src/ws_server.ts` | TLS-4 |
| `typescript/test/tcp.test.ts` | TLS-3 |
| `typescript/test/ws.test.ts` | TLS-4 |
| `typescript/test/certs/server.crt` | TLS-3, TLS-4 (신규, 복사) |
| `typescript/test/certs/server.key` | TLS-3, TLS-4 (신규, 복사) |
| `kotlin/src/main/kotlin/.../TcpClient.kt` | TLS-5 |
| `kotlin/src/main/kotlin/.../TcpServer.kt` | TLS-5 |
| `kotlin/src/test/kotlin/.../TlsTcpTest.kt` | TLS-5 (신규) |
| `kotlin/src/test/kotlin/.../TestHelpers.kt` | TLS-5 |
| `kotlin/src/test/resources/server.crt` | TLS-5 (신규, 복사) |
| `kotlin/src/test/resources/server.key` | TLS-5 (신규, 복사) |

## 의존 관계 및 구현 순서

- TLS-1 → TLS-2 (Python 소스 파일 순서)
- TLS-3 → TLS-4 (TypeScript 소스 파일 순서)
- TLS-5 독립 (Kotlin)
- TLS-1/TLS-2와 TLS-3/TLS-4는 병렬 가능

## 최종 검증

```
$ cd python && python3 -m py_compile toki_socket/tcp_client.py toki_socket/tcp_server.py toki_socket/ws_client.py toki_socket/ws_server.py
(exit 0)

$ cd python && python3 -m pytest test/ -q
(전체 PASS)

$ cd typescript && npx tsc --noEmit
(exit 0)

$ cd typescript && npx vitest run
(전체 PASS)

$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew test
BUILD SUCCESSFUL
```
