[PonsWarp 개념 교실 2] HKDF-SHA256 — 공동 비밀을 안전한 키로 가공하기
쉽게 말하면, **HKDF-SHA256은 "공동 비밀 한 덩어리에서 용도별로 안전한 키를 여러 개 뽑아내는 키 제조 공장"**이다.

앞 글에서 ECDH P-256으로 두 브라우저가 같은 Shared Secret을 만들었다. 이번 글에서는 그 공동 비밀이 왜 바로 AES 키가 될 수 없는지, 그리고 PonsWarp가 HKDF로 어떻게 세션 키를 만들어내는지 파헤친다.
이게 왜 필요한가
ECDH의 결과물은 "두 사람만 아는 비밀값"이다. 그런데 이 값을 그대로 AES-256-GCM의 키로 꽂으면 두 가지 문제가 생긴다.
첫째, 분포가 보장되지 않는다. ECDH의 공동 비밀은 타원곡선 연산의 결과물이다. 암호화 키로 쓰기에는 편향된 비트가 섞여 있을 수 있다. 키는 "무작위처럼 보이는" 값이어야 하는데, 그걸 보장하는 단계가 없다.
둘째, 용도 구분이 안 된다. 같은 공동 비밀로 파일 암호화 키, 메타데이터 키, 키 확인용 HMAC 키를 각각 만들고 싶을 수 있다. 공동 비밀을 그대로 쓰면 전부 같은 키가 되어 버린다. 하나가 뚫리면 전부 뚫린다.
그래서 중간에 키 제조 공장을 둔다.
ECDH Shared Secret
↓
HKDF
↓
AES-256 Encryption Key
HKDF — 이름부터 풀어보자
HKDF는 HMAC-based Key Derivation Function의 약자다.
- HMAC: 키가 있는 해시. 메시지 무결성 + 인증을 동시에.
- Key Derivation Function: 원본 키(여기선 공동 비밀)에서 파생 키를 만들어내는 함수.
- SHA-256: HKDF 내부에서 HMAC의 해시로 SHA-256을 쓴다는 뜻.
HKDF는 RFC 5869로 표준화되어 있고, Extract와 Expand 두 단계로 나뉜다.
1단계: Extract — 난수를 압축하다
입력으로 공동 비밀(Input Keying Material, IKM)과 salt를 받아서, **PRK(Pseudorandom Key)**라는 고정 길이 값을 뽑아낸다.
salt IKM (공동 비밀)
│ │
└───────┬───────────┘
↓
HMAC-SHA256
↓
PRK (32B)
salt는 "편향을 없애는 소금"이다. PonsWarp는 sender가 만든 32바이트 랜덤 salt를 사용한다. 이 salt가 없으면 같은 공동 비밀에서 항상 같은 키가 나오는데, salt를 섞으면 매 세션 다른 키가 나온다.
2단계: Expand — 키를 여러 개 뽑다
PRK와 **info(용도 문자열)**를 입력으로 받아서 원하는 길이만큼 키를 뽑아낸다.
PRK + info ("PonsWarp-E2E-v1")
↓
HMAC-SHA256 (반복)
↓
32 byte 세션 키
여기서 info가 중요한 이유가 있다. 같은 PRK에서도
- info =
"PonsWarp-E2E-v1"→ 파일 암호화 세션 키 - info = 다른 문자열 → 완전히 다른 키
가 나온다. 같은 원본에서 용도별로 다른 키를 갈라내는 것이 HKDF의 핵심 가치다.
실제 코드 — PonsWarp에선 이렇게 쓴다
PonsWarp는 두 곳에서 HKDF를 구현한다. 브라우저의 Web Crypto API(주 경로)와, WASM 코어의 자체 구현(폴백/내부용)이다.
브라우저 Web Crypto 경로
typescript// PonsWarp/src/services/cryptoService.ts const hkdfKey = await crypto.subtle.importKey( 'raw', sharedSecret, 'HKDF', false, ['deriveBits'] ); const sessionKeyBits = await crypto.subtle.deriveBits( { name: 'HKDF', hash: 'SHA-256', salt: this.salt, info: HKDF_INFO, // "PonsWarp-E2E-v1" }, hkdfKey, 256 // 32 bytes );
salt와 info가 Web Crypto에 그대로 전달되는 구조다.
WASM 코어 경로 (RFC 5869 그대로)
Rust WASM 코어의 kdf.rs는 HKDF를 처음부터 구현해 두고, 같은 info 문자열을 공유한다.
rust// pons-core-wasm/src/crypto/kdf.rs const HKDF_INFO: &[u8] = b"PonsWarp-E2E-v1"; fn hkdf_extract(salt: &[u8], ikm: &[u8]) -> [u8; 32] { // salt가 32B보다 길면 먼저 해시, 비어 있으면 0으로 채움 (RFC 5869) hmac_sha256(&actual_salt, ikm) } fn hkdf_expand(prk: &[u8; 32], info: &[u8], length: usize) -> Vec<u8> { // T(i) = HMAC(PRK, T(i-1) || info || i) 반복 후 길이만큼 자름 } pub fn derive_session_key(shared_secret: &[u8], salt: &[u8]) -> Vec<u8> { let prk = hkdf_extract(salt, shared_secret); hkdf_expand(&prk, HKDF_INFO, KEY_SIZE) // KEY_SIZE = 32 }
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| info 문자열 | "PonsWarp-E2E-v1" (브라우저/WASM 공유) | cryptoService.ts:24-26, kdf.rs:11 |
| 키 길이 | 256비트(32B) | cryptoService.ts:125-133, kdf.rs:8 |
| salt | sender 생성 32B 랜덤 | cryptoService.ts:74-81 |
| Extract | HMAC-SHA256(salt, IKM), salt>32B 시 해시 (RFC 5869) | kdf.rs:148-161 |
| Expand | T(i)=HMAC(PRK, T(i-1)‖info‖i), 길이 truncate | kdf.rs:163-181 |
| 세션 키 유도 | derive_session_key = extract → expand | kdf.rs:184-186 |
| 키 확인 | HMAC(session_key, "KEY_CONFIRM") | kdf.rs:191-198, cryptoService.ts:160-210 |
지금 돌아보는 판단
한 가지 솔직히 짚고 싶은 지점이 있다. WASM 코어의 kdf.rs는 SHA-256과 HMAC을 직접 구현해 두었는데, 소스 주석에 "프로덕션에서는 Web Crypto API 사용 권장"이라고 적혀 있다. 브라우저의 Web Crypto가 이미 HKDF를 제공하는데 WASM에 자체 구현을 얹은 이유는, 워커(Worker) 환경에서 WASM 경로만 쓸 수 있는 경우를 대비한 것으로 보인다.
다만 직접 구현한 암호 함수는 감사(audit) 부담이 크다. 이 프로젝트의 진단 문서에서도 hand-rolled AES/GHASH/KDF를 aes-gcm·hkdf 크레이트로 교체하는 작업이 후속 마일스톤(M5)으로 분리되어 있다. 재구축한다면 기본 경로는 Web Crypto에 두고, WASM 폴백은 검증된 크레이트로 대체하는 방향이 안전하다.
한 문장 정리
HKDF-SHA256은 ECDH 공동 비밀을 salt와 info로 가공해 용도별 안전한 키를 뽑아내는 표준 절차(RFC 5869)이고, PonsWarp는 info=PonsWarp-E2E-v1로 32바이트 세션 키를 만들어 AES-256-GCM에 넘긴다.
다음 글: [AES-256-GCM — 암호화의 구조 (CTR + GHASH + tag)] — 뽑아낸 키가 실제로 파일 조각을 어떻게 암호화하는지 본다.