Source code

Revision control

Copy as Markdown

Other Tools

/* 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 <functional>
#include <string>
#include <vector>
#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<uint8_t> msg;
std::vector<uint8_t> ctx;
std::vector<uint8_t> sig;
bool has_msg = false;
};
static SECItem as_item(const std::vector<uint8_t>& v) {
SECItem item = {siBuffer, const_cast<uint8_t*>(v.data()),
static_cast<unsigned int>(v.size())};
return item;
}
class CryptohiMlDsaWycheproofTest : public ::testing::Test {
protected:
typedef std::function<void(const MlDsaTestVector&)> 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<int>(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<int>(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<int>(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<uint8_t>& 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<MlDsaTestVector> 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<uint8_t> ReadOptionalHex(JsonReader& r) {
if (r.PeekValue() == 'n') { // null
r.SkipValue();
return std::vector<uint8_t>();
}
return r.ReadHex();
}
SECOidTag oid_;
Operation op_;
size_t skippedNoContext_;
size_t skippedNoPkcs8_;
std::vector<uint8_t> publicKeyDer_;
std::vector<uint8_t> 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,
&paramSet, &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(&params);
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<SECOidTag> {
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,
&paramSet, &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