sk-pqc (Python)
pip install sk-pqc # core; hybrid KEM needs the pq extra
pip install "sk-pqc[pq]" # + the ML-KEM-768 leg (liboqs)
⚠️ Experimental · pre-1.0 · NOT independently security-audited. This is a clean-room reference implementation — tested and cross-impl-parity-verified against our Rust (
sk-pqc) and Dart (sk_pqc) builds, but it has had no third-party security audit, fuzzing, or formal review. Primitives bind vetted libraries (liboqs/ML-KEM,cryptography); the original code is the wiring. Review it yourself before production use. We apply our own honest-claims discipline to the library itself: don't trust it beyond the evidence.
sk-pqc is a small, app-agnostic Python library of vetted hybrid post-quantum cryptographic primitives.
Use it to add hybrid X25519 + ML-KEM-768 (FIPS 203) confidentiality to any app — without dragging in a messaging framework.
It is the Python sibling of the public Dart sk_pqc package and is byte-for-byte interoperable with it (shared cross-impl KAT vector). Import name sk_pqc; PyPI name sk-pqc.
- Maturity tier: T2 — Hybrid KEM (key exchange/wrap is
HKDF(X25519 ‖ ML-KEM-768); signatures stay classical/optional-hybrid). Per sk-standards CRYPTOGRAPHY_STANDARD. - License: Apache-2.0 · Python: ≥ 3.10 · Version: 0.1.0
Honest claim. "Hybrid" means a derived secret is confidential if EITHER the classical X25519 leg OR the ML-KEM-768 leg holds — it survives a future cryptographically-relevant quantum computer breaking X25519, and it survives a classical break of ML-KEM. It is not "quantum-proof", "quantum-safe", or "unbreakable". AES-256-GCM (the bulk cipher) is symmetric / Grover-only and already quantum-acceptable. Citations: FIPS 203 (ML-KEM), FIPS 204 (ML-DSA, registry only), RFC 7748 (X25519), RFC 5869 (HKDF), SP 800-38D (AES-GCM).
What's in the box
| Module | Primitive | What it gives you |
|---|---|---|
sk_pqc.pqkem |
Hybrid KEM x25519-mlkem768 |
hybrid_keypair / hybrid_encap / hybrid_decap. ML-KEM-768 leg = liboqs (oqs), X25519 leg + HKDF combiner = pyca cryptography. The combiner is the only original crypto: HKDF-SHA256(X25519_ss ‖ MLKEM768_ss) — concat-then-KDF, never XOR, never pure-PQ. |
sk_pqc.pqdm |
PQXDH-style seal | seal / open_sealed a body to a recipient's published hybrid prekey (PrekeyBundle), AES-256-GCM under a KEM-derived key, with a downgrade-lock AAD that makes silent classical downgrade detectable. |
sk_pqc.pqroute |
Metadata-sealing routing envelope pqroute1 |
seal_routed / open_routed / read_route_header. Splits a plaintext next-hop header (a relay reads it, but it is AEAD-bound / tamper-evident) from a hybrid-sealed inner (final destination + content) a relay cannot read. |
sk_pqc.group_ratchet |
Group epoch ratchet | Per-epoch secret distributed once via the hybrid KEM; per-message keys derived symmetrically + index-addressable (loss/reorder tolerant). Forward secrecy across epochs, post-compromise security from independent epoch secrets. |
sk_pqc.dm_ratchet |
1:1 DM epoch ratchet | The pairwise analogue of the group ratchet (distinct HKDF domain labels — a DM key can never collide with a group key). |
sk_pqc.anon_queue |
Anon-queue addressing + deniable auth | new_queue_pair (uncorrelated recipient/sender ids), the aqid: address codec, and a repudiable HMAC-SHA256 authenticator. Addressing + deniable-auth only — not a transport. |
sk_pqc.crypto_suites |
Crypto-agility registry | Machine-readable suite-ids → primitives + quantum-resistance status + FIPS refs. The honest predicate is_quantum_resistant(suite_id) no caller should hand-roll. |
Never silently downgrades. If the liboqs backend is missing, hybrid operations raise PqKemUnavailable (a hard error). The pure-pyca pieces — combiner KAT, suite registry, anon-queue codec/MAC, key derivation — work with no PQ backend at all.
Architecture
flowchart TD
subgraph backends["Vetted backends — no hand-rolled math"]
OQS["liboqs (oqs)<br/>ML-KEM-768 · FIPS 203"]
PYCA["pyca/cryptography<br/>X25519 · HKDF-SHA256 · AES-256-GCM"]
end
OQS --> KEM
PYCA --> KEM
KEM["pqkem<br/>hybrid X25519 ‖ ML-KEM-768 KEM<br/>HKDF(X25519_ss ‖ MLKEM_ss)"]
KEM --> DM["pqdm<br/>PQXDH-style seal<br/>(downgrade-lock AAD)"]
KEM --> ROUTE["pqroute1<br/>metadata-sealing<br/>routing envelope"]
KEM --> GR["group_ratchet<br/>per-epoch group keys"]
KEM --> DMR["dm_ratchet<br/>per-epoch 1:1 DM keys"]
REG["crypto_suites<br/>agility registry<br/>+ honest self-report"]
AQ["anon_queue<br/>aqid: addressing<br/>+ deniable HMAC auth"]
REG -. "describes / status" .-> KEM
REG -. "describes / status" .-> DM
KEM --> VEC{{"cross-impl KAT vector<br/>↔ Dart sk_pqc"}}
classDef prim fill:#e6f0ff,stroke:#369;
classDef be fill:#eee,stroke:#999;
class KEM,DM,ROUTE,GR,DMR,REG,AQ prim;
class OQS,PYCA be;
Install
# Core (pure-pyca pieces work; hybrid KEM needs the pq extra)
pip install sk-pqc
# With the post-quantum (ML-KEM-768) leg via liboqs
pip install "sk-pqc[pq]"
The ML-KEM leg uses
liboqs-python(import nameoqs), which binds the native liboqs. Pointoqsat a prebuiltliboqs.sowithOQS_INSTALL_PATH(orSK_PQC_LIBOQS) to avoid a source build —sk_pqc.pqkem.ensure_liboqs_path()applies this best-effort on import.
Quickstart
from sk_pqc import hybrid_keypair, hybrid_encap, hybrid_decap
kp = hybrid_keypair() # 1216 B pub, 2432 B priv
ct, ss_sender = hybrid_encap(kp.public_key) # 1120 B ciphertext + 32 B secret
ss_recipient = hybrid_decap(ct, kp.private_key)
assert ss_sender == ss_recipient # secure if EITHER leg holds
from sk_pqc import PrekeyBundle, seal, open_sealed, SUITE_ID
bundle = PrekeyBundle(suite=SUITE_ID, hybrid_public_hex=kp.public_key.hex())
blob = seal(b"top secret", bundle, sender="alice", recipient="bob")
assert open_sealed(blob, kp.private_key, sender="alice", recipient="bob") == b"top secret"
1:1 DM epoch ratchet — distribute one epoch secret over the hybrid KEM, then key many messages off it symmetrically (the ~1.1 KB ML-KEM ciphertext is paid once per epoch, not per message):
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from sk_pqc import DmRatchet, hybrid_keypair
from sk_pqc.dm_ratchet import new_epoch_secret, wrap_dm_epoch_secret, unwrap_dm_epoch_secret
bob = hybrid_keypair()
e0 = new_epoch_secret()
bob_e0 = unwrap_dm_epoch_secret(wrap_dm_epoch_secret(e0, bob.public_key), bob.private_key)
alice = DmRatchet(epoch=0, epoch_secret=e0)
bob_r = DmRatchet(epoch=0, epoch_secret=bob_e0)
idx, key = alice.next_outbound_key() # carry idx on the wire
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, b"hi bob", None)
assert AESGCM(bob_r.message_key(index=idx)).decrypt(nonce, ct, None) == b"hi bob"
Runnable examples
The examples/ directory has self-contained scripts (each is also a
smoke test — they assert their own correctness):
| Script | What it shows |
|---|---|
examples/hybrid_kem_roundtrip.py |
Hybrid KEM encap/decap roundtrip + using the shared secret as an AES-256-GCM key. |
examples/dm_ratchet_roundtrip.py |
Two-party (Alice↔Bob) DM-ratchet roundtrip: per-epoch KEM wrap, symmetric per-message keys, out-of-order delivery, post-compromise rekey. |
examples/bench.py |
timeit micro-benchmark of keygen/encap/decap (the table below). |
SK_PQC_LIBOQS=$HOME/.local/lib/liboqs.so LD_LIBRARY_PATH=$HOME/.local/lib \
python examples/hybrid_kem_roundtrip.py
SK_PQC_LIBOQS=$HOME/.local/lib/liboqs.so LD_LIBRARY_PATH=$HOME/.local/lib \
python examples/dm_ratchet_roundtrip.py
Benchmarks
Whole-operation timings for the hybrid KEM (each op includes both the X25519
leg and the ML-KEM-768 leg plus the HKDF combiner — what a caller actually pays).
Measured with examples/bench.py (timeit, 500 iters × 5
batches, median per call):
sk_pqc hybrid KEM bench (suite x25519-mlkem768)
python 3.12.3 (x86_64)
platform Linux-6.17.0-35-generic-x86_64-with-glibc2.39
op median (us) mean (us) ops/sec
----------------------------------------------------
keygen 226 279 ~4,400
encap 360 512 ~2,800
decap 341 416 ~2,900
Sub-millisecond per operation on a commodity x86-64 CPU (no GPU); the ML-KEM-768
leg dominates over X25519. These are machine-specific — reproduce on your own
hardware with python examples/bench.py [iters]. Note the per-epoch DM-ratchet
design means this KEM cost is amortised across every message in an epoch, not paid
per message.
Test
# From a checkout (run from HOME to avoid local-namespace collisions)
cd ~ && python -m pytest /path/to/sk-pqc-py/tests -q
The cross-implementation interop gate (test_pqkem.py::test_cross_impl_vector_matches_sk_pqc) decapsulates the shared Dart/Python KAT vector and asserts the recorded shared secret — this is what proves the two implementations agree byte-for-byte. PQ tests skip cleanly if liboqs is unavailable; the pure-pyca combiner KAT + registry tests always run.
API docs
Full HTML API reference (every public symbol, generated from docstrings with
pdoc) lives in docs/api/ — open
docs/api/index.html in a browser. The pages carry the same experimental /
not-audited banner as this README.
Regenerate after changing any public API (pure-Python, no compile):
pip install pdoc
scripts/build-api-docs.sh # or: make docs
⚠️
docs/api/is generated HTML committed to the repo, and nothing regenerates or validates it on push. It is a snapshot that will drift silently from the docstrings. If it disagrees withsrc/,src/wins. Regenerating it is a manual step, so treat the pages as a convenience, not as the contract.
See also the prose docs/ARCHITECTURE.md.
Self-report (claim evidence)
from sk_pqc import get_suite, is_quantum_resistant
s = get_suite("x25519-mlkem768")
print(s.status.value, s.fips_refs, is_quantum_resistant("x25519-mlkem768"))
# hybrid-pq ('FIPS 203', 'RFC 7748', 'RFC 5869') True
This output is machine-checked. SOP.md's docs-evidence block executes that
exact call on every push and asserts each field, so the claim cannot drift away from
the code without the docs-check gate going red. The registry it reads
(crypto_suites) is pure stdlib, so the self-report answers even where liboqs is
absent and the hybrid operations would raise PqKemUnavailable.
Provenance / clean-room
The pqroute1 routing split and the anon_queue addressing are inspired by no-identity / mix-network messaging designs (clean-room, original implementation) — only the protocol idea; no third-party code was copied or translated. The wire formats, codecs, and MAC constructions are original and built solely on the vetted backends above.
Related projects / See also
- ↔️ Sibling (Dart): sk_pqc (pub.dev) — the Dart hybrid-KEM companion this package interoperates with (shared KAT vector).
- ↔️ Sibling (Rust): sk-pqc (crates.io) — the Rust implementation of the same suite + wire formats (full module set: kem/pqdm/pqroute/ratchets/anon_queue/suites).
- ⬇️ Used by: skcomms — sovereign multi-transport comms (envelope payload + routing seal).
- ⬇️ Used by: skchat — AI-native encrypted chat (group + 1:1 DM ratchets).
- ↔️ Sibling: sk_pgp — sovereign OpenPGP-PQC signing library (the signature counterpart).
- 📐 Standards: sk-standards — crypto, data-flow, version, and doc/SOP standards this repo conforms to.
Metadata
Release files for sk-pqc 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sk_pqc-0.1.2.tar.gz | 60.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sk_pqc-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 106.3 kB
Release files / sk_pqc-0.1.2.tar.gz
| Download URL | sk_pqc-0.1.2.tar.gz |
|---|---|
| Size | 60.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c67559ecb7f940fb2ddf2cdc5b96e19d274bd249a2296af0acbe461192f0007a
|
|
BLAKE2b-256 checksum How to use checksums |
e031cdf140175e939a8150e22bb6582a20c74d33ced6dc3f4538b941a1bb63e4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / sk_pqc-0.1.2-py3-none-any.whl
| Download URL | sk_pqc-0.1.2-py3-none-any.whl |
|---|---|
| Size | 45.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e2d43d5ef053e7cfe6ef5cae6dfec39ff468d98210fde43ae55d4a209e5eca4f
|
|
BLAKE2b-256 checksum How to use checksums |
bacd5d9fb099fbcb1acb9be1b19891c7b74a170d35d9d0dbb457bbf8c187e379
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|