How to add TLS, and optionally

I watched a video on YouTube from a try!Swift conference from 8 months ago, I think from NYC. The speaker introduced SwiftNIO and Network.framework, and I think he worked for Apple. An example was a SMTP client stack. I think Apple’s GitHub repositories has one with SwiftNIO examples, and the SMTP stack is one of them. The example doesn’t handle TLS. I’ve read somewhere that SwiftNIO can do both implicit- and start-TLS. How could that be added here? (Remember the resulting SMTP client needs to do no-TLS, implicit TLS, and start TLS.)

As a related question, if I had a text protocol like smtp, and I wanted to interrupt the text I/O to run a Kerberos or other authentication routine, how would I do that?

The session is SwiftNIO and Network.framework, and the speaker was our very own @johannesweiss.

StartTLS is not currently a thing there is pre-built support for in Network.framework. Using NWProtocolFramer and prependApplicationProtocol you can add it, but that's currently in beta right now.

In the short term, the easiest way to do it is to take NIO's general purpose TLS implementation from swift-nio-ssl. This TLS implementation is separate from the OS and runs within the NIO ChannelPipeline, and it supports being dynamically added and removed from the pipeline.

To do something like this, or your kerberos example, you need a custom ChannelHandler. This ChannelHandler should implement RemovableChannelHandler, because its purpose is to drive a handshake and then leave the pipeline. You then want to implement roughly the following logic:

  1. When the handler is added to the pipeline (func handlerAdded), it should send a STARTTLS message. It should enter a "waiting for response" mode.
  2. It should look at all inbound messages (func channelRead) for the STARTTLS response. If it doesn't see it, assume the message was in flight before the handler got added and pass it on.
  3. It should catch all outbound messages (func write) and buffer them, as we don't want to send other cleartext commands while this handshake continues.
  4. If it sees a failure response for STARTTLS, it should fire an error down the pipeline and your error catching handler should see it and close the connection (or apply whatever logic you want), then the handler should remove itself.
  5. If it sees a success response:
    1. The handler should flip into an "upgrading" state. In this state, any call to channelRead or write should cause data to be buffered: you're going to replay it afterwards.
    2. The handler should construct a NIOSSLClientHandler appropriately, and then all context.pipeline.addHandler to add that handler to the pipeline. You want to add this handler after the STARTTLSHandler.
    3. When the promise from addHandler completes, the handler should call context.pipeline.removeHandler(context: context).
    4. That call will (eventually) invoke func removeHandler. In that function, you want to deliver all buffered channelReads. These likely include encrypted data, so the NIOSSLClientHandler will want to see them.
    5. You also want to deliver all buffered writes.

This seems somewhat complex, but it's the full logic, and it's maximally defensive against other weird things happening in the pipeline.

This is not an unusual pattern: the HTTPServerUpgradeHandler also implements it. This suggests that we may want to factor this pattern out to something like ByteToMessageDecoder, to implement the common case that you want to do some kind of negotiated handshake that pauses the rest of the pipeline until it completes.

3 Likes

Awesome answer @lukasa! As a further example I added STARTTLS support to the NIOSMTP example iOS app.

2 Likes

I don't mind waiting for Catalina; how would this API change things (for either no/implicit/start-TLS and authentication routines written by others)?

This API allows you to insert a protocol "handshake" into the NWParameters protocol stack. Essentially you would write a framer that performs the STARTTLS "handshake" and, if it succeeds, calls prependApplicationProtocol with NWProtocolTLS and then turns into a passthrough.

You'd do this by writing an NWProtocolFramer implementation that, when it is told the connection is ready, sends STARTTLS and then waits for the response. If the response is positive, it would configure an NWProtocolTLS and use prependApplicationProtocol to insert it, and then call markReady to allow the connection establishment to continue.

1 Like