게임 웹서버 만들기

패킷 암호화 / 복호화 - 게임 웹서버 만들기 7

소수결게임 2025. 12. 8. 16:23

이번 글에서는 서버 측에서 요청과 응답 데이터를 안전하게 처리하기 위한 패킷 암호화/복호화 기능을 구현해보겠습니다.

테스트를 위해 Unity 기반의 간단한 클라이언트를 함께 제작하였으며,

암호화된 데이터 송수신이 정상적으로 이루어지는지를 검증합니다.

이 클라이언트는 추후 구글 로그인에서도 사용될 예정입니다.

 


[패킷 암호화/복호화가 필요한 이유]

- 평문 데이터는 노출 위험이 높음

- 프록시 툴을 활요한 트래픽 가로채기를 통해 위조, 변조가 발생할 수 있음

- 비신뢰 클라이언트와의 통신환경에서 데이터 위조, 변조에 대한 방지가 필요함

 

이러한 이유로 HTTPS와 별개로 패킷 암호화를 통해 보안성을 한층 강화하는 전략이 필요합니다.

 


[미들웨어 생성]

● 암호화 / 복호화를 미들웨어로 생성하면 생기는 장점

- 컨트롤러나 라우팅처리와 분리하여, 보안 로직과 비즈니스 로직의 관심사를 명확히 구분

- 모든 요청과 응답에 대해 전역적으로 처리할 수 있어, 코드 중복 없이 효율적인 적용이 가능

- 라우터별로 암호화, 복호화 코드를 반복하지 않고 한 곳에서 공통적관리 가능

 

 

1. 파일 생성

[Middleware] 디렉토리에 PacketEncryptionMiddleware.cs 파일을 생성합니다.

 

 

2. 암호화 키 생성

서버와 클라이언트만 공유하는 비밀 키를 생성합니다.

이 키는 네트워크를 통해 전송되지 않으며, 별도로 안전하게 관리하여야 합니다.

 

PACKET_SECRET=SecretKey

.env 파일에 PACKET_SECRET 키를 추가합니다.

 

 

3. 미들웨어 초기화

public PacketEncryptionMiddleware(RequestDelegate next, IConfiguration config)
{
    _next = next;
    _secret = config["PACKET_SECRET"] ?? throw new ArgumentNullException("PACKET_SECRET 설정이 필요합니다.");
}

PacketEncryptionMiddleware 클래스에서 RequestDelegate 와  _secret값을 주입받아 초기화 합니다.

 

RequestDelegate : 미들웨어는 다음 미들웨어로 제어를 넘겨야 하는데, 그 역할을 해줌

 


 

[암호화]

이번 프로젝트에서는 매 요청마다 무작위 Salt를 생성하여,

고정된 키 기반의 AES 암호화를 수행하는 구조로 구현합니다.

 

암호화로직은 일반적으로 다음과 같이 구성됩니다.


[복호화]

암호화와 같은 방식으로 복호화를 진행하였습니다.

 

 

 


[Request, Response를 암호화/복호화]

이번에는 요청(Request) 본문과 응답(Response) 본문을 암호화/복호화 처리하는 로직을 구현해 보겠습니다.

 

1. RequestBody 복호화

if(context.Request.Method == HttpMethods.Post && context.Request.ContentLength > 0)
{
    using (var reader = new StreamReader(context.Request.Body))
    {
        //Body를 복호화
        var body = await reader.ReadToEndAsync();
        var decryptedBody = Decrypt(body);

        //복호화된 Body로 교체
        var byteArray = Encoding.UTF8.GetBytes(decryptedBody);
        context.Request.Body = new MemoryStream(byteArray);
    }
}

먼저 POST 요청에 대해 본문(Body)을 낚아채 복호화 합니다.

복호화된 내용을 새로운 메모리 스트림에 담아 context.Request.Body를 교체합니다.

이후 컨트롤러에서는 이미 복호화된 데이터를 그대로 사용하면 됩니다.

 

 

2. Response Body 암호화

 //기존 응답 스트림을 저장
 var originalBodyStream = context.Response.Body;

 //새로운 메모리 스트림으로 응답 바디 교체
 using var newBodyStream = new MemoryStream();
 context.Response.Body = newBodyStream;

 //컨트롤러 응답 기다림
 await _next(context);

 //스트림 포인터를 초기위치로 이동
 context.Response.Body.Seek(0, SeekOrigin.Begin);

