diff --git a/SPMS.API/Filters/SecureTransportAttribute.cs b/SPMS.API/Filters/SecureTransportAttribute.cs new file mode 100644 index 0000000..63feb87 --- /dev/null +++ b/SPMS.API/Filters/SecureTransportAttribute.cs @@ -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(); + + // 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"); + } + } +} diff --git a/SPMS.Application/Interfaces/IE2EEService.cs b/SPMS.Application/Interfaces/IE2EEService.cs new file mode 100644 index 0000000..1866178 --- /dev/null +++ b/SPMS.Application/Interfaces/IE2EEService.cs @@ -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); +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 7e7efa2..1fe5cb4 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -6,6 +6,7 @@ using SPMS.Domain.Interfaces; using SPMS.Infrastructure.Auth; using SPMS.Infrastructure.Persistence; using SPMS.Infrastructure.Persistence.Repositories; +using SPMS.Infrastructure.Security; namespace SPMS.Infrastructure; @@ -26,6 +27,7 @@ public static class DependencyInjection // External Services services.AddScoped(); + services.AddSingleton(); return services; } diff --git a/SPMS.Infrastructure/Security/AesEncryption.cs b/SPMS.Infrastructure/Security/AesEncryption.cs new file mode 100644 index 0000000..0ab24bb --- /dev/null +++ b/SPMS.Infrastructure/Security/AesEncryption.cs @@ -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; + } +} diff --git a/SPMS.Infrastructure/Security/E2EEService.cs b/SPMS.Infrastructure/Security/E2EEService.cs new file mode 100644 index 0000000..7275772 --- /dev/null +++ b/SPMS.Infrastructure/Security/E2EEService.cs @@ -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; + } +} diff --git a/SPMS.Infrastructure/Security/RsaEncryption.cs b/SPMS.Infrastructure/Security/RsaEncryption.cs new file mode 100644 index 0000000..77ed183 --- /dev/null +++ b/SPMS.Infrastructure/Security/RsaEncryption.cs @@ -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); + } +} diff --git a/SPMS.Infrastructure/Security/TimestampValidator.cs b/SPMS.Infrastructure/Security/TimestampValidator.cs new file mode 100644 index 0000000..f65deda --- /dev/null +++ b/SPMS.Infrastructure/Security/TimestampValidator.cs @@ -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; + } +}