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

init

Generate a fresh Ed25519 + X25519 + ML-KEM-768 identity.

show-identity

Print the active identity (public components only).

export-contact

Write a public-only contact file for sharing out-of-band.

import-contact

Import a peer’s contact file under a local alias.

send

Encrypt and enqueue a message into the recipient’s inbox.

recv

Dequeue and decrypt the oldest message destined for us.

show-keys

Debug: print current ratchet state for a peer session.

reset

Delete ~/.pq-messenger/ (all identities/contacts/state).

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: object

Public-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.identity.export_contact(ident: Identity, path: Path) None#
pqmsg.identity.generate_identity(name: str) Identity#
pqmsg.identity.import_contact(path: Path) Contact#
pqmsg.identity.load_identity(path: Path) Identity#
pqmsg.identity.save_identity(ident: Identity, path: Path) None#

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.