openssl BIO에 대한 지식보다는 왜 쓰게 됐는지, 어떻게 썼는지 경험을 정리한 글입니다.

 

이전에 만든 IOCP 에코서버를 베이스로 작성된 글입니다.

 

I/O Multiplexing IOCP 에코서버 만들기 with.C/C++

리눅스OS에서 epoll 로I/O Multiplexing 구현한건 아래 게시글 참조. I/O Multiplexing epoll 채팅서버 만들기 with.C/C++개발환경은 도커 debian trixie 컨테이너 입니다.ai의 도움을 받아 공부하며 작성된 게시글입

dodontak.tistory.com

openssl 설치방법은 아래 블로그를 참조했습니다.

 

window openssl 설치하기

네 줄 요약1. https://slproweb.com/products/Win32OpenSSL.html에서 운영체제에 맞는(Win64 / Win32) 파일 다운로드.2. 설치파일로 윈도우에 설치3. 윈도우 환경변수로 OpenSSl 설치폴더 (C:\Program Files\OpenSSL-Win64\bin)

chris1108.tistory.com

아래 링크의 example 코드를 참조하며 만들었습니다.

 

OpenSSL example using memory BIO with non-blocking socket IO

OpenSSL example using memory BIO with non-blocking socket IO - ssl_server_nonblock.c

gist.github.com


 

openssl 설치

 

Shining Light Productions - Home

 

slproweb.com

위 사이트에서 Products - Win32/Win64 OpenSSL 에서

해당 파일 설치. 설치는 모두 기본값으로 설치했다. 마지막에 도네이션만 제외해주자.

윈도우 사용자 환경변수의 Path에  C:\Program Files\OpenSSL-Win64\bin 경로를 추가한다.

개인키, 인증서 생성

이제 openssl 프로그램을 사용할 수 있다. 이걸로 개인키와 인증서를 만들어주자.

openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt -days 365 -nodes

이것저것 기입하라고 뜨는데 다그냥 엔터로 넘어가도 된다.

생성된 인증서와 개인키

openssl 라이브러리 설치

방금 설치할때 openssl 라이브러리도 같이 설치되지만 쓰려면 좀 불편하다. vcpkg로 설치해준다. (vcpkg 없으면 설치해야됨)

vcpkg install openssl

 

이제 비주얼 스튜디오 c++코드로 사용할 수 있다.

아직 안되면 아래 명령어를 입력하고 다시 해보자.

vcpkg integrate install

Visual Studio 글로벌 설정파일에 vcpkg 경로를 등록하는 것이고, vcpkg를 설치한 다음 한번만 하면 된다고 한다. (라이브러리 설치할때마다 할 필요 없음)


BIO를 사용해야 하는 이유

저번에 리눅스 컨테이너 환경에서 TLS 암호화 통신을 구현해봤다.

 

openssl example with.C/C++

도커 debian:trixie 컨테이너환경에서 작성된 글입니다.ai의 도움과 여러 블로그를 참조하며 작성된 게시글입니다. SSL/TLS 그리고 인증서란 무엇인가유튜브 채널 생활코딩의 강의영상을 보고 정리한

dodontak.tistory.com

그런데 이 때 만들었던 방식은 IOCP 서버에서 적용할 수가 없었다.

IOCP와 epoll의 근본적인 차이 때문.

 

먼저 epoll서버에서의 TLS 핸드쉐이크 이후에 클라에게 메시지를 받는 과정을 생각해보자.

 

메시지 수신

  1. SSL_set_fd(ssl, socket) 으로 소켓과 ssl 연결
  2. epoll에서 read 이벤트 발생
  3. SSL_read(ssl, readbuffer, size) 함수 호출
    - 내부적으로 recv사용해 커널 버퍼 -> SSL 내부 버퍼
    - SSL_내부 버퍼 -> 복호화 하여 readbuffer에 데이터 담아줌
  4. readbuffer에 상대가 보낸 평문이 적혀있어서 사용하면 됨.

