Skip to content

Commit

Permalink
Support decryption of SSL key
Browse files Browse the repository at this point in the history
This commit makes it possible to store an encrypted SSL key on disk
and have Puma decrypt it at runtime by supplying a
`key_password_command`. Supplying a `key_password_command` will cause
Puma to:

1. Execute the external program.
2. Read the password from stdout and remove the trailing newline.
2. Configure the OpenSSL callbacks to use the password.

Other Web servers, such as NGINX and Apache, have a similar feature.
NGINX only allows supplying a password file via the `ssl_password`
parameter (https://www.nginx.com/blog/secure-distribution-ssl-private-keys-nginx/),
while Apache has a `SSLPassPhraseDialog` option (https://httpd.apache.org/docs/2.2/mod/mod_ssl.html#sslpassphrasedialog)
that allows an admin to execute an external program.

Closes puma#3132
  • Loading branch information
stanhu committed Apr 29, 2023
1 parent 904b47a commit 8cf9ce2
Show file tree
Hide file tree
Showing 8 changed files with 154 additions and 3 deletions.
30 changes: 30 additions & 0 deletions examples/puma/encrypted_puma_keypair.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,90F30BF02BE2889D8DA5B8168E8BC346

QxHwMvAj+xu2Qxe3joiBRrTneQAnby1vpLZb+AnsPbwUWC1TuSeQJdRNs9qYlYwg
Ow4/plNp1cPPcjEY2TQlTNtr2ur/Exy11Bs0TLcPxkiQfm+pT/uok2+Y8Qs8WMDm
3MLqMPw0wiqnOoLa8j/oUDC2VsutItCtIsld04NgJYOyQTOdNhZ284x4WLp6vUUy
atWFOWWfRTDGMzXVKnpY8RQw09D/NcHxRzdmWYelVNQpZMfFAMYygNqofoWDrS3Z
XX04fOSxdQg3QwjPkpwD/XH8XT0hGkQaKaAXl5TbCdOOuPtagcDZgt0f4MQJUhEq
HqR2bHACS4MvLk/N6l1DvoqZErepAt5uVSRclREfxnqxgXhxvhMz9fe7z0uCJzQU
Panj8sg3ytP2oAOAg9kPFYfUr+weUOIBXwioHqEnA3K1j2ADJnZvByIqekQ863vl
2f0iweHMJp23gs6FFGA0brLDY78un2L8EPQC9wgPFSZpjFmXZU7ur05bm8fndnn6
jGy/RLQ9N+fXBAALmwL+nj0Q5luxdQqx6ouCiDKB2ehz3LO/KMD3QI5ysT7GpY2F
2Out0Ud4e2XdS+5CDs28LCBnFejaxlIvJBxXu5PhtJ+7Plm4g1uIMdRspgO+Wukw
5/eNMgWm3Q7+1NGlA4kg3HW6K7GV/QIITYWKk7ETX06oD4Uj/XUxinrADT48IMYQ
iGcSECrs9uc3MNEGA3AHDqTvmCFsBZwjtx2JZX3HwwgXUxYSX52d07fctJJe3wYX
hQi9b4EEyGb2uOMWH3gMLzJaLDM89sgwRz0YNHlQK2wV7mfkmjjNHVGix6M+dfo6
kbXZD+rertIqytuaE++uVQEjBHk/yMPK9Yn/jA4TJlHsxNyZwrbYnHYgeGs0//fK
fe+ez1A67pvdS1/xAvFBuicqcI+D/Ib1kY3FIZBcnEpoNOCSBjtdZR06JeCKiD7S
QL5yNLgb0I5LrrO0D0Rr3jjCHpwe6WSEvO0Og6ktzXMKCTldk7YiQP1/Oy4BZBbM
EQh2OyqVLXAQdpgQ+J8xTI62khyqnvDelorcn1xQZbBLH7oS7pturxuFgVTW4RXL
SHq9GLfD4dzzIDo+fSII7PjTYwJiKFYWUZKQGboWkPHhgx3LR3geB6i+4HjuRt70
mRKA1aitwIZsKKkuiTtlUhBN8gLctGJpoP92yMcmADLDLmysWIDb1G/Ogo1a1qLI
2N5pbJUFDrjZ82j2xLtFiVEt17cQ+7x2mxMdb5DaxliZy05XhM6SbnOETBwhIm04
qV5IjZCCIqY3eEOsgeLz6vdjiohn2P2eXbgokl1vRQnzSwYaCTo64pIN4spgl+jN
WNsD1kQBgyvQPXoQntiiwi5CLlmVlE9RY32DYZJs0RazxcN5mBC9qP9JAVHOhUGR
JhaQfPl2GVtKyzz1dkvvXYjPFu7TKkt3Qtu59YmRfDu85JSzZhSFbssKu7lzNJv1
wno95r0fg2uG0dvCMB1MhSaRVBnReyubPvIzycU9PYkaS23I88ZzPsxCGMlJwxCC
A9AlbH46bfzrToF/UgbRTbpX7kNjDcDOFb01E47b2h2Tm8bYg7winVFS1qdWr6CG
-----END RSA PRIVATE KEY-----
3 changes: 3 additions & 0 deletions examples/puma/key_password_command.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh

echo "hello world"
32 changes: 30 additions & 2 deletions ext/puma_http11/mini_ssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ static int engine_verify_callback(int preverify_ok, X509_STORE_CTX* ctx) {
return preverify_ok;
}

static int password_callback(char *buf, int size, int rwflag, void *userdata) {
const char *password = (const char *) userdata;
size_t len = strlen(password);

if (len > (size_t) size) {
return 0;
}

memcpy(buf, password, len);
return (int) len;
}

static VALUE
sslctx_alloc(VALUE klass) {
SSL_CTX *ctx;
Expand Down Expand Up @@ -212,10 +224,12 @@ sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) {
SSL_CTX* ctx;
int ssl_options;
VALUE key, cert, ca, verify_mode, ssl_cipher_filter, no_tlsv1, no_tlsv1_1,
verification_flags, session_id_bytes, cert_pem, key_pem;
verification_flags, session_id_bytes, cert_pem, key_pem, key_password_command, key_password;
BIO *bio;
X509 *x509;
EVP_PKEY *pkey;
pem_password_cb *password_cb = NULL;
const char *password = NULL;
#ifdef HAVE_SSL_CTX_SET_MIN_PROTO_VERSION
int min;
#endif
Expand All @@ -235,6 +249,8 @@ sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) {

key = rb_funcall(mini_ssl_ctx, rb_intern_const("key"), 0);

key_password_command = rb_funcall(mini_ssl_ctx, rb_intern_const("key_password_command"), 0);

cert = rb_funcall(mini_ssl_ctx, rb_intern_const("cert"), 0);

ca = rb_funcall(mini_ssl_ctx, rb_intern_const("ca"), 0);
Expand All @@ -261,6 +277,18 @@ sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) {
}
}

if (!NIL_P(key_password_command)) {
key_password = rb_funcall(mini_ssl_ctx, rb_intern_const("key_password"), 0);

if (!NIL_P(key_password)) {
StringValue(key_password);
password_cb = password_callback;
password = RSTRING_PTR(key_password);
SSL_CTX_set_default_passwd_cb(ctx, password_cb);
SSL_CTX_set_default_passwd_cb_userdata(ctx, (void *) password);
}
}

if (!NIL_P(key)) {
StringValue(key);

Expand All @@ -285,7 +313,7 @@ sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) {
if (!NIL_P(key_pem)) {
bio = BIO_new(BIO_s_mem());
BIO_puts(bio, RSTRING_PTR(key_pem));
pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL);
pkey = PEM_read_bio_PrivateKey(bio, NULL, password_cb, (void *) password);

if (SSL_CTX_use_PrivateKey(ctx, pkey) != 1) {
BIO_free(bio);
Expand Down
3 changes: 2 additions & 1 deletion lib/puma/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def self.ssl_bind_str(host, port, opts)

cert_flags = (cert = opts[:cert]) ? "cert=#{Puma::Util.escape(cert)}" : nil
key_flags = (key = opts[:key]) ? "&key=#{Puma::Util.escape(key)}" : nil
password_flags = (password_command = opts[:key_password_command]) ? "&key_password_command=#{Puma::Util.escape(password_command)}" : nil

reuse_flag =
if (reuse = opts[:reuse])
Expand All @@ -114,7 +115,7 @@ def self.ssl_bind_str(host, port, opts)
nil
end

"ssl://#{host}:#{port}?#{cert_flags}#{key_flags}#{ssl_cipher_filter}" \
"ssl://#{host}:#{port}?#{cert_flags}#{key_flags}#{password_flags}#{ssl_cipher_filter}" \
"#{reuse_flag}&verify_mode=#{verify}#{tls_str}#{ca_additions}#{v_flags}#{backlog_str}#{low_latency_str}"
end
end
Expand Down
17 changes: 17 additions & 0 deletions lib/puma/minissl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
rescue LoadError
end

require 'open3'
# need for Puma::MiniSSL::OPENSSL constants used in `HAS_TLS1_3`
# use require, see https://github.com/puma/puma/pull/2381
require 'puma/puma_http11'
Expand Down Expand Up @@ -277,6 +278,7 @@ def check
else
# non-jruby Context properties
attr_reader :key
attr_reader :key_password_command
attr_reader :cert
attr_reader :ca
attr_reader :cert_pem
Expand All @@ -291,6 +293,10 @@ def key=(key)
@key = key
end

def key_password_command=(key_password_command)
@key_password_command = key_password_command
end

def cert=(cert)
check_file cert, 'Cert'
@cert = cert
Expand All @@ -316,6 +322,17 @@ def check
raise "Cert not configured" if @cert.nil? && @cert_pem.nil?
end

# Executes the command to return the password needed to decrypt the key.
def key_password
raise "Key password command not configured" if @key_password_command.nil?

stdout_str, stderr_str, status = Open3.capture3(key_password_command)

return stdout_str.chomp if status.success?

raise "Key password failed with code #{status.exitstatus}: #{stderr_str}"
end

# Controls session reuse. Allowed values are as follows:
# * 'off' - matches the behavior of Puma 5.6 and earlier. This is included
# in case reuse 'on' is made the default in future Puma versions.
Expand Down
1 change: 1 addition & 0 deletions lib/puma/minissl/context_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def context

ctx.key = params['key'] if params['key']
ctx.key_pem = params['key_pem'] if params['key_pem']
ctx.key_password_command = params['key_password_command'] if params['key_password_command']

if params['cert'].nil? && params['cert_pem'].nil?
log_writer.error "Please specify the SSL cert via 'cert=' or 'cert_pem='"
Expand Down
64 changes: 64 additions & 0 deletions test/test_integration_ssl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,70 @@ def test_ssl_run_with_localhost_authority
activate_control_app 'tcp://#{HOST}:#{control_tcp_port}', { auth_token: '#{TOKEN}' }
app do |env|
[200, {}, [env['rack.url_scheme']]]
end
RUBY

with_server(config) do |http|
body = nil
http.start do
req = Net::HTTP::Get.new '/', {}
http.request(req) { |resp| body = resp.body }
end
assert_equal 'https', body
end
end

def test_ssl_run_with_encrypted_key
skip_if :jruby

config = <<RUBY
key_path = '#{File.expand_path '../examples/puma/encrypted_puma_keypair.pem', __dir__}'
cert_path = '#{File.expand_path '../examples/puma/cert_puma.pem', __dir__}'
key_command = '#{File.expand_path '../examples/puma/key_password_command.sh', __dir__}'
ssl_bind '#{HOST}', '#{bind_port}', {
cert: cert_path,
key: key_path,
verify_mode: 'none',
key_password_command: key_command
}
activate_control_app 'tcp://#{HOST}:#{control_tcp_port}', { auth_token: '#{TOKEN}' }
app do |env|
[200, {}, [env['rack.url_scheme']]]
end
RUBY

with_server(config) do |http|
body = nil
http.start do
req = Net::HTTP::Get.new '/', {}
http.request(req) { |resp| body = resp.body }
end
assert_equal 'https', body
end
end

def test_ssl_run_with_encrypted_pem
skip_if :jruby

config = <<RUBY
key_path = '#{File.expand_path '../examples/puma/encrypted_puma_keypair.pem', __dir__}'
cert_path = '#{File.expand_path '../examples/puma/cert_puma.pem', __dir__}'
key_command = '#{File.expand_path '../examples/puma/key_password_command.sh', __dir__}'
ssl_bind '#{HOST}', '#{bind_port}', {
cert_pem: File.read(cert_path),
key_pem: File.read(key_path),
verify_mode: 'none',
key_password_command: key_command
}
activate_control_app 'tcp://#{HOST}:#{control_tcp_port}', { auth_token: '#{TOKEN}' }
app do |env|
[200, {}, [env['rack.url_scheme']]]
end
Expand Down
7 changes: 7 additions & 0 deletions test/test_minissl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,12 @@ def test_raises_with_invalid_cert_pem
exception = assert_raises(ArgumentError) { ctx.cert_pem = nil }
assert_equal("'cert_pem' is not a String", exception.message)
end

def test_raises_with_invalid_key_password_command
ctx = Puma::MiniSSL::Context.new
ctx.key_password_command = '/unreadable/decrypt_command'

assert_raises(Errno::ENOENT) { ctx.key_password }
end
end
end if ::Puma::HAS_SSL

0 comments on commit 8cf9ce2

Please sign in to comment.