Using Network Framework + Bonjour + QUIC + TLS

Hello,

I was able to use the TicTackToe code base and modify it such that I have a toggle at the top of the screen that allows me to start / stop the NWBrowser and NWListener. I have it setup so when the browser finds another device it attempts to connect to it. I support N devices / connections. I am able to use the NWParameters extension that is in the TickTackToe game that uses a passcode and TLS. I am able to send messages between devices just fine. Here is what I used

extension NWParameters {
    // Create parameters for use in PeerConnection and PeerListener.
    convenience init(passcode: String) {
        // Customize TCP options to enable keepalives.
        let tcpOptions = NWProtocolTCP.Options()
        tcpOptions.enableKeepalive = true
        tcpOptions.keepaliveIdle = 2

        // Create parameters with custom TLS and TCP options.
        self.init(tls: NWParameters.tlsOptions(passcode: passcode), tcp: tcpOptions)

        // Enable using a peer-to-peer link.
        self.includePeerToPeer = true
    }

    // Create TLS options using a passcode to derive a preshared key.
    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: "HI".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("HI")! as __DispatchData)
        sec_protocol_options_append_tls_ciphersuite(tlsOptions.securityProtocolOptions,
                                                    tls_ciphersuite_t(rawValue: TLS_PSK_WITH_AES_128_GCM_SHA256)!)
        return tlsOptions
    }

    // Create a utility function to encode strings as preshared key data.
    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
    }
}

When I try to modify it to use QUIC and TLS 1.3 like so

extension NWParameters {
    // Create parameters for use in PeerConnection and PeerListener.
    convenience init(psk: String) {
        self.init(quic: NWParameters.quicOptions(psk: psk))

        self.includePeerToPeer = true
    }
    
    private static func quicOptions(psk: String) -> NWProtocolQUIC.Options {
        let quicOptions = NWProtocolQUIC.Options(alpn: ["h3"])

        let authenticationKey = SymmetricKey(data: psk.data(using: .utf8)!)
        let authenticationCode = HMAC<SHA256>.authenticationCode(for: "hello".data(using: .utf8)!, using: authenticationKey)

        let authenticationDispatchData = authenticationCode.withUnsafeBytes {
            DispatchData(bytes: $0)
        }
        sec_protocol_options_set_min_tls_protocol_version(quicOptions.securityProtocolOptions, .TLSv13)
        sec_protocol_options_set_max_tls_protocol_version(quicOptions.securityProtocolOptions, .TLSv13)
        
        sec_protocol_options_add_pre_shared_key(quicOptions.securityProtocolOptions,
                                                authenticationDispatchData as __DispatchData,
                                                stringToDispatchData("hello")! as __DispatchData)

        sec_protocol_options_append_tls_ciphersuite(quicOptions.securityProtocolOptions,
                                                    tls_ciphersuite_t(rawValue: TLS_AES_128_GCM_SHA256)!)
        
        sec_protocol_options_set_verify_block(quicOptions.securityProtocolOptions, { _, _, sec_protocol_verify_complete in
            sec_protocol_verify_complete(true)
        }, .main)
        
        return quicOptions
    }

    // Create a utility function to encode strings as preshared key data.
    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
    }
}

I get the following errors in the console

