PEP 748 – Python을 위한 통합 TLS API
- Author:
- Joop van de Pol <joop.vandepol at trailofbits.com>, William Woodruff <william at yossarian.net>
- Sponsor:
- Alyssa Coghlan <ncoghlan at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Created:
- 27-Jun-2024
- Python-Version:
- 3.14
- Post-History:
- 17-Apr-2024
- Replaces:
- 543
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 프로토콜 클래스 모음 형태로 표준 TLS 인터페이스를 정의합니다. 이 인터페이스를 통해 Python 구현과 서드파티 라이브러리는 OpenSSL 이외의 TLS 라이브러리에 대한 바인딩을 제공할 수 있습니다.
이러한 바인딩은 Python 표준 라이브러리가 제공하는 인터페이스를 기대하는 도구에서 사용할 수 있으며, Python 생태계의 OpenSSL 의존도를 줄이는 것을 목표로 합니다.
근거
견고하고 사용자 친화적인 TLS 지원이 인기 있는 모든 프로그래밍 언어의 생태계에서 매우 중요한 부분이라는 점이 점점 더 분명해지고 있습니다. Python 생태계에서 이 역할은 그 역사의 대부분 동안 주로 OpenSSL 라이브러리에 대한 Python API를 제공하는 ssl 모듈이 맡아 왔습니다.
ssl 모듈은 Python 표준 라이브러리와 함께 배포되기 때문에 Python에서 TLS를 다루는 방법 중 압도적으로 가장 널리 쓰이는 방법이 되었습니다. 표준 라이브러리와 Python 패키지 색인 양쪽에 있는 Python 라이브러리 대다수가 TLS 연결을 ssl 모듈에 의존합니다.
안타깝게도 ssl 모듈의 압도적인 지위는 Python 생태계 전체를 OpenSSL에 강하게 묶어 놓았습니다. 이 때문에 Python 사용자는 다른 TLS 구현보다 사용자 경험이 더 나쁠 수 있는 상황에서도 OpenSSL을 사용할 수밖에 없었으며, 이는 인지적 부담을 주고 “플랫폼 네이티브” 경험을 제공하기 어렵게 만듭니다.
문제점
ssl 모듈이 표준 라이브러리에 내장되어 있다는 것은 표준 라이브러리의 모든 Python 네트워킹 라이브러리가 Python 구현이 링크된 OpenSSL에 전적으로 의존한다는 뜻이었습니다. 이로 인해 다음과 같은 문제가 발생합니다.
- 새 OpenSSL을 얻기 위해 Python을 다시 컴파일하지 않고는 보안성이 더 높은 새로운 TLS를 활용하기 어렵습니다. OpenSSL에 대한 서드파티 바인딩(예: pyOpenSSL)이 있기는 하지만, 이를 표준 라이브러리가 이해하는 형식에 맞춰 끼워 넣어야 하므로 이를 사용하려는 프로젝트는 상당한 규모의 호환성 계층을 유지해야 합니다.
- Windows용 Python 배포판은 OpenSSL 사본을 함께 포함하여 배포해야 합니다. 이 때문에 CPython 개발 팀은 OpenSSL 재배포자의 입장에 놓이게 되며, OpenSSL 취약점이 공개되면 Windows용 Python 배포판에 보안 업데이트를 배포해야 할 수도 있습니다.
- macOS용 Python 배포판은 OpenSSL 사본을 함께 포함하여 배포하거나 시스템 OpenSSL 라이브러리에 링크해야 합니다. Apple은 시스템 OpenSSL 라이브러리에 링크하는 것을 공식적으로 지원 중단(deprecated)했으며, 설령 그러지 않았더라도 이 글을 쓰는 시점에 해당 라이브러리 버전은 업스트림에서 거의 1년 동안 지원되지 않은 상태입니다. CPython 개발 팀은 python.org에서 제공하는 Python에 더 새로운 OpenSSL을 포함하여 배포하기 시작했지만, 이 역시 Windows와 같은 문제를 안고 있습니다.
- 사용자는 시스템이 제공하는 구현에 비해 유지 보수 부담이 크다거나, OpenSSL이 자신의 플랫폼에는 너무 크고 다루기 어렵다는(예: 임베디드 Python의 경우) 등의 다른 이유로 OpenSSL 이외의 TLS 라이브러리와 통합하기를 원할 수 있습니다. 이러한 사용자는 선호하는 TLS 라이브러리와 상호 작용할 수 있는 서드파티 네트워킹 라이브러리를 사용하거나, 선호하는 라이브러리를 OpenSSL 전용
ssl모듈 API에 맞춰 끼워 넣어야 하는 상황에 놓입니다.
또한 현재 구현된 ssl 모듈은 대체 TLS 구현에 대한 지원을 추가하거나 OpenSSL 지원을 완전히 제거하는 일이 필요해지거나 유용해질 경우, CPython 자체가 이를 수행할 수 있는 능력을 제한합니다. ssl 모듈은 OpenSSL 전용 함수 호출과 기능을 너무 많이 노출하고 있어서 대체 TLS 구현에 쉽게 대응시킬 수 없습니다.
제안
이 PEP는 OpenSSL에 그다지 강하게 묶이지 않은 TLS 기능을 제공하기 위해 Python 3.14에 몇 가지 새로운 프로토콜 클래스를 도입할 것을 제안합니다. 또한 표준 라이브러리 모듈이 가능한 한 이러한 프로토콜 클래스가 노출하는 인터페이스만 사용하도록 갱신할 것을 제안합니다. 여기에는 세 가지 목표가 있습니다.
- 코어 개발자와 서드파티 개발자 모두가 자신의 TLS 구현을 맞출 수 있는 공통 API 표면을 제공하는 것입니다. 이를 통해 TLS 개발자는 대부분의 Python 코드에서 사용할 수 있는 인터페이스를 제공할 수 있으며, 네트워크 개발자는 다양한 TLS 구현에서 동작하는 인터페이스를 대상으로 삼을 수 있습니다.
- OpenSSL 고유의 개념이 거의 또는 전혀 새어 나오지 않는 API를 제공하기 위해서입니다. 현재
ssl모듈에는 OpenSSL 개념이 API로 새어 나와 생긴 여러 결함이 있습니다. 새로운 프로토콜 클래스는 이러한 특정 개념을 제거할 것입니다. - Python이 TLS를 지원하려면 시스템에 OpenSSL이 반드시 있어야 하는 대신, 핵심 개발 팀이 OpenSSL을 가능한 여러 TLS 구현 중 하나로 만들 수 있는 길을 제공하기 위해서입니다.
제안하는 인터페이스는 아래와 같습니다.
인터페이스
표준화가 필요한 인터페이스가 여러 개 있습니다. 그 인터페이스는 다음과 같습니다:
- TLS 구성. 현재
ssl모듈의SSLContext클래스로 구현되어 있습니다. - 실제 I/O 없이 메모리 내 암호화 또는 복호화를 수행하기 위한 메모리 내 버퍼 제공(비동기 I/O 모델에 필요함). 현재
ssl모듈의SSLObject클래스로 구현되어 있습니다. - 소켓 객체 래핑. 현재
ssl모듈의SSLSocket클래스로 구현되어 있습니다. - (2)와 (3)의 래핑 객체에 TLS 구성 적용. 현재 이것 역시
ssl모듈의 SSLContext 클래스로 구현되어 있습니다. - TLS 암호 스위트 지정. 현재 표준 라이브러리에는 이를 위한 코드가 없습니다. 대신 표준 라이브러리는 OpenSSL 암호 스위트 문자열을 사용합니다.
- TLS 핸드셰이크 중에 협상할 수 있는 애플리케이션 계층 프로토콜 지정.
- TLS 버전 지정.
- 호출자에게 오류 보고. 현재
ssl모듈의SSLError클래스로 구현되어 있습니다. - 클라이언트 인증서 또는 서버 인증서로 로드할 인증서 지정.
- 원격 피어가 제시한 인증서를 검증하는 데 사용할 신뢰 데이터베이스 지정.
- 런타임에 이러한 인터페이스를 얻는 방법 마련.
단순화를 위해 이 PEP는 인터페이스 (3)과 (4)를 제거하고, 소켓을 통한 모든 통신이 TLS로 보호되도록 보장하는 소켓을 반환하는 더 단순한 인터페이스로 대체할 것을 제안합니다. 다시 말해, 이 인터페이스는 소켓 초기화, TLS 핸드셰이크, SNI(Server Name Indication) 등의 개념을 클라이언트 또는 서버 연결 생성의 원자적 일부로 취급합니다. 그러나 메모리 내 버퍼는 비동기 통신에 유용하므로 계속 지원합니다.
당연히 (5)에는 프로토콜 클래스가 필요하지 않습니다. 대신 지원되는 암호 스위트를 구성하기 위한 더 풍부한 API가 필요하며, 이 API는 여러 구현에서 지원하는 암호 스위트로 쉽게 갱신할 수 있어야 합니다.
(9)는 까다로운 문제입니다. 이상적인 세계라면 이러한 인증서와 연결된 개인 키가 Python 프로세스의 메모리에 올라오는 일이 결코 없어야 하기 때문입니다(즉, TLS 라이브러리가 하드웨어 보안 모듈(Hardware Security Module, HSM)과 협력하여 프로세스 메모리에서 추출할 수 없는 방식으로 개인 키를 제공해야 합니다). 따라서 구체적인 구현이 이러한 더 높은 수준의 보안을 제공할 수 있도록 하는 동시에, 그렇게 할 수 없는 구현에는 더 낮은 기준을 허용하는 확장 가능한 인증서 제공 모델을 마련해야 합니다. 이 낮은 기준은 현재 상태와 같습니다. 즉, 인증서를 메모리 내 버퍼나 디스크의 파일에서 로드하거나, 추가로 시스템 인증서 저장소에 대응하는 임의의 ID로 참조할 수 있습니다.
(10) 역시 문제가 됩니다. TLS 구현마다 사용자가 신뢰 저장소를 선택할 수 있게 하는 방식이 매우 다르기 때문입니다. 일부 구현에는 해당 구현만 사용할 수 있는 고유한 신뢰 저장소 형식이 있으며(예: c_rehash로 생성하는 OpenSSL CA 디렉터리 형식), 다른 구현은 기본 신뢰 저장소를 포함하지 않는 신뢰 저장소를 지정하지 못하게 할 수도 있습니다. 반면에 대부분의 구현은 사용자 정의 DER 또는 PEM 인코딩 인증서를 로드하는 기능을 어떤 형태로든 지원할 것입니다.
이러한 이유로, 다른 구현과의 타입 호환성을 유지하면서도 신뢰 저장소가 취하는 형태에 대해서는 가정을 최소화하는 모델을 제공해야 합니다. 아래의 “인증서”, “개인 키”, “신뢰 저장소” 절에서 이를 어떻게 달성하는지 더 자세히 설명합니다.
마지막으로, 이 API는 현재 SSLContext 객체가 떠맡고 있는 책임을 나눕니다. 구체적으로는 구성을 보관하고 관리하는 책임과, 그 구성을 사용하여 버퍼나 소켓을 만드는 책임입니다.
이는 주로 서버 이름 표시(Server Name Indication, SNI)와 같은 기능을 지원하기 위해 필요합니다. OpenSSL에서는(따라서 ssl 모듈에서도) 클라이언트가 접속하려는 호스트 이름을 서버에 알려 주면, 서버가 이에 대응하여 TLS 구성을 수정할 수 있습니다. 이는 주로 주어진 호스트 이름에 맞는 올바른 TLS 인증서 체인을 제시하도록 인증서 체인을 변경하는 데 사용됩니다. 이를 수행하는 구체적인 방식은, 사용자가 제공한 SNI 콜백 함수 안에서 적절한 구성을 갖춘 새 SSLContext 객체를 반환하는 것입니다.
이는 다른 TLS 구현에 잘 대응되지 않는 모델이며, 사용자에게 콜백 함수를 작성해야 하는 부담을 지웁니다. 그 대신, 구체 구현이 관련 인증서를 받은 뒤에는 모든 사용자에 대해 SNI를 투명하게 처리할 것을 제안합니다.
이러한 이유로, SSLContext의 책임을 두 개의 별도 객체로 나누며, 각 객체는 다시 서버용과 클라이언트용으로 나뉩니다. TLSServerConfiguration 및 TLSClientConfiguration 객체는 TLS 구성을 담는 컨테이너 역할을 합니다. ClientContext 객체와 ServerContext 객체는 각각 TLSClientConfiguration 객체와 TLSServerConfiguration 객체로 인스턴스화되며, 버퍼나 소켓을 만드는 데 사용됩니다. 네 객체는 모두 불변입니다.
Note
이어지는 API 선언은 읽기 쉽도록 일관되게 타입 힌트를 사용합니다.
구성
TLSServerConfiguration 및 TLSClientConfiguration 구체 클래스는 TLS 구성을 보관하고 관리할 수 있는 객체를 정의합니다. 이 클래스들의 목표는 다음과 같습니다.
- 입력 오류의 위험을 피할 수 있는 TLS 구성 지정 방법을 제공합니다(따라서 단순한 딕셔너리는 사용하지 않습니다).
- SNI 콜백에서 사용할 수 있도록, TLS 구성의 변경을 감지하기 위해 다른 구성 객체와 안전하게 비교할 수 있는 객체를 제공합니다.
이 클래스들은 프로토콜 클래스가 아닌데, 주된 이유는 구현별로 다른 동작을 가질 것으로 예상되지 않기 때문입니다. TLSServerConfiguration 또는 TLSClientConfiguration 객체를 특정 TLS 구현에 유용한 구성 집합으로 변환하는 책임은 아래에서 다룰 Context 객체에 있습니다.
이 클래스들에는 주목할 만한 속성이 하나 더 있습니다. 바로 불변이라는 점입니다. 이는 몇 가지 이유로 바람직한 특성입니다. 가장 중요한 이유는 기본적으로 불변으로 만드는 것이 좋은 엔지니어링 관행이라는 점입니다. 부수적인 이점으로, 이 객체들을 딕셔너리 키로 사용할 수 있게 되는데, 이는 특정 TLS 구현과 그 SNI 구성에 유용할 수 있습니다. 게다가 구현은 자신도 모르는 사이에 구성 객체가 변경될까 걱정할 필요가 없어지므로, 구체적인 자료 구조와 구성 객체 사이의 변경 사항을 신중하게 동기화해야 하는 수고를 덜 수 있습니다.
이 객체들은 확장 가능합니다. 즉, 향후 Python 릴리스에서 유용해지는 설정 필드를 이 객체들에 추가할 수 있습니다. 하위 호환성을 위해, 새 필드는 이 객체들의 끝에만 추가됩니다. 기존 필드는 절대 제거되거나, 이름이 바뀌거나, 순서가 바뀌지 않습니다. API의 혼란을 최소화하기 위해 클라이언트용과 서버용으로 나뉘어 있습니다.
TLSClientConfiguration 클래스는 다음 코드로 정의됩니다.
class TLSClientConfiguration:
__slots__ = (
"_certificate_chain",
"_ciphers",
"_inner_protocols",
"_lowest_supported_version",
"_highest_supported_version",
"_trust_store",
)
def __init__(
self,
certificate_chain: SigningChain | None = None,
ciphers: Sequence[CipherSuite] | None = None,
inner_protocols: Sequence[NextProtocol | bytes] | None = None,
lowest_supported_version: TLSVersion | None = None,
highest_supported_version: TLSVersion | None = None,
trust_store: TrustStore | None = None,
) -> None:
if inner_protocols is None:
inner_protocols = []
self._certificate_chain = certificate_chain
self._ciphers = ciphers
self._inner_protocols = inner_protocols
self._lowest_supported_version = lowest_supported_version
self._highest_supported_version = highest_supported_version
self._trust_store = trust_store
@property
def certificate_chain(self) -> SigningChain | None:
return self._certificate_chain
@property
def ciphers(self) -> Sequence[CipherSuite | int] | None:
return self._ciphers
@property
def inner_protocols(self) -> Sequence[NextProtocol | bytes]:
return self._inner_protocols
@property
def lowest_supported_version(self) -> TLSVersion | None:
return self._lowest_supported_version
@property
def highest_supported_version(self) -> TLSVersion | None:
return self._highest_supported_version
@property
def trust_store(self) -> TrustStore | None:
return self._trust_store
TLSServerConfiguration 객체는 클라이언트용 객체와 비슷하지만, certificate_chain 매개변수로 Sequence[SigningChain]을 받는다는 점이 다릅니다.
컨텍스트
두 가지 Context 프로토콜 클래스를 정의합니다. 이 프로토콜 클래스들은 TLS 구성을 특정 연결에 적용할 수 있게 하는 객체를 정의합니다. 이 클래스들은 TLSSocket 및 TLSBuffer 객체를 만드는 팩토리로 생각할 수 있습니다.
현재의 ssl 모듈과 달리, 컨텍스트 클래스를 하나가 아닌 두 개 제공합니다. 구체적으로는 ClientContext와 ServerContext 클래스를 제공합니다. 이렇게 하면 API가 단순해지고(예를 들어, 서버가 wrap_socket()에 server_hostname 매개변수를 제공하는 것은 의미가 없지만, 컨텍스트 클래스가 하나뿐이므로 그 매개변수를 여전히 사용할 수 있습니다), 구현이 TLS 연결의 어느 쪽을 담당할지 가능한 한 일찍 알 수 있습니다. 또한 구현이 연결의 한쪽 또는 양쪽 모두를 지원하지 않도록 선택할 수 있습니다.
구현자는 가능한 한 이 클래스들을 불변으로 만드는 것을 목표로 해야 합니다. 즉, 사용자가 내부 상태를 직접 변경하도록 허용하지 않고, 대신 새 TLSConfiguration 객체로부터 새 컨텍스트를 만드는 방식을 택해야 합니다. 물론 프로토콜 클래스는 이 제약을 강제할 수 없으므로, 강제하려 하지도 않습니다.
ClientContext 프로토콜 클래스의 클래스 정의는 다음과 같습니다:
class ClientContext(Protocol):
@abstractmethod
def __init__(self, configuration: TLSClientConfiguration) -> None:
"""Create a new client context object from a given TLS client configuration."""
...
@property
@abstractmethod
def configuration(self) -> TLSClientConfiguration:
"""Returns the TLS client configuration that was used to create the client context."""
...
@abstractmethod
def connect(self, address: tuple[str | None, int]) -> TLSSocket:
"""Creates a TLSSocket that behaves like a socket.socket, and
contains information about the TLS exchange
(cipher, negotiated_protocol, negotiated_tls_version, etc.).
"""
...
@abstractmethod
def create_buffer(self, server_hostname: str) -> TLSBuffer:
"""Creates a TLSBuffer that acts as an in-memory channel,
and contains information about the TLS exchange
(cipher, negotiated_protocol, negotiated_tls_version, etc.)."""
...
ServerContext도 비슷하며, 대신 TLSServerConfiguration을 받습니다.
소켓
컨텍스트를 사용하여 소켓을 만들 수 있으며, 이 소켓은 TLSSocket 프로토콜 클래스의 명세를 따라야 합니다. 구체적으로, 구현은 다음을 구현해야 합니다:
recv와sendlisten과acceptclosegetsocknamegetpeername
또한 다음과 같이 TLS 연결에 관한 정보를 제공하는 몇 가지 인터페이스도 구현해야 합니다:
- 이 소켓을 만드는 데 사용된 기반 컨텍스트 객체
- 협상된 암호
- 협상된 “next” 프로토콜
- 협상된 TLS 버전
다음 코드는 이 함수들을 더 자세히 설명합니다:
class TLSSocket(Protocol):
"""This class implements a socket.socket-like object that creates an OS
socket, wraps it in an SSL context, and provides read and write methods
over that channel."""
@abstractmethod
def __init__(self, *args: tuple, **kwargs: tuple) -> None:
"""TLSSockets should not be constructed by the user.
The implementation should implement a method to construct a TLSSocket
object and call it in ClientContext.connect() and
ServerContext.connect()."""
...
@abstractmethod
def recv(self, bufsize: int) -> bytes:
"""Receive data from the socket. The return value is a bytes object
representing the data received. Should not work before the handshake
is completed."""
...
@abstractmethod
def send(self, bytes: bytes) -> int:
"""Send data to the socket. The socket must be connected to a remote socket."""
...
@abstractmethod
def close(self, force: bool = False) -> None:
"""Shuts down the connection and mark the socket closed.
If force is True, this method should send the close_notify alert and shut down
the socket without waiting for the other side.
If force is False, this method should send the close_notify alert and raise
the WantReadError exception until a corresponding close_notify alert has been
received from the other side.
In either case, this method should return WantWriteError if sending the
close_notify alert currently fails."""
...
@abstractmethod
def listen(self, backlog: int) -> None:
"""Enable a server to accept connections. If backlog is specified, it
specifies the number of unaccepted connections that the system will allow
before refusing new connections."""
...
@abstractmethod
def accept(self) -> tuple[TLSSocket, tuple[str | None, int]]:
"""Accept a connection. The socket must be bound to an address and listening
for connections. The return value is a pair (conn, address) where conn is a
new TLSSocket object usable to send and receive data on the connection, and
address is the address bound to the socket on the other end of the connection."""
...
@abstractmethod
def getsockname(self) -> tuple[str | None, int]:
"""Return the local address to which the socket is connected."""
...
@abstractmethod
def getpeercert(self) -> bytes | None:
"""
Return the raw DER bytes of the certificate provided by the peer
during the handshake, if applicable.
"""
...
@abstractmethod
def getpeername(self) -> tuple[str | None, int]:
"""Return the remote address to which the socket is connected."""
...
@property
@abstractmethod
def context(self) -> ClientContext | ServerContext:
"""The ``Context`` object this socket is tied to."""
...
@abstractmethod
def cipher(self) -> CipherSuite | int | None:
"""
Returns the CipherSuite entry for the cipher that has been negotiated on the connection.
If no connection has been negotiated, returns ``None``. If the cipher negotiated is not
defined in CipherSuite, returns the 16-bit integer representing that cipher directly.
"""
...
@abstractmethod
def negotiated_protocol(self) -> NextProtocol | bytes | None:
"""
Returns the protocol that was selected during the TLS handshake.
This selection may have been made using ALPN or some future
negotiation mechanism.
If the negotiated protocol is one of the protocols defined in the
``NextProtocol`` enum, the value from that enum will be returned.
Otherwise, the raw bytestring of the negotiated protocol will be
returned.
If ``Context.set_inner_protocols()`` was not called, if the other
party does not support protocol negotiation, if this socket does
not support any of the peer's proposed protocols, or if the
handshake has not happened yet, ``None`` is returned.
"""
...
@property
@abstractmethod
def negotiated_tls_version(self) -> TLSVersion | None:
"""The version of TLS that has been negotiated on this connection."""
...
버퍼
컨텍스트를 사용하여 버퍼를 만들 수도 있으며, 이 버퍼는 TLSBuffer 프로토콜 클래스의 명세를 따라야 합니다. 구체적으로, 구현은 다음을 구현해야 합니다:
read와writedo_handshakeshutdownprocess_incoming과process_outgoingincoming_bytes_buffered와outgoing_bytes_bufferedgetpeercert
소켓의 경우와 마찬가지로, 이들도 TLS 연결에 관한 정보를 제공하는 몇 가지 인터페이스를 구현해야 합니다. 예를 들면 다음과 같습니다:
- 이 버퍼를 생성하는 데 사용된 기반 컨텍스트 객체
- 협상된 암호
- 협상된 “next” 프로토콜
- 협상된 TLS 버전
다음 코드는 이 함수들을 더 자세히 설명합니다:
class TLSBuffer(Protocol):
"""This class implements an in memory-channel that creates two buffers,
wraps them in an SSL context, and provides read and write methods over
that channel."""
@abstractmethod
def read(self, amt: int, buffer: Buffer | None) -> bytes | int:
"""
Read up to ``amt`` bytes of data from the input buffer and return
the result as a ``bytes`` instance. If an optional buffer is
provided, the result is written into the buffer and the number of
bytes is returned instead.
Once EOF is reached, all further calls to this method return the
empty byte string ``b''``.
May read "short": that is, fewer bytes may be returned than were
requested.
Raise ``WantReadError`` or ``WantWriteError`` if there is
insufficient data in either the input or output buffer and the
operation would have caused data to be written or read.
May raise ``RaggedEOF`` if the connection has been closed without a
graceful TLS shutdown. Whether this is an exception that should be
ignored or not is up to the specific application.
As at any time a re-negotiation is possible, a call to ``read()``
can also cause write operations.
"""
...
@abstractmethod
def write(self, buf: Buffer) -> int:
"""
Write ``buf`` in encrypted form to the output buffer and return the
number of bytes written. The ``buf`` argument must be an object
supporting the buffer interface.
Raise ``WantReadError`` or ``WantWriteError`` if there is
insufficient data in either the input or output buffer and the
operation would have caused data to be written or read. In either
case, users should endeavour to resolve that situation and then
re-call this method. When re-calling this method users *should*
re-use the exact same ``buf`` object, as some implementations require that
the exact same buffer be used.
This operation may write "short": that is, fewer bytes may be
written than were in the buffer.
As at any time a re-negotiation is possible, a call to ``write()``
can also cause read operations.
"""
...
@abstractmethod
def do_handshake(self) -> None:
"""
Performs the TLS handshake. Also performs certificate validation
and hostname verification.
"""
...
@abstractmethod
def cipher(self) -> CipherSuite | int | None:
"""
Returns the CipherSuite entry for the cipher that has been
negotiated on the connection. If no connection has been negotiated,
returns ``None``. If the cipher negotiated is not defined in
CipherSuite, returns the 16-bit integer representing that cipher
directly.
"""
...
@abstractmethod
def negotiated_protocol(self) -> NextProtocol | bytes | None:
"""
Returns the protocol that was selected during the TLS handshake.
This selection may have been made using ALPN, NPN, or some future
negotiation mechanism.
If the negotiated protocol is one of the protocols defined in the
``NextProtocol`` enum, the value from that enum will be returned.
Otherwise, the raw bytestring of the negotiated protocol will be
returned.
If ``Context.set_inner_protocols()`` was not called, if the other
party does not support protocol negotiation, if this socket does
not support any of the peer's proposed protocols, or if the
handshake has not happened yet, ``None`` is returned.
"""
...
@property
@abstractmethod
def context(self) -> ClientContext | ServerContext:
"""
The ``Context`` object this buffer is tied to.
"""
...
@property
@abstractmethod
def negotiated_tls_version(self) -> TLSVersion | None:
"""
The version of TLS that has been negotiated on this connection.
"""
...
@abstractmethod
def shutdown(self) -> None:
"""
Performs a clean TLS shut down. This should generally be used
whenever possible to signal to the remote peer that the content is
finished.
"""
...
@abstractmethod
def process_incoming(self, data_from_network: bytes) -> None:
"""
Receives some TLS data from the network and stores it in an
internal buffer.
If the internal buffer is overfull, this method will raise
``WantReadError`` and store no data. At this point, the user must
call ``read`` to remove some data from the internal buffer
before repeating this call.
"""
...
@abstractmethod
def incoming_bytes_buffered(self) -> int:
"""
Returns how many bytes are in the incoming buffer waiting to be processed.
"""
...
@abstractmethod
def process_outgoing(self, amount_bytes_for_network: int) -> bytes:
"""
Returns the next ``amt`` bytes of data that should be written to
the network from the outgoing data buffer, removing it from the
internal buffer.
"""
...
@abstractmethod
def outgoing_bytes_buffered(self) -> int:
"""
Returns how many bytes are in the outgoing buffer waiting to be sent.
"""
...
@abstractmethod
def getpeercert(self) -> bytes | None:
"""
Return the raw DER bytes of the certificate provided by the peer
during the handshake, if applicable.
"""
...
암호 스위트
진정으로 라이브러리에 독립적인 방식으로 암호 스위트를 지원하는 것은 매우 어려운 일입니다. 서로 다른 TLS 구현은 암호 스위트를 지정하는 API가 근본적으로 다른 경우가 많은데, 더 문제가 되는 점은 이러한 API가 스타일뿐만 아니라 기능 면에서도 자주 다르다는 것입니다.
아래는 여러 가지 암호 스위트 선택 API의 예입니다. 이 예들은 각 API에 맞춘 구현을 의무화하려는 것이 아니라, 각 API가 부과하는 제약을 보여 주기 위한 것일 뿐입니다.
OpenSSL
OpenSSL은 널리 알려진 암호 문자열 형식을 사용합니다. 이 형식은 Python을 비롯하여 OpenSSL을 사용하는 대부분의 제품에서 설정 언어로 채택되었습니다. 이 형식은 비교적 읽기 쉽지만 여러 가지 단점이 있습니다. 문자열이기 때문에 잘못된 입력을 제공하기 쉽고, 세부적인 검증이 부족하여 OpenSSL이 어떤 암호도 협상할 수 없도록 설정하는 것이 가능하며, 암호 스위트를 여러 가지 다른 방식으로 지정할 수 있어 파싱하기 까다롭습니다. 이 형식의 가장 큰 문제는 공식 명세가 없다는 것이며, 이는 주어진 문자열을 OpenSSL과 같은 방식으로 파싱하는 유일한 방법이 OpenSSL에게 파싱을 맡기는 것뿐이라는 뜻입니다.
OpenSSL의 암호 문자열은 다음과 같은 모습일 수 있습니다:
"ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+CHACHA20:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!eNULL:!MD5"
이 문자열은 OpenSSL 형식의 복잡성을 일부 보여 줍니다. 예를 들어, 하나의 항목이 여러 암호 스위트를 지정할 수 있습니다. ECDH+AESGCM 항목은 “타원 곡선 Diffie-Hellman 키 교환과 Galois Counter Mode의 AES를 모두 포함하는 모든 암호 스위트”를 의미합니다. 더 명시적으로 말하면, 이는 네 개의 암호 스위트로 확장됩니다:
"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256"
이 때문에 완전한 OpenSSL 암호 문자열을 파싱하는 것은 극도로 까다롭습니다. 여기에 “!”(이 기준에 맞는 모든 암호 스위트를, 다른 항목에 의해 포함될 경우라도 제외합니다. “!MD5”는 MD5 해시 알고리즘을 사용하는 암호 스위트를 하나도 포함하지 않아야 한다는 뜻입니다), “-”(일치하는 암호가 이미 포함되어 있으면 제외하되, 나중에 다시 포함되면 재추가를 허용합니다), “+”(일치하는 암호를 포함하되 목록의 끝에 배치합니다)와 같은 다른 메타 문자들이 있다는 사실까지 더하면, 파싱하기 극도로 복잡한 형식이 됩니다. 이러한 복잡성에 더해, OpenSSL 암호 문자열은 OpenSSL이 인식하는 암호를 하나 이상 포함하기만 하면 유효하므로 실제 결과는 OpenSSL 버전에 따라 달라진다는 점에 유의해야 합니다.
또한 OpenSSL은 관련 명세에서 사용되는 이름과는 다른 이름을 암호에 사용합니다. 자세한 내용은 ciphers(1)의 매뉴얼 페이지를 참조하십시오.
암호 문자열을 위한 OpenSSL 내부의 실제 API는 간단합니다:
char *cipher_list = <some cipher list>;
int rc = SSL_CTX_set_cipher_list(context, cipher_list);
이는 이 모듈에서 사용하는 모든 형식이 OpenSSL과 함께 사용할 수 있도록 OpenSSL 암호 문자열로 변환될 수 있어야 한다는 것을 의미합니다.
Network 프레임워크
Network 프레임워크는 macOS(10.15+)의 시스템 TLS 라이브러리입니다. 이 라이브러리는 사용자층이 훨씬 더 한정되어 있기 때문에 여러 면에서 OpenSSL보다 상당히 더 제한적입니다. 이러한 상당한 제약 중 하나는 지원되는 암호 스위트를 제어하는 부분에 있습니다.
Network 프레임워크에서 암호는 Objective-C uint16_t 열거형으로 표현됩니다. 이 열거형은 암호 스위트마다 하나의 항목을 가지며 여러 스위트를 묶는 집합 항목은 없습니다. 따라서 각 열거형 멤버가 어떤 범주에 속하는지 직접 코딩하지 않고서는 “ECDH+AESGCM”과 같은 OpenSSL 암호 문자열의 의미를 재현할 수 없습니다.
그러나 대부분의 열거형 멤버 이름은 암호 스위트의 공식 이름과 일치합니다. 즉, OpenSSL이 “ECDHE-ECDSA-AES256-GCM-SHA384”라고 부르는 암호 스위트를 Network 프레임워크에서는 “tls_ciphersuite_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384”라고 부릅니다.
Network 프레임워크 안에서 암호 스위트를 설정하는 API는 간단합니다.
void sec_protocol_options_append_tls_ciphersuite(sec_protocol_options_t options, tls_ciphersuite_t ciphersuite);
SChannel
SChannel은 Windows의 시스템 TLS 라이브러리입니다.
SChannel은 사용 가능한 TLS 암호 스위트를 제어하는 기능을 극히 제한적으로 지원하며, 어떤 TLS 암호 스위트가 지원되는지를 표현하는 세 번째 방식을 추가로 채택하고 있습니다.
구체적으로, SChannel은 ALG_ID 상수(C unsigned int)의 집합을 정의합니다. 이 상수들은 각각 암호 스위트 전체를 가리키는 것이 아니라 개별 알고리즘을 가리킵니다. 몇 가지 예를 들면, CALG_3DES와 CALG_AES_256은 암호 스위트에서 사용되는 대량 암호화 알고리즘을 가리키고, CALG_ECDH_EPHEM과 CALG_RSA_KEYX는 암호 스위트에서 사용되는 키 교환 알고리즘의 일부를 가리키며, CALG_SHA_256과 CALG_SHA_384는 암호 스위트에서 사용되는 메시지 인증 코드를 가리키고, CALG_ECDSA와 CALG_RSA_SIGN은 키 교환 알고리즘의 서명 부분을 가리킵니다.
SChannel API의 이전 버전에서는 이 상수들을 사용하여 사용할 수 있는 알고리즘을 정의했습니다. 그러나 최신 버전에서는 이 상수들을 사용하여 사용할 수 없는 알고리즘을 지정합니다.
이는 OpenSSL 기능 가운데 Network 프레임워크에 없는 절반이라고 생각할 수 있습니다. Network 프레임워크는 정확한 암호 스위트(와 미리 정의된 소수의 암호 스위트 그룹)만 지정할 수 있는 반면, SChannel은 암호 스위트의 일부만 지정할 수 있고, OpenSSL은 두 가지를 모두 허용합니다.
주어진 연결에서 어떤 암호 스위트가 허용되는지는 이러한 ALG_ID 상수 배열에 대한 포인터를 제공하여 결정합니다. 이는 적합한 API라면 어떤 ALG_ID 상수를 제공해야 하는지를 Python 코드가 결정할 수 있도록 해야 한다는 뜻입니다.
Network Security Services(NSS)
NSS는 Mozilla의 암호화 및 TLS 라이브러리입니다. NSS는 Firefox와 Thunderbird에서 사용되며, curl 등 여러 라이브러리에서 OpenSSL의 대안으로도 사용됩니다.
NSS는 기본적으로 허용되는 암호에 대한 안전한 구성을 갖추고 있습니다. Fedora와 같은 일부 플랫폼에서는 활성화된 암호 목록이 시스템 정책에서 전역적으로 구성됩니다. 일반적으로 애플리케이션은 특별한 이유가 없는 한 암호 스위트를 수정해서는 안 됩니다.
NSS에는 암호 스위트에 대한 프로세스 전역 설정과 연결별 설정이 모두 있습니다. NSS에는 OpenSSL과 같은 SSLContext 개념이 없습니다. SSLContext와 유사한 동작은 쉽게 흉내 낼 수 있습니다. 구체적으로, 암호는 SSL_CipherPrefSetDefault(PRInt32 cipher, PRBool enabled)로 전역적으로 활성화하거나 비활성화할 수 있고, 연결에 대해서는 SSL_CipherPrefSet(PRFileDesc *fd, PRInt32 cipher, PRBool enabled)로 활성화하거나 비활성화할 수 있습니다. 암호의 PRInt32 번호는 등록된 IANA id에 직접 대응하는 부호 있는 32비트 정수입니다. 예를 들어 0x1301은 TLS_AES_128_GCM_SHA256입니다. OpenSSL과 달리 암호의 우선순위는 고정되어 있으며 런타임에 수정할 수 없습니다.
Network 프레임워크와 마찬가지로 NSS에도 집계 항목을 위한 API가 없습니다. NSS를 사용하는 일부 프로젝트는 OpenSSL 암호 이름과 규칙을 NSS 암호로 변환하는 자체 매핑을 구현했습니다(예: mod_nss).
제안하는 인터페이스
새 모듈에 제안하는 인터페이스는 앞서 설명한 구현들의 제약 사항을 모두 합한 것에 영향을 받습니다. 구체적으로 말하면, OpenSSL을 제외한 모든 구현은 개별 암호를 하나하나 제공하도록 요구하므로 그러한 최소 공통분모 방식을 제공할 수밖에 없습니다.
가장 간단한 방법은 TLS에 정의된 암호 스위트 중 상당 부분을 포함하는 열거형을 제공하는 것입니다. 열거형 멤버의 값은 TLS 핸드셰이크에서 사용하는 2옥텟 암호 식별자이며, 16비트 정수로 저장됩니다. 열거형 멤버의 이름은 IANA에 등록된 암호 스위트 이름입니다.
현재 IANA 암호 스위트 레지스트리에는 320개가 넘는 암호 스위트가 들어 있습니다. 이 암호 스위트 중 상당 부분은 네트워크 서비스에 대한 TLS 연결과 관련이 없습니다. 다른 스위트는 폐기되었거나 안전하지 않아 최신 버전의 구현에서 더 이상 제공하지 않는 알고리즘을 지정합니다. 이 열거형에는 TLS v1.3에 정의된 고정 암호 스위트 다섯 개가 들어 있습니다. TLS v1.2의 경우에는 TLS v1.3 암호 스위트에 대응하면서 (완전 순방향 비밀성을 위한) ECDHE 키 교환과 ECDSA 또는 RSA 서명을 사용하는 암호 스위트만 들어 있으며, 이는 추가로 열 개의 암호 스위트입니다.
이 열거형 외에도 인터페이스는 TLS v1.2용 기본 암호 스위트 목록을 정의하며, 이 목록에는 정의된 암호 스위트 중 AES-GCM이나 ChaCha20-Poly1305를 기반으로 하는 것만 들어 있습니다. TLS v1.3용 기본 암호 스위트 목록은 명세에 정의된 암호 스위트 다섯 개로 구성됩니다.
현재 열거형은 순방향 비밀성을 제공하는 암호 스위트만 포함하므로 상당히 제한적입니다. 열거형에 정의된 암호가 모두 들어 있지는 않으며 앞으로 나올 애플리케이션도 지원할 수 있어야 하므로, 이 API에서 CipherSuite 객체를 받는 모든 부분은 원시 16비트 정수도 직접 받습니다.
class CipherSuite(IntEnum):
"""
Known cipher suites.
See: <https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml>
"""
TLS_AES_128_GCM_SHA256 = 0x1301
TLS_AES_256_GCM_SHA384 = 0x1302
TLS_CHACHA20_POLY1305_SHA256 = 0x1303
TLS_AES_128_CCM_SHA256 = 0x1304
TLS_AES_128_CCM_8_SHA256 = 0x1305
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 = 0xC02B
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 = 0xC02C
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 = 0xC02F
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 = 0xC030
TLS_ECDHE_ECDSA_WITH_AES_128_CCM = 0xC0AC
TLS_ECDHE_ECDSA_WITH_AES_256_CCM = 0xC0AD
TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8 = 0xC0AE
TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8 = 0xC0AF
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 = 0xCCA8
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 = 0xCCA9
Network 프레임워크의 경우 이 열거형 멤버는 암호 스위트 상수의 값을 직접 가리킵니다. 예를 들어 Network 프레임워크는 암호 스위트 열거형 멤버 tls_ciphersuite_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384의 값을 0xC02C로 정의합니다. 우연이 아니게도 이 값은 위 열거형에서의 값과 같습니다. 따라서 Network 프레임워크와 위 열거형 사이의 매핑은 정말 쉽습니다.
SChannel은 암호 스위트가 아니라 암호를 설정하기 때문에 쉽고 직접적인 매핑이 없습니다. 이는 SChannel이 가진 지속적인 문제를 보여 줍니다. 즉, 다른 TLS 구현에 비해 특정한 방식으로 설정하기가 매우 어렵다는 점입니다.
이 PEP의 목적상 모든 SChannel 구현은 열거형 멤버를 바탕으로 어떤 암호를 선택할지 결정해야 합니다. 구현의 선택에 따라, 이는 실제 암호 스위트 목록이 허용하려는 것보다 더 개방적일 수도 있고 더 제한적일 수도 있습니다. 이 PEP는 더 제한적인 쪽을 권장하지만, 물론 이를 강제할 수는 없습니다.
마지막으로, 대부분의 사용자에게는 안전한 기본값으로 충분할 것이라고 예상합니다. 암호 목록을 지정하지 않으면 구현은 (시스템 권장 설정에서 가져왔을 수도 있는) 안전한 기본값을 사용해야 합니다.
프로토콜 협상
ALPN은 HTTP/2 핸드셰이크의 일부로 프로토콜 협상을 할 수 있게 합니다. ALPN은 근본적으로 바이트열을 기반으로 하지만, 문자열 기반 API는 알아채기 어려운 오타를 허용하므로 문제가 되는 경우가 많습니다.
이러한 이유로 이 모듈은 프로토콜 협상 구현이 주고받을 수 있는 타입을 정의합니다. 이 타입은 바이트열을 감싸서 잘 알려진 프로토콜에 별칭을 쓸 수 있게 합니다. 이를 통해 잘 알려진 프로토콜의 오타에서 비롯되는 문제를 피하면서도, 필요한 경우 사용자가 바이트 문자열을 직접 전달할 수 있게 하여 프로토콜 협상 계층의 완전한 확장성을 허용할 수 있습니다.
class NextProtocol(Enum):
"""The underlying negotiated ("next") protocol."""
H2 = b"h2"
H2C = b"h2c"
HTTP1 = b"http/1.1"
WEBRTC = b"webrtc"
C_WEBRTC = b"c-webrtc"
FTP = b"ftp"
STUN = b"stun.nat-discovery"
TURN = b"stun.turn"
TLS 버전
지원할 TLS 버전을 제한할 수 있으면 유용한 경우가 많습니다. 오래된 TLS 버전의 사용을 거부하면 보안상 이점이 많으며, 일부 오작동하는 서버는 더 새로운 버전을 지원한다고 알리는 TLS 클라이언트를 잘못 처리합니다.
다음 열거형을 사용하여 TLS 버전을 제한할 수 있습니다. 앞을 내다보는 애플리케이션은 꼭 필요한 경우가 아니라면 최대 TLS 버전을 거의 설정하지 않아야 합니다. 이를 사용하는 Python보다 더 새로운 TLS 구현은 이 열거형에 없는 TLS 버전을 지원할 수 있기 때문입니다.
또한 이 열거형은 구현이 지원하는 가장 낮은 TLS 버전이나 가장 높은 TLS 버전을 요청하는 데 언제나 사용할 수 있는 두 가지 추가 플래그를 정의합니다. 암호 스위트와 마찬가지로, 대부분의 사용자에게는 안전한 기본값으로 충분할 것으로 예상합니다. TLS 버전 목록을 지정하지 않으면 구현은 안전한 기본값(시스템 권장 설정에서 가져온 값일 수 있음)을 사용해야 합니다.
class TLSVersion(Enum):
"""
TLS versions.
The `MINIMUM_SUPPORTED` and `MAXIMUM_SUPPORTED` variants are "open ended",
and refer to the "lowest mutually supported" and "highest mutually supported"
TLS versions, respectively.
"""
MINIMUM_SUPPORTED = "MINIMUM_SUPPORTED"
TLSv1_2 = "TLSv1.2"
TLSv1_3 = "TLSv1.3"
MAXIMUM_SUPPORTED = "MAXIMUM_SUPPORTED"
오류
이 모듈은 오류 처리에 사용할 네 가지 베이스 클래스를 정의합니다. 여기에서 정의하는 다른 많은 클래스와 달리, 이 클래스들은 동작이 없으므로 추상 클래스가 아닙니다. 이 클래스들은 단지 특정한 공통 동작을 알리기 위해 존재합니다. TLS 구현은 자체 패키지에서 이 예외들의 서브클래스를 만들어야 하지만, 그에 대한 동작을 정의할 필요는 없습니다.
일반적으로 구체적인 구현은 이 예외들을 직접 발생시키기보다는 서브클래스를 만들어야 합니다. 이렇게 하면 예기치 않은 오류를 디버깅할 때 어떤 구체적인 TLS 구현이 사용 중인지 파악하기가 어느 정도 쉬워집니다. 다만 이는 필수 사항이 아닙니다.
오류의 정의는 다음과 같습니다:
class TLSError(Exception):
"""
The base exception for all TLS related errors from any implementation.
Catching this error should be sufficient to catch *all* TLS errors,
regardless of what implementation is used.
"""
class WantWriteError(TLSError):
"""
A special signaling exception used only when non-blocking or buffer-only I/O is used.
This error signals that the requested
operation cannot complete until more data is written to the network,
or until the output buffer is drained.
This error is should only be raised when it is completely impossible
to write any data. If a partial write is achievable then this should
not be raised.
"""
class WantReadError(TLSError):
"""
A special signaling exception used only when non-blocking or buffer-only I/O is used.
This error signals that the requested
operation cannot complete until more data is read from the network, or
until more data is available in the input buffer.
This error should only be raised when it is completely impossible to
write any data. If a partial write is achievable then this should not
be raised.
"""
class RaggedEOF(TLSError):
"""A special signaling exception used when a TLS connection has been
closed gracelessly: that is, when a TLS CloseNotify was not received
from the peer before the underlying TCP socket reached EOF. This is a
so-called "ragged EOF".
This exception is not guaranteed to be raised in the face of a ragged
EOF: some implementations may not be able to detect or report the
ragged EOF.
This exception is not always a problem. Ragged EOFs are a concern only
when protocols are vulnerable to length truncation attacks. Any
protocol that can detect length truncation attacks at the application
layer (e.g. HTTP/1.1 and HTTP/2) is not vulnerable to this kind of
attack and so can ignore this exception.
"""
class ConfigurationError(TLSError):
"""An special exception that implementations can use when the provided
configuration uses features not supported by that implementation."""
인증서
이 모듈은 구체적인 인증서 클래스를 정의합니다. 이 모듈의 목표는 X.509 인증서가 제공할 수 있는 모든 관련 암호화 기능을 제공하는 것이 아니므로, 이 클래스에는 동작이 거의 없습니다. 그 대신 필요한 것은 인증서의 출처를 구체적인 구현에 알리는 기능뿐입니다.
그런 이유로 이 인증서 클래스는 구상된 세 가지 생성자, 즉 파일에서 얻은 인증서, 메모리에서 얻은 인증서, 임의의 식별자에서 얻은 인증서에 대응하는 세 가지 속성을 정의합니다. 구현이 이 생성자들을 모두 지원하지 않을 수도 있으며, 이 경우 아래 “런타임” 절에서 설명하는 방식으로 사용자에게 이를 알릴 수 있습니다. 특히 임의의 식별자에서 얻은 인증서는 주로 HSM, TPM, SSM 등을 기반으로 통합을 구축하려는 사용자에게 유용할 것으로 예상합니다.
구체적으로 말하면, 이 클래스는 제공된 입력을 파싱하여 올바른 인증서인지 검증하지 않으며, 특정 인증서에 대한 어떠한 형태의 인트로스펙션 기능도 제공하지 않습니다. TLS 구현 역시 그러한 인트로스펙션 기능을 제공할 필요가 없습니다. 핸드셰이크 중에 수신한 피어 인증서는 가공되지 않은 DER 바이트로 제공됩니다.
class Certificate:
"""Object representing a certificate used in TLS."""
__slots__ = (
"_buffer",
"_path",
"_id",
)
def __init__(
self, buffer: bytes | None = None, path: os.PathLike[str] | None = None, id: bytes | None = None
):
"""
Creates a Certificate object from a path, buffer, or ID.
If none of these is given, an exception is raised.
"""
if buffer is None and path is None and id is None:
raise ValueError("Certificate cannot be empty.")
self._buffer = buffer
self._path = path
self._id = id
@classmethod
def from_buffer(cls, buffer: bytes) -> Certificate:
"""
Creates a Certificate object from a byte buffer. This byte buffer
may be either PEM-encoded or DER-encoded. If the buffer is PEM
encoded it *must* begin with the standard PEM preamble (a series of
dashes followed by the ASCII bytes "BEGIN CERTIFICATE" and another
series of dashes). In the absence of that preamble, the
implementation may assume that the certificate is DER-encoded
instead.
"""
return cls(buffer=buffer)
@classmethod
def from_file(cls, path: os.PathLike[str]) -> Certificate:
"""
Creates a Certificate object from a file on disk. The file on disk
should contain a series of bytes corresponding to a certificate that
may be either PEM-encoded or DER-encoded. If the bytes are PEM encoded
it *must* begin with the standard PEM preamble (a series of dashes
followed by the ASCII bytes "BEGIN CERTIFICATE" and another series of
dashes). In the absence of that preamble, the implementation may
assume that the certificate is DER-encoded instead.
"""
return cls(path=path)
@classmethod
def from_id(cls, id: bytes) -> Certificate:
"""
Creates a Certificate object from an arbitrary identifier. This may
be useful for implementations that rely on system certificate stores.
"""
return cls(id=id)
개인 키
이 모듈은 구체적인 개인 키 클래스를 정의합니다. Certificate 클래스와 마찬가지로 이 클래스에는 세 가지 생성자에 대응하는 세 가지 속성이 있으며, Certificate 클래스의 모든 주의 사항도 그대로 적용됩니다.
class PrivateKey:
"""Object representing a private key corresponding to a public key
for a certificate used in TLS."""
__slots__ = (
"_buffer",
"_path",
"_id",
)
def __init__(
self, buffer: bytes | None = None, path: os.PathLike | None = None, id: bytes | None = None
):
"""
Creates a PrivateKey object from a path, buffer, or ID.
If none of these is given, an exception is raised.
"""
if buffer is None and path is None and id is None:
raise ValueError("PrivateKey cannot be empty.")
self._buffer = buffer
self._path = path
self._id = id
@classmethod
def from_buffer(cls, buffer: bytes) -> PrivateKey:
"""
Creates a PrivateKey object from a byte buffer. This byte buffer
may be either PEM-encoded or DER-encoded. If the buffer is PEM
encoded it *must* begin with the standard PEM preamble (a series of
dashes followed by the ASCII bytes "BEGIN", the key type, and
another series of dashes). In the absence of that preamble, the
implementation may assume that the private key is DER-encoded
instead.
"""
return cls(buffer=buffer)
@classmethod
def from_file(cls, path: os.PathLike) -> PrivateKey:
"""
Creates a PrivateKey object from a file on disk. The file on disk
should contain a series of bytes corresponding to a certificate that
may be either PEM-encoded or DER-encoded. If the bytes are PEM encoded
it *must* begin with the standard PEM preamble (a series of dashes
followed by the ASCII bytes "BEGIN", the key type, and another series
of dashes). In the absence of that preamble, the implementation may
assume that the certificate is DER-encoded instead.
"""
return cls(path=path)
@classmethod
def from_id(cls, id: bytes) -> PrivateKey:
"""
Creates a PrivateKey object from an arbitrary identifier. This may
be useful for implementations that rely on system private key stores.
"""
return cls(id=id)
서명 체인
TLS 참여자는 자신을 인증하기 위해, 상대방이 신뢰하는 루트 인증서까지 이어지는 체인을 갖춘 리프 인증서를 제공해야 합니다. 서버는 항상 클라이언트에게 자신을 인증해야 합니다. 클라이언트도 클라이언트 인증 과정에서 서버에게 자신을 인증할 수 있습니다. 또한 리프 인증서에는 개인 키가 함께 있어야 합니다. 개인 키는 별도의 객체에 저장할 수도 있고, 리프 인증서와 함께 저장할 수도 있습니다. 이 모듈은 이러한 객체들의 모음을 아래에 자세히 설명된 대로 SigningChain으로 정의합니다:
class SigningChain:
"""Object representing a certificate chain used in TLS."""
leaf: tuple[Certificate, PrivateKey | None]
chain: list[Certificate]
def __init__(
self,
leaf: tuple[Certificate, PrivateKey | None],
chain: Sequence[Certificate] | None = None,
):
"""Initializes a SigningChain object."""
self.leaf = leaf
if chain is None:
chain = []
self.chain = list(chain)
위의 설정 클래스에서 볼 수 있듯이, 클라이언트는 클라이언트 인증을 하는 경우 서명 체인을 하나 가질 수 있고, 그렇지 않은 경우에는 서명 체인을 갖지 않습니다. 서버는 서명 체인의 시퀀스를 가질 수 있으며, 이는 서버가 여러 도메인을 담당할 때 유용합니다.
신뢰 저장소
앞서 논의했듯이, TLS 구현마다 사용자가 신뢰 저장소를 선택하는 방식이 크게 다르기 때문에 신뢰 저장소를 불러오는 일은 문제가 됩니다. 이러한 이유로, 신뢰 저장소가 취하는 형태에 대해 최소한의 가정만 하는 모델을 제공해야 합니다.
이 문제는 Certificate 및 PrivateKey 타입이 해결해야 하는 문제와 같습니다. 이러한 이유로, 사용자가 신뢰 저장소를 정의할 수 있는 다양한 방법을 담아내는 구체 클래스를 만들어 완전히 같은 모델을 사용합니다.
특정 TLS 구현이 가능한 모든 신뢰 저장소를 처리해야 하는 것은 아닙니다. 그러나 system 생성자는 가장 흔히 사용되는 검증용 신뢰 저장소이므로, 각 TLS 구현이 가능한 한 이 생성자를 처리할 것을 강력히 권장합니다. TLS 구현은 아래의 “런타임” 섹션에서 설명하는 방식으로 지원하지 않는 옵션을 알릴 수 있습니다.
class TrustStore:
"""
The trust store that is used to verify certificate validity.
"""
__slots__ = (
"_buffer",
"_path",
"_id",
)
def __init__(
self, buffer: bytes | None = None, path: os.PathLike | None = None, id: bytes | None = None
):
"""
Creates a TrustStore object from a path, buffer, or ID.
If none of these is given, the default system trust store is used.
"""
self._buffer = buffer
self._path = path
self._id = id
@classmethod
def system(cls) -> TrustStore:
"""
Returns a TrustStore object that represents the system trust
database.
"""
return cls()
@classmethod
def from_buffer(cls, buffer: bytes) -> TrustStore:
"""
Initializes a trust store from a buffer of PEM-encoded certificates.
"""
return cls(buffer=buffer)
@classmethod
def from_file(cls, path: os.PathLike) -> TrustStore:
"""
Initializes a trust store from a single file containing PEMs.
"""
return cls(path=path)
@classmethod
def from_id(cls, id: bytes) -> TrustStore:
"""
Initializes a trust store from an arbitrary identifier.
"""
return cls(id=id)
런타임 접근
드물지 않은 사용 사례로, 라이브러리 사용자가 사용할 TLS 구현은 직접 지정하되 실제 TLS 연결의 세부 설정은 라이브러리에 맡기고 싶어 하는 경우가 있습니다. 예를 들어, requests 사용자는 Windows와 macOS에서 OpenSSL과 플랫폼 네이티브 솔루션 중 하나를 선택하거나, 일부 Linux 플랫폼에서 OpenSSL과 NSS 중 하나를 선택하고 싶어 할 수 있습니다. 그러나 이러한 사용자는 TLS 설정이 정확히 어떻게 이루어지는지에는 관심이 없을 수 있습니다.
이는 두 가지 문제를 제기합니다. 임의의 구체 구현이 주어졌을 때, 라이브러리는 어떻게 다음을 할 수 있습니까:
- 해당 구현이 인증서나 신뢰 저장소에 대한 특정 생성자(예: 임의의 식별자로부터 생성하는 생성자)를 지원하는지 알아낼 수 있습니까?
- 두 컨텍스트 클래스의 올바른 타입을 얻을 수 있습니까?
인증서 객체와 신뢰 저장소 객체는 구현 외부에서도 생성할 수 있어야 합니다. 따라서 구현은 사용자가 직접 생성한 인증서 및 신뢰 저장소와 해당 구현이 호환되는지 사용자가 확인할 수 있는 방법을 제공해야 합니다. 따라서 각 구현은 TLSClientConfiguration 또는 TLSServerConfiguration 객체를 받아, 지원하지 않는 생성자가 사용된 경우 예외를 발생시키는 validate_config 메서드를 구현해야 합니다.
타입에 대해서는 두 가지 선택지가 있습니다. 모든 구체 구현이 특정 명명 규칙을 따르도록 요구하거나, 이러한 객체를 가져올 수 있게 하는 API를 제공하는 것입니다.
이 PEP는 두 번째 방식을 사용할 것을 제안합니다. 이 방식은 구체 구현에 적절한 속성을 갖춘 단일 객체를 제공하는 것만 요구하므로, 구현이 원하는 대로 코드를 구성할 수 있는 최대한의 자유를 줍니다. 그러면 사용자는 이 구현 객체를 이를 지원하는 라이브러리에 전달할 수 있으며, 해당 라이브러리가 구체 구현의 설정과 사용을 처리할 수 있습니다.
모든 구체 구현은 TLSImplementation 객체를 얻는 방법을 제공해야 합니다. TLSImplementation 객체는 전역 싱글턴일 수도 있고, 그렇게 하는 것이 유리하다면 호출 가능 객체로 생성할 수도 있습니다.
TLSImplementation 객체의 정의는 다음과 같습니다:
class TLSImplementation(Generic[_ClientContext, _ServerContext]):
__slots__ = (
"_client_context",
"_server_context",
"_validate_config",
)
def __init__(
self,
client_context: type[_ClientContext],
server_context: type[_ServerContext],
validate_config: Callable[[TLSClientConfiguration | TLSServerConfiguration], None],
) -> None:
self._client_context = client_context
self._server_context = server_context
self._validate_config = validate_config
처음 두 프로퍼티는 해당 프로토콜 클래스의 구체적인 구현을 제공해야 합니다. 예를 들어, 클라이언트 컨텍스트의 경우는 다음과 같습니다:
@property
def client_context(self) -> type[_ClientContext]:
"""The concrete implementation of the PEP 543 Client Context object,
if this TLS implementation supports being the client on a TLS connection.
"""
return self._client_context
이를 통해 다음과 같은 코드가 어떤 구현에서든 동작하도록 보장합니다:
client_config = TLSClientConfiguration()
client_context = implementation.client_context(client_config)
세 번째 프로퍼티는 주어진 TLS 구성이 구현과 호환되는 인증서, 개인 키, 신뢰 저장소를 포함하는지 검증하는 함수를 제공해야 합니다:
@property
def validate_config(self) -> Callable[[TLSClientConfiguration | TLSServerConfiguration], None]:
"""A function that reveals whether this TLS implementation supports a
particular TLS configuration.
"""
return self._validate_config
이 함수는 인증서, 개인 키, 신뢰 저장소에 지원되는 생성자가 사용되었는지만 검증하면 된다는 점에 유의하십시오. 객체를 더 자세히 검증하기 위해 파싱하거나 가져올 필요는 없습니다.
안전하지 않은 사용
위의 모든 내용은 사용자가 모듈을 안전한 방식으로 사용하려 한다고 가정합니다. 때로는 사용자가 테스트 목적으로 인증서 검증을 비활성화하는 것과 같은 경솔한 일을 하려는 경우가 있습니다. 이를 위해 사용자가 이러한 일을 할 수 있도록 하는 별도의 insecure 모듈을 제안합니다. 이 모듈에는 구성, 컨텍스트, 구현 객체의 안전하지 않은 변형이 포함되어 있으며, 이를 통해 인증서 검증과 서버 호스트 이름 검사를 비활성화할 수 있습니다.
이 기능은 정당한 사용자가 실수로 안전하지 않은 기능을 사용하는 일을 최대한 어렵게 만들기 위해 별도의 모듈에 배치됩니다. 또한 이 모듈은 SecurityWarning이라는 새로운 경고를 정의하며, 안전하지 않은 연결을 생성하려고 할 때 모든 단계마다 요란하게 경고합니다.
이 모듈은 오직 테스트 목적으로만 의도된 것입니다. 사용자가 자체 서명 인증서만 가진 IoT 기기에 연결하려는 실제 상황에서는, 안전하지 않은 모듈을 사용하여 인증서 검증을 비활성화하기보다는 해당 인증서를 사용자 정의 신뢰 저장소에 추가할 것을 강력히 권장합니다.
표준 라이브러리의 변경 사항
표준 라이브러리 중 TLS와 상호 작용하는 부분은 이러한 프로토콜 클래스를 사용하도록 수정되어야 합니다. 이렇게 하면 이 모듈들의 함수가 다른 TLS 구현에서도 동작할 수 있습니다. 여기에는 다음 모듈이 포함됩니다:
ssl 모듈의 마이그레이션
당연히 ssl 모듈 자체도 이러한 프로토콜 클래스를 따르도록 확장해야 합니다. 이 확장은 새로운 클래스의 형태를 취하며, 경우에 따라 완전히 새로운 모듈에 포함될 수도 있습니다. 이를 통해 현재 ssl 모듈을 활용하는 애플리케이션은 계속 그렇게 할 수 있으며, 동시에 새로운 API를 사용하려는 애플리케이션과 라이브러리는 이를 사용할 수 있게 됩니다.
일반적으로 ssl 모듈에서 새로운 프로토콜 클래스로의 마이그레이션은 일대일로 대응되지 않을 것으로 예상됩니다. 이는 대개 문제가 되지 않습니다. ssl 모듈을 사용하는 대부분의 도구는 사용자에게 이를 드러내지 않으므로, 새 모듈을 사용하도록 리팩터링하더라도 사용자에게는 보이지 않을 것입니다.
그러나 정의된 API의 일부로서든, 실수로든(이는 쉽게 일어납니다) ssl 모듈의 예외를 외부로 노출하는 라이브러리나 애플리케이션에서는 특정한 문제가 발생합니다. 이러한 도구의 사용자는 ssl 모듈에서 발생하는 예외를 허용하고 처리하는 코드를 작성했을 수 있습니다. 여기에서 제시하는 프로토콜 클래스로 마이그레이션하면 그 대신 위에서 정의한 예외가 발생할 수 있으며, 기존의 except 블록은 이를 잡아내지 못합니다.
이러한 이유로, ssl 모듈 마이그레이션 작업의 일부로 ssl 모듈의 예외가 위에서 정의한 예외의 별칭이 되도록 해야 합니다. 즉, 다음 문이 모두 성공해야 합니다:
assert ssl.SSLError is tls.TLSError
assert ssl.SSLWantReadError is tls.WantReadError
assert ssl.SSLWantWriteError is tls.WantWriteError
이를 구체적으로 어떻게 구현할지는 현재 ssl 예외가 C 코드로 정의되어 있어 더 복잡해지므로 이 PEP의 범위를 벗어나지만, 자세한 내용은 Christian Heimes가 Security-SIG에 보낸 이메일에서 확인할 수 있습니다.
향후 전망
향후 TLS에 주요 기능이 추가되면 이러한 프로토콜 클래스를 개정해야 할 수 있습니다. 이러한 개정은 신중하게 이루어져야 합니다. 많은 구현이 신속하게 따라가지 못할 수 있으며, 프로토콜 클래스가 변경되면 해당 구현은 더 이상 유효하지 않게 됩니다. 이는 감수할 수 있는 일이지만, 가능한 한 개별 구현에 특화된 기능은 프로토콜 클래스에 추가하지 않아야 합니다. 프로토콜 클래스는 IETF에서 명세한 기능을 고수준에서 기술하는 데 그쳐야 합니다.
그러나 충분한 근거가 있는 확장은 반드시 이 API에 반영되어야 합니다. 이 API의 목적은 Python 커뮤니티에 통일된 최소 공통분모 수준의 구성 옵션을 제공하는 것입니다. TLS는 고정된 대상이 아니며, TLS가 발전함에 따라 이 API도 함께 발전해야 합니다.
감사의 말
이 PEP는 2020년에 철회된 PEP 543의 상당 부분을 가져와 수정한 것입니다. PEP 543은 Cory Benfield와 Christian Heimes가 작성했으며, 커뮤니티의 여러 사람에게서 폭넓은 검토를 받았고 이들은 그 내용을 다듬는 데 크게 기여했습니다. PEP 543과 이 PEP를 모두 상세히 검토해 주신 분들은 다음과 같습니다:
- Alex Chan
- Alex Gaynor
- Antoine Pitrou
- Ashwini Oruganti
- Donald Stufft
- Ethan Furman
- Glyph
- Hynek Schlawack
- Jim J Jewett
- Nathaniel J. Smith
- Alyssa Coghlan
- Paul Kehrer
- Steve Dower
- Steven Fackler
- Wes Turner
- Will Bond
- Cory Benfield
- Marc-André Lemburg
- Seth M. Larson
- Victor Stinner
- Ronald Oussoren
PEP 543에 대한 추가 검토는 Security-SIG 및 python-ideas 메일링 리스트에서 이루어졌습니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.