노트북 03 — 래칫팅과 키 노출#
대칭 래칫을 10개의 메시지 동안 굴려보고, 키 노출을 시뮬레이션합니다. 이 노트북의 전반부는 대칭 래칫만 있을 때 — 즉 v0.1의 동작 — 어떤 일이 일어나는지를 보여줍니다. 후반부는 v0.2의 DH 래칫이 다음 왕복에서 체인을 어떻게 치유(heal)하는지 보여줍니다.
from pqmsg.kdf import derive_chain_step
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import os
단계 1 — 체인이 전진하는 모습 관찰#
chain_key = os.urandom(32)
for i in range(10):
msg_key, next_ck = derive_chain_step(chain_key)
aead = ChaCha20Poly1305(msg_key)
nonce = os.urandom(12)
ct = aead.encrypt(nonce, f"message {i}".encode(), None)
print(f"msg {i}: msg_key[:4]={msg_key[:4].hex()} chain_key[:4]={next_ck[:4].hex()}")
chain_key = next_ck
msg 0: msg_key[:4]=202713e9 chain_key[:4]=37934f37
msg 1: msg_key[:4]=d82b4f09 chain_key[:4]=959fd357
msg 2: msg_key[:4]=96b81958 chain_key[:4]=1209ca00
msg 3: msg_key[:4]=d1ebbdf2 chain_key[:4]=c0d07e51
msg 4: msg_key[:4]=1d534aa6 chain_key[:4]=73923d70
msg 5: msg_key[:4]=af527286 chain_key[:4]=ddc5dfed
msg 6: msg_key[:4]=873b845b chain_key[:4]=e65f54a9
msg 7: msg_key[:4]=7f3e6f6d chain_key[:4]=2cf69194
msg 8: msg_key[:4]=343a1c83 chain_key[:4]=c73ec436
msg 9: msg_key[:4]=e502d3fc chain_key[:4]=f7186886
단계 2 — 5단계에서 노출 시뮬레이션#
공격자가 5단계 시점(메시지 4가 전송된 후)의 chain_key를 훔쳤다고 가정합니다.
chain_key = os.urandom(32)
stolen = None
saved_keys = []
for i in range(10):
if i == 5:
stolen = chain_key
msg_key, next_ck = derive_chain_step(chain_key)
saved_keys.append(msg_key)
chain_key = next_ck
print("stolen chain_key (first 8B):", stolen[:8].hex())
stolen chain_key (first 8B): 751ecd3145b9099f
단계 3 — 공격자가 과거 메시지를 복호화할 수 있을까?#
훔친 chain_key로부터 공격자는 메시지 키 0..4를 유도하려고 시도합니다.
ck = stolen
attacker_keys_future = []
for i in range(5, 10):
mk, nxt = derive_chain_step(ck)
attacker_keys_future.append(mk)
ck = nxt
print("Future messages (5..9): attacker reconstructs every msg_key.")
for i in range(5, 10):
matches = attacker_keys_future[i - 5] == saved_keys[i]
print(f" msg {i} key reconstructed: {matches}")
print("\nPast messages (0..4): attacker has chain_key at step 5, and HKDF is one-way.")
print(" There is NO function that turns next_chain into previous chain_key.")
print(" Attacker CANNOT compute msg_keys 0..4 from `stolen`.")
Future messages (5..9): attacker reconstructs every msg_key.
msg 5 key reconstructed: True
msg 6 key reconstructed: True
msg 7 key reconstructed: True
msg 8 key reconstructed: True
msg 9 key reconstructed: True
Past messages (0..4): attacker has chain_key at step 5, and HKDF is one-way.
There is NO function that turns next_chain into previous chain_key.
Attacker CANNOT compute msg_keys 0..4 from `stolen`.
대칭 전용 체인의 핵심 정리#
전방 비밀성은 유지됩니다: HKDF가 단방향이므로 노출 이전 메시지는 기밀이 유지됩니다.
후방 비밀성은 깨집니다: 노출 시점 이후의 모든 메시지는 읽을 수 있습니다.
다음 섹션은 v0.2의 해법을 보여줍니다: 대화의 방향이 바뀔 때마다 새로운 DH 교환을 실행.
v0.2 — DH 래칫의 등장#
실제 pqmsg.session API는 Double Ratchet을 사용합니다: 대화의 방향이 바뀔 때마다 다음 송신자는 새로운 X25519 키쌍을 생성하고, HKDF(root_key, DH(new_eph, peer_dh_pub))로 자신의 송신 체인을 재키잉합니다. 위와 동일한 체인 키 노출이 더 이상 이후 메시지를 위협하지 않습니다 — 상대가 답장하는 순간 체인이 치유됩니다.
동일한 시나리오를 실제 세션 API로 다시 실행해 봅니다.
from pqmsg.identity import generate_identity
from pqmsg.session import initiate_session, accept_session, encrypt, decrypt
from pqmsg.kdf import derive_chain_step
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.exceptions import InvalidTag
alice = generate_identity('alice')
bob = generate_identity('bob')
sess_a, hs = initiate_session(
ours=alice, peer_name='bob',
peer_x25519_pub=bob.x25519_public,
peer_ml_kem_pub=bob.ml_kem_public,
)
sess_b = accept_session(
ours=bob, peer_name='alice',
peer_x25519_pub=alice.x25519_public, handshake=hs,
)
# Alice가 Bob에게 메시지 5개 전송 (한 번의 송신 burst)
for i in range(5):
m = encrypt(sess_a, f'a->b {i}'.encode(), is_first=(i == 0), handshake=hs if i == 0 else None)
decrypt(sess_b, m)
# 공격자가 바로 이 시점의 Alice 송신 체인 키를 탈취
leaked = sess_a.chain_key_send
print('탈취한 chain_key (앞 8B):', leaked[:8].hex())
탈취한 chain_key (앞 8B): a02a37ecfed8999b
이제 Bob이 답장하고, 다시 Alice가 송신합니다. Alice의 다음 송신은 DH 회전(rotation)을 트리거합니다 — 그녀의 새로운 송신 체인은 DH(new_eph, bob_dh_pub)을 섞어 들어가며, 이 엔트로피는 공격자가 본 적이 없습니다.
# Bob -> Alice: 방향 전환이 Bob의 새 DH 공개키를 함께 운반
reply = encrypt(sess_b, b'reply', is_first=False, handshake=None)
decrypt(sess_a, reply)
# Alice -> Bob: 새 burst의 시작 → 그녀 측에서 DH 래칫 회전
msg6 = encrypt(sess_a, b'healed', is_first=False, handshake=None)
# 단순 공격자: 탈취한 체인을 한 번 전진시켜 다음 메시지 키를 추측
predicted_key, _ = derive_chain_step(leaked)
aead = ChaCha20Poly1305(predicted_key)
try:
aead.decrypt(msg6['nonce'], msg6['ciphertext'], None)
print('공격자가 msg6를 복호화했음 — 체인이 치유되지 않음')
except InvalidTag:
print('공격자 거부됨 — DH 래칫에 의해 체인이 치유됨')
# Bob은 헤더의 새 dh_pub를 보고 정상적으로 복호화
print('bob 평문:', decrypt(sess_b, msg6).decode())
공격자 거부됨 — DH 래칫에 의해 체인이 치유됨
bob 평문: healed
v0.2가 실제로 얻는 것#
Post-compromise security: 일회성 노출이 더 이상 모든 미래 메시지를 망치지 않습니다. 다음 왕복에서 체인이 치유됩니다.
순서 어긋남 처리: 수신자가 건너뛴 메시지 키를 (제한된 크기로) 캐싱하므로, 한 메시지가 늦거나 누락되어도 채널이 막히지 않습니다. 명세는
tests/test_dh_ratchet.py::test_out_of_order_within_chain_decrypts참조.와이어 포맷: 이제 모든 메시지가
dh_pub와prev_chain_length를 포함합니다. 와이어 포맷 v2; v1은 거부됩니다. 노트북 07 — 레퍼런스 참조.
v0.2도 얻지 못한 것 — 노트북 05 참조: TOFU를 넘어선 인증, sealed sender, 멀티 디바이스, 그룹 채팅, constant-time 암호. 이는 여전히 의도적으로 범위 밖입니다.