Skip to content

ML-DSA Signature.verify on structurally malformed signatures: BC returns false where the JDK provider throws SignatureException — cross-provider divergence worth documenting or aligning #2367

Description

@Arpan0995

Summary

On byte-identical inputs, the BC and JDK (SUN) providers disagree about how to reject a structurally malformed ML-DSA signature: SUN throws SignatureException where BC's Signature.verify() returns false. The divergence is exact and stable — 21 divergent rows (7 structural mutations × 3 parameter sets), identical across every version pairing I tested:

  • BC 1.85 (GA) and 1.86-SNAPSHOT (2026-07-19 beta): return false
  • JDK 24.0.2, 25.0.2, 26.0.1 (SUN, JEP 497): throw SignatureException

I'm not claiming either provider is wrong — the Signature.verify javadoc permits both (it may throw "if the passed-in signature is improperly encoded or of the wrong type"). But the two providers have landed on opposite sides of that latitude, which is a real portability and robustness hazard now that ML-DSA code is being written against both. Filing this so the behaviour can be either documented as policy or aligned, whichever the maintainers prefer.

Method

Differential harness: one reference keypair and one valid signature per parameter set; the public key is re-imported natively into each provider; each mutated signature is then passed to a byte-identical verify() call on both. Harness and raw CSVs: https://github.com/Arpan0995/pqc-provider-differential

Results (ML-DSA-65 shown; ML-DSA-44 and ML-DSA-87 are identical)

signature mutation JDK SUN (24/25/26) BC (1.85 / 1.86-SNAPSHOT) agree?
flip-first-bit (well-formed, wrong) returns false returns false yes
flip-mid-bit (well-formed, wrong) returns false returns false yes
high-bit-first-byte (well-formed, wrong) returns false returns false yes
zero-all (decodes: empty hint vector is valid) returns false returns false yes
flip-last-bit (corrupts hint encoding) throws SignatureException: Invalid hints encoding returns false no
ones-all (correct length, invalid hint encoding) throws SignatureException: Invalid hints encoding returns false no
truncate-1 throws SignatureException: Incorrect signature length returns false no
truncate-half throws SignatureException: Incorrect signature length returns false no
extend-1 throws SignatureException: Incorrect signature length returns false no
extend-block throws SignatureException: Incorrect signature length returns false no
empty throws SignatureException: Incorrect signature length returns false no

The pattern is coherent on both sides: SUN throws exactly when the signature fails FIPS 204 signature decoding (wrong length, or the hint region fails HintBitUnpack), and returns false when the signature is decodable but cryptographically wrong. The zero-all row shows this cleanly — an all-zeros signature of correct length decodes (an empty hint vector is a valid encoding), so SUN returns false like BC; it throws only on genuine decode failure. BC returns false uniformly for both classes. Note the same-length divergent rows: length-checking the input before verify() does not shield an application from the divergence. Exception messages were confirmed identical on JDK 24, 25, and 26.

Why it matters

Verifiers accept attacker-controlled bytes, so the divergent class is exactly the input an attacker chooses. Application code written and tested against BC —

if (sig.verify(candidate)) { ... }   // no try/catch needed on BC

— fails with an uncaught SignatureException when run against SUN on inputs BC quietly rejects (an unexpected-exception/DoS surface). Conversely, code written against SUN that distinguishes structural rejection (exception) from cryptographic rejection (false) — for logging, metrics, or abuse detection — silently loses that distinction on BC. With PQC migrations actively moving JVM workloads between these two providers, this is the kind of quiet behavioural difference that surfaces in production rather than in tests.

Ask

Whichever of these the maintainers think is right, the current state (undocumented divergence) seems like the only wrong option:

  1. Document the BC policy (uniformly return false for undecodable signatures) — even a javadoc line on the provider's Signature behaviour would let developers code portably; or
  2. Align with the JDK's throw-on-undecodable behaviour.

For what it's worth, BC's uniform false is arguably the friendlier contract for verifiers (single rejection path, no exception handling on untrusted input) — the problem is only that the ecosystem now has both behaviours undocumented.

I'm mirroring this to OpenJDK security-dev so both sides see the same data, and I'd be happy to contribute Wycheproof-style test vectors for the structural-malformation class so whichever behaviour is agreed gets pinned by tests. Happy to help with a documentation or alignment PR here too.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions