Interop with quantum-safe-py
quantum-safe-ts is byte-compatible with quantum-safe-py 0.3.0, and with 0.3.2 or later for the additional formats marked below (they first appeared in 0.3.1). Data written by one opens in the other, in both directions. That is checked in CI against the real Python library (liboqs backend), not a re-implementation: the fixtures are produced by Python and verified here, and produced here and verified by Python.
Which Python version
Use quantum-safe-py 0.3.2 or later (pip install "quantum-safe-py>=0.3.2"). Version 0.3.1 fixed a signature-verification flaw in Python 0.1.0 to 0.3.0 (see Security) and added the formats marked "0.3.2+" below; 0.3.2 added Sign.sign_raw() and a standard Sign.verify_raw(). The vectors in this repository's tests were checked against the released 0.3.2.
What crosses
| Data | Compatible |
|---|---|
Hybrid KEM ciphertexts (X25519+ML-KEM-*, P-256+ML-KEM-*) and pure ML-KEM | Yes |
SealedMessage envelopes (v1, hybrid) | Yes |
Keys: CBOR, PEM, public-key JWK, fingerprints, KeyPair bundles | Yes, byte for byte |
Signatures and SignedMessage (ML-DSA, SLH-DSA shared sets, hybrids), default format | Yes |
| JWTs in quantum-safe-py mode | Yes |
Migration Upgrader output and migration store layout | Yes (through converters for the store) |
Envelope v2 (CNSA profile: pure ML-KEM-1024, HKDF-SHA-384) | Yes, with quantum-safe-py 0.3.2+ |
Signature format -v2 (all eight identifiers) | Yes, with quantum-safe-py 0.3.2+ |
StandardJwt (RFC 9964) tokens and public AKP JWKs | Yes, with quantum-safe-py 0.3.2+ |
Private AKP JWKs and private COSE keys (priv is the 32-byte seed) | Yes, with quantum-safe-py 1.0.0 and its cryptography backend (pip install 'quantum-safe-py[pyca]'): a seed made in either library signs in the other |
X-Wing KEM, streaming encryption (format v3), all twelve SLH-DSA sets | Yes, with quantum-safe-py 1.0.0 |
Envelope re-wrap (Envelope.rewrap, the directory driver and its report) | Yes, with quantum-safe-py 1.0.0: what one library re-wraps the other opens |
| LMS signing | TypeScript signs only in Node.js with durable state; Python signs through its optional extra |
What each library has that the other does not, and why, is in Differences from quantum-safe-py.
Python 0.3.0 fails closed on the identifiers it does not know (it never mis-verifies). The full table, the wire formats and the known quirks are in COMPATIBILITY.md.
Compatible is not the same as standard
Matching quantum-safe-py means matching its own constructions: an HKDF-SHA-256 hybrid key combiner, and an ML-DSA "context" that is a message prefix signed under an empty FIPS 204 context. They are sound engineering choices, but they are not X-Wing, not the TLS X25519MLKEM768 group and not FIPS 204's native context, and they have not been reviewed against NIST SP 800-227's key-combiner guidance.
| Your situation | Use |
|---|---|
| Both ends are quantum-safe-py or quantum-safe-ts | The defaults, or -v2 for new signatures |
| The other side is a standard FIPS 204 ML-DSA implementation, and you need a signature over your own bytes | Python: Sign.sign_raw() / Sign.verify_raw() (0.3.2; aliases sign_standard / verify_standard in 1.0.0). TypeScript: Sign.signRaw() / Sign.verifyRaw() (aliases signStandard / verifyStandard) |
| A JOSE or JWT library must read the token | StandardJwt (RFC 9964), in both libraries |
| The other side is a third-party X-Wing or TLS-hybrid implementation | Use X-Wing for KEMs. quantum-safe-py envelopes (HKDF-SHA-256 combiner) cannot be read by them |
What each signature format is, exactly:
- Default (v1): signs
len(ctx) ‖ ctx ‖ prefix ‖ messageunder an empty FIPS 204 context, in a library-specific blob. A standard library cannot verify it. -v2: signsM2 = len(algo) ‖ algo ‖ len(ctx) ‖ ctx ‖ messageunder the FIPS 204 contextquantum-safe-sig-v2. A standard library could verify it only by rebuildingM2and passing that context.-v2is for quantum-safe-py and quantum-safe-ts.- Standard ML-DSA:
ML-DSA.Sign(sk, message, ctx)with the native context and the bare signature. Python 0.3.2 makes and checks it withsign_raw/verify_raw; before 0.3.2,verify_rawrejected every standard signature.
In TypeScript the pair is Sign.signRaw() / Sign.verifyRaw(). It is tested against Python's sign_raw and verify_raw for ML-DSA-44, 65 and 87 with empty, short and 255-byte contexts, in both directions.
Keys: TypeScript signs with seed keys, and verifies everything
TypeScript's standards-mode key is FIPS 204's native secret key, a 32-byte seed (StandardKeyPair). A secret key generated by quantum-safe-py through liboqs is a 4,032-byte expanded key; no seed can be recovered from it, so quantum-safe-ts cannot sign with it. It can verify every signature that key makes, because public keys are identical.
To sign in both libraries with one key, use a seed key: generate it with Sign.generateStandardKeyPair() in TypeScript, or with quantum-safe-py's cryptography backend (1.0.0), and move the 32-byte seed between them.
Settings that must match across the two libraries
The verifiers state what they expect, in both libraries. A mismatch fails closed, with no detail about why.
| What | quantum-safe-ts | quantum-safe-py (0.3.2+) |
|---|---|---|
| Signature context | verify(signed, pub, { expectedContext }) | verify(sm, pub, context=...) |
| Hedging mode of v1 signatures | new Sign(algo, { hedged }) (default true) | Sign(algo, hedged=...), JWTVerifier(..., hedged=), verify_cosig(..., hedged=) |
| Envelope AAD | Envelope.open(sealed, sk, { expectedAad }) | Envelope.open(sealed, sk, expected_aad=...) |
A signature made with hedged: false verifies only on a verifier built with hedged: false; the prefix length is not covered by the signature, so a verifier requires the one its own mode produces. -v2 signatures have no hedging mode.
Worked round trip, TypeScript to Python
TypeScript signs with the clean format, encrypts to a key the Python side published, and writes the bytes. (The key PEMs in this example are generated on the spot to show the shape; in reality the Python side generates its key and publishes only the public PEM.)
import { Envelope, HybridKEM, HybridSign, utf8 } from 'quantum-safe-ts';
const signer = new HybridSign('Ed25519+ML-DSA-65-v2');
using signing = signer.generateKeyPair();
const signed = signer.sign(utf8('hello from TypeScript'), signing.secretKey, { context: utf8('interop-v1') });
using pythonKey = new HybridKEM().generateKeyPair();
const sealed = Envelope.seal(utf8('secret for Python'), pythonKey.publicKey, { aad: utf8('interop-v1') });
const files = {
'ts-signing-public.pem': signing.publicKey.toPem(), // text
'ts-signed.bin': signed.toBytes(), // CBOR SignedMessage
'ts-sealed.bin': sealed.toBytes(), // CBOR SealedMessage
};
console.log(Object.fromEntries(Object.entries(files).map(([name, v]) => [name, typeof v === 'string' ? `${v.length} chars` : `${v.length} bytes`])));
if (typeof files['ts-signing-public.pem'] !== 'string' || files['ts-signed.bin'].length < 3000 || files['ts-sealed.bin'].length < 1100) throw new Error('unexpected sizes');Python then reads them, stating the context and AAD it expects (verified with quantum-safe-py 0.3.2; the files are the ones the block above writes, plus the secret PEM of the key it encrypted to):
from quantum_safe import HybridSign
from quantum_safe.types import PublicKey, SecretKey
from quantum_safe.types.signatures import SignedMessage
from quantum_safe.protocols.envelope import Envelope, SealedMessage
pub = PublicKey.from_pem(open("ts-signing-public.pem").read())
sm = SignedMessage.from_cbor(open("ts-signed.bin", "rb").read())
HybridSign("Ed25519", "ML-DSA-65-v2").verify(sm, pub, context=b"interop-v1") # raises unless valid
print(sm.message) # b'hello from TypeScript'
sk = SecretKey.from_pem(open("py-encryption-secret.pem").read())
print(Envelope.open(SealedMessage.from_bytes(open("ts-sealed.bin", "rb").read()), sk, expected_aad=b"interop-v1"))Worked round trip, Python to TypeScript
Python signs and encrypts to a public key the TypeScript side published:
from quantum_safe import HybridSign
from quantum_safe.types import PublicKey
from quantum_safe.protocols.envelope import Envelope
signer = HybridSign("Ed25519", "ML-DSA-65-v2")
kp = signer.generate_keypair()
sm = signer.sign(b"hello from Python", kp.secret, context=b"interop-v1")
open("py-signing-public.pem", "w").write(kp.public.to_pem())
open("py-signed.bin", "wb").write(sm.to_cbor())
ts_pub = PublicKey.from_pem(open("ts-encryption-public.pem").read()) # published by the TypeScript side
open("py-sealed.bin", "wb").write(Envelope.seal(b"secret for TypeScript", ts_pub, aad=b"interop-v1").to_bytes())TypeScript reads them (a Node.js example, verified against the files the Python above writes; it is marked no-run only because it needs those files):
import { readFileSync } from 'node:fs';
import { Envelope, HybridSign, PublicKey, SecretKey, SignedMessage, utf8 } from 'quantum-safe-ts';
const pub = PublicKey.fromPem(readFileSync('py-signing-public.pem', 'utf8'));
const signed = SignedMessage.fromBytes(readFileSync('py-signed.bin'));
new HybridSign('Ed25519+ML-DSA-65-v2').verify(signed, pub, { expectedContext: utf8('interop-v1') });
console.log(new TextDecoder().decode(signed.message)); // hello from Python
using sk = SecretKey.fromPem(readFileSync('ts-encryption-secret.pem', 'utf8'));
const plain = Envelope.open(readFileSync('py-sealed.bin'), sk, { expectedAad: utf8('interop-v1') });
console.log(new TextDecoder().decode(plain)); // secret for TypeScriptReal bytes from the Python library, kept in the repository and checked in CI: tests/vectors/py_v2_vectors.json (Python-made: envelope v2, all eight -v2 identifiers, StandardJwt tokens) and tests/vectors/ts_v2_vectors.json (TypeScript-made, verified by Python through scripts/verify_ts_vectors.py and by quantum-safe-py's own tests/interop).
Choosing formats when both libraries are in play
| Situation | Use |
|---|---|
| Both ends are quantum-safe-py 0.3.2+ or quantum-safe-ts, new signatures | -v2 |
| A Python 0.3.0 verifier is in the loop | The default format, with matching hedged |
| Encrypting between the two | Hybrid envelopes (v1). For the CNSA 2.0 profile, pure ML-KEM-1024 (v2) |
| Tokens that only your own services verify | JWTSigner / JWTVerifier on both sides |
| Tokens other parties verify | StandardJwt (Python 0.3.2+ and TypeScript) |
| Moving migration state | exportToPyStore / importFromPyStore (Migration) |
When interop fails
| Symptom | Cause | Fix |
|---|---|---|
VerificationError on a signature the other side made | Different context, or a different hedging mode (default format) | Same context on both ends; same hedged; or use -v2 |
UnsupportedAlgorithmError / Python UnsupportedAlgorithm for ...-v2 | The Python side is 0.3.0 | Upgrade Python to 0.3.2+, or use the default format |
DecryptionAuthenticationError on an envelope | Different AAD, wrong key, or modified bytes | Same AAD on both ends |
| Envelope v2 cannot be opened by Python | Python is 0.3.0 | Upgrade, or use a hybrid key |
KeyParseError on a key from Python | A secret key presented as public, or a damaged PEM | Export the right half; copy the PEM whole |
| A private JWK or COSE key from Python cannot sign here | It came from Python's liboqs backend (an expanded key, no seed) | Make it with StandardJwt.generate_private_jwk() (needs quantum-safe-py[pyca]), or generate the key here and give Python the seed |