같은 문제를 푸는 알고리즘은 대개 여러 개입니다. 무엇을 골랐는지보다 무엇을 고르지 않았고 그 이유가 무엇인지에서 배울 것이 더 많습니다. 고르지 않은 알고리즘도 “나쁜 알고리즘”이라서 뺀 것은 아닙니다. 이 프로젝트의 조건에 맞지 않았을 뿐이고, 그 알고리즘이 더 나은 경우가 있으면 함께 적었습니다.
선택AES-256-GCM (대안으로 ChaCha20-Poly1305)
암호화와 무결성 검증을 한 연산으로 처리하는 AEAD만 씁니다.
내용을 숨기기만 하고 변조를 검사하지 않으면, 공격자가 암호문을 고쳐 복호 결과를 바꿀 수 있습니다. 암호화와 MAC을 따로 붙이는 방식은 순서를 한 번만 잘못 잡아도 안전하지 않게 됩니다. 그래서 두 가지를 한 연산으로 처리하는 AEAD를 씁니다. AES 하드웨어 가속이 없는 환경에서는 ChaCha20-Poly1305를 대신 씁니다.
AES-CBC + HMAC 직접 조립
MAC-then-Encrypt 순서로 조립하면 패딩 오라클 공격에 노출됩니다. 순서를 맞게 잡아도 검증 순서와 상수 시간 비교를 직접 챙겨야 해서 실수하기 쉽습니다.
더 나은 경우: AEAD를 지원하지 않는 오래된 라이브러리와 호환해야 할 때입니다.
AES-CBC 단독
변조 검사가 전혀 없습니다. 누군가 암호문을 고쳐도 알아챌 수 없어 복호 결과를 믿을 수 없습니다.
AES-ECB
같은 평문 블록은 언제나 같은 암호문 블록이 되어 데이터의 패턴이 그대로 보입니다. 어떤 용도에도 쓰지 않습니다.
선택scrypt (N=32768, r=8, p=1)
연산량과 함께 메모리도 요구해서 병렬 대입 공격의 비용을 높입니다.
사람이 만든 비밀번호는 경우의 수가 적어서 공격자가 대량으로 대입해 봅니다. 막는 방법은 한 번 대입하는 비용을 높이는 것뿐입니다. 연산량만 늘리면 GPU나 ASIC이 많은 계산을 동시에 돌려 그 비용을 상쇄합니다. scrypt는 메모리도 함께 요구하므로 동시에 돌릴 수 있는 계산 수 자체가 줄어듭니다.
PBKDF2
반복 횟수만 늘릴 수 있고 필요한 메모리는 그대로라서 병렬 하드웨어 공격에 약합니다.
더 나은 경우: FIPS 준수가 필요한 환경입니다. 검증받은 구현이 가장 많습니다.
argon2id
지금 가장 많이 권장되는 알고리즘이지만 Node 표준 라이브러리에 없어서 네이티브 의존성을 추가해야 합니다. 이 프로젝트는 런타임이 기본으로 제공하는 기능만 쓰는 원칙을 우선했습니다.
더 나은 경우: 네이티브 의존성을 추가해도 괜찮다면 지금은 argon2id를 먼저 고릅니다.
bcrypt
비밀번호가 72바이트를 넘으면 뒷부분이 잘리고, 메모리 비용을 조절할 수 없습니다.
SHA-256 단독
너무 빠른 것이 문제입니다. 초당 수십억 번 대입할 수 있어서 방어가 되지 않습니다.
선택HMAC-SHA-256
키와 메시지를 이어 붙여 해시하는 방식은 MAC이 아닙니다.
SHA-256 같은 Merkle–Damgård 해시는 출력값이 곧 계산 도중의 내부 상태입니다. 그래서 키를 앞에 붙여 해시하면, 키를 모르는 사람도 메시지 뒤에 내용을 덧붙이고 그에 맞는 MAC을 계산할 수 있습니다. HMAC은 해시를 두 번 겹쳐 쓰는 구조로 이 공격을 막습니다.
SHA-256(key ‖ message)
길이 확장 공격으로 위조할 수 있습니다. 실제 서비스가 이 방식 때문에 뚫린 사례도 있습니다.
SHA-3 / BLAKE2 키 모드
구조상 길이 확장 공격이 통하지 않아 HMAC 없이도 안전합니다. 다만 HMAC-SHA-256을 지원하는 시스템이 훨씬 많아서 공통 모듈에는 이쪽이 맞습니다.
더 나은 경우: 성능이 중요하고 다른 시스템과 호환할 필요가 없다면 BLAKE2가 더 빠릅니다.
CRC · 체크섬
전송 중 생긴 오류를 찾는 용도라서 의도적인 변조는 막지 못합니다. 키가 없으니 누구나 다시 계산할 수 있습니다.
선택ES256(호환) · Ed25519(신규 설계)
외부 표준이 요구하는 곳은 ES256, 새로 설계하는 곳은 Ed25519를 씁니다.
ECDSA는 서명할 때마다 비밀 난수가 필요하고, 그 값을 한 번이라도 다시 쓰면 개인키가 드러납니다. Ed25519는 그 값을 키와 메시지로 계산하므로 이런 실수가 생길 여지가 없습니다. 다만 WebAuthn 인증기와 대부분의 OIDC 공급자가 ES256이나 RS256을 요구하므로 둘 다 둡니다.
RS256 (RSA)
키와 서명이 훨씬 크고 키 생성이 느립니다. 패딩 방식(PKCS#1 v1.5)을 잘못 구현해 생긴 취약점도 오랫동안 반복됐습니다.
더 나은 경우: 오래된 시스템과 호환해야 할 때입니다. 지금도 가장 널리 지원됩니다.
ES256 단독
안전성이 여전히 난수 품질에 달려 있습니다. RFC 6979(결정론적 ECDSA)로 위험을 줄일 수 있지만 이를 지원하는 구현이 많지 않습니다.
선택X25519
곡선 설계 단계에서 구현 실수가 생길 여지를 줄였습니다.
X25519는 예외 처리 분기가 거의 없도록 설계되어 상수 시간으로 구현하기 쉽습니다. 잘못된 공개키를 받아도 비밀 정보가 새지 않는 쪽으로 실패합니다. 빠르고 키도 32바이트로 짧습니다.
유한체 DH (FFDHE)
같은 보안 강도를 내려면 키가 훨씬 크고 느립니다. 약한 파라미터를 쓰도록 협상을 유도해 보안 수준을 낮춘 공격 사례도 있습니다.
P-256 ECDH
널리 지원되지만 구현이 까다로워서 무효 곡선 공격 같은 구현 실수가 많이 나왔습니다.
더 나은 경우: FIPS 준수가 필요한 환경입니다.
키를 서버 공개키로 감싸 전송(RSA 키 수송)
전방 비밀성이 없습니다. 나중에 서버 키가 유출되면 공격자가 저장해 둔 과거 트래픽을 전부 복호할 수 있습니다. 그래서 TLS 1.3에서 빠졌습니다.
선택HKDF-SHA-256
추출과 확장 단계를 나눈 표준 키 유도 함수를 씁니다.
용도마다 키를 따로 환경변수로 넣게 하면 번거로워서 결국 키 하나를 여러 곳에 돌려쓰게 됩니다. HKDF는 마스터 키 하나에서 용도별 키를 항상 같은 방식으로 만들어 냅니다. 그래서 문서로 당부하지 않아도 기본 동작만으로 용도별 키가 나뉩니다.
SHA-256(master ‖ purpose)
구분자가 없으면 서로 다른 조합이 같은 입력이 되어 구별되지 않습니다. 길이 확장 공격에도 노출됩니다.
용도별 키를 각각 환경변수로 주입
안전하지만 관리할 키가 많아져서 실무에서는 잘 지켜지지 않습니다. HKDF와 함께 쓸 수도 있습니다.
더 나은 경우: 키마다 서로 다른 HSM이나 KMS에 보관해야 할 때입니다.
선택timingSafeEqual (상수 시간)
비교에 걸린 시간도 정보를 흘립니다.
일반 비교는 다른 바이트를 만나는 즉시 멈춥니다. 그래서 응답 시간을 재면 앞에서부터 몇 바이트가 맞았는지 알 수 있습니다. 이 정보만 있으면 전체 경우의 수를 다 시도할 필요 없이 한 바이트씩 맞춰 나갈 수 있어서, 공격 비용이 길이에 비례하는 수준으로 줄어듭니다.
=== · Buffer.equals
중간에 멈추는 시점으로 앞에서부터 몇 바이트가 맞았는지가 드러납니다. 8바이트 MAC이 2,048번 시도만으로 복원됩니다.
양쪽을 다시 HMAC해서 비교
유효한 방법입니다. 비교하는 값이 매번 달라져서 앞부분이 몇 바이트 맞았는지 알아도 쓸모가 없습니다. 다만 연산이 하나 늘고, 상수 시간 비교보다 의도를 알아보기 어렵습니다.
더 나은 경우: 상수 시간 비교 API가 없는 런타임에서 씁니다.
선택정규형 base64url (왕복 검사)
같은 바이트는 반드시 같은 문자열이 되어야 합니다.
규칙을 느슨하게 적용하는 디코더는 서로 다른 문자열을 같은 바이트로 풀어 냅니다. 그러면 문자열로 비교하는 곳과 바이트로 비교하는 곳의 판단이 달라져서, 이미 쓴 토큰 목록 같은 방어 장치를 우회할 수 있습니다. 그래서 디코딩한 값을 다시 인코딩했을 때 원래 문자열과 같은 경우에만 받아들입니다.
표준 base64
+ 와 / 를 URL이나 쿠키에 넣으려면 다시 인코딩해야 하고, 끝에 = 패딩도 붙습니다.
느슨한 base64url 디코딩
"QQ"와 "QR"이 둘 다 같은 바이트로 풀립니다. 문자열은 다른데 바이트는 같은 상황이 생깁니다.
16진수
안전하지만 같은 데이터를 담는 데 길이가 두 배로 늘어납니다. 토큰이 길어져서 URL이나 쿠키에 넣기 부담스럽습니다.
더 나은 경우: 키 지문처럼 사람이 눈으로 대조해야 하는 값에 씁니다.
여기 적힌 근거는 각 모듈의 증명과 연결되어 있습니다. 연결된 모듈이나 증명이 사라지면 CI가 실패하므로, 구현은 바뀌었는데 근거만 예전 그대로 남는 일은 생기지 않습니다.