diff --git a/Docs/net-http-over-tls.md b/Docs/net-http-over-tls.md
index 5ec9fc6b1..513217dc1 100644
--- a/Docs/net-http-over-tls.md
+++ b/Docs/net-http-over-tls.md
@@ -171,8 +171,19 @@ As built:
and `ssl_socket_connect` then wait on `#to_io` with their own timeouts, so `open_timeout`,
`read_timeout` and `write_timeout` work without any IronRuby code in net/protocol, for
RubyGems' vendored `Gem::Net` as well.
-- Not done: the server side (`SSLServer`, `#accept`) is still not implemented, so puma over
- TLS still cannot run.
+- puma over TLS runs, on its own engine rather than `SSLServer`: puma never uses
+ `OpenSSL::SSL::SSLServer`, it drives its C extension's `Puma::MiniSSL::Engine`, OpenSSL over
+ memory BIOs. `Src/Libraries/Puma/MiniSsl.cs` is that engine on a server `SslStream` over an
+ in-memory stream: `#inject` fills its inbound side, `#extract` drains its outbound side, the
+ handshake and the reads run as SslStream's asynchronous operations, and `#read`, `#write` and
+ `#extract` first wait until SslStream has used up its input, as OpenSSL would have by the time
+ `SSL_read` returns. puma's own `Puma::MiniSSL::Socket` (minissl.rb) does the socket I/O, so
+ the TCP socket stays an ordinary one for puma's reactor. `ssl://` binds take `cert`/`key`
+ files or `cert_pem`/`key_pem`, `key_password_command`, `ca` with `verify_mode` none, peer or
+ force_peer (`puma.peercert` in the env), and `no_tlsv1`/`no_tlsv1_1`. Ignored:
+ `ssl_cipher_filter`, `ssl_ciphersuites`, `verification_flags`, `reuse`.
+- Not done: `OpenSSL::SSL::SSLServer` and `SSLSocket#accept` (server-side TLS for Ruby code
+ other than puma) still raise NotImplementedError.
## How to test it
@@ -189,6 +200,10 @@ As built:
timeouts, the name check), `NetHttp_TlsVerifyCallback1`, `NetHttp_GemNetTls1`,
`NetHttp_PlainNoOpenSsl1` and `Ssl_NonBlocking1` (stage 2: `connect_nonblock`,
`read_nonblock`, `write_nonblock`, `IO.select` and `NIO::Selector` over TLS).
+- puma over TLS: `Src/IronRuby.Tests/Runtime/PumaTlsTests.cs`, `Puma_Tls1`: puma in single mode
+ on three `ssl://` binds (cert files, `cert_pem`/`key_pem`, `force_peer` with a `ca`), with
+ `Net::HTTP` and `ca_file:`, keep-alive, pipelining, plain HTTP on the TLS port, and client
+ certificates presented through .NET's `SslStream`.
## Before this
diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs
index 0d86b17bf..41bc8c8fe 100644
--- a/Src/IronRuby.Tests/RubyTests.cs
+++ b/Src/IronRuby.Tests/RubyTests.cs
@@ -764,6 +764,7 @@ public Tests(Driver/*!*/ driver) {
NetHttp_PlainNoOpenSsl1,
NetHttp_GemNetTls1,
Ssl_NonBlocking1,
+ Puma_Tls1,
Sprintf_MriTieRounding1,
Float_NegativeZero1,
Math_CLibrary1,
diff --git a/Src/IronRuby.Tests/Runtime/PumaTlsTests.cs b/Src/IronRuby.Tests/Runtime/PumaTlsTests.cs
new file mode 100644
index 000000000..c507181ae
--- /dev/null
+++ b/Src/IronRuby.Tests/Runtime/PumaTlsTests.cs
@@ -0,0 +1,279 @@
+/* ****************************************************************************
+ *
+ * Copyright (c) IronRuby contributors.
+ *
+ * This source code is subject to terms and conditions of the Apache License, Version 2.0. A
+ * copy of the license can be found in the License.html file at the root of this distribution. If
+ * you cannot locate the Apache License, Version 2.0, please send an email to
+ * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound
+ * by the terms of the Apache License, Version 2.0.
+ *
+ * You must not remove this notice, or any other, from this software.
+ *
+ *
+ * ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+
+namespace IronRuby.Tests {
+
+ public partial class Tests {
+ ///
+ /// puma serving TLS in single mode, on Puma::MiniSSL::Engine (Src/Libraries/Puma/MiniSsl.cs):
+ /// an ssl:// bind with cert and key files, an ssl_bind with cert_pem and key_pem, and one
+ /// with verify_mode=force_peer and a ca. Net::HTTP talks to the first two with ca_file,
+ /// keep-alive and a large POST; an SSLSocket pipelines two requests; plain HTTP on the TLS
+ /// port is turned away and puma serves on. The client certificates are presented by
+ /// PumaTlsClient (.NET's SslStream), as IronRuby's SSLSocket has no client certificates.
+ ///
+ [Options(Prism = true)]
+ public void Puma_Tls1() {
+ using (var files = new PumaTlsFiles()) {
+ Runtime.Globals.SetVariable("MtlsClient", new PumaTlsClient(files.ClientPfx, files.RoguePfx));
+ TestOutput(@"
+require 'ruby4'
+require 'net/http'
+require 'openssl'
+require 'stringio'
+require 'puma'
+require 'puma/configuration'
+require 'puma/launcher'
+require 'puma/log_writer'
+
+PORTS = " + files.RubyPorts + @"
+CERT = '" + files.Cert + @"'
+KEY = '" + files.Key + @"'
+CA = '" + files.Ca + @"'
+
+p Puma.ssl?
+
+app = lambda do |env|
+ body = env['rack.input'].read
+ cert = env['puma.peercert']
+ text = env['PATH_INFO'] == '/big' ? 'x' * 200_000 :
+ [env['REQUEST_METHOD'], env['PATH_INFO'], body.bytesize, env['HTTPS'], cert && cert.subject].compact.join(' ')
+ [200, { 'content-type' => 'text/plain' }, [text]]
+end
+
+conf = Puma::Configuration.new do |c|
+ c.bind ""ssl://127.0.0.1:#{PORTS[0]}?cert=#{CERT}&key=#{KEY}""
+ c.ssl_bind '127.0.0.1', PORTS[1], cert_pem: File.read(CERT), key_pem: File.read(KEY)
+ c.bind ""ssl://127.0.0.1:#{PORTS[2]}?cert=#{CERT}&key=#{KEY}&ca=#{CA}&verify_mode=force_peer""
+ c.app app
+ c.threads 1, 4
+ c.workers 0
+end
+$0 ||= 'puma' # the launcher looks for the script it was started from, and for Gem.ruby
+require 'rubygems'
+log = StringIO.new
+launcher = Puma::Launcher.new(conf, log_writer: Puma::LogWriter.new(log, log))
+server = Thread.new { launcher.run }
+100.times do
+ begin
+ TCPSocket.new('127.0.0.1', PORTS[2]).close
+ break
+ rescue SystemCallError
+ sleep 0.1
+ end
+end
+
+def http(port)
+ h = Net::HTTP.new('127.0.0.1', port)
+ h.use_ssl = true
+ h.ca_file = CERT
+ h.max_retries = 0
+ h
+end
+
+# keep-alive: four requests on one connection
+http(PORTS[0]).start do |h|
+ puts h.get('/a').body
+ puts h.post('/b', 'y' * 300_000).body
+ p h.get('/big').body.bytesize
+ puts h.get('/c').body
+end
+puts http(PORTS[1]).start { |h| h.get('/pem').body }
+
+# two requests in one TLS record
+ctx = OpenSSL::SSL::SSLContext.new
+ctx.set_params(ca_file: CERT)
+s = OpenSSL::SSL::SSLSocket.new(TCPSocket.new('127.0.0.1', PORTS[0]), ctx)
+s.sync_close = true
+s.connect
+s.write ""GET /one HTTP/1.1\r\nHost: x\r\n\r\nGET /two HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n""
+p s.read.scan(/GET \S+/)
+s.close
+
+# plain HTTP on the TLS port
+c = TCPSocket.new('127.0.0.1', PORTS[0])
+c.write ""GET / HTTP/1.1\r\nHost: x\r\n\r\n""
+answer = begin; c.read; rescue SystemCallError; ''; end
+p answer.include?('HTTP')
+c.close
+puts http(PORTS[0]).start { |h| h.get('/after').body }
+
+# force_peer: only a certificate the ca issued gets in
+puts MtlsClient.Get(PORTS[2], '/issued', 'client')
+puts MtlsClient.Get(PORTS[2], '/rogue', 'rogue')
+puts MtlsClient.Get(PORTS[2], '/none', nil)
+
+launcher.stop
+server.join
+p log.string.include?('SSL error')
+", @"
+true
+GET /a 0 https
+POST /b 300000 https
+200000
+GET /c 0 https
+GET /pem 0 https
+[""GET /one"", ""GET /two""]
+false
+GET /after 0 https
+GET /issued 0 https /CN=test client
+refused
+refused
+true
+");
+ }
+ }
+
+ ///
+ /// A server certificate for 127.0.0.1 and its key as PEM files, a CA (PEM) and a client
+ /// certificate it issued, a self-signed client certificate (both PFX), and three free ports.
+ ///
+ private sealed class PumaTlsFiles : IDisposable {
+ private static readonly Oid ClientAuthentication = new Oid("1.3.6.1.5.5.7.3.2");
+ private readonly List _paths = new List();
+ private readonly int[] _ports = new int[3];
+
+ public readonly string Cert, Key, Ca, ClientPfx, RoguePfx;
+
+ public PumaTlsFiles() {
+ var now = DateTimeOffset.UtcNow;
+ using (var key = RSA.Create(2048)) {
+ var request = new CertificateRequest("CN=127.0.0.1", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var names = new SubjectAlternativeNameBuilder();
+ names.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(names.Build());
+ using (var cert = request.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1))) {
+ Cert = Write(".pem", cert.ExportCertificatePem() + "\n");
+ Key = Write(".pem", key.ExportPkcs8PrivateKeyPem() + "\n");
+ }
+ }
+
+ using (var caKey = RSA.Create(2048)) {
+ var request = new CertificateRequest("CN=Test Client CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
+ using (var ca = request.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1))) {
+ Ca = Write(".pem", ca.ExportCertificatePem() + "\n");
+ ClientPfx = WritePfx(ClientCertificate("CN=test client", ca));
+ }
+ }
+ RoguePfx = WritePfx(ClientCertificate("CN=rogue client", null));
+
+ for (int i = 0; i < _ports.Length; i++) {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ _ports[i] = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ }
+ }
+
+ public string/*!*/ RubyPorts {
+ get { return "[" + String.Join(", ", _ports) + "]"; }
+ }
+
+ // Issued by ca, or self-signed without one.
+ private static X509Certificate2/*!*/ ClientCertificate(string/*!*/ subject, X509Certificate2 ca) {
+ using (var key = RSA.Create(2048)) {
+ var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { ClientAuthentication }, false));
+ var now = DateTimeOffset.UtcNow;
+ if (ca == null) {
+ return request.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1));
+ }
+ var serial = new byte[8];
+ RandomNumberGenerator.Fill(serial);
+ serial[0] &= 0x7f;
+ using (var issued = request.Create(ca, now.AddMinutes(-5), now.AddHours(12), serial)) {
+ return issued.CopyWithPrivateKey(key);
+ }
+ }
+ }
+
+ private string/*!*/ Write(string/*!*/ extension, string/*!*/ content) {
+ string path = Path.Combine(Path.GetTempPath(), "ironruby-puma-tls-" + Guid.NewGuid().ToString("N") + extension);
+ File.WriteAllText(path, content);
+ _paths.Add(path);
+ return path.Replace("\\", "/");
+ }
+
+ private string/*!*/ WritePfx(X509Certificate2/*!*/ certificate) {
+ using (certificate) {
+ string path = Path.Combine(Path.GetTempPath(), "ironruby-puma-tls-" + Guid.NewGuid().ToString("N") + ".pfx");
+ File.WriteAllBytes(path, certificate.Export(X509ContentType.Pfx));
+ _paths.Add(path);
+ return path;
+ }
+ }
+
+ public void Dispose() {
+ foreach (var path in _paths) {
+ try {
+ File.Delete(path);
+ } catch (IOException) {
+ }
+ }
+ }
+ }
+
+ ///
+ /// A TLS client with a client certificate: GET path, and the response body, or "refused".
+ ///
+ public sealed class PumaTlsClient {
+ private readonly Dictionary _pfx = new Dictionary();
+
+ internal PumaTlsClient(string/*!*/ client, string/*!*/ rogue) {
+ _pfx["client"] = client;
+ _pfx["rogue"] = rogue;
+ }
+
+ public string/*!*/ Get(int port, string/*!*/ path, string certificate) {
+ var certificates = new X509CertificateCollection();
+ if (certificate != null) {
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadPkcs12FromFile(_pfx[certificate], null));
+#else
+ certificates.Add(new X509Certificate2(_pfx[certificate]));
+#endif
+ }
+ try {
+ using (var tcp = new TcpClient("127.0.0.1", port))
+ using (var ssl = new SslStream(tcp.GetStream(), false, (sender, cert, chain, errors) => true)) {
+ ssl.ReadTimeout = ssl.WriteTimeout = 10000;
+ ssl.AuthenticateAsClient("127.0.0.1", certificates, false);
+ var request = Encoding.ASCII.GetBytes("GET " + path + " HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
+ ssl.Write(request, 0, request.Length);
+ var response = new MemoryStream();
+ ssl.CopyTo(response);
+ string text = Encoding.ASCII.GetString(response.ToArray());
+ int body = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
+ return body >= 0 ? text.Substring(body + 4) : "refused";
+ }
+ } catch (Exception e) when (e is IOException || e is SocketException || e is System.Security.Authentication.AuthenticationException) {
+ return "refused";
+ }
+ }
+ }
+ }
+}
diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs
index 680c33439..a5b438138 100644
--- a/Src/Libraries/Initializers.Generated.cs
+++ b/Src/Libraries/Initializers.Generated.cs
@@ -17632,6 +17632,7 @@ public sealed class PumaLibraryInitializer : IronRuby.Builtins.LibraryInitialize
protected override void LoadModules() {
IronRuby.Builtins.RubyClass classRef0 = GetClass(typeof(IronRuby.Builtins.RubyObject));
IronRuby.Builtins.RubyClass classRef1 = GetClass(typeof(System.SystemException));
+ IronRuby.Builtins.RubyClass classRef2 = GetClass(typeof(System.Object));
IronRuby.Builtins.RubyModule def1 = DefineGlobalModule("Puma", typeof(IronRuby.StandardLibrary.Puma.PumaModule), 0x00000008, null, null, null, IronRuby.Builtins.RubyModule.EmptyArray);
@@ -17640,8 +17641,19 @@ protected override void LoadModules() {
);
IronRuby.Builtins.RubyClass def3 = DefineClass("Puma::HttpParserError", typeof(IronRuby.StandardLibrary.Puma.PumaModule.HttpParserError), 0x00000008, classRef1, null, null, null, IronRuby.Builtins.RubyModule.EmptyArray,
new Func(PumaLibraryInitializer.ExceptionFactory__Puma__HttpParserError));
+ IronRuby.Builtins.RubyModule def4 = DefineModule("Puma::MiniSSL", typeof(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl), 0x00000008, null, LoadPuma__MiniSSL_Class, LoadPuma__MiniSSL_Constants, IronRuby.Builtins.RubyModule.EmptyArray);
+ IronRuby.Builtins.RubyClass def5 = DefineClass("Puma::MiniSSL::Engine", typeof(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine), 0x00000008, classRef2, LoadPuma__MiniSSL__Engine_Instance, LoadPuma__MiniSSL__Engine_Class, null, IronRuby.Builtins.RubyModule.EmptyArray);
+ IronRuby.Builtins.RubyClass def6 = DefineClass("Puma::MiniSSL::SSLContext", typeof(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLContext), 0x00000008, classRef2, null, null, null, IronRuby.Builtins.RubyModule.EmptyArray,
+ new Func>, IronRuby.Builtins.RubyClass, System.Object, IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLContext>(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLContext.Create)
+ );
+ IronRuby.Builtins.RubyClass def7 = DefineClass("Puma::MiniSSL::SSLError", typeof(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLError), 0x00000008, classRef1, null, null, null, IronRuby.Builtins.RubyModule.EmptyArray,
+ new Func(PumaLibraryInitializer.ExceptionFactory__Puma__MiniSSL__SSLError));
SetConstant(def1, "HttpParser", def2);
SetConstant(def1, "HttpParserError", def3);
+ SetConstant(def1, "MiniSSL", def4);
+ SetConstant(def4, "Engine", def5);
+ SetConstant(def4, "SSLContext", def6);
+ SetConstant(def4, "SSLError", def7);
}
private static void LoadPuma__HttpParser_Instance(IronRuby.Builtins.RubyModule/*!*/ module) {
@@ -17687,10 +17699,82 @@ private static void LoadPuma__HttpParser_Instance(IronRuby.Builtins.RubyModule/*
}
+ private static void LoadPuma__MiniSSL_Constants(IronRuby.Builtins.RubyModule/*!*/ module) {
+ SetConstant(module, "OPENSSL_LIBRARY_VERSION", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_LIBRARY_VERSION);
+ SetConstant(module, "OPENSSL_NO_SSL3", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_NO_SSL3);
+ SetConstant(module, "OPENSSL_NO_TLS1", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_NO_TLS1);
+ SetConstant(module, "OPENSSL_NO_TLS1_1", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_NO_TLS1_1);
+ SetConstant(module, "OPENSSL_VERSION", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_VERSION);
+
+ }
+
+ private static void LoadPuma__MiniSSL_Class(IronRuby.Builtins.RubyModule/*!*/ module) {
+ DefineLibraryMethod(module, "check", 0x21,
+ 0x00000000U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Check)
+ );
+
+ }
+
+ private static void LoadPuma__MiniSSL__Engine_Instance(IronRuby.Builtins.RubyModule/*!*/ module) {
+ DefineLibraryMethod(module, "extract", 0x11,
+ 0x00000000U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Extract)
+ );
+
+ DefineLibraryMethod(module, "init?", 0x11,
+ 0x00000000U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.InHandshake)
+ );
+
+ DefineLibraryMethod(module, "inject", 0x11,
+ 0x00000002U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Inject)
+ );
+
+ DefineLibraryMethod(module, "peercert", 0x11,
+ 0x00000000U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.GetPeerCertificate)
+ );
+
+ DefineLibraryMethod(module, "read", 0x11,
+ 0x00000000U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Read)
+ );
+
+ DefineLibraryMethod(module, "shutdown", 0x11,
+ 0x00000000U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Shutdown)
+ );
+
+ DefineLibraryMethod(module, "ssl_vers_st", 0x11,
+ 0x00000000U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.GetVersionState)
+ );
+
+ DefineLibraryMethod(module, "write", 0x11,
+ 0x00000002U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Write)
+ );
+
+ }
+
+ private static void LoadPuma__MiniSSL__Engine_Class(IronRuby.Builtins.RubyModule/*!*/ module) {
+ DefineLibraryMethod(module, "server", 0x21,
+ 0x00000002U,
+ new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Server)
+ );
+
+ }
+
public static System.Exception/*!*/ ExceptionFactory__Puma__HttpParserError(IronRuby.Builtins.RubyClass/*!*/ self, [DefaultParameterValueAttribute(null)]object message) {
return IronRuby.Runtime.RubyExceptionData.InitializeException(new IronRuby.StandardLibrary.Puma.PumaModule.HttpParserError(IronRuby.Runtime.RubyExceptionData.GetClrMessage(self, message), (System.Exception)null), message);
}
+ public static System.Exception/*!*/ ExceptionFactory__Puma__MiniSSL__SSLError(IronRuby.Builtins.RubyClass/*!*/ self, [DefaultParameterValueAttribute(null)]object message) {
+ return IronRuby.Runtime.RubyExceptionData.InitializeException(new IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLError(IronRuby.Runtime.RubyExceptionData.GetClrMessage(self, message), (System.Exception)null), message);
+ }
+
}
}
diff --git a/Src/Libraries/Puma/MiniSsl.cs b/Src/Libraries/Puma/MiniSsl.cs
new file mode 100644
index 000000000..9e64aefba
--- /dev/null
+++ b/Src/Libraries/Puma/MiniSsl.cs
@@ -0,0 +1,618 @@
+/* ****************************************************************************
+ *
+ * Copyright (c) IronRuby contributors.
+ *
+ * Puma's TLS engine - ext/puma_http11/mini_ssl.c - for IronRuby, on System.Net.Security.SslStream.
+ * 3-clause BSD, as puma is.
+ *
+ * ***************************************************************************/
+
+// What this is, and where it differs from the C extension:
+//
+// * The C extension is OpenSSL over a pair of memory BIOs: puma reads ciphertext off the
+// socket and hands it to Engine#inject, takes plaintext from #read, gives plaintext to #write
+// and sends what #extract answers. Here the pair of BIOs is MemoryBio, the inner stream of a
+// server SslStream: #inject fills its inbound side, #extract drains its outbound side. The
+// handshake and the reads run as SslStream's asynchronous operations, waiting on MemoryBio for
+// input; nothing blocks on the socket, which puma alone touches.
+// * OpenSSL does its work inside SSL_read, so the plaintext (or the handshake's answer) is there
+// as soon as #inject returns. SslStream does it in continuations on the thread pool. So that
+// puma sees the same thing, #read, #extract and #write first wait until SslStream has used up
+// the input and is waiting for more (or has finished): it never waits for anything else, so
+// that is a matter of microseconds.
+// * #read answers all the plaintext there is, not 512 bytes of it at a time. #extract likewise.
+// * The context: cert and key (files, or cert_pem and key_pem), key_password_command for an
+// encrypted key, ca and verify_mode none, peer or force_peer for client certificates,
+// no_tlsv1 and no_tlsv1_1. Not used: ssl_cipher_filter and ssl_ciphersuites (SslStream takes
+// the system's cipher suites; Windows has no per-connection list), verification_flags and
+// reuse (SslStream keeps its own session cache).
+// * Engine.client, used by puma's own tests only, is not here.
+
+using System;
+using System.IO;
+using System.Net.Security;
+using System.Runtime.CompilerServices;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using IronRuby.Builtins;
+using IronRuby.Runtime;
+using Microsoft.Scripting.Runtime;
+
+namespace IronRuby.StandardLibrary.Puma {
+
+ public static partial class PumaModule {
+
+ [RubyModule("MiniSSL")]
+ public static class MiniSsl {
+ // puma's minissl.rb takes the version out of these (HAS_TLS1_3): the TLS SslStream
+ // speaks is what OpenSSL 3.0 speaks.
+ [RubyConstant]
+ public const string OPENSSL_VERSION = "OpenSSL 3.0.0 (IronRuby, System.Net.Security.SslStream)";
+
+ [RubyConstant]
+ public const string OPENSSL_LIBRARY_VERSION = OPENSSL_VERSION;
+
+ [RubyConstant]
+ public const bool OPENSSL_NO_SSL3 = true;
+
+ [RubyConstant]
+ public const bool OPENSSL_NO_TLS1 = false;
+
+ [RubyConstant]
+ public const bool OPENSSL_NO_TLS1_1 = false;
+
+ [RubyMethod("check", RubyMethodAttributes.PublicSingleton)]
+ public static object Check(RubyModule/*!*/ self) {
+ return null;
+ }
+
+ [RubyException("SSLError")]
+ public class SSLError : SystemException {
+ public SSLError() : this(null, null) { }
+ public SSLError(string message) : this(message, null) { }
+ public SSLError(string message, Exception inner) : base(message ?? "Puma::MiniSSL::SSLError", inner) { }
+ }
+
+ // puma's VERIFY_PEER and VERIFY_FAIL_IF_NO_PEER_CERT, OpenSSL's values.
+ private const int VerifyPeer = 1;
+ private const int VerifyFailIfNoPeerCert = 2;
+
+ private static readonly Oid ClientAuthentication = new Oid("1.3.6.1.5.5.7.3.2");
+
+ ///
+ /// What a Puma::MiniSSL::Context says, loaded once per listener: the server
+ /// certificate with its key and chain, and how to check a client's certificate.
+ ///
+ [RubyClass("SSLContext")]
+ public sealed class SSLContext {
+ internal SslStreamCertificateContext/*!*/ Certificate;
+ internal X509Certificate2Collection Ca;
+ internal int VerifyMode;
+ internal SslProtocols Protocols;
+
+ [RubyConstructor]
+ public static SSLContext/*!*/ Create(CallSiteStorage>/*!*/ keyPasswordStorage,
+ RubyClass/*!*/ self, [NotNull]object/*!*/ miniContext) {
+
+ var context = self.Context;
+ string key = FileText(context, Get(context, miniContext, "@key"), "key");
+ if (key == null) {
+ key = StringOrNull(Get(context, miniContext, "@key_pem"));
+ }
+ string cert = FileText(context, Get(context, miniContext, "@cert"), "cert");
+ if (cert == null) {
+ cert = StringOrNull(Get(context, miniContext, "@cert_pem"));
+ }
+ if (key == null) {
+ throw new SSLError("no SSL key given (key= or key_pem=)");
+ }
+ if (cert == null) {
+ throw new SSLError("no SSL certificate given (cert= or cert_pem=)");
+ }
+
+ string password = null;
+ if (Get(context, miniContext, "@key_password_command") != null) {
+ var site = keyPasswordStorage.GetCallSite("key_password", 0);
+ password = StringOrNull(site.Target(site, miniContext));
+ }
+
+ var result = new SSLContext();
+ result.Certificate = LoadCertificate(cert, key, password);
+
+ string ca = FileText(context, Get(context, miniContext, "@ca"), "ca");
+ if (ca != null) {
+ result.Ca = new X509Certificate2Collection();
+ try {
+ result.Ca.ImportFromPem(ca);
+ } catch (CryptographicException e) {
+ throw new SSLError("cannot load the SSL ca: " + e.Message, e);
+ }
+ }
+
+ object verifyMode = Get(context, miniContext, "@verify_mode");
+ result.VerifyMode = verifyMode is int ? (int)verifyMode : 0;
+
+#pragma warning disable SYSLIB0039 // TLS 1.0 and 1.1: what no_tlsv1 and no_tlsv1_1 are about
+ if (RubyOps.IsTrue(Get(context, miniContext, "@no_tlsv1_1"))) {
+ result.Protocols = SslProtocols.Tls12 | SslProtocols.Tls13;
+ } else if (RubyOps.IsTrue(Get(context, miniContext, "@no_tlsv1"))) {
+ result.Protocols = SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13;
+ } else {
+ result.Protocols = SslProtocols.None;
+ }
+#pragma warning restore SYSLIB0039
+ return result;
+ }
+
+ private static object Get(RubyContext/*!*/ context, object/*!*/ obj, string/*!*/ name) {
+ object value;
+ return context.TryGetInstanceVariable(obj, name, out value) ? value : null;
+ }
+
+ private static string StringOrNull(object value) {
+ var str = value as MutableString;
+ return str != null ? str.ToString() : null;
+ }
+
+ private static string FileText(RubyContext/*!*/ context, object path, string/*!*/ what) {
+ string name = StringOrNull(path);
+ if (name == null) {
+ return null;
+ }
+ try {
+ using (var stream = context.DomainManager.Platform.OpenInputFileStream(name))
+ using (var reader = new StreamReader(stream)) {
+ return reader.ReadToEnd();
+ }
+ } catch (IOException e) {
+ throw new SSLError(String.Format("cannot read the SSL {0} file {1}: {2}", what, name, e.Message), e);
+ } catch (UnauthorizedAccessException e) {
+ throw new SSLError(String.Format("cannot read the SSL {0} file {1}: {2}", what, name, e.Message), e);
+ }
+ }
+
+ // The first certificate of the PEM is the server's, the others its chain.
+ private static SslStreamCertificateContext/*!*/ LoadCertificate(string/*!*/ certPem, string/*!*/ keyPem, string password) {
+ X509Certificate2 leaf;
+ var chain = new X509Certificate2Collection();
+ try {
+ leaf = password != null
+ ? X509Certificate2.CreateFromEncryptedPem(certPem, keyPem, password)
+ : X509Certificate2.CreateFromPem(certPem, keyPem);
+ chain.ImportFromPem(certPem);
+ } catch (CryptographicException e) {
+ throw new SSLError("cannot load the SSL certificate and key: " + e.Message, e);
+ } catch (ArgumentException e) {
+ throw new SSLError("cannot load the SSL certificate and key: " + e.Message, e);
+ }
+ if (chain.Count > 0) {
+ chain.RemoveAt(0);
+ }
+
+ // SChannel cannot serve with an ephemeral key: round-trip the certificate through PFX.
+ if (OperatingSystem.IsWindows()) {
+ using (leaf) {
+#if NET9_0_OR_GREATER
+ leaf = X509CertificateLoader.LoadPkcs12(leaf.Export(X509ContentType.Pfx), null);
+#else
+ leaf = new X509Certificate2(leaf.Export(X509ContentType.Pfx));
+#endif
+ }
+ }
+ return SslStreamCertificateContext.Create(leaf, chain, offline: true);
+ }
+ }
+
+ ///
+ /// One TLS connection, server side: an SslStream over a MemoryBio.
+ ///
+ [RubyClass("Engine")]
+ public sealed class Engine {
+ // Guards everything below and MemoryBio's buffers; never held while SslStream runs.
+ private readonly object _sync = new object();
+ private readonly ByteQueue/*!*/ _inbound = new ByteQueue();
+ private readonly ByteQueue/*!*/ _outbound = new ByteQueue();
+ private readonly ByteQueue/*!*/ _plain = new ByteQueue();
+ // SslStream's read of the inbound side, while there is nothing to give it.
+ private TaskCompletionSource _bioRead;
+ private Memory _bioReadBuffer;
+ private bool _inboundClosed;
+
+ private SslStream _ssl;
+ private bool _verifyPeer;
+ private bool _handshaken;
+ private bool _eof; // the client sent close_notify
+ private bool _done; // the handshake or a read failed, or ended
+ private Exception _error;
+
+ // How long #read, #extract and #write wait for SslStream to take up its input.
+ // It only computes, so this is a safety net, not a timeout anyone should meet.
+ private const int IdleTimeout = 30 * 1000;
+
+ [RubyMethod("server", RubyMethodAttributes.PublicSingleton)]
+ public static Engine/*!*/ Server(RubyClass/*!*/ self, [NotNull]SSLContext/*!*/ context) {
+ var engine = new Engine();
+ engine._verifyPeer = (context.VerifyMode & VerifyPeer) != 0;
+ engine._ssl = new SslStream(new MemoryBio(engine), false);
+
+ var options = new SslServerAuthenticationOptions {
+ ServerCertificateContext = context.Certificate,
+ EnabledSslProtocols = context.Protocols,
+ CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
+ ClientCertificateRequired = engine._verifyPeer,
+ RemoteCertificateValidationCallback = (sender, certificate, chain, errors) =>
+ !engine._verifyPeer || ValidateClient(context, certificate, chain, errors),
+ };
+ // Runs up to its first read of MemoryBio, and on from there in the background.
+ _ = engine.Run(options);
+ return engine;
+ }
+
+ // OpenSSL's check of a client certificate: against the ca, as a TLS client.
+ private static bool ValidateClient(SSLContext/*!*/ context, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) {
+ if (certificate == null) {
+ return (context.VerifyMode & VerifyFailIfNoPeerCert) == 0;
+ }
+ if (context.Ca == null) {
+ return (errors & ~SslPolicyErrors.RemoteCertificateNameMismatch) == SslPolicyErrors.None;
+ }
+ using (var custom = new X509Chain())
+ using (var leaf = new X509Certificate2(certificate)) {
+ var policy = custom.ChainPolicy;
+ policy.RevocationMode = X509RevocationMode.NoCheck;
+ policy.TrustMode = X509ChainTrustMode.CustomRootTrust;
+ policy.ApplicationPolicy.Add(ClientAuthentication);
+ policy.CustomTrustStore.AddRange(context.Ca);
+ policy.ExtraStore.AddRange(context.Ca);
+ // The intermediates the client sent.
+ if (chain != null) {
+ policy.ExtraStore.AddRange(chain.ChainPolicy.ExtraStore);
+ }
+ return custom.Build(leaf);
+ }
+ }
+
+ // The handshake, then reads until the client closes: started here, it runs on as
+ // MemoryBio gets input.
+ private async Task Run(SslServerAuthenticationOptions/*!*/ options) {
+ try {
+ await _ssl.AuthenticateAsServerAsync(options, CancellationToken.None).ConfigureAwait(false);
+ lock (_sync) {
+ _handshaken = true;
+ }
+ var buffer = new byte[16 * 1024];
+ while (true) {
+ int count = await _ssl.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
+ lock (_sync) {
+ if (count == 0) {
+ _eof = true;
+ break;
+ }
+ _plain.Append(buffer, 0, count);
+ }
+ }
+ } catch (Exception e) {
+ lock (_sync) {
+ _error = e;
+ }
+ } finally {
+ bool dispose;
+ lock (_sync) {
+ _done = true;
+ dispose = _inboundClosed;
+ Monitor.PulseAll(_sync);
+ }
+ if (dispose) {
+ _ssl.Dispose();
+ }
+ }
+ }
+
+ // Waits until SslStream has taken up all of the input and waits for more, or is done.
+ // Called holding _sync.
+ private void WaitIdle() {
+ int start = Environment.TickCount;
+ while (!_done && _bioRead == null) {
+ int left = IdleTimeout - unchecked(Environment.TickCount - start);
+ if (left <= 0 || !Monitor.Wait(_sync, left)) {
+ throw new SSLError("the TLS engine did not take up its input");
+ }
+ }
+ }
+
+ private SSLError/*!*/ Failure() {
+ var authentication = _error as AuthenticationException;
+ string message = (_error.InnerException != null && authentication != null)
+ ? _error.Message + " (" + _error.InnerException.Message + ")"
+ : _error.Message;
+ return new SSLError("SSL error: " + message, _error);
+ }
+
+ ///
+ /// inject(data) -> Integer: hands ciphertext read off the socket to the engine.
+ ///
+ [RubyMethod("inject")]
+ public static int Inject(Engine/*!*/ self, [NotNull]MutableString/*!*/ data) {
+ byte[] bytes = data.ToByteArray();
+ TaskCompletionSource read = null;
+ int count = 0;
+ lock (self._sync) {
+ if (self._inboundClosed) {
+ return 0;
+ }
+ self._inbound.Append(bytes, 0, bytes.Length);
+ if (self._bioRead != null && self._inbound.Count > 0) {
+ read = self._bioRead;
+ count = self._inbound.Take(self._bioReadBuffer.Span);
+ self._bioRead = null;
+ self._bioReadBuffer = default(Memory);
+ }
+ }
+ if (read != null) {
+ read.SetResult(count);
+ }
+ return bytes.Length;
+ }
+
+ ///
+ /// read -> String or nil: the plaintext there is, nil while there is none yet.
+ /// Raises EOFError once the client has closed the TLS session, Puma::MiniSSL::SSLError
+ /// when the handshake or the connection failed.
+ ///
+ [RubyMethod("read")]
+ public static MutableString Read(Engine/*!*/ self) {
+ lock (self._sync) {
+ self.WaitIdle();
+ if (self._plain.Count > 0) {
+ return MutableString.CreateBinary(self._plain.TakeAll());
+ }
+ if (self._eof) {
+ throw new EOFError("end of file reached");
+ }
+ if (self._error != null && !self._inboundClosed) {
+ throw self.Failure();
+ }
+ return null;
+ }
+ }
+
+ ///
+ /// write(data) -> Integer: encrypts all of the plaintext; #extract has the ciphertext.
+ ///
+ [RubyMethod("write")]
+ public static int Write(Engine/*!*/ self, [NotNull]MutableString/*!*/ data) {
+ byte[] bytes = data.ToByteArray();
+ lock (self._sync) {
+ self.WaitIdle();
+ if (self._error != null) {
+ throw self.Failure();
+ }
+ if (!self._handshaken) {
+ throw new SSLError("SSL error: the handshake has not finished");
+ }
+ }
+ try {
+ // MemoryBio's writes complete at once, so this does not block.
+ self._ssl.Write(bytes, 0, bytes.Length);
+ } catch (IOException e) {
+ throw new SSLError("SSL error: " + e.Message, e);
+ } catch (ObjectDisposedException e) {
+ throw new SSLError("SSL error: " + e.Message, e);
+ }
+ return bytes.Length;
+ }
+
+ ///
+ /// extract -> String or nil: the ciphertext to send, nil when there is none.
+ ///
+ [RubyMethod("extract")]
+ public static MutableString Extract(Engine/*!*/ self) {
+ lock (self._sync) {
+ self.WaitIdle();
+ return self._outbound.Count > 0 ? MutableString.CreateBinary(self._outbound.TakeAll()) : null;
+ }
+ }
+
+ ///
+ /// shutdown -> true or false: false when there is a close_notify for #extract to hand out.
+ ///
+ [RubyMethod("shutdown")]
+ public static bool Shutdown(Engine/*!*/ self) {
+ bool notify;
+ lock (self._sync) {
+ self.WaitIdle();
+ notify = self._handshaken && self._error == null && !self._inboundClosed;
+ }
+ if (notify) {
+ try {
+ self._ssl.ShutdownAsync().GetAwaiter().GetResult();
+ } catch (IOException) {
+ } catch (InvalidOperationException) {
+ }
+ }
+
+ TaskCompletionSource read;
+ bool disposeNow;
+ lock (self._sync) {
+ // Ends the read in flight, and so the engine's loop.
+ self._inboundClosed = true;
+ read = self._bioRead;
+ self._bioRead = null;
+ self._bioReadBuffer = default(Memory);
+ disposeNow = self._done;
+ notify = self._outbound.Count > 0;
+ }
+ if (read != null) {
+ read.SetResult(0);
+ }
+ if (disposeNow) {
+ self._ssl.Dispose();
+ }
+ return !notify;
+ }
+
+ [RubyMethod("init?")]
+ public static bool InHandshake(Engine/*!*/ self) {
+ lock (self._sync) {
+ return !self._handshaken;
+ }
+ }
+
+ ///
+ /// peercert -> String or nil: the client's certificate, DER; nil with verify_mode none.
+ ///
+ [RubyMethod("peercert")]
+ public static MutableString GetPeerCertificate(Engine/*!*/ self) {
+ lock (self._sync) {
+ if (!self._verifyPeer || !self._handshaken) {
+ return null;
+ }
+ }
+ var certificate = self._ssl.RemoteCertificate;
+ return certificate != null ? MutableString.CreateBinary(certificate.GetRawCertData()) : null;
+ }
+
+ ///
+ /// ssl_vers_st -> [version, state]: SSL_get_version and SSL_state_string, which puma
+ /// looks at to tell a plain HTTP request on a TLS port from a TLS 1.3 handshake.
+ ///
+ [RubyMethod("ssl_vers_st")]
+ public static RubyArray/*!*/ GetVersionState(Engine/*!*/ self) {
+ string version = "TLSv1.3", state;
+ lock (self._sync) {
+ if (self._handshaken) {
+#pragma warning disable SYSLIB0039
+ switch (self._ssl.SslProtocol) {
+ case SslProtocols.Tls: version = "TLSv1"; break;
+ case SslProtocols.Tls11: version = "TLSv1.1"; break;
+ case SslProtocols.Tls12: version = "TLSv1.2"; break;
+ }
+#pragma warning restore SYSLIB0039
+ }
+ state = self._error != null ? "SSLERR" : self._handshaken ? "SSLOK " : "before";
+ }
+ return new RubyArray { MutableString.CreateMutable(version, RubyEncoding.Binary), MutableString.CreateMutable(state, RubyEncoding.Binary) };
+ }
+
+ ///
+ /// The engine's side of the socket: what SslStream reads is what #inject was given,
+ /// what it writes waits for #extract.
+ ///
+ private sealed class MemoryBio : Stream {
+ private readonly Engine/*!*/ _engine;
+
+ public MemoryBio(Engine/*!*/ engine) {
+ _engine = engine;
+ }
+
+ public override bool CanRead { get { return true; } }
+ public override bool CanWrite { get { return true; } }
+ public override bool CanSeek { get { return false; } }
+ public override long Length { get { throw new NotSupportedException(); } }
+ public override long Position {
+ get { throw new NotSupportedException(); }
+ set { throw new NotSupportedException(); }
+ }
+ public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); }
+ public override void SetLength(long value) { throw new NotSupportedException(); }
+ public override void Flush() { }
+
+ public override int Read(byte[]/*!*/ buffer, int offset, int count) {
+ return ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult();
+ }
+
+ public override Task ReadAsync(byte[]/*!*/ buffer, int offset, int count, CancellationToken cancellationToken) {
+ return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+ }
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default(CancellationToken)) {
+ var engine = _engine;
+ lock (engine._sync) {
+ if (engine._inbound.Count > 0) {
+ return new ValueTask(engine._inbound.Take(buffer.Span));
+ }
+ if (engine._inboundClosed || buffer.Length == 0) {
+ return new ValueTask(0);
+ }
+ // Continuations run elsewhere: not inside #inject, holding the lock of whoever called it.
+ var read = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ engine._bioRead = read;
+ engine._bioReadBuffer = buffer;
+ Monitor.PulseAll(engine._sync);
+ return new ValueTask(read.Task);
+ }
+ }
+
+ public override void Write(byte[]/*!*/ buffer, int offset, int count) {
+ lock (_engine._sync) {
+ _engine._outbound.Append(buffer, offset, count);
+ }
+ }
+
+ public override Task WriteAsync(byte[]/*!*/ buffer, int offset, int count, CancellationToken cancellationToken) {
+ Write(buffer, offset, count);
+ return Task.CompletedTask;
+ }
+
+ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default(CancellationToken)) {
+ lock (_engine._sync) {
+ _engine._outbound.Append(buffer.Span);
+ }
+ return default(ValueTask);
+ }
+
+ public override Task FlushAsync(CancellationToken cancellationToken) {
+ return Task.CompletedTask;
+ }
+ }
+ }
+
+ /// A FIFO of bytes.
+ private sealed class ByteQueue {
+ private byte[]/*!*/ _data = new byte[0];
+ private int _start, _count;
+
+ public int Count { get { return _count; } }
+
+ public void Append(byte[]/*!*/ bytes, int offset, int count) {
+ Append(new ReadOnlySpan(bytes, offset, count));
+ }
+
+ public void Append(ReadOnlySpan bytes) {
+ if (_start + _count + bytes.Length > _data.Length) {
+ if (_count + bytes.Length <= _data.Length) {
+ Buffer.BlockCopy(_data, _start, _data, 0, _count);
+ } else {
+ var data = new byte[Math.Max(_data.Length * 2, _count + bytes.Length)];
+ Buffer.BlockCopy(_data, _start, data, 0, _count);
+ _data = data;
+ }
+ _start = 0;
+ }
+ bytes.CopyTo(new Span(_data, _start + _count, bytes.Length));
+ _count += bytes.Length;
+ }
+
+ public int Take(Span destination) {
+ int count = Math.Min(_count, destination.Length);
+ new ReadOnlySpan(_data, _start, count).CopyTo(destination);
+ _start += count;
+ _count -= count;
+ if (_count == 0) {
+ _start = 0;
+ }
+ return count;
+ }
+
+ public byte[]/*!*/ TakeAll() {
+ var result = new byte[_count];
+ Take(result);
+ return result;
+ }
+ }
+ }
+ }
+}
diff --git a/Src/Libraries/Puma/PumaHttp11.cs b/Src/Libraries/Puma/PumaHttp11.cs
index 48c85c1d8..a8245d4b8 100644
--- a/Src/Libraries/Puma/PumaHttp11.cs
+++ b/Src/Libraries/Puma/PumaHttp11.cs
@@ -25,7 +25,7 @@
// PUMA_REQUEST_PATH_MAX_LENGTH, PUMA_QUERY_STRING_MAX_LENGTH) are read from the environment
// when the parser is first used, as puma's JRuby parser does - there is no compile step
// here to hand them to.
-// * Puma::MiniSSL (TLS) is not part of this file; see Src/StdLib/ironruby/puma/puma_http11.rb.
+// * Puma::MiniSSL (TLS) is not part of this file; it is in MiniSsl.cs.
using System;
using System.Collections.Generic;
@@ -37,7 +37,7 @@
namespace IronRuby.StandardLibrary.Puma {
[RubyModule("Puma")]
- public static class PumaModule {
+ public static partial class PumaModule {
[RubyException("HttpParserError")]
public class HttpParserError : SystemException {
diff --git a/Src/StdLib/ironruby/puma/puma_http11.rb b/Src/StdLib/ironruby/puma/puma_http11.rb
index e62d53e90..2c1e67141 100644
--- a/Src/StdLib/ironruby/puma/puma_http11.rb
+++ b/Src/StdLib/ironruby/puma/puma_http11.rb
@@ -5,15 +5,19 @@
#
# `gem install puma` cannot work here: the extension is C (a Ragel-generated HTTP parser, and
# MiniSSL over OpenSSL's memory BIOs). IronRuby ships puma's Ruby half unchanged
-# (Src/StdLib/ironruby/puma.rb and puma/, from puma 8.0.2) and implements the parser in C# -
-# Puma::HttpParser and Puma::HttpParserError, in Src/Libraries/Puma/PumaHttp11.cs, a port of the
-# same state machine with the C extension's messages and limits.
+# (Src/StdLib/ironruby/puma.rb and puma/, from puma 8.0.2) and implements the native half in C#:
+#
+# * Puma::HttpParser and Puma::HttpParserError, in Src/Libraries/Puma/PumaHttp11.cs, a port of
+# the same state machine with the C extension's messages and limits.
+# * Puma::MiniSSL::Engine, SSLContext and SSLError, puma's TLS, in Src/Libraries/Puma/MiniSsl.cs:
+# the engine is a server SslStream over an in-memory stream that #inject fills and #extract
+# drains, so Puma.ssl? is true and ssl:// binds serve TLS. cert/key files or cert_pem/key_pem,
+# key_password_command, ca with verify_mode none, peer or force_peer, no_tlsv1 and no_tlsv1_1
+# are honoured; ssl_cipher_filter, ssl_ciphersuites, verification_flags and reuse are not
+# (SslStream uses the system's cipher suites and its own session cache).
#
# Not implemented:
#
-# * MiniSSL, puma's TLS. Puma::MiniSSL::Engine is not defined, which is exactly how a puma
-# built without OpenSSL looks: Puma.ssl? is false, and binding an ssl:// URL fails with
-# puma's own "Puma compiled without SSL support" error. Terminate TLS in front of puma.
# * Cluster mode. .NET cannot fork(2), so Process.respond_to?(:fork) is false and puma
# refuses `workers N` with its own "worker mode not supported on ironruby on this
# platform" message. Single mode - one process, a thread pool - is fully supported.
diff --git a/Src/StdLib/ruby/gems/4.0.0/specifications/default/puma-8.0.2.gemspec b/Src/StdLib/ruby/gems/4.0.0/specifications/default/puma-8.0.2.gemspec
index 9283ea558..662fcb8c6 100644
--- a/Src/StdLib/ruby/gems/4.0.0/specifications/default/puma-8.0.2.gemspec
+++ b/Src/StdLib/ruby/gems/4.0.0/specifications/default/puma-8.0.2.gemspec
@@ -10,8 +10,8 @@ Gem::Specification.new do |s|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze)
s.require_paths = ["lib".freeze]
s.authors = ["IronRuby".freeze]
- s.description = "Puma, with its HTTP parser in C#; single mode only, no TLS. Provided by IronRuby itself.".freeze
- s.summary = "Puma, with its HTTP parser in C#; single mode only, no TLS".freeze
+ s.description = "Puma, with its HTTP parser and TLS engine in C#; single mode only. Provided by IronRuby itself.".freeze
+ s.summary = "Puma, with its HTTP parser and TLS engine in C#; single mode only".freeze
s.homepage = "https://github.com/IronLanguages/ironruby".freeze
s.licenses = ["Ruby".freeze, "BSD-2-Clause".freeze]
s.metadata = { "ironruby_pinned".freeze => "true".freeze }
diff --git a/Util/gen-default-gemspecs.rb b/Util/gen-default-gemspecs.rb
index d102ff0b2..ee11d8534 100644
--- a/Util/gen-default-gemspecs.rb
+++ b/Util/gen-default-gemspecs.rb
@@ -154,11 +154,11 @@
# nio4r and puma: both C extensions (over libev, and a Ragel HTTP parser plus OpenSSL
# glue), so neither installs here. nio4r's Ruby half is vendored with its native half
# in Src/Libraries/Nio4r; puma's Ruby half is vendored unchanged and its HTTP parser is
- # Src/Libraries/Puma. puma's MiniSSL (TLS) is not implemented: Puma.ssl? is false, as it
- # is for a puma built without OpenSSL. puma depends on nio4r, and says so here too.
+ # Src/Libraries/Puma, as is its MiniSSL engine (TLS, on SslStream). puma depends on
+ # nio4r, and says so here too.
"nio4r" => ["2.7.5", "NIO::Selector, Monitor and ByteBuffer, on epoll, poll(2) or Socket.Select",
["nio", "nio/", "nio4r", "nio4r_ext"], require: "nio", check: "NIO::VERSION", pinned: true],
- "puma" => ["8.0.2", "Puma, with its HTTP parser in C#; single mode only, no TLS",
+ "puma" => ["8.0.2", "Puma, with its HTTP parser and TLS engine in C#; single mode only",
["puma", "puma/", "rack/handler/puma"], require: "puma/const",
check: "Puma::Const::PUMA_VERSION", pinned: true, bin: ["puma", "pumactl"],
deps: [["nio4r", "~> 2.0"]]],