Notebook 02 — Hybrid X3DH key agreement#

We run the hybrid handshake between two fresh identities and verify both sides derive the same 96 bytes of keying material.

from pqmsg.identity import generate_identity
from pqmsg.session import initiate_session, accept_session, Handshake

Step 1 — generate identities#

alice = generate_identity("alice")
bob   = generate_identity("bob")
print("alice X25519 pub (first 8B):", alice.x25519_public[:8].hex())
print("bob   X25519 pub (first 8B):", bob.x25519_public[:8].hex())
print("alice ML-KEM pub size      :", len(alice.ml_kem_public))
print("bob   ML-KEM pub size      :", len(bob.ml_kem_public))
alice X25519 pub (first 8B): 39f84640ab7fca1d
bob   X25519 pub (first 8B): 9143a34e5bedee34
alice ML-KEM pub size      : 1184
bob   ML-KEM pub size      : 1184

Step 2 — Alice initiates#

initiate_session runs X25519 DH, ML-KEM encapsulation, and the HKDF derivation.

sess_a, handshake = initiate_session(
    ours=alice, peer_name=bob.name,
    peer_x25519_pub=bob.x25519_public,
    peer_ml_kem_pub=bob.ml_kem_public,
)
print("root_key (first 8B):        ", sess_a.root_key[:8].hex())
print("chain_key_send (first 8B):  ", sess_a.chain_key_send[:8].hex())
print("chain_key_recv (first 8B):  ", sess_a.chain_key_recv[:8].hex())
print("ephemeral_pk size:           ", len(handshake.ephemeral_pk))
print("kem_ciphertext size:         ", len(handshake.kem_ciphertext))
root_key (first 8B):         f3b43285b4c13f23
chain_key_send (first 8B):   4b3747c1e1f1df16
chain_key_recv (first 8B):   4d3a73d912de561c
ephemeral_pk size:            32
kem_ciphertext size:          1088

Step 3 — Bob accepts#

accept_session uses Bob’s private keys and the handshake fields Alice sent.

sess_b = accept_session(
    ours=bob, peer_name=alice.name,
    peer_x25519_pub=alice.x25519_public,
    handshake=handshake,
)
print("root_key match:        ", sess_a.root_key == sess_b.root_key)
print("chain_a2b mirrored:    ", sess_a.chain_key_send == sess_b.chain_key_recv)
print("chain_b2a mirrored:    ", sess_a.chain_key_recv == sess_b.chain_key_send)
root_key match:         True
chain_a2b mirrored:     True
chain_b2a mirrored:     True

Why both lines must succeed#

The two chain keys are directional: one for Alice→Bob, one for Bob→Alice. After this handshake, each party can encrypt and the other can decrypt, independently. The next notebook cranks the symmetric ratchet ten steps to show forward secrecy — and where it stops.