그런데 IOCP에서는 위와같은 방식으로 통신이 불가능하다.

IOCP는 커널버퍼 -> 유저레벨 readbuffer 까지의 동작을 커널에서 알아서 다 처리한 다음에 완료됐다고 통지만 해주기때문에 중간에 복호화 시킬 방법이 없기 때문. 유저는 암호화된 메시지를 버퍼에 받게 되는 것이다.

 

그래서 IOCP서버에서 TLS를 사용하고싶다면 반드시 BIO라는걸 사용해야 하고, BIO를 사용해 암호화된 메시지를 평문으로 복호화 할 수 있다. (반대도 가능)


BIO가 뭐냐?

위 수신과정 중 SSL 내부 버퍼가 rbio 라는 BIO이다.

BIO에는 항상 암호화된 데이터가 담겨있고(실제로는 아니지만 그렇게 생각하는게 편함), 사용하는 함수에 따라 복호화 할수도 있고, 그냥 꺼낼수도 있다. 그리고 중요한건 이 BIO를 SSL세팅에 따라 유저가 관리할 수 있다는 점이다.

 

SSL_set_bio(ssl, rbio, wbio) 로 세팅하면 SSL_read는 "rbio -> 복호화 하여 readBuffer에 담아줌" 만 수행한다.

그럼 rbio에 데이터는 누가 넣어줄까? 유저가 직접 넣어줘야한다.

 

IOCP에서 recv 완료 통지 이후 동작을 나열해보자.

  1. IOCP recv 완료통지 받음. recvBuffer에 암호화된 데이터 담겨있다
  2. 암호화 데이터를 rbio에 넣음. BIO_write(rbio, recvBuffer, recvLen)
  3. SSL_read로 rbio에 담긴 암호화 데이터를 복호화하여 recvBuffer에 다시 담음.
    SSL_read(ssl, recvBuffer, size)

이렇게하면 IOCP를 사용하면서도 TLS 암호화 통신을 할 수 있는 것.

 

실제로는 BIO가 더 다양한 역할을 할 수 있지만 이번에 내가 사용할것은 memory bio라는것 한가지 뿐이다.


주요 함수

  • BIO_new(BIO_s_mem())
    bio 생성 함수
  • SSL_set_bio(ssl, rbio, wbio)
    ssl에 bio 세팅하는 함수.
  • SSL_accept
    TLS 핸드쉐이킹을 해주는 함수.핸드쉐이킹 과정에 따라 여러번 호출되야한다.
    클라가 보낸 핸드쉐이크 관련 데이터가 rbio에 담기면, response 데이터를 wbio에 담아준다.
  • SSL_read
    bio에 적힌 암호화된 데이터를 평문으로 복호화하는 함수.
  • SSL_write
    평문으로 된 데이터를 암호화 하여 bio에 적는 함수
  • BIO_read
    bio에 적힌 암호화된 데이터를 그대로 buffer로 복사하는 함수
  • BIO_write
    암호화된 데이터를 그대로 bio에 적는 함수.

이런 순서로 사용된다. (accept는 소스코드 참조)

ssl, bio 세팅 과정

SSL* ssl = SSL_new(ctx);
BIO* rbio = BIO_new(BIO_s_mem());
BIO* wbio = BIO_new(BIO_s_mem());
SSL_set_bio(ssl, rbio, wbio);

Recv 과정

recvBuffer에 암호화된 데이터를 수신받음

BIO_write(rbio, recvBuffer, recvLen);
-> 암호화된 데이터를 그대로 rbio에 복사함
SSL_read(ssl, recvBuffer, sizeof(recvBuffer));
-> recvBuffer에 복호화된 데이터 담김

 

Send과정

SSL_write(ssl, sendBuffer, sendLen);
-> 평문으로 된 데이터를 암호화 하여 wbio에 넣음.
BIO_read(wbio, sendBuffer, sizeof(sendBuffer));
-> wbio에 적힌 암호화 데이터를 그대로 sendBuffer에 옮겨적음.

