결제 대행사의 웹훅(webhook) 수신 기능 PR 설명에 이런 문장이 있다. "카드 번호는 Base64로 암호화해 저장합니다. API 키는 SHA-256으로 암호화해 DB에 둡니다. 웹훅 서명(signature)은 받은 값과 ===로 비교해 검증합니다." 세 문장 모두 리뷰에서 걸린다. 첫 문장의 Base64는 암호화가 아니고, 둘째 문장의 SHA-256도 암호화가 아니며, 셋째 문장의 비교 방법은 걸린 시간으로 정보를 흘린다.
인코딩(encoding), 해시(hash), 암호화(encryption)는 모두 "데이터를 알아보기 어려운 모양으로 바꾼다"는 점에서 비슷해 보인다. 갈리는 지점은 두 질문이다. 되돌릴 수 있나? 되돌리려면 열쇠(key, 암호 용어로는 키)가 필요한가? 일상어로는 '열쇠', 공개 키(public key)·개인 키(private key)·비밀 키(secret key)처럼 굳은 용어는 '키'로 쓴다. 이 장은 세 가지를 하나씩 코드로 짜 보며 이 두 질문에 답하고, 마지막에 웹훅 서명 검증을 제대로 짠다.
인코딩: Base64는 모양만 바꾼다
이메일 본문, URL, JSON 문자열처럼 글자만 다니는 길에 이미지 같은 바이너리 바이트를 실어야 할 때가 있다. Base64는 바이트를 영문 대소문자, 숫자, +, / 64글자로 바꾼다. 64는 이다. 비트 1개로 2가지, 2개로 4가지, 3개로 8가지, … 6개로 64가지를 구별할 수 있어서(『개발 수학』 Unit 1) 글자 하나가 6비트를 나타내고, 바이트 3개(24비트)가 글자 4개가 된다(MDN: Base64). 결과는 늘 4글자 단위로 맞추고, 모자란 자리는 =로 채운다.
hi(2바이트)를 Base64로 바꾸면 몇 글자가 될까?
선택지와 해설 보기
실험 Base64를 손으로 인코딩하기
바이트 3개(24비트)를 6비트씩 4조각으로 나누고, 조각마다 64글자 표에서 글자를 고릅니다. data는 바이트 값 목록으로 보여 줍니다.
const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'const data = new TextEncoder().encode('Man')let encoded = ''for (let i = 0; i < data.length; i += 3) { const take = Math.min(3, data.length - i) let n = 0 for (let j = 0; j < 3; j++) { n = n * 256 + (j < take ? data[i + j] : 0) } for (let k = 0; k < 4; k++) { const idx = (n >> (18 - 6 * k)) & 63 encoded += k <= take ? ALPHA[idx] : '=' }}console.log(encoded)
아직 변수가 없습니다.
- 해볼 것
Man을 끝까지 실행해TWFu가 되는 것을 본다hi를 끝까지 실행해=가 어디서 붙는지 본다한을 끝까지 실행해 한 글자가 Base64 네 글자가 되는 것을 본다
Man은 바이트 77, 97, 110이다. 이어 붙인 24비트를 6비트씩 자르면 19, 22, 5, 46이고(는 켜진 자리의 값을 더해 읽는다. 010011은 16 + 2 + 1 = 19다. 밀기 >>와 가리기 &는 Unit 1), 표에서 고르면 TWFu다. hi는 바이트가 2개뿐이라 셋째 바이트 자리를 0으로 채운다. 넷째 조각은 채운 0으로만 이루어져서 글자 대신 =를 붙인다. 남은 바이트가 1개면 ==가 붙는다(RFC 4648 §4). 한은 UTF-8로 3바이트라서 Base64 네 글자 7ZWc가 된다. 3바이트마다 4글자라서 입력이 길면 결과는 원래보다 약 1/3 길어진다. hi처럼 짧은 입력은 = 채움 때문에 더 늘어난다(2바이트 → 4글자).
Base64 변환 어디에도 비밀 값이 없다. 표와 규칙이 공개되어 있으니 누구나 거꾸로 따라가 원래 바이트를 얻는다. 인코딩은 되돌릴 수 있고, 되돌리는 데 열쇠가 필요 없다. 그래서 보안이 아니다. URL에 넣기 좋게 +와 /를 -와 _로 바꾼 변형이 Base64url이다(RFC 4648 §5). JWT(JSON Web Token)의 세 부분이 각각 Base64url로 인코딩한 값이다(RFC 7519). 서명만 한 JWT의 가운데 부분은 누구나 풀어 읽을 수 있으니, 비밀번호나 개인정보를 넣으면 안 된다(JWT).
해시: 되돌릴 수 없는 지문
해시 함수는 길이에 상관없이 입력을 받아 정해진 길이의 값을 낸다. SHA-256은 늘 256비트( 한 글자가 4비트라 256 ÷ 4 = 64글자)를 낸다. 같은 입력이면 늘 같은 값이 나오고, 값에서 입력을 거꾸로 구할 방법이 없다. 브라우저와 Node.js는 Web Crypto의 crypto.subtle.digest로, Python은 hashlib으로 계산한다(MDN: SubtleCrypto.digest, Python 문서: hashlib).
const data = new TextEncoder().encode('hello')const digest = await crypto.subtle.digest('SHA-256', data)const digestHex = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')console.log(digestHex)
두 코드 모두 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824를 출력한다. crypto.subtle은 보안 컨텍스트(HTTPS, localhost)에서만 쓸 수 있고 결과를 Promise로 준다.
hello와 hellp(마지막 글자만 다름)의 SHA-256 값은 256비트 중 몇 비트쯤 다를까?
선택지와 해설 보기
실험 한 글자 바꾸면 해시는 얼마나 바뀔까
두 입력의 SHA-256 해시를 브라우저의 crypto.subtle.digest로 계산합니다. 서로 다른 16진수 자리는 색으로, 서로 다른 비트는 아래 256칸에 칠해 표시합니다.
계산 중입니다.
- 해볼 것
- 두 입력이 한 글자만 다르게 만들고 바뀐 비트 수를 본다
- 두 입력을 똑같이 만들어 해시도 똑같은지 본다
- 한쪽에 100글자 넘는 글을 넣어도 해시 길이(16진수 64글자)가 그대로인지 본다
hello와 hellp는 256비트 중 131비트가 다르다. 입력의 작은 차이가 출력 전체로 번지는 이 성질을 눈사태 효과(avalanche effect)라고 한다. 출력이 비슷하면 "입력도 비슷하겠다"는 단서가 되는데, 절반쯤 뒤집히면 그런 단서가 남지 않는다. 그래서 해시는 내용의 지문으로 쓰인다. 내려받은 파일의 해시를 공개된 값과 견주면 한 바이트라도 바뀌었는지 안다. 다만 지문은 공개된 함수로 누구나 다시 계산할 수 있다. 해시만으로는 "누가 만들었는지"를 증명하지 못한다. 그 일은 마지막 절의 HMAC이 한다.
비밀번호: 일부러 느린 해시
비밀번호를 그대로 저장하면 DB가 새는 순간 모든 계정이 털린다. 암호화해 저장해도 열쇠가 같이 새면 그만이다. 그래서 비밀번호는 되돌릴 수 없는 해시로 저장하고, 로그인할 때 입력값을 같은 방식으로 해시해 견준다(OWASP: Password Storage Cheat Sheet).
그런데 SHA-256 한 번으로는 부족하다. 해시를 되돌릴 수는 없어도, 공격자는 흔한 비밀번호를 하나씩 해시해 새어 나간 값과 견줄 수 있다. SHA-256은 빠르게 계산하도록 만든 함수라서 이런 추측을 대량으로 시험하기 좋다. Python 문서도 sha1(password) 같은 단순한 해시는 무차별 대입(brute force)에 약하다고 하고, 비밀번호 해시는 느리고, 비용을 조절할 수 있고, 솔트를 써야 한다고 적는다(Python 문서: hashlib 키 유도).
| 장치 | 하는 일 |
|---|---|
| 솔트(salt) | 사용자마다 다른 무작위 값을 비밀번호에 섞는다. 같은 비밀번호도 해시가 달라지고, 미리 계산해 둔 해시 표(rainbow table)가 쓸모없어진다 |
| 작업 비용(work factor) | 해시 한 번에 드는 계산량(과 메모리)을 일부러 키운다. 로그인 한 번에는 견딜 만하고, 추측 수십억 번에는 견딜 수 없게 |
| 알고리즘 | OWASP는 Argon2id를 먼저 권하고, 없으면 scrypt, 오래된 시스템이면 bcrypt를 든다 |
bcrypt는 대부분의 구현에서 입력의 앞 72바이트만 쓴다는 점도 OWASP 문서에 있다. Python 표준 라이브러리에는 hashlib.scrypt와 hashlib.pbkdf2_hmac이 있다. 실무에서는 솔트 저장과 비교까지 맡아 주는 검증된 라이브러리를 쓴다(비밀번호 해싱).
암호화: 열쇠로 잠그고 연다
암호화는 되돌릴 수 있다. 단, 열쇠가 있어야 한다. 카드 번호처럼 나중에 원래 값이 필요한 데이터는 해시가 아니라 암호화로 지킨다.
- 대칭 암호화(symmetric encryption): 잠그는 열쇠와 여는 열쇠가 같다. AES가 대표적이다. Web Crypto의
encrypt는 AES-CTR, AES-CBC, AES-GCM을 지원하고, MDN은 암호문이 바뀌지 않았는지까지 검사하는 인증 암호화(authenticated encryption)인 AES-GCM을 권한다(MDN: SubtleCrypto.encrypt). - 비대칭(공개 키) 암호화(asymmetric encryption): 열쇠가 한 쌍이다. 공개 키로 잠근 것은 짝인 개인 키로만 열린다. 공개 키는 누구에게 줘도 된다. Web Crypto에서는 RSA-OAEP다. 같은 문서에 따르면 2048비트 RSA 키(SHA-256 사용)로 한 번에 잠글 수 있는 데이터는 190바이트까지다. 그래서 큰 데이터는 대칭 암호화로 잠그고, 공개 키 기술은 그 대칭 키를 안전하게 전달하는 데 쓰는 조합이 흔하다.
HTTPS의 TLS 1.3도 그렇다. 핸드셰이크에서 공개 키 기술로 서버를 인증하고 공유 키를 합의한 뒤, 실제 데이터는 그 키로 대칭 인증 암호화(AEAD, authenticated encryption with associated data)해 주고받는다(RFC 8446, HTTPS). 지금까지를 표로 모으면 이렇다.
| 되돌릴 수 있나 | 열쇠 | 지키는 것 | 예 | |
|---|---|---|---|---|
| 인코딩 | 있다 | 필요 없다 | 아무것도(전달 형식일 뿐) | Base64, URL 인코딩, UTF-8 |
| 해시 | 없다 | 필요 없다 | 바뀌지 않았음(무결성, integrity) | SHA-256, 비밀번호 해시 |
| 암호화 | 있다 | 필요하다 | 내용을 못 읽게(기밀성, confidentiality) | AES-GCM, RSA-OAEP |
| HMAC | 없다 | 필요하다 | 열쇠를 가진 쪽이 만들었고 바뀌지 않았음 | 웹훅 서명 |
HMAC과 웹훅 서명
웹훅을 보내는 쪽과 받는 쪽은 비밀 값(secret)을 하나 나눠 갖는다. 보내는 쪽은 본문과 비밀 값으로 HMAC을 계산해 헤더에 싣고, 받는 쪽은 같은 계산을 해 헤더 값과 견준다. 비밀 값이 없는 사람은 맞는 서명을 만들 수 없다. HMAC은 해시 함수에 비밀 키를 섞는 방법을 정한 표준이다(RFC 2104). 비밀 값을 문자열 앞에 붙여 해시하는 식으로 직접 만들지 말고 언어가 주는 HMAC을 쓴다(Node.js crypto.createHmac, Python hmac.new). SHA-256을 비롯한 흔한 해시 함수는 secret + body의 해시만 알면 비밀 값을 모르고도 body 뒤에 내용을 덧붙인 메시지의 해시를 만들 수 있는 구조라서(길이 확장 공격, length extension attack. Wikipedia: Length extension attack), 앞에 붙이는 방식은 서명이 되지 않는다. HMAC 표준이 따로 있는 이유가 이런 함정이다.
GitHub의 웹훅 문서가 좋은 예다. 서명은 SHA-256 HMAC을 16진수로 적고 sha256=을 앞에 붙여 X-Hub-Signature-256 헤더로 보낸다. 받는 쪽에는 세 가지를 당부한다. 검증 전에 본문을 바꾸지 말 것, 비밀 값을 코드에 적거나 저장소에 올리지 말 것, ==가 아닌 "상수 시간"(constant time) 비교를 쓸 것(GitHub 문서: 웹훅 전달 검증).
함정 찾기 AI가 짠 웹훅 처리 함수, 어디가 위험할까?
“결제 웹훅의 sha256 서명을 검증하고 주문을 저장하는 함수를 짜 줘. 카드 번호는 암호화해서 저장해.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 5개입니다.
Python에서는 hmac.new(key, body, hashlib.sha256).hexdigest()로 서명을 만들고 hmac.compare_digest로 견준다. 문서는 hexdigest() 결과를 외부에서 받은 값과 견줄 때 == 대신 이 함수를 쓰라고 하고, 이 함수가 내용에 따라 일찍 끝나지 않도록 짜여 있다고 설명한다(Python 문서: hmac). Node.js에서는 crypto.timingSafeEqual을 쓴다. === 같은 일반 비교는 처음 다른 글자에서 멈출 수 있어서, 걸린 시간으로 앞에서 몇 자리를 맞혔는지가 샌다. 이것이 공격자에게 얼마나 도움이 되는지는 보너스 문제에서 비교한 글자 수를 세며 본다.
확인 문제 장을 덮기 전에 떠올려 보기
1. 카드 번호를 Base64로 바꿔 DB에 저장했다. DB가 새면?
2. 비밀번호를 SHA-256 한 번으로 해시해 저장하면 안 되는 이유는? (솔트도 없다)
3. Node.js에서 받은 웹훅 서명과 계산한 HMAC을 견줄 때 쓸 것은?
더 풀어 보기(선택) · 일찍 끝나는 비교는 무엇을 흘릴까
서버의 서명이 c0de이고, 공격자가 추측한 서명을 보내면 서버가 앞에서부터 한 글자씩 비교해 다른 글자를 만나는 즉시 거절한다. 비교한 글자 수가 응답 시간에 그대로 드러난다고 하자. 공격자는 이것으로 무엇을 알 수 있을까? 비교한 글자 수를 세는 코드로 이를 확인하고, 내용과 상관없이 늘 같은 수의 글자를 보는 비교 함수를 짜 보자.
추측 a000 → 1글자 보고 거절 추측 c100 → 2글자 보고 거절 추측 c0a0 → ?