본문 바로가기

勉強/C#

문서 중앙화 DLP/ECM - C# KmsClient 구현 (RSA+AES 하이브리드 암호화, .enc 자동 복호화 열기, 저장 감지 재암호화)

프로젝트 개요

문서 중앙화 DLP/ECM 시스템의 Windows 클라이언트입니다. .enc 파일을 더블클릭하면 KMS 서버에서 복호화 키를 받아 임시 복호화 후 원본 앱으로 열고, 사용자가 저장하면 자동으로 재암호화합니다.

GitHub: https://github.com/LEntropy/Document_Management

전체 파일 열기 흐름

사용자가 file.docx.enc 더블클릭
    ↓
EncOpenHelper.DecryptToTempAndOpenAsync(encPath)
    ↓
EncCryptoBridge.DecryptEncToBytesAsync(encPath)
    ├─ .enc 파일에서 헤더(keyId, namespace, IV) 파싱
    ├─ KmsApiClient.RequestDecrypt(encKey, requesterOrg, fileOrg, keyId)
    │   └─ TLS 소켓 → KMS 서버(3.37.149.78:8443)
    │       └─ 서버가 RSA 개인키로 AES 키 복호화 후 반환
    └─ AesCrypto.Decrypt(ciphertext, aesKey, iv) → 원본 bytes
    ↓
%LOCALAPPDATA%/KmsClient/TempOpen/file.docx 임시 저장
    ├─ ACL: 현재 사용자만 접근 허용
    ↓
explorer.exe로 열기 (연결 프로그램 자동 실행)
    ↓
FileSystemWatcher 저장 감지 (Changed/Renamed 이벤트)
    ├─ 디바운스 700ms
    ├─ 파일 안정화 확인 (StableCheck × 4회)
    └─ EncCryptoBridge.EncryptAndWriteBack() → .enc 재암호화
    ↓
30초 후 임시 파일 자동 삭제

1. RSA+AES 하이브리드 암호화 구조

단계 알고리즘 역할
파일 암호화 AES-256-CBC 실제 파일 내용 암호화 (고속)
키 암호화 RSA 4096bit PKCS#1 v1.5 AES 키를 조직 공개키로 보호
키 복호화 RSA 개인키 (KMS 서버 보관) 서버만 AES 키 복호화 가능

클라이언트는 RSA 개인키를 절대 보유하지 않습니다. 복호화는 항상 KMS 서버를 거쳐야 합니다.

2. RSA 암호화 - RsaCrypto.cs

public static class RsaCrypto
{
    // AES 키를 RSA 공개키(X.509 인증서)로 암호화
    public static byte[] EncryptKey(byte[] plainKey, string pemText)
    {
        using (RSA rsa = LoadRsaFromCertificatePem(pemText))
        {
            byte[] encrypted = rsa.Encrypt(
                plainKey,
                RSAEncryptionPadding.Pkcs1  // OpenSSL RSA_private_decrypt와 호환
            );
            // RSA 4096bit = 512 bytes 검증
            if (encrypted.Length != rsa.KeySize / 8)
                throw new Exception("RSA encrypted length mismatch");
            return encrypted;
        }
    }

    // X.509 PEM → RSA 공개키 추출
    private static RSA LoadRsaFromCertificatePem(string pem)
    {
        byte[] der = DecodePem(pem, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----");
        var cert = new X509Certificate2(der);
        return cert.PublicKey.Key as RSA;
    }
}

3. AES-256-CBC 암호화/복호화 - AesCrypto.cs

public static class AesCrypto
{
    public static byte[] Encrypt(byte[] plaintext, byte[] key, byte[] iv)
    {
        using (var aes = Aes.Create())
        {
            aes.KeySize = 256;
            aes.Mode    = CipherMode.CBC;
            aes.Padding = PaddingMode.PKCS7;
            aes.Key = key;
            aes.IV  = iv;

            using (var encryptor = aes.CreateEncryptor())
            using (var ms = new MemoryStream())
            {
                using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
                    cs.Write(plaintext, 0, plaintext.Length);
                return ms.ToArray();
            }
        }
    }

    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())
            using (var ms = new MemoryStream(ciphertext))
            using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
            using (var result = new MemoryStream())
            {
                cs.CopyTo(result);
                return result.ToArray();
            }
        }
    }
}

4. KMS 서버 통신 - KmsApiClient.cs

TLS 소켓으로 KMS 서버(포트 8443)에 연결해 암호화된 AES 키의 복호화를 요청합니다.

public static byte[] RequestDecrypt(byte[] encKey, string requesterOrg, string fileOrg, string keyId)
{
    using (TcpClient client = new TcpClient())
    {
        client.ReceiveTimeout = 5000;
        client.Connect("3.37.149.78", 8443);

        using (SslStream ssl = new SslStream(client.GetStream(), false, (a,b,c,d) => true))
        {
            ssl.AuthenticateAsClient("kms-server");

            // 요청자 조직, 파일 조직, 키 ID 전송
            WriteString(ssl, requesterOrg);
            WriteString(ssl, fileOrg);
            WriteString(ssl, keyId);

            // 암호화된 AES 키 전송 (512 bytes)
            WriteInt32BE(ssl, encKey.Length);
            WriteExact(ssl, encKey);
            ssl.Flush();

            // 서버 응답 코드 수신
            int result = ReadInt32BE(ssl);
            if (result == -403)
            {
                MainForm.ShowAccessDeniedNotification("허용되지 않은 접근입니다.");
                throw new Exception("KMS 403 Forbidden");
            }

            // 복호화된 AES 키 수신
            int outLen = ReadInt32BE(ssl);
            return ReadExact(ssl, outLen);
        }
    }
}