boringssl_session_handshake_incomplete(241) [C3:1][0x109d0c600] SSL library error boringssl_session_handshake_error_print(44) [C3:1][0x109d0c600] Error: 4459057536:error:100000ae:SSL routines:OPENSSL_internal:NO_CERTIFICATE_SET:/Library/Caches/com.apple.xbs/Sources/boringssl/ssl/tls13_server.cc:882: boringssl_session_handshake_incomplete(241) [C4:1][0x109d0d200] SSL library error boringssl_session_handshake_error_print(44) [C4:1][0x109d0d200] Error: 4459057536:error:100000ae:SSL routines:OPENSSL_internal:NO_CERTIFICATE_SET:/Library/Caches/com.apple.xbs/Sources/boringssl/ssl/tls13_server.cc:882: nw_endpoint_flow_failed_with_error [C3 fe80::1884:2662:90ca:b011%en0.65328 in_progress channel-flow (satisfied (Path is satisfied), viable, interface: en0[802.11], scoped, ipv4, dns, uses wifi)] already failing, returning nw_endpoint_flow_failed_with_error [C4 192.168.0.98:65396 in_progress channel-flow (satisfied (Path is satisfied), viable, interface: en0[802.11], scoped, ipv4, dns, uses wifi)] already failing, returning quic_crypto_connection_state_handler [C1:1] [2ae0263d7dc186c7-] TLS error -9858 (state failed) nw_connection_copy_connected_local_endpoint_block_invoke [C3] Client called nw_connection_copy_connected_local_endpoint on unconnected nw_connection nw_connection_copy_connected_remote_endpoint_block_invoke [C3] Client called nw_connection_copy_connected_remote_endpoint on unconnected nw_connection nw_connection_copy_protocol_metadata_internal_block_invoke [C3] Client called nw_connection_copy_protocol_metadata_internal on unconnected nw_connection quic_crypto_connection_state_handler [C2:1] [84fdc1e910f59f0a-] TLS error -9858 (state failed) nw_connection_copy_connected_local_endpoint_block_invoke [C4] Client called nw_connection_copy_connected_local_endpoint on unconnected nw_connection nw_connection_copy_connected_remote_endpoint_block_invoke [C4] Client called nw_connection_copy_connected_remote_endpoint on unconnected nw_connection nw_connection_copy_protocol_metadata_internal_block_invoke [C4] Client called nw_connection_copy_protocol_metadata_internal on unconnected nw_connection

Am I missing some configuration? I noticed with the working code that uses TCP and TLS that there is an NWParameters initializer that accepts tls options and tcp option but there isnt one that accepts tls and quic.

Thank you for any help :)

Answered by DTS Engineer in 814642022

The obvious path forward here won’t work. You’re trying to combine two features:

  • QUIC

  • TLS-PSK

The issue is that QUIC requires TLS 1.3 but we only support TLS-PSK with TLS 1.2.

Thus, to work with QUIC you need to use standard TLS, that is, your server must have a digital identity. It’s possible to do that in a peer-to-peer environment, but it’s not trivial.

The best path forward depends on the nature of your product and where you are in its development. If you’re just getting started and want to explore QUIC, I recommend that you hard code a digital identity into your server. That’ll get QUIC working, and allow you to verify that it offers the features that you need. If you then decide that you really do want to use QUIC, we can talk about how to generate a digital identity for your server.

Share and Enjoy

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

I plan to only allow up to 6 connections (ie 7 devices all connected to each other) and the connections will be used for the following

  • send command messages (ie "start recording", "stop recording", "take photo")
  • fetch thumbnails of media on device and its URI to fetch
  • download media content (ie photos and videos) from Device A to Device B
  • streaming video (ie I will also want to stream video from Device B, C and D to Device A (ie Device A can get a preview of what Device B, C and D are seeing))

There could be a scenario where Device A is the "controller" and its connected to 3 other devices (B, C, D) and Device A requests a video from each other device - so it would be downloading 3 videos from 3 different devices.

Is TCP "enough" for my use cases and is it worth it to try and use QUIC for the performance gains.

Also, both the physical devices I am running the app on (iphone and ipad) are on iOS 18

If it's easier I can post my demo code in a public github repo.

Oh, I will also want to stream video from Device B, C and D to Device A (ie Device A can get a preview of what Device B, C and D are seeing)

Also, both the physical devices I am running the app on (iphone and ipad) are on iOS 18

The obvious path forward here won’t work. You’re trying to combine two features:

  • QUIC

  • TLS-PSK

The issue is that QUIC requires TLS 1.3 but we only support TLS-PSK with TLS 1.2.

Thus, to work with QUIC you need to use standard TLS, that is, your server must have a digital identity. It’s possible to do that in a peer-to-peer environment, but it’s not trivial.

The best path forward depends on the nature of your product and where you are in its development. If you’re just getting started and want to explore QUIC, I recommend that you hard code a digital identity into your server. That’ll get QUIC working, and allow you to verify that it offers the features that you need. If you then decide that you really do want to use QUIC, we can talk about how to generate a digital identity for your server.

Share and Enjoy

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

Thanks for the reply and information. I am early in the development of my app so things can change. I want to be careful to not get captivated by what QUIC can offer if I don't really need it.

