diff --git a/.github/workflows/ci-bc-compat.yml b/.github/workflows/ci-bc-compat.yml
index 34d7869b..6a7da405 100644
--- a/.github/workflows/ci-bc-compat.yml
+++ b/.github/workflows/ci-bc-compat.yml
@@ -48,3 +48,11 @@ jobs:
- name: invoker test (BC ${{ matrix.bc-version }})
run: ./mvnw -ntp test -f src/it/${{ matrix.bc-version }}/pom.xml
timeout-minutes: 15
+
+ - name: ML-DSA Net::HTTP mutual TLS acceptance (BCJSSE 1.82 / Java 11)
+ if: matrix.bc-version == '1.82' && matrix.java-version == 11
+ env:
+ JAVA_TOOL_OPTIONS: '-Djruby.openssl.load.jars=false'
+ CLASSPATH: 'src/it/1.82/target/dependency/*'
+ run: jruby -rbundler/setup -S rake test:mldsa_tls
+ timeout-minutes: 5
diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml
index dc1d0aab..0a60c815 100644
--- a/.github/workflows/ci-test.yml
+++ b/.github/workflows/ci-test.yml
@@ -102,3 +102,8 @@ jobs:
- name: rake ${{ matrix.test-target }}
run: jruby -rbundler/setup -S rake ${{ matrix.test-target }}
timeout-minutes: 15
+
+ - name: ML-DSA Net::HTTP mutual TLS acceptance
+ if: matrix.runtime.java == 25 && matrix.test-target == 'test'
+ run: jruby -rbundler/setup -S rake test:mldsa_tls
+ timeout-minutes: 5
diff --git a/BUILDING.md b/BUILDING.md
index 939e63f2..61273c24 100644
--- a/BUILDING.md
+++ b/BUILDING.md
@@ -32,6 +32,31 @@ git submodule update --init ruby-openssl
jruby -rbundler/setup -S rake test:mri
```
+The local ML-DSA mutual TLS acceptance test requires BCJSSE 1.82 or later
+with its ML-DSA signature schemes explicitly enabled. This task starts a
+separate JRuby process with the existing JSSE properties set before provider
+initialization:
+
+```
+jruby -rbundler/setup -S rake test:mldsa_tls
+```
+
+It performs actual Net::HTTP GET and POST requests for all three parameter
+sets without selecting a client SSL method. It checks the server's authenticated
+peer certificate and TLS version, and exercises invalid server and client
+identities. It also checks ML-DSA server identities with a default server context.
+CI runs it on Java 25 with the default BC version and on Java 11
+with BCJSSE 1.82; the task does not skip the deployment acceptance test.
+The key and X.509 regressions also run with the regular unit suite.
+
+An optional interoperability test checks that native OpenSSL accepts keys and
+signatures produced by JRuby for all three ML-DSA parameter sets. It requires
+`openssl` 3.5 or later on `PATH` and runs separately from the regular suite:
+
+```
+jruby -rbundler/setup -S rake test:mldsa_openssl
+```
+
### Releasing
* fill in [History.md](History.md) change-log entries for release
diff --git a/History.md b/History.md
index fb8a9190..5cb16a67 100644
--- a/History.md
+++ b/History.md
@@ -1,3 +1,9 @@
+## 0.19.4 (unreleased)
+
+- [feat] add ML-DSA-44/65/87 keys, signatures and X.509 operations with BC 1.80+.
+ Mutual TLS through Net::HTTP requires BCJSSE 1.82+ and explicitly enabled
+ ML-DSA signature schemes; Java requirements remain unchanged
+
## 0.19.3
Broader OpenSSL compatibility across PKCS7, X.509, key handling and big numbers,
diff --git a/README.md b/README.md
index a317e16c..88bd7900 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,79 @@ For older versions of the gem compatibility wasn't reported:
| ~>0.15.x | 9.2.x-10.0.x | Java 8-25 | 1.78-1.83 |
| ~>0.16.x | 9.3.x-10.1.x | Java 8-25 | 1.83-1.86 |
+### ML-DSA
+
+| Feature | Minimum provider version |
+|---------|--------------------------|
+| ML-DSA keys, signatures, certificates, CSRs and CRLs | BC 1.80 |
+| ML-DSA mutual TLS | BCJSSE 1.82 |
+
+Java requirements remain those of the selected JRuby version; Java 25 is not
+required. BCJSSE's ML-DSA signature schemes must be explicitly enabled before
+provider initialization.
+
+Pure ML-DSA-44, ML-DSA-65, and ML-DSA-87 keys use the existing generic
+`OpenSSL::PKey::PKey` API:
+
+```ruby
+key = OpenSSL::PKey.generate_key('ML-DSA-65')
+public_key = OpenSSL::PKey.read(key.public_to_pem)
+signature = key.sign(nil, message)
+public_key.verify(nil, signature, message)
+```
+
+`OpenSSL::PKey.read` accepts PEM/DER SubjectPublicKeyInfo and PKCS#8, including
+password-encrypted PKCS#8. Private keys derive their public key through BC.
+`public_to_pem` / `public_to_der` export SubjectPublicKeyInfo;
+`private_to_pem` / `private_to_der` export unencrypted expanded-only PKCS#8. `oid` reports the
+OpenSSL short name (`id-ml-dsa-65`); certificate, CSR, and CRL signature names
+are `ML-DSA-65`. All three parameter sets support certificate, CSR, and CRL
+signing with a `nil` digest, verification, and PEM/DER serialization.
+
+Signatures use pure ML-DSA with an empty context. Explicit digests, HashML-DSA,
+OpenSSL signature/key-generation options, raw-key operations, key agreement,
+and encrypted private-key export are outside this implementation. PKCS#8 import
+supports standard seed-only, seed-and-expanded, and expanded-only keys.
+Prestandard bare encodings emitted by BC 1.80 are not accepted. Inconsistent
+seed/expanded or private/public components are rejected; unlike OpenSSL's
+default, contradictory expanded data is not ignored in favor of the seed. See the
+independent [test fixtures](test/fixtures/mldsa/README.md).
+
+For Net::HTTP client authentication, a default SSL context with an ML-DSA
+credential automatically uses the existing TLS provider selection, normally
+BCJSSE. No Java-specific application code or `ssl_version` setting is needed.
+BCJSSE disables its ML-DSA signature schemes by default; enable them before
+provider initialization. For example, start JRuby with the existing JSSE
+properties (the classical schemes below allow an RSA server certificate):
+
+```sh
+JRUBY_OPTS='-J-Djdk.tls.client.SignatureSchemes=mldsa44,mldsa65,mldsa87,rsa_pss_rsae_sha256,rsa_pkcs1_sha256' jruby client.rb
+```
+
+```ruby
+require 'net/http'
+require 'openssl'
+
+http = Net::HTTP.new('vault.example', 443)
+http.use_ssl = true
+http.min_version = http.max_version = OpenSSL::SSL::TLS1_3_VERSION
+http.verify_mode = OpenSSL::SSL::VERIFY_PEER
+http.verify_hostname = true
+http.ca_file = 'ca.pem'
+http.cert = OpenSSL::X509::Certificate.new(File.read('client.pem'))
+http.key = OpenSSL::PKey.read(File.read('client-key.pem'))
+response = http.get('/v1/example')
+```
+
+The peer must also enable the corresponding ML-DSA client-authentication
+signature schemes. A JRuby BCJSSE server uses the existing
+`jdk.tls.server.SignatureSchemes` property and `SSLContext.new(:TLS)`.
+Contexts without an ML-DSA credential retain their existing provider selection.
+Explicit SSL methods, protocol bounds, and the `jruby.openssl.provider.ssl`
+property remain effective. TLS support depends on both peers and their policy.
+ML-DSA support in the separately distributed FIPS variant is not established
+by these regular-BC tests.
+
## Security
JRuby-OpenSSL is an essential part of [JRuby](https://www.jruby.org/), please report security vulnerabilities to
diff --git a/Rakefile b/Rakefile
index c1dbd1cd..2356862c 100644
--- a/Rakefile
+++ b/Rakefile
@@ -100,6 +100,20 @@ define_mri_test_task
require_relative 'tasks/provider_tests'
namespace :test do
+ desc 'Check JRuby ML-DSA keys and signatures with native OpenSSL 3.5 or later'
+ task :mldsa_openssl => 'lib/jopenssl.jar' do
+ ruby '-rbundler/setup -Ilib:test integration/mldsa_openssl.rb'
+ end
+
+ desc 'Run local ML-DSA Net::HTTP TLS 1.3 mutual authentication tests with BCJSSE'
+ task :mldsa_tls => 'lib/jopenssl.jar' do
+ # BCJSSE disables standalone PQ signature schemes by default.
+ schemes = 'mldsa44,mldsa65,mldsa87,rsa_pss_rsae_sha256,rsa_pkcs1_sha256'
+ ruby "-J-Djdk.tls.client.SignatureSchemes=#{schemes} " \
+ "-J-Djdk.tls.server.SignatureSchemes=#{schemes} " \
+ '-rbundler/setup -Ilib:test integration/mldsa_http.rb'
+ end
+
namespace :provider do
define_provider_test_task :bc_all,
description: 'Run tests with BC providers registered',
diff --git a/integration/mldsa_http.rb b/integration/mldsa_http.rb
new file mode 100644
index 00000000..de282114
--- /dev/null
+++ b/integration/mldsa_http.rb
@@ -0,0 +1,205 @@
+require_relative '../test/test_helper'
+require 'net/http'
+require 'socket'
+require 'timeout'
+
+class TestMLDSAHTTP < TestCase
+ def setup
+ super
+ @ca_key = Fixtures.pkey('rsa2048')
+ @ca = issue_cert(distinguished_name('trusted CA'), @ca_key, 1,
+ [['basicConstraints', 'CA:TRUE', true],
+ ['keyUsage', 'keyCertSign,cRLSign', true]], nil, nil)
+ @server_key = Fixtures.pkey('rsa2048')
+ @server_cert = server_certificate
+ end
+
+ def test_get_and_post_authenticate_each_mldsa_parameter_set
+ [44, 65, 87].each do |parameter|
+ # Exercise imported keys as Net::HTTP users do, including public derivation.
+ path = File.expand_path("../test/fixtures/mldsa/ml-dsa-#{parameter}-seed.pem", File.dirname(__FILE__))
+ key = OpenSSL::PKey.read(File.binread(path))
+ cert = client_certificate(key)
+ [Net::HTTP::Get.new('/vault'), Net::HTTP::Post.new('/vault')].each do |request|
+ request.body = "binary\0payload\xff".b if request.is_a?(Net::HTTP::Post)
+ with_server do |port, result|
+ response = http(port, cert, key).start { |connection| connection.request(request) }
+ assert_equal '200', response.code
+ peer_der, protocol, method, body = Timeout.timeout(15) { result.pop }
+ raise peer_der if peer_der.is_a?(Exception)
+ assert_equal cert.to_der, peer_der, 'server must authenticate the intended certificate'
+ assert_equal 'TLSv1.3', protocol
+ assert_equal request.method, method
+ assert_equal request.body.to_s, body
+ assert_equal cert.subject.to_s, response.body
+ end
+ end
+ end
+ end
+
+ def test_rejects_untrusted_server_ca
+ key = OpenSSL::PKey.generate_key('ML-DSA-44')
+ with_server do |port, result|
+ connection = http(port, client_certificate(key), key)
+ connection.cert_store = OpenSSL::X509::Store.new
+ assert_raise(OpenSSL::SSL::SSLError) { connection.get('/vault') }
+ assert_server_rejected(result)
+ end
+ end
+
+ def test_mldsa_server_identity_with_default_context
+ client_key = OpenSSL::PKey.generate_key('ML-DSA-44')
+ client_cert = client_certificate(client_key)
+ [44, 65, 87].each do |parameter|
+ @server_key = OpenSSL::PKey.generate_key("ML-DSA-#{parameter}")
+ @server_cert = server_certificate
+ with_server(context: OpenSSL::SSL::SSLContext.new) do |port, result|
+ http(port, client_cert, client_key).start do |connection|
+ assert_equal @server_cert.to_der, connection.peer_cert.to_der
+ assert_equal '200', connection.get('/vault').code
+ end
+ peer_der, protocol = Timeout.timeout(15) { result.pop }
+ raise peer_der if peer_der.is_a?(Exception)
+ assert_equal client_cert.to_der, peer_der
+ assert_equal 'TLSv1.3', protocol
+ end
+ end
+ end
+
+ def test_rejects_wrong_hostname
+ key = OpenSSL::PKey.generate_key('ML-DSA-44')
+ with_server(server_certificate('wrong.example')) do |port, result|
+ assert_raise(OpenSSL::SSL::SSLError) { http(port, client_certificate(key), key).get('/vault') }
+ assert_server_rejected(result)
+ end
+ end
+
+ def test_rejects_expired_server_certificate
+ key = OpenSSL::PKey.generate_key('ML-DSA-44')
+ with_server(server_certificate('localhost', expired: true)) do |port, result|
+ assert_raise(OpenSSL::SSL::SSLError) { http(port, client_certificate(key), key).get('/vault') }
+ assert_server_rejected(result)
+ end
+ end
+
+ def test_rejects_absent_client_identity
+ with_server do |port, result|
+ assert_raise(OpenSSL::SSL::SSLError) { http(port, nil, nil).get('/vault') }
+ assert_server_rejected(result)
+ end
+ end
+
+ def test_rejects_untrusted_client_identity
+ key = OpenSSL::PKey.generate_key('ML-DSA-44')
+ cert = issue_cert(distinguished_name('untrusted client'), key, 99,
+ [['keyUsage', 'digitalSignature', true]], nil, nil, digest: nil)
+ with_server do |port, result|
+ assert_raise(OpenSSL::SSL::SSLError) { http(port, cert, key).get('/vault') }
+ assert_server_rejected(result)
+ end
+ end
+
+ def test_rejects_expired_client_identity
+ key = OpenSSL::PKey.generate_key('ML-DSA-44')
+ cert = client_certificate(key, expired: true)
+ store = trust_store
+ assert_equal false, store.verify(cert)
+ assert_equal OpenSSL::X509::V_ERR_CERT_HAS_EXPIRED, store.error
+ with_server do |port, result|
+ assert_raise(OpenSSL::SSL::SSLError, Errno::ECONNRESET) { http(port, cert, key).get('/vault') }
+ assert_server_rejected(result)
+ end
+ end
+
+ def test_rejects_certificate_private_key_mismatch
+ key = OpenSSL::PKey.generate_key('ML-DSA-44')
+ other = OpenSSL::PKey.generate_key('ML-DSA-44')
+ with_server do |port, result|
+ assert_raise(OpenSSL::SSL::SSLError, Errno::ECONNRESET) { http(port, client_certificate(key), other).get('/vault') }
+ assert_server_rejected(result)
+ end
+ end
+
+ private
+
+ def distinguished_name(value)
+ OpenSSL::X509::Name.new([['CN', value]])
+ end
+
+ def server_certificate(host = 'localhost', expired: false)
+ issue_cert(distinguished_name(host), @server_key, 2,
+ [['keyUsage', 'digitalSignature', true],
+ ['extendedKeyUsage', 'serverAuth', false],
+ ['subjectAltName', "DNS:#{host}", false]], @ca, @ca_key,
+ not_after: expired ? Time.now - 60 : Time.now + 3600)
+ end
+
+ def client_certificate(key, expired: false)
+ issue_cert(distinguished_name("client #{key.oid}"), key, 3,
+ [['keyUsage', 'digitalSignature', true],
+ ['extendedKeyUsage', 'clientAuth', false]], @ca, @ca_key,
+ not_after: expired ? Time.now - 60 : Time.now + 3600)
+ end
+
+ def trust_store
+ OpenSSL::X509::Store.new.tap { |store| store.add_cert(@ca) }
+ end
+
+ def http(port, cert, key)
+ Net::HTTP.new('localhost', port, nil).tap do |connection|
+ connection.use_ssl = true
+ connection.min_version = connection.max_version = OpenSSL::SSL::TLS1_3_VERSION
+ connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
+ connection.verify_hostname = true
+ connection.cert_store = trust_store
+ connection.cert = cert
+ connection.key = key
+ connection.open_timeout = connection.read_timeout = 10
+ connection.max_retries = 0
+ end
+ end
+
+ def with_server(cert = @server_cert, context: OpenSSL::SSL::SSLContext.new(:TLS))
+ context.cert = cert
+ context.key = @server_key
+ context.cert_store = trust_store
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
+ context.min_version = context.max_version = OpenSSL::SSL::TLS1_3_VERSION
+ tcp = TCPServer.new('127.0.0.1', 0)
+ server = OpenSSL::SSL::SSLServer.new(tcp, context)
+ result = Queue.new
+ thread = Thread.new do
+ begin
+ ssl = server.accept
+ request_line = ssl.gets
+ raise EOFError, 'client closed before sending an HTTP request' unless request_line
+ method = request_line.split.first
+ length = 0
+ while (line = ssl.gets) && line != "\r\n"
+ length = line.split(':', 2).last.to_i if line.downcase.start_with?('content-length:')
+ end
+ body = length.zero? ? '' : ssl.read(length)
+ peer = ssl.peer_cert
+ result << [peer.to_der, ssl.ssl_version, method, body]
+ response = peer.subject.to_s
+ ssl.write("HTTP/1.1 200 OK\r\nContent-Length: #{response.bytesize}\r\nConnection: close\r\n\r\n#{response}")
+ rescue => error
+ result << [error]
+ ensure
+ ssl.close if ssl
+ end
+ end
+ yield tcp.addr[1], result
+ ensure
+ tcp.close if tcp && !tcp.closed?
+ if thread
+ thread.kill unless thread.join(15)
+ thread.join
+ end
+ end
+
+ def assert_server_rejected(result)
+ outcome = Timeout.timeout(15) { result.pop }
+ assert_kind_of Exception, outcome.first
+ end
+end
diff --git a/integration/mldsa_openssl.rb b/integration/mldsa_openssl.rb
new file mode 100644
index 00000000..c5845697
--- /dev/null
+++ b/integration/mldsa_openssl.rb
@@ -0,0 +1,42 @@
+require_relative '../test/test_helper'
+require 'open3'
+require 'tmpdir'
+
+class TestMLDSAOpenSSL < TestCase
+ def test_native_openssl_accepts_jruby_keys_and_signatures
+ version = openssl('version')
+ number = version[/\AOpenSSL (\d+\.\d+\.\d+)/, 1]
+ assert(number && Gem::Version.new(number) >= Gem::Version.new('3.5.0'),
+ "OpenSSL 3.5 or later is required; found #{version.strip}")
+ message = File.expand_path('../test/fixtures/mldsa/binary.bin', File.dirname(__FILE__))
+
+ Dir.mktmpdir('mldsa-openssl') do |directory|
+ private_path = File.join(directory, 'private.der')
+ public_path = File.join(directory, 'public.der')
+ signature_path = File.join(directory, 'signature.bin')
+ [44, 65, 87].each do |parameter|
+ key = OpenSSL::PKey.generate_key("ML-DSA-#{parameter}")
+ File.binwrite(private_path, key.private_to_der)
+ File.binwrite(public_path, key.public_to_der)
+ File.binwrite(signature_path, key.sign(nil, File.binread(message)))
+
+ assert_equal key.public_to_der,
+ openssl('pkey', '-inform', 'DER', '-in', private_path, '-pubout', '-outform', 'DER')
+ assert_equal key.public_to_der,
+ openssl('pkey', '-pubin', '-inform', 'DER', '-in', public_path, '-pubout', '-outform', 'DER')
+ openssl('pkeyutl', '-verify', '-pubin', '-keyform', 'DER', '-inkey', public_path,
+ '-in', message, '-sigfile', signature_path)
+ end
+ end
+ end
+
+ private
+
+ def openssl(*arguments)
+ output, error, status = Open3.capture3('openssl', *arguments, binmode: true)
+ assert status.success?, "openssl #{arguments.join(' ')} failed: #{error}#{output}"
+ output
+ rescue Errno::ENOENT
+ flunk 'OpenSSL 3.5 or later must be installed and available on PATH'
+ end
+end
diff --git a/pom.xml b/pom.xml
index 04cf8e88..64298414 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1041,6 +1041,7 @@ DO NOT MODIFY - GENERATED CODE
org/jruby/ext/openssl/PKeyEC.java
org/jruby/ext/openssl/PKeyEdDSA.java
org/jruby/ext/openssl/PKeyHMAC.java
+ org/jruby/ext/openssl/PKeyMLDSA.java
org/jruby/ext/openssl/PKeyRSA.java
org/jruby/ext/openssl/PKeyXDH.java
org/jruby/ext/openssl/Random.java
diff --git a/src/main/java/org/jruby/ext/openssl/ASN1.java b/src/main/java/org/jruby/ext/openssl/ASN1.java
index 533439ac..4518c97b 100644
--- a/src/main/java/org/jruby/ext/openssl/ASN1.java
+++ b/src/main/java/org/jruby/ext/openssl/ASN1.java
@@ -297,6 +297,10 @@ private static void defaultObjects(final Ruby runtime) {
addObject(runtime, 1087, "ED25519", "ED25519", "1.3.101.112");
addObject(runtime, 1088, "ED448", "ED448", "1.3.101.113");
+ addObject(runtime, 1457, "id-ml-dsa-44", "ML-DSA-44", "2.16.840.1.101.3.4.3.17");
+ addObject(runtime, 1458, "id-ml-dsa-65", "ML-DSA-65", "2.16.840.1.101.3.4.3.18");
+ addObject(runtime, 1459, "id-ml-dsa-87", "ML-DSA-87", "2.16.840.1.101.3.4.3.19");
+
addObject(runtime, 660, "street", "streetAddress", "2.5.4.9");
addObject(runtime, 391, "DC", "domainComponent", "0.9.2342.19200300.100.1.25");
//addObject(runtime, 509, null, "generationQualifier", "2.5.4.44");
diff --git a/src/main/java/org/jruby/ext/openssl/PKey.java b/src/main/java/org/jruby/ext/openssl/PKey.java
index 25b9008c..a7685d22 100644
--- a/src/main/java/org/jruby/ext/openssl/PKey.java
+++ b/src/main/java/org/jruby/ext/openssl/PKey.java
@@ -122,6 +122,9 @@ public static PKey newInstance(final Ruby runtime, final PublicKey publicKey) {
if (PKeyXDH.isXDHKey(publicKey)) {
return new PKeyXDH(runtime, publicKey);
}
+ if (PKeyMLDSA.isMLDSAAlgorithm(publicKey.getAlgorithm())) {
+ return new PKeyMLDSA(runtime, publicKey, null);
+ }
if (publicKey instanceof DHPublicKey) {
return new PKeyDH(runtime, (DHPublicKey) publicKey, null);
}
@@ -203,6 +206,9 @@ private static IRubyObject readImpl(final ThreadContext context, IRubyObject...
if ( PKeyXDH.isXDHAlgorithm(alg) ) {
return PKeyXDH.newInstance(runtime, keyPair);
}
+ if ( PKeyMLDSA.isMLDSAAlgorithm(alg) ) {
+ return new PKeyMLDSA(runtime, keyPair.getPublic(), keyPair.getPrivate());
+ }
if ( "DH".equals(alg) ) {
return new PKeyDH(runtime, (DHPublicKey) keyPair.getPublic(), (DHPrivateKey) keyPair.getPrivate());
}
@@ -254,6 +260,9 @@ private static IRubyObject readImpl(final ThreadContext context, IRubyObject...
if (PKeyXDH.isXDHKey(pubKey)) {
return new PKeyXDH(runtime, pubKey);
}
+ if (pubKey != null && PKeyMLDSA.isMLDSAAlgorithm(pubKey.getAlgorithm())) {
+ return new PKeyMLDSA(runtime, pubKey, null);
+ }
if (pubKey instanceof DHPublicKey) {
return new PKeyDH(runtime, (DHPublicKey) pubKey, null);
}
@@ -294,6 +303,12 @@ public static IRubyObject generate_key(final ThreadContext context, IRubyObject
if ( PKeyXDH.isXDHAlgorithm(algorithm) ) {
return PKeyXDH.generate(context, algorithm);
}
+ if ( PKeyMLDSA.isMLDSAAlgorithm(algorithm) ) {
+ if (!options.isNil() && !options.convertToHash().isEmpty()) {
+ throw newPKeyError(runtime, "ML-DSA key generation options are not supported");
+ }
+ return PKeyMLDSA.generate(context, algorithm);
+ }
if ( "RSA".equalsIgnoreCase(algorithm) ) {
return generateRSAKey(context, options);
}
diff --git a/src/main/java/org/jruby/ext/openssl/PKeyMLDSA.java b/src/main/java/org/jruby/ext/openssl/PKeyMLDSA.java
new file mode 100644
index 00000000..37ad5a63
--- /dev/null
+++ b/src/main/java/org/jruby/ext/openssl/PKeyMLDSA.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) 2026 JRuby contributors.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.jruby.ext.openssl;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SignatureException;
+
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
+import org.bouncycastle.jcajce.interfaces.MLDSAPrivateKey;
+import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
+import org.jruby.Ruby;
+import org.jruby.RubyString;
+import org.jruby.runtime.ThreadContext;
+import org.jruby.runtime.builtin.IRubyObject;
+import org.jruby.ext.openssl.x509store.PEMInputOutput;
+import org.jruby.util.ByteList;
+
+import static org.jruby.ext.openssl.OpenSSL.throwOperationErrorOrOriginal;
+import static org.jruby.ext.openssl.util.RubySupport.newString;
+
+/** Pure ML-DSA keys, exposed as OpenSSL::PKey::PKey. */
+public class PKeyMLDSA extends PKey {
+ private final PublicKey publicKey;
+ private final PrivateKey privateKey;
+
+ PKeyMLDSA(Ruby runtime, PublicKey publicKey, PrivateKey privateKey) {
+ super(runtime, PKey._PKey(runtime).getClass("PKey"));
+ this.publicKey = publicKey;
+ this.privateKey = privateKey;
+ final int length = SubjectPublicKeyInfo.getInstance(publicKey.getEncoded()).getPublicKeyData().getOctets().length;
+ final int expected = "ML-DSA-44".equals(publicKey.getAlgorithm()) ? 1312 :
+ "ML-DSA-65".equals(publicKey.getAlgorithm()) ? 1952 : 2592;
+ if (length != expected) throw newPKeyError(runtime, "invalid ML-DSA public key length");
+ }
+
+ static PKeyMLDSA generate(ThreadContext context, String algorithm) {
+ try {
+ KeyPairGenerator generator = SecurityHelper.getKeyPairGenerator("ML-DSA");
+ generator.initialize(MLDSAParameterSpec.fromName(algorithm), OpenSSL.getSecureRandom(context));
+ KeyPair pair = generator.generateKeyPair();
+ return new PKeyMLDSA(context.runtime, pair.getPublic(), pair.getPrivate());
+ }
+ catch (GeneralSecurityException | IllegalArgumentException e) {
+ throw newPKeyError(context.runtime, e);
+ }
+ }
+
+ static boolean isMLDSAAlgorithm(String algorithm) {
+ return "ML-DSA-44".equalsIgnoreCase(algorithm) ||
+ "ML-DSA-65".equalsIgnoreCase(algorithm) ||
+ "ML-DSA-87".equalsIgnoreCase(algorithm);
+ }
+
+ @Override
+ public PublicKey getPublicKey() { return publicKey; }
+
+ @Override
+ public PrivateKey getPrivateKey() { return privateKey; }
+
+ @Override
+ public String getAlgorithm() {
+ return privateKey != null ? privateKey.getAlgorithm() : publicKey.getAlgorithm();
+ }
+
+ // BCJSSE requests the exact parameter set when selecting a TLS credential.
+ @Override
+ public String getKeyType() { return getAlgorithm(); }
+
+ @Override
+ public RubyString oid() {
+ return getRuntime().newString("id-ml-dsa-" + getAlgorithm().substring(7));
+ }
+
+ @Override
+ public IRubyObject sign(IRubyObject digest, IRubyObject data) {
+ final Ruby runtime = getRuntime();
+ if (!isPrivateKey()) throw runtime.newArgumentError("Private key is needed.");
+ if (!digest.isNil()) throw newPKeyError(runtime, "ML-DSA does not support digests");
+ try {
+ return RubyString.newString(runtime, sign(getAlgorithm(), privateKey, data.convertToString().getByteList()));
+ }
+ catch (GeneralSecurityException e) {
+ throw newPKeyError(runtime, e);
+ }
+ catch (Throwable e) {
+ return throwOperationErrorOrOriginal(runtime, e);
+ }
+ }
+
+ @Override
+ public IRubyObject verify(IRubyObject digest, IRubyObject signature, IRubyObject data) {
+ final Ruby runtime = getRuntime();
+ if (!digest.isNil()) throw newPKeyError(runtime, "ML-DSA does not support digests");
+ try {
+ ByteList message = data.convertToString().getByteList();
+ ByteList bytes = signature.convertToString().getByteList();
+ return runtime.newBoolean(verify(getAlgorithm(), publicKey, message, bytes));
+ }
+ catch (SignatureException e) {
+ return runtime.getFalse();
+ }
+ catch (GeneralSecurityException e) {
+ throw newPKeyError(runtime, e);
+ }
+ catch (Throwable e) {
+ return throwOperationErrorOrOriginal(runtime, e);
+ }
+ }
+
+ @Override
+ public RubyString to_der() {
+ return privateKey != null ? private_to_der(getRuntime().getCurrentContext(), IRubyObject.NULL_ARRAY) :
+ newString(getRuntime(), publicKey.getEncoded());
+ }
+
+ @Override
+ public RubyString private_to_der(ThreadContext context, IRubyObject[] args) {
+ if (privateKey == null || args.length > 0) return super.private_to_der(context, args);
+ try {
+ // Always emit standard expanded-only PKCS#8, including with BC 1.80's prestandard encoder.
+ PrivateKeyInfo info = new PrivateKeyInfo(
+ SubjectPublicKeyInfo.getInstance(publicKey.getEncoded()).getAlgorithm(),
+ new DEROctetString(((MLDSAPrivateKey) privateKey).getPrivateData()));
+ return newString(context.runtime, info.getEncoded());
+ }
+ catch (IOException e) {
+ throw newPKeyError(context.runtime, e);
+ }
+ }
+
+ @Override
+ public RubyString private_to_pem(ThreadContext context, IRubyObject[] args) {
+ try {
+ StringWriter writer = new StringWriter();
+ PEMInputOutput.writePKCS8PrivateKey(writer, private_to_der(context, args).getBytes());
+ return context.runtime.newString(writer.toString());
+ }
+ catch (IOException e) {
+ throw newPKeyError(context.runtime, e);
+ }
+ }
+
+ @Override
+ public RubyString to_pem(ThreadContext context, IRubyObject[] args) {
+ return privateKey != null ? private_to_pem(context, args) : public_to_pem(context);
+ }
+
+ @Override
+ public RubyString to_text() {
+ return getRuntime().newString(getAlgorithm() + (privateKey != null ? " Private-Key\n" : " Public-Key\n"));
+ }
+}
diff --git a/src/main/java/org/jruby/ext/openssl/SSLContext.java b/src/main/java/org/jruby/ext/openssl/SSLContext.java
index 18fbf415..6fc42d03 100644
--- a/src/main/java/org/jruby/ext/openssl/SSLContext.java
+++ b/src/main/java/org/jruby/ext/openssl/SSLContext.java
@@ -583,18 +583,19 @@ public RubyArray ciphers(final ThreadContext context) { // SSL_CTX_get_ciphers
}
private CipherListCache matchedCiphersWithCache(final ThreadContext context, final String ciphers) {
+ final String contextProtocol = getContextProtocol();
final CipherListCache cache = cipherListCache;
- if ( protocol.equals(cache.protocol) && ciphers.equals(cache.ciphers) ) {
+ if ( contextProtocol.equals(cache.protocol) && ciphers.equals(cache.ciphers) ) {
return cache;
}
- return cipherListCache = matchedCiphers(context, ciphers);
+ return cipherListCache = matchedCiphers(context, ciphers, contextProtocol);
}
- private CipherListCache matchedCiphers(final ThreadContext context, final String ciphers) {
+ private CipherListCache matchedCiphers(final ThreadContext context, final String ciphers, final String contextProtocol) {
final Ruby runtime = context.runtime;
try {
- final String[] supported = getSupportedCipherSuites(protocol);
+ final String[] supported = getSupportedCipherSuites(contextProtocol);
final Collection cipherDefs =
CipherStrings.matchingCiphers(ciphers, supported, false);
@@ -612,7 +613,7 @@ private CipherListCache matchedCiphers(final ThreadContext context, final String
cipherList.add(cipherInfo(context, def).freeze(context));
legacyCipherCount++;
}
- return new CipherListCache(protocol, ciphers,
+ return new CipherListCache(contextProtocol, ciphers,
runtime.newArrayNoCopy(cipherList.toArray(new IRubyObject[cipherList.size()])), legacyCipherCount);
}
catch (GeneralSecurityException gse) {
@@ -1091,6 +1092,28 @@ private List buildCredentials(final X509Cert cert, final PKey key,
return credentials;
}
+ private String getContextProtocol() {
+ final InternalContext context = internalContext;
+ if (context != null) return context.sslContext.getProtocol();
+ final IRubyObject cert = getInstanceVariable("@cert");
+ final IRubyObject key = getInstanceVariable("@key");
+ return getContextProtocol(buildCredentials(
+ cert instanceof X509Cert ? (X509Cert) cert : t_cert,
+ key instanceof PKey ? (PKey) key : t_key, null));
+ }
+
+ private String getContextProtocol(final List credentials) {
+ if ("SSL".equals(protocol)) {
+ for (Credential credential : credentials) {
+ if (PKeyMLDSA.isMLDSAAlgorithm(credential.keyType)) {
+ // BCJSSE provides ML-DSA credentials through its TLS context.
+ return "TLS";
+ }
+ }
+ }
+ return protocol;
+ }
+
private InternalContext initSSLContext(ThreadContext context,
final Store store, final List clientCA,
final int verifyMode, final IRubyObject verifyCallback, final int verifyDepth, final int timeout,
@@ -1140,7 +1163,7 @@ private class InternalContext {
this.alpnProtocols = alpnProtocols;
this.alpnSelectCallback = alpnSelectCallback;
- this.sslContext = SecurityHelper.getSSLContext(protocol);
+ this.sslContext = SecurityHelper.getSSLContext(getContextProtocol(certsAndKeys));
}
void initSSLContext(final ThreadContext context) throws NoSuchAlgorithmException, KeyManagementException {
diff --git a/src/main/java/org/jruby/ext/openssl/X509CRL.java b/src/main/java/org/jruby/ext/openssl/X509CRL.java
index 59deac27..70c521e1 100644
--- a/src/main/java/org/jruby/ext/openssl/X509CRL.java
+++ b/src/main/java/org/jruby/ext/openssl/X509CRL.java
@@ -709,8 +709,8 @@ private static String getSignatureAlgorithm(final Ruby runtime, final PKey key,
// Have to obey some artificial constraints of the OpenSSL implementation. Stupid.
final String keyAlg = key.getAlgorithm();
- // PureEdDSA (Ed25519/Ed448) — no digest needed
- if (digest.isNil() && PKeyEdDSA.isEdDSAAlgorithm(keyAlg)) {
+ // Pure signatures do not take a digest.
+ if (digest.isNil() && (PKeyEdDSA.isEdDSAAlgorithm(keyAlg) || PKeyMLDSA.isMLDSAAlgorithm(keyAlg))) {
return keyAlg;
}
diff --git a/src/main/java/org/jruby/ext/openssl/X509Cert.java b/src/main/java/org/jruby/ext/openssl/X509Cert.java
index beaeac54..d4ebf793 100644
--- a/src/main/java/org/jruby/ext/openssl/X509Cert.java
+++ b/src/main/java/org/jruby/ext/openssl/X509Cert.java
@@ -283,6 +283,8 @@ private void initialize(final ThreadContext context, final X509Certificate cert)
if ( sigAlgorithm == null ) sigAlgorithm = cert.getSigAlgName(); // e.g. SHA256withRSA
else {
sigAlgorithm = ASN1.oid2name(runtime, new ASN1ObjectIdentifier(sigAlgorithm), true);
+ final String longName = ASN1.o2a(runtime, new ASN1ObjectIdentifier(cert.getSigAlgOID()), true);
+ if (PKeyMLDSA.isMLDSAAlgorithm(longName)) sigAlgorithm = longName;
if (sigAlgorithm == null) {
sigAlgorithm = "0.0"; // "NULL";
// for some certificates that MRI parses,
@@ -746,8 +748,8 @@ public IRubyObject sign(final ThreadContext context, final IRubyObject key, fina
// Have to obey some artificial constraints of the OpenSSL implementation. Stupid.
final String keyAlg = ((PKey) key).getAlgorithm();
final String digAlg; final String digName;
- if (digest.isNil() && PKeyEdDSA.isEdDSAAlgorithm(keyAlg)) {
- // PureEdDSA (Ed25519/Ed448) — no digest needed
+ if (digest.isNil() && (PKeyEdDSA.isEdDSAAlgorithm(keyAlg) || PKeyMLDSA.isMLDSAAlgorithm(keyAlg))) {
+ // Pure signatures do not take a digest.
digAlg = null; digName = null;
}
else if (digest instanceof Digest) {
@@ -809,6 +811,7 @@ else if (digest instanceof RubyString) {
}
String name = ASN1Registry.o2a(certHolder.getSignatureAlgorithm().getAlgorithm());
+ if (name == null) name = ASN1.o2a(runtime, certHolder.getSignatureAlgorithm().getAlgorithm(), true);
if ( name == null ) name = cert.getSigAlgOID();
this.sig_alg = runtime.newString(name);
this.changed = false;
diff --git a/src/main/java/org/jruby/ext/openssl/X509Request.java b/src/main/java/org/jruby/ext/openssl/X509Request.java
index cfb068bb..c432d9e9 100644
--- a/src/main/java/org/jruby/ext/openssl/X509Request.java
+++ b/src/main/java/org/jruby/ext/openssl/X509Request.java
@@ -361,7 +361,8 @@ public IRubyObject signature_algorithm(final ThreadContext context) {
private String getSignatureAlgorithm() {
AlgorithmIdentifier signatureAlgId = request == null ? null : request.getSignatureAlgorithm();
if (signatureAlgId == null) return "NULL";
- final String name = ASN1Registry.o2a(signatureAlgId.getAlgorithm());
+ String name = ASN1Registry.o2a(signatureAlgId.getAlgorithm());
+ if (name == null) name = ASN1.o2a(getRuntime(), signatureAlgId.getAlgorithm(), true);
return name == null ? "" : name;
}
@@ -388,13 +389,17 @@ public IRubyObject sign(final ThreadContext context, final IRubyObject key, fina
try {
request = null;
- if (_digest.isNil() && PKeyEdDSA.isEdDSAAlgorithm(pkey.getAlgorithm())) {
- // PureEdDSA (Ed25519/Ed448) — pass algorithm identifier directly
+ if (_digest.isNil() && (PKeyEdDSA.isEdDSAAlgorithm(pkey.getAlgorithm()) ||
+ PKeyMLDSA.isMLDSAAlgorithm(pkey.getAlgorithm()))) {
+ // Pure signatures take the algorithm identifier directly.
final String keyAlg = pkey.getAlgorithm();
AlgorithmIdentifier algId = new DefaultSignatureAlgorithmIdentifierFinder().find(keyAlg);
getRequest().sign( pkey.getPrivateKey(), algId );
}
else {
+ if (PKeyMLDSA.isMLDSAAlgorithm(pkey.getAlgorithm())) {
+ throw newRequestError(runtime, new IllegalArgumentException("ML-DSA does not support digests"));
+ }
Digest digest = Digest.getDigest(context, _digest);
supportedSignatureAlgorithm(runtime, _RequestError(runtime), public_key, digest);
getRequest().sign( pkey.getPrivateKey(), digest.getShortAlgorithm() );
diff --git a/src/main/java/org/jruby/ext/openssl/impl/PKCS10Request.java b/src/main/java/org/jruby/ext/openssl/impl/PKCS10Request.java
index 5c0528b7..6136e72e 100644
--- a/src/main/java/org/jruby/ext/openssl/impl/PKCS10Request.java
+++ b/src/main/java/org/jruby/ext/openssl/impl/PKCS10Request.java
@@ -245,6 +245,11 @@ else if (X9ObjectIdentifiers.id_dsa.equals(algOID)) {
else if (X9ObjectIdentifiers.id_ecPublicKey.equals(algOID)) {
algorithm = "EC";
}
+ else if (org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_ml_dsa_44.equals(algOID) ||
+ org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_ml_dsa_65.equals(algOID) ||
+ org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_ml_dsa_87.equals(algOID)) {
+ algorithm = "ML-DSA";
+ }
else {
throw new NoSuchAlgorithmException("could not generate public key for request, oid: " + algOID);
}
diff --git a/src/main/java/org/jruby/ext/openssl/impl/PKey.java b/src/main/java/org/jruby/ext/openssl/impl/PKey.java
index 81be7197..feead836 100644
--- a/src/main/java/org/jruby/ext/openssl/impl/PKey.java
+++ b/src/main/java/org/jruby/ext/openssl/impl/PKey.java
@@ -62,13 +62,16 @@
import org.bouncycastle.asn1.ASN1InputStream;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.ASN1OctetString;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.ASN1Sequence;
+import org.bouncycastle.asn1.ASN1TaggedObject;
import org.bouncycastle.asn1.DERBitString;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.DERSequence;
import org.bouncycastle.asn1.DLSequence;
import org.bouncycastle.asn1.edec.EdECObjectIdentifiers;
+import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
@@ -76,7 +79,10 @@
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.asn1.x9.X9ObjectIdentifiers;
import org.bouncycastle.jcajce.interfaces.EdDSAPrivateKey;
+import org.bouncycastle.jcajce.interfaces.MLDSAPrivateKey;
import org.bouncycastle.jcajce.interfaces.XDHPrivateKey;
+import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
+import org.bouncycastle.jcajce.spec.MLDSAPrivateKeySpec;
import org.bouncycastle.openssl.PEMParser;
import org.jruby.ext.openssl.SecurityHelper;
@@ -90,7 +96,7 @@
*/
public class PKey {
- public enum Type { RSA, DSA, EC, EdDSA, XDH, DH; }
+ public enum Type { RSA, DSA, EC, EdDSA, XDH, DH, MLDSA; }
public static KeyPair readPrivateKey(final Type type, final byte[] input)
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
@@ -158,6 +164,8 @@ public static KeyPair readPrivateKey(final Type type, final PrivateKeyInfo keyIn
return readEdDSAPrivateKey(keyInfo);
case XDH:
return readXDHPrivateKey(keyInfo);
+ case MLDSA:
+ return readMLDSAPrivateKey(keyInfo);
case DH:
return readDHPrivateKey(keyInfo);
default:
@@ -210,6 +218,9 @@ private static Type matchPublicKeyType(final AlgorithmIdentifier algId) throws I
if (EdECObjectIdentifiers.id_Ed448.equals(algIdentifier)) return Type.EdDSA;
if (EdECObjectIdentifiers.id_X25519.equals(algIdentifier)) return Type.XDH;
if (EdECObjectIdentifiers.id_X448.equals(algIdentifier)) return Type.XDH;
+ if (NISTObjectIdentifiers.id_ml_dsa_44.equals(algIdentifier) ||
+ NISTObjectIdentifiers.id_ml_dsa_65.equals(algIdentifier) ||
+ NISTObjectIdentifiers.id_ml_dsa_87.equals(algIdentifier)) return Type.MLDSA;
if (PKCSObjectIdentifiers.dhKeyAgreement.equals(algIdentifier)) return Type.DH;
throw new IOException("unsupported public key algorithm: " + algIdentifier);
@@ -355,6 +366,51 @@ private static KeyPair readDHPrivateKey(final PrivateKeyInfo keyInfo)
return new KeyPair(publicKey, privateKey);
}
+ private static KeyPair readMLDSAPrivateKey(final PrivateKeyInfo keyInfo)
+ throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
+ final ASN1ObjectIdentifier oid = keyInfo.getPrivateKeyAlgorithm().getAlgorithm();
+ final int parameter = NISTObjectIdentifiers.id_ml_dsa_44.equals(oid) ? 44 :
+ NISTObjectIdentifiers.id_ml_dsa_65.equals(oid) ? 65 : 87;
+ final int expandedLength = parameter == 44 ? 2560 : parameter == 65 ? 4032 : 4896;
+ final ASN1Encodable encoded = keyInfo.parsePrivateKey();
+ byte[] seed = null;
+ byte[] expanded = null;
+ if (encoded instanceof ASN1TaggedObject) {
+ ASN1TaggedObject tagged = (ASN1TaggedObject) encoded;
+ if (!tagged.hasContextTag(0) || tagged.isExplicit()) throw new InvalidKeySpecException("invalid ML-DSA seed tag");
+ seed = ASN1OctetString.getInstance(tagged, false).getOctets();
+ }
+ else if (encoded instanceof ASN1Sequence) {
+ ASN1Sequence both = (ASN1Sequence) encoded;
+ if (both.size() != 2) throw new InvalidKeySpecException("invalid ML-DSA private key sequence");
+ seed = ASN1OctetString.getInstance(both.getObjectAt(0)).getOctets();
+ expanded = ASN1OctetString.getInstance(both.getObjectAt(1)).getOctets();
+ }
+ else {
+ expanded = ASN1OctetString.getInstance(encoded).getOctets();
+ }
+ if (expanded != null && expanded.length != expandedLength) {
+ throw new InvalidKeySpecException("invalid ML-DSA expanded key length");
+ }
+ final MLDSAParameterSpec parameters = MLDSAParameterSpec.fromName("ML-DSA-" + parameter);
+ // BC 1.80 misreads standardized seed encodings; its KeySpec works across supported BCs.
+ final KeySpec spec = seed != null ? new MLDSAPrivateKeySpec(parameters, seed) :
+ new MLDSAPrivateKeySpec(parameters, expanded, null);
+ final PrivateKey privateKey = SecurityHelper.getKeyFactory("ML-DSA").generatePrivate(spec);
+ if (!(privateKey instanceof MLDSAPrivateKey)) {
+ throw new InvalidKeySpecException("ML-DSA public key derivation requires a BC private key");
+ }
+ final MLDSAPrivateKey key = (MLDSAPrivateKey) privateKey;
+ if (expanded != null && !org.bouncycastle.util.Arrays.constantTimeAreEqual(expanded, key.getPrivateData())) {
+ throw new InvalidKeySpecException("ML-DSA seed does not match expanded key");
+ }
+ if (keyInfo.hasPublicKey() && !org.bouncycastle.util.Arrays.constantTimeAreEqual(
+ keyInfo.getPublicKeyData().getOctets(), key.getPublicKey().getPublicData())) {
+ throw new InvalidKeySpecException("ML-DSA public key does not match private key");
+ }
+ return new KeyPair(key.getPublicKey(), privateKey);
+ }
+
// XDH (X25519 / X448)
private static KeyPair readXDHPrivateKey(final PrivateKeyInfo keyInfo)
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
diff --git a/src/main/java/org/jruby/ext/openssl/x509store/PEMInputOutput.java b/src/main/java/org/jruby/ext/openssl/x509store/PEMInputOutput.java
index bcc2ea6f..0e845174 100644
--- a/src/main/java/org/jruby/ext/openssl/x509store/PEMInputOutput.java
+++ b/src/main/java/org/jruby/ext/openssl/x509store/PEMInputOutput.java
@@ -1475,6 +1475,12 @@ public static KeyFactory getKeyFactory(final AlgorithmIdentifier algId)
private static Type getPrivateKeyType(final AlgorithmIdentifier algId) {
final ASN1ObjectIdentifier algIdentifier = algId.getAlgorithm();
+ if (org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_ml_dsa_44.equals(algIdentifier) ||
+ org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_ml_dsa_65.equals(algIdentifier) ||
+ org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_ml_dsa_87.equals(algIdentifier)) {
+ return Type.MLDSA;
+ }
+
if (X9ObjectIdentifiers.id_ecPublicKey.equals(algIdentifier)) {
return Type.EC;
}
diff --git a/src/test/java/org/jruby/ext/openssl/SSLContextTest.java b/src/test/java/org/jruby/ext/openssl/SSLContextTest.java
new file mode 100644
index 00000000..88a4676c
--- /dev/null
+++ b/src/test/java/org/jruby/ext/openssl/SSLContextTest.java
@@ -0,0 +1,129 @@
+package org.jruby.ext.openssl;
+
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.TrustManager;
+
+import org.jruby.RubyArray;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class SSLContextTest extends OpenSSLHelper {
+ @BeforeEach
+ public void setUp() throws Exception {
+ setUpRuntime();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ tearDownRuntime();
+ }
+
+ @Test
+ public void mldsaCredentialSourcesUseTlsProvider() throws Exception {
+ for (int parameter : new int[] {44, 65, 87}) {
+ loadMLDSAIdentity(parameter);
+ for (String credentials : new String[] {
+ "ctx.cert = $cert; ctx.key = $key",
+ "ctx.add_certificate($cert, $key)",
+ "ctx.client_cert_cb = proc { [$cert, $key] }"}) {
+ SSLContext context = context("ctx = OpenSSL::SSL::SSLContext.new; " + credentials + "; ctx");
+ assertEquals(engineClass("TLS"), context.createSSLEngine(null, 0).getClass());
+ assertEquals("SSL", context.getProtocol(), "provider selection must not change the SSL method");
+ }
+ }
+ }
+
+ @Test
+ public void cipherQueriesFollowMldsaCredentials() {
+ loadMLDSAIdentity(44);
+ RubyArray tlsCiphers = (RubyArray) runtime.evalScriptlet("OpenSSL::SSL::SSLContext.new(:TLS).ciphers");
+ RubyArray sslCiphers = (RubyArray) runtime.evalScriptlet("OpenSSL::SSL::SSLContext.new.ciphers");
+ for (String credentials : new String[] {
+ "$ctx.cert = $cert; $ctx.key = $key",
+ "$ctx.add_certificate($cert, $key)",
+ "$ctx.client_cert_cb = proc { $calls += 1; [$cert, $key] }"}) {
+ SSLContext context = (SSLContext) runtime.evalScriptlet(
+ "$calls = 0; $ctx = OpenSSL::SSL::SSLContext.new; $ctx.ciphers; " + credentials + "; $ctx");
+ boolean callback = credentials.contains("client_cert_cb");
+ assertEquals(callback ? sslCiphers : tlsCiphers, context.ciphers(currentContext()));
+ assertEquals("0", runtime.evalScriptlet("$calls").toString(), "cipher queries must not call client_cert_cb");
+ context.doSetup(currentContext());
+ assertEquals(tlsCiphers, context.ciphers(currentContext()));
+ }
+
+ runtime.evalScriptlet("$ctx = OpenSSL::SSL::SSLContext.new; $ctx.cert = $cert; $ctx.key = $key");
+ assertEquals(tlsCiphers, runtime.evalScriptlet("$ctx.ciphers"));
+ assertEquals(sslCiphers, runtime.evalScriptlet("$ctx.key = nil; $ctx.ciphers"));
+ }
+
+ @Test
+ public void emptyAndClassicalContextsKeepDefaultProvider() throws Exception {
+ SSLContext empty = context("OpenSSL::SSL::SSLContext.new");
+ assertEquals(engineClass("SSL"), empty.createSSLEngine(null, 0).getClass());
+ SSLContext rsa = context(
+ "ctx = OpenSSL::SSL::SSLContext.new; " +
+ "ctx.cert = OpenSSL::X509::Certificate.new(File.read('test/ssl/client.crt')); " +
+ "ctx.key = OpenSSL::PKey.read(File.read('test/ssl/client.key')); ctx");
+ assertEquals(engineClass("SSL"), rsa.createSSLEngine(null, 0).getClass());
+ }
+
+ @Test
+ public void mixedCredentialsUseTlsProvider() throws Exception {
+ loadMLDSAIdentity(44);
+ SSLContext context = context(
+ "ctx = OpenSSL::SSL::SSLContext.new; " +
+ "ctx.cert = OpenSSL::X509::Certificate.new(File.read('test/ssl/client.crt')); " +
+ "ctx.key = OpenSSL::PKey.read(File.read('test/ssl/client.key')); " +
+ "ctx.add_certificate($cert, $key); ctx");
+ assertEquals(engineClass("TLS"), context.createSSLEngine(null, 0).getClass());
+ }
+
+ @Test
+ public void mldsaCredentialsPreserveExplicitMethods() throws Exception {
+ loadMLDSAIdentity(44);
+ for (String method : new String[] {"TLSv1_2", "SSLv3"}) {
+ SSLContext context = context("ctx = OpenSSL::SSL::SSLContext.new(:" + method + "); " +
+ "ctx.cert = $cert; ctx.key = $key; ctx");
+ assertEquals(engineClass(context.getProtocol()), context.createSSLEngine(null, 0).getClass());
+ }
+ }
+
+ @Test
+ public void mldsaCredentialsPreserveProtocolBoundsAndOptions() {
+ loadMLDSAIdentity(44);
+ SSLContext bounded = context("ctx = OpenSSL::SSL::SSLContext.new; " +
+ "ctx.cert = $cert; ctx.key = $key; " +
+ "ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION; ctx");
+ assertArrayEquals(new String[] {"TLSv1.3"}, bounded.createSSLEngine(null, 0).getEnabledProtocols());
+ SSLContext disabled = context("ctx = OpenSSL::SSL::SSLContext.new; " +
+ "ctx.cert = $cert; ctx.key = $key; " +
+ "ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION; " +
+ "ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION; " +
+ "ctx.options |= OpenSSL::SSL::OP_NO_TLSv1_3; ctx");
+ assertArrayEquals(new String[] {"TLSv1.2"}, disabled.createSSLEngine(null, 0).getEnabledProtocols());
+ }
+
+ private void loadMLDSAIdentity(int parameter) {
+ runtime.evalScriptlet(
+ "$key = OpenSSL::PKey.read(File.read('test/fixtures/mldsa/ml-dsa-" + parameter + "-seed.pem')); " +
+ "$cert = OpenSSL::X509::Certificate.new; $cert.version = 2; $cert.serial = 1; " +
+ "$cert.subject = $cert.issuer = OpenSSL::X509::Name.parse('/CN=ML-DSA test'); " +
+ "$cert.public_key = $key; " +
+ "$cert.not_before = Time.now - 60; $cert.not_after = Time.now + 3600; $cert.sign($key, nil)");
+ }
+
+ private SSLContext context(String script) {
+ SSLContext context = (SSLContext) runtime.evalScriptlet(script);
+ context.doSetup(currentContext());
+ return context;
+ }
+
+ private Class> engineClass(String protocol) throws Exception {
+ javax.net.ssl.SSLContext context = SecurityHelper.getSSLContext(protocol);
+ context.init(null, new TrustManager[0], null);
+ return context.createSSLEngine().getClass();
+ }
+}
diff --git a/test/fixtures/mldsa/README.md b/test/fixtures/mldsa/README.md
new file mode 100644
index 00000000..dd1dd7ce
--- /dev/null
+++ b/test/fixtures/mldsa/README.md
@@ -0,0 +1,54 @@
+These are disposable ML-DSA test identities, generated independently by OpenSSL
+3.5.7 (9 Jun 2026), Debian trixie amd64, with its default provider. They must never
+be used as real credentials.
+
+Run `./generate.sh` in this directory with OpenSSL 3.5+ and native Ruby linked to
+that OpenSSL to regenerate them. Generation is random, so key and signature
+bytes change. The script verifies signatures and compares the public keys
+derived from every private-key encoding. `empty.bin` is empty; `binary.bin`
+contains NUL and bytes outside ASCII.
+
+For each parameter set, `seed.pem`, `seed-priv.pem`, and `expanded.pem` are PKCS#8
+with seed-only, seed-and-expanded, and expanded-only private keys, respectively.
+`public.pem` is SubjectPublicKeyInfo. `encrypted.pem` is expanded-only encrypted
+PKCS#8 using PBES2, PBKDF2-HMAC-SHA256 (2048 iterations), and AES-256-CBC; the
+password is `mldsa-test-password`. Tests decode PEM to exercise the equivalent
+DER inputs rather than retaining duplicate fixtures. Expanded-only encryption
+keeps encrypted-key import testable with BC 1.80, whose decoder does not accept
+the newer tagged seed-only encoding. jruby-openssl imports standardized seed
+encodings through BC's seed key-spec API, including on BC 1.80. It exports
+standard expanded-only PKCS#8 on every BC version. The ambiguous prestandard
+bare encodings emitted directly by BC 1.80 are outside this import contract.
+
+`binary.sig` was generated by `openssl pkeyutl`. OpenSSL 3.5.7's CLI rejects an
+empty file with "Could not allocate 0 bytes for oneshot sign/verify buffer", so
+`empty.sig` uses native Ruby's `OpenSSL::PKey#sign(nil, "")` against the same
+libcrypto. Both signatures use pure ML-DSA with an empty context and no external
+digest.
+
+Run `jruby -rbundler/setup -S rake test:mldsa_openssl` from the repository root
+to check the reverse direction with native OpenSSL 3.5 or later on `PATH`.
+This optional test generates JRuby keys for all three parameter sets, checks
+their exported private and public keys with OpenSSL, and verifies their
+signatures over `binary.bin`. It uses temporary files and leaves these fixtures
+unchanged.
+
+To check a JRuby-produced signature using independent OpenSSL (replace `44` as
+needed), write the signature to `/tmp/jruby-binary.sig`, then run:
+
+```sh
+openssl pkeyutl -verify -pubin -inkey ml-dsa-44-public.pem \
+ -in binary.bin -sigfile /tmp/jruby-binary.sig
+```
+
+For empty messages, use native Ruby linked to OpenSSL 3.5+:
+
+```sh
+ruby -ropenssl -e 'key = OpenSSL::PKey.read(File.binread(ARGV[0]));
+ abort "invalid signature" unless key.verify(nil, File.binread(ARGV[1]), "")' \
+ ml-dsa-44-public.pem /tmp/jruby-empty.sig
+```
+
+The selected encodings and pure signature options are documented by OpenSSL:
+[EVP_PKEY-ML-DSA](https://docs.openssl.org/3.5/man7/EVP_PKEY-ML-DSA/) and
+[openssl-pkeyutl](https://docs.openssl.org/3.5/man1/openssl-pkeyutl/).
diff --git a/test/fixtures/mldsa/binary.bin b/test/fixtures/mldsa/binary.bin
new file mode 100644
index 00000000..00dd2693
Binary files /dev/null and b/test/fixtures/mldsa/binary.bin differ
diff --git a/test/fixtures/mldsa/empty.bin b/test/fixtures/mldsa/empty.bin
new file mode 100644
index 00000000..e69de29b
diff --git a/test/fixtures/mldsa/generate.sh b/test/fixtures/mldsa/generate.sh
new file mode 100755
index 00000000..c22d0152
--- /dev/null
+++ b/test/fixtures/mldsa/generate.sh
@@ -0,0 +1,41 @@
+#!/bin/sh
+# Regenerate independent test fixtures with OpenSSL 3.5 or later.
+set -eu
+cd "$(dirname "$0")"
+openssl version
+: > empty.bin
+printf '\000\001\002\177\200\376\377JRuby\000OpenSSL\n' > binary.bin
+for parameter in 44 65 87; do
+ name="ml-dsa-$parameter"
+ openssl genpkey -algorithm "$name" -out "$name-seed-priv.pem"
+ openssl pkey -provparam ml-dsa.output_formats=seed-only \
+ -in "$name-seed-priv.pem" -out "$name-seed.pem"
+ openssl pkey -provparam ml-dsa.output_formats=priv-only \
+ -in "$name-seed-priv.pem" -out "$name-expanded.pem"
+ openssl pkey -in "$name-seed.pem" -pubout -out "$name-public.pem"
+ openssl pkcs8 -provparam ml-dsa.output_formats=priv-only -topk8 -in "$name-expanded.pem" -v2 aes-256-cbc -iter 2048 \
+ -passout pass:mldsa-test-password -out "$name-encrypted.pem"
+ for message in binary; do
+ openssl pkeyutl -sign -inkey "$name-seed.pem" -in "$message.bin" \
+ -out "$name-$message.sig"
+ openssl pkeyutl -verify -pubin -inkey "$name-public.pem" \
+ -in "$message.bin" -sigfile "$name-$message.sig"
+ done
+ # pkeyutl 3.5.7 cannot allocate its buffer for an empty file; use libcrypto.
+ ruby -ropenssl -e '
+ abort "native Ruby is required for independent fixtures" if defined?(JRUBY_VERSION)
+ name = ARGV.fetch(0)
+ key = OpenSSL::PKey.read(File.binread("#{name}-seed.pem"))
+ signature = key.sign(nil, "")
+ File.binwrite("#{name}-empty.sig", signature)
+ abort "invalid empty-message signature" unless key.verify(nil, signature, "")
+ ' "$name"
+ # Verify every private encoding derives the same public key.
+ for encoding in seed seed-priv expanded; do
+ openssl pkey -in "$name-$encoding.pem" -pubout | cmp - "$name-public.pem"
+ done
+ openssl pkey -in "$name-encrypted.pem" -passin pass:mldsa-test-password \
+ -pubout | cmp - "$name-public.pem"
+done
+
+chmod 644 *.pem *.bin *.sig
diff --git a/test/fixtures/mldsa/ml-dsa-44-binary.sig b/test/fixtures/mldsa/ml-dsa-44-binary.sig
new file mode 100644
index 00000000..ca171567
Binary files /dev/null and b/test/fixtures/mldsa/ml-dsa-44-binary.sig differ
diff --git a/test/fixtures/mldsa/ml-dsa-44-empty.sig b/test/fixtures/mldsa/ml-dsa-44-empty.sig
new file mode 100644
index 00000000..e98cdaf2
Binary files /dev/null and b/test/fixtures/mldsa/ml-dsa-44-empty.sig differ
diff --git a/test/fixtures/mldsa/ml-dsa-44-encrypted.pem b/test/fixtures/mldsa/ml-dsa-44-encrypted.pem
new file mode 100644
index 00000000..4930942e
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-44-encrypted.pem
@@ -0,0 +1,59 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+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
+KU6SxIowLZzM
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-44-expanded.pem b/test/fixtures/mldsa/ml-dsa-44-expanded.pem
new file mode 100644
index 00000000..c0ac3029
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-44-expanded.pem
@@ -0,0 +1,56 @@
+-----BEGIN PRIVATE KEY-----
+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
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-44-public.pem b/test/fixtures/mldsa/ml-dsa-44-public.pem
new file mode 100644
index 00000000..6ff70b1f
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-44-public.pem
@@ -0,0 +1,30 @@
+-----BEGIN PUBLIC KEY-----
+MIIFMjALBglghkgBZQMEAxEDggUhADmw28QI8U0qHfpntj7jflsKpqMPojKp1k9R
+shNEIdVTfmzgJ8HVz80H+Astue/R3NRBP5C9TJozRCK3iLbXA3V1Cgx3MLMM4wpB
+thH17x59V4/taDLJt7dacE2iAfzkkELUqm08rCNBn7yZLZnYlKWT5X0BT/YlnJMH
+bmHkAOo7mlre3eIQULrttLZ4JfB1tOstbbG7gu4SLyQfquKXnB71Vuw60MLwEcJH
+Hh2vKHkIp8JH0Cpp4mqOAhEerjmwk0bV4k1oan58yk8oSzQlkwYNs852b+joYXmg
+Y+EuYpj2RxtI0kx8rLDtbzxsrcHfyL+Gq7AHIgsEBIi8pmitUOUoYoHtg4E98n6L
+VpNOl4HYYRJIhBR4VvUlyS+RoxM4DzGjQJlv1Ou64a73nynDpndDasn80IUAmtKF
+sqmoHDAC03x7u9yryctgUDAg2io8F6UQCJo7eBbwTDtM+E8OmoXwpUdfnlr3R6Mq
+G1fNAFdllwTlqeBjh3HphEaz7lmLeiRoEathpeuAe/aUkJNhzFY1uniuiJrta2Sf
+A/OtgdA2b0UM/x+SLNW1Y4XeHnURFnQHA8i+s6SmS5kBN+wYWO3lmo31Klgczw3g
+PZEGZc8KelOBHoqImWYkvHHVN/REsjh1l7a0Ei9Sd2mrqU8EFyUqGCoia0ktkkj/
++zegALjl2T7URZ4tJdN7nZTYA1T+U5pZKQSgoOIxq7ICtvButM5XyB354Cfws8IX
+BJXc6Uge20bzHP09hWzoZPnpvXXlwX2qXkICiReZfo53Hmm0WVCJxZ7gbJZnS2qR
+qO/sJtw6LTwCIVzqdg5DJjp0dyIY/pJCoqZOfPYOGapjJPcAafSu32t0UDCf9OLs
+ei/BXb2ZZuAvRJdEcThpmfNuJEjKAssnNpJ0E+mDEICwifWtmtRZZr8PorvpeGNj
+Il32FYh9YpEvAXpbD0gro3bScbBVBI7aCp9rKqHzKSHf+sEObCwHkY3OpFdDPJVr
+4qsQu7p9jlU4wevSczc9AFEgiAWNQHgTUmuG4zK8RyEUTOG3UBbkfziL6V9086EE
+wDun00OW+9gbOQ4CAS8tEQYE7KHPETzXHtUwfo0i4x56wcb/uunWa5PMcAh4tKf5
+d4kD/ugNHquOoduRPq2rSVf8oDAFfVbBHbCq4NfsveRrREP1mXbK4etPqOMPYRk/
+ynpiaM/VEzvoq3fCx/fzbtd6FR/AbQSJi7Wtfc7xNdKPieI2anoHaRoGihhXfql3
+NAQYvDq7jJCO61e6bfjgjt2CDHnps6TcoYCHIzDCDSxj+xiGq6e/y0V0AurAt0j0
+PY/HHMQI8mCsvPeiAloY0FkP38Uu+feZrm+DaqXnoD8yCtnBjLzf+2GaZvuNwrH2
+080gSxF5FCrUpaOEsdcDM18m2ebIMyuOcnSdHpc0aCjN7TYfahrzWdklUdCAiEDN
+q4DSlXHGE4RFp5/KdHDYPuLRnAfc1hN3dP7l3neS9AqsgScLxGv8s2GBL9ZN5+Xo
+x/1l9lXyN35bNp/4ZmSw/NS84X+ND4GgYVwmErOOq86GniibxGZbjepUEdzdtoaS
+69JxJlZrq4aBqN0gswSYvXxWlN8pvObxxuq2/o7sylJgHCvZWem2+C3IsRdfPSUu
+zy0xN84fwPFdHuMljgGhHfBLrifr/WReM0Znn+Sfizgu0ODhpP3jwuxXSVQQbcri
+0UIX8P9au6HW1tT5IYerTq97nKWL59ejtfrlOm1YPiDd/ALFx8I=
+-----END PUBLIC KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-44-seed-priv.pem b/test/fixtures/mldsa/ml-dsa-44-seed-priv.pem
new file mode 100644
index 00000000..0d64bc9e
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-44-seed-priv.pem
@@ -0,0 +1,57 @@
+-----BEGIN PRIVATE KEY-----
+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
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-44-seed.pem b/test/fixtures/mldsa/ml-dsa-44-seed.pem
new file mode 100644
index 00000000..e665e8b5
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-44-seed.pem
@@ -0,0 +1,4 @@
+-----BEGIN PRIVATE KEY-----
+MDQCAQAwCwYJYIZIAWUDBAMRBCKAIAPG5lOUUKGxUIy2tZNDwXdxDE587itIDH6o
+L7K8Mvhx
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-65-binary.sig b/test/fixtures/mldsa/ml-dsa-65-binary.sig
new file mode 100644
index 00000000..220af870
Binary files /dev/null and b/test/fixtures/mldsa/ml-dsa-65-binary.sig differ
diff --git a/test/fixtures/mldsa/ml-dsa-65-empty.sig b/test/fixtures/mldsa/ml-dsa-65-empty.sig
new file mode 100644
index 00000000..9cd7c403
Binary files /dev/null and b/test/fixtures/mldsa/ml-dsa-65-empty.sig differ
diff --git a/test/fixtures/mldsa/ml-dsa-65-encrypted.pem b/test/fixtures/mldsa/ml-dsa-65-encrypted.pem
new file mode 100644
index 00000000..c4626d95
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-65-encrypted.pem
@@ -0,0 +1,89 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+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
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-65-expanded.pem b/test/fixtures/mldsa/ml-dsa-65-expanded.pem
new file mode 100644
index 00000000..592ddcb3
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-65-expanded.pem
@@ -0,0 +1,87 @@
+-----BEGIN PRIVATE KEY-----
+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
+fN8MZs19cDRzbeSF4Vvt/2W0YieuHADFtZd2cg==
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-65-public.pem b/test/fixtures/mldsa/ml-dsa-65-public.pem
new file mode 100644
index 00000000..f20855e4
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-65-public.pem
@@ -0,0 +1,44 @@
+-----BEGIN PUBLIC KEY-----
+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
+wuXBRFpR
+-----END PUBLIC KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-65-seed-priv.pem b/test/fixtures/mldsa/ml-dsa-65-seed-priv.pem
new file mode 100644
index 00000000..1bde6a07
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-65-seed-priv.pem
@@ -0,0 +1,88 @@
+-----BEGIN PRIVATE KEY-----
+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
+5IXhW+3/ZbRiJ64cAMW1l3Zy
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-65-seed.pem b/test/fixtures/mldsa/ml-dsa-65-seed.pem
new file mode 100644
index 00000000..21d6fcf8
--- /dev/null
+++ b/test/fixtures/mldsa/ml-dsa-65-seed.pem
@@ -0,0 +1,4 @@
+-----BEGIN PRIVATE KEY-----
+MDQCAQAwCwYJYIZIAWUDBAMSBCKAIJAqqiFPbG0ZHTvcRTTX/HJupZUKRFNYYJiN
+dS0lyk1O
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/mldsa/ml-dsa-87-binary.sig b/test/fixtures/mldsa/ml-dsa-87-binary.sig
new file mode 100644
index 00000000..8284db5d
Binary files /dev/null and b/test/fixtures/mldsa/ml-dsa-87-binary.sig differ
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 00000000..9eaa7171
Binary files /dev/null and b/test/fixtures/mldsa/ml-dsa-87-empty.sig differ
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 00000000..5b6ab96f
--- /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 00000000..ec7b2195
--- /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 00000000..4cae6006
--- /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 00000000..ba3e5588
--- /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 00000000..e65b8d0c
--- /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 00000000..3caf84e0
--- /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 00000000..f9ce40b3
--- /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