5. .enc 파일 열기 - EncOpenHelper.cs

public static async Task DecryptToTempAndOpenAsync(string encPath, Action<string> log, Func<string, Task> onSaveBack)
{
    // 1. .enc → 원본 bytes 복호화
    byte[] plain = await EncCryptoBridge.DecryptEncToBytesAsync(encPath, log);

    // 2. ACL로 보호된 임시 디렉토리 생성 (현재 사용자만 접근)
    string tempDir = EnsureSecureTempDirectory(log);
    string tempPath = Path.Combine(tempDir, RemoveTrailingEnc(Path.GetFileName(encPath)));

    // 3. 임시 파일 저장 (Temporary 속성)
    File.WriteAllBytes(tempPath, plain);
    File.SetAttributes(tempPath, FileAttributes.Temporary);

    // 4. 저장 감지 Watcher 등록
    if (onSaveBack != null)
        InstallSaveWatcher(tempPath, log, onSaveBack);

    // 5. explorer.exe로 열기 (연결 프로그램 자동 실행)
    Process.Start(new ProcessStartInfo
    {
        FileName = "explorer.exe",
        Arguments = '"' + tempPath + '"',
        UseShellExecute = true
    });

    // 6. 30초 후 임시 파일 자동 삭제 (재시도 포함)
    Task.Run(() => DelayedDeleteWithRetry(tempPath, log));
}

6. 저장 감지 + 자동 재암호화 (FileSystemWatcher)

사용자가 임시 파일을 저장하면 원본 .enc 파일에 재암호화하여 덮어씁니다. 디바운스와 파일 안정화 확인으로 이벤트 폭주를 방지합니다.

private static void InstallSaveWatcher(string tempPath, Action<string> log, Func<string,Task> onSaveBack)
{
    var w = new FileSystemWatcher(Path.GetDirectoryName(tempPath))
    {
        Filter = Path.GetFileName(tempPath),
        NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName
    };

    // Changed: 일반 저장 (Word, Notepad 등)
    w.Changed += (s, e) => ScheduleSaveBack(tempPath, log, onSaveBack, "Changed");

    // Renamed: Office가 임시파일로 저장 후 rename하는 방식 대응
    w.Renamed += (s, e) => ScheduleSaveBack(tempPath, log, onSaveBack, "Renamed");

    w.EnableRaisingEvents = true;
    _watchers[tempPath] = w;
}

// 디바운스 700ms + 파일 안정화 확인 후 재암호화 콜백 호출
private static void ScheduleSaveBack(string tempPath, Action<string> log,
    Func<string,Task> onSaveBack, string reason)
{
    // 이전 예약 취소
    if (_debounce.TryGetValue(tempPath, out var old)) { old.Cancel(); }

    var cts = new CancellationTokenSource();
    _debounce[tempPath] = cts;

    Task.Run(async () =>
    {
        await Task.Delay(700, cts.Token);         // 디바운스 700ms
        bool stable = await WaitForStableAsync(tempPath, cts.Token);  // 안정화 확인
        if (stable) await onSaveBack(tempPath);   // 재암호화 실행
    });
}

7. .enc 파일 포맷 구조

[ .enc 파일 헤더 ]
+-------------------+------------------+
| 필드              | 크기             |
+-------------------+------------------+
| Magic (ECM1)      | 4 bytes          |
| keyId (UTF-8)     | 2 + N bytes      |
| namespace (UTF-8) | 2 + N bytes      |
| IV (AES)          | 16 bytes         |
| encKey (RSA)      | 512 bytes        |
| ciphertext        | 나머지 전체       |
+-------------------+------------------+

복호화 흐름:
1. 헤더에서 keyId, namespace, encKey, IV 파싱
2. KMS 서버에 encKey 복호화 요청 → rawAesKey 반환
3. AesCrypto.Decrypt(ciphertext, rawAesKey, IV) → 원본 파일

8. 아이콘 변경 - Shell/IconComposer.cs

.enc 파일에 자물쇠 아이콘을 오버레이하여 암호화 상태를 시각적으로 표시합니다.

public static class IconComposer
{
    // 원본 파일 아이콘 + 자물쇠 오버레이 합성
    public static Icon ComposeWithLock(string originalExtension)
    {
        Icon baseIcon = IconChache.GetIconForExtension(originalExtension);
        Bitmap bmp = baseIcon.ToBitmap();

        using (Graphics g = Graphics.FromImage(bmp))
        {
            // 우하단에 자물쇠 아이콘 오버레이
            g.DrawImage(Resources.LockOverlay, bmp.Width - 12, bmp.Height - 12, 12, 12);
        }
        return Icon.FromHandle(bmp.GetHicon());
    }
}

9. 가상 드라이브 마운트

암호화 보관 폴더를 Windows 가상 드라이브(Z:)로 매핑하여 탐색기에서 편리하게 접근할 수 있게 합니다.

// 가상 드라이브 마운트 (subst 명령 활용)
subst Z: "%TEMP%\ECM_Virtual_Root"

// 프로그램 코드에서:
Process.Start("subst", @"Z: " + virtualRootPath);

undefined