# 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 { return new Promise((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 { return new Promise((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` 추가: ```kotlin fun createTestSslContexts(certPath: String, keyPath: String): Pair { 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 ```