Unable to detect TLS PSK Failure in Bonjour "Service" mode for NWConnection/NWListener

Hi there, we're looking to build a Bonjour service for our users so that they can share data between devices. Things are mostly going ok, but we would like to make sure the connection is secure.

Being good developers we took a look at the TicTacToe example from WWDC. This looks great! We'd love to secure our comms with the latest TLS via a Pre Shared Key (PSK) e.g. a Passcode in our case.

In the normal happy path, things work well, we can send and receive messages and all is well. However, when we enter the wrong passcode we don't receive any notification back on the client side. The server can detect the incorrect passcode, but the client is left hanging around.

The issue only appears to affect a Bonjour service or mode (not quite sure of the terminology here). If we explicitly specify a host (e.g. "localhost" and port (e.g. 12345) for connection/listening then we get the expected callbacks on both client/server that the PIN was incorrect.

However if we just setup a service and try to connect to it (in our case we use NWBrowser in our App, but below we create an endpoint manually), everything works fine for a good passcode, but for a bad passcode we don't receive any callback and have no way to know the passcode was no good and inform the user.

So, we'd love to be able to detect that incorrect passcode on the client side. What are we doing wrong.

Sample code below (mostly shamelessly ripped from some of @eskimos sample code in another issue) demonstrates the issue, change the ServiceMode / Passcodes inside main() to see the issue.

Hoping we can page Dr. @eskimo and Dr. @meaton - Could really do with your expertise here. Ta!

import CryptoKit
import Foundation
import Network

let ServerName = "My-Bonjour-Server"
let ServiceName = "_my_bonjour_service._tcp"

var listenerRef: NWListener?
var receiveConnectionRef: NWConnection?
var sendConnectionRef: NWConnection?

enum ServiceMode {
    case explicitHostAndPort // This works all the time
    case bonjourService // This doesn't work for an incorrect passcode
}

extension NWParameters { // Just ripped from the TicTacToe example
    convenience init(passcode: String) {
        self.init(tls: NWParameters.tlsOptions(passcode: passcode))
    }

    private static func tlsOptions(passcode: String) -> NWProtocolTLS.Options {
        let tlsOptions = NWProtocolTLS.Options()

        let authenticationKey = SymmetricKey(data: passcode.data(using: .utf8)!)
        let authenticationCode = HMAC<SHA256>.authenticationCode(for: ServiceName.data(using: .utf8)!, using: authenticationKey)

        let authenticationDispatchData = authenticationCode.withUnsafeBytes {
            DispatchData(bytes: $0)
        }

        sec_protocol_options_add_pre_shared_key(tlsOptions.securityProtocolOptions,
                                                authenticationDispatchData as __DispatchData,
                                                stringToDispatchData(ServiceName)! as __DispatchData)

        sec_protocol_options_append_tls_ciphersuite(tlsOptions.securityProtocolOptions,
                                                    tls_ciphersuite_t(rawValue: TLS_PSK_WITH_AES_128_GCM_SHA256)!)

        return tlsOptions
    }

    private static func stringToDispatchData(_ string: String) -> DispatchData? {
        guard let stringData = string.data(using: .utf8) else {
            return nil
        }
        let dispatchData = stringData.withUnsafeBytes {
            DispatchData(bytes: $0)
        }
        return dispatchData
    }
}

func startListener(passcode: String, serviceMode: ServiceMode) {
    let listener: NWListener

    switch serviceMode {
    case .explicitHostAndPort:
        listener = try! NWListener(using: NWParameters(passcode: passcode), on: 12345)
    case .bonjourService:
        listener = try! NWListener(using: NWParameters(passcode: passcode))
        listener.service = NWListener.Service(name: ServerName, type: ServiceName)
    }

    listenerRef = listener
    listener.stateUpdateHandler = { state in
        print("listener: state did change, new: \(state)")
    }
    listener.newConnectionHandler = { conn in
        if let old = receiveConnectionRef {
            print("listener: will cancel old connection")
            old.cancel()
            receiveConnectionRef = nil
        }
        receiveConnectionRef = conn
        startReceive(on: conn)
        conn.start(queue: .main)
    }
    listener.start(queue: .main)
}

func startReceive(on connection: NWConnection) {
    connection.receive(minimumIncompleteLength: 1, maximumLength: 2048) { dataQ, _, _, errorQ in
        if let data = dataQ, let str = String(data: data, encoding: .utf8) {
            print("receiver: did receive: \"\(str)\"")
        }
        if let error = errorQ {
            if case let .tls(oSStatus) = error, oSStatus == errSSLBadRecordMac {
                print("receiver has detected an Incorrect PIN")
            } else {
                print("receiver: did fail, error: \(error)")
            }
            return
        }
    }
}

func startSender(passcode: String, serviceMode: ServiceMode) {
    let connection: NWConnection

    switch serviceMode {
    case .explicitHostAndPort:
        connection = NWConnection(host: "localhost", port: 12345, using: NWParameters(passcode: passcode))
    case .bonjourService:
        let endpoint = NWEndpoint.service(name: ServerName, type: ServiceName, domain: "local.", interface: nil)
        connection = NWConnection(to: endpoint, using: NWParameters(passcode: passcode))
    }

    sendConnectionRef = connection

    connection.stateUpdateHandler = { state in
        if case let .waiting(error) = state {
            if case let .tls(os) = error, os == errSSLPeerBadRecordMac { // Incorrect PIN
                print("Sender has detected an Incorrect PIN")
            }
        } else {
            print("sender: state did change, new: \(state)")
        }
    }

    connection.send(content: "It goes to 11".data(using: .utf8), completion: .idempotent)
    
    connection.start(queue: .main)
}

func main() {
    let serviceMode: ServiceMode = .explicitHostAndPort // Set this to Bonjour to see the issue

    // Change one of the Passcodes below to see the incorrect pin message(s) or lack thereof
    
    startListener(passcode: "1234", serviceMode: serviceMode)

    // Wait for server to spin up...
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        startSender(passcode: "1234", serviceMode: serviceMode)
    }

    dispatchMain()
}