A high level description of the app:

  • up to 7 devices can all discover each other and connect to each other
  • command messages are sent between the devices (ie Device A sends out a "take photo" command and it's sent to all the connected devices)
  • media content is downloaded between devices (ie Device A download the photo that was taken by each peer)
  • stream what the camera is see so the main / controller device can get a preview of what the other devices are seeing

As I'm describing what the app does I'm thinking when a device is discovered two connections are opened up - a TCP connection used for commands and downloading and a UDP connection used for camera preview.

When it comes to QUIC and your advice of hardcoding a digital identify - would that be ill-advised in production? Would a user be able to inspect the app payload and extract the hard coded digital identity and be a bad actor with it? No sensitive PPI data will be sent over the QUIC connection.

I appreciate your input and advice.

would doing this be wrong in a production app?

sec_protocol_options_set_verify_block(options.securityProtocolOptions, { (sec_protocol_metadata, sec_trust, sec_protocol_verify_complete) in
    sec_protocol_verify_complete(true)
}, queue)

I found this from this app https://github.com/paxsonsa/quic-swift-demo/blob/main/Sources/main.swift - I think he posted on this forum before.

Hard coding a credential in product code is always a mistake IMO. It’s only slightly better than disabling TLS entirely.

Would a user be able to inspect the app payload and extract the hard coded digital identity and be a bad actor with it?

If you put something in your app, you should assume that the user will be able to access it. You can do stuff to try to prevent that, but that means you’re essentially building a DRM scheme, and those are never 100% effective.

However, it sounds like you’re just testing out QUIC right now, and hard coding a digital identity for the purposes of your test is fine. You don’t want to spend time building the digital identity generation code and then find that QUIC doesn’t work for you. And if you find that QUIC does work for you, it’s certainly feasible to do the digital identity stuff correctly (it’s a pain, but definitely doable).

would doing this be wrong in a production app?

That is effectively disabling TLS server trust evaluation, which is only slightly better than disabling TLS entirely.

If it comes time to do this correctly, you’ll want to find a way to:

  • Generate the digital identity on the server side.

  • Distribute that digital identity to all the clients.

How you do that depends on the details of your product. I outline a number of potential options in TLS For Accessory Developers, but you might be able to do better depending on the specifics of your app.

IMO it’s best to defer this discussion until you decide that QUIC is for you or not.


Of course there’s a meta question here: If you don’t use QUIC, how do you implement security with the protocols you do use? For TCP you can add TLS-PSK and that gives you more flexibility in how you deal with trust. For UDP, there’s DTLS but I’ve never looked as to whether that supports PSK or not.

Share and Enjoy

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

Thanks for the response!

For UDP, there’s DTLS but I’ve never looked as to whether that supports PSK or not.

I tried this and it worked

extension NWParameters {
    convenience init(passcode: String) {
        let udpOptions = NWProtocolUDP.Options()

        self.init(dtls: NWParameters.tlsOptions(passcode: passcode), udp: udpOptions)
       
        self.includePeerToPeer = true
    }

    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: "HI".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("HI")! as __DispatchData)
        sec_protocol_options_append_tls_ciphersuite(tlsOptions.securityProtocolOptions,
                                                    tls_ciphersuite_t(rawValue: TLS_PSK_WITH_AES_128_GCM_SHA256)!)
        return tlsOptions
    }

    // Create a utility function to encode strings as preshared key data.
    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
    }
}

I was able to send messages back and forth.

However, it sounds like you’re just testing out QUIC right now, and hard coding a digital identity for the purposes of your test is fine.

I would like to hard code a digital identity to be able to test - do you have a resource that could help along that process?

If it comes time to do this correctly, you’ll want to find a way to: Generate the digital identity on the server side. Distribute that digital identity to all the clients.

