A hybrid X25519 + ML-KEM-768 key-encapsulation library with one wire format across Python, Dart, and Rust. Secure if either leg holds. Harvest-now-decrypt-later starts the day you do nothing.
The same hybrid KEM, the same bytes on the wire, published to PyPI, pub.dev, and crates.io. Every package imports as sk_pqc.
Registry names differ by convention (sk-pqc on PyPI/crates.io, sk_pqc on pub.dev) — but you always import sk_pqc.
Three implementations, one byte-exact contract. The hybrid KEM is the same construction everywhere — HKDF-SHA256(X25519_ss ‖ ML-KEM-768_ss), concatenate-then-KDF, never XOR, never pure-PQ — and a shared cross-implementation vector anchored to the NIST ACVP FIPS 203 keyGen seed proves every backend recovers the identical secret.
A message encapsulated by the Python package opens in the Dart app and the Rust service, byte for byte — the same hybrid construction that ships in TLS 1.3's X25519MLKEM768 and Signal's PQXDH, so the security argument inherits theirs. The web Dart backend even negotiates this in the browser, where no WebCrypto PQC API exists.
The KEM and the DM ratchet are wire-verified in all three languages against one shared cross-implementation vector. The higher-layer primitives (group ratchet, message seal, routing envelope, anonymous addressing) live in Python and Rust today and reach Dart over the single Rust core — see below. No row is aspirational: a ✓ means there is a passing parity test, not a plan.
| Capability | 🐍 Python | 🎯 Dart | 🦀 Rust | Cross-impl proof |
|---|---|---|---|---|
Hybrid KEMx25519-mlkem768 · 1216 / 1120 / 32 B |
✓ | ✓ | ✓ | FIPS 203 ACVP keyGen vector; ct sealed by any impl decaps on the others, both directions |
DM epoch ratchetderive_dm_message_key · 50-msg / 7-day rekey |
✓ | ✓bridge | ✓ | Same KAT (epoch/index → 32-byte key) matches byte-for-byte across all three |
| Group epoch ratchetper-member hybrid-wrapped epoch secret | ✓ | → core | ✓ | Python ↔ Rust parity vector (group_ratchet) |
| PQ message sealPQXDH-style wrap + downgrade-lock AAD | ✓ | → core | ✓ | Python ↔ Rust: canonical-JSON AAD, pqdm1: tokens |
Routing envelopepqroute1 relay-readable header + sealed inner |
✓ | → core | ✓ | Python ↔ Rust: route-header AAD parity |
Anon queue addressingaqid: codec + deniable HMAC tag |
✓ | → core | ✓ | Python ↔ Rust: address codec parity |
| Suite registry + self-reportcrypto-agility seam + honesty gate | ✓ | → core | ✓ | Rust pins the exact Python report byte-string as a vector |
Cross-node, not just cross-language. The same bytes have been proven on the wire between live nodes: web (noble) ↔ native (liboqs) agree, the Rust PyO3 wheel ↔ pure-Python sk_pqc decapsulate each other's ciphertexts both ways, and skchat/skcomms negotiate a hybrid post-quantum DM in the browser — verified end-to-end over Chrome DevTools Protocol.
Whole-operation timings for the hybrid KEM — each call pays both the X25519 leg and the ML-KEM-768 leg plus the HKDF combiner, which is what a caller actually spends. Measured with examples/bench.py (timeit, 500 iters × 5 batches, median per call) on a commodity x86-64 desktop, no GPU.
| op | median | mean | ops/sec |
|---|---|---|---|
| keygen | ~226 µs | 279 µs | ~4,400 |
| encap | ~360 µs | 512 µs | ~2,800 |
| decap | ~341 µs | 416 µs | ~2,900 |
The ML-KEM-768 leg dominates over X25519, and the whole thing still clears in well under a millisecond. These are machine-specific — reproduce on your own hardware:
# Python — pip install "sk-pqc[pq]" python examples/bench.py # 200 iters (default) python examples/bench.py 1000 # custom iteration count
And it is paid once per epoch, not per message: the DM and group ratchets distribute one hybrid-KEM-wrapped epoch secret, then derive every per-message key symmetrically off it. The ~1.1 KB ciphertext and the microseconds above amortise across a whole conversation epoch.
Three hand-written copies of a security-critical key schedule is three places to silently drift. The direction is a single audited Rust core — pure Rust (ml-kem + x25519-dalek, no OpenSSL, no liboqs) — surfaced to every other language over FFI, so there is one place the crypto actually lives.
Because the core is pure Rust, the Python wheel links clean — no system OpenSSL, no liboqs to ship. The PyO3 feature is off by default, so cargo test stays pure-Rust and crates.io sees a dependency-clean crate; the Python services opt in to being backed by the same audited core the Rust clients use. Until the Dart FFI lands, the Dart package keeps a deliberately thin, clearly-marked ratchet port (and binds vetted @noble/post-quantum on web, liboqs on native for the ML-KEM leg) — pinned to the identical cross-language vectors so it cannot drift unnoticed.
Honesty is a feature. Overclaiming in cryptography tells people they're safe where they aren't, so here is the unvarnished boundary.
-768 tier (the NIST-recommended default)@noble/post-quantum, pyca cryptography do the lattice/curve math-768 tier, not the level-5 ceilingThe concrete adversary this is built against is harvest-now, decrypt-later (HNDL): someone who records your ciphertext today and waits for a cryptographically-relevant quantum computer to break the classical key exchange later. Here is exactly where the line sits.
-768 hybrid protects-768 tier (the internet default), not the level-5 -1024 ceiling government suites mandateThe honest framing of "hybrid": confidential as long as either the classical X25519 leg or the post-quantum ML-KEM-768 leg is unbroken — never both required, never "quantum-proof." The report module mechanically rejects the words "quantum-proof", "quantum-safe", and "unbreakable" from any externally-visible note, and never marks a classical suite quantum-resistant. ML-KEM-768 is standardized as FIPS 203; the companion signature standard is FIPS 204 (ML-DSA) — referenced, not yet implemented.
Generate a keypair, encapsulate to the public key, decapsulate with the secret key. The shared secret is identical across languages.
# Python — pip install sk-pqc import sk_pqc pk, sk = sk_pqc.generate_keypair() # 1216-byte hybrid public key ct, ss = sk_pqc.encapsulate(pk) # 1120-byte ciphertext, 32-byte secret ss2 = sk_pqc.decapsulate(sk, ct) # ss == ss2
// Dart — dart pub add sk_pqc import 'package:sk_pqc/sk_pqc.dart'; final kp = await SkPqc.generateKeypair(); final enc = await SkPqc.encapsulate(kp.publicKey); // enc.ciphertext, enc.sharedSecret
// Rust — cargo add sk-pqc use sk_pqc::{generate_keypair, encapsulate, decapsulate}; let (pk, sk) = generate_keypair(); let (ct, ss) = encapsulate(&pk); let ss2 = decapsulate(&sk, &ct); // ss == ss2
API names shown are illustrative of the shared shape; see each package's README for exact signatures. The wire bytes (1216 / 1120 / 32) are the contract that doesn't change.
sk_pqc is the KEM extracted from a real sovereign messaging migration. The full story — group ratchets, at-rest wrapping, a post-quantum signing root, and a self-report engine that structurally refuses to overclaim — is written up in the research paper.