apple / swift-nio-ssl

TLS Support for SwiftNIO, based on BoringSSL.
https://swiftpackageindex.com/apple/swift-nio-ssl/main/documentation/niossl
Apache License 2.0
387 stars 139 forks source link

Get the final handshake TLS version #337

Closed Chenyibo closed 2 years ago

Chenyibo commented 2 years ago

When using swift-nio-ssl to carry out TLS handshake with the server, I need to know the final handshake TLS version. But currently swift-nio-ssl does not support showing the result, I hope you can expose this function, thanks!

agnosticdev commented 2 years ago

I did a bit of research on this today and exposing this functionality does not seem like something that would fit nicely into our APIs for NIOSSLContext. For example, using NIOSSLHTTP1Client as a test bed, and badssl.com as test API, I was able to establish a connection with TLSv1, and then use the NIOSSLContext object available in NIOSSLHTTP1Client to extract the TLS version, but I had to expose a public function (getTLSVersion) in NIOSSLContext to get the final TLS version data:

NIOSSLHTTP1Client

private final class HTTPResponseHandler: ChannelInboundHandler {

    ...

    func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        let httpResponsePart = unwrapInboundIn(data)
        switch httpResponsePart {
        case .head(let httpResponseHeader):
            print("\(httpResponseHeader.version) \(httpResponseHeader.status.code) \(httpResponseHeader.status.reasonPhrase)")
            for (name, value) in httpResponseHeader.headers {
                print("\(name): \(value)")
            }
        case .body(var byteBuffer):
            if let data = byteBuffer.readData(length: byteBuffer.readableBytes) {
                FileHandle.standardOutput.write(data)
            }
        case .end(_):
            closeFuture = context.channel.close()
            // Extract the version from the cached SSL OpaquePointer for this connection that is saved in NIOSSLContext.
            print("Context TLS version \(sslContext.getTLSVersion())")
            promise.succeed(())
        }
    }

    ...
}

if let u = arg1 {
    url = URL(string: u)!
} else {
    url = URL(string: "https://tls-v1-0.badssl.com:1010")!
}

var tlsConfiguration = TLSConfiguration.makeClientConfiguration()
tlsConfiguration.maximumTLSVersion = .tlsv11
tlsConfiguration.minimumTLSVersion = .tlsv1

let sslContext = try! NIOSSLContext(configuration: tlsConfiguration)

And from NIOSSLContext.swift, I had to grab the sslPointer from the incoming connection and cache this as property of the class to extract TLS version from this property when the connection is finished. This is not great, and probably something that we want to avoid doing. Note that the reason I had to cache the sslPointer is because this is the object that BoringSSL uses to extract the on-wire version of TLS used for the connection.

public final class NIOSSLContext {

    // Note that this pointer holds data based on 1 connection ONLY. 
    private var sslPointer: OpaquePointer?

    internal func createConnection() -> SSLConnection? {
        guard let ssl = CNIOBoringSSL_SSL_new(self.sslContext) else {
            return nil
        }
        // This is not great
        sslPointer = ssl

        ...
    }

    public func getTLSVersion() -> String {
        guard self.sslPointer != nil else {
            return "(N/A)"
        }

        let uint16Version = CNIOBoringSSL_SSL_version(sslPointer)

        switch uint16Version {
          case TLS1_3_VERSION:
            return "TLSv1.3"

          case TLS1_2_VERSION:
            return "TLSv1.2"

          case TLS1_1_VERSION:
            return "TLSv1.1"

          case TLS1_VERSION:
            return "TLSv1"

          case DTLS1_VERSION:
            return "DTLSv1"

          case DTLS1_2_VERSION:
            return "DTLSv1.2"

          default:
            return "unknown";
        }
    }
    ...
}

Produces the following:

$ swift run NIOSSLHTTP1Client
[3/3] Build complete!
HTTP/1.1 200 OK
Server: nginx/1.10.3 (Ubuntu)
Date: Mon, 10 Jan 2022 17:55:47 GMT
Content-Type: text/html
Content-Length: 496
Last-Modified: Sat, 04 Dec 2021 00:09:04 GMT
Connection: close
ETag: "61aab1a0-1f0"
Cache-Control: no-store
Accept-Ranges: bytes
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="shortcut icon" href="/icons/favicon-red.ico"/>
  <link rel="apple-touch-icon" href="/icons/icon-red.png"/>
  <title>tls-v1-0.badssl.com</title>
  <link rel="stylesheet" href="/style.css">
  <style>body { background: red; }</style>
</head>
<body>
<div id="content">
  <h1 style="font-size: 12vw;">
    tls-v1-0.<br>badssl.com
  </h1>
</div>

</body>
</html>
Context TLS version TLSv1

However, @Lukasa I'd like to get your thoughts on this matter too.

Lukasa commented 2 years ago

Thanks for the investigation @agnosticdev!

I think the best move here is not to expose this API on the NIOSSLContext, because there is no requirement that a single NIOSSLContext have a 1:1 relationship with an SSLConnection. In fact, it probably shouldn't, as the NIOSSLContext hides a TLS session cache that can speed up subsequent TLS transactions with the same state.

The best API probably extends NIOSSLHandler to allow querying of this at runtime. We can then provide a helper API on Channel that abstracts away the need to find the handler and then call this method. As the NIOSSLHandler does have a 1:1 relationship with the TLS connection, we can safely store this value.