I am using AppWrite as my backend service (it's similar to firebase). Would the idea / flow be as follows:

  1. user signs in on the mobile app
  2. make request to my backend requesting identity to be generated
  3. respond with generated identity
  4. use identity to secure QUIC connections

In the extreme example the user would have 7 devices where they use the same credentials to sign in. Would each device need to have the same identity that was generated on my backend in order to properly connect to each other?

I would like to hard code a digital identity to be able to test - do you have a resource that could help along that process?

Sure. Do this:

  1. Generate a self-signed digital identity however you want. I typically use Keychain Access > Certificate Assistant for this.

  2. Export it as a PKCS#12 file (.p12).

  3. Add that to your app’s bundle.

  4. Import it using SecPKCS12Import. At the end of this post you’ll find my standard code for that.

  5. Apply it to the parameters you use to create your listener.

Hmmm, I’ve written the code for step 5 a bazillion times but, weirdly, I’ve never posted it to DevForums. OK, so here’s what it looks like:

extension NWProtocolTLS.Options {

    func setLocalIdentity(_ localIdentity: SecIdentity) {
        let i = sec_identity_create(localIdentity)!
        sec_protocol_options_set_local_identity(self.securityProtocolOptions, i)
    }
}

You’d use this like so:

func listenerParameters(localIdentity: SecIdentity) -> NWParameters {
    let tcp = NWProtocolTCP.Options()
    … customise any TCP options …
    let tls = NWProtocolTLS.Options()
    tls.setLocalIdentity(localIdentity)
    return .init(tls: tls, tcp: tcp)
}

This example uses TCP+TLS but you can do a similar thing with QUIC.

There is, howeer, an important difference. QUIC always enables TLS so it's important to get the security options using the QUIC securityProtocolOptions property.


Would the idea / flow be as follows

Sure. Presuming that you can run code on the back end to generate the identity.

If you weren’t running your own back end then a similar option is to generate the identity on the client and have it register the identity’s certificate in CloudKit.

Would each device need to have the same identity that was generated on my backend in order to properly connect to each other?

That depends on what you want to authenticate:

  • If you want to authenticate the device, then each device needs its own identity. In that case, your identity generation code will need to include something device specific into the identity’s certificate so that a peer can tell that remote peer is the device that it’s expecting.

  • If you want to authenticate the user, then you can use a single identity for that. A peer can tell that the remote peer is the same user by checking that the certificate matches the certificate in the digital identity that it’s using.

The tricky part of about the latter is that your server has to store the digital identity (so, the certificate and the private key) so that new clients can access it. OTOH, if you authenticate the device then each device retains its own private key and the server only has to issue the certificate and store that.

Share and Enjoy

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


extension Bundle {
    
    func identityNamed(_ name: String, password: String) -> SecIdentity? {
        guard
            let pkcs12URL = self.url(forResource: name, withExtension: "p12"),
            let pkcs12Data = try? Data(contentsOf: pkcs12URL)
        else {
            return nil
        }
        var importedCF: CFArray? = nil
        let err = SecPKCS12Import(pkcs12Data as NSData, [
            kSecImportExportPassphrase: password
        ] as NSDictionary, &importedCF)
        guard err == errSecSuccess else {
            return nil
        }
        guard
            let importedNS = importedCF as NSArray?,
            let imported = importedNS as? [[String:Any]],
            let firstImport = imported.first,
            let identityAny = firstImport[kSecImportItemIdentity as String],
            CFGetTypeID(identityAny as CFTypeRef) == SecIdentityGetTypeID()
        else {
            return nil
        }
        return (identityAny as! SecIdentity)
    }
}

After doing research about the benefits QUIC has over TCP I would like to fully move forward with the QUIC implementation.

Before I start coding I like to get a clear mental model of what needs to happen and all the moving parts. After reading your response many times I think I understand.

To give a bit more background into how I want my app to operate I want to share some expected behavior.

Side note - on the Appwrite side (my backend) I am going to limit the user to having a max of 7 auth sessions (including web auth sessions and ios device auth sessions). So if they are authenticated on 2 browsers they would only be allows to run my app on 5 physical ios devices.

Example behavior: Bob has my app on 3 of his devices. He has signed in and is authenticated (via the Appwrite swift SDK). Tom has my app on 3 of his devices and he too has signed in and is authenticated. Both Bob and Tom are in the same room. While in the same room Bob and Tom could be on no wifi or they could be on the same wifi or one is on wifi and one is not. The expectation is that Bob cannot discover and connect to Tom's devices and vice versa.

Seeing how the the serviceName (ie "_p2pchat._udp") needs to be set prior to the app starting and since it's also set in the Bonjour Info.plist field could, in theory, any device using my app discover one another?

Using the TickTackToe example code I was thinking I would use the hash of the user's email concatenated with their userID as the PSK. So I guess Bob and Tom's devices could all be discovered but only Bob's devices could connect to each other and only Tom's devices could connect to each other. I would then only show devices to the user that I've connected to.

Now when it comes to using QUIC and digital identify I'm a bit confused about how to achieve the desired behavior (ie Bob cannot discover and connect to Tom's devices and vice versa).

