/* 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 "json_reader.h" #include "keyhi.h" #include "ml_dsat.h" #include "nss_scoped_ptrs.h" #include "pk11priv.h" #include "pk11pub.h" #include "pq_pkcs8.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 PKCS#11 rather than freebl. The // same vectors are run against freebl directly in // gtests/freebl_gtest/mldsa_unittest.cc; here they go in as the DER encoded // keys that the files carry, so they also cover the SubjectPublicKeyInfo and // PKCS#8 decoders, pk11wrap and softoken. struct MlDsaTestVector { uint64_t id; bool valid; std::vector msg; std::vector ctx; std::vector sig; bool has_msg = false; bool has_rnd = false; }; static SECItem as_item(const std::vector& v) { SECItem item = {siBuffer, const_cast(v.data()), static_cast(v.size())}; return item; } class Pkcs11MlDsaWycheproofTest : 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; WycheproofHeader(file, ParameterSetName(oid), schema, [this](JsonReader& r) { RunGroup(r); }); } 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 msg = as_item(t.msg); SECItem sig = as_item(t.sig); SECItem param = SigningParams(t.ctx); SECStatus rv = PK11_VerifyWithMechanism(pub.get(), CKM_ML_DSA, ¶m, &sig, &msg, nullptr); EXPECT_EQ(t.valid ? SECSuccess : SECFailure, rv); } void Sign(const MlDsaTestVector& t) { // Neither external-mu signing (a test case with a mu but no message) nor // hedged signing with caller-supplied randomness can be expressed here. if (!t.has_msg || t.has_rnd) { return; } ScopedSECKEYPrivateKey priv(ImportPrivateKey(Pkcs8())); if (!priv) { EXPECT_FALSE(t.valid) << "could not import the private key of a valid " "vector: " << PORT_ErrorToString(PORT_GetError()); return; } std::vector sigbuf(MAX_ML_DSA_SIGNATURE_LEN); SECItem sig = {siBuffer, sigbuf.data(), (unsigned int)sigbuf.size()}; SECItem msg = as_item(t.msg); SECItem param = SigningParams(t.ctx); SECStatus rv = PK11_SignWithMechanism(priv.get(), CKM_ML_DSA, ¶m, &sig, &msg); ASSERT_EQ(t.valid ? SECSuccess : SECFailure, rv); if (!t.valid) { return; } EXPECT_EQ(t.sig, std::vector(sig.data, sig.data + sig.len)); } 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 ""; } } // Deterministic signing, so that a signature can be compared against the // expected one. Verification ignores the hedge variant but takes the same // parameter structure. SECItem SigningParams(const std::vector& ctx) { signCtx_.hedgeVariant = CKH_DETERMINISTIC_REQUIRED; signCtx_.pContext = const_cast(ctx.data()); signCtx_.ulContextLen = ctx.size(); SECItem param = {siBuffer, (unsigned char*)&signCtx_, sizeof(signCtx_)}; return param; } // The signing key to import. Most groups carry a PKCS#8; the rest give only // a raw seed or a raw expanded key, which have to be wrapped in one. std::vector Pkcs8() { if (!privateKeyPkcs8_.empty()) { return privateKeyPkcs8_; } if (!privateSeed_.empty()) { return BuildPqPkcs8(oid_, PqSeedChoice(privateSeed_)); } return BuildPqPkcs8(oid_, PqExpandedKeyChoice(privateKey_)); } 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())); } ScopedSECKEYPrivateKey ImportPrivateKey(const std::vector& pkcs8) { ScopedPK11SlotInfo slot(PK11_GetInternalSlot()); EXPECT_TRUE(slot); if (!slot || pkcs8.empty()) { return nullptr; } SECItem item = as_item(pkcs8); 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") { r.SkipValue(); t.has_rnd = true; } else if (n == "mu") { r.SkipValue(); } else { FAIL() << "unsupported test case field: " << n; } } void RunGroup(JsonReader& r) { std::vector tests; publicKeyDer_.clear(); privateKeyPkcs8_.clear(); privateKey_.clear(); privateSeed_.clear(); while (r.NextItem()) { std::string n = r.ReadLabel(); if (n == "") { break; } if (n == "publicKeyDer") { publicKeyDer_ = r.ReadHex(); } else if (n == "privateKeyPkcs8") { // Null for groups whose signing key cannot be encoded. privateKeyPkcs8_ = ReadOptionalHex(r); } else if (n == "privateKey") { privateKey_ = r.ReadHex(); } else if (n == "privateSeed") { privateSeed_ = r.ReadHex(); } else if (n == "type" || n == "source" || n == "publicKey") { // publicKey is the raw form of the key taken here as publicKeyDer. r.SkipValue(); } else if (n == "tests") { WycheproofReadTests(r, &tests, ReadTestAttr, false); } else { FAIL() << "unknown group label: " << n; } } for (auto& t : tests) { 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_; CK_SIGN_ADDITIONAL_CONTEXT signCtx_; std::vector publicKeyDer_; std::vector privateKeyPkcs8_; std::vector privateKey_; std::vector privateSeed_; }; #define ML_DSA_WYCHEPROOF_TESTS(name, bits, oid) \ TEST_F(Pkcs11MlDsaWycheproofTest, name##Verify) { \ Run("mldsa_" #bits "_verify", oid, "mldsa_verify_schema.json", \ [this](const MlDsaTestVector& t) { Verify(t); }); \ } \ TEST_F(Pkcs11MlDsaWycheproofTest, name##SignSeed) { \ Run("mldsa_" #bits "_sign_seed", oid, "mldsa_sign_seed_schema.json", \ [this](const MlDsaTestVector& t) { Sign(t); }); \ } \ TEST_F(Pkcs11MlDsaWycheproofTest, name##SignNoSeed) { \ Run("mldsa_" #bits "_sign_noseed", oid, "mldsa_sign_noseed_schema.json", \ [this](const MlDsaTestVector& t) { Sign(t); }); \ } 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) // Lifetime tests for the MLDSAContext that softoken manages during a signing // operation. These should be run under ASAN. class Pkcs11MlDsaLifetimeTest : public ::testing::Test { protected: void SetUp() override { slot_.reset(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_); signCtx_.hedgeVariant = CKH_DETERMINISTIC_REQUIRED; signCtx_.pContext = nullptr; signCtx_.ulContextLen = 0; param_.type = siBuffer; param_.data = (unsigned char*)&signCtx_; param_.len = sizeof(signCtx_); } // C_SignInit happens here, which is where softoken builds the MLDSAContext. PK11Context* StartSigning() { return PK11_CreateContextByPrivKey(CKM_ML_DSA, CKA_SIGN, priv_.get(), ¶m_); } static const unsigned char kMsg[6]; ScopedPK11SlotInfo slot_; ScopedSECKEYPrivateKey priv_; ScopedSECKEYPublicKey pub_; CK_SIGN_ADDITIONAL_CONTEXT signCtx_; SECItem param_; }; const unsigned char Pkcs11MlDsaLifetimeTest::kMsg[6] = {'m', 'l', '-', 'd', 's', 'a'}; TEST_F(Pkcs11MlDsaLifetimeTest, DestroyKeyDuringSignOperation) { ScopedPK11Context ctx(StartSigning()); ASSERT_TRUE(ctx); ASSERT_EQ(SECSuccess, PK11_DestroyObject(slot_.get(), priv_->pkcs11ID)); ASSERT_EQ(SECSuccess, PK11_DigestOp(ctx.get(), kMsg, sizeof(kMsg))); std::vector sigbuf(ML_DSA_44_SIGNATURE_LEN); unsigned int sigLen = 0; ASSERT_EQ(SECSuccess, PK11_DigestFinal(ctx.get(), sigbuf.data(), &sigLen, static_cast(sigbuf.size()))); EXPECT_EQ(static_cast(ML_DSA_44_SIGNATURE_LEN), sigLen); SECItem sig = {siBuffer, sigbuf.data(), sigLen}; SECItem msg = {siBuffer, const_cast(kMsg), sizeof(kMsg)}; EXPECT_EQ(SECSuccess, PK11_VerifyWithMechanism(pub_.get(), CKM_ML_DSA, ¶m_, &sig, &msg, nullptr)); } TEST_F(Pkcs11MlDsaLifetimeTest, AbandonedSignOperationDoesNotLeak) { PK11Context* ctx = StartSigning(); ASSERT_TRUE(ctx); ASSERT_EQ(SECSuccess, PK11_DigestOp(ctx, kMsg, sizeof(kMsg))); PK11_DestroyContext(ctx, PR_TRUE); } // The mechanism and key type tables. These are one-line mappings, but every // caller that has to get from one of these to another goes through them. // PK11_GetKeyMechanism and PK11_GetKeyGenWithSize have ML-DSA arms too, but // neither is exported from nss3, so they cannot be reached from here. TEST(Pkcs11MlDsaMechanismTest, KeyTypeAndMechanismMappings) { EXPECT_EQ(static_cast(CKK_ML_DSA), PK11_GetKeyType(CKM_ML_DSA, 0)); EXPECT_EQ(static_cast(CKK_ML_DSA), PK11_GetKeyType(CKM_ML_DSA_KEY_PAIR_GEN, 0)); EXPECT_EQ(static_cast(CKM_ML_DSA), PK11_MapSignKeyType(mldsaKey)); } // Getting an ML-DSA key into and out of storage: softoken's PKCS#8 packaging // and unwrapping, and the permanent (token) object paths. The Wycheproof tests // above import PKCS#8 blobs that the vector files carry, always as session // objects, so none of this is reached from there. class Pkcs11MlDsaStorageTest : public ::testing::TestWithParam { protected: void SetUp() override { slot_.reset(PK11_GetInternalKeySlot()); ASSERT_TRUE(slot_); ASSERT_EQ(SECSuccess, PK11_Authenticate(slot_.get(), PR_TRUE, nullptr)) << PORT_ErrorToString(PORT_GetError()); CK_ML_DSA_PARAMETER_SET_TYPE paramSet = GetParam(); 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_); static const unsigned char pw[] = "pw"; SECItem pwItem = {siBuffer, const_cast(pw), sizeof(pw)}; password_.reset(SECITEM_DupItem(&pwItem)); ASSERT_TRUE(password_); signCtx_.hedgeVariant = CKH_HEDGE_PREFERRED; signCtx_.pContext = nullptr; signCtx_.ulContextLen = 0; param_.type = siBuffer; param_.data = (unsigned char*)&signCtx_; param_.len = sizeof(signCtx_); } // A key that came back out of storage has to still be the same key, so sign // with it and verify against the public half of the pair it came from. void ExpectPairs(SECKEYPrivateKey* priv, SECKEYPublicKey* pub) { std::vector sigbuf(MAX_ML_DSA_SIGNATURE_LEN); SECItem sig = {siBuffer, sigbuf.data(), static_cast(sigbuf.size())}; SECItem msg = {siBuffer, const_cast(kMsg), sizeof(kMsg)}; ASSERT_EQ(SECSuccess, PK11_SignWithMechanism(priv, CKM_ML_DSA, ¶m_, &sig, &msg)) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(SECSuccess, PK11_VerifyWithMechanism(pub, CKM_ML_DSA, ¶m_, &sig, &msg, nullptr)) << PORT_ErrorToString(PORT_GetError()); } static SECOidTag OidTag(CK_ML_DSA_PARAMETER_SET_TYPE paramSet) { switch (paramSet) { case CKP_ML_DSA_44: return SEC_OID_ML_DSA_44; case CKP_ML_DSA_65: return SEC_OID_ML_DSA_65; case CKP_ML_DSA_87: return SEC_OID_ML_DSA_87; default: ADD_FAILURE() << "unsupported parameter set"; return SEC_OID_UNKNOWN; } } std::vector BuildBothPkcs8(const std::vector& seed, const std::vector& key) { return BuildPqPkcs8(OidTag(GetParam()), PqBothChoice(seed, key)); } SECKEYPrivateKey* ImportPkcs8(const std::vector& pkcs8) { SECItem item = {siBuffer, const_cast(pkcs8.data()), static_cast(pkcs8.size())}; SECKEYPrivateKey* key = nullptr; if (PK11_ImportDERPrivateKeyInfoAndReturnKey( slot_.get(), &item, nullptr, nullptr, PR_FALSE, PR_FALSE, KU_ALL, &key, nullptr) != SECSuccess) { return nullptr; } return key; } static const unsigned char kMsg[6]; ScopedPK11SlotInfo slot_; ScopedSECKEYPrivateKey priv_; ScopedSECKEYPublicKey pub_; ScopedSECItem password_; CK_SIGN_ADDITIONAL_CONTEXT signCtx_; SECItem param_; }; const unsigned char Pkcs11MlDsaStorageTest::kMsg[6] = {'m', 'l', '-', 'd', 's', 'a'}; // C_WrapKey on a private key packages it as a PKCS#8 first, and C_UnwrapKey // takes one apart again. TEST_P(Pkcs11MlDsaStorageTest, WrapAndUnwrap) { ScopedPK11SymKey kek( PK11_KeyGen(slot_.get(), CKM_AES_CBC, nullptr, 16, nullptr)); ASSERT_TRUE(kek); ScopedSECItem wrapParam(PK11_ParamFromIV(CKM_NSS_AES_KEY_WRAP_PAD, nullptr)); ASSERT_TRUE(wrapParam); // Room for the largest signing key plus its seed, PKCS#8 framing and padding. ScopedSECItem wrapped(SECITEM_AllocItem(nullptr, nullptr, 8192)); ASSERT_TRUE(wrapped); ASSERT_EQ(SECSuccess, PK11_WrapPrivKey(slot_.get(), kek.get(), priv_.get(), CKM_NSS_AES_KEY_WRAP_PAD, wrapParam.get(), wrapped.get(), nullptr)) << PORT_ErrorToString(PORT_GetError()); ScopedSECKEYPrivateKey unwrapped(PK11_UnwrapPrivKeyByKeyType( slot_.get(), kek.get(), CKM_NSS_AES_KEY_WRAP_PAD, wrapParam.get(), wrapped.get(), nullptr, &pub_->u.mldsa.publicValue, PR_FALSE, PR_FALSE, mldsaKey, KU_ALL, nullptr)); ASSERT_TRUE(unwrapped) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(mldsaKey, unwrapped->keyType); ExpectPairs(unwrapped.get(), pub_.get()); } // Importing as a permanent object also creates the matching public key object, // which is what makes the key findable later. TEST_P(Pkcs11MlDsaStorageTest, ExportEncryptedAndImportAsTokenKey) { ScopedSECKEYEncryptedPrivateKeyInfo epki(PK11_ExportEncryptedPrivKeyInfo( slot_.get(), SEC_OID_AES_256_CBC, password_.get(), priv_.get(), 1, nullptr)); ASSERT_TRUE(epki) << PORT_ErrorToString(PORT_GetError()); static const unsigned char nick[] = "ml-dsa token key"; SECItem nickname = {siBuffer, const_cast(nick), sizeof(nick)}; SECKEYPrivateKey* imported = nullptr; ASSERT_EQ(SECSuccess, PK11_ImportEncryptedPrivateKeyInfoAndReturnKey( slot_.get(), epki.get(), password_.get(), &nickname, &pub_->u.mldsa.publicValue, PR_TRUE /* isPerm */, PR_TRUE /* isPrivate */, mldsaKey, KU_ALL, &imported, nullptr)) << PORT_ErrorToString(PORT_GetError()); ScopedSECKEYPrivateKey tokenKey(imported); ASSERT_TRUE(tokenKey); EXPECT_EQ(mldsaKey, tokenKey->keyType); ExpectPairs(tokenKey.get(), pub_.get()); EXPECT_EQ(SECSuccess, PK11_DeleteTokenPrivateKey(tokenKey.release(), PR_TRUE)); } // Moving a key onto a slot reads it back out attribute by attribute, so the // attribute list for an ML-DSA private key has to be right. TEST_P(Pkcs11MlDsaStorageTest, LoadPrivKeyOntoASlot) { ScopedSECKEYPrivateKey loaded(PK11_LoadPrivKey( slot_.get(), priv_.get(), pub_.get(), PR_FALSE, PR_FALSE)); ASSERT_TRUE(loaded) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(mldsaKey, loaded->keyType); ExpectPairs(loaded.get(), pub_.get()); } // Copying a token key to a session key is a C_CopyObject on the token side, // which reassembles the key from its stored attributes rather than copying an // in-memory object. TEST_P(Pkcs11MlDsaStorageTest, TokenKeyCopiesToASessionKey) { CK_ML_DSA_PARAMETER_SET_TYPE paramSet = GetParam(); SECKEYPublicKey* pub = nullptr; ScopedSECKEYPrivateKey tokenPriv( PK11_GenerateKeyPair(slot_.get(), CKM_ML_DSA_KEY_PAIR_GEN, ¶mSet, &pub, PR_TRUE /* token */, PR_FALSE, nullptr)); ScopedSECKEYPublicKey tokenPub(pub); ASSERT_TRUE(tokenPriv) << PORT_ErrorToString(PORT_GetError()); ASSERT_TRUE(tokenPub); ScopedSECKEYPrivateKey sessionPriv( PK11_CopyTokenPrivKeyToSessionPrivKey(slot_.get(), tokenPriv.get())); ASSERT_TRUE(sessionPriv) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(mldsaKey, sessionPriv->keyType); ExpectPairs(sessionPriv.get(), tokenPub.get()); EXPECT_EQ(SECSuccess, PK11_DeleteTokenPrivateKey(tokenPriv.release(), PR_TRUE)); EXPECT_EQ(SECSuccess, PK11_DeleteTokenPublicKey(tokenPub.release())); } // RFC 9881 lets a PKCS#8 carry the seed and the expanded key together, which // is the form NSS itself writes. Nothing forces the two to agree, so softoken // re-derives the key from the seed and rejects the pair if they differ. If it // did not, a corrupted or hostile blob would be signed with under a seed that // does not produce it. The Wycheproof vectors cannot reach this: their PKCS#8 // blobs are all seed-only, and the no-seed files carry no PKCS#8 at all. TEST_P(Pkcs11MlDsaStorageTest, BothEncodedKeyChecksSeedAgainstExpandedKey) { ScopedSECItem seed(SECITEM_AllocItem(nullptr, nullptr, 0)); ScopedSECItem value(SECITEM_AllocItem(nullptr, nullptr, 0)); ASSERT_TRUE(seed); ASSERT_TRUE(value); ASSERT_EQ(SECSuccess, PK11_ReadRawAttribute(PK11_TypePrivKey, priv_.get(), CKA_SEED, seed.get())) << PORT_ErrorToString(PORT_GetError()); ASSERT_EQ(SECSuccess, PK11_ReadRawAttribute(PK11_TypePrivKey, priv_.get(), CKA_VALUE, value.get())) << PORT_ErrorToString(PORT_GetError()); ASSERT_EQ(static_cast(ML_DSA_SEED_LEN), seed->len); ASSERT_NE(0U, value->len); std::vector seedBytes(seed->data, seed->data + seed->len); std::vector keyBytes(value->data, value->data + value->len); // The matching pair has to import and work, otherwise the rejections below // would only be telling us the encoding is wrong. ScopedSECKEYPrivateKey good(ImportPkcs8(BuildBothPkcs8(seedBytes, keyBytes))); ASSERT_TRUE(good) << PORT_ErrorToString(PORT_GetError()); EXPECT_EQ(mldsaKey, good->keyType); ExpectPairs(good.get(), pub_.get()); // A seed that expands to something else. std::vector badSeed = seedBytes; badSeed[0] ^= 0x01; EXPECT_FALSE(ImportPkcs8(BuildBothPkcs8(badSeed, keyBytes))); // ...and the same disagreement reached from the other side. std::vector badKey = keyBytes; badKey[0] ^= 0x01; EXPECT_FALSE(ImportPkcs8(BuildBothPkcs8(seedBytes, badKey))); // A truncated expanded key disagrees on length rather than content, which is // the other half of the check. std::vector shortKey(keyBytes.begin(), keyBytes.end() - 1); EXPECT_FALSE(ImportPkcs8(BuildBothPkcs8(seedBytes, shortKey))); } INSTANTIATE_TEST_SUITE_P(Pkcs11MlDsaStorageTest, Pkcs11MlDsaStorageTest, ::testing::Values(CKP_ML_DSA_44, CKP_ML_DSA_65, CKP_ML_DSA_87)); } // namespace nss_test