Signatures
A digital signature proves that a message was produced by the holder of a signing key and has not been changed. Sign is for a single algorithm (ML-DSA or SLH-DSA); HybridSign pairs a classical signature (Ed25519 or P-256) with ML-DSA so that both must verify.
Use it for release artifacts, signed documents, audit-log entries, webhooks, anything a recipient must be able to attribute to you. Do not use it to hide content (a signature does not encrypt), or without a context (see below).
A complete example
import { HybridSign, SignedMessage, VerificationError, utf8 } from 'quantum-safe-ts';
const signer = new HybridSign(); // Ed25519 + ML-DSA-65, hedged
using keys = signer.generateKeyPair();
const signed = signer.sign(utf8('release 1.2.3'), keys.secretKey, { context: utf8('myapp-release-v1') });
// Verify with the context YOU expect. Returns nothing; throws VerificationError unless valid.
signer.verify(signed, keys.publicKey, { expectedContext: utf8('myapp-release-v1') });
// Transport: the signed message is self-contained.
const wire = signed.toBytes();
const received = SignedMessage.fromBytes(wire);
if (new TextDecoder().decode(received.message) !== 'release 1.2.3') throw new Error('message changed');
// Any change to the message, the signature, the context or the key is refused.
const tampered = SignedMessage.fromParts({ ...received, message: utf8('release 9.9.9') });
try {
signer.verify(tampered, keys.publicKey, { expectedContext: utf8('myapp-release-v1') });
throw new Error('should have failed');
} catch (e) {
if (!(e instanceof VerificationError)) throw e;
}verify returns nothing and throws VerificationError with a fixed message and no detail about why: telling an attacker which part failed helps them. Use isValid for a boolean, or verifyBytes(message, signature, publicKey, { context }) to check a detached signature.
What to know
- Pass the context you expect (
expectedContext, default empty). A message carries its own context, but an attacker can choose it, so the verifier never trusts it. A context (at most 255 bytes) ties a signature to one purpose:myapp-release-v1andmyapp-login-v1cannot be swapped. See Concepts. - A hybrid signature carries a classical and a post-quantum signature, and both must verify. Both halves are always evaluated before the result is combined, so a failure does not reveal which half failed.
- Hedged signing (extra randomness) is the default, as in quantum-safe-py. With the default format (v1), a verifier must be in the same hedging mode as the signer (
new Sign(algo, { hedged: false })). The-v2format has no such setting:hedged: falsewith a-v2suite is ignored and emits aQS_HEDGED_IGNOREDwarning (quantum-safe-py raisesValueError; see Context and AAD). SignedMessageis self-contained:message,signature,algorithm,context,signerFingerprint,signedAt. The fingerprint and timestamp are metadata and are not covered by the signature. Do not use them for security decisions.- Sign a digest of large artifacts, not the whole file.
- Keep
signerFingerprintuseful:signer.signWithFingerprint(message, keyPair, { context })recordskeyPair.publicKey.fingerprint()so a verifier can find the right key quickly.
Algorithms
| Name | Notes |
|---|---|
Ed25519+ML-DSA-44/65/87, P-256+ML-DSA-44/65, P-384+ML-DSA-87 | Hybrid. Default Ed25519+ML-DSA-65-v2 (NIST category 3). Byte-compatible with quantum-safe-py. |
ML-DSA-44/65/87 | FIPS 204. ML-DSA-87 is the CNSA 2.0 level. |
SLH-DSA-SHAKE-* and SLH-DSA-SHA2-* (128/192/256, s/f; 12 sets) | FIPS 205, hash-based. Large signatures, conservative assumptions, slower signing. f is faster to sign, s smaller. |
<any ML-DSA or hybrid>-v2 | The cleaner format described below. |
| LMS / HSS | Verification everywhere (Lms), per RFC 8554; signing in Node.js with write-ahead durable state (LmsSigner, LMS signing). |
import { Sign, sigSuites, utf8 } from 'quantum-safe-ts';
const fast = new Sign('SLH-DSA-SHAKE-128f'); // hash-based: no lattice assumption
using keys = fast.generateKeyPair();
const signed = fast.sign(utf8('long-term archive record'), keys.secretKey, { context: utf8('archive-v1') });
fast.verify(signed, keys.publicKey, { expectedContext: utf8('archive-v1') });
console.log('SLH-DSA-SHAKE-128f signature bytes:', signed.signature.length);
if (sigSuites().filter((s) => s.name.startsWith('SLH-DSA')).length !== 12) throw new Error('expected 12 SLH-DSA sets');Format v2 (-v2)
Every ML-DSA and hybrid suite also exists as <name>-v2 (ML-DSA-65-v2, Ed25519+ML-DSA-65-v2, ...). It removes the structure that makes the quantum-safe-py construction delicate:
- No prefix, no unsigned length byte. There is no boundary between "prefix" and "message" to move, and no
hedgedsetting to get wrong. The blob is the signature itself, in one fixed-length encoding. - A native FIPS 204 context for the ML-DSA half. It signs
M2(not your message) under the FIPS 204 contextquantum-safe-sig-v2. A standard FIPS 204 library can verify it only by rebuildingM2and passing that context; it is not a standard signature over your message. For that, see Standard ML-DSA below. Signing is always hedged inside ML-DSA. - Algorithm and context are signed.
M2 = len(algo) ‖ algo ‖ len(ctx) ‖ ctx ‖ message, and both halves of a hybrid sign the same bytes (the classical half signslabel ‖ 0x00 ‖ M2). - One encoding. Hybrid blob = classical signature (64 bytes) ‖ ML-DSA signature; P-256 signatures are raw
r ‖ sand the high-S twin is refused. - Keys are tagged
-v2, and the library refuses a v1 key in a v2 signer and the reverse. The tag is metadata, though: v1 and v2 keys have identical bytes, so never use the same key material in both formats. Generate new keys for v2. - The
SignedMessagecontainer is the same CBOR as in v1 and is not canonical; the signature blob and the verified message are not malleable, but do not use a hash of the container as an identity.
import { HybridSign, VerificationError, utf8 } from 'quantum-safe-ts';
const signer = new HybridSign('Ed25519+ML-DSA-65-v2');
using pair = signer.generateKeyPair();
const signed = signer.sign(utf8('release 1.2.3'), pair.secretKey, { context: utf8('myapp-release-v1') });
signer.verify(signed, pair.publicKey, { expectedContext: utf8('myapp-release-v1') });
let rejected = false;
try { signer.verify(signed, pair.publicKey); } catch (e) { rejected = e instanceof VerificationError; } // wrong (empty) context
if (!rejected) throw new Error('context must be enforced');
if (signed.signature.length !== 64 + 3309) throw new Error('hybrid v2 blob is 64 + the ML-DSA-65 signature');quantum-safe-py 0.3.2 or later reads and writes v2 (all eight identifiers are verified in both directions); 0.3.0 cannot read it and fails closed on the identifier (the format first appeared in 0.3.1). There is no SLH-DSA v2. JWTSigner / JWTVerifier accept v2 keys too.
Which to use: -v2 is the default hybrid from 1.0.0. Name the original Ed25519+ML-DSA-65 when a verifier older than quantum-safe-py 0.3.2 is involved (Choosing what to use).
Standard ML-DSA for other ecosystems
Compatible with quantum-safe-py is not the same as standard. Three different things get called "an ML-DSA signature" here:
| Format | What is signed | A standard FIPS 204 library can verify it |
|---|---|---|
| Default (v1) | len(ctx) ‖ ctx ‖ prefix ‖ message, under an empty FIPS 204 context, in a library-specific blob | No: it signs a different byte string |
-v2 | M2 = len(algo) ‖ algo ‖ len(ctx) ‖ ctx ‖ message, under the FIPS 204 context quantum-safe-sig-v2 | Only by rebuilding M2 and passing that context |
Standard ML-DSA (FIPS 204 ML-DSA.Sign(sk, message, ctx)) | Your message, with your context as FIPS 204's own ctx input; the bare signature | Yes |
When the other side is not quantum-safe-py or quantum-safe-ts:
- JOSE and JWT: use
StandardJwt(RFC 9964). Any compliant JOSE library verifies it. - A standard signature over arbitrary bytes:
Sign.signRaw()andSign.verifyRaw()(aliasessignStandardandverifyStandard), below. -v2is for quantum-safe-py and quantum-safe-ts.
Standard FIPS 204 signatures
signRaw signs your bytes exactly as given with FIPS 204's native context and returns the bare signature: no prefix, no wrapper, no metadata. Any FIPS 204 implementation verifies it from the message, context and public key; quantum-safe-py verifies it with Sign.verify_raw().
import { Sign, utf8 } from 'quantum-safe-ts';
const signer = new Sign('ML-DSA-65'); // ML-DSA-44, ML-DSA-65 or ML-DSA-87
using pair = signer.generateStandardKeyPair(); // a 32-byte seed and the FIPS 204 public key
const sig = signer.signRaw(utf8('release 1.2.3'), pair, { context: utf8('myapp-release') });
signer.verifyRaw(utf8('release 1.2.3'), sig, pair.publicKey, { context: utf8('myapp-release') }); // throws VerificationError if wrong- The context is at most 255 bytes and defaults to empty. A signature verifies only under the context it was made with.
verifyRawaccepts the FIPS 204 public key as bytes or as aPublicKeyof the same algorithm.standardKeyPairFromSeed(seed)rebuilds a key pair from its 32-byte seed;pair.exportSeed()returns the seed (wipe your copy).
Seed keys sign; expanded keys from quantum-safe-py only verify
A secret key made by quantum-safe-py's liboqs backend is a 4,032-byte expanded key. No seed can be recovered from it, so quantum-safe-ts cannot sign with it. Every signature that key makes verifies here. For one key that signs in both libraries, use a seed key (generateStandardKeyPair(), or quantum-safe-py's cryptography backend in 1.0.0).
Verifying bytes from elsewhere
verifyBytes checks a detached message and signature blob, which is how you verify something another tool produced.
import { Sign, VerificationError, utf8 } from 'quantum-safe-ts';
const signer = new Sign('ML-DSA-65-v2');
using keys = signer.generateKeyPair();
const signed = signer.sign(utf8('detached'), keys.secretKey, { context: utf8('ctx-v1') });
signer.verifyBytes(utf8('detached'), signed.signature, keys.publicKey, { context: utf8('ctx-v1') });
try {
signer.verifyBytes(utf8('detached!'), signed.signature, keys.publicKey, { context: utf8('ctx-v1') });
throw new Error('should have failed');
} catch (e) {
if (!(e instanceof VerificationError)) throw e;
}The easy layer
import { easy } from 'quantum-safe-ts';
const keys = easy.generateSigningKeys(); // PEM strings; default is Ed25519+ML-DSA-65-v2
const signed = easy.sign(keys.secretKey, 'hello', { context: 'docs-v1' }); // Uint8Array (CBOR SignedMessage)
if (easy.verifyText(keys.publicKey, signed, { context: 'docs-v1' }) !== 'hello') throw new Error('verify');
if (easy.isValid(keys.publicKey, signed, { context: 'other' })) throw new Error('a different context must not verify');easy.verify returns the message bytes and throws unless the signature is valid and its context equals options.context (default empty).
Stateful hash-based signatures (LMS / HSS)
Lms.verify(publicKey, message, signature) verifies RFC 8554 signatures (SHA-256 parameter sets), checked against the RFC's own vectors and against signatures made by an independent implementation. Lms.isValid returns a boolean and Lms.inspectPublicKey reports the parameter set. Signing is provided in Node.js only, by LmsSigner from quantum-safe-ts/lms-signer: a stateful scheme that reuses a one-time key is broken, so the signer makes the index durable before a signature exists and refuses on any state problem. See LMS signing.
Which default makes which format
| Call | Format |
|---|---|
new HybridSign() | Ed25519+ML-DSA-65-v2 (the clean format, no prefix) |
new Sign() | ML-DSA-65 in the original format. ML-DSA-65 is also the name the standards formats (JOSE, COSE) use for the algorithm, so the bare name keeps its meaning; ask for new Sign('ML-DSA-65-v2') for the clean format |
new HybridSign('Ed25519+ML-DSA-65') | The original hybrid format, by name |
new Sign().signRaw(...) | A bare FIPS 204 signature with a native context, for other ML-DSA implementations |
quantum-safe-py makes the same choices, so the same call gives the same format in both libraries.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Signing without a context | A signature made for one purpose is valid for any other use of the key | Always pass context and verify with expectedContext |
| Reading the context out of the message | An attacker chooses it | Pass the value you expect |
Verifying a hedged: false signature with the default verifier | VerificationError | Construct the verifier with the signer's mode, or use -v2 |
| Using one key for both formats (v1 and v2) | The classical halves are not domain-separated | Separate keys per format |
Treating signerFingerprint / signedAt as authenticated | They are not signed | Put anything that matters inside the signed message |
Using the hash of a SignedMessage as an identity | The container is not canonical | Use an identifier inside the signed data |
| Signing a huge buffer | Memory and time | Sign a digest |