My first thought would be when a new user signs up I generate them a digital identity on my backend. When they go to the app and sign in they would request their digital identity and I would then store in UserDefaults to persist it. I would then use that digital identity to setup TLS for QUIC.

I know I can use a NWBrowser without TLS - it's just NWListener and NWConnection that require TLS. So going back to the above example. For Bob's devices I would use his downloaded digital identity to listen for connections and use it to create connections. If the identities don't match then the connection cannot be made.

Is my thinking on the right track?

Seeing how the the serviceName (ie _p2pchat._udp) needs to be set prior to the app starting and since it's also set in the Bonjour Info.plist field could, in theory, any device using my app discover one another?

Right. And even devices not running your app (-:

There’s two things you need here:

  • An easy way to filter out other user’s instances of your app.

  • A guard against impersonation.

The first is a convenience. The second is security critical.


A good option for the first is to put a unique identifier into the TXT record. Your browser can then filter on that.

On the listener side, that means using the init(name:type:domain:txtRecord:) initialiser which allows you to specify a TXT record.

On the browser side, that means starting the browser with the bonjourWithTXTRecord(type:domain:) descriptor and then, in the results handler, getting the TXT record from the metadata property.

Anyone on a nearby network can see the TXT record, so make sure it doesn’t contain any person info. One option would be for your back end to give each user a big random number as part of your provisioning process.


On the security side, you wrote:

I would then store in UserDefaults to persist it.

Don’t use UserDefaults for security stuff. Rather, save this in the keychain.

Note If you’ve not used the keychain before, see:

If the identities don't match then the connection cannot be made.

Sounds good. You’ll need sec_protocol_options_set_verify_block to set up your custom trust evaluation. The code in that callback is really simple: Get the certificate from the remote peer and check that it matches the certificate from the digital identity you’ve saved. As long as your back end provisions each user with their own unique certificate, that should be sufficient to ensure that users can only talk to themselves.

Share and Enjoy

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

Get the certificate from the remote peer and check that it matches the certificate from the digital identity you’ve saved. As long as your back end provisions each user with their own unique certificate, that should be sufficient to ensure that users can only talk to themselves.

How can I get the certificate from the remote peer if I can't make a connect with the peer until I've verified its certificate? It feels like a chicken and the egg type of thing. Isn't the sec_protocol_options_set_verify_block part of the NWParameters setup process? How would I get the certificate from the remote peer while in that callback?

I would still greatly appreciate a response to my last question so this thread can be complete for me if I need to come back here later.

I did some testing with TCP and was able to download a 30 second video from a peer that was 36 feet away using only peer to peer wifi (both devices were NOT on a wifi network) in around 2 seconds. At this point this is sufficient for me to get going. I'm sure I can clean up the code and make it more robust and maybe "faster".

I say all this to say I'm going to move forward with two connections per peer. One TCP connection, which will be used for sending commands and downloading content and one UDP connection, which will be used for video streaming. I plan to create both of these connection right when the peer is discovered.

On the topic of TCP and UDP. Would the NWBrowser need to be UDP or TCP or does it matter? For example _p2pchat._udp or _p2pchat._tcp? Is using PSK (with both TCP and UDP) enough security to protect against malicious devices trying to connect? Should I still use the bonjourWithTXTRecord(type:domain:)? For the PSK I was planning on making it SHA256(user.id + "PSK" + user.email + "PSK" + user.id). The user.id is something I generate on the backend (appwrite generates it actually). Can I use something similar for the TXT record, like SHA256(user.id + "TXT_RECORD" + user.email + "TXT_RECORD" + user.id)?

Thank you for all your time and input - it is really appreciated!

Also, do you think you guys will ever support PSK for TLS 1.3 so it can be used with QUIC and if so is there an estimated time when it will be available?

Using Network Framework + Bonjour + QUIC + TLS
 
 
Q