mirror of
https://github.com/puma/puma.git
synced 2022-11-09 13:48:40 -05:00
[jruby] support a truststore option (#2849)
* [jruby] support a truststore option which might be a completely different file than keystore ... due backwards compatibility we assume `truststore = keystore` (`truststore_pass = keystore_pass`) * [jruby] actually use truststore on initialize * [jruby] add keystore_type and truststore_type * [jruby] dry and simplify native bits * [jruby] setup SSLError in native (like C part) * [jruby] map to SSLError from native exception * [jruby] provide peercert even if hand-shake fails
This commit is contained in:
parent
db751ba82f
commit
ceb4c56ad4
5 changed files with 175 additions and 54 deletions
2
Rakefile
2
Rakefile
|
@ -72,6 +72,8 @@ else
|
|||
|
||||
Rake::JavaExtensionTask.new("puma_http11", gemspec) do |ext|
|
||||
ext.lib_dir = "lib/puma"
|
||||
ext.source_version = '1.8'
|
||||
ext.target_version = '1.8'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
BIN
examples/puma/client-certs/ca_store.p12
Normal file
BIN
examples/puma/client-certs/ca_store.p12
Normal file
Binary file not shown.
|
@ -15,6 +15,7 @@ import org.jruby.runtime.builtin.IRubyObject;
|
|||
import org.jruby.util.ByteList;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLEngine;
|
||||
|
@ -22,6 +23,7 @@ import javax.net.ssl.SSLEngineResult;
|
|||
import javax.net.ssl.SSLException;
|
||||
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
|
@ -32,15 +34,18 @@ import java.security.KeyStore;
|
|||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static javax.net.ssl.SSLEngineResult.Status;
|
||||
import static javax.net.ssl.SSLEngineResult.HandshakeStatus;
|
||||
|
||||
public class MiniSSL extends RubyObject {
|
||||
public class MiniSSL extends RubyObject { // MiniSSL::Engine
|
||||
private static ObjectAllocator ALLOCATOR = new ObjectAllocator() {
|
||||
public IRubyObject allocate(Ruby runtime, RubyClass klass) {
|
||||
return new MiniSSL(runtime, klass);
|
||||
|
@ -51,11 +56,10 @@ public class MiniSSL extends RubyObject {
|
|||
RubyModule mPuma = runtime.defineModule("Puma");
|
||||
RubyModule ssl = mPuma.defineModuleUnder("MiniSSL");
|
||||
|
||||
mPuma.defineClassUnder("SSLError",
|
||||
runtime.getClass("IOError"),
|
||||
runtime.getClass("IOError").getAllocator());
|
||||
// Puma::MiniSSL::SSLError
|
||||
ssl.defineClassUnder("SSLError", runtime.getStandardError(), runtime.getStandardError().getAllocator());
|
||||
|
||||
RubyClass eng = ssl.defineClassUnder("Engine",runtime.getObject(),ALLOCATOR);
|
||||
RubyClass eng = ssl.defineClassUnder("Engine", runtime.getObject(), ALLOCATOR);
|
||||
eng.defineAnnotatedMethods(MiniSSL.class);
|
||||
}
|
||||
|
||||
|
@ -141,70 +145,89 @@ public class MiniSSL extends RubyObject {
|
|||
public static synchronized IRubyObject server(ThreadContext context, IRubyObject recv, IRubyObject miniSSLContext)
|
||||
throws KeyStoreException, IOException, CertificateException, NoSuchAlgorithmException, UnrecoverableKeyException {
|
||||
// Create the KeyManagerFactory and TrustManagerFactory for this server
|
||||
String keystoreFile = miniSSLContext.callMethod(context, "keystore").convertToString().asJavaString();
|
||||
char[] password = miniSSLContext.callMethod(context, "keystore_pass").convertToString().asJavaString().toCharArray();
|
||||
String keystoreFile = asStringValue(miniSSLContext.callMethod(context, "keystore"), null);
|
||||
char[] keystorePass = asStringValue(miniSSLContext.callMethod(context, "keystore_pass"), null).toCharArray();
|
||||
String keystoreType = asStringValue(miniSSLContext.callMethod(context, "keystore_type"), KeyStore::getDefaultType);
|
||||
|
||||
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
String truststoreFile;
|
||||
char[] truststorePass;
|
||||
String truststoreType;
|
||||
IRubyObject truststore = miniSSLContext.callMethod(context, "truststore");
|
||||
if (truststore.isNil()) {
|
||||
truststoreFile = keystoreFile;
|
||||
truststorePass = keystorePass;
|
||||
truststoreType = keystoreType;
|
||||
} else {
|
||||
truststoreFile = truststore.convertToString().asJavaString();
|
||||
truststorePass = asStringValue(miniSSLContext.callMethod(context, "truststore_pass"), null).toCharArray();
|
||||
truststoreType = asStringValue(miniSSLContext.callMethod(context, "truststore_type"), KeyStore::getDefaultType);
|
||||
}
|
||||
|
||||
KeyStore ks = KeyStore.getInstance(keystoreType);
|
||||
InputStream is = new FileInputStream(keystoreFile);
|
||||
try {
|
||||
ks.load(is, password);
|
||||
ks.load(is, keystorePass);
|
||||
} finally {
|
||||
is.close();
|
||||
}
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509");
|
||||
kmf.init(ks, password);
|
||||
kmf.init(ks, keystorePass);
|
||||
keyManagerFactoryMap.put(keystoreFile, kmf);
|
||||
|
||||
KeyStore ts = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
is = new FileInputStream(keystoreFile);
|
||||
KeyStore ts = KeyStore.getInstance(truststoreType);
|
||||
is = new FileInputStream(truststoreFile);
|
||||
try {
|
||||
ts.load(is, password);
|
||||
ts.load(is, truststorePass);
|
||||
} finally {
|
||||
is.close();
|
||||
}
|
||||
TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509");
|
||||
tmf.init(ts);
|
||||
trustManagerFactoryMap.put(keystoreFile, tmf);
|
||||
trustManagerFactoryMap.put(truststoreFile, tmf);
|
||||
|
||||
RubyClass klass = (RubyClass) recv;
|
||||
return klass.newInstance(context,
|
||||
new IRubyObject[] { miniSSLContext },
|
||||
Block.NULL_BLOCK);
|
||||
return klass.newInstance(context, miniSSLContext, Block.NULL_BLOCK);
|
||||
}
|
||||
|
||||
private static String asStringValue(IRubyObject value, Supplier<String> defaultValue) {
|
||||
if (defaultValue != null && value.isNil()) return defaultValue.get();
|
||||
return value.convertToString().asJavaString();
|
||||
}
|
||||
|
||||
@JRubyMethod
|
||||
public IRubyObject initialize(ThreadContext threadContext, IRubyObject miniSSLContext)
|
||||
public IRubyObject initialize(ThreadContext context, IRubyObject miniSSLContext)
|
||||
throws KeyStoreException, NoSuchAlgorithmException, KeyManagementException {
|
||||
|
||||
String keystoreFile = miniSSLContext.callMethod(threadContext, "keystore").convertToString().asJavaString();
|
||||
String keystoreFile = miniSSLContext.callMethod(context, "keystore").convertToString().asJavaString();
|
||||
KeyManagerFactory kmf = keyManagerFactoryMap.get(keystoreFile);
|
||||
TrustManagerFactory tmf = trustManagerFactoryMap.get(keystoreFile);
|
||||
if(kmf == null || tmf == null) {
|
||||
throw new KeyStoreException("Could not find KeyManagerFactory/TrustManagerFactory for keystore: " + keystoreFile);
|
||||
String truststoreFile = asStringValue(miniSSLContext.callMethod(context, "truststore"), () -> keystoreFile);
|
||||
TrustManagerFactory tmf = trustManagerFactoryMap.get(truststoreFile);
|
||||
if (kmf == null || tmf == null) {
|
||||
throw new KeyStoreException("Could not find KeyManagerFactory/TrustManagerFactory for keystore: " + keystoreFile + " truststore: " + truststoreFile);
|
||||
}
|
||||
|
||||
SSLContext sslCtx = SSLContext.getInstance("TLS");
|
||||
|
||||
sslCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
|
||||
sslCtx.init(kmf.getKeyManagers(), getTrustManagers(tmf), null);
|
||||
closed = false;
|
||||
handshake = false;
|
||||
engine = sslCtx.createSSLEngine();
|
||||
|
||||
String[] protocols;
|
||||
if(miniSSLContext.callMethod(threadContext, "no_tlsv1").isTrue()) {
|
||||
if (miniSSLContext.callMethod(context, "no_tlsv1").isTrue()) {
|
||||
protocols = new String[] { "TLSv1.1", "TLSv1.2" };
|
||||
} else {
|
||||
protocols = new String[] { "TLSv1", "TLSv1.1", "TLSv1.2" };
|
||||
}
|
||||
|
||||
if(miniSSLContext.callMethod(threadContext, "no_tlsv1_1").isTrue()) {
|
||||
if (miniSSLContext.callMethod(context, "no_tlsv1_1").isTrue()) {
|
||||
protocols = new String[] { "TLSv1.2" };
|
||||
}
|
||||
|
||||
engine.setEnabledProtocols(protocols);
|
||||
engine.setUseClientMode(false);
|
||||
|
||||
long verify_mode = miniSSLContext.callMethod(threadContext, "verify_mode").convertToInteger("to_i").getLongValue();
|
||||
long verify_mode = miniSSLContext.callMethod(context, "verify_mode").convertToInteger("to_i").getLongValue();
|
||||
if ((verify_mode & 0x1) != 0) { // 'peer'
|
||||
engine.setWantClientAuth(true);
|
||||
}
|
||||
|
@ -212,7 +235,7 @@ public class MiniSSL extends RubyObject {
|
|||
engine.setNeedClientAuth(true);
|
||||
}
|
||||
|
||||
IRubyObject sslCipherListObject = miniSSLContext.callMethod(threadContext, "ssl_cipher_list");
|
||||
IRubyObject sslCipherListObject = miniSSLContext.callMethod(context, "ssl_cipher_list");
|
||||
if (!sslCipherListObject.isNil()) {
|
||||
String[] sslCipherList = sslCipherListObject.convertToString().asJavaString().split(",");
|
||||
engine.setEnabledCipherSuites(sslCipherList);
|
||||
|
@ -227,6 +250,47 @@ public class MiniSSL extends RubyObject {
|
|||
return this;
|
||||
}
|
||||
|
||||
private TrustManager[] getTrustManagers(TrustManagerFactory factory) {
|
||||
final TrustManager[] tms = factory.getTrustManagers();
|
||||
if (tms != null) {
|
||||
for (int i=0; i<tms.length; i++) {
|
||||
final TrustManager tm = tms[i];
|
||||
if (tm instanceof X509TrustManager) {
|
||||
tms[i] = new TrustManagerWrapper((X509TrustManager) tm);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tms;
|
||||
}
|
||||
|
||||
private volatile transient X509Certificate[] lastCheckedChain;
|
||||
|
||||
private class TrustManagerWrapper implements X509TrustManager {
|
||||
|
||||
private final X509TrustManager delegate;
|
||||
|
||||
TrustManagerWrapper(X509TrustManager delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
|
||||
lastCheckedChain = chain.clone();
|
||||
delegate.checkClientTrusted(chain, authType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
|
||||
delegate.checkServerTrusted(chain, authType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return delegate.getAcceptedIssuers();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@JRubyMethod
|
||||
public IRubyObject inject(IRubyObject arg) {
|
||||
ByteList bytes = arg.convertToString().getByteList();
|
||||
|
@ -338,9 +402,7 @@ public class MiniSSL extends RubyObject {
|
|||
|
||||
return RubyString.newString(getRuntime(), appDataByteList);
|
||||
} catch (SSLException e) {
|
||||
RaiseException re = getRuntime().newEOFError(e.getMessage());
|
||||
re.initCause(e);
|
||||
throw re;
|
||||
throw newSSLError(getRuntime(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -373,19 +435,23 @@ public class MiniSSL extends RubyObject {
|
|||
|
||||
return RubyString.newString(context.runtime, dataByteList);
|
||||
} catch (SSLException e) {
|
||||
RaiseException ex = context.runtime.newRuntimeError(e.toString());
|
||||
ex.initCause(e);
|
||||
throw ex;
|
||||
throw newSSLError(getRuntime(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@JRubyMethod
|
||||
public IRubyObject peercert() throws CertificateEncodingException {
|
||||
public IRubyObject peercert(ThreadContext context) throws CertificateEncodingException {
|
||||
Certificate peerCert;
|
||||
try {
|
||||
return JavaEmbedUtils.javaToRuby(getRuntime(), engine.getSession().getPeerCertificates()[0].getEncoded());
|
||||
peerCert = engine.getSession().getPeerCertificates()[0];
|
||||
} catch (SSLPeerUnverifiedException e) {
|
||||
return getRuntime().getNil();
|
||||
if (lastCheckedChain != null) {
|
||||
peerCert = lastCheckedChain[0];
|
||||
} else {
|
||||
peerCert = null;
|
||||
}
|
||||
}
|
||||
return peerCert == null ? context.nil : JavaEmbedUtils.javaToRuby(context.runtime, peerCert.getEncoded());
|
||||
}
|
||||
|
||||
@JRubyMethod(name = "init?")
|
||||
|
@ -404,4 +470,19 @@ public class MiniSSL extends RubyObject {
|
|||
return getRuntime().getFalse();
|
||||
}
|
||||
}
|
||||
|
||||
private static RubyClass getSSLError(Ruby runtime) {
|
||||
return (RubyClass) ((RubyModule) runtime.getModule("Puma").getConstantAt("MiniSSL")).getConstantAt("SSLError");
|
||||
}
|
||||
|
||||
private static RaiseException newSSLError(Ruby runtime, SSLException cause) {
|
||||
return newError(runtime, getSSLError(runtime), cause.toString(), cause);
|
||||
}
|
||||
|
||||
private static RaiseException newError(Ruby runtime, RubyClass errorClass, String message, Throwable cause) {
|
||||
RaiseException ex = new RaiseException(runtime, errorClass, message, true);
|
||||
ex.initCause(cause);
|
||||
return ex;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -195,10 +195,6 @@ module Puma
|
|||
if IS_JRUBY
|
||||
OPENSSL_NO_SSL3 = false
|
||||
OPENSSL_NO_TLS1 = false
|
||||
|
||||
class SSLError < StandardError
|
||||
# Define this for jruby even though it isn't used.
|
||||
end
|
||||
end
|
||||
|
||||
class Context
|
||||
|
@ -222,7 +218,11 @@ module Puma
|
|||
if IS_JRUBY
|
||||
# jruby-specific Context properties: java uses a keystore and password pair rather than a cert/key pair
|
||||
attr_reader :keystore
|
||||
attr_reader :keystore_type
|
||||
attr_accessor :keystore_pass
|
||||
attr_reader :truststore
|
||||
attr_reader :truststore_type
|
||||
attr_accessor :truststore_pass
|
||||
attr_accessor :ssl_cipher_list
|
||||
|
||||
def keystore=(keystore)
|
||||
|
@ -230,8 +230,24 @@ module Puma
|
|||
@keystore = keystore
|
||||
end
|
||||
|
||||
def truststore=(truststore)
|
||||
raise ArgumentError, "No such truststore file '#{truststore}'" unless File.exist? truststore
|
||||
@truststore = truststore
|
||||
end
|
||||
|
||||
def keystore_type=(type)
|
||||
raise ArgumentError, "Invalid keystore type: #{type.inspect}" unless ['pkcs12', 'jks', nil].include?(type)
|
||||
@keystore_type = type
|
||||
end
|
||||
|
||||
def truststore_type=(type)
|
||||
raise ArgumentError, "Invalid truststore type: #{type.inspect}" unless ['pkcs12', 'jks', nil].include?(type)
|
||||
@truststore_type = type
|
||||
end
|
||||
|
||||
def check
|
||||
raise "Keystore not configured" unless @keystore
|
||||
# @truststore defaults to @keystore due backwards compatibility
|
||||
end
|
||||
|
||||
else
|
||||
|
|
|
@ -292,7 +292,7 @@ class TestPumaServerSSLClient < Minitest::Test
|
|||
ctx.verify_mode = Puma::MiniSSL::VERIFY_PEER | Puma::MiniSSL::VERIFY_FAIL_IF_NO_PEER_CERT
|
||||
}
|
||||
|
||||
def assert_ssl_client_error_match(error, subject=nil, &blk)
|
||||
def assert_ssl_client_error_match(error, subject: nil, context: CTX, &blk)
|
||||
host = "localhost"
|
||||
port = 0
|
||||
|
||||
|
@ -300,7 +300,7 @@ class TestPumaServerSSLClient < Minitest::Test
|
|||
|
||||
log_writer = SSLLogWriterHelper.new STDOUT, STDERR
|
||||
server = Puma::Server.new app, log_writer
|
||||
server.add_ssl_listener host, port, CTX
|
||||
server.add_ssl_listener host, port, context
|
||||
host_addrs = server.binder.ios.map { |io| io.to_io.addr[2] }
|
||||
server.run
|
||||
|
||||
|
@ -316,34 +316,35 @@ class TestPumaServerSSLClient < Minitest::Test
|
|||
req = Net::HTTP::Get.new "/", {}
|
||||
http.request(req)
|
||||
end
|
||||
rescue OpenSSL::SSL::SSLError, EOFError, Errno::ECONNRESET
|
||||
rescue OpenSSL::SSL::SSLError, EOFError, Errno::ECONNRESET => e
|
||||
# Errno::ECONNRESET TruffleRuby
|
||||
client_error = true
|
||||
client_error = e
|
||||
# closes socket if open, may not close on error
|
||||
http.send :do_finish
|
||||
end
|
||||
|
||||
sleep 0.1
|
||||
assert_equal !!error, client_error
|
||||
assert_equal !!error, !!client_error, client_error
|
||||
# The JRuby MiniSSL implementation lacks error capturing currently,
|
||||
# so we can't inspect the messages here
|
||||
unless Puma.jruby?
|
||||
assert_match error, log_writer.error.message if error
|
||||
assert_includes host_addrs, log_writer.addr if error
|
||||
assert_equal subject, log_writer.cert.subject.to_s if subject
|
||||
end
|
||||
assert_match error, log_writer.error.message if error
|
||||
assert_includes host_addrs, log_writer.addr if error
|
||||
assert_equal subject, log_writer.cert.subject.to_s if subject
|
||||
ensure
|
||||
server.stop(true) if server
|
||||
end
|
||||
|
||||
def test_verify_fail_if_no_client_cert
|
||||
assert_ssl_client_error_match 'peer did not return a certificate' do |http|
|
||||
error = Puma.jruby? ? /Empty server certificate chain/ : 'peer did not return a certificate'
|
||||
assert_ssl_client_error_match(error) do |http|
|
||||
# nothing
|
||||
end
|
||||
end
|
||||
|
||||
def test_verify_fail_if_client_unknown_ca
|
||||
assert_ssl_client_error_match(/self[- ]signed certificate in certificate chain/, '/DC=net/DC=puma/CN=CAU') do |http|
|
||||
error = Puma.jruby? ? /No trusted certificate found/ : /self[- ]signed certificate in certificate chain/
|
||||
cert_subject = Puma.jruby? ? '/DC=net/DC=puma/CN=localhost' : '/DC=net/DC=puma/CN=CAU'
|
||||
assert_ssl_client_error_match(error, subject: cert_subject) do |http|
|
||||
key = "#{CERT_PATH}/client_unknown.key"
|
||||
crt = "#{CERT_PATH}/client_unknown.crt"
|
||||
http.key = OpenSSL::PKey::RSA.new File.read(key)
|
||||
|
@ -353,7 +354,8 @@ class TestPumaServerSSLClient < Minitest::Test
|
|||
end
|
||||
|
||||
def test_verify_fail_if_client_expired_cert
|
||||
assert_ssl_client_error_match('certificate has expired', '/DC=net/DC=puma/CN=localhost') do |http|
|
||||
error = Puma.jruby? ? /NotAfter:/ : 'certificate has expired'
|
||||
assert_ssl_client_error_match(error, subject: '/DC=net/DC=puma/CN=localhost') do |http|
|
||||
key = "#{CERT_PATH}/client_expired.key"
|
||||
crt = "#{CERT_PATH}/client_expired.crt"
|
||||
http.key = OpenSSL::PKey::RSA.new File.read(key)
|
||||
|
@ -363,7 +365,7 @@ class TestPumaServerSSLClient < Minitest::Test
|
|||
end
|
||||
|
||||
def test_verify_client_cert
|
||||
assert_ssl_client_error_match(nil) do |http|
|
||||
assert_ssl_client_error_match(false) do |http|
|
||||
key = "#{CERT_PATH}/client.key"
|
||||
crt = "#{CERT_PATH}/client.crt"
|
||||
http.key = OpenSSL::PKey::RSA.new File.read(key)
|
||||
|
@ -372,6 +374,26 @@ class TestPumaServerSSLClient < Minitest::Test
|
|||
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||
end
|
||||
end
|
||||
|
||||
def test_verify_client_cert_with_truststore
|
||||
ctx = Puma::MiniSSL::Context.new
|
||||
ctx.keystore = "#{CERT_PATH}/server.p12"
|
||||
ctx.keystore_type = 'pkcs12'
|
||||
ctx.keystore_pass = 'jruby_puma'
|
||||
ctx.truststore = "#{CERT_PATH}/ca_store.p12"
|
||||
ctx.truststore_type = 'pkcs12'
|
||||
ctx.truststore_pass = 'jruby_puma'
|
||||
|
||||
assert_ssl_client_error_match(false, context: ctx) do |http|
|
||||
key = "#{CERT_PATH}/client.key"
|
||||
crt = "#{CERT_PATH}/client.crt"
|
||||
http.key = OpenSSL::PKey::RSA.new File.read(key)
|
||||
http.cert = OpenSSL::X509::Certificate.new File.read(crt)
|
||||
http.ca_file = "#{CERT_PATH}/ca.crt"
|
||||
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||
end
|
||||
end if Puma.jruby?
|
||||
|
||||
end if ::Puma::HAS_SSL
|
||||
|
||||
class TestPumaServerSSLWithCertPemAndKeyPem < Minitest::Test
|
||||
|
|
Loading…
Reference in a new issue