main()
exit(EXIT_SUCCESS)
Answered by DTS Engineer in 776531022

Are you able to see the same?

I don’t have time to run that test right now.

Still, this sounds bugworthy to me, and I recommend that you file it as such. Please post your bug number, just for the record.

Fortunately you’ve also found a workaround, namely, to resolve the service and then connect using .hostPort(host:port:). Annoyingly, resolving the service isn’t as easy as it should be, but you can find some code for it here.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

The issue only appears to affect a Bonjour service

Well, that’s weird.

Lemme see if I have this straight:

  • You have a Network framework client and server, where the server uses NWListener to listen for incoming connections and the client uses NWBrowser to find, and NWConnection to connect to, the server.

  • You’ve set up TLS PSK à la TicTacToe.

  • When the client connects, the listener is notified of the new connection.

  • If the PSK values don’t match, the connection fails, as it should.

  • On the server this always works as expected.

  • On the client, you’re not notified of the failure.

  • If you, as a test, tweak the client to connect by a DNS name and port (.hostPort(host:port:)) rather than a Bonjour service (.service(name:type:domain:interface:)), things work as they do on the client.

Is that right?

If so, I’d like to clarify the exact failure mode. Based on the code snippet you posted, my understanding is that the OK case (well, it’s not actually working, so this is more like the “successfully detected the PSK mismatch” case) the connection enters the .waiting(_:) state where the error is errSSLPeerBadRecordMac. What behaviour do you see in the NG case? What state does the connection get stuck in?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

That's exactly right on all counts @eskimo.

In the "NG" case as you call it, we see the connection remaining in .preparing state, with no changes or updates ever received afterwards. So, we're just left hanging.

This is reproducible in the code posted above. Are you able to see the same?

Accepted Answer

Are you able to see the same?

I don’t have time to run that test right now.

Still, this sounds bugworthy to me, and I recommend that you file it as such. Please post your bug number, just for the record.

Fortunately you’ve also found a workaround, namely, to resolve the service and then connect using .hostPort(host:port:). Annoyingly, resolving the service isn’t as easy as it should be, but you can find some code for it here.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

No worries, here you go: FB13548312

Unable to detect TLS PSK Failure in Bonjour "Service" mode for NWConnection/NWListener
 
 
Q