안녕하세요!

오늘은 대칭 알고리즘(SymmetricAlgorithm)을 이용한 데이터를 암호화하고 복호화하는 방법을 소개해 드리려고 합니다.


데이터의 양이 많아지면서, 정보의 양도 많아지고 "데이터 및 정보에 대한 보안"도 중요시되고 있습니다.

보안이라는 주제로 이야기를 하면 옛날 이야기로 시작을 할 수 있는데요, 세계 2차대전 당시 독일이 사용했던 "에니그마"를 대표적인 암호화 기법이라 말씀드릴 수 있습니다. 2차 세계대전 이전에도 암호화 기법을 사용하긴 했지만, 제가 아는 암호화 기법은 에니그마일 뿐더러(...) 해킹 대회에 출전했을 때에 에니그마로 암호화된 데이터를 복호화하는 문제가 있어서 기억에 많이 남습니다.


전쟁 중에는 작전 내용이 적힌 문서나 명령서등의 문서는 아주 중요한 정보를 담고 있고, 그 내용은 외부에는 절대 노출되어서는 안될만큼 기밀을 요구합니다. 이런 문서를 암호화하지 않고 평문으로 작성하였다면, 인질로 잡히거나 문서 전달 도중에 적을 만나서 그 문서를 빼았기게 된다면 모든 정보가 노출되버리는 것이죠. 하지만, 내용을 암호화하였다면 이야기는 달라집니다. 적군의 경우 봐도 내용을 모르기 때문에 정보가 노출될 위험이 적어지게 됩니다.


물론, 전쟁과 관련된 내용만이 아닙니다. 기업의 경우엔 새로 개발/연구중인 제품의 도안이나 사내 직원들의 개인 정보가 저장된 문서 등이 노출되면 안되겠죠. 경쟁 기업에서 헤드헌팅을 할 수도, 도안을 배껴서 먼저 특허를 낼 수도 있기 때문이죠.


개인의 경우는, 개인 정보를 예로 들 수 있겠습니다. 개인 정보가 유출되면 온갖 스팸과 보이스피싱, 광고 등에 이용되기 마련이죠.


서론이 상당히 길었습니다. 이제 본론으로 들어가서 C#에서는 어떻게 대칭 알고리즘을 이용하여 데이터를 암/복호화하는지 소개하도록 하겠습니다.



.NET Framework에 정의된 대칭 알고리즘의 종류는 총 6가지입니다.

  • Aes
  • DES
  • MACTripleDES
  • RC2
  • Rijndael
  • TripleDES

여기서, 중복되는 것을 제외하면 총 5가지입니다.
Aes 및 Rijndael 알고리즘은 서로 중복되기 때문입니다. 내부 코드를 보시면 아시겠지만, Aes 에서는 내부적으로 Rijndael 알고리즘을 사용하고 있습니다.
이 게시글에서는 Aes 를 다루려 합니다.

우선, 들어가기에 앞서서 알아야 할 몇 가지에 대해서 짚고 넘어가도록 하겠습니다.
  • ICryptoTransform 인터페이스와 CryptoStream 클래스를 사용해야만 암/복호화가 가능하다.
  • 잘못된 키 혹은 데이터가 손상된 경우 예외가 발생할 가능성이 매우 높다.
  • 알고리즘이 잘 최적화 되어있기 때문에 속도 걱정은 안해도 된다.
  • 키와 초기 벡터(IV)라는 것을 이용하여 암/복호화 처리를 한다.

제가 봤을 때 중요하다고 생각되는 것들은 굵은 글씨로 표시를 해놓았습니다.
이제, 맛보기를 시작할 차례입니다.

