Skip to content

BCPQC Signature.sign() returns the signed-message envelope (message embedded) for MAYO, SNOVA, QRUOV, SQIsign and AIMer, and verify() accepts any trailing bytes for the first four #2403

Description

@Arpan0995

Summary

On 1.86-SNAPSHOT (provider version 1.8599) and current main, the JCA Signature services for every MAYO, SNOVA, QRUOV, SQIsign and AIMer parameter set (74 services in the BCPQC provider) return the NIST crypto_sign "signed message" envelope from Signature.sign(): the complete plaintext message is embedded in the returned signature bytes. For MAYO, SNOVA, QRUOV and SQIsign (67 of them) verify() only checks a lower bound on the length and reads the leading signature-size bytes, so a signature with the trailing message removed, with one byte dropped, or with arbitrary data appended still verifies. The accepted encoding is therefore not unique, which is the class #2401 fixed for AIMer. AIMer now enforces the exact length but still embeds the message. The other on-ramp signers (Faest, HAETAE, MQOM, UOV, SDitH) and Falcon return the bare signature and verify it at its exact length, so this is also an inconsistency within the provider.

Reproduction

Security.addProvider(new BouncyCastlePQCProvider());
KeyPair kp = KeyPairGenerator.getInstance("MAYO-1", "BCPQC").generateKeyPair();
byte[] msg = "sweep message".getBytes();          // 13 bytes
Signature s = Signature.getInstance("MAYO-1", "BCPQC");
s.initSign(kp.getPrivate()); s.update(msg);
byte[] sig = s.sign();                            // 467 bytes = 454 (MAYO-1 signature) + 13
// the last 13 bytes are the message
Arrays.equals(Arrays.copyOfRange(sig, 454, 467), msg);           // true

Signature v = Signature.getInstance("MAYO-1", "BCPQC");
v.initVerify(kp.getPublic()); v.update(msg);
v.verify(Arrays.copyOf(sig, 454));                // true: message stripped
v.initVerify(kp.getPublic()); v.update(msg);
byte[] big = Arrays.copyOf(sig, sig.length + 1000);
new SecureRandom().nextBytes(big); System.arraycopy(sig, 0, big, 0, sig.length);
v.verify(big);                                    // true: 1000 random bytes appended

A 5000-byte message gives a 5454-byte MAYO-1 "signature" and a 5248-byte SNOVA_24_5_4_ESK one. Measured over 157 BCPQC signature services (every MAYO, SNOVA, QRUOV, SQIsign, AIMer, Faest, HAETAE, MQOM, UOV and SDitH parameter set, plus Falcon, XMSS and LMS) with a 13-byte message, flipping individual bytes, truncating, and appending:

family (services) sign() output bytes ignored by verify truncated by 1 / +1 / +1000 random
MAYO (5) signature followed by message 13 (the message) accepted / accepted / accepted
SNOVA (45) signature followed by message 13 accepted / accepted / accepted
QRUOV (13) signature followed by message 13 accepted / accepted / accepted
SQIsign (4) signature followed by message 13 accepted / accepted / accepted
AIMer (7) message followed by signature 13 (the message, at the front) rejected / rejected / rejected
Faest (13), HAETAE (4), MQOM (37), UOV (13), Falcon, SDitH bare signature 0 rejected / rejected / rejected

For the first four families the shortest accepted length is exactly the scheme's signature size (MAYO-1 454, MAYO-3 681, SNOVA_24_5_4 248, QRUOV1Q127L3V156M54 200, sqisign_lvl1 148), and flipping any of the trailing message bytes has no effect on the result. For AIMer the leading 13 bytes are the message, flipping any of them has no effect either, and the bare signature with the message removed is rejected, so a caller cannot strip it.

Root cause

The lightweight signers return the envelope, following the reference crypto_sign API:

  • MayoSigner.generateSignature line 259: return Arrays.concatenate(sig, message); (MAYO-C mayo.c: memmove(sm + param_sig_bytes, m, mlen))
  • SnovaSigner.generateSignature line 104: return Arrays.concatenate(signature, message); (SNOVA sign.c: memcpy(sm + CRYPTO_BYTES, m, mlen))
  • QRUOVSigner.generateSignature line 83: return Arrays.concatenate(sigBytes, message);
  • SQIsignSigner.signLvl1 line 109: return Arrays.concatenate(SQIsignEncodeLvl1.signatureToBytes(sig), message); (likewise lvl3 and lvl5)
  • AIMerSigner with the message in front, the "message followed by the signature" envelope the 1.86 release note for check signed-message length in AIMerSigner.verifySignature #2401 describes.

At the lightweight API this is documented: the MayoSigner.generateSignature javadoc (line 84) says it returns "the signature bytes concatenated with the original message". The JCA layer carries no such note and passes that output straight through: engineSign is return signer.generateSignature(message); in the mayo, snova and aimer SignatureSpi (line 133), qruov (136) and sqisign (127). On the verify side the guard is a lower bound only: MayoSigner line 295 if (signature.length < params.getSigBytes()), SnovaSigner line 115, QRUOVSigner line 92 if (signature.length < params.getSignatureBytes()), SQIsignSigner lines 168, 196 and 222 signature.length < ...SIGNATURE_BYTES. The comment above the MAYO and SNOVA checks says verify "reads only the leading" signature bytes, which is what allows the trailing data through. The 1.86 release note for #2401 describes AIMer's new exact-length check as "matching the guard the Falcon, Faest, Mayo, Snova and QRUOV signers already apply"; for Mayo, Snova and QRUOV the existing guard is the lower bound above, and AIMerSigner line 81 is the only one of the five with !=.

The known-answer tests do not catch this because TestUtils.testTestVector reads the sm field of the NIST .rsp files (line 192) and compares generateSignature output to it directly (line 242), and sm is the same envelope.

Impact

  1. Signature.sign() is expected to return the signature. Callers that store, log or transmit the signature separately from the message (detached signatures, signature-only proofs, audit records) expose the complete message, and the signature grows with the message instead of having the fixed size the scheme defines.
  2. For MAYO, SNOVA, QRUOV and SQIsign the accepted encoding is not unique: unboundedly many byte strings verify for one message and key. That is the non-unique-encoding problem check signed-message length in AIMerSigner.verifySignature #2401 closed for AIMer, still present in 67 services.
  3. Behaviour differs between on-ramp signers in the same provider, so code written against Faest or HAETAE behaves differently on MAYO.

None of this weakens the underlying signature; a valid leading signature is still required. It is an API contract and encoding issue in experimental algorithms.

Suggested fix

Return only the signature from the JCA layer (the leading getSigBytes() bytes for the four that append the message, the trailing bytes for AIMer), or have the lightweight signers return the bare signature and have the KAT harness reconstruct sm for comparison. On verify, require signature.length == sigBytes and return false otherwise, as Faest and the post-#2401 AIMer already do. Happy to send a PR for either shape if that is useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions