Prepared by:
HALBORN
Last Updated 03/31/2026
Date of Engagement: March 16th, 2026 - March 24th, 2026
100% of all REPORTED Findings have been addressed
All findings
13
Critical
0
High
0
Medium
0
Low
0
Informational
13
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.
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 packer packSig) 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.1 npm 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., polyChallenge seed-length tolerance) without clear documentation.
Strengthen type validation at the public API boundary: enforce Uint8Array checks 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 for reduce32(), 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 mldsa87 and dilithium5 so that the security implications of each delta are tracked alongside the code.
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.
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.
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.
The repository is organized as an npm workspace monorepo with Turbo used for orchestration.
package.json: workspace definition, shared scripts, Playwright, Turbo, semantic-release tooling
packages/mldsa87: primary audit target
packages/dilithium5: secondary reference target
.github/workflows: CI, release, cross-verification, provenance, SBOM, and SLSA workflows
scripts/check-shared-files.js: byte-identity check for a subset of shared files
SECURITY.md: documented security assumptions and JavaScript-specific limitations
packages/mldsa87/src contains 13 .js source files plus src/index.d.ts
packages/dilithium5/src contains 13 .js source files plus src/index.d.ts
current source-line baseline:
mldsa87: 1,719 lines of .js source
dilithium5: 1,678 lines of .js source
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 zetas table used by the NTT.
reduce.js
Implements Montgomery reduction and related modular arithmetic helpers.
rounding.js
Implements power2round, decompose, makeHint, and useHint.
ntt.js
Implements forward and inverse number-theoretic transforms.
random.js
Wraps globalThis.crypto.getRandomValues.
utils.js
Provides zeroize() and isZero().
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.
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.js
utils.js
reduce.js
ntt.js
rounding.js
polyvec.js
fips202.js
index.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.
The mldsa87 package implements the ML-DSA-87 parameter set with the following constants:
security level: NIST Level 5
ring dimension: N = 256
modulus: Q = 8380417
module dimensions: K = 8, L = 7
small-coefficient bound: ETA = 2
challenge weight: TAU = 60
norm-related bounds: BETA = 120, GAMMA1 = 2^19, GAMMA2 = (Q - 1) / 32
hint budget: OMEGA = 75
seed lengths: SeedBytes = 32, CRHBytes = 64, TRBytes = 64, RNDBytes = 32
challenge 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.
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.
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:
rho for matrix generation
rhoPrime for sampling s1 and s2
key for later signing use
Expand the public matrix A from rho.
Sample the short secret vectors s1 and s2.
Compute the public relation t = A * s1 + s2.
Decompose t into t1 and t0 using power2round.
Pack the public key as (rho, t1).
Compute tr = SHAKE256(pk).
Pack the secret key as (rho, key, tr, s1, s2, t0).
cryptoSignSignature(sig, m, sk, randomizedSigning, ctx) performs the following high-level sequence:
Unpack the secret key into rho, tr, key, t0, s1, and s2.
Construct pre = 0x00 || len(ctx) || ctx.
Convert the message into bytes.
Compute mu = SHAKE256(tr || pre || m).
Derive rnd:
32 random bytes when randomizedSigning = true
a zero-filled 32-byte array when randomizedSigning = false
Derive rhoPrime = SHAKE256(key || rnd || mu).
Expand the matrix A from rho.
Enter the Fiat-Shamir-with-Aborts rejection loop:
sample a masking vector y
compute w = A * y
decompose w
derive the challenge seed ctilde
compute challenge polynomial c
derive z, w0, and hint vector h
reject if norm or hint-budget conditions fail
Pack the final signature as (ctilde, z, h).
cryptoSignVerify(sig, m, pk, ctx) performs the following high-level sequence:
Validate top-level key and signature lengths.
Unpack pk into rho and t1.
Unpack sig into c, z, and h.
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.
ML-DSA-87 supports an explicit context parameter. In this implementation:
the context is a Uint8Array
the maximum supported length is 255 bytes
the default context is "ZOND"
the context is incorporated into both signing and verification through the pre prefix
This is one of the most important implementation differences between mldsa87 and dilithium5.
@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.
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
No context support
dilithium5 does 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
dilithium5 uses a 32-byte challenge seed in signatures.
mldsa87 uses 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
mldsa87 expands the key-generation seed as SHAKE256(seed || [K, L]).
dilithium5 expands the key-generation seed as SHAKE256(seed).
Different randomized-signing derivation
In mldsa87, randomized signing uses 32 random bytes and derives rhoPrime = SHAKE256(key || rnd || mu).
In dilithium5, randomized signing generates a random 64-byte rhoPrime directly.
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 mldsa87
challenge-seed and randomized-signing behavior differ between the two packages
key-generation seed expansion differs between the two packages
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:
Uint8Array
hex strings, which are normalized by trimming whitespace and accepting an optional 0x prefix
This message-normalization behavior is an implementation detail that matters for API semantics and audit review.
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.
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.
Each package publishes:
CJS bundle in dist/cjs
ESM bundle in dist/mjs
Type 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.
The implementation targets:
Node.js >= 20.19.0
browsers with Web Crypto and ES2020 BigInt
Supported browser assumptions documented in the repo include:
Chrome 67+
Firefox 68+
Safari 14+
Edge 79+
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.
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 >= 16
environments without supported Web Crypto cause an exception
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.
The project includes:
Mocha-based Node test suites
Playwright browser tests in CI
cross-verification workflows against:
go-qrllib
pq-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
The release workflow includes:
CI execution before release
npm publish with --provenance
SBOM generation
GitHub attestation artifacts
SLSA provenance upload
The release job is gated by CI, but not by the separate cross-verification workflow.
The following design choices are especially important to the audit:
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.
The high-level interface is strongly byte-oriented:
keys are packed byte arrays
signatures are packed byte arrays
combined signed messages are signature || message
messages 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.
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.
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.
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
This technical profile establishes the implementation baseline for the audit:
what qrypto.js implements
how mldsa87 and dilithium5 differ
which modules are responsible for the core cryptographic behavior
how the package is exposed, built, tested, and released
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.
Function: cryptoSignKeypair(seed, pk, sk)Source: packages/mldsa87/src/sign.js lines 125-203
seed: 32-byte Uint8Array or null for random generation
pk: caller-provided output buffer, must be exactly 2,592 bytes
sk: caller-provided output buffer, must be exactly 4,896 bytes
pk.length must equal CryptoPublicKeyBytes (2,592)
sk.length must equal CryptoSecretKeyBytes (4,896)
if pk or sk is null, the .length access throws TypeError, which is caught and re-thrown as Error
if seed is provided, seed.length must equal SeedBytes (32)
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 generation
rhoPrime (64 bytes): seed for sampling secret vectors
key (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 from rho.
Sample secret vectors. Sample s1 (L polynomials) and s2 (K polynomials) with small coefficients bounded by ETA = 2, using rhoPrime as the sampling seed.
Compute public relation. Transform s1 into NTT domain, compute t = A * NTT(s1) via Montgomery multiplication, transform back, and add s2.
Decompose t. Apply power2round to split t into high bits t1 and low bits t0.
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 null for random generation.
rhoPrime (64 bytes): used to sample secret vectors; zeroized in finally
key (32 bytes): stored in sk, also zeroized locally in finally
s1, s2 (polynomial vectors): secret lattice vectors; coefficients zeroed in finally
s1hat (NTT-domain copy of s1): coefficients zeroed in finally
t0 (low bits of t): secret component stored in sk; coefficients zeroed in finally
seedBuf (128 bytes): full SHAKE output; zeroized in finally
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).
pk buffer is filled with the 2,592-byte public key
sk buffer is filled with the 4,896-byte secret key
returns the 32-byte seed
throws Error if pk or sk is null or wrong size
throws Error if seed is provided but wrong size
throws if randomBytes() fails (no CSPRNG available)
Function: cryptoSignSignature(sig, m, sk, randomizedSigning, ctx)Source: packages/mldsa87/src/sign.js lines 227-346
sig: caller-provided output buffer, must be at least 4,627 bytes
m: message as Uint8Array or hex string
sk: 4,896-byte secret key
randomizedSigning: boolean flag
ctx: context Uint8Array, 0-255 bytes, defaults to "ZOND"
sig must not be null and must have .length >= CryptoBytes (4,627)
ctx.length must be <= 255
sk.length must equal CryptoSecretKeyBytes (4,896)
Unpack secret key. Extract rho, tr, key, t0, s1, s2 from 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 or Uint8Array.
Derive mu. Compute mu = SHAKE256(tr || pre || mBytes) (64 bytes). This binds the message, context, and public-key hash together.
Derive randomness. If randomizedSigning is true, generate rnd = randomBytes(32). Otherwise, rnd is 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. Transform s1, s2, t0 into 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 rhoPrime using an incrementing nonce counter.
(b) Compute w = A * NTT(y) and transform back to coefficient domain.
(c) Decompose w into high bits w1 and low bits w0.
(d) Derive challenge seed. Pack w1 and compute ctilde = SHAKE256(mu || packed_w1) (64 bytes).
(e) Generate challenge polynomial c from ctilde via polyChallenge.
(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).
key (32 bytes): extracted from sk; zeroized in finally
rhoPrime (64 bytes): nonce master seed; zeroized in finally
s1, s2 (polynomial vectors): secret lattice vectors; coefficients zeroed in finally
t0 (polynomial vector): secret low bits; coefficients zeroed in finally
y (polynomial vector): masking vector; coefficients zeroed in finally
mu (64 bytes): publicly derivable from pk, ctx, and message; not specially zeroized
rnd (32 bytes): random or zero; goes to GC
The finally block zeroizes: key, rhoPrime, s1.coeffs, s2.coeffs, t0.coeffs, y.coeffs.
sig buffer is filled with the 4,627-byte signature
returns 0 on success
throws Error if sig buffer is null or too small
throws Error if ctx exceeds 255 bytes
throws Error if sk is wrong size
throws Error if message cannot be parsed by messageToBytes
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.
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 cryptoSignSignature to fill the signature portion.
Returns the combined signature || message buffer.
The cryptographic behavior is identical to the detached signing flow. The only difference is the output format.
Function: cryptoSignVerify(sig, m, pk, ctx)Source: packages/mldsa87/src/sign.js lines 403-481
sig: 4,627-byte signature
m: message as Uint8Array or hex string
pk: 2,592-byte public key
ctx: context Uint8Array, 0-255 bytes, defaults to "ZOND"
ctx.length > 255: returns false
sig.length !== CryptoBytes (4,627): returns false
pk.length !== CryptoPublicKeyBytes (2,592): returns false
unpackSig returns non-zero (hint ordering or budget violation): returns false
||z||_inf >= GAMMA1 - BETA: returns false
message parsing fails inside try/catch: returns false
Validate lengths. Check context, signature, and public-key sizes. Return false immediately on mismatch.
Unpack public key. Extract rho and t1 from the packed public key.
Unpack signature. Extract challenge seed c, response vector z, and hint vector h. 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, unpackSig returns 1 and verification returns false.
Norm check on z. If ||z||_inf >= GAMMA1 - BETA, return false.
Recompute tr. Derive tr = SHAKE256(pk) (64 bytes).
Construct context prefix. Build pre = 0x00 || len(ctx) || ctx.
Convert message. Call messageToBytes(m) inside a try/catch. If parsing fails, return false.
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)). Transform w1' back to coefficient domain.
Apply hints. Call useHint(w1', h) to recover the verifier-side w1.
Pack w1. Serialize into a byte buffer.
Recompute challenge seed. Derive c2 = SHAKE256(mu || packed_w1) (64 bytes).
Constant-time comparison. Compare c and c2 bytewise using an accumulator: diff |= c[i] ^ c2[i] for all i. Return diff === 0.
Verification does not handle secret keys or nonces. All intermediate values are derived from public inputs (signature, message, public key, context).
returns true if the signature is valid
returns false otherwise
returns false for all validation and verification failures
can throw TypeError if fundamentally malformed types are passed (e.g., null as sig) before the length checks execute
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.
Function: cryptoSignOpen(sm, pk, ctx)Source: packages/mldsa87/src/sign.js lines 501-513
sm: signed message buffer (signature || message)
pk: 2,592-byte public key
ctx: context Uint8Array, 0-255 bytes, defaults to "ZOND"
Length check. If sm.length < CryptoBytes (4,627), return undefined.
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, return undefined.
returns the recovered message Uint8Array on success
returns undefined on failure
returns undefined if the signed message is too short
returns undefined if verification fails
can throw if fundamentally malformed types are passed before the length check
The @theqrl/dilithium5 package follows the same high-level flow structure, but with these differences:
seed expansion uses SHAKE256(seed) without the [K, L] domain separator
no other structural difference
no context parameter; no pre prefix construction
mu = SHAKE256(tr || mBytes) directly (no context binding)
randomized signing generates a random 64-byte rhoPrime directly, rather than deriving it from SHAKE256(key || rnd || mu)
deterministic signing uses rhoPrime = SHAKE256(key || mu)
challenge seed is 32 bytes (SeedBytes) rather than 64 bytes (CTILDEBytes)
no context parameter
mu = SHAKE256(tr || mBytes) directly
challenge seed comparison is over 32 bytes rather than 64 bytes
signature length is 4,595 bytes rather than 4,627 bytes
no context parameter
minimum signed-message length is 4,595 bytes rather than 4,627 bytes
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/catch in verification
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
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
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
ML-DSA-87 binds every signature to a context string through pre = 0x00 || len(ctx) || ctx
Dilithium5 does not support context binding
the default context for ML-DSA-87 is "ZOND"
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.
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.
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.
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.
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.
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.
zeroize and isZero are security helper functions. They require Uint8Array input and throw TypeError otherwise. Callers may rely on them for secret-handling policy.
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.
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.
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.
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?
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Random key generation and randomized signing depend on globalThis.crypto.getRandomValues. The quality and availability of randomness are outside the library's control.
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.
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.
The mldsa87 and dilithium5 packages are similar but not identical. Only 8 of 13 source files are covered by the automated sync check.
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.
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.
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.
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.
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.
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.
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
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.
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.
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 return true when it should return false. 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 return undefined. 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.
The fuzzing infrastructure has three layers: a shared mutation engine, four per-target harnesses, and a parallel campaign runner.
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 |
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.
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.
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.
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.
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 |
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 |
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 |
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.
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/mldsa87 was in scope for this fuzzing effort.
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 1000000| Security analysis | Risk level | Remediation |
|---|---|---|
| Polynomial Norm Check Produces Incorrect Results for Sufficiently Negative Coefficients | Informational | Solved - 03/24/2026 |
| Dilithium5 Challenge Generator Accepts Arbitrary-Length Seeds Without Validation | Informational | Solved - 03/24/2026 |
| ML-DSA Context Parameter Accepts Non-Byte Values Through Implicit Coercion | Informational | Solved - 03/18/2026 |
| Signing Path Exhibits Material Cross-Key Timing Variability | Informational | Solved - 03/24/2026 |
| ML-DSA Hedged-Signing Randomness Is Not Explicitly Zeroized After Use | Informational | Solved - 03/24/2026 |
| Committed Distribution Files Do Not Match Current Source Rebuild | Informational | Solved - 03/24/2026 |
| Signing Mode Parameter Accepts Non-Boolean Values Through JavaScript Truthiness | Informational | Solved - 03/24/2026 |
| Secret Key Signing Input Accepts Non-Uint8Array Objects Through Length-Only Validation | Informational | Solved - 03/24/2026 |
| Whitespace-Only Hex Strings Are Silently Interpreted as Empty Messages | Informational | Solved - 03/24/2026 |
| Exported Low-Level Unpack Helpers Accept Arbitrary-Length Input Buffers | Informational | Solved - 03/24/2026 |
| Signature Packer Does Not Validate Hint-Vector Structure Before Encoding | Informational | Solved - 03/24/2026 |
| Signature Unpacker Writes Partial Output Before Canonical Rejection | Informational | Solved - 03/24/2026 |
| Low-Level Reduction Helpers Produce Incorrect Results Outside Intended Working Range | Informational | Solved - 03/24/2026 |
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.
// Download the full report
QRL (Quantum Resistant Ledger)
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed