3.2. net:: - 네트워크 (TCP / UDP / Unix + TLS 1.3)
rok::std::net 모듈은 저수준 네트워크 primitive 와 함께 TLS 1.3 까지 한 묶음으로 제공합니다. 다만 HTTP, HTTP/2, HTTP/3 같은 고수준 프로토콜은 의도적으로 범위 밖에 두었으며, rok/http 등 별도 패키지로 위임합니다. 이는 표준 라이브러리가 1.0 으로 동결된 뒤 진화가 멈춰버리는 Go 의 전철을 피하기 위한 선택이기도 합니다.
3.2.0. 공통 규칙
- 쓰는 데이터 (
write/write_all/send/send_to와 그_async) 는AsBytes(3.5.1) 로str,List<u8>,Slice<u8>를 받습니다. 읽는 buffer 는List<u8>입니다.read(buf)는 미리 길이를 잡아 둔buf의 앞n바이트를 채우고n을 돌려줍니다.0은 stream 의 끝입니다. - 소켓 타입은 OS 핸들입니다. 값을 복사하면 같은 소켓을 공유하며,
try_clone만 fd 를 복제합니다. - 소켓은
Drop을 구현하므로using블록을 벗어날 때 닫힙니다 (2.13.4). 그 밖에는close()로 닫습니다. 닫힌 소켓을 쓰면IoError(InvalidInput) 입니다. 다른 task 가 소켓을 닫을 때 그 소켓에서 대기 중인*_async연산은 그 에러로 끝나며,shutdown(Read | Both)뒤에는 대기 중인 read 가 0 으로 끝납니다. TcpStream은Reader+Writer(3.5.1) 를 구현합니다.
blocking 과 *_async
메서드는 두 벌입니다.
| 형태 | 기다리는 방식 |
|---|---|
accept, read, write .. | 호출한 OS 스레드를 막습니다 (task 안이면 그 worker 도) |
accept_async, read_async .. | task 만 중단하고 worker 는 다른 task 를 실행합니다 |
*_async 메서드는 async fn 이며 try await s.read_async(buf) 처럼 씁니다. 준비를 기다리는 일은 I/O 드라이버 가 맡습니다. 보통 함수는 stackless 코루틴을 중단시킬 수 없으므로, 같은 이름의 sync 호출이 task 안에서 저절로 중단되지는 않습니다.
| blocking | async |
|---|---|
TcpListener::accept | TcpListener::accept_async |
TcpStream::connect | TcpStream::connect_async |
TcpStream::read | TcpStream::read_async |
TcpStream::read_to_end | TcpStream::read_to_end_async |
TcpStream::write | TcpStream::write_async |
TcpStream::write_all | TcpStream::write_all_async |
TcpStream::write_str | TcpStream::write_str_async |
UdpSocket::send_to | UdpSocket::send_to_async |
UdpSocket::recv_from | UdpSocket::recv_from_async |
UdpSocket::send | UdpSocket::send_async |
UdpSocket::recv | UdpSocket::recv_async |
tcp_connect | tcp_connect_async |
tls::Client::new / with_config | tls::Client::new_async / with_config_async |
tls::Server::new | tls::Server::new_async |
tls::Client::read ... key_update (Server 도) | read_async ... key_update_async |
timeout 은 blocking 쪽에만 적용됩니다 (set_read_timeout / set_write_timeout, 넘으면 TimedOut). async 쪽은 task::timeout (task 를 기다림) 이나 task::select (2.18.1) 로 감쌉니다. select 는 진 쪽을 취소하므로 읽기를 포기할 때 쓰기 좋습니다.
enum Got { Data(usize), Timeout }
let (_, got) = await task::select([
async { Got::Data(try await s.read_async(buf) catch _ { 0 }) },
async { await task::sleep(Duration::sec(5)); Got::Timeout },
]);
set_nonblocking(true) 이면 blocking 메서드는 기다리지 않고 WouldBlock 을 던집니다.
wasm32-wasi 에서 std::net (TCP / UDP connect, bind, listen, accept, DNS) 은 wasi:sockets 로 동작하며, host 가 네트워크 접근을 허락해야 합니다 (예: wasmtime -S inherit-network -S allow-ip-name-lookup). wasm32-wasi-threads 에서는 TcpListener::inherited() 의 listener 만 동작하고, connect / bind / DNS 는 Unsupported 를 throw 합니다.
3.2.1. TCP
TCP 쪽은 TcpListener 와 TcpStream 두 타입을 축으로 구성합니다. 리스너는 바인드와 accept 흐름을 담당하고, 스트림은 읽기·쓰기와 옵션 제어를 모두 노출합니다.
struct TcpListener { /* OS handle */ }
struct TcpStream { /* OS handle */ }
impl TcpListener {
fn bind(addr: SocketAddr) throws (IoError) -> TcpListener; // SO_REUSEADDR, backlog 128
fn inherited() -> List<TcpListener>; // host 가 프로세스에 넘긴 listener
fn accept(self) throws (IoError) -> (TcpStream, SocketAddr);
async fn accept_async(self) throws (IoError) -> (TcpStream, SocketAddr);
fn local_addr(self) throws (IoError) -> SocketAddr; // port 0 으로 bind 한 뒤 실제 port
fn try_clone(self) throws (IoError) -> TcpListener;
fn set_nonblocking(self, nb: bool) throws (IoError);
fn incoming(self) -> Incoming; // Iterator<Item = Result<TcpStream, IoError>> 구현
fn fd(self) -> i32;
fn close(self);
}
impl TcpStream {
fn connect(addr: SocketAddr) throws (IoError) -> TcpStream;
fn connect_timeout(addr: SocketAddr, dur: Duration) throws (IoError) -> TcpStream;
async fn connect_async(addr: SocketAddr) throws (IoError) -> TcpStream;
fn read(self, buf: List<u8>) throws (IoError) -> usize;
fn read_to_end(self) throws (IoError) -> List<u8>; // 끝까지
fn write<B: AsBytes>(self, buf: B) throws (IoError) -> usize;
fn write_all<B: AsBytes>(self, buf: B) throws (IoError);
fn write_str(self, s: str) throws (IoError);
fn flush(self) throws (IoError); // no-op (버퍼 없음)
// + read_async / read_to_end_async / write_async / write_all_async / write_str_async
fn peer_addr(self) throws (IoError) -> SocketAddr;
fn local_addr(self) throws (IoError) -> SocketAddr;
fn shutdown(self, how: Shutdown) throws (IoError);
fn try_clone(self) throws (IoError) -> TcpStream;
fn fd(self) -> i32;
fn close(self);
// TCP 옵션
fn set_nodelay(self, nodelay: bool) throws (IoError); // TCP_NODELAY
fn set_keepalive(self, alive: Duration | none) throws (IoError); // SO_KEEPALIVE + idle (초), none = 끔
fn set_read_timeout(self, dur: Duration | none) throws (IoError);
fn set_write_timeout(self, dur: Duration | none) throws (IoError);
fn read_timeout(self) -> Duration | none;
fn write_timeout(self) -> Duration | none;
fn set_nonblocking(self, nb: bool) throws (IoError);
}
impl Reader for TcpStream { ... }
impl Writer for TcpStream { ... }
impl Drop for TcpStream { ... } // close
impl Drop for TcpListener { ... }
enum Shutdown { Read, Write, Both }
TcpListener::inherited() 는 host 가 프로세스에 넘긴 listener 를 돌려줍니다: WASI pre-opened 소켓, Unix 의 systemd LISTEN_FDS socket activation, Windows 에서는 없음.
Incoming 은 끝나지 않습니다. 원소마다 accept 를 한 번 부르며, 그 에러는 Err 원소로 나옵니다. 쓰기에서 peer 가 닫혀도 SIGPIPE 로 프로세스가 끝나지 않고 IoError (BrokenPipe) 가 됩니다.
사용 예
use rok::std::net::{tcp_listen, TcpListener, TcpStream};
// blocking - 이 스레드가 accept 와 handle 에서 기다림
using l = try tcp_listen("127.0.0.1:8080") {
for c in l.incoming() {
handle(try c.get());
}
}
// async - 연결마다 task, 기다리는 동안 worker 는 다른 task 실행
async fn serve_all(l: TcpListener) throws (IoError) {
loop {
let (s, peer) = try await l.accept_async();
task::spawn(serve(s)); // async fn serve(s: TcpStream)
}
}
3.2.2. UDP
UDP 는 단일 UdpSocket 타입이 모든 역할을 맡습니다. 데이터그램 송수신을 기본으로 하되, connect 로 peer 를 고정해두면 send / recv 만으로도 다룰 수 있도록 했습니다. 멀티캐스트 가입과 TTL 같은 옵션도 함께 노출하며, QUIC 같은 워크로드를 염두에 두고 GSO/GRO 활용을 위한 segment size 제어까지 포함합니다.
struct UdpSocket { /* OS handle */ }
impl UdpSocket {
fn bind(addr: SocketAddr) throws (IoError) -> UdpSocket;
fn send_to<B: AsBytes>(self, buf: B, addr: SocketAddr) throws (IoError) -> usize;
fn recv_from(self, buf: List<u8>) throws (IoError) -> (usize, SocketAddr);
fn connect(self, addr: SocketAddr) throws (IoError); // peer 고정
fn send<B: AsBytes>(self, buf: B) throws (IoError) -> usize;
fn recv(self, buf: List<u8>) throws (IoError) -> usize;
// + send_to_async / recv_from_async / send_async / recv_async
fn local_addr(self) throws (IoError) -> SocketAddr;
fn peer_addr(self) throws (IoError) -> SocketAddr;
fn set_broadcast(self, on: bool) throws (IoError); // SO_BROADCAST
fn set_read_timeout(self, dur: Duration | none) throws (IoError);
fn set_write_timeout(self, dur: Duration | none) throws (IoError);
fn set_nonblocking(self, nb: bool) throws (IoError);
fn try_clone(self) throws (IoError) -> UdpSocket;
fn fd(self) -> i32;
fn close(self);
// 멀티캐스트 (IPv4)
fn join_multicast_v4(self, addr: Ipv4Addr, iface: Ipv4Addr) throws (IoError);
fn leave_multicast_v4(self, addr: Ipv4Addr, iface: Ipv4Addr) throws (IoError);
fn set_multicast_loop_v4(self, on: bool) throws (IoError);
fn set_multicast_ttl_v4(self, ttl: u32) throws (IoError);
// QUIC 용 (GSO / GRO)
fn set_segment_size(self, size: u16) throws (IoError); // UDP_SEGMENT, Linux 외에는 Unsupported
}
impl Drop for UdpSocket { ... } // close
recv_from / recv 는 데이터그램 하나를 buf 에 받습니다. buf 보다 긴 데이터그램의 나머지는 버려집니다.
3.2.3. Unix domain socket
Unix domain socket 은 UnixListener, UnixStream, UnixDatagram 세 타입으로 정리합니다. 전체적인 모양은 TCP 와 닮아 있지만, 같은 호스트 안에서만 통신하므로 파일 경로를 식별자로 쓰며 pair() 처럼 socketpair(2) 에 대응되는 편의 메서드도 제공합니다. 또한 SCM_RIGHTS 를 통한 fd 전달은 Unix 소켓에서만 의미가 있으므로 send_with_fd / recv_with_fd 로 분리해 두었습니다.
struct UnixListener { /* unix only */ }
struct UnixStream { /* unix only */ }
struct UnixDatagram { /* unix only */ }
#[cfg(target_family = "unix")]
impl UnixListener {
fn bind(path: Path) throws (IoError) -> UnixListener;
fn accept(self) throws (IoError) -> (UnixStream, Path | none); // peer 가 bind 한 경로
// ... TcpListener 와 비슷
}
#[cfg(target_family = "unix")]
impl UnixStream {
fn connect(path: Path) throws (IoError) -> UnixStream;
fn pair() throws (IoError) -> (UnixStream, UnixStream); // socketpair
// SCM_RIGHTS - fd passing
fn send_with_fd(self, buf: List<u8>, fd: RawFd) throws (IoError) -> usize;
fn recv_with_fd(self, buf: List<u8>) throws (IoError) -> (usize, RawFd | none);
}
3.2.4. SocketAddr / IpAddr
주소 표현은 IPv4 와 IPv6 를 합집합으로 묶은 SocketAddr enum 을 중심으로 합니다. 각 변종은 IP 와 포트, 그리고 IPv6 의 경우 flow info 와 scope id 까지 그대로 노출하므로, 필요할 때 직접 들여다볼 수 있습니다. 문자열 파싱은 FromStr 구현을 통해 자연스럽게 이뤄지며, 호스트네임을 다루는 DNS 조회는 별도 함수로 제공합니다.
enum SocketAddr {
V4(SocketAddrV4),
V6(SocketAddrV6),
}
struct SocketAddrV4 { ip: Ipv4Addr; port: u16; }
struct SocketAddrV6 { ip: Ipv6Addr; port: u16; flow_info: u32; scope_id: u32; }
impl SocketAddr {
fn v4(ip: Ipv4Addr, port: u16) -> SocketAddr;
fn v6(ip: Ipv6Addr, port: u16) -> SocketAddr; // flow_info = scope_id = 0
fn port(self) -> u16;
fn with_port(self, port: u16) -> SocketAddr;
fn is_ipv4(self) -> bool;
fn is_ipv6(self) -> bool;
}
struct Ipv4Addr { octets: [u8; 4]; }
struct Ipv6Addr { segments: [u16; 8]; }
impl Ipv4Addr {
const LOCALHOST: Ipv4Addr = Ipv4Addr { octets: [127, 0, 0, 1] };
const UNSPECIFIED: Ipv4Addr = Ipv4Addr { octets: [0, 0, 0, 0] };
fn new(a: u8, b: u8, c: u8, d: u8) -> Ipv4Addr;
fn to_bits(self) -> u32;
fn is_loopback(self) -> bool;
fn is_unspecified(self) -> bool;
fn is_multicast(self) -> bool;
}
impl Ipv6Addr {
const LOCALHOST: Ipv6Addr; // ::1
const UNSPECIFIED: Ipv6Addr; // ::
fn new(a: u16, b: u16, c: u16, d: u16, e: u16, f: u16, g: u16, h: u16) -> Ipv6Addr;
fn is_loopback(self) -> bool;
}
// 파싱 / 출력 - 세 타입 모두 FromStr (Err = ParseError), Display, Debug, PartialEq
impl FromStr for SocketAddr { ... } // "127.0.0.1:8080" / "[::1]:8080"
impl FromStr for Ipv4Addr { ... }
impl FromStr for Ipv6Addr { ... } // "::" 축약 허용
// DNS (getaddrinfo)
fn resolve(host: str) throws (IoError) -> List<SocketAddr>;
fn resolve_one(host: str, port: u16) throws (IoError) -> SocketAddr;
resolve 는 OS 의 getaddrinfo 로 주소 목록을 얻으며, 호출한 스레드를 막습니다. host 는 다음을 받습니다.
host | 결과 |
|---|---|
소켓 주소 ("10.0.0.1:80", "[::1]:80") | 그 주소 하나 (조회 없음) |
"name:port" | name 의 주소들, port 는 port |
"[v6]:port" | 그 IPv6 주소, port 는 port |
| 이름 또는 IP 만 | 그 주소들, port 0 |
resolve_one 은 이름과 port 를 따로 받아 첫 주소를 돌려줍니다. 이름을 찾지 못하면 IoError (NotFound) 이며, 메시지에 resolver 의 설명이 들어갑니다.
3.2.5. TLS 1.3 - net::tls
TLS 는 net::tls 하위 모듈에 모아 두었고, 처음부터 1.3 만을 기본 가정으로 잡았습니다. ClientConfig 와 ServerConfig 가 인증서, 암호 모음, ALPN 프로토콜 목록을 표현하며, post-quantum 옵션을 통해 ML-KEM 하이브리드 키 교환을 활성화할 수 있습니다. 핸드셰이크 이후에는 Client / Server 가 평문 스트림처럼 동작하므로 기존 I/O 코드를 거의 그대로 얹을 수 있습니다.
use rok::std::net::tls;
struct ClientConfig {
root_certs: List<Certificate>; // 신뢰 앵커
cipher_suites: List<CipherSuite>; // TLS 1.3 suite, 선호 순서
alpn_protocols: List<List<u8>>; // ["h2", "http/1.1"] 등
enable_pq: bool; // post-quantum (X25519MLKEM768 hybrid)
client_cert_chain: List<Certificate>; // 서버가 요청하면 보냄: leaf 먼저; 비면 없음
client_key: PrivateKey | none; // 클라이언트 leaf 의 키
session_cache: SessionCache | none; // resume 할 ticket; none: resume 안 함
early_data: List<u8>; // 0-RTT 데이터, 아니면 핸드셰이크 뒤의 첫 데이터
}
impl ClientConfig {
fn new() -> ClientConfig; // system_roots(), 모든 suite, ALPN 없음, enable_pq = true,
// 클라이언트 인증서 없음, SessionCache::new(), early data 없음
}
struct ServerConfig {
cert_chain: List<Certificate>; // leaf 먼저, 그다음 중간 인증서
private_key: PrivateKey; // leaf 의 키
cipher_suites: List<CipherSuite>;
alpn_protocols: List<List<u8>>;
client_roots: List<Certificate>; // 비어 있지 않으면 클라이언트 인증서를 요청하고 이것으로 검증
require_client_cert: bool; // client_roots 와 함께: 인증서 없는 클라이언트 거부
ocsp_response: List<u8>; // 요청받으면 staple 하는 DER OCSPResponse; 비면 없음
ticket_store: TicketStore | none; // resumption; none: ticket 없음
max_early_data: u32; // 연결마다 받는 0-RTT 바이트; 0: 없음
}
impl ServerConfig {
fn new(cert_chain: List<Certificate>, private_key: PrivateKey) throws (TlsError) -> ServerConfig;
// 키는 cert_chain[0] 의 것이어야 함; 모든 suite, ALPN 없음, 클라이언트 인증서 없음,
// OCSP 응답 없음, TicketStore::new(), require_client_cert = true, max_early_data = 0
}
struct SessionCache { // 서버 이름별 클라이언트의 ticket; 복사본은 공유
fn new() -> SessionCache;
fn len(self) -> usize;
fn clear(self);
}
struct TicketStore { // 서버의 resume 가능한 세션; 복사본은 공유
fn new() -> TicketStore;
fn len(self) -> usize;
}
enum CipherSuite { Aes128GcmSha256, Aes256GcmSha384, ChaCha20Poly1305Sha256 }
struct Certificate {
fn from_der(der: List<u8> | Slice<u8>) throws (TlsError) -> Certificate;
fn from_pem(text: str) throws (TlsError) -> List<Certificate>;
fn der(self) -> List<u8>;
fn dns_names(self) -> List<str>;
fn not_before(self) -> i64; // Unix 초
fn not_after(self) -> i64;
}
struct PrivateKey { // Ed25519, ECDSA P-256 / P-384, RSA (2048..4096 bits), RSASSA-PSS
fn from_der(der: List<u8> | Slice<u8>) throws (TlsError) -> PrivateKey; // PKCS #8
fn from_pem(text: str) throws (TlsError) -> PrivateKey; // PRIVATE KEY, EC PRIVATE KEY 또는 RSA PRIVATE KEY
}
fn system_roots() throws (TlsError) -> List<Certificate>; // OS trust store (호출마다 읽음)
enum TlsErrorKind { Io, Certificate, Protocol, PeerAlert }
struct TlsError { kind: TlsErrorKind; msg: str; alert: u8; }
struct Client {
fn new(stream: TcpStream, server_name: str) throws (TlsError) -> Client;
fn with_config(stream: TcpStream, server_name: str, cfg: ClientConfig) throws (TlsError) -> Client;
async fn new_async(stream: TcpStream, server_name: str) throws (TlsError) -> Client;
async fn with_config_async(stream: TcpStream, server_name: str, cfg: ClientConfig) throws (TlsError) -> Client;
fn read(self, buf: List<u8>) throws (IoError) -> usize;
fn read_to_end(self) throws (IoError) -> List<u8>;
fn write<B: AsBytes>(self, buf: B) throws (IoError) -> usize;
fn write_all<B: AsBytes>(self, buf: B) throws (IoError);
fn flush(self) throws (IoError);
fn close_notify(self) throws (IoError);
fn key_update(self) throws (IoError);
fn close(self);
async fn read_async(self, buf: List<u8>) throws (IoError) -> usize;
async fn read_to_end_async(self) throws (IoError) -> List<u8>;
async fn write_async<B: AsBytes>(self, buf: B) throws (IoError) -> usize;
async fn write_all_async<B: AsBytes>(self, buf: B) throws (IoError);
async fn flush_async(self) throws (IoError);
async fn close_notify_async(self) throws (IoError);
async fn key_update_async(self) throws (IoError);
fn negotiated_alpn(self) -> List<u8> | none;
fn peer_certificates(self) -> List<Certificate>;
fn resumed(self) -> bool; // 핸드셰이크가 세션을 resume 함
fn early_data_accepted(self) -> bool; // early_data 가 0-RTT 데이터로 나감
}
struct Server {
fn new(stream: TcpStream, cfg: ServerConfig) throws (TlsError) -> Server;
async fn new_async(stream: TcpStream, cfg: ServerConfig) throws (TlsError) -> Server;
// read, read_to_end, write, write_all, flush, close_notify, key_update,
// close, 그 *_async 형태, negotiated_alpn, peer_certificates 는
// Client 와 같고, 추가로:
fn server_name(self) -> str | none; // 클라이언트의 SNI
fn resumed(self) -> bool;
fn early_data(self) -> List<u8>; // 받아들인 0-RTT 데이터; read 는 그 뒤의 것을 돌려줌
}
Client 와 Server 는 Reader / Writer (3.5.1) 와 Drop (close_notify 후 socket 을 닫음) 을 구현합니다. 복사본은 하나의 연결을 공유하며, 한 연결은 한 번에 한 thread 만, 한 번에 한 연산만 씁니다 (!Sync).
*_async 메서드는 3.2.0 을 따릅니다: socket 이 준비되지 않은 동안 task 만 중단하며, 핸드셰이크 (new_async, with_config_async) 도 상대의 메시지 사이에서 마찬가지입니다. 한 연결에서 blocking 형태와 async 형태를 섞어 써도 됩니다.
스택은 구현이 제공하며 rok::core::crypto (2.21) 위에 rok 로 작성됩니다. OS 나 서드파티 TLS 라이브러리는 쓰지 않으며, 사용자가 그런 라이브러리를 호출할 일도 없습니다. TLS 1.3 (RFC 8446) 만 사용하고, TLS 1.2 이하는 거부합니다. 핸드셰이크는 X25519 와 X25519MLKEM768 하이브리드 (ML-KEM-768 + X25519, enable_pq), HelloRetryRequest (group 또는 cookie), ALPN, SNI, KeyUpdate, close_notify, 클라이언트 인증서, OCSP stapling, 그리고 0-RTT 데이터를 포함한 resumption 을 지원합니다.
Resumption: ticket_store 가 있는 서버는 핸드셰이크마다 NewSessionTicket 하나를 보냅니다. store 는 세션을 보관하며 (최대 4096 개, 가장 오래된 것부터 버림; 수명 2 시간) ticket 하나를 많아야 한 번 사용합니다. session_cache 가 있는 클라이언트는 받은 ticket 을 접속한 서버 이름 아래에 보관하며 (최대 64 개), 자기도 제시하는 cipher suite 의 해시를 쓰는 살아 있는 ticket 중 가장 새 것을 한 번 제시합니다 (psk_dhe_ke 만: resume 한 핸드셰이크도 새 키 교환을 합니다). resume 한 연결은 인증서 인증을 건너뛰며, peer_certificates() 는 원래 연결의 chain 을 돌려줍니다. ticket 은 클라이언트가 read 하는 동안 받습니다.
0-RTT: early_data 가 비어 있지 않고 ticket 이 그만큼의 바이트를 허용하면, 클라이언트는 그것을 ClientHello 와 함께 early data 로 보냅니다. 그렇지 않거나 서버가 거절하면 핸드셰이크 뒤의 첫 application data 로 보냅니다 (어느 쪽인지는 early_data_accepted() 가 알려 줌). 서버는 max_early_data > 0 이고, 첫 번째로 제시된 ticket 이며, HelloRetryRequest 가 없고, ticket 의 cipher suite 와 ALPN 프로토콜이 같으며, 클라이언트의 ticket age 가 서버의 것과 10 초 안일 때만 early data 를 받습니다. 받은 데이터는 early_data() 로 내주고, 거절한 early data 는 건너뜁니다. 모든 ticket 은 그 TicketStore 에서 한 번만 사용되므로, 재전송된 ClientHello 는 아무것도 resume 하지 못하고 그 early data 는 건너뜁니다 (RFC 8446 8.1). 서로의 재전송을 거부해야 하는 서버들은 store 하나를 공유해야 합니다. early data 는 여전히 ticket 에 대해 forward-secret 이 아니므로 멱등이어야 합니다.
클라이언트 인증서: client_roots 가 비어 있지 않은 서버는 CertificateRequest 를 보냅니다. 클라이언트의 chain 은 서버의 것처럼 (아래) client_roots 에 대해 검증하되 extendedKeyUsage clientAuth 를 요구하고 이름은 검사하지 않으며, 그 CertificateVerify 도 검사합니다. 인증서가 없는 클라이언트는 require_client_cert 이면 certificate_required 로 핸드셰이크가 실패하고, 아니면 peer_certificates() 없이 계속합니다. 클라이언트는 서버가 client_key 에 맞는 scheme 을 받으면 CertificateRequest 에 client_cert_chain 과 client_key 로 만든 CertificateVerify 로 답하고, 아니면 빈 Certificate 로 답합니다. 첫 인증서의 것이 아닌 client_key 는 아무것도 보내기 전에 Client::with_config 를 실패시킵니다.
서버 인증: 인증서 경로는 상대의 chain 에서 root_certs 중 하나까지 구성하고 RFC 5280 에 따라 검증합니다 (서명, 현재 시각 기준 유효 기간, basicConstraints, keyUsage, extendedKeyUsage serverAuth, DNS 및 IP name constraints; 모르는 critical extension 은 실패). 서버 이름은 subjectAltName 하고만 대조합니다 (RFC 6125: 대소문자 무시, wildcard 는 가장 왼쪽 label 전체). IP 리터럴은 iPAddress 항목과 대조하며 SNI 를 보내지 않습니다. 허용하는 서명: Ed25519, SHA-256/384/512 를 쓰는 ECDSA P-256 / P-384, RSA PKCS #1 v1.5 (인증서에만) 와 SHA-256/384/512 를 쓰는 RSA-PSS (2048-4096-bit 키, rsaEncryption 또는 id-RSASSA-PSS; 인증서에서는 salt 길이 무관, id-RSASSA-PSS 키의 해시와 최소 salt 를 따름). Revocation: 클라이언트는 OCSP 응답을 요청합니다 (status_request). staple 된 응답은 성공한 basic OCSP 응답 (RFC 6960) 이어야 하며, leaf 의 발급자나 발급자가 extendedKeyUsage OCSPSigning 으로 발급한 인증서가 서명하고, leaf 를 가리키는 SHA-1 또는 SHA-2 CertID 를 가지며, thisUpdate 가 5 분 넘게 앞서지 않고 nextUpdate 가 있으며 지나지 않아야 합니다. 상태가 revoked 이면 certificate_revoked 로, 그 밖의 문제 (unknown 상태 포함) 는 bad_certificate_status_response 로 실패합니다. status_request 에 대한 TLS feature extension (RFC 7633) 이 있는 leaf 는 staple 된 응답을 요구합니다. staple 도 그 extension 도 없으면 인증서를 받아들입니다. CRL 과 네트워크 OCSP 조회는 쓰지 않습니다.
만료되었거나 아직 유효하지 않은 인증서는 certificate_expired 로, extendedKeyUsage 가 그 역할을 허용하지 않는 인증서는 unsupported_certificate 로, root_certs 까지의 경로가 없는 인증서는 unknown_ca 로, 그 밖의 실패는 bad_certificate 로 실패합니다. ServerHello, HelloRetryRequest, EncryptedExtensions 또는 Certificate 항목에 클라이언트가 요청하지 않은 extension 이 있으면 unsupported_extension 으로 실패합니다. 실패한 핸드셰이크는 그 alert 를 보낸 뒤 stream 을 닫습니다.
양쪽 모두 CertificateVerify 를 키의 scheme 으로 서명합니다: ed25519, ecdsa_secp256r1_sha256, ecdsa_secp384r1_sha384, rsaEncryption 인증서는 상대가 제시한 rsa_pss_rsae_sha256 / _sha384 / _sha512 중 첫 번째, id-RSASSA-PSS 인증서는 상대가 제시한 rsa_pss_pss_sha256 / _sha384 / _sha512 중 키의 매개변수가 허용하는 첫 번째. PrivateKey 는 PKCS #8 (Ed25519, P-256 / P-384 의 id-ecPublicKey, rsaEncryption, id-RSASSA-PSS), RFC 5915 EC PRIVATE KEY, PKCS #1 RSA PRIVATE KEY 를 읽습니다. ServerConfig::new 는 키의 공개 부분이 leaf 의 것이 아니면 실패합니다 (TlsErrorKind::Certificate).
스택은 traffic, handshake, resumption secret, record 키, 키 교환 secret, ticket PSK, Ed25519 / EC 개인 키를 비밀 저장소 (2.21.5) 에 두며, KeyUpdate 때, 핸드셰이크가 끝날 때, close 때 지웁니다.
system_roots() 는 OS trust store 를 읽습니다: macOS keychain 의 anchor (와 무조건 신뢰된 admin / user 인증서), Windows ROOT store, Linux 에서 처음 발견되는 CA bundle (/etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt, ...). SSL_CERT_FILE 이 이를 덮어씁니다. wasm target 에는 store 가 없으므로 root_certs 를 넘겨야 합니다.
read 는 상대의 close_notify 이후 0 을 돌려줍니다. close_notify 없이 끝난 연결은 IoError (UnexpectedEof) 이며, 프로토콜 실패는 fatal alert 를 보낸 뒤 IoError (InvalidData) 입니다. TLS record 는 통째로 쓰이므로 write 는 buf 전체를 씁니다.
사용 예
실제 사용 흐름은 평문 TCP 연결 위에 TLS 클라이언트를 얹고, 그 위에서 평문 I/O 를 호출하는 모양으로 단순합니다.
use rok::std::net::{tcp_connect, tls};
let conn = try tcp_connect("example.com:443");
let tls_conn = try tls::Client::new(conn, "example.com");
try tls_conn.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n");
let resp = try tls_conn.read_to_end();
try tls_conn.close_notify();
3.2.6. 편의 함수
자주 쓰는 패턴은 짧은 helper 로 감싸 두었습니다. 문자열을 받는 형태에서는 resolve (3.2.4) 로 DNS 해석까지 수행하므로, tcp_connect("example.com:80") 처럼 호스트네임을 그대로 넘길 수 있습니다.
fn tcp_connect(addr: str | SocketAddr) throws (IoError) -> TcpStream;
async fn tcp_connect_async(addr: str | SocketAddr) throws (IoError) -> TcpStream;
fn tcp_listen(addr: str | SocketAddr) throws (IoError) -> TcpListener;
fn udp_bind(addr: str | SocketAddr) throws (IoError) -> UdpSocket;
tcp_connect 는 해석된 주소를 차례로 시도해 처음 연결되는 것을 돌려주고, 모두 실패하면 마지막 에러를 던집니다. tcp_listen / udp_bind 는 첫 주소에 bind 합니다.
3.2.7. zerocopy hooks (advanced)
처리량이 곧 비용이 되는 자리, 예컨대 프록시나 파일 서버 같은 경우를 위해 zerocopy 계열 hook 도 별도로 노출합니다. 대부분의 사용자에게는 필요하지 않으며, 정말 필요할 때 손에 닿는 곳에 두는 정도가 목적입니다.
impl TcpStream {
fn sendfile(self, ref file: File, offset: u64, count: u64) throws (IoError) -> u64;
// sendfile(2) / TransmitFile
fn splice(self, ref src: File, count: u64) throws (IoError) -> u64;
// Linux splice(2)
fn write_zerocopy(self, buf: List<u8>) throws (IoError) -> usize;
// MSG_ZEROCOPY (Linux 4.14+)
}
3.2.8. 모듈 매핑
플랫폼별 지원 범위는 다음 표에 정리됩니다. 핵심 기능은 데스크톱 OS 와 wasi 양쪽에서 모두 동작하지만, Unix socket 이나 zerocopy hook 처럼 Linux 고유 자원에 의존하는 항목은 제한적으로만 활성화됩니다.
| 항목 | linux/macos/windows | wasi | 비고 |
|---|---|---|---|
| TCP | ✓ | ✓ | wasi:sockets |
| UDP | ✓ | ✓ | wasi:sockets |
| Unix socket | linux/macos | ✗ | Windows 부분 (named pipe 별도) |
| TLS 1.3 | ✓ | ✓ (trust store 없음: root_certs 전달) | rok 자체 구현, rok::core::crypto 위 |
| sendfile | linux | ✗ | |
| splice | linux | ✗ | |
| MSG_ZEROCOPY | linux 4.14+ | ✗ |