컨트롤러에서 반환하는 응답도 가로채기 위해, 응답 스트림을 메모리 스트림으로 교체합니다.

기존 스트림을 사용하면 기존 내용이 쌓여있을 수 있으므로, 별도의 스트림으로 작업하는 것이 안정적입니다.

 

 

3. 암호화

//컨트롤러가 만든 응답을 꺼내서 읽음
var plainResponseBody = await new StreamReader(context.Response.Body).ReadToEndAsync();

//응답 암호화
var encryptedResponseBody = Encrypt(plainResponseBody);
var responseByteArray = Encoding.UTF8.GetBytes(encryptedResponseBody);

컨트롤러에서 생성된 응답 본문을 읽은 후 다시 암호화합니다.

 

 

4. 응답 스트림 복원

//원래 응답 스트림으로 복원
context.Response.Body = originalBodyStream;

await context.Response.Body.WriteAsync(responseByteArray, 0, responseByteArray.Length);

모든 작업이 끝난 후, 처음 저장해두었던 응답 스트림을 복원합니다.

 


[암호화/복호화 실행 환경 설정]

실행 환경에 관계없이 매번 암호화/복호화를 적용하게 되면 

Postman이나 Swagger등으로 API를 테스트하는것이 힘들어집니다.

 

이러한 불편을 줄이기 위해

개발 환경에서는 로직을 생략하고

운영 환경에서만 실제로 동작하도록 구성합니다.

 

 

 

launchSettings.json 파일 또는 환경변수에서

ASPNETCORE_ENVIRONMENT 의 값을 설정하실 수 있습니다.

 

● 테스트 개발환경의 경우

"ASPNETCORE_ENVIRONMENT": "Development"

 

● 배포 환경의 경우

"ASPNETCORE_ENVIRONMENT": "Production"

 

 


[암호화/복호화 실행]

 

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}
else
{
    app.UseMiddleware<PacketEncryptionMiddleware>();
}

 

Program.csDevelopment가 아닐 경우 MiddleWare를 실행시키는 코드를 추가합니다.

+ MapOpenApi는 Swagger 가 데이터를 가져올 수 있도록 돕는 함수입니다.

 

 


[테스트]

테스트용 임시 Unity 클라이언트를 만들었습니다.

 

https://github.com/Minoritygames2/UnityApiTester/releases/tag/HttpRequese

 

암호화/복호화 방식은 서버와 동일한 구조로 구현되어있습니다.

 

● 테스트 환경

개발 환경에서는 암호화 없이도 요청과 응답이 정상처리됩니다.

 

 

● 배포환경

1. 암호화 하지 않을 경우

서버 에러가 납니다.

 

2. 올바른 키로 암호화 한 경우

정상 응답을 받을 수 있습니다.

 

3. 올바르지 않은 키로 암호화한 경우

에러가 나는것을 보실 수 있습니다.

 


이하 코드 전문

 

-. PacketEncryptionMiddleware.cs

using System.Security.Cryptography;
using System.Text;

namespace PPProject.Middleware
{
    public class PacketEncryptionMiddleware
    {
        private readonly RequestDelegate _next;

        private const int SaltSize = 16;    
        private const int KeySize = 32;
        private const int IvSize = 16;
        private const int Iterations = 100000;
        private readonly string _secret;

        public PacketEncryptionMiddleware(RequestDelegate next, IConfiguration config)
        {
            _next = next;
            _secret = config["PACKET_SECRET"] ?? throw new ArgumentNullException("PACKET_SECRET 설정이 필요합니다.");
        }

        public async Task InvokeAsync(HttpContext context)
        {
            if(context.Request.Method == HttpMethods.Post && context.Request.ContentLength > 0)
            {
                using (var reader = new StreamReader(context.Request.Body))
                {
                    //Body를 복호화
                    var body = await reader.ReadToEndAsync();
                    var decryptedBody = Decrypt(body);

                    //복호화된 Body로 교체
                    var byteArray = Encoding.UTF8.GetBytes(decryptedBody);
                    context.Request.Body = new MemoryStream(byteArray);
                }
            }

            //기존 응답 스트림을 저장
            var originalBodyStream = context.Response.Body;

            //새로운 메모리 스트림으로 응답 바디 교체
            using var newBodyStream = new MemoryStream();
            context.Response.Body = newBodyStream;

            //컨트롤러 응답 기다림
            await _next(context);

            //스트림 포인터를 초기위치로 이동
            context.Response.Body.Seek(0, SeekOrigin.Begin);

            //컨트롤러가 만든 응답을 꺼내서 읽음
            var plainResponseBody = await new StreamReader(context.Response.Body).ReadToEndAsync();

            //응답 암호화
            var encryptedResponseBody = Encrypt(plainResponseBody);
            var responseByteArray = Encoding.UTF8.GetBytes(encryptedResponseBody);

            //원래 응답 스트림으로 복원
            context.Response.Body = originalBodyStream;

            await context.Response.Body.WriteAsync(responseByteArray, 0, responseByteArray.Length);
        }


