/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ #include #include #include #include "gtest/gtest.h" #include "cert.h" #include "cryptohi.h" #include "hasht.h" #include "json_reader.h" #include "keyhi.h" #include "nss_scoped_ptrs.h" #include "pk11pub.h" #include "prerror.h" #include "secasn1.h" #include "secerr.h" #include "secitem.h" #include "secoid.h" namespace nss_test { // Wycheproof ML-DSA vectors driven through cryptohi's SEC_SignData and // VFY_VerifyData, which is the layer certificate verification goes through. // The same vectors run against PKCS#11 in gtests/pk11_gtest and against freebl // in gtests/freebl_gtest. // // Two things cryptohi cannot express, so the cases needing them are skipped and // counted rather than quietly passing: // // * A domain-separation context. RFC 9881 requires an ML-DSA // AlgorithmIdentifier's parameters to be absent, so there is nowhere for a // caller to put one; sec_DecodeSigAlg always asks for an empty context. // * Deterministic signing. sec_DecodeSigAlg asks for CKH_HEDGE_PREFERRED, so // a signature cannot be compared against the one in the vector; the // signing tests round-trip through verification instead. struct MlDsaTestVector { uint64_t id; bool valid; std::vector msg; std::vector ctx; std::vector sig; bool has_msg = false; }; static SECItem as_item(const std::vector& v) { SECItem item = {siBuffer, const_cast(v.data()), static_cast(v.size())}; return item; } class CryptohiMlDsaWycheproofTest : public ::testing::Test { protected: typedef std::function Operation; void Run(const std::string& file, SECOidTag oid, const std::string& schema, Operation op) { oid_ = oid; op_ = op; skippedNoContext_ = 0; skippedNoPkcs8_ = 0; WycheproofHeader(file, ParameterSetName(oid), schema, [this](JsonReader& r) { RunGroup(r); }); std::cout << " skipped " << skippedNoContext_ << " case(s) needing a context or an external mu, and " << skippedNoPkcs8_ << " whose key is not given as a PKCS#8" << std::endl; } void Verify(const MlDsaTestVector& t) { ScopedSECKEYPublicKey pub(ImportPublicKey(publicKeyDer_)); if (!pub) { EXPECT_FALSE(t.valid) << "could not import the public key of a valid " "vector: " << PORT_ErrorToString(PORT_GetError()); return; } SECItem sig = as_item(t.sig); SECStatus rv = VFY_VerifyData(t.msg.data(), static_cast(t.msg.size()), pub.get(), &sig, oid_, nullptr); EXPECT_EQ(t.valid ? SECSuccess : SECFailure, rv); } // Signatures are hedged here, so instead of comparing against the expected // signature this signs and then verifies what came out. Vectors that expect // signing to fail still have to fail. void SignAndVerify(const MlDsaTestVector& t) { // Some groups give only a raw seed or expanded key. Wrapping those in a // PKCS#8 is pk11_gtest's job, not this layer's. if (privateKeyPkcs8_.empty()) { skippedNoPkcs8_++; return; } ScopedSECKEYPrivateKey priv(ImportPrivateKey()); if (!priv) { EXPECT_FALSE(t.valid) << "could not import the private key of a valid " "vector: " << PORT_ErrorToString(PORT_GetError()); return; } ScopedSECItem sig(SECITEM_AllocItem(nullptr, nullptr, 0)); ASSERT_TRUE(sig); SECStatus rv = SEC_SignData(sig.get(), t.msg.data(), static_cast(t.msg.size()), priv.get(), oid_); ASSERT_EQ(t.valid ? SECSuccess : SECFailure, rv) << PORT_ErrorToString(PORT_GetError()); if (!t.valid) { return; } ScopedSECKEYPublicKey pub(SECKEY_ConvertToPublicKey(priv.get())); ASSERT_TRUE(pub); EXPECT_EQ(SECSuccess, VFY_VerifyData(t.msg.data(), static_cast(t.msg.size()), pub.get(), sig.get(), oid_, nullptr)) << "a freshly made signature did not verify: " << PORT_ErrorToString(PORT_GetError()); } private: static std::string ParameterSetName(SECOidTag oid) { switch (oid) { case SEC_OID_ML_DSA_44: return "ML-DSA-44"; case SEC_OID_ML_DSA_65: return "ML-DSA-65"; case SEC_OID_ML_DSA_87: return "ML-DSA-87"; default: ADD_FAILURE() << "unsupported parameter set"; return ""; } } ScopedSECKEYPublicKey ImportPublicKey(const std::vector& spki) { SECItem item = as_item(spki); ScopedCERTSubjectPublicKeyInfo info( SECKEY_DecodeDERSubjectPublicKeyInfo(&item)); if (!info) { return nullptr; } return ScopedSECKEYPublicKey(SECKEY_ExtractPublicKey(info.get())); } // Only the groups that carry a PKCS#8 can be used; building one from a raw // key is pk11_gtest's job, not this layer's. ScopedSECKEYPrivateKey ImportPrivateKey() { ScopedPK11SlotInfo slot(PK11_GetInternalSlot()); EXPECT_TRUE(slot); if (!slot) { return nullptr; } SECItem item = as_item(privateKeyPkcs8_); SECKEYPrivateKey* key = nullptr; if (PK11_ImportDERPrivateKeyInfoAndReturnKey(slot.get(), &item, nullptr, nullptr, false, false, KU_ALL, &key, nullptr) != SECSuccess) { return nullptr; } return ScopedSECKEYPrivateKey(key); } static void ReadTestAttr(MlDsaTestVector& t, const std::string& n, JsonReader& r) { if (n == "msg") { t.msg = r.ReadHex(); t.has_msg = true; } else if (n == "ctx") { t.ctx = r.ReadHex(); } else if (n == "sig") { t.sig = r.ReadHex(); } else if (n == "rnd" || n == "mu") { r.SkipValue(); } else { FAIL() << "unsupported test case field: " << n; } } void RunGroup(JsonReader& r) { std::vector tests; publicKeyDer_.clear(); privateKeyPkcs8_.clear(); while (r.NextItem()) { std::string n = r.ReadLabel(); if (n == "") { break; } if (n == "publicKeyDer") { publicKeyDer_ = r.ReadHex(); } else if (n == "privateKeyPkcs8") { privateKeyPkcs8_ = ReadOptionalHex(r); } else if (n == "type" || n == "source" || n == "publicKey" || n == "privateKey" || n == "privateSeed") { r.SkipValue(); } else if (n == "tests") { WycheproofReadTests(r, &tests, ReadTestAttr, false); } else { FAIL() << "unknown group label: " << n; } } for (auto& t : tests) { // A non-empty context cannot be passed through cryptohi, and an // external-mu case has no message to sign or verify. if (!t.ctx.empty() || !t.has_msg) { skippedNoContext_++; continue; } SCOPED_TRACE(testing::Message() << "tcId " << t.id); op_(t); } } static std::vector ReadOptionalHex(JsonReader& r) { if (r.PeekValue() == 'n') { // null r.SkipValue(); return std::vector(); } return r.ReadHex(); } SECOidTag oid_; Operation op_; size_t skippedNoContext_; size_t skippedNoPkcs8_; std::vector publicKeyDer_; std::vector privateKeyPkcs8_; }; #define ML_DSA_WYCHEPROOF_TESTS(name, bits, oid) \ TEST_F(CryptohiMlDsaWycheproofTest, name##Verify) { \ Run("mldsa_" #bits "_verify", oid, "mldsa_verify_schema.json", \ [this](const MlDsaTestVector& t) { Verify(t); }); \ } \ TEST_F(CryptohiMlDsaWycheproofTest, name##Sign) { \ Run("mldsa_" #bits "_sign_seed", oid, "mldsa_sign_seed_schema.json", \ [this](const MlDsaTestVector& t) { SignAndVerify(t); }); \ } // RFC 9881: an ML-DSA AlgorithmIdentifier's parameters MUST be absent. // sec_DecodeSigAlg has to reject anything else rather than ignore it, since a // caller putting something there has asked for something cryptohi is not // doing. class CryptohiMlDsaParamsTest : public ::testing::Test { protected: void SetUp() override { ScopedPK11SlotInfo slot(PK11_GetInternalSlot()); ASSERT_TRUE(slot); CK_ML_DSA_PARAMETER_SET_TYPE paramSet = CKP_ML_DSA_44; SECKEYPublicKey* pub = nullptr; priv_.reset(PK11_GenerateKeyPair(slot.get(), CKM_ML_DSA_KEY_PAIR_GEN, ¶mSet, &pub, PR_FALSE, PR_FALSE, nullptr)); pub_.reset(pub); ASSERT_TRUE(priv_); ASSERT_TRUE(pub_); arena_.reset(PORT_NewArena(DER_DEFAULT_CHUNKSIZE)); ASSERT_TRUE(arena_); sig_.reset(SECITEM_AllocItem(nullptr, nullptr, 0)); ASSERT_TRUE(sig_); ASSERT_EQ(SECSuccess, SEC_SignData(sig_.get(), kMsg, sizeof(kMsg), priv_.get(), SEC_OID_ML_DSA_44)); } // Build an ML-DSA AlgorithmIdentifier, optionally with parameters. SECAlgorithmID AlgorithmID(SECItem* params) { SECAlgorithmID algid = {}; EXPECT_EQ(SECSuccess, SECOID_SetAlgorithmID(arena_.get(), &algid, SEC_OID_ML_DSA_44, params)); return algid; } static const unsigned char kMsg[6]; ScopedPLArenaPool arena_; ScopedSECKEYPrivateKey priv_; ScopedSECKEYPublicKey pub_; ScopedSECItem sig_; }; const unsigned char CryptohiMlDsaParamsTest::kMsg[6] = {'m', 'l', '-', 'd', 's', 'a'}; TEST_F(CryptohiMlDsaParamsTest, AbsentParametersAccepted) { SECAlgorithmID algid = AlgorithmID(nullptr); ASSERT_EQ(0U, algid.parameters.len) << "NSS should encode ML-DSA with absent parameters"; VFYContext* cx = VFY_CreateContextWithAlgorithmID(pub_.get(), sig_.get(), &algid, nullptr, nullptr); ASSERT_TRUE(cx); EXPECT_EQ(SECSuccess, VFY_Begin(cx)); EXPECT_EQ(SECSuccess, VFY_Update(cx, kMsg, sizeof(kMsg))); EXPECT_EQ(SECSuccess, VFY_End(cx)); VFY_DestroyContext(cx, PR_TRUE); SGNContext* sgn = SGN_NewContextWithAlgorithmID(&algid, priv_.get()); EXPECT_TRUE(sgn); if (sgn) { SGN_DestroyContext(sgn, PR_TRUE); } } TEST_F(CryptohiMlDsaParamsTest, PresentParametersRejected) { // A DER NULL, which some encoders emit for algorithms that take no // parameters, is still not absent. unsigned char derNull[] = {SEC_ASN1_NULL, 0}; SECItem params = {siBuffer, derNull, sizeof(derNull)}; SECAlgorithmID algid = AlgorithmID(¶ms); ASSERT_EQ(sizeof(derNull), algid.parameters.len); EXPECT_FALSE(VFY_CreateContextWithAlgorithmID(pub_.get(), sig_.get(), &algid, nullptr, nullptr)); EXPECT_FALSE(SGN_NewContextWithAlgorithmID(&algid, priv_.get())); } ML_DSA_WYCHEPROOF_TESTS(MlDsa44, 44, SEC_OID_ML_DSA_44) ML_DSA_WYCHEPROOF_TESTS(MlDsa65, 65, SEC_OID_ML_DSA_65) ML_DSA_WYCHEPROOF_TESTS(MlDsa87, 87, SEC_OID_ML_DSA_87) // The rest of the cryptohi surface: key encoding, the algorithm-OID lookups, // and the certificate signing and verification entry points. The Wycheproof // tests above decode keys that the vector files hand them and call // SEC_SignData/VFY_VerifyData, so none of this is reached from there. class CryptohiMlDsaKeyTest : public ::testing::TestWithParam { protected: // SECKEY_GetMLDSAPkcs11ParamSetByOidTag is not exported from nss3, so map // the OID here rather than link against it. static CK_ML_DSA_PARAMETER_SET_TYPE Pkcs11ParamSet(SECOidTag oid) { switch (oid) { case SEC_OID_ML_DSA_44: return CKP_ML_DSA_44; case SEC_OID_ML_DSA_65: return CKP_ML_DSA_65; case SEC_OID_ML_DSA_87: return CKP_ML_DSA_87; default: ADD_FAILURE() << "unsupported parameter set"; return CKP_ML_DSA_44; } } void SetUp() override { oid_ = GetParam(); ScopedPK11SlotInfo slot(PK11_GetInternalSlot()); ASSERT_TRUE(slot); CK_ML_DSA_PARAMETER_SET_TYPE paramSet = Pkcs11ParamSet(oid_); SECKEYPublicKey* pub = nullptr; priv_.reset(PK11_GenerateKeyPair(slot.get(), CKM_ML_DSA_KEY_PAIR_GEN, ¶mSet, &pub, PR_FALSE, PR_FALSE, nullptr)); pub_.reset(pub); ASSERT_TRUE(priv_); ASSERT_TRUE(pub_); ASSERT_EQ(mldsaKey, pub_->keyType); ASSERT_EQ(oid_, pub_->u.mldsa.paramSet); sig_.reset(SECITEM_AllocItem(nullptr, nullptr, 0)); ASSERT_TRUE(sig_); ASSERT_EQ(SECSuccess, SEC_SignData(sig_.get(), kMsg, sizeof(kMsg), priv_.get(), oid_)); } static const unsigned char kMsg[6]; // CERT_SignedDataTemplate holds what is signed as an ASN.1 ANY, so what goes // into SEC_DerSignData has to be well formed DER -- a TBSCertificate in real // use. This is an OCTET STRING wrapping kMsg. static const unsigned char kDerMsg[8]; SECOidTag oid_; ScopedSECKEYPrivateKey priv_; ScopedSECKEYPublicKey pub_; ScopedSECItem sig_; }; const unsigned char CryptohiMlDsaKeyTest::kMsg[6] = {'m', 'l', '-', 'd', 's', 'a'}; const unsigned char CryptohiMlDsaKeyTest::kDerMsg[8] = { SEC_ASN1_OCTET_STRING, 0x06, 'm', 'l', '-', 'd', 's', 'a'}; // An ML-DSA public key has no DER structure of its own, so importing one is // just a length-driven parameter set lookup. TEST_P(CryptohiMlDsaKeyTest, ImportRawPublicKey) { SECItem raw = pub_->u.mldsa.publicValue; ScopedSECKEYPublicKey imported(SECKEY_ImportDERPublicKey(&raw, CKK_ML_DSA)); ASSERT_TRUE(imported) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(mldsaKey, imported->keyType); EXPECT_EQ(oid_, imported->u.mldsa.paramSet); EXPECT_EQ(0, SECITEM_CompareItem(&raw, &imported->u.mldsa.publicValue)); // A length that matches no parameter set has to be rejected rather than // guessed at. SECItem truncated = {siBuffer, raw.data, raw.len - 1}; EXPECT_FALSE(SECKEY_ImportDERPublicKey(&truncated, CKK_ML_DSA)); EXPECT_EQ(SEC_ERROR_BAD_KEY, PORT_GetError()); } TEST_P(CryptohiMlDsaKeyTest, SubjectPublicKeyInfoRoundTrip) { ScopedSECItem der(SECKEY_EncodeDERSubjectPublicKeyInfo(pub_.get())); ASSERT_TRUE(der) << PORT_ErrorToString(PORT_GetError()); ScopedCERTSubjectPublicKeyInfo spki( SECKEY_DecodeDERSubjectPublicKeyInfo(der.get())); ASSERT_TRUE(spki) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(oid_, SECOID_GetAlgorithmTag(&spki->algorithm)); // RFC 9881: the parameters component must be absent. EXPECT_EQ(0U, spki->algorithm.parameters.len); ScopedSECKEYPublicKey decoded(SECKEY_ExtractPublicKey(spki.get())); ASSERT_TRUE(decoded) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(mldsaKey, decoded->keyType); EXPECT_EQ(oid_, decoded->u.mldsa.paramSet); EXPECT_EQ(0, SECITEM_CompareItem(&pub_->u.mldsa.publicValue, &decoded->u.mldsa.publicValue)); } // For ML-DSA the "hash" and the signature algorithm are the same OID, since // the parameter set already fixes the hash. TEST_P(CryptohiMlDsaKeyTest, SignatureAlgorithmOidTags) { EXPECT_EQ(oid_, SEC_GetSignatureAlgorithmOidTag(mldsaKey, oid_)); EXPECT_EQ(SEC_OID_UNKNOWN, SEC_GetSignatureAlgorithmOidTag(mldsaKey, SEC_OID_SHA256)); // Given a key, the parameter set is taken from the key and whatever hash the // caller passed is ignored. EXPECT_EQ(oid_, SEC_GetSignatureAlgorithmOidTagByKey(priv_.get(), nullptr, SEC_OID_UNKNOWN)); EXPECT_EQ(oid_, SEC_GetSignatureAlgorithmOidTagByKey(nullptr, pub_.get(), SEC_OID_SHA256)); } // SEC_DerSignData with no algorithm named has to derive it from the key. TEST_P(CryptohiMlDsaKeyTest, DerSignDataDerivesTheParameterSet) { ScopedPLArenaPool arena(PORT_NewArena(DER_DEFAULT_CHUNKSIZE)); ASSERT_TRUE(arena); SECItem signedData = {siBuffer, nullptr, 0}; ASSERT_EQ(SECSuccess, SEC_DerSignData(arena.get(), &signedData, kDerMsg, sizeof(kDerMsg), priv_.get(), SEC_OID_UNKNOWN)) << PORT_ErrorToString(PORT_GetError()); CERTSignedData sd = {}; ASSERT_EQ( SECSuccess, SEC_ASN1DecodeItem(arena.get(), &sd, SEC_ASN1_GET(CERT_SignedDataTemplate), &signedData)); EXPECT_EQ(oid_, SECOID_GetAlgorithmTag(&sd.signatureAlgorithm)); EXPECT_EQ(SECSuccess, CERT_VerifySignedDataWithPublicKey(&sd, pub_.get(), nullptr)) << PORT_ErrorToString(PORT_GetError()); } // CERT_VerifySignedDataWithPublicKey has to reject a key that does not match // the signature algorithm, both when the key is not ML-DSA at all and when it // is the wrong parameter set. TEST_P(CryptohiMlDsaKeyTest, SignedDataRejectsAMismatchedKey) { ScopedPLArenaPool arena(PORT_NewArena(DER_DEFAULT_CHUNKSIZE)); ASSERT_TRUE(arena); SECItem signedData = {siBuffer, nullptr, 0}; ASSERT_EQ(SECSuccess, SEC_DerSignData(arena.get(), &signedData, kDerMsg, sizeof(kDerMsg), priv_.get(), oid_)); CERTSignedData sd = {}; ASSERT_EQ( SECSuccess, SEC_ASN1DecodeItem(arena.get(), &sd, SEC_ASN1_GET(CERT_SignedDataTemplate), &signedData)); // Same key material, relabelled as some other parameter set. SECOidTag otherOid = (oid_ == SEC_OID_ML_DSA_44) ? SEC_OID_ML_DSA_65 : SEC_OID_ML_DSA_44; ScopedSECKEYPublicKey wrongParamSet(SECKEY_CopyPublicKey(pub_.get())); ASSERT_TRUE(wrongParamSet); wrongParamSet->u.mldsa.paramSet = otherOid; EXPECT_EQ(SECFailure, CERT_VerifySignedDataWithPublicKey( &sd, wrongParamSet.get(), nullptr)); // Same key material, relabelled as a different algorithm entirely. ScopedSECKEYPublicKey wrongKeyType(SECKEY_CopyPublicKey(pub_.get())); ASSERT_TRUE(wrongKeyType); wrongKeyType->keyType = ecKey; EXPECT_EQ(SECFailure, CERT_VerifySignedDataWithPublicKey( &sd, wrongKeyType.get(), nullptr)); } // The *Direct entry points take the signature and hash algorithms already // split apart, which is a different mechanism lookup than sec_DecodeSigAlg. // For ML-DSA the parameter set is both halves of the pair, so naming it as // the hash and leaving the hash unnamed both work; nothing else does. TEST_P(CryptohiMlDsaKeyTest, VerifyDataDirect) { EXPECT_EQ(SECSuccess, VFY_VerifyDataDirect(kMsg, sizeof(kMsg), pub_.get(), sig_.get(), oid_, oid_, nullptr, nullptr)) << PORT_ErrorToString(PORT_GetError()); // An unnamed hash resolves to the parameter set, and is reported back as // such through the out parameter. SECOidTag hash = SEC_OID_UNKNOWN; EXPECT_EQ(SECSuccess, VFY_VerifyDataDirect(kMsg, sizeof(kMsg), pub_.get(), sig_.get(), oid_, SEC_OID_UNKNOWN, &hash, nullptr)) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(oid_, hash); // Any other hash is not a rational pairing, so there is no mechanism for it. EXPECT_EQ(SECFailure, VFY_VerifyDataDirect(kMsg, sizeof(kMsg), pub_.get(), sig_.get(), oid_, SEC_OID_SHA256, nullptr, nullptr)); } // ML-DSA signs the message, not a digest of it, so there is nothing for the // digest entry point to do once it has a context. TEST_P(CryptohiMlDsaKeyTest, VerifyDigestDirectIsUnsupported) { unsigned char digest[SHA256_LENGTH]; ASSERT_EQ(SECSuccess, PK11_HashBuf(SEC_OID_SHA256, digest, kMsg, sizeof(kMsg))); SECItem digestItem = {siBuffer, digest, sizeof(digest)}; EXPECT_EQ(SECFailure, VFY_VerifyDigestDirect(&digestItem, pub_.get(), sig_.get(), oid_, oid_, nullptr)); EXPECT_EQ(SEC_ERROR_UNSUPPORTED_KEYALG, PORT_GetError()); } INSTANTIATE_TEST_SUITE_P(CryptohiMlDsaKeyTest, CryptohiMlDsaKeyTest, ::testing::Values(SEC_OID_ML_DSA_44, SEC_OID_ML_DSA_65, SEC_OID_ML_DSA_87)); } // namespace nss_test