[PonsWarp 개념 교실 3] AES-256-GCM — 암호화의 구조 (CTR + GHASH + tag)
쉽게 말하면, AES-256-GCM은 "데이터를 섞어서 못 읽게 만드는 것"과 "중간에 누군가 바꿨는지 알아내는 것"을 동시에 하는 암호화 방식이다.

앞 글에서 ECDH→HKDF로 만든 32바이트 세션 키가 실제 파일 조각에 어떻게 적용되는지, 이번 글에서 GCM의 내부 구조를 뜯어본다.
이게 왜 필요한가
PonsWarp는 파일을 64KiB~192KiB 조각으로 나눠 WebRTC DataChannel로 보낸다. 암호화에는 두 가지 요구가 동시에 붙는다.
첫째, 기밀성 — 네트워크를 엿듣는 사람이 파일 내용을 읽으면 안 된다.
둘째, 무결성 + 인증 — 전송 중 누군가 패킷을 바꿔치기하면 안 된다. 그리고 바꿔치기당했으면 수신측이 확실히 알아채야 한다.
"암호화"만 하면 첫째는 되지만 둘째는 안 된다. 공격자가 암호문의 일부를 뒤집으면 복호화 결과가 이상해지긴 하는데, 그게 "공격자의 조작"인지 "네트워크 노이즈"인지 구분이 안 될 수 있다. 그래서 GCM은 암호화와 인증을 묶은 AEAD(Authenticated Encryption with Associated Data) 방식으로 설계되었다.
평문 파일 조각
↓
AES-256-GCM
↓
암호문 + 16B 인증 태그
GCM — 이름부터 풀어보자
GCM은 Galois/Counter Mode의 약자다. 두 가지 기술이 합쳐져 있다.
- Counter Mode (CTR): AES 블록 암호로 만든 키 스트림을 평문과 XOR해서 암호문을 만든다. 블록마다 독립적이라 병렬 처리가 쉽고, 스트리밍(조각 단위)에 잘 맞는다.
- Galois (GHASH): GF(2^128) 체에서의 곱셈을 이용해 인증 태그를 만든다. CTR로 암호화한 결과를 해시해 "누가 바꿨는지" 감지한다.
GCM의 강점은 CTR 덕분에 조각 단위로 암호화해도 전체 무결성을 검증할 수 있다는 점이다. 이건 대용량 파일을 청크로 나눠 보내는 PonsWarp의 구조와 정확히 맞는다.
CTR 모드 — 어떻게 섞는가
AES는 고정 크기(16바이트) 블록을 키로 변환하는 함수다. CTR은 여기에 카운터를 섞는다.
nonce(12B) + 카운터(4B) → AES-256(키) → 키 스트림 16B
↓ XOR
평문 블록 16B
↓
암호문 블록 16B
핵심: 카운터가 0, 1, 2, ...로 증가하면서 매 블록 다른 키 스트림이 만들어진다. 같은 평문이라도 다른 위치(다른 카운터)에서는 다른 암호문이 된다.
복호화는 같은 연산을 한 번 더 하는 것이다. 암호문에 같은 키 스트림을 XOR하면 평문이 돌아온다. 그래서 CTR은 암호화와 복호화가 동일한 함수다 — 구현이 단순해진다.
GHASH — 어떻게 위변조를 잡는가
CTR만으로는 무결성이 없다. 그래서 GCM은 암호화가 끝난 뒤 GHASH라는 GF(2^128) 곱셈 체인으로 인증 태그를 계산한다.
암호문 블록들
↓
GHASH (GF(2^128) 곱셈 누적)
↓
16B 인증 태그
태그는 암호문 전체에 의존한다. 한 비트만 바뀌어도 태그가 완전히 달라진다. 수신측은 복호화 후 태그를 다시 계산해 비교한다. 불일치하면 "이 패킷은 조작됐다"고 판정하고 버린다.
여기서 중요한 점: 태그를 만들 때 사용하는 값(E(K, J0))은 키에 의존하므로, 키를 모르는 공격자는 올바른 태그를 만들 수 없다. 그래서 GCM 태그는 "무결성 + 인증"을 동시에 제공한다.
실제 코드 — PonsWarp에선 이렇게 쓴다
PonsWarp의 현재 전송 경로(파티션 기반)는 브라우저의 Web Crypto API로 AES-256-GCM을 호출한다. 워커(Worker)에서 암호화해 UI 스레드를 막지 않는다.
typescript// PonsWarp/src/workers/bulk-encrypt.worker.ts const nonce = new Uint8Array(12); // 96비트 nonce new DataView(nonce.buffer).setUint32(0, nonceCounter++, true); // 4B 카운터 nonce.set(randomPrefix.subarray(0, 8), 4); // 8B 랜덤 프리픽스 const ciphertextWithTag = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce, tagLength: 128 }, // 16B 태그 cryptoKey, payload );
암호화된 패킷은 38바이트 헤더 + 암호문 + 태그 구조다. 헤더가 22바이트 평문 패킷보다 16바이트 큰 이유가 바로 태그다.
평문 패킷: [22B 헤더][payload]
암호 패킷: [38B 헤더][암호문][16B 태그]
└ nonce·길이 등 포함 ┘
Rust WASM 코어에도 자체 AES-256-GCM 구현(aes_gcm.rs)이 있다. S-Box·라운드 상수·GHASH GF(2^128) 곱셈을 직접 구현했고, nonce 12B = 카운터 4B + 랜덤 프리픽스 8B 구성을 공유한다. 이 구현은 프로덕션보다는 폴백/레거시 경로에 가깝고, 후속 작업으로 검증된 aes-gcm 크레이트 교체가 계획되어 있다.
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| 암호화 호출 | Web Crypto AES-GCM, tagLength 128 | bulk-encrypt.worker.ts:111-117 |
| nonce 구성 | 12B = 카운터 4B(LE) + 랜덤 프리픽스 8B | bulk-encrypt.worker.ts:107-109 |
| 암호 패킷 구조 | 38B 헤더 + 암호문 + 16B 태그 | bulk-encrypt.worker.ts:120-123, crypto/mod.rs:21-36 |
| WASM 자체 구현 | AES-256 14라운드, SBOX/RCON, GHASH GF(2^128) | aes_gcm.rs:20-63 |
| nonce 크기/태그 크기 | NONCE_SIZE=12, AUTH_TAG_SIZE=16 | crypto/mod.rs |
| 복호화 경로 | 워커에서 crypto.subtle.decrypt (태그 검증 포함) | bulk-decrypt.worker.ts:61-62, directFileWriter.ts:1373-1476 |
지금 돌아보는 판단
두 가지를 솔직히 짚고 싶다.
첫째, "왜 WASM에 AES를 직접 구현했나"라는 질문이 남는다. 브라우저 Web Crypto가 이미 AES-256-GCM을 제공하는데도 aes_gcm.rs에 S-Box부터 GHASH까지 처음부터 구현해 두었다. 워커에서 WASM 경로만 쓸 수 있는 상황을 대비한 것으로 보이지만, 직접 구현한 암호 함수는 감사 부담과 실수 위험이 크다. 프로젝트 진단에서도 이 부분을 후속 교체 항목으로 분리했다.
둘째, nonce 설계가 암호화의 안전을 좌우한다. GCM에서 nonce가 재사용되면 두 암호문의 키 스트림이 같아져 XOR로 키를 역산할 수 있다. PonsWarp는 4바이트 카운터 + 8바이트 랜덤 프리픽스로 이 문제를 피하려 했는데, 카운터가 32비트라 래핑(wrapping) 시 재사용 위험이 남는다. 이 이야기는 다음 글에서 깊게 다룬다.
한 문장 정리
AES-256-GCM은 CTR로 데이터를 섞고 GHASH로 16바이트 인증 태그를 만들어 "못 읽게"와 "못 바꾸게"를 동시에 보장하며, PonsWarp는 nonce(12B)와 함께 조각 단위로 적용해 스트리밍 전송에 맞췄다.
다음 글: [GCM nonce — 왜 4B 카운터 + 8B random prefix인가] — nonce 재사용이 왜 GCM을 붕괴시키는지, 그리고 이 설계의 남은 위험을 본다.