Digital signatures¶
Choosing an algorithm¶
Algorithm |
Type |
NIST level |
Notes |
|---|---|---|---|
|
Hybrid Sign |
— |
Recommended default. Classical + PQC. |
|
Pure PQC |
2 |
Smallest ML-DSA. |
|
Pure PQC |
3 |
Recommended pure-PQC choice. |
|
Pure PQC |
5 |
Maximum security. |
|
Pure PQC (hash-based) |
1 |
Small signatures, very slow to sign. |
|
Pure PQC (hash-based) |
1 |
Faster signing, larger signatures. |
HybridSign¶
HybridSign is the high-level
hybrid signer. It produces a combined Ed25519 + ML-DSA signature.
Both sub-signatures must verify for the overall verification to pass.
Note
Verification is timing-safe: both the classical and PQC sub-signatures are always verified unconditionally before the combined result is checked. Early-return on first failure would create a timing oracle revealing which component was invalid — this implementation avoids that.
from quantum_safe import HybridSign
signer = HybridSign() # Ed25519 + ML-DSA-65 by default
kp = signer.generate_keypair()
# Sign a message
sm = signer.sign(b"document", kp.secret, context=b"myapp-v1")
# Verify — raises VerificationError if invalid
signer.verify(sm, kp.public)
# Include signer fingerprint for key lookup
sm = signer.sign_with_fingerprint(b"document", kp, context=b"myapp-v1")
print(sm.signer_fingerprint) # "3a7f..." (SHA-256 of public key)
Custom algorithm combination:
signer = HybridSign(classical="Ed25519", pqc="ML-DSA-87")
Sign (pure PQC)¶
Sign uses a single PQC algorithm:
from quantum_safe import Sign
signer = Sign("ML-DSA-65")
kp = signer.generate_keypair()
sm = signer.sign(b"document", kp.secret, context=b"myapp")
signer.verify(sm, kp.public)
Context strings¶
The context parameter provides domain separation between applications
and protocol versions, following FIPS 204 §5.2. Always use a unique
context string for each signing context:
# Different contexts — same key, completely isolated
sm_docs = signer.sign(b"doc", kp.secret, context=b"myapp-docs-v1")
sm_auth = signer.sign(b"token", kp.secret, context=b"myapp-auth-v1")
# Verification must use the same context
signer.verify(sm_docs, kp.public) # OK
# signer.verify(sm_auth, kp.public) would fail with VerificationError
# if verified with sm_docs' context
Hedged mode¶
Both HybridSign and
Sign default to hedged mode:
a 32-byte random prefix is prepended before signing.
This prevents fault-injection attacks demonstrated on lattice signatures. Two signings of the same message will produce different signatures, but both verify correctly:
sm1 = signer.sign(b"same message", kp.secret)
sm2 = signer.sign(b"same message", kp.secret)
assert sm1.signature != sm2.signature # different random prefix
signer.verify(sm1, kp.public) # both valid
signer.verify(sm2, kp.public)
Disable with hedged=False only when you need deterministic signatures:
signer = HybridSign(hedged=False)
sm1 = signer.sign(b"same", kp.secret)
sm2 = signer.sign(b"same", kp.secret)
assert sm1.signature == sm2.signature # deterministic
SignedMessage¶
SignedMessage is self-describing — it carries
the original message, signature, algorithm, and context:
print(sm.algorithm) # "Ed25519+ML-DSA-65"
print(sm.context) # b"myapp-v1"
# Serialize for storage or transport
cbor_bytes = sm.to_cbor()
sm2 = SignedMessage.from_cbor(cbor_bytes)
signer.verify(sm2, kp.public) # round-trips perfectly
HybridSignature¶
A HybridSignature exposes the individual
sub-signatures for hybrid messages:
from quantum_safe.types import HybridSignature
hybrid_sig = HybridSignature.from_bytes(sm.signature)
print(len(hybrid_sig.classical_sig)) # Ed25519: 64 bytes
print(len(hybrid_sig.pqc_sig)) # ML-DSA-65: ~3293-3309 bytes