This document specifies the implemented pqmsg prototype semantics at protocol level.
Normative terms (MUST, SHOULD, MAY) follow RFC 2119 intent.
The design targets a verifiable baseline for hybrid post-quantum asynchronous messaging:
- hybrid handshake confidentiality against passive archival adversaries,
- authenticated prekey bundle consumption with hybrid dual signatures (Ed25519 + ML-DSA-65),
- explicit downgrade checks via version and suite binding,
- strict parse failure for malformed or ambiguous wire material,
- one-time prekey (DH4) consumption for single-use initial-contact setup, with relay-side ciphertext deduplication as a separate server replay control.
sequenceDiagram
participant A as Alice
participant S as Server
participant B as Bob
A->>S: GET /v1/users/{bob}/bundle
S->>S: Mark served OTPK pair consumed when inventory is above reserve
S-->>A: IK_B, SPK_B, PQSPK_B, OTPK_B, signatures (Ed25519 + ML-DSA-65)
A->>A: Verify dual signatures (Ed25519 + ML-DSA-65)
A->>A: EK_A + encapsulate(PQSPK_B)
A->>A: SK = HKDF(DH1 || DH2 || DH3 || DH4 || ss_pq)
A->>S: Relay InitialMessage (includes otpk_id)
B->>S: Poll inbox or subscribe ws-inbox
S-->>B: InitialMessage
B->>B: decapsulate + DH recompute (DH1-4) + decrypt
The key schedule is:
DH1 = DH(IK_A, SPK_B)DH2 = DH(EK_A, IK_B)DH3 = DH(EK_A, SPK_B)DH4 = DH(EK_A, OTPK_B)(when one-time prekey is available)SK = HKDF-SHA256(DH1 || DH2 || DH3 || DH4 || ss_pq)
When no one-time prekey is available (last-resort bundle), the key schedule omits DH4:
SK = HKDF-SHA256(DH1 || DH2 || DH3 || ss_pq)
The implementation recognizes:
suite_id = 1: ML-KEM-768 + X25519 + HKDF-SHA256 + ChaCha20Poly1305suite_id = 2: Kyber768 alias + X25519 + HKDF-SHA256 + ChaCha20Poly1305
Protocol version is currently v1.
Prekey bundles carry dual signatures over SPK and PQSPK:
- Ed25519 (
sig_over_spk,sig_over_pqspk): classical signature under identity signing key, - ML-DSA-65 (
pq_sig_over_spk,pq_sig_over_pqspk): post-quantum signature under PQ identity signing key.
Verifiers MUST check BOTH signatures. Security holds under the hybrid assumption: the handshake is authenticated if EITHER the classical OR the PQ signature scheme is secure.
The AlgorithmSuite type includes a signature_algorithm field:
Ed25519: classical-only signature verification,HybridEd25519MlDsa65: dual-signature verification (default for PQ-enabled builds).
Bundles from peers that do not include PQ signatures are accepted in Ed25519 mode for backward compatibility.
SessionState maintains:
- root key,
- sending and receiving chain states,
- local/remote DH ratchet keys,
- bounded skipped-message-key cache,
- sparse PQ ratchet state support with configurable interval.
The implementation is intentionally minimal and is not a complete Signal clone.
Current recovery guarantee:
- session snapshots preserve the bounded skipped-message-key cache, allowing out-of-order messages that were already derivable at snapshot time to remain decryptable after restore,
- snapshot restore is an implementation continuity guarantee, not a claim of full production-grade multi-device synchronization semantics by itself.
Current PCS / recovery posture:
- on the supported direct-message path, the sparse PQ ratchet mixes a fresh ML-KEM shared secret into the root key at the configured interval; the supported default remains every message,
- the classical DH ratchet and per-message chain advancement continue to derive fresh message/header keys after each honest ratchet step,
- snapshot restore retains the bounded skipped-message-key cache and bounded PQ local key history needed to decrypt already-derivable out-of-order traffic.
Current PCS / recovery bounds:
- these guarantees are scoped to one live session state, not to account-wide or multi-device convergence,
- identity rotation changes the server-advertised identity and active device, but it does not automatically migrate or bless already-open client session snapshots,
- supported clients are expected to re-check transparency / pinned identity state after rotation and establish a fresh session when the pinned peer identity changes,
- endpoint compromise remains out of scope: if an attacker continues to control the local device or exported local secrets, protocol ratcheting alone does not restore safety.
Server-side directory behavior is defined as:
user_ididentity binding is immutable after first successful registration,- prekey uploads are accepted only when Ed25519 signatures over
SPKandPQSPKtranscripts verify under registeredidentity_sig_pub. - relay/inbox/identity-log access requires Ed25519-signed transport auth headers bound to registered
user_idanddevice_id. - websocket inbox subscriptions (
/v1/ws/inbox/{user_id}) require the same Ed25519 transport-auth header model and includesincein the signature transcript. - push-token registration (
/v1/users/{user_id}/push-token) requires signed transport auth and only permits wake-signal push semantics. - relay ciphertext replay is constrained server-side via deduplication over
(sender, recipient, ciphertext)within a bounded TTL window. - inbox
sincecursors are monotonic per authenticated(user_id, device_id)session and regressions are rejected. - server exposes authenticated prekey inventory status and marks low-inventory conditions with a replenishment recommendation.
- bundle responses expose remaining one-time prekey inventory and an explicit
last_resort_prekey_onlyindicator.
The implementation supports a sealed-sender transport mode with the following properties:
- sender identifiers are encrypted inside a sealed envelope payload,
- server-side sealed relay routing uses only
recipient_user_id, - sealed relay persistence stores only recipient addressing and opaque blob bytes,
- sealed inbox retrieval is authenticated with the same signed request-header model, using endpoint label
sealed-inbox, - sealed relay enforces IP-based rate limiting (extracted from
X-Forwarded-For/X-Real-IP) alongside per-recipient rate limiting to mitigate anonymous abuse.
All secret key material is zeroized on drop via explicit Drop implementations:
DhKeyPair: zeroizes DH secret key,SessionState: zeroizes root key,SessionSnapshot: zeroizes root key, sending/receiving chain keys, and local DH secret,RootStepOutput: zeroizes root key and chain key,PqStepOutput: zeroizes root key,SkippedMessageKeys: zeroizes all cached message keys,SkippedMessageKeySnapshot: zeroizes message key.
The pqmsg-core::hsm module provides a PKCS#11 signing abstraction:
Signertrait withsign()andpublic_key()methods,KeyHandleenum supportingSoftware(in-process) andHsm(slot/label reference) variants,SoftwareSignerimplementation for Ed25519 software keys,Pkcs11Signer: real PKCS#11 implementation viacryptokicrate (featurehsm-pkcs11), supporting CKM_EDDSA signing and EC_POINT public key extraction; falls back to a feature-disabled stub when compiled without the feature.
Session AEAD associated data MUST include:
- protocol version,
- suite id,
- sender ratchet DH public key,
- message number,
- previous chain length,
pq_step_ctwhen present on interval-triggered PQ step messages,pq_target_pub_hashwhen present,pq_next_public_keywhen present,- external caller AD derived from shared
pqmsg-coreconversation-associated-data construction.
This requirement ensures ratchet header mutation is rejected at AEAD verification time.
A compliant implementation MUST:
- authenticate
versionandsuite_id, - reject unknown suites at decode/dispatch,
- reject per-message suite mismatch once session state is established.
All parser entry points are length-delimited and fallible.
Critical unknown TLV tags and duplicate critical tags are rejected in strict mode.
No parser path should panic on adversarial input.
The implementation enforces replay resistance at four layers:
- transport request nonces (signed header transcripts),
- relay ciphertext deduplication with TTL on the server,
- client-side seen-message tracking and per-peer monotonic transport message-id checks,
- one-time prekey (OTPK) consumption: server marks OTPKs as used on bundle fetch and does not reissue consumed OTPK material in later bundle responses for that device inventory state.
The current server does not parse opaque relayed InitialMessage payloads deeply enough to reject replay by otpk_id at relay time; replay protection for relayed blobs is currently enforced via relay deduplication plus client-side state.
Current verification set:
- unit tests for handshake/session success and tamper failure paths,
- snapshot restore coverage for skipped-message-key continuity after out-of-order delivery,
- deterministic handshake KAT transcript,
- fuzz targets for TLV and wire decoding,
- integration tests for server endpoint behavior and input validation,
- symbolic handshake model in
verification/proverif/pqxdh_hybrid_model.pv(7 security queries including DH4 OTPK, PQ dual-signature verification, forward secrecy, and identity misbinding), - Tamarin Prover model in
verification/tamarin/pqxdh_hybrid.spthy(6 security lemmas with compromise rules, OTPK single-use linear fact, and hybrid-signature security), - cross-platform interoperability test suite in
crates/pqmsg-core/tests/interop.rs(16 tests: wire format round-trip, snapshot persistence, bidirectional exchange, suite tampering, AD mismatch, large/empty messages), - end-to-end client-to-client test suite in
crates/pqmsg-server/tests/e2e.rs(9 tests: full message+receipt flow, ephemeral messages, multi-device fan-out, bidirectional messaging, receipt idempotency, security headers), - penetration smoke runbooks and scripts under
docs/PENETRATION_TESTING.mdandscripts/security/.
The server provides authenticated REST-based call signaling for 1:1 voice and video calls:
POST /v1/call/offer— initiator sends SDP offer with callee user ID,POST /v1/call/:call_id/answer— callee responds with SDP answer,POST /v1/call/:call_id/ice— exchange ICE candidates,POST /v1/call/:call_id/hangup— terminate call with reason code,GET /v1/call/:call_id/signals?since=0— poll call signals.
All call endpoints use format-string authentication: the signature transcript is {action}:{user_id}:{device_id}:{callee_or_call_id} signed with the registered Ed25519 identity key.
Clients perform a PQ key exchange (PQXDH handshake via WASM or UniFFI) before SDP exchange to derive a media_key. WebRTC Insertable Streams encrypt/decrypt RTP payloads with ChaCha20-Poly1305 keyed by media_key, providing post-quantum end-to-end encrypted calls.
The server provides social broadcast features:
POST /v1/stories— publish story (24-hour TTL, max 512KB content, text/image/video),GET /v1/stories/feed[?user_id=]— fetch non-expired stories with view counts,POST /v1/stories/:story_id/view— mark story as viewed (deduplicated per viewer).
POST /v1/channels— create channel (owner auto-subscribed),GET /v1/channels— list subscribed channels with subscriber counts,GET /v1/channels/:channel_id/messages[?since=]— read channel messages (subscribers only),POST /v1/channels/:channel_id/messages— post message (owner/admin only, max 256KB),POST /v1/channels/:channel_id/subscribe— subscribe to channel.
All stories and channels endpoints use format-string authentication.