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

성능이 매우 낮은 레거시를 쳐내는 작업이었다. 이번 작업의 요구사항은 아래와 같이 정의할 수 있었다.
성능이 기존보다 빨라야 할 것
aws KMS를 사용하고 있었는데 네트워크를 타야하다보니 암호화가 느렸다. 개인정보를 암호화하는 데 이것보다는 훨씬 성능이 빨라야 한다.
여러 구현체를 두고 시간 측정을 진행했다. ( 사실 유의미한 결과는 얻지 못했다는게 함정.. )
유지보수가 쉬워야 할 것
잘 유지보수가 되는 오픈소스 라이브러리를 생각했다. 다양한 언어와 플랫폼에서 구현가능한 암호화여야 했다. 표준 알고리즘을 사용한다면 큰 문제는 없었을 것이다. 무결성을 보장할 수 있는 MAC인증을 지원하는 알고리즘(GCM, CCM, EAX, SIV, OCB)을 사용하고자 했다.
AES-256-GCM은 aws KMS 에서 대칭키 설정에서 사용되는 알고리즘이기도 하다.

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

구현체
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)
- 데이터베이스에 데이터를 준비하는 함수만 오버라이딩했다. API에 대한 자세한 설명은 https://docs.djangoproject.com/en/4.0/howto/custom-model-fields/ 로 갈음하겠다.
장고 ORM 모델링에 EncryptedCharField
를 사용
class User(AbstractBaseUser, PermissionsMixin):
# ...
email_address = EncryptedCharField(max_length=220, null=True)
빠른 검색이 필요하다면, email_address_hash
같은 필드와 함께 해시값을 같이 저장하면 되겠다.
다음 포스트에선, django application (python)환경에서 암호화한 코드를 Java환경에서 사용할 수 있는지 안내한다.