feat: E2EE 암호화 유틸리티 구현 (#28)
- AesEncryption: AES-256-CBC 암호화/복호화 - RsaEncryption: RSA-2048 키 쌍 생성/암복호화 - E2EEService: 하이브리드 암복호화 (요청 복호화, 응답 암호화) - TimestampValidator: 타임스탬프 검증 (±30초) - SecureTransportAttribute: Action Filter (보안등급 3 엔드포인트용) - DI 등록: IE2EEService → E2EEService (Singleton) Closes #28
This commit is contained in:
parent
0070ae58b9
commit
27f33f809b
96
SPMS.API/Filters/SecureTransportAttribute.cs
Normal file
96
SPMS.API/Filters/SecureTransportAttribute.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
SPMS.Application/Interfaces/IE2EEService.cs
Normal file
13
SPMS.Application/Interfaces/IE2EEService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
SPMS.Infrastructure/Security/AesEncryption.cs
Normal file
44
SPMS.Infrastructure/Security/AesEncryption.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
SPMS.Infrastructure/Security/E2EEService.cs
Normal file
64
SPMS.Infrastructure/Security/E2EEService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
SPMS.Infrastructure/Security/RsaEncryption.cs
Normal file
32
SPMS.Infrastructure/Security/RsaEncryption.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
SPMS.Infrastructure/Security/TimestampValidator.cs
Normal file
13
SPMS.Infrastructure/Security/TimestampValidator.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user