[PonsWarp 개념 교실 1] ECDH P-256 — 둘만 아는 공동 비밀 만들기
쉽게 말하면, **ECDH P-256은 "서로 비밀키를 직접 보내지 않고, 두 기기만 똑같은 암호화 키를 만들어내는 방법"**이다.

PonsWarp처럼 브라우저끼리 파일을 보내는 구조에서 이 방식이 왜 필요한지, 그리고 실제로 코드에 어떻게 들어가 있는지 하나씩 풀어보자.
이게 왜 필요한가
PonsWarp의 전송 흐름은 브라우저 A가 파일을 조각내 암호화해서 브라우저 B로 보내는 구조다. 그런데 암호화에는 양쪽이 같은 키를 갖고 있어야 한다.
여기서 문제가 생긴다. 키를 어떻게 나눌까?
가장 쉬운 방법은 "A가 키를 만들어서 B에게 보내는 것"이다. 하지만 이 경우 키를 보내는 동안 누군가 엿듣는다면(중간자 공격), 그 사람은 앞으로 오가는 모든 파일을 복호화할 수 있게 된다. 키를 보내는 순간 그 키는 "둘만 아는 비밀"이 아니게 되기 때문이다.
그래서 PonsWarp는 키 자체를 보내지 않는다. 대신 각자 만들고, 합치면 같은 값이 되는 조각만 주고받는다.
ECDH — 이름부터 풀어보자
ECDH는 Elliptic Curve Diffie-Hellman의 약자다.
- Diffie-Hellman: 1976년에 나온 키 교환 방식. 두 사람이 공개된 정보만 주고받고도 같은 비밀값을 만들 수 있다는 획기적인 아이디어.
- Elliptic Curve: DH의 계산을 타원곡선 위에서 수행한다는 뜻. 같은 안전성을 더 짧은 키로 얻을 수 있어서 브라우저 같은 제한된 환경에 적합하다.
- P-256: 사용하는 타원곡선의 종류. NIST P-256, secp256r1, prime256v1은 사실상 같은 곡선을 가리키는 다른 이름이다. 256비트 키 기반이고, 브라우저 Web Crypto API에서 기본 지원한다.
흐름 — 공개키만 돌아다닌다
A가 B에게 파일을 보낸다고 하자. 각 브라우저는 자신의 키쌍을 만든다.
- Private Key(비밀키)는 절대 밖으로 나가지 않는다.
- Public Key(공개키)만 서로 교환한다.
그다음 A는 자신의 비밀키 + B의 공개키를 계산하고, B는 자신의 비밀키 + A의 공개키를 계산한다. 신기하게도 결과가 같다.
A 브라우저 B 브라우저
│ │
Private A Private B
│ │
Public A ──────────────→ │
│ ←────────────── Public B
│ │
└───── Shared Secret ──────┘
(양쪽이 같은 값)
네트워크에는 Public Key만 돌아다녔는데, A와 B는 같은 비밀값(Shared Secret)을 가지게 된다. 엿듣는 사람은 공개키 두 개만 볼 수 있고, 거기서 비밀키를 역산하는 것은 현재 기술로는 사실상 불가능하다. 이게 "공개된 정보만으로 비밀을 만든다"는 DH의 핵심이다.
"그럼 공동 비밀을 그냥 AES 키로 쓰면 안 되나?"
여기서 자연스러운 의문이 든다. 이미 같은 값을 만들었는데 그걸 바로 암호화 키로 쓰면 되지 않을까?
안 된다. ECDH가 만들어주는 것은 엄밀히 말해 "Shared Secret"이지, "좋은 AES-256 Key"라고 단정할 수 없다. 분포, 길이, 용도 구분 같은 문제가 있어서 그 사이에 키 제조 공장을 하나 둔다. 그게 바로 다음 글에서 다룰 HKDF-SHA256이다.
ECDH Shared Secret
↓
HKDF
↓
AES-256 Encryption Key
실제 코드 — PonsWarp에선 이렇게 쓴다
PonsWarp의 cryptoService.ts는 브라우저 내장 Web Crypto API로 이 흐름을 그대로 구현한다. 먼저 키쌍 생성.
typescript// PonsWarp/src/services/cryptoService.ts async generateKeyPair(): Promise<string> { this.keyPair = (await crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits'] )) as CryptoKeyPair; const publicKeyRaw = await crypto.subtle.exportKey('raw', this.keyPair.publicKey); return bytesToBase64(new Uint8Array(publicKeyRaw)); }
상대의 공개키를 받으면 공동 비밀을 만들고, 그다음 HKDF로 세션 키를 유도한다.
typescript// PonsWarp/src/services/cryptoService.ts const sharedSecret = await crypto.subtle.deriveBits( { name: 'ECDH', public: peerPublicKey }, this.keyPair.privateKey, 256 ); 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, }, hkdfKey, 256 );
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| 키쌍 생성 | crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }) | PonsWarp/src/services/cryptoService.ts:56-72 |
| 공동 비밀 생성 | deriveBits(ECDH, 256) | cryptoService.ts:110-118 |
| 세션 키 유도 | HKDF-SHA256, salt 32B, info=PonsWarp-E2E-v1 | cryptoService.ts:24-26, 125-133 |
| 키 확인 | HMAC-SHA256 (KEY_CONFIRM) | cryptoService.ts:160-210 |
| 핸드셰이크 | CRYPTO_INIT → CRYPTO_PUBLIC_KEY → CRYPTO_KEY_CONFIRM → CRYPTO_READY | cryptoService.ts:211-255 |
한 가지 눈여겨볼 점은 핸드셰이크다. PonsWarp는 키를 교환한 뒤 서로 같은 키를 가졌는지 HMAC으로 확인하는 단계(CRYPTO_KEY_CONFIRM)를 거친다. 키가 어긋나면 파일이 깨지기 전에 미리 잡기 위한 장치다. 이 키 확인 방식(왜 단순 문자열 비교가 아니라 HMAC인지)은 뒤의 "HMAC과 상수시간 비교" 편에서 더 깊게 다룬다.
한 문장 정리
ECDH P-256은 공개키만 주고받고도 양쪽이 같은 비밀값을 만들게 해주는 키 교환 방식이고, PonsWarp는 이 공동 비밀을 HKDF로 가공해 AES-256-GCM 암호화에 쓴다.
다음 글: [HKDF-SHA256 — 공동 비밀을 안전한 키로 가공하기] — "왜 ECDH 결과를 그냥 AES 키로 쓰면 안 되나"부터 시작한다.