agnosticdev commented 2 years ago

I think the best move here is not to expose this API on the NIOSSLContext, because there is no requirement that a single NIOSSLContext have a 1:1 relationship with an SSLConnection. In fact, it probably shouldn't, as the NIOSSLContext hides a TLS session cache that can speed up subsequent TLS transactions with the same state.

Agreed, and it would require exposing a public API on NIOSSLContext which is something that does not sit well with me.

Regarding:

The best API probably extends NIOSSLHandler to allow querying of this at runtime. We can then provide a helper API on Channel that abstracts away the need to find the handler and then call this method. As the NIOSSLHandler does have a 1:1 relationship with the TLS connection, we can safely store this value.

Okay great. I'll take a look at this path and post something if I run into any issues. Appreciate the feedback here @Lukasa as always.

agnosticdev commented 2 years ago

@Lukasa question here on the placement of these APIs to extract the TLSVersion. Right now I have them sitting in NIOSSLContext as extensions for development but wanted to get your thoughts on if there was a better place for them out in the swift-nio-ssl project?

extension NIOSSLHandler {
    /// Variable that can be queried during the connection lifecycle to grab the `TLSVesion` used on the connection.
    public var tlsVersionForHandler: TLSVersion {
        return self.connection.getTLSVersionForConnection()
    }
}

extension Channel {
    ///  API to add the `NIOSSLHandler` to a channel to extract the `TLSVersion` from the result type during
    ///  lifecycle events such as `whenSuccess` or `whenComplete`.
    public func nioSSL_tlsVersionOnChannel() -> EventLoopFuture<NIOSSLHandler> {
        return self.pipeline.handler(type: NIOSSLHandler.self)
    }
}

extension ChannelPipeline.SynchronousOperations {
    /// API to query the `TLSVersion` directly from the `Channel`.
    public func nioSSL_tlsVersionForChannel() -> TLSVersion {
        let handler = try! self.handler(type: NIOSSLHandler.self)
        return handler.tlsVersionForHandler
    }
}

Thank you!

Lukasa commented 2 years ago

I think putting them in the file with NIOSSLHandler is the better place.

agnosticdev commented 2 years ago

Great, that works perfect. Thanks.

agnosticdev commented 2 years ago

@Chenyibo #338 has now been merged. Once this hits a release you can take advantage of this feature with 2 APIs to extract the final TLSVersion. Using NIOSSLHTTP1Client as an example, you could create an EventLoopFuture to extract the TLSVersion:

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
    let httpResponsePart = unwrapInboundIn(data)

    // First Option, creating an EventLoopFuture to extract the TLSVersion
    let tlsVersionForChannel = context.channel.nioSSL_tlsVersion()
    switch httpResponsePart {
    ...
    case .end(_):
        // Extract the finished TLSVersion before the channel is about to be closed.
        // This should be the final TLSVersion used on the connection.
        tlsVersionForChannel.whenComplete { result in
            switch result {
            case .success(let tlsVersion):
                print("TLSVersion: \(tlsVersion!)")
            case .failure(let error):
                print("error: \(error.localizedDescription)")
            }
        }
        closeFuture = context.channel.close()
        promise.succeed(())
    }
}

Also, using the second API option, you could extract the TLSVersion directly from the Channel. However, make sure that you do not directly add this to channelRead as it may create a deadlock. I would create a helper function here to extract the TLSVersion if you need it in this context:

func getTLSVersion(context: ChannelHandlerContext) throws {
    // Second Option, extract the TLSVersion directly from the channel.
    guard let syncTLSVersion = try context.channel.pipeline.syncOperations.nioSSL_tlsVersion() else {
        fatalError()
    }
    print("TLSVersion: \(syncTLSVersion)")
}
Chenyibo commented 2 years ago

@Chenyibo #338 has now been merged. Once this hits a release you can take advantage of this feature with 2 APIs to extract the final TLSVersion. Using NIOSSLHTTP1Client as an example, you could create an EventLoopFuture to extract the TLSVersion:

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
    let httpResponsePart = unwrapInboundIn(data)

    // First Option, creating an EventLoopFuture to extract the TLSVersion
    let tlsVersionForChannel = context.channel.nioSSL_tlsVersion()
    switch httpResponsePart {
    ...
    case .end(_):
      // Extract the finished TLSVersion before the channel is about to be closed.
      // This should be the final TLSVersion used on the connection.
        tlsVersionForChannel.whenComplete { result in
            switch result {
            case .success(let tlsVersion):
                print("TLSVersion: \(tlsVersion!)")
            case .failure(let error):
                print("error: \(error.localizedDescription)")
            }
        }
        closeFuture = context.channel.close()
        promise.succeed(())
    }
}

Also, using the second API option, you could extract the TLSVersion directly from the Channel. However, make sure that you do not directly add this to channelRead as it may create a deadlock. I would create a helper function here to extract the TLSVersion if you need it in this context:

func getTLSVersion(context: ChannelHandlerContext) throws {
    // Second Option, extract the TLSVersion directly from the channel.
    guard let syncTLSVersion = try context.channel.pipeline.syncOperations.nioSSL_tlsVersion() else {
        fatalError()
    }
    print("TLSVersion: \(syncTLSVersion)")
}

@agnosticdev Great,Thank you very very much!