💡 DRF로 구축한 백엔드에 클라이언트가 CRUD 요청할 때마다 로그를 남기도록 코딩을 해놨는데요, 주요 사용자 정보는 해싱하고, 다른정보들은 암호화하고 압축하여 Amazon S3에 적재했습니다. 그 과정을 기록했습니다.
Introduction to Salted-Hashed Passwords
해싱은 단순히 특정 문자열을 어떤 함수를 통과시켜서 다른 문자열로 바꾸는 것을 의미합니다.(되돌릴 수 없음)
그래서 사용자의 패스워드에 많이 사용되었는데요, 문제는 같은 비밀번호를 사용하는 경우에 같은 해싱 비밀번호를 가진다는 것입니다.
그래서 도입된 것이 Salt-Hashed Password 입니다.
Salted-Hash = SHA256(password + salt)
와 같은 방식인데요(SHA256은 해싱 알고리즘을 의미합니다. 해시의 의미를 잘생각보면 됩니다. 함수같은느낌?)
패스워드마다 salt로 칭하는 짧은 문자열을 넣어서 해싱함으로써 같은 비밀번호도 다른 해시값이 나오게 됩니다.
Salt라고 불리는 문자열이 같은 문자열에 재사용되는 것을 주의해야합니다. Salt를 첨가 한 것이 의미도 없이 같은 해시값이 나올테니까요.
너무 짧은 Salt문자열은 피해야합니다. 예를 들어 한 문자이면 완전 탐색을 통해 패스워드를 알아낼 수도 있으니까요.
저는 hashlib
를 통해 구현했습니다. Salt는 랜덤한 바이너리 값으로 붙여주어서 중요하다고 여길 수 있는 유저의 정보를 해시함수를 통과시켜서 다른 문자열로 바꾸었습니다.
def get_hash(integer):
"""
입력한 정수값을 binary 문자열로 인코딩 후, salt값을 append하여 SHA256 알고리즘을 이용하여 해시값을 생성하는 함수
"""
salt = os.urandom(32) #binary 값 생성
#ex)\xa9\x84\x01\x96\xa4\t\xadP\x1e\xf3:\x94[\xb7\x9c=\xfebI\x03\xa2\x05\xd5\x9a\x19\x9b\xabhO\x13\xb8\x83
#
plainstring = str(integer)
plaintext = plainstring.encode() #encode하고 싶은 문자열을 binary 문자열로 인코딩
digest = hashlib.pbkdf2_hmac('sha256', plaintext, salt, 10000) #digest 객체는 생성된 해시값을 가짐
hex_hash = digest.hex()
return hex_hash #바이트 문자열을 16진수로 변환한 문자열(hex)을 반환
python의 cryptography
의 Fernet
을 활용하여 대칭키 암호화/복호화를 구현했습니다.
여기서 암호화 방식에는 대칭키 방식과 비대칭키 방식이 있는데요, 간단히 설명드리자면 대칭키는 암/복호화 하는 키가 같은 키가 필요하고, 비대칭키는 암호화하는 키와 복호화하는 키가 다른 암호화 방식을 의미합니다. 자세한 건 아래 참고한 블로그를 확인해주세요.
생성한 로그들
최초로 RestAPI 방식으로 요청한 로그를 기록한 것은 board_logging.log입니다. 게시판에 글을 쓰고 수정하고 삭제하는 요청을 기록을 했습니다.
대략 아래와 같이 기록됩니다. (user_id 는 해싱을 거친 상태입니다. 원래는 1과같은 정수로 나타내집니다.)
이러한 json 데이터를 암호화 했습니다.
코드
import hashlib
import os
from cryptography.fernet import Fernet
from pathlib import Path
from dateutil import parser #epoch time 생성
import json
from uuid import uuid4 #recordId 생성
def decrypt(plaintext):
"""
양방향 암호화를 사용하여 key를 생성 및 별도 파일에 저장하며, 복호화된 데이터를 반환하는 함수
plaintext: 복호화하려는 데이터(json 형태)
"""
#logkey.key 파일에서 key값 불러오기
mod_path = Path(__file__).parent
#print(mod_path)
absolute_keyfile_path = (mod_path/"./logkey.key").resolve()#resolve: 절대 경로 반환
#print(absolute_keyfile_path)
my_file = Path(absolute_keyfile_path)
if my_file.is_file():
# 'logkey.key' 파일이 존재
with open(absolute_keyfile_path,'rb') as file:
key = file.read()
fernet = Fernet(key)
json_log = plaintext
decrypt_str = fernet.decrypt(f"{json_log}".encode('ascii'))
# decrypt_str = fernet.decrypt(encrypt_str)
return decrypt_str.decode('utf-8')
def encrypt(plaintext):
"""
양방향 암호화를 사용하여 key를 생성 및 별도 파일에 저장하며, 암호화된 데이터를 반환하는 함수
plaintext: 암호화하려는 데이터(json 형태)
"""
#logkey.key 파일에서 key값 불러오기
mod_path = Path(__file__).parent
absolute_keyfile_path = (mod_path /"./logkey.key").resolve() #resolve: 절대 경로 반환
print(absolute_keyfile_path)
my_file = Path(absolute_keyfile_path)
if my_file.is_file():
# 'logkey.key' 파일이 존재
with open(absolute_keyfile_path,'rb') as file:
key = file.read()
else:
#키 생성, 'logkey.key' 파일 생성 및 키값 저장
key = Fernet.generate_key()
with open('logkey.key','wb') as file:
file.write(key)
fernet = Fernet(key)
json_log = plaintext
encrypt_str = fernet.encrypt(f"{json_log}".encode('ascii'))
# decrypt_str = fernet.decrypt(encrypt_str)
return encrypt_str
def executedata_from_encrypted_log():
"""
암호화된 로그로부터 복호화 하여 메타데이터 추출
"""
mod_path = Path(__file__).parent
decrypt_log_path = (mod_path/"../logs/decrypt_log.json").resolve()
absolute_logfile_path = (mod_path /"../logs/encrypted_log.json").resolve()
print(absolute_logfile_path)
with open(absolute_logfile_path,'r') as file:
meta = [json.loads(decrypt(line["data"])) for line in json.loads(file.read())]
#print(type(file_data))
# Join encrypted with file_data inside encrypted_logs
# Sets file's current position at offset.
#file.seek(0)
# convert back to json.
with open(decrypt_log_path, 'w') as file:
file.write(json.dumps(meta, indent=2))
def update_file():
"""
로그 파일에 로그가 추가될 때마다 해당 내용을 가져와서 암호화된 내용을 별도 JSON 파일에 저장하는 함수
recordId, ArrivalTimestamp 생성 후 암호화된 데이터 (data)와 함께 저장
"""
#로그 파일 읽어오기
mod_path = Path(__file__).parent
absolute_logfile_path = (mod_path /"../logs/board_logging.log").resolve()
#로그 파일의 마지막 줄 읽어오기
with open(absolute_logfile_path, 'rb') as f:
try: # catch OSError in case of a one line file
f.seek(-2, os.SEEK_END)
while f.read(1) != b'\n':
f.seek(-2, os.SEEK_CUR)
except OSError:
f.seek(0)
last_line = f.readline().decode()
#데이터 생성하기
data = encrypt(last_line).decode('utf8')
data_dict = json.loads(last_line) #json 형태의 log를 dictionary 형태로 변환
strtime = data_dict["time"]
epoch_time = parser.parse(strtime).timestamp() #string 형태의 로그 생성 시간을 timestamp 형태로 변환
encrypted = {}
encrypted["recordId"] = uuid4().int #랜덤한 고유값
encrypted["ArrivalTimestamp"] = epoch_time #로그 생성 시간 (epoch time으로 표시)
encrypted["data"] = data #암호화된 개별 로그 데이터값
newLogfile_path = Path(absolute_logfile_path).parent
newLogfile_path = (newLogfile_path /"encrypted_log.json").resolve() #logs 폴더에 저장
# json_root = {"encrypted_logs": []} #json 파일 생성 시 필요한 root 추가
# json_root = json.dumps(json_root, indent=4)
my_file = Path(newLogfile_path)
if not my_file.is_file():
with open(newLogfile_path,'w') as file:
file.write('[')
file.write(json.dumps(encrypted, indent=2))
file.write(']')
else:
with open(newLogfile_path,'r+') as file:
file_data = json.loads(file.read())
#print(type(file_data))
# Join encrypted with file_data inside encrypted_logs
file_data.append(encrypted)
# Sets file's current position at offset.
file.seek(0)
# convert back to json.
file.write(json.dumps(file_data, indent=2))
흐름을 설명드리면, 매 요청 마다 로그가 남게되고 그 로그는 로그파일의 마지막줄에 기록됩니다. 마지막줄을 읽어서 암호화한 다음 UUID와 데이터를 생성한 시간을 epoxytime으로 바꾸고, data 부분에 암호화한 데이터 파일이 쓰여집니다. 결과물은 아래와 같습니다.
로그 한줄 당 이러한 형태로 기록이 됩니다.
실행하고 나면 아래와 같은 logkey.key가 생성이 되는데요
이 파일을 통해 암호화 / 복호화가 이루어집니다. 안의 파일을 열어보면 키로 사용되는 문자열이 들어있습니다.
이 과정을 통해 json 파일 데이터를 다루는 방법에 조금은 익숙해졌습니다. 사실 json 데이터를 python에서 다루는 과정이 제일 오래걸렸던 것 같습니다ㅠ 이렇게 다시 로그파일을 복호화해서 ELK 스택을 구현해보려합니다. 지금까지 읽어주셔서 감사합니다.
전체 코드는 여기서 참고 해주세요