9.17. 암호화된 소켓과 TLS

여기까지 다룬 모든 것은 바이트를 평문 그대로 주고받습니다. 카메라와 서버 사이 경로에 있는 모든 장치 – 가정용 라우터, 인터넷 서비스 제공업체, 커피숍의 악의적인 액세스 포인트 – 는 원칙적으로 그곳을 지나가는 내용을 읽거나 수정할 수 있습니다. 대부분의 인터넷 트래픽에서는 이것이 용인되지 않습니다. 표준적인 해결책은 연결을 암호화 계층으로 감싸는 것입니다. 바로 TLS, 즉 Transport Layer Security 프로토콜입니다. 브라우저의 “HTTPS” 자물쇠 아이콘은 TCP 위에서 동작하는 TLS이며, 다른 모든 인터넷 프로토콜을 “보안”으로 만드는 것도 동일한 감싸기 방식입니다. 카메라의 ssl 모듈이 바로 socket 을 TLS로 감싸는 역할을 합니다.

9.17.1. TLS가 추가하는 것, 그리고 카메라에 기본 탑재된 것

TLS는 TCP와 애플리케이션 사이에 위치합니다 – 애플리케이션은 TLS로 감싼 소켓에 바이트를 쓰고, TLS가 이를 암호화하여 그 결과를 TCP에 넘기며, 반대편에서는 이 과정이 역으로 진행됩니다. 완전한 형태의 TLS는 평범한 TCP 위에 세 가지 보장을 제공합니다:

  • 기밀성. 경로상의 도청자는 두 엔드포인트가 주고받는 내용을 읽을 수 없습니다.

  • 무결성. 전송 중 트래픽의 어떠한 변조도 탐지됩니다. 변조된 데이터를 전달하는 대신 연결이 끊어집니다.

  • 인증. 서버는 자신이 사칭자가 아니라 명시된 바로 그 서버임을 증명합니다 (그리고 선택적으로 클라이언트도 자신이 누구인지 증명합니다).

처음 두 가지는 암호화 자체에서 나옵니다. 세 번째는 적어도 한쪽에 인증서 가 있어야 하며, 그 인증서를 검증할 사전 신뢰된 무언가가 추가로 필요합니다. OpenMV 카메라는 내장 인증서 저장소가 전혀 없는 상태로 출하됩니다. 갓 플래시된 카메라는 어떠한 인증 기관도 신뢰하지 않고, 자체 서버 인증서도 없으며, 기본 검증 모드(ssl.CERT_NONE)는 피어의 인증서를 그 무엇과도 대조하지 않습니다. 따라서 기본 상태에서 카메라의 TLS는 처음 두 가지 보장 – 수동적 관찰자의 도청과 변조에 대한 암호화 – 은 제공하지만, 세 번째는 제공하지 않습니다.

9.17.2. 아웃바운드 연결 암호화하기

가장 간단한 사용법은 아웃바운드 TCP 연결을 감싸는 것입니다. 흐름은 다음과 같습니다: 평범한 TCP 소켓을 열고, 이를 ssl.wrap_socket() 에 넘긴 다음, 평범한 소켓을 다룰 때와 똑같은 방식으로 감싼 소켓을 통해 읽고 씁니다:

import socket
import ssl

addr = socket.getaddrinfo("example.com", 443)[0][-1]
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(addr)

s = ssl.wrap_socket(sock)