DataCryptor 클래스 (C#)
using System;
using System.Security.Cryptography;

class DataCryptor : IDisposable {
    Aes     aes = null;
    bool    disposed = false;


    public DataCryptor() {
        // 이 코드는 new AesManaged(); 로도 변경이 가능합니다.
        // aes = new AesManaged();
        aes = Aes.Create();
    }

    public byte[] Encrypt(byte[] b, byte[] key, byte[] iv) {

    }

    public byte[] Decrypt(byte[] b, byte[] key, byte[] iv) {

    }
    
    public void Dispose() {
        if (disposed) return;
        aes.Dispose();
        disposed = true;
    }
}
예제 코드 (.NET Fiddle)


DataCryptor 라는 클래스를 통해 손쉽게 암호화 및 복호화를 수행하기 위해 Encrypt, Decrypt 메서드를 정의하였습니다.

IDisposable 인터페이스의 경우 나중에 자세한 소개/설명을 하도록 하겠습니다. 지금은 다른 것에 집중할 때이니까요.

위 클래스의 생성자를 보시면 `aes = Aes.Create();` 코드를 보실 수 있습니다.


.NET 에서 지원하는 모든 대칭 알고리즘에는 알고리즘 이름에 해당하는 클래스와, CryptoServiceProvider, 그리고 몇몇 알고리즘에는 Managed 가 접미사로 붙는 클래스가 있습니다. 해당 클래스는 "알고리즘 이름" 이나 "알고리즘 이름Managed" 나 서로 동일한 것입니다. 단, 차이점은 "알고리즘 이름" 클래스는 정적 메서드인 Create를 호출해야 개체를 만들고, "알고리즘 이름Managed"는 new 키워드를 사용해 개체를 만들 수 있다는 점입니다.


생성자에서는 할 일을 다했습니다. 그럼 이제 다음 단계로 넘어가 보도록 하죠.


DataCryptor.Encrypt 메서드 (C#)
// ABCDEFGHABCDEFGHABCDEFGHABCDEFGH 입니다.
static byte[] DefaultKey = {
    0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
    0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
    0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
    0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
};
// 0123456701234567 입니다.
static byte[] DefaultIV  = {
    0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
    0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37
};

public byte[] Encrypt(byte[] b, byte[] key, byte[] iv) {
    // 데이터가 입력되지 않으면 예외를 발생시키고,
    // 키나 IV가 입력되지 않으면 기본 값을 사용합니다.
    if (b == null || b.Length == 0) throw new ArgumentException();
    if (key == null || key.Length == 0) key = DefaultKey;
    if (iv == null || iv.Length == 0) iv = DefaultIV;

    // #1
    aes.Key = key;
    aes.IV = iv;

    // #2
    ICryptoTransform transform = aes.CreateEncryptor();
    MemoryStream result = new MemoryStream();

    // #3
    using (CryptoStream cs = new CryptoStream(result, transform, CryptoStreamMode.Write)) {

    }

    return null;
}

코드 내에 각각 #1, #2, #3 표시가 있는데요, 해당하는 번호에 맞는 설명을 하려고 표시를 해놓았습니다.


#1

암/복호화에 사용할 키와 IV를 설정하는 부분입니다.

각 알고리즘마다 지원하는 키 길이가 있는데요, 이 길이에 맞추지 않으면 예외가 발생하게 됩니다.

그리고 키 길이에 대해서는 바이트 단위가 아닌 비트 단위로 크기를 계산하는 것이기 때문에 실수를 할 수 있습니다.

또한 키 길이는 앞서 말씀드렸듯 지원하는 키 길이가 있습니다. 그럼 어떻게 지원하는 키 길이는 어떻게 알아낼 수 있을까요?

정답은! LegalKeySizes 속성을 이용하는 방법이 있습니다.

이 속성은 KeySize 클래스의 배열이고 KeySize 클래스는 MinSize, SkipSize, MaxSize 세 가지 속성을 가지고 있습니다.

각 속성이 나타내는 의미는 다음과 같습니다.

  • MinSize : 사용할 수 있는 최소 크기
  • SkipSize: 건너뛸 수 있는 크기
  • MaxSize : 사용할 수 있는 최대 크기
SkipSize는 MinSize와 MaxSize간의 간격을 얼만큼씩 띄워줘야 하는지를 나타냅니다.

말로는 설명이 힘드니 예를 들어보도록 하겠습니다.


MinSize = 64 / SkipSize = 32 / MaxSize = 256

* 여기서 나타내는 값은 바이트 단위가 아닌 "비트" 단위임을 명심하셔야 합니다!


최소 크기 8바이트 / 스킵 크기 4바이트 / 최대 크기 32바이트 입니다.

키 크기를 64나 96, 128, ..., 256 이 값들을 사용하는 것은 오류가 나지 않습니다.

왜냐? MinSize 부터 SkipSize 값 만큼 크기가 차이나는 값들을 사용했기 때문이죠.

하지만, 65나 100, 150 이런 값들은 오류가 나게 됩니다. SkipSize 만큼 차이가 나지 않기 때문이죠. (65: 64와 1차이, 100: 96과 4차이 등등)



#2

조금 위로 올라가시면 제가 "ICryptoTransform 인터페이스와 CryptoStream 클래스를 사용해야만 암/복호화가 가능하다" 는 말씀을 드렸습니다.

암/복호화는 그냥 되는게 아니라 CryptoStream 클래스와 ICryptoTransform 인터페이스가 같이 사용되어야만 가능합니다.

비유로 들어보자면 CryptoStream 클래스는 "암호문, 또는 평문" 이고, ICryptoTransform 인터페이스는 "암호 생성/해독기" 로 표현할 수 있습니다.

그리고, CryptoStream 클래스를 생성할 때 스트림이 필요한데요!

암호화의 경우엔 암호화된 데이터가 저장될 스트림을 사용해야 하고,

복호화의 경우엔 복호화할 데이터가 저장된 스트림을 사용해야 합니다.

즉, 암호화의 경우 일반적으로 빈 스트림을 사용하지만 복호화의 경우엔 꼭 암호화된 데이터가 저장된 스트림을 사용해야 합니다.



#3

이 CryptoStream 개체를 통해 데이터 암/복호화가 이뤄집니다. 정확히는 ICryptoTransform 개체를 통해서 이뤄지죠.

매개 변수부터 살펴보도록 할게요!

CryptoStream 생성자 (MSDN)

첫 번째 매개 변수

Stream

작업의 결과가 저장될 스트림 또는 작업할 데이터가 저장된 스트림


두 번째 매개 변수

ICryptoTransform

암호화 또는 복호화에 사용할 일종의 "필터"


세 번째 매개 변수

CryptoStreamMode

암호화할 것인지 복호화할 것인지를 결정함. (Read = 복호화 / Write = 암호화)


첫 번째 매개 변수는 말 그대로이구요!

두 번째 매개 변수는 CreateEncryptor(), CreateDecryptor() 메서드를 통하여 전달하면 됩니다.

세 번째 매개 변수도 말 그대로입니다!



자세하게 짚어봤으니 이제 데이터를 암호화하는 구간을 보도록 할게요!


데이터 암호화 (C#)
using (CryptoStream cs = new CryptoStream(result, transform, CryptoStreamMode.Write)) {
    int ofs = 0;
    int bufsize = 4096;

    while ( ofs < b.Length ) {
        int c = b.Length - ofs;
        if (c > bufsize) c = bufsize;
        cs.Write(b, ofs, c);
        ofs += bufsize;
    }
}
transform.Dispose();
return result.ToArray();

정말 간단하지 않나요?

CryptoStream.Write() 메서드 하나로 암호화를 하는 구간이 완성됬습니다. 정말이예요.

암호화된 결과는 간단하게 MemoryStream.ToArray() 메서드를 호출함으로서 반환을 하도록 되었습니다.

그럼, 한번 실행 결과를 보시도록 할게요!


테스트를 위해서 작성된 진입점 코드입니다:


DataCryptor 클래스 암호화 테스트 (C#)
class MainProgram {
    static void Main(string[] args) {
        DataCryptor dc = new DataCryptor();
        byte[] data = Encoding.ASCII.GetBytes("Encryption example in C# Application");
        Console.WriteLine("Before Encrypt");
        Console.WriteLine("  Raw   : {0}", Encoding.ASCII.GetString(data));
        Console.WriteLine("  Base64: {0}", Convert.ToBase64String(data));
        Console.WriteLine();
        data = dc.Encrypt(data, null, null);
        Console.WriteLine("After Encrypt");
        Console.WriteLine("  Raw   : {0}", Encoding.ASCII.GetString(data));
        Console.WriteLine("  Base64: {0}", Convert.ToBase64String(data));
    }
}
예제 코드 (.NET Fiddle)


실행 결과 화면입니다:


암호화를 하기 전에는 문자열이 잘 출력되는 것을 보실 수 있습니다. 하지만, 암호화가 된 후에는 알 수 없는 문자들이 출력되고 있네요!

그럼 이제, 복호화를 하는 메서드도 완성을 해볼까요?


DataCryptor.Decrypt 메서드 (C#)
public byte[] Decrypt(byte[] b, byte[] key, byte[] iv) {
    if (b == null || b.Length == 0) throw new ArgumentException();
    if (key == null || key.Length == 0) key = DefaultKey;
    if (iv == null || iv.Length == 0) iv = DefaultIV;
    
    aes.Key = key;
    aes.IV = iv;
    
    ICryptoTransform transform = aes.CreateDecryptor();
    MemoryStream data = new MemoryStream(b);
    MemoryStream result = new MemoryStream();

    using (CryptoStream cs = new CryptoStream(data, transform, CryptoStreamMode.Read)) {
        int bufsize = 4096;
        byte[] buf = new byte[4096];
        // 암호 스트림에서 데이터를 읽습니다.
        int nRead = cs.Read(buf, 0, bufsize);

        // 데이터를 읽어왔다면,
        while (nRead > 0 ) {
            // 읽은 데이터만큼 결과를 저장할 스트림에 쓰고,
            // 다시 암호 스트림에서 데이터를 읽습니다.
            result.Write(buf, 0, nRead);
            nRead = cs.Read(buf, 0, bufsize);
        }
    }

    transform.Dispose();
    result.Dispose();
    return result.ToArray();
}
예제 코드 (.NET Fiddle)


Encrypt 메서드와 바뀐 점

  • ICryptoTransform 변수를 CreateEncryptor 메서드가 아닌 CreateDecryptor 메서드를 사용
  • 암호화된 데이터가 저장되어 있는 data, 복호화된 데이터가 저장될 result 총 2개의 스트림을 사용
  • CryptoStream 생성 시 CryptoStreamMode 의 값을 Write 가 아닌 Read 사용
  • CryptoStream 의 데이터를 읽은 후, result 스트림에 씀


이 정도가 바뀌었습니다.

그럼 전체 코드를 한 번 보여드리고, 실행 결과와 테스트에 사용된 코드를 보여드리고 마치도록 하겠습니다.


DataCryptor 클래스 소스 (C#)
class DataCryptor : IDisposable {
    Aes     aes = null;
    bool    disposed = false;


    public DataCryptor() {
        // 이 코드는 new AesManaged(); 로도 변경이 가능합니다.
        // aes = new AesManaged();
        aes = Aes.Create();
    }

    // ABCDEFGHABCDEFGHABCDEFGHABCDEFGH 입니다.
    static byte[] DefaultKey = {
        0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
        0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
        0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
        0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68,
    };
    // 0123456701234567 입니다.
    static byte[] DefaultIV  = {
        0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
        0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37
    };

    public byte[] Encrypt(byte[] b, byte[] key, byte[] iv) {
        // 데이터가 입력되지 않으면 예외를 발생시키고,
        // 키나 IV가 입력되지 않으면 기본 값을 사용합니다.
        if (b == null || b.Length == 0) throw new ArgumentException();
        if (key == null || key.Length == 0) key = DefaultKey;
        if (iv == null || iv.Length == 0) iv = DefaultIV;

        // #1
        aes.Key = key;
        aes.IV = iv;

        // #2
        ICryptoTransform transform = aes.CreateEncryptor();
        MemoryStream result = new MemoryStream();

        // #3
        using (CryptoStream cs = new CryptoStream(result, transform, CryptoStreamMode.Write)) {
            int ofs = 0;
            int bufsize = 4096;

            while ( ofs < b.Length ) {
                int c = b.Length - ofs;
                if (c > bufsize) c = bufsize;
                cs.Write(b, ofs, c);
                ofs += bufsize;
            }
        }
        transform.Dispose();
        return result.ToArray();
    }

    public byte[] Decrypt(byte[] b, byte[] key, byte[] iv) {
        if (b == null || b.Length == 0) throw new ArgumentException();
        if (key == null || key.Length == 0) key = DefaultKey;
        if (iv == null || iv.Length == 0) iv = DefaultIV;
        
        aes.Key = key;
        aes.IV = iv;
        
        ICryptoTransform transform = aes.CreateDecryptor();
        MemoryStream data = new MemoryStream(b);
        MemoryStream result = new MemoryStream();

        using (CryptoStream cs = new CryptoStream(data, transform, CryptoStreamMode.Read)) {
            int bufsize = 4096;
            byte[] buf = new byte[4096];
            // 암호 스트림에서 데이터를 읽습니다.
            int nRead = cs.Read(buf, 0, bufsize);

            // 데이터를 읽어왔다면,
            while (nRead > 0 ) {
                // 읽은 데이터만큼 결과를 저장할 스트림에 쓰고,
                // 다시 암호 스트림에서 데이터를 읽습니다.
                result.Write(buf, 0, nRead);
                nRead = cs.Read(buf, 0, bufsize);
            }
        }

        transform.Dispose();
        result.Dispose();
        return result.ToArray();
    }
    
    public void Dispose() {
        if (disposed) return;
        aes.Dispose();
        disposed = true;
    }
}
예제 코드 (.NET Fiddle)

테스트에 사용된 진입점 코드:

DataCryptor 암/복호화 테스트 (C#)
class MainProgram {
    static void Main(string[] args) {
        DataCryptor dc = new DataCryptor();
        byte[] data = Encoding.ASCII.GetBytes("Encryption example in C# Application");
        Console.WriteLine("Before Encrypt");
        Console.WriteLine("  Raw   : {0}", Encoding.ASCII.GetString(data));
        Console.WriteLine("  Base64: {0}", Convert.ToBase64String(data));
        Console.WriteLine();
        data = dc.Encrypt(data, null, null);
        Console.WriteLine("After Encrypt");
        Console.WriteLine("  Raw   : {0}", Encoding.ASCII.GetString(data));
        Console.WriteLine("  Base64: {0}", Convert.ToBase64String(data));
        Console.WriteLine();
        data = dc.Decrypt(data, null, null);
        Console.WriteLine("And Decrypt the encrypted data");
        Console.WriteLine("  Raw   : {0}", Encoding.ASCII.GetString(data));
        Console.WriteLine("  Base64: {0}", Convert.ToBase64String(data));
    }
}
예제 코드 (.NET Fiddle)


실행 결과 화면입니다:



대칭 알고리즘을 통한 데이터 암/복호화를 하는 방법에 대해서 말씀드려보았습니다.

어려울 것 같았지만, 어렵지 않다는 것이 장점이라 볼 수 있겠네요.


글을 읽으시면서 이해가 안가셨거나, 어려웠던 내용이 있다면! 틀린 점이나 지적하실 부분이 있다면! 댓글을 남겨주세요!!

긴 글 읽어주셔서 감사합니다.

+ Recent posts