feat: E2EE 암호화 유틸리티 구현 (#28)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/29
This commit is contained in:
김선규 2026-02-09 07:36:55 +00:00
commit fb0da8669d
7 changed files with 264 additions and 0 deletions

View File

@ -0,0 +1,96 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Infrastructure.Security;
namespace SPMS.API.Filters;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class SecureTransportAttribute : Attribute, IAsyncResourceFilter
{
public async Task OnResourceExecutionAsync(
ResourceExecutingContext context,
ResourceExecutionDelegate next)
{
var e2eeService = context.HttpContext.RequestServices
.GetRequiredService<IE2EEService>();
// 1. Read raw request body
context.HttpContext.Request.EnableBuffering();
using var ms = new MemoryStream();
await context.HttpContext.Request.Body.CopyToAsync(ms);
var encryptedPayload = ms.ToArray();
if (encryptedPayload.Length < 272)
{
context.Result = new ObjectResult(
ApiResponse.Fail(ErrorCodes.BadRequest,
"암호화된 요청 데이터가 올바르지 않습니다."))
{ StatusCode = 400 };
return;
}
// 2. Decrypt request
E2EEDecryptResult decryptResult;
try
{
decryptResult = e2eeService.DecryptRequest(encryptedPayload);
}
catch
{
context.Result = new ObjectResult(
ApiResponse.Fail(ErrorCodes.BadRequest,
"요청 복호화에 실패했습니다."))
{ StatusCode = 400 };
return;
}
// 3. Validate timestamp
if (decryptResult.DecryptedBody.Length > 0)
{
try
{
using var doc = JsonDocument.Parse(decryptResult.DecryptedBody);
if (doc.RootElement.TryGetProperty("timestamp", out var ts))
{
if (!TimestampValidator.IsValid(ts.GetInt64()))
{
context.Result = new ObjectResult(
ApiResponse.Fail(ErrorCodes.Unauthorized,
"요청 시간이 만료되었습니다."))
{ StatusCode = 401 };
return;
}
}
}
catch (JsonException)
{
// timestamp 필드가 없거나 JSON이 아닌 경우 스킵
}
}
// 4. Store AES key for response encryption
context.HttpContext.Items["E2EE_AesKey"] = decryptResult.AesKey;
// 5. Replace request body with decrypted content
var decryptedStream = new MemoryStream(decryptResult.DecryptedBody);
context.HttpContext.Request.Body = decryptedStream;
context.HttpContext.Request.ContentLength = decryptResult.DecryptedBody.Length;
context.HttpContext.Request.ContentType = "application/json";
// 6. Execute action
var resultContext = await next();
// 7. Encrypt response
if (resultContext.Result is ObjectResult objectResult &&
context.HttpContext.Items.TryGetValue("E2EE_AesKey", out var keyObj) &&
keyObj is byte[] aesKey)
{
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(objectResult.Value);
var encrypted = e2eeService.EncryptResponse(jsonBytes, aesKey);
resultContext.Result = new FileContentResult(encrypted, "application/octet-stream");
}
}
}

View File

@ -0,0 +1,13 @@
namespace SPMS.Application.Interfaces;
public class E2EEDecryptResult
{
public required byte[] DecryptedBody { get; init; }
public required byte[] AesKey { get; init; }
}
public interface IE2EEService
{
E2EEDecryptResult DecryptRequest(byte[] encryptedPayload);
byte[] EncryptResponse(byte[] plainResponse, byte[] aesKey);
}

View File

@ -6,6 +6,7 @@ using SPMS.Domain.Interfaces;
using SPMS.Infrastructure.Auth; using SPMS.Infrastructure.Auth;
using SPMS.Infrastructure.Persistence; using SPMS.Infrastructure.Persistence;
using SPMS.Infrastructure.Persistence.Repositories; using SPMS.Infrastructure.Persistence.Repositories;
using SPMS.Infrastructure.Security;
namespace SPMS.Infrastructure; namespace SPMS.Infrastructure;
@ -26,6 +27,7 @@ public static class DependencyInjection
// External Services // External Services
services.AddScoped<IJwtService, JwtService>(); services.AddScoped<IJwtService, JwtService>();
services.AddSingleton<IE2EEService, E2EEService>();
return services; return services;
} }

View File

