노트북 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`.

대칭 전용 체인의 핵심 정리#

  1. 전방 비밀성은 유지됩니다: HKDF가 단방향이므로 노출 이전 메시지는 기밀이 유지됩니다.

  2. 후방 비밀성은 깨집니다: 노출 시점 이후의 모든 메시지는 읽을 수 있습니다.

다음 섹션은 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_pubprev_chain_length를 포함합니다. 와이어 포맷 v2; v1은 거부됩니다. 노트북 07 — 레퍼런스 참조.

v0.2도 얻지 못한 것 — 노트북 05 참조: TOFU를 넘어선 인증, sealed sender, 멀티 디바이스, 그룹 채팅, constant-time 암호. 이는 여전히 의도적으로 범위 밖입니다.