Summary
100% of all REPORTED Findings have been addressed
- 0Acknowledged
- 0Risk Accepted
- 13Solved
- 13All Findings
- Critical0
- High0
- Medium0
- Low0
- Informational13
- 13Solved
INTRODUCTION#
The QRL project team engaged Halborn to conduct a security assessment of the qrypto.js monorepo (https://github.com/theQRL/qrypto.js), a pure-JavaScript implementation of two post-quantum digital signature schemes: ML-DSA-87 (FIPS 204) and CRYSTALS-Dilithium Round 3. The assessment covered the cryptographic implementation logic, public and internal API surfaces, packing and serialization correctness, arithmetic primitives, build and distribution integrity, and the overall security posture of the library as published to npm.
The repository contains two packages:
@theqrl/mldsa87(v1.1.1): the primary audit target, implementing ML-DSA-87 at NIST Security Level 5 with FIPS 204 context-string support, intended for use in the QRL blockchain ecosystem.@theqrl/dilithium5(v1.1.1): a secondary target implementing the pre-standardization CRYSTALS-Dilithium5 Round 3 variant, reviewed through differential analysis against the primary package.
The audit was performed against commit 58db119 on the main branch.
Because post-quantum signature libraries sit at the foundation of identity and transaction authorization in blockchain systems, the security boundary extends beyond functional correctness into areas that are specific to JavaScript cryptographic implementations: side-channel exposure from BigInt arithmetic and JIT compilation, secret-material lifecycle within a garbage-collected runtime, deterministic behavior across source and bundled distribution outputs, and the trust model between reviewed source code and shipped npm artifacts.
Key themes observed across findings#
API type-contract gaps: the high-level signing and verification functions accept inputs more loosely than their documented type signatures suggest, relying on JavaScript coercion and
Uint8Array.set()rather than strict type enforcement. While the library's own internal paths always supply correctly typed data, the exported surface permits integration-level misuse that could produce unexpected behavior.Exported helper correctness at edge domains: several internal arithmetic and packing helpers produce incorrect results for coefficient values near JavaScript's signed 32-bit boundaries. These edge cases are not reachable through normal high-level use, but the functions are exported and callable by third-party consumers.
Parser and packing asymmetry: the signature parser
unpackSig) enforces canonicality checks that the packerpackSig) does not mirror, and the parser writes partial state before later validation steps reject the input. This is consistent with a low-level C-style helper contract but creates defense-in-depth gaps for callers who misuse the return-code protocol.Build and release artifact integrity: the committed distribution files in the published
1.1.1npm package do not match a fresh rebuild from the current source, which affects auditability and reproducibility even though no behavioral exploit was demonstrated.Cross-package divergence: the two packages intentionally differ in context handling, challenge-seed length, and nonce derivation, but some helper-level behaviors diverge silently (e.g.,
polyChallengeseed-length tolerance) without clear documentation.
Main recommendations#
Strengthen type validation at the public API boundary: enforce
Uint8Arraychecks for all key, signature, message, and context inputs at the high-level API entry points rather than relying on downstream coercion. This prevents integration-level surprises without affecting performance for well-typed callers.Fix or document the safe input domain for exported arithmetic helpers: the
polyChkNorm()overflow should be corrected, and the accepted coefficient ranges forreduce32(),cAddQ(), and related functions should be documented so that callers of the exported internal API understand the contract.lign packer and parser validation: add explicit length and structure validation to
packSig()and the unpack helpers to create symmetric defense-in-depth, rather than relying solely on the high-level API to enforce input sizes.Rebuild and recommit distribution artifacts from a clean, pinned build: establish a CI step that verifies
dist/matches a fresh rebuild before release, so that consumers and auditors can confirm that the shipped npm artifact corresponds to the reviewed source.Document cross-package behavioral differences explicitly: maintain a living changelog of intentional divergences between
mldsa87anddilithium5so that the security implications of each delta are tracked alongside the code.
TEST METHODOLOGY AND APPROACH#
Halborn performed a combination of exhaustive manual static review, targeted dynamic testing, property-based validation, differential analysis, and large-scale automated fuzzing. The assessment was designed to maximize coverage of the cryptographic implementation while maintaining practical focus on the highest-impact attack surfaces.
The following phases were performed:
Repository baseline and inventory: full file inventory, dependency audit, build reproduction, and establishment of the review baseline including commit hash, test-suite status (223 passing, 2 pending, 0 failing), and CI pipeline state.
Static review track: ten phases of line-by-line manual source review covering the public API and type declarations, high-level signing and verification control flow, serialization and canonical encoding, arithmetic constants and reductions, NTT and polynomial logic, hashing and SHAKE streams, RNG and secret lifecycle, the existing test suite and KAT infrastructure, Dilithium5 differential review, build and CI pipeline, and static-finding consolidation.
Dynamic testing track: harness verification and baseline execution, existing test inventory and gap mapping, positive functional unit tests, negative high-level API boundary and misuse tests, parser and packing canonicalization abuse tests, arithmetic and property-based tests, differential and reference interoperability testing, and automated byte-mutation fuzzing.
Automated fuzzing campaign: a custom-built fuzzing infrastructure with 4 parallel harnesses targeting signature verification, signed-message opening, parser canonicalization, and source-to-distribution differential testing. Two campaign rounds totaling 2,400,000 mutated inputs were executed, producing zero false accepts, zero behavioral divergences, zero canonicalization drift, and zero crashes.
Skeptical reassessment: all 11 initial findings were re-evaluated with a deliberately skeptical lens, distinguishing between true security vulnerabilities, API-contract issues, and implementation notes. Several severities were downgraded based on reachability and exploitability analysis.
Technical Profile#
Document Purpose#
This document provides the technical baseline for the pre-audit review of qrypto.js. Its role is to describe what the system is, how it is implemented, how it is packaged, and which architectural characteristics are most relevant to the audit.
This is not a threat model and not a findings report. It should be read as the implementation and architecture profile that supports the separate attack-surface and threat-model documents.
System Summary#
qrypto.js is a pure JavaScript / TypeScript monorepo that implements two post-quantum digital signature schemes:
@theqrl/mldsa87: ML-DSA-87, the NIST-standardized FIPS 204 module-lattice signature scheme@theqrl/dilithium5: Dilithium5, the earlier pre-standardization CRYSTALS Round 3 variant
The primary audit target is @theqrl/mldsa87 because it is intended for critical use in the QRL ecosystem. @theqrl/dilithium5 remains relevant because the two packages are structurally similar and share large portions of implementation logic, so issues in one may inform risk in the other.
At a functional level, the library provides:
key generation
detached signing
detached verification
combined signed-message generation
combined signed-message verification and recovery
The library does not provide:
encryption
key exchange
network communication
persistent key storage
hardware-backed secret protection
protocol-level policy enforcement
This means the audit is primarily concerned with implementation correctness, misuse resistance, secret handling, runtime constraints, and artifact integrity rather than with network-facing application logic.
Repository and Package Layout#
The repository is organized as an npm workspace monorepo with Turbo used for orchestration.
Root-Level Structure#
package.json: workspace definition, shared scripts, Playwright, Turbo, semantic-release toolingpackages/mldsa87: primary audit targetpackages/dilithium5: secondary reference target.github/workflows: CI, release, cross-verification, provenance, SBOM, and SLSA workflowsscripts/check-shared-files.js: byte-identity check for a subset of shared filesSECURITY.md: documented security assumptions and JavaScript-specific limitations
Package Source Inventory#
packages/mldsa87/srccontains 13.jssource files plussrc/index.d.tspackages/dilithium5/srccontains 13.jssource files plussrc/index.d.tscurrent source-line baseline:
mldsa87: 1,719 lines of.jssourcedilithium5: 1,678 lines of.jssource
Source Module Responsibilities#
sign.js
Implements key generation, detached signing, combined signing, verification, and signed-message open.
This is the central control-flow module for both packages.
packing.js
Packs and unpacks public keys, secret keys, and signatures between structured polynomial state and raw byte arrays.
poly.js
Implements polynomial-level operations, sampling, challenge generation, and polynomial packing helpers.
polyvec.js
Implements vectorized polynomial operations and matrix-vector arithmetic used by key generation, signing, and verification.
const.js
Defines algorithm constants, encoding sizes, and the
zetastable used by the NTT.
reduce.js
Implements Montgomery reduction and related modular arithmetic helpers.
rounding.js
Implements
power2round,decompose,makeHint, anduseHint.
ntt.js
Implements forward and inverse number-theoretic transforms.
random.js
Wraps
globalThis.crypto.getRandomValues.
utils.js
Provides
zeroize()andisZero().
fips202.js
Wraps SHAKE-128 and SHAKE-256 operations via
@noble/hashes.
symmetric-shake.js
Provides the stream abstraction used by the sampling functions.
index.js / index.d.ts
Exposes the package's public runtime exports and TypeScript declarations.
Shared-Code Model#
The two packages are intentionally similar, but they are not mechanically generated from a single source of truth. The repo includes scripts/check-shared-files.js, which enforces byte-identity for 8 files:
random.jsutils.jsreduce.jsntt.jsrounding.jspolyvec.jsfips202.jsindex.js
Important files such as sign.js, poly.js, packing.js, const.js, and symmetric-shake.js are not covered by that sync check and therefore can diverge independently.
ML-DSA-87 Technical Profile#
Algorithm Parameters#
The mldsa87 package implements the ML-DSA-87 parameter set with the following constants:
security level: NIST Level 5
ring dimension:
N = 256modulus:
Q = 8380417module dimensions:
K = 8,L = 7small-coefficient bound:
ETA = 2challenge weight:
TAU = 60norm-related bounds:
BETA = 120,GAMMA1 = 2^19,GAMMA2 = (Q - 1) / 32hint budget:
OMEGA = 75seed lengths:
SeedBytes = 32,CRHBytes = 64,TRBytes = 64,RNDBytes = 32challenge seed length:
CTILDEBytes = 64
At a high level, the scheme operates over module lattices in the ring Zq[X] / (X^256 + 1) and uses SHAKE-based domain separation and sampling throughout the key-generation, signing, and verification paths.
Encodings and Object Sizes#
The ML-DSA-87 package uses the following top-level object sizes:
public key: 2,592 bytes
secret key: 4,896 bytes
signature: 4,627 bytes
seed: 32 bytes
context: 0 to 255 bytes
The public key packs rho and t1. The secret key packs rho, key, tr, s1, s2, and t0. The signature packs the 64-byte challenge seed, the z vectors, and the hint vector.
Key Generation Flow#
cryptoSignKeypair(seed, pk, sk) performs the following high-level sequence:
Obtain a 32-byte seed from the caller or from
randomBytes().Expand the seed with
SHAKE256(seed || [K, L])to derive:rhofor matrix generationrhoPrimefor samplings1ands2keyfor later signing use
Expand the public matrix
Afromrho.Sample the short secret vectors
s1ands2.Compute the public relation
t = A * s1 + s2.Decompose
tintot1andt0usingpower2round.Pack the public key as
(rho, t1).Compute
tr = SHAKE256(pk).Pack the secret key as
(rho, key, tr, s1, s2, t0).
Signing Flow#
cryptoSignSignature(sig, m, sk, randomizedSigning, ctx) performs the following high-level sequence:
Unpack the secret key into
rho,tr,key,t0,s1, ands2.Construct
pre = 0x00 || len(ctx) || ctx.Convert the message into bytes.
Compute
mu = SHAKE256(tr || pre || m).Derive
rnd:32 random bytes when
randomizedSigning = truea zero-filled 32-byte array when
randomizedSigning = false
Derive
rhoPrime = SHAKE256(key || rnd || mu).Expand the matrix
Afromrho.Enter the Fiat-Shamir-with-Aborts rejection loop:
sample a masking vector
ycompute
w = A * ydecompose
wderive the challenge seed
ctildecompute challenge polynomial
cderive
z,w0, and hint vectorhreject if norm or hint-budget conditions fail
Pack the final signature as
(ctilde, z, h).
Verification Flow#
cryptoSignVerify(sig, m, pk, ctx) performs the following high-level sequence:
Validate top-level key and signature lengths.
Unpack
pkintorhoandt1.Unpack
sigintoc,z, andh.Reject non-canonical or out-of-range values through
unpackSig()and norm checks.Recompute
tr = SHAKE256(pk).Reconstruct
pre = 0x00 || len(ctx) || ctx.Convert the message into bytes and recompute
mu = SHAKE256(tr || pre || m).Reconstruct the verification equation
Az - c * t1.Apply hints to recover the verifier-side
w1.Recompute the challenge seed and compare it to the one supplied in the signature using a constant-time bytewise accumulator.
Context Handling#
ML-DSA-87 supports an explicit context parameter. In this implementation:
the context is a
Uint8Arraythe maximum supported length is 255 bytes
the default context is
"ZOND"the context is incorporated into both signing and verification through the
preprefix
This is one of the most important implementation differences between mldsa87 and dilithium5.
Dilithium5 Technical Profile and Implementation Deltas#
@theqrl/dilithium5 is the earlier CRYSTALS Round 3 variant that predates ML-DSA standardization. It is included in secondary scope because its implementation is closely related and because cross-package consistency matters for security maintenance.
Shared Characteristics#
The two packages share:
the same high-level code organization
the same main lattice dimensions (
K = 8,L = 7,N = 256,Q = 8380417)the same public key size (2,592 bytes)
the same secret key size (4,896 bytes)
the same broad decomposition of logic into signing, packing, polynomial math, NTT, rounding, hashing, randomness, and utilities
Main Differences from mldsa87#
No context support
dilithium5does not expose the ML-DSA context parameter.Its signing and verification hashes are therefore not domain-separated in the same way.
Different challenge seed length
dilithium5uses a 32-byte challenge seed in signatures.mldsa87uses a 64-byte challenge seed.This is why the Dilithium5 signature length is 4,595 bytes rather than 4,627 bytes.
No [K, L] domain separator during key generation
mldsa87expands the key-generation seed asSHAKE256(seed || [K, L]).dilithium5expands the key-generation seed asSHAKE256(seed).
Different randomized-signing derivation
In
mldsa87, randomized signing uses 32 random bytes and derivesrhoPrime = SHAKE256(key || rnd || mu).In
dilithium5, randomized signing generates a random 64-byterhoPrimedirectly.
Audit Relevance of the Deltas#
These differences matter because they define where findings are likely to be shared and where they may be package-specific:
arithmetic and packing issues may affect both packages
context-handling issues are specific to
mldsa87challenge-seed and randomized-signing behavior differ between the two packages
key-generation seed expansion differs between the two packages
API and Export Model#
Documented High-Level API#
The high-level, intended consumer-facing interface consists of:
cryptoSignKeypair(seed, pk, sk)cryptoSignSignature(sig, m, sk, randomizedSigning, ctx?)cryptoSign(msg, sk, randomizedSigning, ctx?)cryptoSignVerify(sig, m, pk, ctx?)cryptoSignOpen(sm, pk, ctx?)zeroize(buffer)isZero(buffer)
The signing and verification functions accept messages as either:
Uint8Arrayhex strings, which are normalized by trimming whitespace and accepting an optional
0xprefix
This message-normalization behavior is an implementation detail that matters for API semantics and audit review.
Caller-Allocated Output Buffers#
The library uses caller-provided output buffers for:
public keys
secret keys
detached signatures
This is a performance- and control-oriented API choice, but it means correctness depends on callers supplying buffers of the expected size.
Runtime Export Model#
The module entry point re-exports all source modules:
constants
polynomial classes and helpers
vector helpers
packing and unpacking functions
NTT functions
reduction helpers
rounding helpers
SHAKE wrappers
signing functions
utilities
The TypeScript declarations label many of these as internal or primarily for internal use, but there is no runtime mechanism that hides them from consumers. In practice, any JavaScript consumer importing the package can call them.
There is also a small but important mismatch between the runtime export surface and the published TypeScript declaration surface. For example, index.js re-exports fips202.js, so runtime consumers can access KeccakState and the shake128* / shake256* helpers, but those helpers are not fully represented in src/index.d.ts. This means the JavaScript-reachable surface is slightly broader than the declared TypeScript surface.
Distribution Model#
Each package publishes:
CJS bundle in
dist/cjsESM bundle in
dist/mjsType declarations via
src/index.d.ts
This means the shipped runtime surface is the built bundle, while the declared API semantics are partly described in the source .d.ts file.
Runtime, Dependency, Build, and Test Model#
Runtime Requirements#
The implementation targets:
Node.js
>= 20.19.0browsers with Web Crypto and ES2020
BigInt
Supported browser assumptions documented in the repo include:
Chrome 67+
Firefox 68+
Safari 14+
Edge 79+
Dependency Model#
The cryptographic dependency surface is deliberately small.
The primary external cryptographic dependency is
@noble/hashes@2.0.1.SHAKE-128 and SHAKE-256 are delegated to that dependency through local wrappers.
All other cryptographic logic is implemented directly in this repository.
Runtime Randomness Model#
Randomness is obtained through globalThis.crypto.getRandomValues via random.js.
Important details:
requests are chunked in blocks of at most 65,536 bytes
an all-zero check is applied to the first 16 bytes when
size >= 16environments without supported Web Crypto cause an exception
Build and Packaging#
The project uses:
npm workspaces
Turbo for orchestration
Rollup for package builds
semantic-release tooling for automated versioning and release
Built package outputs are what downstream consumers install and execute.
Test and Verification Model#
The project includes:
Mocha-based Node test suites
Playwright browser tests in CI
cross-verification workflows against:
go-qrllibpq-crystals reference code
These cross-verification workflows are useful interoperability controls, but they are not fully self-contained or fully pinned. The Dilithium5 pq-crystals Round 3 workflow pins commit ac743d5, while the go-qrllib workflows and the ML-DSA pq-crystals workflow clone live upstream state at workflow time.
The current baseline captured during audit preparation was:
223 passing, 2 pending, 0 failing in the current Mocha baseline
lint passing
shared-file check passing
Release and Supply-Chain Workflow#
The release workflow includes:
CI execution before release
npm publish with
--provenanceSBOM generation
GitHub attestation artifacts
SLSA provenance upload
The release job is gated by CI, but not by the separate cross-verification workflow.
Security-Relevant Implementation Characteristics#
The following design choices are especially important to the audit:
Pure JavaScript Cryptographic Core#
The implementation is intentionally written in JavaScript using Uint8Array, Int32Array, and BigInt. This improves portability and auditability at the source level, but it also means the implementation inherits JavaScript-runtime limitations around constant-time execution, memory lifetime, and zeroization guarantees.
Byte-Oriented API Design#
The high-level interface is strongly byte-oriented:
keys are packed byte arrays
signatures are packed byte arrays
combined signed messages are
signature || messagemessages may enter as bytes or normalized hex strings
This design is common for cryptographic libraries, but it makes packing, unpacking, buffer validation, and parsing semantics first-order implementation concerns.
Best-Effort Secret Cleanup#
The implementation explicitly attempts cleanup through zeroize() and coeffs.fill(0) in finally blocks. This is useful, but it should be understood as best-effort cleanup within JavaScript, not as a hardware-grade guarantee.
Broad Runtime Export Surface#
Although the package has a clear high-level API, the runtime export surface is much broader because index.js re-exports all source modules. This affects how consumers can interact with the package and is relevant to both misuse analysis and attack-surface mapping.
That broader runtime surface is not perfectly mirrored by the TypeScript declarations, so there is a small but real distinction between the declared consumer surface and the JavaScript-reachable surface.
Two Similar Packages in One Monorepo#
The repository maintains two closely related implementations side-by-side. This is useful for comparison and cross-review, but it also means:
some fixes will need to be applied to both packages
divergence risk exists where shared-file enforcement is incomplete
package-specific deltas must be reviewed carefully rather than assumed to be harmless
Conclusion#
This technical profile establishes the implementation baseline for the audit:
what
qrypto.jsimplementshow
mldsa87anddilithium5differwhich modules are responsible for the core cryptographic behavior
how the package is exposed, built, tested, and released
Execution Flows#
Document Purpose#
This document describes the step-by-step execution flow of each major cryptographic operation in @theqrl/mldsa87. For each flow it identifies:
inputs and their validation
internal steps in execution order
sensitive values created during execution
validation and rejection points
cleanup behavior
outputs and failure modes
This is not a threat model or a findings report. It is a reference document that describes how data moves through the implementation so that the audit team and client share a common understanding of the operational behavior before detailed testing begins.
Key Generation Flow#
Function: cryptoSignKeypair(seed, pk, sk)Source: packages/mldsa87/src/sign.js lines 125-203
Inputs#
seed: 32-byteUint8Arrayornullfor random generationpk: caller-provided output buffer, must be exactly 2,592 bytessk: caller-provided output buffer, must be exactly 4,896 bytes
Validation (before any cryptographic work)#
pk.lengthmust equalCryptoPublicKeyBytes(2,592)sk.lengthmust equalCryptoSecretKeyBytes(4,896)if
pkorskisnull, the.lengthaccess throwsTypeError, which is caught and re-thrown asErrorif
seedis provided,seed.lengthmust equalSeedBytes(32)
Execution Steps#
Obtain seed. If the caller provides a 32-byte seed, use it directly. Otherwise, generate 32 random bytes via
randomBytes(SeedBytes).Expand seed with domain separator. Compute
seedBuf = SHAKE256(seed || [K, L]), producing 128 bytes. Slice into:rho(32 bytes): public seed for matrix generationrhoPrime(64 bytes): seed for sampling secret vectorskey(32 bytes): keying material stored in sk for later signing
Expand public matrix A. Call
polyVecMatrixExpand(mat, rho)to deterministically generate the K-by-L matrix of polynomials fromrho.Sample secret vectors. Sample
s1(L polynomials) ands2(K polynomials) with small coefficients bounded byETA = 2, usingrhoPrimeas the sampling seed.Compute public relation. Transform
s1into NTT domain, computet = A * NTT(s1)via Montgomery multiplication, transform back, and adds2.Decompose t. Apply
power2roundto splittinto high bitst1and low bitst0.Pack public key. Write
pk = (rho, packed_t1)into the caller's output buffer (2,592 bytes).Compute tr. Derive
tr = SHAKE256(pk)(64 bytes). This is a public hash of the public key, stored inside the secret key for efficient signing.Pack secret key. Write
sk = (rho, key, tr, packed_s1, packed_s2, packed_t0)into the caller's output buffer (4,896 bytes).Return seed. The function returns the 32-byte seed that was used, which is useful when the caller passed
nullfor random generation.
Sensitive Values Created#
rhoPrime(64 bytes): used to sample secret vectors; zeroized infinallykey(32 bytes): stored in sk, also zeroized locally infinallys1,s2(polynomial vectors): secret lattice vectors; coefficients zeroed infinallys1hat(NTT-domain copy of s1): coefficients zeroed infinallyt0(low bits of t): secret component stored in sk; coefficients zeroed infinallyseedBuf(128 bytes): full SHAKE output; zeroized infinally
Cleanup#
The finally block zeroizes: seedBuf, rhoPrime, key, s1.coeffs, s2.coeffs, s1hat.coeffs, t0.coeffs.
Values NOT specially cleaned: rho (public), tr (publicly derivable from pk), t1 (public).
Outputs#
pkbuffer is filled with the 2,592-byte public keyskbuffer is filled with the 4,896-byte secret keyreturns the 32-byte seed
Failure Modes#
throws
Errorifpkorskis null or wrong sizethrows
Errorif seed is provided but wrong sizethrows if
randomBytes()fails (no CSPRNG available)
Detached Signing Flow#
Function: cryptoSignSignature(sig, m, sk, randomizedSigning, ctx)Source: packages/mldsa87/src/sign.js lines 227-346
Inputs#
sig: caller-provided output buffer, must be at least 4,627 bytesm: message asUint8Arrayor hex stringsk: 4,896-byte secret keyrandomizedSigning: boolean flagctx: contextUint8Array, 0-255 bytes, defaults to"ZOND"
Validation (before any cryptographic work)#
sigmust not be null and must have.length >= CryptoBytes(4,627)ctx.lengthmust be <= 255sk.lengthmust equalCryptoSecretKeyBytes(4,896)
Execution Steps#
Unpack secret key. Extract
rho,tr,key,t0,s1,s2from the packed 4,896-byte secret key.Construct context prefix. Build
pre = 0x00 || len(ctx) || ctx. This is the ML-DSA-87 domain-separation prefix.Convert message. Call
messageToBytes(m)to normalize the message from hex string orUint8Array.Derive mu. Compute
mu = SHAKE256(tr || pre || mBytes)(64 bytes). This binds the message, context, and public-key hash together.Derive randomness. If
randomizedSigningis true, generaternd = randomBytes(32). Otherwise,rndis 32 zero bytes.Derive rhoPrime. Compute
rhoPrime = SHAKE256(key || rnd || mu)(64 bytes). This is the master nonce seed for the signing attempt.Prepare NTT-domain values. Expand matrix A from
rho. Transforms1,s2,t0into NTT domain.Enter the rejection loop (Fiat-Shamir with Aborts). This loop may iterate multiple times before producing a valid signature:
(a) Sample masking vector y from
rhoPrimeusing an incrementing nonce counter.(b) Compute w = A * NTT(y) and transform back to coefficient domain.
(c) Decompose w into high bits
w1and low bitsw0.(d) Derive challenge seed. Pack
w1and computectilde = SHAKE256(mu || packed_w1)(64 bytes).(e) Generate challenge polynomial c from
ctildeviapolyChallenge.(f) Compute response z = NTT(c) * s1 + y. Transform c to NTT domain, multiply by s1, transform back, and add y.
(g) Rejection check 1: if
||z||_inf >= GAMMA1 - BETA, reject and continue the loop.(h) Compute w0' = w0 - c * s2. Check norm.
(i) Rejection check 2: if
||w0'||_inf >= GAMMA2 - BETA, reject and continue.(j) Compute hint contribution h = c * t0. Check norm.
(k) Rejection check 3: if
||h||_inf >= GAMMA2, reject and continue.(l) Compute final hints. Add
w0' + h, compute hint vector, count hint bits.(m) Rejection check 4: if hint count >
OMEGA(75), reject and continue.(n) Pack signature. Call
packSig(sig, ctilde, z, h)to write the 4,627-byte signature.(o) Return 0 (success).
Sensitive Values Created#
key(32 bytes): extracted from sk; zeroized infinallyrhoPrime(64 bytes): nonce master seed; zeroized infinallys1,s2(polynomial vectors): secret lattice vectors; coefficients zeroed infinallyt0(polynomial vector): secret low bits; coefficients zeroed infinallyy(polynomial vector): masking vector; coefficients zeroed infinallymu(64 bytes): publicly derivable from pk, ctx, and message; not specially zeroizedrnd(32 bytes): random or zero; goes to GC
Cleanup#
The finally block zeroizes: key, rhoPrime, s1.coeffs, s2.coeffs, t0.coeffs, y.coeffs.
Outputs#
sigbuffer is filled with the 4,627-byte signaturereturns
0on success
Failure Modes#
throws
Errorif sig buffer is null or too smallthrows
Errorif ctx exceeds 255 bytesthrows
Errorif sk is wrong sizethrows
Errorif message cannot be parsed bymessageToBytes
Rejection Loop Behavior#
The loop is unbounded in theory. Each iteration generates a fresh masking vector y using an incrementing nonce. In practice, the expected number of iterations for ML-DSA-87 parameters is approximately 5.1 (per the FIPS 204 specification). The variable iteration count is a known side-channel consideration because total signing time correlates with the number of rejections.
Combined Signing Flow#
Function: cryptoSign(msg, sk, randomizedSigning, ctx)Source: packages/mldsa87/src/sign.js lines 366-382
This is a convenience wrapper that:
Converts the message to bytes.
Allocates a new buffer of size
CryptoBytes + message.length.Copies the message into the buffer after the signature region.
Calls
cryptoSignSignatureto fill the signature portion.Returns the combined
signature || messagebuffer.
The cryptographic behavior is identical to the detached signing flow. The only difference is the output format.
Verification Flow#
Function: cryptoSignVerify(sig, m, pk, ctx)Source: packages/mldsa87/src/sign.js lines 403-481
Inputs#
sig: 4,627-byte signaturem: message asUint8Arrayor hex stringpk: 2,592-byte public keyctx: contextUint8Array, 0-255 bytes, defaults to"ZOND"
Early Rejection Points (return false, no exceptions)#
ctx.length > 255: returnsfalsesig.length !== CryptoBytes(4,627): returnsfalsepk.length !== CryptoPublicKeyBytes(2,592): returnsfalseunpackSigreturns non-zero (hint ordering or budget violation): returnsfalse||z||_inf >= GAMMA1 - BETA: returnsfalsemessage parsing fails inside
try/catch: returnsfalse
Execution Steps#
Validate lengths. Check context, signature, and public-key sizes. Return
falseimmediately on mismatch.Unpack public key. Extract
rhoandt1from the packed public key.Unpack signature. Extract challenge seed
c, response vectorz, and hint vectorh. The unpacking function validates:hint index ordering (strictly increasing within each polynomial)
hint budget (cumulative count <=
OMEGA)extra hint indices must be zero If any check fails,
unpackSigreturns 1 and verification returnsfalse.
Norm check on z. If
||z||_inf >= GAMMA1 - BETA, returnfalse.Recompute tr. Derive
tr = SHAKE256(pk)(64 bytes).Construct context prefix. Build
pre = 0x00 || len(ctx) || ctx.Convert message. Call
messageToBytes(m)inside atry/catch. If parsing fails, returnfalse.Recompute mu. Derive
mu = SHAKE256(tr || pre || mBytes)(64 bytes).Reconstruct challenge polynomial. Call
polyChallenge(cp, c)to regenerate the challenge polynomial from the challenge seed.Expand matrix A. Call
polyVecMatrixExpand(mat, rho).Compute verification equation. In NTT domain:
w1' = A * NTT(z) - NTT(c) * shiftL(NTT(t1)). Transformw1'back to coefficient domain.Apply hints. Call
useHint(w1', h)to recover the verifier-sidew1.Pack w1. Serialize into a byte buffer.
Recompute challenge seed. Derive
c2 = SHAKE256(mu || packed_w1)(64 bytes).Constant-time comparison. Compare
candc2bytewise using an accumulator:diff |= c[i] ^ c2[i]for alli. Returndiff === 0.
No Sensitive Values#
Verification does not handle secret keys or nonces. All intermediate values are derived from public inputs (signature, message, public key, context).
Outputs#
returns
trueif the signature is validreturns
falseotherwise
Failure Modes#
returns
falsefor all validation and verification failurescan throw
TypeErrorif fundamentally malformed types are passed (e.g.,nullassig) before the length checks execute
Timing Considerations#
The final challenge comparison is constant-time. However, the overall verification path includes early exits on public malformed input (length mismatches, unpack failures, norm violations). These early exits are on public data and do not leak secret information, but they do mean the total verification time varies depending on input validity.
Signed-Message Open Flow#
Function: cryptoSignOpen(sm, pk, ctx)Source: packages/mldsa87/src/sign.js lines 501-513
Inputs#
sm: signed message buffer (signature || message)pk: 2,592-byte public keyctx: contextUint8Array, 0-255 bytes, defaults to"ZOND"
Execution Steps#
Length check. If
sm.length < CryptoBytes(4,627), returnundefined.Split buffer. Extract:
sig = sm.slice(0, CryptoBytes)(first 4,627 bytes)msg = sm.slice(CryptoBytes)(remaining bytes)
Verify. Call
cryptoSignVerify(sig, msg, pk, ctx).Return result. If verification succeeds, return the recovered
msg. Otherwise, returnundefined.
Outputs#
returns the recovered message
Uint8Arrayon successreturns
undefinedon failure
Failure Modes#
returns
undefinedif the signed message is too shortreturns
undefinedif verification failscan throw if fundamentally malformed types are passed before the length check
Dilithium5 Flow Differences#
The @theqrl/dilithium5 package follows the same high-level flow structure, but with these differences:
Key Generation#
seed expansion uses
SHAKE256(seed)without the[K, L]domain separatorno other structural difference
Signing#
no context parameter; no
preprefix constructionmu = SHAKE256(tr || mBytes)directly (no context binding)randomized signing generates a random 64-byte
rhoPrimedirectly, rather than deriving it fromSHAKE256(key || rnd || mu)deterministic signing uses
rhoPrime = SHAKE256(key || mu)challenge seed is 32 bytes (
SeedBytes) rather than 64 bytes (CTILDEBytes)
Verification#
no context parameter
mu = SHAKE256(tr || mBytes)directlychallenge seed comparison is over 32 bytes rather than 64 bytes
signature length is 4,595 bytes rather than 4,627 bytes
Open#
no context parameter
minimum signed-message length is 4,595 bytes rather than 4,627 bytes
Summary of Security-Relevant Flow Properties#
Validation Points#
key generation validates buffer sizes and seed length before any cryptographic work
signing validates buffer sizes, context length, and secret-key length before unpacking
verification validates sizes and unpacking canonicality before any heavy computation
message parsing is protected by
try/catchin verification
Secret Lifecycle#
key generation creates and cleans up secret vectors, seed expansion material, and keying data
signing creates and cleans up the nonce seed, masking vector, and unpacked secret-key components
verification does not handle secret material
Rejection Loop#
signing uses Fiat-Shamir with Aborts, which means the number of iterations varies per signature
each iteration derives a fresh masking vector using an incrementing nonce
the variable iteration count is a known side-channel consideration
Constant-Time Properties#
the final challenge comparison in verification uses a bytewise accumulator pattern
early exits in verification are on public data only
signing includes early-return norm checks that operate on secret-derived data
Context Binding#
ML-DSA-87 binds every signature to a context string through
pre = 0x00 || len(ctx) || ctxDilithium5 does not support context binding
the default context for ML-DSA-87 is
"ZOND"
Attack Surface#
Public API Entry Points#
The library exposes seven documented high-level functions that ordinary consumers are expected to call. These form the primary attack surface because they are the points where untrusted data enters the cryptographic implementation.
Key Generation#
cryptoSignKeypair accepts a seed (or null for random generation) and two output buffers for the public and secret keys. The caller controls whether generation is deterministic or random, and provides the output buffers directly. The function validates buffer lengths and seed size before doing any cryptographic work, but can still throw if fundamentally wrong types (like null) are passed instead of byte arrays.
Detached Signing#
cryptoSignSignature is the most sensitive API because it processes the caller's secret key. It accepts the signature output buffer, message, secret key, a randomized-signing flag, and a context string. It validates the minimum signature buffer size, the secret key length, and the context length bound. It parses messages from either Uint8Array or hex string form. This function exercises the rejection loop, nonce derivation, and all of the internal polynomial arithmetic.
Combined Signing#
cryptoSign is a convenience wrapper that allocates a new buffer, calls the detached signing function, and returns signature concatenated with the message. It exposes the same secret-bearing signing path through a simpler interface.
Verification#
cryptoSignVerify is the most important public-input attack surface because it is where attacker-controlled signatures are processed. In a blockchain or transaction-processing context, this is the function that decides whether to accept or reject a signature. It exercises unpacking, canonicality checks, matrix reconstruction, hint application, and the final constant-time challenge comparison. It validates exact signature and public-key lengths, catches message-parsing failures, but can still throw TypeError on fundamentally malformed input types before the graceful-failure path kicks in.
Combined Verify and Recover#
cryptoSignOpen splits a combined signed-message buffer into signature and message portions, then delegates to verification. The splitting logic itself is part of the attack surface because the buffer boundary is controlled by the caller.
Utilities#
zeroize and isZero are security helper functions. They require Uint8Array input and throw TypeError otherwise. Callers may rely on them for secret-handling policy.
Untrusted Data Types#
The attack surface is not just about which functions are callable but also about which data types and encodings can cross into the library.
Messages can enter as Uint8Array or hex strings. The hex parser trims whitespace, accepts optional 0x/0X prefixes, requires even-length input, and rejects non-hex characters. This normalization behavior is itself part of the attack surface.
Seeds are 32-byte caller-supplied values that directly determine the keypair when deterministic generation is used. Their origin and lifecycle are outside the library's control.
Secret keys are 4,896-byte packed buffers that the signing path trusts to be correctly formatted. Malformed or cross-algorithm byte arrays can enter through the same API boundary and reach unpacking and polynomial state directly.
Public keys are 2,592-byte packed buffers accepted by verification. Wrong-sized, substituted, or malformed key bytes all enter at this boundary.
Signatures are the most important attacker-controlled input because they carry the packed challenge seed, response vectors, and hint data. Incorrect, truncated, non-canonical, or cross-package signatures all enter at the verification boundary.
Context strings are 0 to 255 bytes chosen by the caller and directly affect domain separation. Security depends on consistent context usage across all systems that sign and verify.
Output buffers are caller-provided for key generation and detached signing. Undersized buffers, aliased buffers, or unexpected object types with a .length property are all part of the effective input surface even though they are nominally outputs.
Exported Internal Helpers#
The runtime attack surface is significantly broader than the documented high-level API. The module entry point (index.js) re-exports every source module, which means any JavaScript consumer importing the package can call internal functions directly at runtime.
The most security-relevant exported internals include:
Packing and unpacking functions (packPk, unpackPk, packSk, unpackSk, packSig, unpackSig): these convert between raw bytes and structured polynomial state. Some expect well-formed buffers and do not independently enforce safety checks that the high-level API provides.
Polynomial arithmetic (Poly, PolyVecK, PolyVecL, and dozens of poly*/polyVec* helper functions): these operate on internal mathematical state directly and are not designed as safe consumer APIs.
NTT, reduction, and rounding (ntt, invNTTToMont, montgomeryReduce, power2round, decompose, makeHint, useHint): these implement core arithmetic. montgomeryReduce uses JavaScript BigInt, which has runtime-dependent timing behavior.
Sampling and challenge functions (polyUniform, polyUniformEta, polyUniformGamma1, polyChallenge): these create internal state used directly by the signature algorithm with varying input-size expectations.
SHAKE and Keccak state (KeccakState, shake128*/shake256* functions): these are stateful primitives exposed at runtime. Notably, these helpers are accessible in JavaScript but are not fully declared in the TypeScript type definitions, so the runtime-reachable surface is slightly broader than what TypeScript consumers see.
Runtime and Environment#
Secure randomness comes from globalThis.crypto.getRandomValues. Randomness quality is entirely outside the library's control. Environments without Web Crypto support will throw. Deterministic key generation with a caller-provided seed and deterministic signing mode do not require runtime randomness.
JavaScript engine behavior affects side-channel exposure. BigInt arithmetic is not guaranteed constant-time by any engine. JIT optimizations may eliminate dead stores, defeating zeroize(). The garbage collector may retain copies of secret-bearing buffers. Timing observability differs between Node.js (nanosecond precision) and browsers (reduced precision due to Spectre mitigations).
The @noble/hashes dependency (version 2.0.1) provides all SHAKE-128 and SHAKE-256 behavior. Both its functional correctness and its package integrity matter, since every key generation, signing, and verification path depends on correct XOF output.
Threat Model#
What This Library Must Protect#
The core security question for qrypto.js is straightforward: can the implementation be trusted to correctly sign messages, correctly verify signatures, and protect secret material throughout its lifecycle?
Secret Signing Keys#
The 4,896-byte packed secret key is the highest-value asset. If it is exposed, an attacker can forge signatures on any message and fully impersonate the legitimate key holder. The library provides a zeroize() function for callers to clear this buffer, but JavaScript cannot guarantee that runtime copies or garbage-collected fragments are also cleared.
Seed Material#
When deterministic key generation is used, the 32-byte seed fully determines the keypair. Compromising the seed is equivalent to compromising the secret key. The library zeroes intermediate expanded material in a finally block, but the seed itself is returned to the caller and its lifecycle is application-controlled after that.
Transient Signing Secrets#
During signing, the library generates a masking vector y and a nonce master seed rhoPrime that are security-critical even though they exist only briefly. If enough nonce-related material leaks, signature security can collapse and secret-key recovery may become feasible. The signing path uses a rejection loop, meaning multiple candidate nonces may be generated and discarded per successful signature. All of these values are zeroed in a finally block after signing completes.
Public-Key Integrity#
Public keys are not confidential, but their integrity is critical. If a public key is substituted or corrupted, signatures will verify under the wrong identity. In a blockchain context, this directly enables impersonation.
Verification Results#
The boolean output of cryptoSignVerify is the central trust decision of the library. If it incorrectly returns true for a forged signature, the security model fails entirely. If it incorrectly returns false for a valid signature, downstream systems experience availability loss.
Domain Separation Context#
ML-DSA-87 supports a context string (0-255 bytes) that binds each signature to a specific application domain. The library defaults to ZOND for the QRL ecosystem. If context is misused, omitted, or collides between applications, signatures may be valid in unintended domains.
Package and Release Integrity#
Because consumers install bundled build artifacts rather than raw source, trust extends beyond the code itself to the build pipeline, CI/CD workflows, dependency integrity, and npm publication process.
Who Can Threaten This System#
External Attackers Supplying Malformed Input#
The most direct threat comes from anyone who can supply crafted signatures, public keys, messages, or contexts to systems that use the library. In a blockchain context, this means anyone who can submit a transaction. The verification path is the primary target because it processes fully attacker-controlled byte sequences.
Integrators Who Misuse the API#
A consumer who calls exported internal functions directly, passes wrong-sized buffers to low-level helpers, uses empty or colliding context strings, or assumes the library provides stronger guarantees than it actually does can create security problems even without malicious intent.
Observers Who Can Measure Timing#
An attacker who can trigger repeated signing operations and measure execution time with sufficient precision could potentially extract information about secret-key material. The main concerns are: JavaScript BigInt arithmetic in Montgomery reduction (not guaranteed constant-time in any engine), the variable number of rejection-loop iterations during signing, and early-return patterns in norm-checking functions that operate on secret-derived data.
Supply-Chain Attackers#
Anyone who can compromise the @noble/hashes dependency, tamper with the GitHub Actions release workflow, modify Rollup build outputs, or manipulate the npm publication step could inject backdoored cryptographic primitives or weakened randomness into the distributed package.
Operational and Governance Failures#
Even without malicious intent, misconfigured GitHub branch protection, skipped cross-verification checks before release, shared-code drift between the two packages, or publication from an unprotected branch could introduce security regressions. The cross-verification workflow is not currently a release gate.
Where Trust Boundaries Exist#
Between Consumers and the Library API#
Every message, key, signature, context, seed, and output buffer crosses from the consumer into the library. High-level functions validate buffer lengths and context bounds, but some malformed types can still cause TypeErrors before graceful failure paths are reached. Internal exports generally assume well-formed inputs.
Between the Library and the JavaScript Runtime#
The implementation depends on correct Uint8Array and Int32Array behavior, BigInt arithmetic correctness, exception propagation, and memory lifecycle management. Side-channel behavior and secret-cleanup effectiveness are both partly determined by the runtime rather than by the source code alone.
Between the Library and Secure Randomness#
Random key generation and randomized signing depend on globalThis.crypto.getRandomValues. The quality and availability of randomness are outside the library's control.
Between the Library and Its Hash Dependency#
All SHAKE-128 and SHAKE-256 behavior is delegated to @noble/hashes version 2.0.1. The library trusts both the functional correctness and the package integrity of this dependency.
Between Reviewed Source and Shipped Artifact#
The source code is bundled through Rollup into CJS and ESM outputs before being published to npm. Consumers execute these built artifacts, not the raw source.
Between the Two Packages in the Monorepo#
The mldsa87 and dilithium5 packages are similar but not identical. Only 8 of 13 source files are covered by the automated sync check.
What Can Go Wrong#
Incorrect Signing or Verification#
The most fundamental threat is that the library could produce invalid signatures, accept forged signatures, reject valid signatures, or bind signatures to the wrong public key or context. This could result from errors in polynomial arithmetic, NTT computation, Montgomery reduction, packing and unpacking logic, challenge generation, hint handling, or domain-separation prefix construction.
Secret Material Exposure#
Threats include direct exposure through API misuse, accidental retention in memory after the finally block runs, JIT-optimized dead-store elimination defeating zeroize(), garbage-collected copies persisting in the heap, or indirect disclosure through error messages, logging, or crash artifacts.
Side-Channel Information Leakage#
JavaScript runtime behavior can create observable timing differences that correlate with secret values. The main concerns are BigInt-based Montgomery reduction (not constant-time in any engine), the variable iteration count of the signing rejection loop, and early-return norm checks on secret-derived polynomial data.
Unsafe Integration and API Misuse#
The package exports far more than its documented high-level API. Internal polynomial arithmetic, packing helpers, SHAKE state machines, sampling functions, and NTT operations are all accessible at runtime. Context misuse is also a realistic integration-level threat.
Compromised Artifacts or Dependencies#
If the @noble/hashes dependency is compromised, the build pipeline is tampered with, or the release workflow is weakened, downstream consumers could receive a package that diverges from the reviewed source. Shared-code drift between the two packages could also create silent security differences.
Availability Degradation#
Adversarial inputs could trigger excessive computation in the signing rejection loop, cause unexpected exceptions that propagate to callers, or create repeated failure conditions that degrade availability in downstream systems.
Key Assumptions#
The ML-DSA-87 scheme and its standard hardness assumptions remain appropriate for the intended use case
The @noble/hashes dependency is functionally correct and delivered to consumers with integrity preserved
Secure randomness is available and trustworthy when needed
Supported JavaScript runtimes preserve the expected correctness of typed arrays, exceptions, and BigInt arithmetic
Consumers protect secret keys, seeds, and contexts appropriately outside the library boundary
Live GitHub, npm, and repository governance settings enforce the intended review and release model
Preliminary Risk Concentration#
Before detailed testing begins, the highest security concern is concentrated in the signing and verification control flow, followed closely by packing and unpacking correctness. JavaScript runtime side-channel exposure and the broad exported-internal surface represent the next tier of concern. Build, release, and governance integrity are important for a cryptographic library. Cross-package divergence and availability under pathological inputs are lower but still relevant.
This is a planning view only and should not be interpreted as a statement that these areas already contain confirmed vulnerabilities.
Automated Fuzzing Campaign#
A comprehensive byte-mutation fuzzing campaign was executed against the @theqrl/mldsa87 package, targeting the four highest-risk attack surfaces in the library: signature verification, signed-message opening, signature parsing and serialization, and source-to-distribution build consistency.
Across two campaign rounds totaling 2,400,000 mutated inputs, the implementation produced zero false accepts, zero behavioral divergences between source and compiled outputs, zero canonicalization drift in the parser, and zero crashes or hangs.
What We Were Looking For#
The campaign was designed to hunt specifically for high-severity cryptographic implementation defects. In order of impact:
Forgery via false accept: any mutated signature, message, public key, or context that causes
cryptoSignVerify()to returntruewhen it should returnfalse. This would be a critical vulnerability allowing an attacker to forge signatures.Message recovery from invalid input: any mutated signed-message that causes
cryptoSignOpen()to return message bytes when it should returnundefined. This would allow extraction of messages from invalid signatures.Parser canonicalization bypass: any non-canonical signature encoding that is accepted by the parser, survives a round-trip through pack/unpack, and is later tolerated by verification. This could enable signature malleability attacks.
Source/distribution divergence: any input where the source code, ESM distribution, and CJS distribution disagree on accept/reject behavior. This would indicate a build-pipeline integrity issue.
Crashes, hangs, or runtime spikes: any input that causes an unhandled exception, infinite loop, or per-call time exceeding 5 seconds. This could enable denial-of-service attacks.
How It Works#
The fuzzing infrastructure has three layers: a shared mutation engine, four per-target harnesses, and a parallel campaign runner.
Mutation Engine
Each iteration takes a valid, known-good cryptographic tuple (public key, secret key, signature, message) and applies one randomly chosen mutation before feeding the result to the target function. The mutation engine uses a seeded pseudorandom number generator, making every run fully deterministic and reproducible given the same seed.
Seven mutation families are available, selected by weighted random draw:
Family | Weight | Description |
|---|---|---|
Bit flips | 35% | 1 to 8 random single-bit flips at random positions in the buffer |
Truncation / Extension | 20% | Resize the buffer by -16 to +16 bytes, filling extensions with random data |
Region fill | 15% | Overwrite a contiguous region with 0x00, 0xFF, or a random constant byte |
Region copy | 10% | Duplicate one region of the buffer onto a different position within the same buffer |
Hint-region targeted | 10% | Focused mutations on the ML-DSA hint encoding: row counts, sort ordering, overflow values |
Donor splice | 5% | Copy a region from a different corpus entry into the current buffer |
Random corrupt | 5% | Overwrite a random-length region with fully random bytes |
Harness: verify-src (Signature Verification)
This harness tests the core cryptoSignVerify() function. Each iteration takes one of 10 base tuples and mutates either the signature (40% of iterations), the public key (20%), the message (20%), the context (10%), or cross-splices components from different tuples (10%). The oracle is simple: verification must return false for any input where at least one byte differs from the original valid tuple. A per-iteration timer catches any call exceeding 5 seconds, and periodic sanity checks confirm that unmodified tuples still verify correctly.
Harness: open-src (Signed-Message Opening)
This harness tests cryptoSignOpen(), which expects a combined buffer of signature || message. Six mutation strategies target different regions: the signature prefix (30%), the message suffix (30%), the boundary between them (15%), truncation near the boundary (10%), public key corruption (10%), and trailing extension (5%). The oracle: the function must return undefined for any mutated input. If it returns a Uint8Array different from the original message, that constitutes a critical forgery.
Harness: unpack-sig (Parser Canonicalization)
This harness tests the low-level unpackSig() and packSig() functions that convert between byte arrays and the internal polynomial representation. Five field-aware mutation strategies target the three regions of an ML-DSA signature: the challenge hash (20%), the z-vector coefficients (25%), and the hint encoding (35%), plus truncation/extension (10%) and full random mutation (10%).
The hint-region mutations are particularly targeted because the hint encoding has strict structural constraints: indices must be sorted within each polynomial row, row-end markers must be monotonically increasing, and the total number of set bits must not exceed OMEGA (75 for ML-DSA-87). The fuzzer specifically tests: duplicate indices within a row, reversed sort order, overflowed row counts, count values exceeding OMEGA, and non-zero padding after the last row.
Three oracles operate on every parser-accepted input: (a) round-trip canonicalization: unpack then repack, and verify the output matches the input byte-for-byte; (b) false-accept-via-parser: if the parser accepts a mutated signature, run full verification to check whether permissive parsing could enable a forgery; (c) determinism: periodically re-parse the same input and confirm identical results.
Harness: verify-dist (Differential Build Testing)
This harness runs the same mutation strategy as verify-src, but calls three implementations simultaneously: the source code (src/), the ESM distribution (dist/mjs/), and the CJS distribution (dist/cjs/). The oracle: all three must return the same boolean result for every input. Any disagreement is flagged as a divergence, which would indicate the build pipeline introduced behavioral differences.
Results#
Campaign 1: Nightly Profile (100,000 iterations per harness)
Harness | Iterations | Duration | False Accepts | Divergences | Canon Drift | Throws | Verdict |
|---|---|---|---|---|---|---|---|
verify-src | 100,000 | 5m 07s | 0 | n/a | n/a | 0 | CLEAN |
open-src | 100,000 | 4m 44s | 0 | n/a | n/a | 0 | CLEAN |
unpack-sig | 100,000 | 3m 35s | 0 | n/a | 0 | 0 | CLEAN |
verify-dist | 100,000 | 11m 40s | 0 | 0 | n/a | 0 | CLEAN |
Campaign 2: Deep Profile (1,000,000 iterations, targeted harnesses)
Harness | Iterations | Duration | False Accepts | Canon Drift | Throws | Verdict |
|---|---|---|---|---|---|---|
verify-src | 1,000,000 | 39m 32s | 0 | n/a | 0 | CLEAN |
unpack-sig | 1,000,000 | 26m 21s | 0 | 0 | 0 | CLEAN |
Cumulative Totals
Metric | Value |
|---|---|
Total mutated inputs tested | 2,400,000 |
Total false accepts (forgeries) | 0 |
Total src/dist divergences | 0 |
Total canonicalization drift | 0 |
Total crashes or throws | 0 |
Total timeouts (per-call >5s) | 0 |
Unique mutation families exercised | 7 |
Unique harness strategies exercised | 17 |
Base corpus tuples across all runs | 40 |
What This Means#
The verification path is robust. The cryptoSignVerify() function correctly rejected over 2.1 million malformed signature, message, and key combinations without a single false accept. This includes bit-flipped signatures, truncated signatures, extended signatures, wrong public keys, wrong messages, wrong contexts, and cross-spliced tuples from unrelated key pairs.
The signed-message opening path is robust. The cryptoSignOpen() function correctly rejected 100,000 malformed signed-messages across 6 distinct mutation strategies, including corruption at the signature/message boundary, truncation, and trailing garbage bytes.
The parser is canonical. Across 1.1 million parser invocations on mutated signatures, there were zero cases where unpackSig() accepted input that packSig() re-serialized differently. This means the parser does not silently normalize non-canonical encodings, which is important for preventing signature malleability.
The build outputs are consistent. The source code, ESM bundle, and CJS bundle produced identical accept/reject decisions on 100,000 mutated inputs. The build pipeline does not introduce behavioral differences.
Limitations#
Fuzzing does not prove absence of bugs. A sufficiently rare mutation combination could still trigger an issue not encountered in 2.4 million iterations.
The mutation engine is random, not coverage-guided. Unlike tools such as AFL or libFuzzer, the fuzzer does not track code coverage or evolve the corpus toward unexplored branches. Some deep code paths may have received less testing than others.
Signing-path correctness was not fuzzed. Key generation, nonce derivation, and rejection sampling were covered by static analysis and property tests in earlier audit phases, not by this fuzzing campaign.
The dilithium5 package was excluded. Only
@theqrl/mldsa87was in scope for this fuzzing effort.
Reproducibility#
All fuzzing runs are fully deterministic. Given the same --seed value, the same base corpus is generated and the same mutation sequence is applied. To reproduce the campaigns:
# Nightly campaign (all 4 harnesses, 100K each)node scripts/fuzz/run-campaign.mjs --profile nightly --seed 1774310297# Deep campaign (targeted, 1M each)node packages/mldsa87/fuzz/verify-src.mjs --seed 1774311831 --iterations 1000000node packages/mldsa87/fuzz/unpack-sig.mjs --seed 1774311832 --iterations 1000000Scope#
Findings Overview#
# | Title | Severity | Score | Status |
|---|---|---|---|---|
HAL-01 | Polynomial Norm Check Produces Incorrect Results for Sufficiently Negative Coefficients | Informational | 1.7 | Solved03/24/2026 |
HAL-02 | Dilithium5 Challenge Generator Accepts Arbitrary-Length Seeds Without Validation | Informational | 1.7 | Solved03/24/2026 |
HAL-03 | ML-DSA Context Parameter Accepts Non-Byte Values Through Implicit Coercion | Informational | 0.5 | Solved03/18/2026 |
HAL-04 | Signing Path Exhibits Material Cross-Key Timing Variability | Informational | 0.1 | Solved03/24/2026 |
HAL-05 | ML-DSA Hedged-Signing Randomness Is Not Explicitly Zeroized After Use | Informational | 0.1 | Solved03/24/2026 |
HAL-06 | Committed Distribution Files Do Not Match Current Source Rebuild | Informational | 0.0 | Solved03/24/2026 |
HAL-07 | Signing Mode Parameter Accepts Non-Boolean Values Through JavaScript Truthiness | Informational | 0.0 | Solved03/24/2026 |
HAL-08 | Secret Key Signing Input Accepts Non-Uint8Array Objects Through Length-Only Validation | Informational | 0.0 | Solved03/24/2026 |
HAL-09 | Whitespace-Only Hex Strings Are Silently Interpreted as Empty Messages | Informational | 0.0 | Solved03/24/2026 |
HAL-10 | Exported Low-Level Unpack Helpers Accept Arbitrary-Length Input Buffers | Informational | 0.0 | Solved03/24/2026 |
HAL-11 | Signature Packer Does Not Validate Hint-Vector Structure Before Encoding | Informational | 0.0 | Solved03/24/2026 |
HAL-12 | Signature Unpacker Writes Partial Output Before Canonical Rejection | Informational | 0.0 | Solved03/24/2026 |
HAL-13 | Low-Level Reduction Helpers Produce Incorrect Results Outside Intended Working Range | Informational | 0.0 | Solved03/24/2026 |
Disclaimer#
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