s.send(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
print(s.recv(4096))
s.close()

감싸기 과정에서 TLS 핸드셰이크가 수행됩니다. 그 이후로는 s.send 를 통해 나가는 모든 바이트가 출력 시 암호화되고, s.recv 로 들어오는 모든 바이트는 전송 중 암호화되어 있던 것입니다. 인증서를 구성하지도, 신뢰 앵커를 공급하지도 않았습니다 – TLS는 그저 응답하는 서버가 무엇이든 그것과 임시 세션 키를 협상하여 사용합니다.

"client"와 "server"로 표시된 두 개의 열이 있는 다이어그램. 상단 근처의 점선 가로선에는 "TCP connection already open"이라고 표시되어 있습니다. 그 아래 세 개의 화살표가 TLS 핸드셰이크를 보여줍니다: 클라이언트에서 서버로 가는 "ClientHello", 다시 돌아오는 "ServerHello + certificate + key share", 그리고 다시 앞으로 가는 "Finished". 그 아래의 두 번째 점선 가로선에는 "TLS session open -- everything after this is encrypted"라고 표시되어 있습니다. 그 아래 두 개의 굵은 양방향 화살표가 "encrypted data"를 전달합니다.

ssl.wrap_socket() 이 실행하는 TLS 핸드셰이크. 이는 이전 그림의 이미 열려 있는 TCP 연결 위에 위치합니다. 양쪽 모두 Finished 를 전송하고 나면, 나머지 대화는 양방향으로 암호화됩니다.

경고

이것은 암호화 전용일 뿐, 인증된 TLS가 아닙니다. 카메라는 TCP 연결의 반대편에서 응답한 것이 무엇이든 그것과 안전하게 통신합니다. 만약 중간자(man-in-the-middle)가 연결을 자신이 제어하는 서버로 리다이렉트하고 그 서버가 아무 인증서나 제시하면, 핸드셰이크는 여전히 성공하고 카메라는 결국 공격자와 안전하게 통신하게 됩니다. 이 모드는 중간자가 위협 모델에 포함되지 않을 때 – 폐쇄된 로컬 네트워크, 개발 환경, 동일한 하드웨어에서 실행되는 서비스와 통신하는 카메라 – 에만 사용하세요. 공개 인터넷으로 나갈 때는 사용하지 마세요.

진정한 인증 – 카메라가 공개 서버를 검증하거나, 카메라가 TLS 서버로 동작하거나, 상호 TLS – 을 위해서는 장치에 인증서를 가져와야 합니다. 전체 내용은 TLS 인증서 다루기 에 있습니다.

동일한 감싸기는 서버 프로토콜을 선택하고 ssl.wrap_socket()server_side=True 를 전달함으로써 인바운드 TCP 트래픽에도 적용됩니다. 위의 경고는 여전히 유효합니다: 자체 인증서가 없으면 카메라는 자신이 누구인지를 클라이언트에게 증명할 수 없으며, 호기심 많은 클라이언트는 대부분의 TLS 스택에서 “no certificate” 핸드셰이크 실패를 보게 됩니다. 카메라를 유용한 방식으로 TLS 서버로 실행할 수 있게 해주는 것이 바로 프로덕션 측의 인증서 워크플로입니다.

9.17.3. asyncio와 함께

asyncio 장 에서는 평범한 TCP 클라이언트용 asyncio.open_connection() 을 보여주었습니다. 동일한 호출이 연결을 TLS로 감싸는 ssl=True 키워드를 받아들이며, 이 역시 어떠한 인증서 설정도 없이 동작합니다:

import asyncio

async def main():
    reader, writer = await asyncio.open_connection(
        "example.com", 443, ssl=True,
    )
    writer.write(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
    await writer.drain()
    print(await reader.read(4096))
    writer.close()
    await writer.wait_closed()

asyncio.run(main())

TLS 연결 뒤에 있는 reader/writer 쌍은 평범한 TCP 연결의 경우와 형태가 동일합니다 – 설정만 다를 뿐입니다. 인증에 관한 동일한 주의 사항이 적용됩니다: ssl=True 만으로는 암호화만 제공할 뿐 검증은 제공하지 않습니다.

9.17.4. DTLS – UDP 위의 TLS

지금까지 설명한 TLS는 TCP 위에서 동작합니다. UDP를 위한 대응 프로토콜은 DTLS (Datagram TLS)이며, 카메라의 ssl 모듈은 이를 동일한 방식으로 지원합니다. TLS가 하나의 TCP 연결을 하나의 암호화된 바이트 스트림으로 바꾸는 것과 달리, DTLS는 하나의 UDP 소켓을 암호화되어 개별적으로 전달되는 데이터그램의 스트림으로 바꿉니다. 따라서 UDP – 패킷을 보내고 잘 되기를 바라기 에서 다룬 UDP의 손실 / 순서 뒤바뀜 / 흐름 제어 없음과 같은 특성이 그대로 이어지며, 이제 각 데이터그램 내부의 바이트는 암호화됩니다.

감싸기는 TLS의 경우와 동일하게 보이며, 다만 SOCK_DGRAM 소켓과 DTLS 프로토콜 상수를 사용합니다:

import socket
import ssl

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(socket.getaddrinfo("example.com", 4433)[0][-1])

ctx = ssl.SSLContext(ssl.PROTOCOL_DTLS_CLIENT)
s = ctx.wrap_socket(sock)

s.send(b"ping")
print(s.recv(64))
s.close()

(UDP 소켓에서 connect() 를 호출해도 연결이 열리지는 않습니다 – 그저 기본 목적지를 기억해 두어 이후의 send() / recv() 호출에서 그것을 반복하지 않아도 되게 할 뿐입니다. DTLS는 핸드셰이크를 진행하기 위해 그 고정된 목적지가 필요합니다.)

핸드셰이크는 위의 TLS 다이어그램과 형태가 동일합니다. 차이점은 각 핸드셰이크 메시지 자체가 하나의 UDP 데이터그램이며, 손실 시 어느 쪽이든 재시도한다는 것입니다.

참고

패킷을 잃어버리면 암호화가 깨질까요? 아닙니다. 각 DTLS 패킷은 시퀀스 번호를 담고 있으며, 암호화는 그 번호를 사용하여 각 패킷마다 다른 출력을 생성합니다 – 따라서 동일한 입력이 두 번 같은 바이트로 암호화되는 일이 결코 없고, 어떤 패킷이든 이전 패킷이 도착하지 않아도 자체적으로 복호화될 수 있습니다. 손실되거나 순서가 뒤바뀐 패킷이 양쪽의 동기화를 깨뜨리지 않습니다. (핸드셰이크 자체는 반드시 신뢰성 있게 도착해야 하는 유일한 부분이며, DTLS는 자체 재전송으로 이를 처리합니다.)

위에서 언급한 인증서 없는 암호화 전용에 관한 동일한 경고가 적용됩니다: CERT_NONE 피어를 상대로 한 DTLS 핸드셰이크는 트래픽을 암호화하지만 상대방이 누구인지는 검증하지 않습니다. 전체 DTLS 워크플로 – 인증서, 서버 측 스푸핑 방지 쿠키, 그리고 이것이 프로토콜 상수를 제외하면 TLS와 동일한 표면이라는 점 – 는 TLS 자료와 함께 TLS 인증서 다루기 에서 다룹니다.

asyncio 버전은 asyncio를 이용한 소켓 의 동일한 논블로킹 UDP 패턴을 사용합니다. 핸드셰이크를 먼저 동기적으로 수행하고, 소켓을 논블로킹으로 전환한 다음, 코루틴 내부에서 폴링합니다:

import asyncio
import socket
import ssl

async def dtls_ping(target_addr, period_ms):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.connect(target_addr)

    # Handshake while still blocking, then switch to async polling.
    ctx = ssl.SSLContext(ssl.PROTOCOL_DTLS_CLIENT)
    s = ctx.wrap_socket(sock)
    s.setblocking(False)

    while True:
        try:
            s.send(b"ping")
        except OSError:
            pass
        await asyncio.sleep_ms(period_ms)

핸드셰이크는 이 코루틴이 이벤트 루프를 블로킹하는 유일한 지점입니다. 그 이후로는 모든 s.send / s.recv 가 즉시 반환되거나(또는 OSError 를 발생시키며), await asyncio.sleep_ms 가 프로그램의 나머지 부분을 계속 실행되게 합니다.

9.17.5. 더 나아가기

암호화 전용 TLS 이상의 모든 것 – 공개 HTTPS 서버의 인증서 검증, 카메라를 인증된 TLS 서버로 실행하기, 카메라와 백엔드 간 상호 TLS, 키와 키 유형 선택, 인증서 만료 처리 – 은 TLS 인증서 다루기 에 있습니다. 해당 섹션에서는 로컬 테스트용 자체 서명 인증서를 생성하는 방법, 프로덕션용 CA 서명 인증서를 얻는 방법, 이를 올바른 형식(DER)으로 카메라에 올리는 방법, 카메라가 클라이언트일 때 공개 서버를 검증하는 방법, 공격자가 분해할 수도 있는 장치에서 키 보호를 어떻게 생각해야 하는지, 그리고 인증서가 만료되는 날을 어떻게 대비할지를 다룹니다.

ssl API 전체 레퍼런스 – 지원되는 TLS 버전, 암호 스위트, 컨텍스트 옵션 – 는 ssl — SSL/TLS 모듈 을 참고하세요.