노트북 06 — ML-KEM (FIPS 203)#

목표: 표준 문서를 동작하는 구현과 나란히 읽습니다. 세 가지 파라미터 집합을 보여주고, K-PKE와 FO-변환 래퍼를 따라가며, 한 번의 완전한 encapsulation/decapsulation을 실행합니다.

from pqc_edu.params import ALL
header = f"{'name':12} {'k':>3} {'eta1':>5} {'eta2':>5} {'du':>3} {'dv':>3} {'ek_bytes':>9} {'ct_bytes':>9}"
print(header)
print('-' * len(header))
for p in ALL:
    print(f"{p.name:12} {p.k:>3} {p.eta1:>5} {p.eta2:>5} {p.du:>3} {p.dv:>3} {p.ek_bytes:>9} {p.ct_bytes:>9}")
name           k  eta1  eta2  du  dv  ek_bytes  ct_bytes
--------------------------------------------------------
ML-KEM-512     2     3     2  10   4       800       768
ML-KEM-768     3     2     2  10   4      1184      1088
ML-KEM-1024    4     2     2  11   5      1568      1568

K-PKE.KeyGen (FIPS 203 Algorithm 13)#

Input: d in {0,1}^256
(rho, sigma) <- G(d)
A_hat[i][j]  <- SampleNTT(rho, j, i)   for i,j in 0..k-1
s[i]  <- CBD_eta1(PRF(sigma, N))       for i in 0..k-1
e[i]  <- CBD_eta1(PRF(sigma, N+k+i))   for i in 0..k-1
s_hat <- NTT(s)
e_hat <- NTT(e)
t_hat <- A_hat . s_hat + e_hat
ek <- ByteEncode(t_hat, 12) || rho
dk <- ByteEncode(s_hat, 12)
from pqc_edu.params import ML_KEM_512
from pqc_edu.ml_kem import k_pke_keygen
keys = k_pke_keygen(ML_KEM_512, b'\x00' * 32)
print('ek bytes:', len(keys.ek), '(expected 800 for ML-KEM-512)')
print('dk bytes:', len(keys.dk), '(expected 768)')
ek bytes: 800 (expected 800 for ML-KEM-512)
dk bytes: 768 (expected 768)

K-PKE.Encrypt (Algorithm 14) — compression 단계가 암호문 크기를 줄이는 핵심이지만, 동시에 복호화 실패 가능성을 도입하는 원인이기도 합니다.#

from pqc_edu.ml_kem import k_pke_encrypt, k_pke_decrypt, compress, decompress
from pqc_edu.ntt import Q
import numpy as np
import os

m = os.urandom(32)
ct = k_pke_encrypt(ML_KEM_512, keys.ek, m, os.urandom(32))
m2 = k_pke_decrypt(ML_KEM_512, keys.dk, ct)
print('K-PKE match:', m == m2)

# Show the compression effect on a random coefficient vector.
from pqc_edu.polyring import Poly
rng = np.random.default_rng(0)
p = Poly(rng.integers(0, Q, 256), Q)
p_round = decompress(compress(p, 10), 10)
# distortion in Z_q, wrapped into [-q/2, q/2]
diff = (p.coeffs - p_round.coeffs) % Q
diff = np.where(diff > Q // 2, diff - Q, diff)
print(f'compress(d=10) round-trip: max |distortion| = {int(np.max(np.abs(diff)))} out of q/2 = {Q//2}')
K-PKE match: True
compress(d=10) round-trip: max |distortion| = 2 out of q/2 = 1664

복호화 실패율. ML-KEM의 파라미터는 복호화 실패 확률이 2^-139 미만이 되도록 선택됩니다 — 실제로는 거의 무시할 수준이지만 이론적으로는 0이 아닙니다. 아래의 FO 변환은 이 실패를 공격자 관점에서 정상 복호화와 구별할 수 없도록 만듭니다.#

FO 변환 — Encaps가 단순히 K-PKE.Encrypt(임의 메시지)가 아닌 이유. K-PKE 자체만으로는 IND-CPA 수준(수동적 공격자에 대해 안전)입니다. Fujisaki-Okamoto 변환은 이를 IND-CCA2 (chosen ciphertext를 제출할 수 있는 능동적 공격자에 대해서도 안전)로 끌어올립니다. 핵심 아이디어: 암호화에 쓰는 “무작위 값”을 메시지로부터 해시를 통해 결정적으로 유도하고, decapsulation이 재암호화해 비교합니다. 재암호화에 실패하는 암호문에는 에러 대신 의사난수 shared secret이 반환됩니다.#

from pqc_edu.ml_kem import ml_kem_keygen, ml_kem_encaps, ml_kem_decaps
from pqc_edu.params import ML_KEM_768

ek, dk = ml_kem_keygen(ML_KEM_768)
K1, ct = ml_kem_encaps(ML_KEM_768, ek)
K2 = ml_kem_decaps(ML_KEM_768, dk, ct)
print('ek size:', len(ek), '| ct size:', len(ct), '| K size:', len(K1))
print('shared secrets match:', K1 == K2)
print('K (hex):', K1.hex())
ek size: 1184 | ct size: 1088 | K size: 32
shared secrets match: True
K (hex): 788daf5f735b67a12ac1999b5649ce06d0f132a2147b8eaa6adc398a1b67f252