Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci-bc-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions History.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
205 changes: 205 additions & 0 deletions integration/mldsa_http.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading