Notebook 07 — Reference#
The CLI commands and Python API surface, in one place.
CLI commands#
All commands honor the PQMSG_HOME environment variable (default: ~/.pq-messenger/). State written under that directory: identity.json, contacts/<alias>.json, sessions/<peer>.json, inbox/<recipient>/<uuid>.json.
Command |
Purpose |
|---|---|
|
Generate a fresh Ed25519 + X25519 + ML-KEM-768 identity. |
|
Print the active identity (public components only). |
|
Write a public-only contact file for sharing out-of-band. |
|
Import a peer’s contact file under a local alias. |
|
Encrypt and enqueue a message into the recipient’s inbox. |
|
Dequeue and decrypt the oldest message destined for us. |
|
Debug: print current ratchet state for a peer session. |
|
Delete |
pqmsg init --name TEXT
pqmsg show-identity
pqmsg export-contact [--output PATH]
pqmsg import-contact PATH --as ALIAS
pqmsg send RECIPIENT BODY
pqmsg recv [--all]
pqmsg show-keys PEER
pqmsg reset
Python API#
The whole library is small enough to enumerate. Re-exports from the package root: nothing; import from the submodule directly.
pqmsg.identity#
Identity keypairs for pq-messenger.
- Each identity owns three keypairs:
Ed25519 (signing) — reserved; unused in this scope but provisioned
X25519 (DH) — classical half of hybrid X3DH
ML-KEM-768 — post-quantum half of hybrid X3DH
- class pqmsg.identity.Contact(name: str, x25519_public: bytes, ed25519_public: bytes, ml_kem_public: bytes)#
Bases:
objectPublic-only projection of an Identity.
- ed25519_public: bytes#
- ml_kem_public: bytes#
- name: str#
- x25519_public: bytes#
- class pqmsg.identity.Identity(name: 'str', x25519_public: 'bytes', x25519_private: 'bytes', ed25519_public: 'bytes', ed25519_private: 'bytes', ml_kem_public: 'bytes', ml_kem_private: 'bytes')#
Bases:
object- ed25519_private: bytes#
- ed25519_public: bytes#
- ml_kem_private: bytes#
- ml_kem_public: bytes#
- name: str#
- x25519_private: bytes#
- x25519_public: bytes#
pqmsg.session#
Hybrid X3DH + Double Ratchet session.
v0.2 adds the DH half of Signal’s Double Ratchet on top of the symmetric ratchet from v0.1: every time the conversation flips direction, the next sender generates a fresh X25519 keypair, attaches the public half to the message header, and mixes DH(new_eph, peer_dh_pub) into the root key.
Out-of-order delivery within a single sending burst is supported via a bounded skipped-key cache.
- class pqmsg.session.Handshake(ephemeral_pk: 'bytes', kem_ciphertext: 'bytes')#
Bases:
object- ephemeral_pk: bytes#
- kem_ciphertext: bytes#
- class pqmsg.session.Session(peer_name: 'str', root_key: 'bytes', chain_key_send: 'bytes', chain_key_recv: 'bytes', dh_send_priv: 'bytes', dh_send_pub: 'bytes', dh_recv_pub: 'bytes | None' = None, send_index: 'int' = 0, recv_index: 'int' = 0, prev_send_count: 'int' = 0, skipped_keys: 'dict' = <factory>)#
Bases:
object- chain_key_recv: bytes#
- chain_key_send: bytes#
- dh_recv_pub: bytes | None = None#
- dh_send_priv: bytes#
- dh_send_pub: bytes#
- peer_name: str#
- prev_send_count: int = 0#
- recv_index: int = 0#
- root_key: bytes#
- send_index: int = 0#
- skipped_keys: dict#
- pqmsg.session.accept_session(ours: Identity, peer_name: str, peer_x25519_pub: bytes, handshake: Handshake) Session#
Bob-side: decapsulate, complete DH, derive session keys.
Bob seeds his DH-ratchet send key with his own long-term X25519 pair initially (it matches Alice’s dh_recv_pub); on his first reply he’ll rotate to a fresh keypair and ratchet the root.
- pqmsg.session.decrypt(session: Session, msg: dict) bytes#
Decrypt a message, applying DH ratchet on direction flip and consulting the skipped-key cache for out-of-order delivery.
- pqmsg.session.encrypt(session: Session, plaintext: bytes, *, is_first: bool, handshake: Handshake | None) dict#
Advance the send chain (rotating DH if a new burst) and encrypt.
- pqmsg.session.initiate_session(ours: Identity, peer_name: str, peer_x25519_pub: bytes, peer_ml_kem_pub: bytes) tuple[Session, Handshake]#
Alice-side: generate ephemeral, encapsulate, derive session keys.
The X3DH ephemeral doubles as Alice’s first DH-ratchet keypair. Bob’s long-term X25519 public is used as Bob’s first DH-ratchet pub until Bob sends his own message with a fresh DH pub.
pqmsg.kdf#
HKDF over SHAKE256.
We use SHAKE256 (variable-length XOF) so the protocol is consistent with ML-KEM’s internal hash choice. HKDF construction (RFC 5869 style):
PRK = HMAC(salt, ikm)
OKM = HMAC(PRK, info || counter) || ...
With SHAKE256 we collapse both steps into a single XOF invocation keyed by salt || ikm, domain-separated by info. This is a standard educational construction and is sufficient for our toy messenger.
- pqmsg.kdf.derive_chain_step(chain_key: bytes) tuple[bytes, bytes]#
Advance the symmetric ratchet. Returns (message_key, next_chain_key).
- pqmsg.kdf.hkdf_shake256(salt: bytes, ikm: bytes, info: bytes, length: int) bytes#
Derive length bytes of keying material from (salt, ikm, info).
pqmsg.transport#
Local-filesystem transport: one file per message, oldest-first FIFO.
Each message is written atomically (<name>.json.tmp -> rename -> <name>.json) so a concurrent reader never sees a partial file. We sort by mtime for a stable FIFO ordering.
- pqmsg.transport.list_inbox(*, inbox_root: Path, recipient: str) list[Path]#
List pending messages in mtime order (oldest first). Ignores .tmp files.
- pqmsg.transport.pop_message(*, inbox_root: Path, recipient: str) bytes | None#
Return and remove the oldest message, or None if inbox is empty.
- pqmsg.transport.send_blob(*, inbox_root: Path, recipient: str, blob: bytes) Path#
Atomically write blob into recipient’s inbox. Returns final path.
pqmsg.encoding#
Wire format v2 for messages: JSON with base64-encoded byte fields.
v2 (added in pqmsg 0.2.0): every message carries the sender’s current DH public key (dh_pub) and the length of the previous send chain (prev_chain_length) so the receiver can run the DH ratchet and skip unread keys for out-of-order delivery. v1 messages are rejected.
- exception pqmsg.encoding.MessageFormatError#
On-disk wire format#
Every queued message is a single JSON file:
{
"version": 1,
"sender": "alice",
"recipient": "bob",
"msg_index": 0,
"kem_ciphertext": "base64...",
"ephemeral_pk": "base64...",
"nonce": "base64...",
"ciphertext": "base64...",
"sent_at": "2026-04-22T15:30:00Z"
}
Only the first message in a session carries kem_ciphertext and ephemeral_pk; subsequent messages omit them. See notebook 01 §Wire format for the rationale.