[PonsWarp 개념 교실 5] HMAC과 상수시간 비교 — 키 확인과 타이밍 공격
쉽게 말하면, **HMAC은 "키가 있어야 만들 수 있는 도장"이고, 상수시간 비교는 "도장을 대조할 때 흔적을 남기지 않는 방법"**이다. 둘은 짝이다.

앞 글들에서 "키 확인"이라는 단계가 반복해서 나왔다. ECDH 편에서는 CRYPTO_KEY_CONFIRM, nonce 편에서는 키 재사용 방지. 이번 글에서는 그 키 확인이 왜 HMAC으로 만들어지고, 왜 단순 문자열 비교가 아니라 상수시간 비교로 검증되는지 본다.
이게 왜 필요한가 — "키가 같은지" 확인의 두 가지 함정
PonsWarp에서 sender와 receiver는 각자 HKDF로 세션 키를 유도한다. 이론상 둘은 같아야 하지만, salt가 잘못 전달되거나 한쪽만 실패하면 어긋날 수 있다.
여기서 "같은지 확인"을 하려고 한다. 그런데 이 확인에는 두 가지 함정이 있다.
함정 1: 키를 직접 보내면 안 된다. "내 키는 이거야"라고 보내는 순간 키가 노출된다.
함정 2: 단순 비교는 타이밍 정보를 새나간다. "abcdef"와 "abcxyz"를 앞에서부터 비교하면, 첫 글자부터 틀린 경우와 두 글자까지 맞은 경우의 연산 시간이 다르다. 공격자는 이 시간 차이를 반복 측정해 키를 한 글자씩 역추적할 수 있다. 이게 **타이밍 공격(timing attack)**이다.
HMAC — 키가 있어야 찍히는 도장
HMAC(Hash-based Message Authentication Code)은 메시지에 키를 섞어 해시한 값이다.
HMAC-SHA256(key, message)
핵심 속성: 같은 메시지라도 키가 다르면 완전히 다른 값이 나온다. 그리고 키 없이는 같은 값을 만들어낼 수 없다.
PonsWarp의 키 확인은 이렇게 동작한다. sender가 자신의 세션 키로 HMAC("KEY_CONFIRM")을 계산해 보내고, receiver가 자신의 세션 키로 같은 계산을 해서 값이 같은지 본다.
Sender: HMAC(myKey, "KEY_CONFIRM") = ABC...
↓ 전송
Receiver: HMAC(myKey, "KEY_CONFIRM") = ABC... ? → 같으면 키 일치
키 자체는 전송되지 않는다. 오직 키가 있어야 만들 수 있는 값(도장)만 오간다.
typescript// PonsWarp/src/services/cryptoService.ts const hmacKey = await crypto.subtle.importKey( 'raw', this.sessionKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const confirmation = await crypto.subtle.sign( 'HMAC', hmacKey, new TextEncoder().encode('KEY_CONFIRM') );
상수시간 비교 — 흔적 없이 대조하기
이제 receiver는 받은 confirmation과 자기 계산값을 비교해야 한다. 여기서 비교 방식이 문제가 된다.
자바스크립트의 ===나 루프의 if (a[i] !== b[i]) return false는 첫 불일치 지점에서 일찍 종료한다. 앞부분이 맞으면 맞을수록 시간이 오래 걸린다. 공격자는 시간을 재서 "몇 글자까지 맞췄는지"를 알 수 있다.
상수시간 비교는 모든 바이트를 항상 XOR로 누적해서, 어느 위치에서 틀렸든 같은 시간이 걸리게 한다.
rust// ponswarp-signaling-rs/src/mesh_security.rs fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { if left.len() != right.len() { return false; } let mut diff = 0_u8; for (a, b) in left.iter().zip(right.iter()) { diff |= a ^ b; // ← 틀린 위치와 무관하게 항상 전체 순회 } diff == 0 }
핵심은 diff |= a ^ b다. XOR 결과를 누적하고 마지막에 한 번만 비교하므로, 루프는 항상 전체 길이만큼 돈다. 어느 위치에서 불일치가 나도 시간이 같다.
"그런데 그 정도 차이를 정말 측정할 수 있나?"
의문이 들 수 있다. 나노초 단위 차이를 어떻게 측정하냐는 것. 실제로는 반복 측정과 통계가 답이다. 공격자가 같은 요청을 수천~수백만 번 보내 평균 시간을 내면, 단일 측정의 노이즈가 사라지고 미세한 차이가 드러난다. 그래서 "차이가 너무 작아서 무시해도 되지 않나"는 안전한 가정이 아니다.
또 하나: 이 비교가 네트워크를 통과하는 값(TURN 자격증명, 비밀번호 해시, grant 토큰)에 쓰이면, 공격자가 로컬이 아닌 원격에서도 시간 차이를 측정할 수 있다. PonsWarp 서버는 TURN 자격증명 검증(subtle::ConstantTimeEq)과 메시 토큰 검증(constant_time_eq)에 이 방식을 적용했다.
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| 키 확인 생성 | Web Crypto HMAC-SHA256, 메시지 "KEY_CONFIRM" | cryptoService.ts:165-178 |
| 키 확인 검증 | crypto.subtle.verify (상수시간 내장) | cryptoService.ts:187-210 |
| WASM 키 확인 | hmac_sha256(session_key, b"KEY_CONFIRM") | kdf.rs:191-198 |
| 상수시간 비교 (Rust) | XOR 누적 `diff | = a ^ b` |
| TURN 자격증명 검증 | subtle ConstantTimeEq | turn.rs:222-223 |
| HMAC-SHA256 (서버) | hmac crate HmacSha256 | mesh_security.rs:365-378 |
| 비밀번호/공유 검증 | hmac-sha256:v1:{salt}:{digest} + XOR 비교 | cloud_share.rs:1502-1511, 1551-1560 |
지금 돌아보는 판단
세 가지를 짚고 싶다.
첫째, "키 확인"은 선택이 아니라 필수다. 키가 어긋난 채 전송을 시작하면 파일 전체가 깨진다. PonsWarp는 그걸 전송 전에 잡으려고 핸드셰이크에 이 단계를 넣었다. 전송 중 실패보다 전송 전 확인이 싸다.
둘째, 상수시간 비교는 "이미 검증된 라이브러리를 쓰는 게 최선"이다. Web Crypto의 crypto.subtle.verify와 Rust의 subtle 크레이트는 검증된 구현을 제공한다. 이 글에서 직접 구현을 보여준 것은 원리를 설명하기 위함이고, 실무에서는 라이브러리를 쓰는 게 맞다. PonsWarp도 서버의 constant_time_eq는 직접 구현했지만, TURN 검증은 subtle을 쓴다. 직접 구현과 라이브러리 사용이 섞여 있는 점은 일관성 측면에서 아쉬운 부분이다.
셋째, 길이 비교는 어쩔 수 없이 정보를 새나간다. left.len() != right.len()은 길이가 다르면 즉시 false를 반환한다. 그래서 길이도 비밀로 취급해야 하는 경우(비밀번호 등)는 길이를 고정하거나 패딩해야 한다. 토큰은 고정 길이로 만들어 이 문제를 피하는 것이 일반적이다.
한 문장 정리
HMAC은 키가 있어야 만들 수 있는 도장이라 키 자체를 보내지 않고도 키 일치를 확인하게 해주고, 상수시간 비교는 대조 과정에서 타이밍 정보가 새지 않게 하며, PonsWarp는 키 확인(KEY_CONFIRM)·TURN 자격증명·토큰 검증에 이 짝을 적용했다.
관련 개념: [개념 1 — ECDH P-256] · [개념 2 — HKDF-SHA256] (키 확인의 전제)
다음 글: [개념 6 — 무결성 3총사: CRC32 vs SHA-256 vs Merkle Tree]