        private byte[] DeriveKey(string password, byte[] salt)
        {
            return Rfc2898DeriveBytes.Pbkdf2(
                password: password,
                salt: salt,
                iterations: Iterations,
                hashAlgorithm: HashAlgorithmName.SHA256,
                outputLength: KeySize
            );
        }

        private string Encrypt(string plainText)
        {
            var plainBytes = Encoding.UTF8.GetBytes(plainText);

            var salt = RandomNumberGenerator.GetBytes(SaltSize);
            var iv = RandomNumberGenerator.GetBytes(IvSize);

            var key = DeriveKey(_secret, salt);

            //AES 암호화 객체 생성
            using Aes aes = Aes.Create();
            aes.Key = key;
            aes.IV = iv;
            aes.Mode = CipherMode.CBC;
            aes.Padding = PaddingMode.PKCS7;

            //실제 암호화 객체 생성
            using var encryptor = aes.CreateEncryptor();

            //암호문
            var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);

            var resultByte = new byte[SaltSize + IvSize + cipherBytes.Length];

            Buffer.BlockCopy(salt, 0, resultByte, 0, SaltSize);
            Buffer.BlockCopy(iv, 0, resultByte, SaltSize, IvSize);
            Buffer.BlockCopy(cipherBytes, 0, resultByte, SaltSize + IvSize, cipherBytes.Length);

            return Convert.ToBase64String(resultByte);
        }

        private string Decrypt(string encryptedText)
        {
            var bytes = Convert.FromBase64String(encryptedText);

            var salt = new byte[SaltSize];
            var iv = new byte[IvSize];
            var cipherBytes = new byte[bytes.Length - SaltSize - IvSize];

            Buffer.BlockCopy(bytes, 0, salt, 0, SaltSize);
            Buffer.BlockCopy(bytes, SaltSize, iv, 0, IvSize);
            Buffer.BlockCopy(bytes, SaltSize + IvSize, cipherBytes, 0, cipherBytes.Length);

            //키 생성
            var key = DeriveKey(_secret, salt);

            using Aes aes = Aes.Create();
            aes.Key = key;
            aes.IV = iv;
            aes.Mode = CipherMode.CBC;
            aes.Padding = PaddingMode.PKCS7;

            using var decryptor = aes.CreateDecryptor();
            var resultBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);

            return Encoding.UTF8.GetString(resultBytes);
        }
    }
}

 

-. Program.cs

using PPProject.Auth;
using PPProject.Infrastructure;
using PPProject.Middleware;

var builder = WebApplication.CreateBuilder(args);

//.env 파일 로드
DotNetEnv.Env.Load();

builder.Configuration.AddEnvironmentVariables();

builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddMysql(builder.Configuration);
builder.Services.AddSnowflake();

builder.Services.AddAuth();

var app = builder.Build();


if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}
else
{
    app.UseMiddleware<PacketEncryptionMiddleware>();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

 

-. .env

MYSQL_HOST=pp-mysql
MYSQL_PORT=3306
MYSQL_USER=gameuser
MYSQL_PASSWORD=qwer1234!
MYSQL_DB=ppdb

PACKET_SECRET=SecretKey

 

-. launchSettings.json

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false,
      "applicationUrl": "http://0.0.0.0:80",
      "environmentVariables": {
        //"ASPNETCORE_ENVIRONMENT": "Development"
        "ASPNETCORE_ENVIRONMENT": "Production"
      }
    },
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false,
      "applicationUrl": "https://0.0.0.0:443;http://0.0.0.0:80",
      "environmentVariables": {
        //"ASPNETCORE_ENVIRONMENT": "Development"
        "ASPNETCORE_ENVIRONMENT": "Production"
      }
    }
  }
}