[PonsWarp 구현 분석 6] 브라우저끼리 키를 나누는 법 — E2E 암호화의 실제 적용
쉽게 말하면, **PonsWarp의 E2E 암호화는 "개념 글 네 개(ECDH·HKDF·AES-GCM·nonce)가 하나의 상태 머신으로 연결된 파이프라인"**이다.

이번 글은 개념 교실 1~4편을 실제 코드에 적용한 결과물을 추적한다. 전송 전 키 교환부터, 전송 중 조각 암호화, 끊긴 뒤 재개할 때 nonce를 안전하게 이어가는 법까지.
이게 왜 필요한가 — "암호화 키가 없는 재개"라는 실패
PonsWarp 개발 과정에서 실제로 부딪힌 문제가 있다. 전송이 끊기고 재개(resume)할 때, 암호화 키가 메모리에서 사라지면 재개 자체가 불가능하다. 벤치마크 기록에도 "Cannot resume encrypted transfer without an active crypto key"라는 실패가 남아 있다.
즉 E2E 암호화는 "파일을 섞는 일"만이 아니라, 키의 수명 주기(생성 → 교환 → 전송 → 재개) 전체를 설계하는 일이다. 이 글은 그 수명 주기를 따라간다.
1단계: 전송 전 — 키 교환 (Sender → Receiver)
전송이 시작되기 전, sender의 CryptoService가 키쌍과 salt를 만들고 4단계 핸드셰이크를 수행한다.
Sender Receiver
│ CRYPTO_INIT (알고리즘 목록) │
├───────────────────────────────────→ │
│ CRYPTO_PUBLIC_KEY (공개키 + salt) │
├───────────────────────────────────→ │
│ │ ECDH 공동 비밀 → HKDF 세션 키
│ ←───────────────────────────────────┤ CRYPTO_KEY_CONFIRM (HMAC)
│ HMAC 검증 │
│ CRYPTO_READY │
├───────────────────────────────────→ │
핸드셰이크 메시지는 cryptoService.ts에서 생성된다.
typescript// PonsWarp/src/services/cryptoService.ts createInitMessage() // { type: 'CRYPTO_INIT', algorithms: [...] } createPublicKeyMessage() // { type: 'CRYPTO_PUBLIC_KEY', publicKey, salt } createKeyConfirmMessage() // { type: 'CRYPTO_KEY_CONFIRM', confirmation: HMAC } createReadyMessage() // { type: 'CRYPTO_READY' }
핵심은 CRYPTO_KEY_CONFIRM 단계다. 양쪽이 HKDF로 유도한 키가 같은지 HMAC으로 확인한다. 키가 어긋난 채로 전송을 시작하면 파일 전체가 깨지는데, 그걸 전송 전에 잡는다.
2단계: 전송 전 — 세션 키 전달 (Control 채널)
핸드셰이크가 끝나면 sender는 실제 암호화에 쓸 세션 키를 control 채널로 receiver에게 보낸다.
typescript// PonsWarp/src/services/swarmManager.ts private sendCryptoSessionToPeer(peer: SinglePeerConnection): void { const sent = peer.send(JSON.stringify({ type: 'CRYPTO_SESSION', version: 1, algorithm: 'AES-256-GCM', key: bytesToBase64(this.sessionKey), randomPrefix: bytesToBase64(this.randomPrefix), })); }
receiver 쪽 webRTCService는 이 메시지를 받아 Writer(DirectFileWriter)에 키를 심는다.
typescript// PonsWarp/src/services/webRTCService.ts case 'CRYPTO_SESSION': { const { key, randomPrefix } = message.payload; this.setSessionKey(sessionKey, randomPrefix); // → writer.setEncryptionKey }
3단계: 전송 중 — 조각 암호화 (Worker)
암호화는 UI 스레드에서 하지 않는다. bulk-encrypt.worker가 조각을 받아 AES-256-GCM으로 암호화하고, transferable로 메인 스레드에 돌려준다.
typescript// PonsWarp/src/workers/bulk-encrypt.worker.ts const nonce = new Uint8Array(12); new DataView(nonce.buffer).setUint32(0, nonceCounter++, true); nonce.set(randomPrefix.subarray(0, 8), 4); const ciphertextWithTag = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce, tagLength: 128 }, cryptoKey, payload );
워커가 준비된 조각(prepare-ahead 크레딧)만큼만 암호화해 메모리를 통제한다. 이 크레딧 흐름은 이후 "흐름 제어" 편에서 다룬다.
4단계: 수신 — 복호화와 무결성
receiver의 DirectFileWriter는 암호문 패킷을 받아 복호화 워커로 보낸다. crypto.subtle.decrypt는 태그 검증을 포함하므로, 조작된 패킷이 오면 여기서 걸러진다.
typescript// PonsWarp/src/workers/bulk-decrypt.worker.ts const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv, tagLength: 128 }, cryptoKey, ciphertext );
복호화가 실패하면 전송 세션을 죽이지 않고, 연속된 오프셋(contiguous frontier) 기준으로 sender에게 재전송을 요청한다. "한 패킷 실패 = 전체 실패"가 아니라 "그 위치부터 다시"다.
5단계: 재개 — nonce를 안전하게 이어가기
여기가 이 설계의 숨은 정성이다. 전송이 끊기고 startOffset부터 재개할 때, nonce 카운터를 이미 보낸 조각 수만큼 전진시켜야 한다. 그렇지 않으면 재개 후 첫 조각이 재개 전 마지막 조각과 같은 nonce를 쓰게 되어 GCM이 붕괴한다.
typescript// PonsWarp/src/services/swarmManager.ts private advancePartitionNonceForResume(startOffset: number): void { const chunkSize = this.currentTransferTuningProfile?.chunkSizeBytes ?? CHUNK_SIZE_INITIAL; // 보수적 하한: 오프셋까지 도달하는 데 쓰였을 nonce 수 const minCounter = Math.ceil(startOffset / chunkSize); if (this.partitionNonceCounter < minCounter) { this.partitionNonceCounter = minCounter; } }
동시에 워커의 CryptoKey가 GC/복구로 사라진 경우를 대비해 세션 키로부터 키를 다시 import하는 경로(ensurePartitionCryptoKey)도 있다. 이 두 가지가 합쳐져 "암호화 키 없는 재개" 실패를 막는다.
PonsWarp 적용 지점
| 단계 | 동작 | 증거 |
|---|---|---|
| 키쌍·salt 생성 | generateKeyPair() ECDH P-256, salt 32B | cryptoService.ts:56-72, 74-81 |
| 핸드셰이크 메시지 | INIT→PUBLIC_KEY→KEY_CONFIRM→READY | cryptoService.ts:211-255 |
| 키 확인 | HMAC-SHA256(KEY_CONFIRM) 상수시간 비교 | cryptoService.ts:160-210, kdf.rs:191-198 |
| 세션 키 전송 | CRYPTO_SESSION control 메시지 | swarmManager.ts:478-503 |
| 수신 키 심기 | CRYPTO_SESSION 처리 → writer.setEncryptionKey | webRTCService.ts:1206-1209, directFileWriter.ts:233 |
| 조각 암호화 | Web Crypto AES-GCM, nonce 4B+8B, tag 128 | bulk-encrypt.worker.ts:107-117 |
| 조각 복호화 | crypto.subtle.decrypt (태그 검증 포함) | bulk-decrypt.worker.ts:61-62 |
| 재개 nonce 전진 | ceil(startOffset / chunkSize) | swarmManager.ts:529-547 |
| 키 재import | GC/복구 후 crypto.subtle.importKey 재실행 | swarmManager.ts:512-527 |
지금 돌아보는 판단
세 가지가 인상적이다.
첫째, 키 수명 주기가 전송 자체와 분리되지 않았다. 핸드셰이크 → 세션 키 전달 → 워커 암호화 → 복호화 → 재개 전진이 하나의 흐름으로 연결되어 있다. 개념만 알고 코드를 보면 "어디서 이어지는지"가 핵심이다.
둘째, 실패 전략이 "세션 유지"다. 복호화 실패 시 세션을 죽이지 않고 재전송을 요청하고, 키가 사라지면 재import한다. 파일 전송 앱에서 중간 실패는 흔하므로, 이 "복구 가능한 설계"가 완성도를 만든다.
셋째, nonce 전진은 '보수적 하한'이다. ceil(startOffset / chunkSize)는 실제로 쓰인 nonce 수의 하한을 잡는다. 더 많이 전진시켜도 안전하지만(카운터 공간이 넓으므로), 덜 전진시키면 재사용 위험이 생긴다. 그래서 하한으로 잡는 것이 맞다.
한 문장 정리
PonsWarp의 E2E 암호화는 4단계 핸드셰이크로 키를 교환하고, control 채널로 세션 키를 전달하며, 워커에서 AES-256-GCM으로 조각을 암호화·복호화하고, 재개 시 nonce 카운터를 보수적으로 전진시켜 GCM 재사용을 막는 하나의 수명 주기다.
관련 개념: [개념 1 — ECDH P-256] · [개념 2 — HKDF-SHA256] · [개념 3 — AES-256-GCM] · [개념 4 — GCM nonce]
다음 글: [구현 2 — 복사하지 않는 게 제일 빠르다: WASM 메모리와 제로카피] — 같은 암호화를 WASM 안에서 복사 없이 수행하는 법.