You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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:
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)
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
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.
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.
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.
Summary
On 1.86-SNAPSHOT (provider version 1.8599) and current
main, the JCASignatureservices for every MAYO, SNOVA, QRUOV, SQIsign and AIMer parameter set (74 services in the BCPQC provider) return the NISTcrypto_sign"signed message" envelope fromSignature.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
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:
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_signAPI:MayoSigner.generateSignatureline 259:return Arrays.concatenate(sig, message);(MAYO-Cmayo.c:memmove(sm + param_sig_bytes, m, mlen))SnovaSigner.generateSignatureline 104:return Arrays.concatenate(signature, message);(SNOVAsign.c:memcpy(sm + CRYPTO_BYTES, m, mlen))QRUOVSigner.generateSignatureline 83:return Arrays.concatenate(sigBytes, message);SQIsignSigner.signLvl1line 109:return Arrays.concatenate(SQIsignEncodeLvl1.signatureToBytes(sig), message);(likewise lvl3 and lvl5)AIMerSignerwith 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.generateSignaturejavadoc (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:engineSignisreturn signer.generateSignature(message);in the mayo, snova and aimerSignatureSpi(line 133), qruov (136) and sqisign (127). On the verify side the guard is a lower bound only:MayoSignerline 295if (signature.length < params.getSigBytes()),SnovaSignerline 115,QRUOVSignerline 92if (signature.length < params.getSignatureBytes()),SQIsignSignerlines 168, 196 and 222signature.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, andAIMerSignerline 81 is the only one of the five with!=.The known-answer tests do not catch this because
TestUtils.testTestVectorreads thesmfield of the NIST.rspfiles (line 192) and comparesgenerateSignatureoutput to it directly (line 242), andsmis the same envelope.Impact
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.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 reconstructsmfor comparison. On verify, requiresignature.length == sigBytesand return false otherwise, as Faest and the post-#2401 AIMer already do. Happy to send a PR for either shape if that is useful.