[PonsWarp 개념 교실 4] GCM nonce — 왜 4B 카운터 + 8B random prefix인가
쉽게 말하면, GCM의 nonce는 "같은 키로 같은 조합을 두 번 쓰면 안 되는 카운터"인데, PonsWarp는 12바이트 nonce를 4바이트 카운터 + 8바이트 랜덤 값으로 나눠 재사용을 막는다.

이번 글에서는 nonce가 왜 그렇게 중요한지, 4+8 설계가 어떤 문제를 풀려 했는지, 그리고 아직 남아 있는 위험이 무엇인지 본다.
이게 왜 필요한가 — GCM의 치명적 조건
앞 글에서 GCM은 CTR 모드로 키 스트림을 만들어 평문과 XOR한다고 했다. 여기서 결정적인 조건이 하나 붙는다.
같은 키 + 같은 nonce로 두 번 암호화하면 안 된다.
이유를 보자. nonce가 같으면 키 스트림도 같다.
암호문1 = 평문1 ⊕ 키스트림
암호문2 = 평문2 ⊕ 키스트림
공격자가 이 두 암호문을 XOR하면?
암호문1 ⊕ 암호문2 = 평문1 ⊕ 평문2
키가 통째로 사라진다. 평문1 ⊕ 평문2만 남는데, 평문 구조를 조금이라도 알면(파일 포맷, 압축 헤더 등) 두 평문을 복원할 수 있다. GCM에서 nonce 재사용은 단순한 버그가 아니라 암호화 붕괴다.
그래서 nonce 설계는 "절대 같은 값을 두 번 쓰지 않는다"를 어떻게 보장할지의 문제다.
nonce 후보들과 함정
nonce를 만드는 방법은 크게 세 가지가 있다.
1. 완전 랜덤 (12바이트 전부)
- 장점: 구현이 단순하다.
- 함정: 2^96 공간이지만 생일 문제(Birthday Paradox) 때문에 실제로는 수십억 개를 만들면 충돌 확률이 유의미해진다. 그리고 세션이 재시작되면 같은 랜덤 값이 또 나올 수 있다.
2. 카운터 단독
- 장점: 단조 증가라 "같은 값을 두 번"을 구조적으로 피한다.
- 함정: 세션이 재시작되면 카운터가 0으로 돌아간다. 세션 A의 nonce=1과 세션 B의 nonce=1이 같은 키로 쓰이면 재사용이 된다. 그리고 카운터가 32비트면 42억 개를 넘기는 순간 래핑된다.
3. 카운터 + 랜덤 프리픽스 (PonsWarp 방식)
- 랜덤 8바이트가 "세션마다 다른" 기준을 만들어주고, 카운터 4바이트가 "세션 안에서 단조 증가"를 담당한다. 세션이 재시작돼 카운터가 0으로 돌아가도 프리픽스가 다르면 nonce가 다르다.
PonsWarp는 3번을 선택했다.
nonce (12B)
┌──────────┬──────────────────┐
│ counter │ random prefix │
│ 4 bytes │ 8 bytes │
└──────────┴──────────────────┘
세션 안에서 세션마다 다름
단조 증가
실제 코드 — PonsWarp에선 이렇게 쓴다
웹 워커 (현재 전송 경로)
typescript// PonsWarp/src/workers/bulk-encrypt.worker.ts const nonce = new Uint8Array(12); new DataView(nonce.buffer).setUint32(0, nonceCounter++, true); // 4B 카운터 nonce.set(randomPrefix.subarray(0, 8), 4); // 8B 프리픽스
카운터는 워커 안의 nonceCounter 변수로, 패킷마다 1씩 증가한다. 전송을 재개(resume)할 때는 카운터를 저장된 위치까지 전진시켜 nonce 재사용을 피한다.
WASM 코어 (레거시/폴백 경로)
rust// pons-core-wasm/src/crypto/aes_gcm.rs struct CryptoSession { nonce_counter: u64, random_prefix: [u8; 8], ... } fn generate_nonce(&mut self) -> [u8; NONCE_SIZE] { let mut nonce = [0u8; NONCE_SIZE]; nonce[..4].copy_from_slice(&self.nonce_counter.to_le_bytes()[..4]); nonce[4..12].copy_from_slice(&self.random_prefix); self.nonce_counter += 1; nonce }
Rust 쪽은 nonce_counter를 u64로 선언했지만, nonce에는 하위 4바이트만 들어간다. 그래서 실질적으로는 32비트 카운터다.
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| nonce 구성 | 12B = 카운터 4B(LE) + 랜덤 프리픽스 8B | bulk-encrypt.worker.ts:107-109, aes_gcm.rs:271-275 |
| 프리픽스 생성 | crypto.getRandomValues(8) — 전송 전 1회 | cryptoService.ts:153-158 |
| 프리픽스 검증 | 8B 미만이면 오류 (래핑/절단 방지) | aes_gcm.rs:246-250 |
| resume 시 카운터 전진 | 파티션별 단조 증가, 재개 시 advance | swarmManager.ts:530-543 |
| nonce 크기 상수 | NONCE_SIZE=12, AUTH_TAG_SIZE=16 | crypto/mod.rs:33,36 |
| 진단 항목 | 32-bit nonce 래핑 → GCM 붕괴 위험 (WC-01) | DIAGNOSIS-REPORT.md:69 |
지금 돌아보는 판단 — 남은 위험
4+8 설계는 "세션 재시작 재사용" 문제는 해결했다. 하지만 한 가지가 남는다.
카운터가 32비트다. 42억 9천만 개(2^32) 패킷을 넘기면 카운터가 0으로 래핑되고, 같은 프리픽스 + 같은 카운터 = 같은 nonce가 다시 만들어진다. 64KiB 청크 기준으로 2^32개면 약 256TB를 보낼 때 발생한다. 실사용에서 닿기 어려운 수치지만, "절대 재사용"을 구조적으로 보장해야 하는 암호 설계 원칙에서는 경계값이 남는다.
진단 문서(WC-01)도 이 지점을 지적했다. 개선 방향으로는 카운터를 nonce에 8바이트로 넣는 구조(프리픽스와 역할 교환) 또는 래핑 시 명시적으로 세션을 실패시키는(fail-closed) 처리가 있다. PonsWarp에서는 후속 개선 작업으로 분리되어 있다.
또 하나 짚을 점: 프리픽스 재사용 금지 규칙은 코드가 아니라 "호출 규약"에 의존한다. 세션이 새로 시작될 때 항상 새 crypto.getRandomValues(8)를 호출한다는 보장이 있어야 안전한데, 이 규약이 깨지면(예: 프리픽스를 저장해서 재사용) 4+8 설계의 의미가 사라진다.
한 문장 정리
GCM nonce는 "같은 키 + 같은 nonce"가 두 번 나오면 암호화가 붕괴되는 재사용 금지 카운터이고, PonsWarp는 8바이트 랜덤 프리픽스(세션 구분) + 4바이트 카운터(세션 내 단조 증가) 조합으로 재사용을 막되, 32비트 래핑이라는 경계 위험을 남겼다.
다음 글: [HMAC과 상수시간 비교 — 키 확인과 타이밍 공격] — 키가 제대로 맞는지 확인하는 단계가 왜 단순 문자열 비교가 아닌지 본다.