이후 sendBuffer에 적힌 암호화 데이터를 전송

소스코드

대부분 IOCP관련 코드니까 TLS 송수신 관련된것만 추려서 적어봤다. (전체 코드는 맨아래에 있음)

#include <openssl/bio.h>
#include <openssl/ssl.h>

struct ssl_client
{
	SOCKET socket;
	SSL* ssl;
	BIO* rbio; /* SSL reads from, we write to. */
	BIO* wbio; /* SSL writes to, we read from. */

	array<BYTE, 10000> recvBuffer;
	array<BYTE, 10000> sendBuffer;
};

//SSL_CTX 초기화 함수
SSL_CTX* create_context()
{
	const SSL_METHOD* method = TLS_server_method();
	SSL_CTX* ctx = SSL_CTX_new(method);
	if (!ctx)
		HandleError("Failed to create SSL_CTX");
	return ctx;
}

//SSL_CTX에 인증서, 비밀키 등록하는 함수
void configure_context(SSL_CTX* ctx)
{
	if (SSL_CTX_use_certificate_file(ctx, "server.crt", SSL_FILETYPE_PEM) <= 0)
		HandleError("SSL_CTX_use_certificate_file error");

	if (SSL_CTX_use_PrivateKey_file(ctx, "server.key", SSL_FILETYPE_PEM) <= 0)
		HandleError("SSL_CTX_use_PrivateKey_file error");
}

int main()
{
	// SSL_CTX 생성 및 설정
	SSL_CTX* ctx = create_context();
	configure_context(ctx);

	// SSL 세팅
	ssl_client client;
	client.ssl = SSL_new(ctx);
	client.rbio = BIO_new(BIO_s_mem());
	client.wbio = BIO_new(BIO_s_mem());
	SSL_set_bio(client.ssl, client.rbio, client.wbio);
    
    /*...*/
    
    while (true)
	{
		/*...*/
		GetQueuedCompletionStatus(hIOCP, &numOfBytes, &completionKey, (LPOVERLAPPED*)&overlapped, INFINITE);
		
		if (overlapped->type == IO_TYPE::ACCEPT)
		{ // AcceptEx 완료. 새 클라이언트 접속 성공.
			/*...*/
			RegisterRecv(client, IO_TYPE::TLS_HANDSHAKE);// TLS 핸드쉐이크 시작
			//RegisterAccept(listenSocket); 테스트용으로 1개 클라만 받으려고 주석처리함
		}
		else if (overlapped->type == IO_TYPE::TLS_HANDSHAKE)
		{ // TLS 핸드쉐이크 진행중
         	// 클라에게 받은 데이터 bio에 씀
			BIO_write(client.rbio, client.recvBuffer.data(), numOfBytes);
            
            // SSL_accept 호출하여 TLS 핸드쉐이크 진행.
            // 내부적으로 rbio에서 데이터를 읽고, wbio에 응답 데이터를 씀
			SSL_accept(client.ssl); 
            
            // wbio적힌 TLS 핸드쉐이크 응답 데이터를 sendBuffer로 복사
			int len = BIO_read(client.wbio, client.sendBuffer.data(), client.sendBuffer.size());
			if (len > 0) // 보낼 데이터가 있으면
				RegisterSend(client, len); // TLS 핸드쉐이크 응답 데이터를 클라이언트로 보냄
			if (SSL_is_init_finished(client.ssl)) // TLS 핸드쉐이크 완료 여부 확인
				RegisterRecv(client, IO_TYPE::Recv); // TLS 핸드쉐이크 완료. 수신 등록
			else
				RegisterRecv(client, IO_TYPE::TLS_HANDSHAKE); // 계속 핸드쉐이크 진행
		}
		else if (overlapped->type == IO_TYPE::RECV)
		{// 클라이언트로부터 데이터 수신 완료 recvBuffer에 데이터가 담겨있음
         	
            // 클라에게 받은 데이터를 bio에 씀
			BIO_write(client.rbio, client.recvBuffer.data(), numOfBytes);
            //bio에 적히널 복호화해서 recvBuffer에 복사
			int rlen = SSL_read(client.ssl, client.recvBuffer.data(), client.recvBuffer.size());
            
            // 잘 복호화 잘 됐는지 출력해보기
			client.recvBuffer.data()[rlen] = '\0';
			cout << client.recvBuffer.data();
            
            // 에코서버니까 복호화 한 데이터 다시 암호화해서 wbio에 넣기
			int wlen = SSL_write(client.ssl, client.recvBuffer.data(), rlen);
            
            // wbio에 암호화된 데이터를 sendBuffer에 복사
			int sendLen = BIO_read(client.wbio, client.sendBuffer.data(), client.sendBuffer.size());
            
            // 송신 등록
			RegisterSend(client, sendLen);
            // 수신 등록
			RegisterRecv(client);
		}
		else if (overlapped->type == IO_TYPE::SEND)
		{

		}
	}
}

 

 