@ -0,0 +1,44 @@
using System.Security.Cryptography;
namespace SPMS.Infrastructure.Security;
public static class AesEncryption
{
public static byte[] Encrypt(byte[] plaintext, byte[] key, byte[] iv)
{
using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var encryptor = aes.CreateEncryptor();
return encryptor.TransformFinalBlock(plaintext, 0, plaintext.Length);
}
public static byte[] Decrypt(byte[] ciphertext, byte[] key, byte[] iv)
{
using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
return decryptor.TransformFinalBlock(ciphertext, 0, ciphertext.Length);
}
public static byte[] GenerateKey()
{
var key = new byte[32];
RandomNumberGenerator.Fill(key);
return key;
}
public static byte[] GenerateIv()
{
var iv = new byte[16];
RandomNumberGenerator.Fill(iv);
return iv;
}
}

View File

@ -0,0 +1,64 @@
using Microsoft.Extensions.Configuration;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Exceptions;
namespace SPMS.Infrastructure.Security;
public class E2EEService : IE2EEService
{
private const int RsaEncryptedKeySize = 256;
private const int AesIvSize = 16;
private const int MinPayloadSize = RsaEncryptedKeySize + AesIvSize;
private readonly string _privateKeyPem;
public E2EEService(IConfiguration configuration)
{
var keyPath = configuration["E2EE:PrivateKeyPath"];
if (!string.IsNullOrEmpty(keyPath) && File.Exists(keyPath))
{
_privateKeyPem = File.ReadAllText(keyPath);
}
else
{
_privateKeyPem = configuration["E2EE:PrivateKeyPem"] ?? string.Empty;
}
}
public E2EEDecryptResult DecryptRequest(byte[] encryptedPayload)
{
if (string.IsNullOrEmpty(_privateKeyPem))
throw SpmsException.BadRequest("E2EE 키가 설정되지 않았습니다.");
if (encryptedPayload.Length < MinPayloadSize)
throw SpmsException.BadRequest("잘못된 암호화 페이로드입니다.");
var encryptedKey = encryptedPayload[..RsaEncryptedKeySize];
var iv = encryptedPayload[RsaEncryptedKeySize..(RsaEncryptedKeySize + AesIvSize)];
var encryptedBody = encryptedPayload[(RsaEncryptedKeySize + AesIvSize)..];
var aesKey = RsaEncryption.Decrypt(encryptedKey, _privateKeyPem);
var decryptedBody = encryptedBody.Length > 0
? AesEncryption.Decrypt(encryptedBody, aesKey, iv)
: [];
return new E2EEDecryptResult
{
DecryptedBody = decryptedBody,
AesKey = aesKey
};
}
public byte[] EncryptResponse(byte[] plainResponse, byte[] aesKey)
{
var iv = AesEncryption.GenerateIv();
var encrypted = AesEncryption.Encrypt(plainResponse, aesKey, iv);
var result = new byte[iv.Length + encrypted.Length];
Buffer.BlockCopy(iv, 0, result, 0, iv.Length);
Buffer.BlockCopy(encrypted, 0, result, iv.Length, encrypted.Length);
return result;
}
}

View File

@ -0,0 +1,32 @@
using System.Security.Cryptography;
namespace SPMS.Infrastructure.Security;
public static class RsaEncryption
{
public static byte[] Decrypt(byte[] encryptedData, string privateKeyPem)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
return rsa.Decrypt(encryptedData, RSAEncryptionPadding.OaepSHA256);
}
public static byte[] Encrypt(byte[] data, string publicKeyPem)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.Encrypt(data, RSAEncryptionPadding.OaepSHA256);
}
public static (string PublicKeyPem, string PrivateKeyPem) GenerateKeyPair()
{
using var rsa = RSA.Create(2048);
var privateKey = rsa.ExportRSAPrivateKey();
var publicKey = rsa.ExportRSAPublicKey();
var privatePem = new string(PemEncoding.Write("RSA PRIVATE KEY", privateKey));
var publicPem = new string(PemEncoding.Write("RSA PUBLIC KEY", publicKey));
return (publicPem, privatePem);
}
}

View File

@ -0,0 +1,13 @@
namespace SPMS.Infrastructure.Security;
public static class TimestampValidator
{
private static readonly TimeSpan Tolerance = TimeSpan.FromSeconds(30);
public static bool IsValid(long unixTimeSeconds)
{
var requestTime = DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds);
var diff = DateTimeOffset.UtcNow - requestTime;
return diff.Duration() <= Tolerance;
}
}