django 서버의 개인정보를 AES-256-GCM 알고리즘으로 암호화하기

django 서버의 개인정보를 AES-256-GCM 알고리즘으로 암호화하기
Photo by Markus Spiske / Unsplash

이번 작업은...

성능이 매우 낮은 레거시를 쳐내는 작업이었다. 이번 작업의 요구사항은 아래와 같이 정의할 수 있었다.

성능이 기존보다 빨라야 할 것

aws KMS를 사용하고 있었는데 네트워크를 타야하다보니 암호화가 느렸다. 개인정보를 암호화하는 데 이것보다는 훨씬 성능이 빨라야 한다.

법률을 준수하는 보안 설계
프로젝트 수행 시 정보 시스템을 구축하게 되고 정보 시스템을 보호하기 위해 보안 솔루션들을 도입하게 되는데 이때 ‘보안 관련 법률을 잘 준수되는 시스템을 구축되도록 하려면 어떻게 하면 될까?’ 고민하였던..
이 정도까지는 아니겠지만, 많은 항목들을 암호화해야 하므로..

여러 구현체를 두고 시간 측정을 진행했다. ( 사실 유의미한 결과는 얻지 못했다는게 함정.. )

유지보수가 쉬워야 할 것

잘 유지보수가 되는 오픈소스 라이브러리를 생각했다. 다양한 언어와 플랫폼에서 구현가능한 암호화여야 했다. 표준 알고리즘을 사용한다면 큰 문제는 없었을 것이다. 무결성을 보장할 수 있는 MAC인증을 지원하는 알고리즘(GCM, CCM, EAX, SIV, OCB)을 사용하고자 했다.

AES-256-GCM은 aws KMS 에서 대칭키 설정에서 사용되는 알고리즘이기도 하다.

Galois/Counter Mode - Wikipedia
사실 수학적인 부분은 전공시간에 열심히 배웠지만.. 대충 이해되긴 한다. 

구현은 pycryptodome 이라는 라이브러리를 사용하였다.

Modern modes of operation for symmetric block ciphers — PyCryptodome 3.14.1 documentation

구현체

import base64

from Crypto.Cipher import AES
from django.conf import settings


class CipherV1:
    def cipher(self, nonce=None):
        return AES.new(
            key=self._base64str_to_binary(settings.CIPHER_V1_KEY), 
            mode=AES.MODE_GCM, 
            nonce=nonce
        )

    def encrypt(self, value: str) -> str:
        cipher = self.cipher()
        cipher_text, tag = cipher.encrypt_and_digest(bytes(value, 'utf-8'))
        nonce = self._binary_to_base64str(cipher.nonce)
        cipher_text = self._binary_to_base64str(cipher_text)
        tag = self._binary_to_base64str(tag)
        return f'v=1,a=aes256gcm,{nonce},{cipher_text},{tag}'

    def decrypt(self, value: str) -> str:
        splitted_text = value.split(',')
        nonce = self._base64str_to_binary(splitted_text[2])
        cipher_text = self._base64str_to_binary(splitted_text[3])
        tag = self._base64str_to_binary(splitted_text[4])
        cipher = self.cipher(nonce)
        text = cipher.decrypt_and_verify(cipher_text, tag)
        return bytes.decode(text, 'utf-8')

    @staticmethod
    def _binary_to_base64str(value: bytes) -> str:
        encoded = base64.b64encode(value)
        return bytes.decode(encoded, 'utf-8')

    @staticmethod
    def _base64str_to_binary(value: str) -> bytes:
        return base64.b64decode(value)
메인 장고 코드에 이런 클래스를 구현해두었다.

def cipher(self, nonce=None)

text를 encrypt할 때에는 nonce (IV)를 랜덤으로 생성할 수 있도록 AES.new() 를 호출 시 nonce=None 으로 대입될 수 있게 한다.

def encrypt(self, value: str)

랜덤으로 생성된 nonce (IV)로 암호화할 수 있도록 한다.

  • encrypt_and_digest() 라는 함수를 통해 암호화와 인증을 동시에 할 수 있도록 인터페이스가 제공된다. cipher_text 는 암호화된 텍스트, tag 는 인증을 위한 값이며 암호화의 무결성을 얻을 수 있다.
  • 데이터베이스에 필드를 작성할 때, 아래와 같은 규칙을 따르려고 하였다.
    v=1,a=aes256gcm,{nonce(IV)},{cipher_text},{tag}
    어짜피 base64인코딩된 스트링은 , 가 포함되지 못하므로 한 필드에 여러가지 데이터를 담을 수 있는 구분자가 될 수 있다. 향후 서비스가 고도화되어 다양한 암호화가 진행되어야 할 때, 이런 프로토콜을 갖추고 있다면 문제없을 것 같았다.

def decrypt(self, value: str)

데이터베이스에 저장된 필드를 그대로 value 안에 넣도록 하였다. 조금 더 리팩토링할 여지가 보인다면, 인정할 수 있는 부분이지만 성급한 일반화를 시키고싶지는 않았다. 같은 의견의 코드리뷰도 있었고.

  • decrypt_and_verify() 라는 함수는 위 encrypt() 함수 안에서 사용된 encrypt_and_digest() 와 쌍이 되는 인터페이스이다. 복호화와 동시에 tag를 통해 인증을 진행한다. 인증에 실패하면 Error가 발생된다.

장고 서버에 커스텀 필드 생성

from django.db.models import CharField

from lib.encryption.cipher import CipherV1


class EncryptedCharField(CharField):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def get_prep_value(self, value):
        value = super().get_prep_value(value)
        if not value:
            return None
        return CipherV1().encrypt(value)

장고 ORM 모델링에 EncryptedCharField 를 사용

class User(AbstractBaseUser, PermissionsMixin):
    # ...
    email_address = EncryptedCharField(max_length=220, null=True)

빠른 검색이 필요하다면, email_address_hash 같은 필드와 함께 해시값을 같이 저장하면 되겠다.

다음 포스트에선, django application (python)환경에서 암호화한 코드를 Java환경에서 사용할 수 있는지 안내한다.