전체 코드

#include <array>
#include <vector>
#include <iostream>

// WSAStartup SOCKADDR_IN WSASocket bind listen 
// RecvEx SendEx CreateIoCompletionPort GetQueuedCompletionStatus
#include <winsock2.h>

// AcceptEx LPFN_CONNECTEX LPFN_DISCONNECTEX LPFN_ACCEPTEX
#include <mswsock.h>

#include <openssl/bio.h>
#include <openssl/ssl.h>

#pragma comment(lib, "ws2_32.lib") // 동적라이브러리 링크용
#pragma comment(lib, "mswsock.lib") // 동적라이브러리 링크용

//LPFN_ACCEPTEX		fnAcceptEx = nullptr;

using namespace std;

enum class IO_TYPE { ACCEPT, RECV, TLS_HANDSHAKE, SEND };

struct OverlappedEx {
	WSAOVERLAPPED overlapped; // [필수] 반드시 첫 번째 멤버!
	SOCKET socket;            // 어떤 소켓의 작업인가?
	IO_TYPE type;             // 읽기인가, 쓰기인가, 접속인가?
};

struct ssl_client
{
	SOCKET socket;
	SSL* ssl;
	BIO* rbio; /* SSL reads from, we write to. */
	BIO* wbio; /* SSL writes to, we read from. */

	array<BYTE, 10000> recvBuffer;
	array<BYTE, 10000> sendBuffer;
};

void HandleError(const char* errmsg);
void RegisterAccept(SOCKET listenSocket);
void RegisterRecv(ssl_client& client, IO_TYPE type);
void RegisterSend(ssl_client& client, DWORD sendLen);

SSL_CTX* create_context()
{
	const SSL_METHOD* method = TLS_server_method();
	SSL_CTX* ctx = SSL_CTX_new(method);
	if (!ctx)
		HandleError("Failed to create SSL_CTX");
	return ctx;
}

void configure_context(SSL_CTX* ctx)
{
	if (SSL_CTX_use_certificate_file(ctx, "server.crt", SSL_FILETYPE_PEM) <= 0)
		HandleError("SSL_CTX_use_certificate_file error");

	if (SSL_CTX_use_PrivateKey_file(ctx, "server.key", SSL_FILETYPE_PEM) <= 0)
		HandleError("SSL_CTX_use_PrivateKey_file error");
}

