I-Zl
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
JA}B65Iz#aI9qa%A
literal 0
HcmV?d00001
diff --git a/test/fixtures/mldsa/ml-dsa-87-empty.sig b/test/fixtures/mldsa/ml-dsa-87-empty.sig
new file mode 100644
index 0000000000000000000000000000000000000000..9eaa71712493938f2fbfe74c58c499f2c7531b11
GIT binary patch
literal 4627
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
J7#|`hEj8lu1$F=c
literal 0
HcmV?d00001
diff --git a/test/fixtures/mldsa/ml-dsa-87-encrypted.pem b/test/fixtures/mldsa/ml-dsa-87-encrypted.pem
new file mode 100644
index 000000000..5b6ab96f5
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-87-encrypted.pem
@@ -0,0 +1,107 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+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
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-87-expanded.pem b/test/fixtures/mldsa/ml-dsa-87-expanded.pem
new file mode 100644
index 000000000..ec7b21952
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-87-expanded.pem
@@ -0,0 +1,105 @@
+-----BEGIN PRIVATE KEY-----
+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
+0BUoI5X5ViSHiU0yww/5Lq2nEytsGGjdg/IbGg==
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-87-public.pem b/test/fixtures/mldsa/ml-dsa-87-public.pem
new file mode 100644
index 000000000..4cae60067
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-87-public.pem
@@ -0,0 +1,57 @@
+-----BEGIN PUBLIC KEY-----
+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
+jLF4EgA4S33VertB8j9GV7RGAxpmLQ==
+-----END PUBLIC KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-87-seed-priv.pem b/test/fixtures/mldsa/ml-dsa-87-seed-priv.pem
new file mode 100644
index 000000000..ba3e5588d
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-87-seed-priv.pem
@@ -0,0 +1,106 @@
+-----BEGIN PRIVATE KEY-----
+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
+TTLDD/kuracTK2wYaN2D8hsa
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-87-seed.pem b/test/fixtures/mldsa/ml-dsa-87-seed.pem
new file mode 100644
index 000000000..e65b8d0c0
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-87-seed.pem
@@ -0,0 +1,4 @@
+-----BEGIN PRIVATE KEY-----
+MDQCAQAwCwYJYIZIAWUDBAMTBCKAIJAWOdONYe+HC2RSDaU/fxEUftnHtZf+CnEp
+7oU8nkOG
+-----END PRIVATE KEY-----
diff --git a/test/pkey/test_pkey_mldsa.rb b/test/pkey/test_pkey_mldsa.rb
new file mode 100644
index 000000000..3caf84e0a
--- /dev/null
+++ b/test/pkey/test_pkey_mldsa.rb
@@ -0,0 +1,179 @@
+require File.expand_path('../test_helper', File.dirname(__FILE__))
+require 'base64'
+
+class TestPKeyMLDSA < TestCase
+ PARAMETERS = [44, 65, 87].freeze
+ FIXTURES = File.expand_path('../fixtures/mldsa', File.dirname(__FILE__))
+
+ def setup
+ super
+ if fips?
+ begin
+ OpenSSL::PKey.generate_key('ML-DSA-44')
+ rescue OpenSSL::PKey::PKeyError => error
+ skip("ML-DSA unavailable from the FIPS provider: #{error.message}")
+ end
+ end
+ end
+
+ def test_generate_and_sign
+ PARAMETERS.each do |parameter|
+ algorithm = "ML-DSA-#{parameter}"
+ key = OpenSSL::PKey.generate_key(algorithm)
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.generate_key(algorithm, 'seed' => 'ignored') }
+ assert_equal key.oid, OpenSSL::PKey.generate_key(algorithm, {}).oid
+ public_key = OpenSSL::PKey.read(key.public_to_der)
+ other = OpenSSL::PKey.generate_key(algorithm)
+ assert_instance_of OpenSSL::PKey::PKey, key
+ assert_equal "id-ml-dsa-#{parameter}", key.oid
+ assert_match(/type_name=#{algorithm}/, key.inspect)
+ assert_equal true, key.private?
+ assert_equal true, key.public?
+ assert_equal false, public_key.private?
+ exported = OpenSSL::ASN1.decode(key.private_to_der)
+ expanded = OpenSSL::ASN1.decode(exported.value[2].value)
+ assert_instance_of OpenSSL::ASN1::OctetString, expanded
+ assert_equal(parameter == 44 ? 2560 : parameter == 65 ? 4032 : 4896, expanded.value.bytesize)
+
+ ['', fixture('binary.bin'), 'prefix-message-suffix'[7, 7]].each do |message|
+ signature = key.sign(nil, message)
+ assert_equal true, public_key.verify(nil, signature, message)
+ assert_equal false, public_key.verify(nil, signature, message + "\0")
+ assert_equal false, other.verify(nil, signature, message)
+ ['', signature[0, signature.bytesize - 1], signature + "\0",
+ "\xff".b * signature.bytesize].each do |bad_signature|
+ assert_equal false, public_key.verify(nil, bad_signature, message)
+ end
+ end
+ assert_raise(ArgumentError) { public_key.sign(nil, 'message') }
+ ['SHA256', OpenSSL::Digest::SHA512.new].each do |digest|
+ assert_raise(OpenSSL::PKey::PKeyError) { key.sign(digest, 'message') }
+ assert_raise(OpenSSL::PKey::PKeyError) { public_key.verify(digest, '', 'message') }
+ end
+ assert_raise(OpenSSL::PKey::PKeyError) { key.derive(public_key) }
+ assert_raise(OpenSSL::PKey::PKeyError) { public_key.private_to_der }
+ assert_raise(OpenSSL::PKey::PKeyError) { key.private_to_pem(OpenSSL::Cipher.new('aes-256-cbc'), 'password') }
+ end
+ end
+
+ %w[seed seed-priv expanded].each do |encoding|
+ define_method("test_independent_openssl_#{encoding.tr('-', '_')}_keys_and_signatures") do
+ PARAMETERS.each do |parameter|
+ public_key = OpenSSL::PKey.read(fixture("ml-dsa-#{parameter}-public.pem"))
+ pem = fixture("ml-dsa-#{parameter}-#{encoding}.pem")
+ [pem, pem_der(pem)].each do |input|
+ key = OpenSSL::PKey.read(input)
+ assert_equal public_key.public_to_der, key.public_to_der
+ assert_equal true, key.private?
+ assert_equal "id-ml-dsa-#{parameter}", key.oid
+ %w[empty binary].each do |message|
+ data = fixture("#{message}.bin")
+ signature = fixture("ml-dsa-#{parameter}-#{message}.sig")
+ assert_equal true, key.verify(nil, signature, data)
+ assert_equal true, public_key.verify(nil, key.sign(nil, data), data)
+ end
+ [key.private_to_der, key.private_to_pem].each do |export|
+ assert_equal public_key.public_to_der, OpenSSL::PKey.read(export).public_to_der
+ end
+ end
+ [public_key.public_to_der, public_key.public_to_pem].each do |export|
+ assert_equal public_key.public_to_der, OpenSSL::PKey.read(export).public_to_der
+ end
+ spki = OpenSSL::ASN1.decode(public_key.public_to_der)
+ assert_equal "2.16.840.1.101.3.4.3.#{parameter == 44 ? 17 : parameter == 65 ? 18 : 19}", spki.value[0].value[0].oid
+ assert_equal "id-ml-dsa-#{parameter}", spki.value[0].value[0].sn
+ assert_equal "ML-DSA-#{parameter}", spki.value[0].value[0].ln
+ end
+ end
+ end
+
+ def test_encrypted_pkcs8_import
+ PARAMETERS.each do |parameter|
+ pem = fixture("ml-dsa-#{parameter}-encrypted.pem")
+ expected = OpenSSL::PKey.read(fixture("ml-dsa-#{parameter}-public.pem"))
+ [pem, pem_der(pem)].each do |input|
+ key = OpenSSL::PKey.read(input, 'mldsa-test-password')
+ assert_equal expected.public_to_der, key.public_to_der
+ assert_equal true, expected.verify(nil, key.sign(nil, ''), '')
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(input, 'wrong-password') }
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(input) }
+ end
+ end
+ end
+
+ def test_malformed_keys
+ ['', 'not a key', "\x30\x03\x02\x01\x00".b].each do |input|
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(input) }
+ end
+ PARAMETERS.each do |parameter|
+ public_der = pem_der(fixture("ml-dsa-#{parameter}-public.pem"))
+ [:short, :long].each do |variant|
+ spki = OpenSSL::ASN1.decode(public_der)
+ bytes = spki.value[1].value
+ spki.value[1].value = variant == :short ? bytes[0, bytes.bytesize - 1] : bytes + "\0"
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(spki.to_der) }
+ end
+ %w[seed expanded public].each do |encoding|
+ der = pem_der(fixture("ml-dsa-#{parameter}-#{encoding}.pem"))
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(der[0, der.bytesize - 1]) }
+ end
+ end
+ end
+
+ def test_invalid_private_key_components
+ PARAMETERS.each do |parameter|
+ original = pem_der(fixture("ml-dsa-#{parameter}-seed-priv.pem"))
+ [31, 33].each do |length|
+ info = OpenSSL::ASN1.decode(original)
+ both = OpenSSL::ASN1.decode(info.value[2].value)
+ both.value[0].value = "\0" * length
+ info.value[2].value = both.to_der
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(info.to_der) }
+ end
+ [:short, :long, :inconsistent].each do |variant|
+ info = OpenSSL::ASN1.decode(original)
+ both = OpenSSL::ASN1.decode(info.value[2].value)
+ expanded = both.value[1].value
+ both.value[1].value = case variant
+ when :short then expanded[0, expanded.bytesize - 1]
+ when :long then expanded + "\0"
+ else "\0" * expanded.bytesize
+ end
+ info.value[2].value = both.to_der
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(info.to_der) }
+ end
+ info = OpenSSL::ASN1.decode(pem_der(fixture("ml-dsa-#{parameter}-seed.pem")))
+ seed = OpenSSL::ASN1.decode(info.value[2].value)
+ seed.tag = 1
+ info.value[2].value = seed.to_der
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(info.to_der) }
+
+ # These malformed standard encodings have the length of a legacy bare key.
+ info.value[2].value = OpenSSL::ASN1::OctetString.new("\0" * 30, 0, :IMPLICIT, :CONTEXT_SPECIFIC).to_der
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(info.to_der) }
+ info = OpenSSL::ASN1.decode(pem_der(fixture("ml-dsa-#{parameter}-expanded.pem")))
+ expanded = OpenSSL::ASN1.decode(info.value[2].value)
+ expanded.value = expanded.value[0, expanded.value.bytesize - 4]
+ info.value[2].value = expanded.to_der
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(info.to_der) }
+
+ info = OpenSSL::ASN1.decode(original)
+ info.value[0].value = 1
+ public_key = OpenSSL::ASN1.decode(pem_der(fixture("ml-dsa-#{parameter}-public.pem"))).value[1].value
+ info.value << OpenSSL::ASN1::BitString.new(public_key, 1, :IMPLICIT, :CONTEXT_SPECIFIC)
+ assert_equal public_key, OpenSSL::ASN1.decode(OpenSSL::PKey.read(info.to_der).public_to_der).value[1].value
+ info.value.last.value = "\0" * public_key.bytesize
+ assert_raise(OpenSSL::PKey::PKeyError) { OpenSSL::PKey.read(info.to_der) }
+ end
+ end
+
+ private
+
+ def fixture(name)
+ File.binread(File.join(FIXTURES, name))
+ end
+
+ def pem_der(pem)
+ Base64.decode64(pem.lines.reject { |line| line.start_with?('-----') }.join)
+ end
+end
diff --git a/test/test_x509_mldsa.rb b/test/test_x509_mldsa.rb
new file mode 100644
index 000000000..f9ce40b34
--- /dev/null
+++ b/test/test_x509_mldsa.rb
@@ -0,0 +1,131 @@
+require_relative 'test_helper'
+
+class TestX509MLDSA < TestCase
+ ALGORITHMS = %w[ML-DSA-44 ML-DSA-65 ML-DSA-87].freeze
+ CA_EXTENSIONS = [
+ ['basicConstraints', 'CA:TRUE', true],
+ ['keyUsage', 'keyCertSign,cRLSign', true]
+ ].freeze
+
+ def test_certificate_public_key_and_roundtrip
+ ALGORITHMS.each do |algorithm|
+ key = generate_key(algorithm)
+ other = generate_key(algorithm)
+ public_key = OpenSSL::PKey.read(key.public_to_der)
+ cert = issue_cert(distinguished_name(algorithm), public_key, 1, [], nil, key, digest: nil)
+
+ [cert, OpenSSL::X509::Certificate.new(cert.to_der),
+ OpenSSL::X509::Certificate.new(cert.to_pem)].each do |copy|
+ assert_equal algorithm, copy.signature_algorithm
+ assert_equal "id-ml-dsa-#{algorithm.split('-').last}", copy.public_key.oid
+ assert_equal key.public_to_der, copy.public_key.public_to_der
+ assert_equal false, copy.public_key.private?
+ assert_equal true, copy.check_private_key(key)
+ assert_equal false, copy.check_private_key(other)
+ assert_equal true, copy.verify(copy.public_key)
+ assert_equal false, copy.verify(other)
+ assert_equal cert.to_der, copy.to_der
+ end
+ end
+ end
+
+ def test_request_sign_verify_and_roundtrip
+ ALGORITHMS.each do |algorithm|
+ key = generate_key(algorithm)
+ other = generate_key(algorithm)
+ request = OpenSSL::X509::Request.new
+ request.version = 0
+ request.subject = distinguished_name(algorithm)
+ request.public_key = OpenSSL::PKey.read(key.public_to_der)
+ assert_same request, request.sign(key, nil)
+
+ [request, OpenSSL::X509::Request.new(request.to_der),
+ OpenSSL::X509::Request.new(request.to_pem)].each do |copy|
+ assert_equal algorithm, copy.signature_algorithm
+ assert_equal "id-ml-dsa-#{algorithm.split('-').last}", copy.public_key.oid
+ assert_equal key.public_to_der, copy.public_key.public_to_der
+ assert_equal false, copy.public_key.private?
+ assert_equal true, copy.verify(copy.public_key)
+ assert_equal false, copy.verify(other)
+ assert_equal request.to_der, copy.to_der
+ end
+
+ request.subject = distinguished_name('modified')
+ assert_equal false, request.verify(key)
+ end
+ end
+
+ def test_crl_sign_verify_and_roundtrip
+ ALGORITHMS.each do |algorithm|
+ key = generate_key(algorithm)
+ other = generate_key(algorithm)
+ ca = issue_cert(distinguished_name(algorithm), key, 1, CA_EXTENSIONS, nil, nil, digest: nil)
+ now = Time.now
+ crl = issue_crl([[2, now - 60, 1]], 1, now - 60, now + 3600, [], ca, key, nil)
+
+ [crl, OpenSSL::X509::CRL.new(crl.to_der),
+ OpenSSL::X509::CRL.new(crl.to_pem)].each do |copy|
+ assert_equal algorithm, copy.signature_algorithm
+ assert_equal true, copy.verify(ca.public_key)
+ assert_equal false, copy.verify(other)
+ assert_equal 2, copy.revoked.first.serial.to_i
+ assert_equal crl.to_der, copy.to_der
+ end
+ end
+ end
+
+ def test_mixed_classical_and_mldsa_chains
+ rsa = Fixtures.pkey('rsa2048')
+ ALGORITHMS.each do |algorithm|
+ key = generate_key(algorithm)
+ [[rsa, key, rsa], [key, rsa, key]].each do |root_key, issuer_key, leaf_key|
+ root_digest = root_key.equal?(rsa) ? 'SHA256' : nil
+ issuer_digest = issuer_key.equal?(rsa) ? 'SHA256' : nil
+ root = issue_cert(distinguished_name('root'), root_key, 1, CA_EXTENSIONS,
+ nil, nil, digest: root_digest)
+ issuer = issue_cert(distinguished_name('intermediate'), issuer_key, 2, CA_EXTENSIONS,
+ root, root_key, digest: root_digest)
+ leaf = issue_cert(distinguished_name('leaf'), leaf_key, 3,
+ [['keyUsage', 'digitalSignature', true]],
+ issuer, issuer_key, digest: issuer_digest)
+ store = OpenSSL::X509::Store.new
+ store.add_cert(OpenSSL::X509::Certificate.new(root.to_der))
+ assert_equal true, store.verify(OpenSSL::X509::Certificate.new(leaf.to_pem),
+ [OpenSSL::X509::Certificate.new(issuer.to_der)]), store.error_string
+ assert_equal true, issuer.verify(root.public_key)
+ assert_equal true, leaf.verify(issuer.public_key)
+ end
+ end
+ end
+
+ def test_signing_rejects_digests
+ ALGORITHMS.each do |algorithm|
+ key = generate_key(algorithm)
+ cert = issue_cert(distinguished_name(algorithm), key, 1, [], nil, nil, digest: nil)
+ request = OpenSSL::X509::Request.new
+ request.version = 0
+ request.subject = distinguished_name(algorithm)
+ request.public_key = OpenSSL::PKey.read(key.public_to_der)
+ crl = issue_crl([], 1, Time.now - 60, Time.now + 3600, [], cert, key, nil)
+
+ ['SHA256', OpenSSL::Digest::SHA512.new].each do |digest|
+ assert_raise(OpenSSL::X509::CertificateError) { cert.sign(key, digest) }
+ assert_raise(OpenSSL::X509::RequestError) { request.sign(key, digest) }
+ assert_raise(OpenSSL::X509::CRLError) { crl.sign(key, digest) }
+ end
+ end
+ end
+
+ private
+
+ def generate_key(algorithm)
+ OpenSSL::PKey.generate_key(algorithm)
+ rescue OpenSSL::PKey::PKeyError => error
+ raise unless fips? && error.message.include?('unsupported algorithm')
+ skip("#{algorithm} unavailable from the FIPS provider: #{error.message}")
+ end
+
+ def distinguished_name(common_name)
+ OpenSSL::X509::Name.new([['CN', common_name]])
+ end
+end