pqblocks-wire
A small, length-prefixed binary frame (PQCWF1) that carries an ML-KEM ciphertext, an ML-DSA signature, an SLH-DSA signature and a payload, with a strict parser. It also has thin wrappers for ML-KEM / ML-DSA / SLH-DSA key generation, encapsulation and signing, a composite ML-DSA-65 + Ed25519 signature, and an X25519 + ML-KEM-768 hybrid secret.
When to use it: you need to move post-quantum ciphertexts and signatures between Python services in one self-describing blob, and you control both ends.
Standards involved: the primitives are FIPS 203 (ML-KEM), FIPS 204 (ML-DSA) and FIPS 205 (SLH-DSA). This package does not implement them: it calls liboqs through liboqs-python. X25519 and Ed25519 come from pyca/cryptography. The frame format is this package's own and is not an IETF or NIST format.
pip install git+https://github.com/PQBlocks/pqc-wireformat # needs the liboqs shared library, see "Requirements"
from pqblocks_wire import (generate_mlkem_keypair, encapsulate_mlkem, decapsulate_mlkem,
encode_wire_format, decode_wire_format)
pk, sk = generate_mlkem_keypair("ML-KEM-768")
ct, ss_sender = encapsulate_mlkem(pk, "ML-KEM-768")
frame = encode_wire_format(mlkem_ciphertext=ct, payload=b"hello") # 1109 bytes
received = decode_wire_format(frame)
ss_receiver = decapsulate_mlkem(sk, received.mlkem_ciphertext, "ML-KEM-768")
print(ss_sender == ss_receiver, received.payload) # True b'hello'
Requirements
- Python: 3.11 to 3.14.
- Runtime dependencies:
liboqs-python, for ML-KEM, ML-DSA and SLH-DSA.cryptography(pyca), for X25519 and Ed25519.
- System library: liboqs. liboqs-python needs the liboqs shared library. This release was tested with liboqs 0.16.0 and liboqs-python 0.16.0.1.
- Install liboqs, then point to it:
export OQS_LIB_PATH=/path/to/liboqs.so(or put it on the system loader path, e.g./usr/local/libplusldconfig). - If liboqs-python cannot find the library, it tries to download and build liboqs itself on first import. That needs git, cmake and a compiler.
- Install liboqs, then point to it:
- Optional:
kyber-py. It is only needed forencapsulate_kat_mlkemand the tests that use it.
Why
- One frame, strict parsing. The decoder rejects:
- bad magic or an unknown version;
- truncation at any byte;
- components above the largest FIPS sizes;
- trailing bytes.
- Maintained primitives only. Every primitive is delegated to liboqs or pyca. No cryptographic algorithm is re-implemented here.
- Honest test record: 219 tests (209 pass, 10 skipped), with 98 % statement coverage. With the official vector corpus configured (
PQBLOCKS_VECTORS), 218 pass and 1 is skipped: the ML-KEM and SLH-DSA tests then compare this package's outputs with the NIST ACVP vectors. TESTING.md says plainly what the tests do not check; in particular, no ML-DSA output is compared with a vector.
Usage
The frame
"PQCWF1" (6 bytes) | version = 0x01 (1 byte) | flags (1 byte)
then, for each flag set, in this fixed order: u32 big-endian length | bytes
0x01 ML-KEM ciphertext (at most 1568 bytes, the ML-KEM-1024 size)
0x02 ML-DSA signature (at most 4627 bytes, the ML-DSA-87 size)
0x04 SLH-DSA signature (at most 49856 bytes, the largest SLH-DSA size)
0x08 payload (any length; an empty payload is not written)
- What the frame does not say: it carries no algorithm or parameter-set identifier. The receiver must know which parameter set was used.
- No integrity: the frame adds no integrity protection. Sign the payload (see
examples/signed_frame.py), and verify before trusting it. - Decoding: the decoder returns
DecodedWireFormat. Absent components areNone, and an absent payload isb"".
Primitives
- ML-KEM:
"ML-KEM-512","ML-KEM-768","ML-KEM-1024". - ML-DSA:
"ML-DSA-44","ML-DSA-65","ML-DSA-87". - SLH-DSA: liboqs names, e.g.
"SLH_DSA_PURE_SHA2_128F". There are 12 in total: SHA2 / SHAKE × 128 / 192 / 256 × s / f. - Randomness: all key generation and signing is randomised by liboqs.
Composite signature and hybrid secret
- Composite signature:
composite_signsignsM' = "CompositeAlgorithmSignatures2025" || label || len(ctx) || ctx || SHA-512(M)with both ML-DSA-65 and Ed25519.composite_verifyis True only if both verify. - Hybrid secret:
hybrid_key_exchange_initiator/_respondergive both sidesSHA-256(X25519 secret || ML-KEM-768 secret). - See Limitations before using either.
API reference
Real signatures from the code. Everything is importable from pqblocks_wire.
| Call | Returns / raises |
|---|---|
encode_wire_format(mlkem_ciphertext=None, mldsa_signature=None, slhdsa_signature=None, payload=None) |
bytes. Raises TypeError (non-bytes) and WireFormatError (oversize). |
decode_wire_format(wire) |
DecodedWireFormat(mlkem_ciphertext, mldsa_signature, slhdsa_signature, payload, version). Raises WireFormatError (too short, bad magic, wrong version, truncated, oversize, trailing data) and TypeError. |
encode_composite_signatures(...) / decode_composite_signatures(wire) |
The same format and behaviour as the two calls above, under a second name. |
generate_mlkem_keypair(parameter_set) |
(public_key, secret_key) |
encapsulate_mlkem(public_key, parameter_set) |
(ciphertext, shared_secret), with a 32-byte secret |
decapsulate_mlkem(private_key, ciphertext, parameter_set) |
shared_secret. Liboqs applies implicit rejection to a bad ciphertext and returns a pseudorandom secret. |
generate_mldsa_keypair(ps), sign_mldsa(message, private_key, ps), verify_mldsa(message, signature, public_key, ps) |
(pk, sk), bytes and bool respectively. Raises ValueError for an unknown ps. |
generate_slhdsa_keypair(ps), sign_slhdsa(message, private_key, ps), verify_slhdsa(message, signature, public_key, ps) |
As for ML-DSA. |
MLDSAWrapper / SLHDSASignatureWrapper .length_public_key(ps), .length_secret_key(ps), .length_signature(ps) |
int |
generate_composite_keypair() |
(CompositePublicKey, CompositePrivateKey) for ML-DSA-65 + Ed25519 |
composite_sign(composite_private_key, message, context=b"") |
CompositeSignature. context is at most 255 bytes. |
composite_verify(composite_public_key, message, composite_signature, context=b"") |
bool, True only when both parts verify |
composite_verify_individual(...) |
(mldsa_valid, ed25519_valid) |
generate_hybrid_keypair() |
(HybridPublicKey, HybridPrivateKey) for X25519 + ML-KEM-768 |
hybrid_key_exchange_initiator(x25519_private_key, x25519_public_key, mlkem_public_key) |
(mlkem_ciphertext, secret), with a 32-byte secret |
hybrid_key_exchange_responder(x25519_private_key, x25519_public_key, mlkem_private_key, mlkem_ciphertext) |
secret (32 bytes) |
classical_fallback(x25519_private_key, x25519_public_key) |
32-byte X25519 secret |
HybridPublicKey / HybridPrivateKey / CompositePublicKey / CompositePrivateKey / CompositeSignature |
.serialize() → bytes; .deserialize(data) (classmethod). Length-prefixed. |
encapsulate_kat_mlkem(public_key, parameter_set, m) |
Test helper. Deterministic encapsulation with a caller-chosen m. Needs kyber-py. Never use it in production. |
hybrid_key_exchange(...) |
Test helper. It runs both sides with one party's keys. |
mlkem_test_all_operations(), mldsa_test_all_operations(), slhdsa_test_all_operations(), hybrid_test_basic_operations() |
Printing self-tests |
pqblocks_wire.lane_root_mlkem is an older, smaller copy of pqblocks_wire.mlkem (230 vs 351 lines), kept because the test suite imports it. Do not use it.
Limitations
-
Partial official-vector testing, off by default. The vector corpora are not shipped. With
PQBLOCKS_VECTORSset, ML-KEM key generation, encapsulation and decapsulation are compared byte for byte with the ACVP FIPS 203 vectors, and the SLH-DSA wrapper is run on the ACVP FIPS 205 external/pure vectors with an empty context (36 of the signing and verification vectors, plus all 120 key-generation vectors). SLH-DSA signatures are not compared byte for byte (liboqs signs with fresh randomness), and no ML-DSA output is compared with any vector. Details are in TESTING.md. -
Hybrid secret is a plain hash combiner:
- it has no domain-separation label;
- it does not bind the ciphertext or the public keys;
- its X25519 half is static-static, so that half has no forward secrecy;
- it is fixed to ML-KEM-768;
- it is not X-Wing and not the TLS 1.3 hybrid key schedule.
Feed it into your own transcript-bound KDF, or use X-Wing.
-
The composite signature does not interoperate with draft-ietf-lamps-pq-composite-sigs. The label, the ML-DSA context handling and the serialization differ from the draft.
-
No ML-DSA / SLH-DSA context strings:
sign_mldsaandsign_slhdsahave noctxargument. -
Secret-key lengths are not checked. A wrong-length secret key leads to an out-of-bounds read inside liboqs. Pass only keys produced by this package or by liboqs.
-
Not deterministic despite their docstrings: the private
_keygen_from_seed/_sign_from_seed/_verify_from_seedhelpers are not seed-deterministic. Do not use them. -
Import-time output: importing the package prints
WARNING:lines to stdout when kyber-py is not installed. liboqs-python itself printsliboqs-python faulthandler is disabled. -
No algorithm id in the frame: the receiver must already know the parameter sets.
Security notes
The package handles key material but has had no external audit. It is not FIPS 140 / CMVP validated. Read SECURITY_NOTES.md for the review findings: 7 medium and 6 low, none critical or high.
Verification
TESTING.md · BENCHMARKS.md · EVIDENCE.md · VERIFICATION_REPORT.md · USE_CASES.md · SECURITY_NOTES.md
Part of PQBlocks
PQBlocks builds open, tested building blocks for the post-quantum transition. This package is its wire-format and integration layer over maintained post-quantum libraries.
Metadata
Release files for pqblocks-wire 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pqblocks_wire-0.1.0.tar.gz | 50.1 kB | Without provenance | Details for pqblocks_wire-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pqblocks_wire-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for pqblocks_wire-0.1.0-py3-none-any.whl |
Total release size: 77.0 kB
Release files / pqblocks_wire-0.1.0.tar.gz
| Download URL | pqblocks_wire-0.1.0.tar.gz |
|---|---|
| Size | 50.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e0943b8d149a9d98442eb952b5d5e0ed63c0ea364daa03afaccd1fd85c25e5d9
|
|
BLAKE2b-256 checksum How to use checksums |
dea514daa49d8f9230d16a6bcaf60c2d8ab9bb00dac797c8c4974a5b4c5e2f40
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / pqblocks_wire-0.1.0-py3-none-any.whl
| Download URL | pqblocks_wire-0.1.0-py3-none-any.whl |
|---|---|
| Size | 26.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2ab88d31a1db11bb0d5c7a1063d39040a58e45c08020f7f2a2bf09555b38a9c7
|
|
BLAKE2b-256 checksum How to use checksums |
45f62aa5d7ff3b84de0bff7ec05caadf5e20936e682ff1e4f7516728f5973e22
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Security for pqblocks-wire 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.