int main()
{
	// SSL_CTX 생성 및 설정
	SSL_CTX* ctx = create_context();
	configure_context(ctx);

	// SSL 세팅
	ssl_client client;
	client.ssl = SSL_new(ctx);
	client.rbio = BIO_new(BIO_s_mem());
	client.wbio = BIO_new(BIO_s_mem());
	SSL_set_bio(client.ssl, client.rbio, client.wbio);

	// WSA 초기화 및 IOCP 생성
	WSADATA wsaData;
	if (WSAStartup(MAKEWORD(2, 2), &wsaData))
		HandleError("Failed to initialize WSA");

	HANDLE hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
	if (hIOCP == nullptr)
		HandleError("Failed to create IOCP Handle");

	// listenSocket 생성
	SOCKET listenSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);
	if (listenSocket == INVALID_SOCKET)
		HandleError("Failed to create listenSocket");

	SOCKADDR_IN serverAddr;
	memset(&serverAddr, 0, sizeof(serverAddr));
	serverAddr.sin_family = AF_INET;
	serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
	serverAddr.sin_port = htons(9000);

	if (bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
		HandleError("Failed to bind listenSocket");

	if (listen(listenSocket, SOMAXCONN) == SOCKET_ERROR)
		HandleError("Failed to listen listenSocket");

	// listenSocket을 IOCP에 등록
	CreateIoCompletionPort((HANDLE)listenSocket, hIOCP, (ULONG_PTR)listenSocket, 0);

	RegisterAccept(listenSocket);

	vector<SOCKET> clientSockets;

	while (true)
	{
		DWORD numOfBytes = 0;
		ULONG_PTR completionKey;
		OverlappedEx* overlapped = nullptr;

		BOOL bRet = GetQueuedCompletionStatus(hIOCP, &numOfBytes, &completionKey, (LPOVERLAPPED*)&overlapped, INFINITE);

		if (overlapped->type == IO_TYPE::ACCEPT)
		{
			// AcceptEx 완료. 새 클라이언트 접속 성공.
			client.socket = overlapped->socket;
			clientSockets.push_back(client.socket);
			// 새 클라이언트 소켓을 IOCP에 등록
			CreateIoCompletionPort((HANDLE)client.socket, hIOCP, (ULONG_PTR)client.socket, 0);

			RegisterRecv(client, IO_TYPE::TLS_HANDSHAKE);// TLS 핸드쉐이크 시작
			//RegisterAccept(listenSocket);
		}
		else if (overlapped->type == IO_TYPE::TLS_HANDSHAKE)
		{ // TLS 핸드쉐이크 진행중
			BIO_write(client.rbio, client.recvBuffer.data(), numOfBytes); // 클라에게 받은 데이터 bio에 씀
			SSL_accept(client.ssl); // SSL_accept 호출하여 TLS 핸드쉐이크 진행. 내부적으로 rbio에서 데이터를 읽고, wbio에 응답 데이터를 씀
			int len = BIO_read(client.wbio, client.sendBuffer.data(), client.sendBuffer.size());// wbio에 씌여진 TLS 핸드쉐이크 응답 데이터를 sendBuffer로 옮겨적음
			if (len > 0)
				RegisterSend(client, len); // TLS 핸드쉐이크 응답 데이터를 클라이언트로 보냄
			if (SSL_is_init_finished(client.ssl)) // TLS 핸드쉐이크 완료 여부 확인. 완료되면 true 반환
			{
				RegisterRecv(client, IO_TYPE::RECV); // TLS 핸드쉐이크 완료되었으므로 이제 데이터 수신 대기
			}
			else
			{
				RegisterRecv(client, IO_TYPE::TLS_HANDSHAKE); // TLS 핸드쉐이크 아직 완료 안됐으므로 계속 핸드쉐이크 진행
			}
		}
		else if (overlapped->type == IO_TYPE::RECV)
		{
			// 클라이언트로부터 데이터 수신 완료 recvBuffer에 데이터가 담겨있음
			BIO_write(client.rbio, client.recvBuffer.data(), numOfBytes); // 클라에게 받은 데이터를 bio에 씀
			int rlen = SSL_read(client.ssl, client.recvBuffer.data(), client.recvBuffer.size());
			client.recvBuffer.data()[rlen] = '\0';
			cout << client.recvBuffer.data();
			int wlen = SSL_write(client.ssl, client.recvBuffer.data(), rlen);
			int sendLen = BIO_read(client.wbio, client.sendBuffer.data(), client.sendBuffer.size());
			RegisterSend(client, sendLen);
			RegisterRecv(client, IO_TYPE::RECV);
		}
		else if (overlapped->type == IO_TYPE::SEND)
		{

		}
	}
}

void HandleError(const char* errmsg)
{
	cerr << errmsg << endl;
	exit(1);
}

void RegisterAccept(SOCKET listenSocket)
{
	SOCKET newClientSocket = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);
	OverlappedEx* acceptOverlapped = new OverlappedEx();
	acceptOverlapped->type = IO_TYPE::ACCEPT;
	acceptOverlapped->socket = newClientSocket;
	char acceptBuffer[(sizeof(SOCKADDR_IN) + 16) * 2]; // AcceptEx가 요구하는 버퍼 크기
	DWORD bytes = 0;
	AcceptEx(listenSocket, newClientSocket, acceptBuffer, 0, sizeof(SOCKADDR_IN) + 16,
		sizeof(SOCKADDR_IN) + 16, &bytes, &acceptOverlapped->overlapped);
}

void RegisterRecv(ssl_client& client, IO_TYPE type)
{
	WSABUF wsaBuf;
	wsaBuf.buf = reinterpret_cast<char*>(client.recvBuffer.data());
	wsaBuf.len = client.recvBuffer.size();

	DWORD numOfBytes = 0;
	DWORD flags = 0;

	OverlappedEx* recvOverlapped = new OverlappedEx();
	recvOverlapped->type = type;
	recvOverlapped->socket = client.socket;

	if (SOCKET_ERROR == WSARecv(client.socket, &wsaBuf, 1, OUT & numOfBytes, OUT & flags,
		(LPWSAOVERLAPPED)recvOverlapped, nullptr))
	{
		int errorCode = WSAGetLastError();
		if (errorCode != WSA_IO_PENDING)
			HandleError("Failed to post WSARecv");
	}
}

void RegisterSend(ssl_client& client, DWORD sendLen)
{
	OverlappedEx* sendOverlapped = new OverlappedEx();
	sendOverlapped->type = IO_TYPE::SEND;
	sendOverlapped->socket = client.socket;

	vector<WSABUF> wsaBufs;
	WSABUF wsaBuf;
	wsaBuf.buf = reinterpret_cast<char*>(client.sendBuffer.data());
	wsaBuf.len = sendLen;
	wsaBufs.push_back(wsaBuf);

	DWORD numOfBytes = 0;

	if (SOCKET_ERROR == WSASend(client.socket, wsaBufs.data(), static_cast<DWORD>(wsaBufs.size()),
		OUT & numOfBytes, 0, (LPWSAOVERLAPPED)sendOverlapped, nullptr))
	{
		int errorCode = WSAGetLastError();
		if (errorCode != WSA_IO_PENDING)
			HandleError("Failed to WSASend");
	}
}

테스트

클라이언트 측

openssl 클라이언트로 접속했다.

openssl s_client -connect 127.0.0.1:9000

에코서버라 내가 친 채팅이 그대로 돌아온다.

 

서버측

클라이언트 입력을 복호화 해서 출력한 뒤에 다시 암호화해서 보내주는데, 정상적으로 암호화/복호화가 이루어진다.


글에서는 openssl client를 사용했기 때문에 클라이언트 측에서 connect 하는 코드는 없는데, connect할때도 accept와 마찬가지로 핸드셰이크 과정에서 받은 데이터가 있다면 rbio에 넣고, SSL_connect 호출 -> wbio에 쓰여진게 있다면 상대에 전송 -> 리턴값에 따라 적절한 처리를 반복하면 된다. 주의할것은 리턴값이 성공인 경우에도 wbio에 보낼게 담길 수 있으니까 성공시에도 상대에게 전송해줘야한다.

+ Recent posts