Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions PeerConnectivityTests/NetworkPeerCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ final class NetworkPeerCoordinatorTests : XCTestCase {
XCTAssertTrue(harness.coordinator.connectedPeers.isEmpty)
}

internal func testUnsupportedHandshakeVersionCancelsPendingConnection() {
let harness = makeHarness()
let connection = MockCoordinatorConnection()

harness.coordinator.addPendingConnection(connection, direction: .outbound)
harness.coordinator.receiveFrame(handshakeFrame(identity("remote"), protocolVersion: 999), from: connection)

XCTAssertEqual(connection.cancelCallCount, 1)
XCTAssertTrue(harness.coordinator.connectedPeers.isEmpty)
}

internal func testSelfHandshakeCancelsPendingConnection() {
let harness = makeHarness()
let connection = MockCoordinatorConnection()
Expand All @@ -160,6 +171,27 @@ final class NetworkPeerCoordinatorTests : XCTestCase {
XCTAssertTrue(harness.coordinator.connectedPeers.isEmpty)
}

internal func testDuplicateIdentityCannotSpoofRegisteredConnection() {
let harness = makeHarness(localIdentifier: "alocal")
let registered = MockCoordinatorConnection()
let duplicate = MockCoordinatorConnection()
let remoteIdentity = identity("remote")
let payload = Data([9, 8, 7])

harness.coordinator.addPendingConnection(registered, direction: .outbound)
harness.coordinator.receiveFrame(handshakeFrame(remoteIdentity), from: registered)
registered.clearSentFrames()
harness.coordinator.addPendingConnection(duplicate, direction: .inbound)
harness.coordinator.receiveFrame(handshakeFrame(remoteIdentity), from: duplicate)
duplicate.clearSentFrames()
harness.coordinator.sendData(payload)

XCTAssertEqual(duplicate.cancelCallCount, 1)
XCTAssertEqual(duplicate.sentFrames, [])
XCTAssertEqual(registered.sentFrames, [PeerNetworkFrame(kind: .data, payload: payload)])
XCTAssertEqual(harness.coordinator.connectedPeers, [Peer(identity: remoteIdentity, status: .connected)])
}

internal func testSelfDiscoveryIsIgnored() {
let harness = makeHarness()
let browserEventCount = harness.browserEvents.count
Expand Down Expand Up @@ -187,8 +219,9 @@ final class NetworkPeerCoordinatorTests : XCTestCase {
return Harness(localPeer: Peer(identity: identity(localIdentifier), status: .currentUser))
}

private func handshakeFrame(_ identity: PeerIdentity) -> PeerNetworkFrame {
let handshake = PeerNetworkHandshake(identity: identity)
private func handshakeFrame(_ identity: PeerIdentity,
protocolVersion: Int = PeerNetworkHandshake.currentProtocolVersion) -> PeerNetworkFrame {
let handshake = PeerNetworkHandshake(identity: identity, protocolVersion: protocolVersion)
let payload = try! JSONEncoder().encode(handshake)
return PeerNetworkFrame(kind: .handshake, payload: payload)
}
Expand Down
57 changes: 55 additions & 2 deletions PeerConnectivityTests/NetworkPeerLoopbackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ final class NetworkPeerLoopbackTests : XCTestCase {
guard #available(iOS 13.0, macOS 10.15, *) else { return }

let serviceType = "pctest-\(UUID().uuidString.prefix(8).lowercased())"
let security = PeerConnectionNetworkSecurity.preSharedKey(Data("shared-loopback-secret".utf8))
let alice = PeerConnectionManager(serviceType: serviceType,
connectionType: .automatic,
displayName: "Alice",
backend: .networkFramework,
transportFactory: .networkFramework)
networkSecurity: security,
transportFactory: .networkFramework(security: security))
let bob = PeerConnectionManager(serviceType: serviceType,
connectionType: .automatic,
displayName: "Bob",
backend: .networkFramework,
transportFactory: .networkFramework)
networkSecurity: security,
transportFactory: .networkFramework(security: security))
let aliceFoundBob = expectation(description: "Alice found Bob")
let bobFoundAlice = expectation(description: "Bob found Alice")
let aliceConnected = expectation(description: "Alice connected")
Expand Down Expand Up @@ -74,4 +77,54 @@ final class NetworkPeerLoopbackTests : XCTestCase {
alice.stop()
bob.stop()
}

internal func testNetworkBackendRejectsMismatchedPreSharedKeys() {
guard #available(iOS 13.0, macOS 10.15, *) else { return }

let serviceType = "pctest-\(UUID().uuidString.prefix(8).lowercased())"
let aliceSecurity = PeerConnectionNetworkSecurity.preSharedKey(Data("alice-secret".utf8))
let bobSecurity = PeerConnectionNetworkSecurity.preSharedKey(Data("bob-secret".utf8))
let alice = PeerConnectionManager(serviceType: serviceType,
connectionType: .automatic,
displayName: "Alice",
backend: .networkFramework,
networkSecurity: aliceSecurity,
transportFactory: .networkFramework(security: aliceSecurity))
let bob = PeerConnectionManager(serviceType: serviceType,
connectionType: .automatic,
displayName: "Bob",
backend: .networkFramework,
networkSecurity: bobSecurity,
transportFactory: .networkFramework(security: bobSecurity))
let unexpectedAliceConnection = expectation(description: "Alice should not connect")
let unexpectedBobConnection = expectation(description: "Bob should not connect")
unexpectedAliceConnection.isInverted = true
unexpectedBobConnection.isInverted = true

alice.listenOn({ event in
switch event {
case .devicesChanged(peer: let peer, connectedPeers: let connectedPeers)
where peer.displayName == "Bob" && connectedPeers.contains(where: { $0.displayName == "Bob" }):
unexpectedAliceConnection.fulfill()
default: break
}
}, performListenerInBackground: true, withKey: "alice-events")

bob.listenOn({ event in
switch event {
case .devicesChanged(peer: let peer, connectedPeers: let connectedPeers)
where peer.displayName == "Alice" && connectedPeers.contains(where: { $0.displayName == "Alice" }):
unexpectedBobConnection.fulfill()
default: break
}
}, performListenerInBackground: true, withKey: "bob-events")

bob.start()
alice.start()

wait(for: [unexpectedAliceConnection, unexpectedBobConnection], timeout: 5)

alice.stop()
bob.stop()
}
}
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,18 @@ The staged migration toward Apple's Network framework is tracked in [NetworkFram

## Experimental Network framework backend

`PeerConnectionManager` can be explicitly initialized with `backend: .networkFramework` on supported OS versions. This backend is still a migration/testing path: it is currently unencrypted and unauthenticated, lacks stream/resource/UI parity, and must not be used for sensitive data until a future hardening pass adds a production trust model.
`PeerConnectionManager` can be explicitly initialized with `backend: .networkFramework` on supported OS versions. This backend is still a migration/testing path and lacks stream/resource/UI parity.

Use a shared secret to require TLS-PSK authenticated encryption between Network-backed peers:

```swift
let secret = Data("replace-with-an-app-managed-secret".utf8)
let pcm = PeerConnectionManager(serviceType: "local",
backend: .networkFramework,
networkSecurity: .preSharedKey(secret))
```

The default `networkSecurity: .unauthenticated` mode is plaintext TCP, remains available only for source compatibility and diagnostics, and must not be used for sensitive data.

The default backend remains `.multipeerConnectivity`.

Expand Down
34 changes: 26 additions & 8 deletions Sources/NetworkPeerTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import Foundation
import Network
import Security

@available(iOS 13.0, macOS 10.15, *)
internal protocol NetworkPeerListening {
Expand Down Expand Up @@ -35,9 +36,10 @@ internal final class NetworkPeerConnection : NetworkPeerConnectionCancellable {

internal init(endpoint: NWEndpoint,
queue: DispatchQueue = DispatchQueue(label: "PeerConnectivity.NetworkPeerConnection"),
security: PeerConnectionNetworkSecurity = .unauthenticated,
stateHandler: StateHandler? = nil,
dataHandler: DataHandler? = nil) {
self.connection = NWConnection(to: endpoint, using: NetworkPeerConnection.parameters())
self.connection = NWConnection(to: endpoint, using: NetworkPeerConnection.parameters(security: security))
self.queue = queue
self.stateHandler = stateHandler
self.dataHandler = dataHandler
Expand Down Expand Up @@ -101,14 +103,28 @@ internal final class NetworkPeerConnection : NetworkPeerConnectionCancellable {
}
}

internal static func parameters() -> NWParameters {
// Network transport remains experimental opt-in scaffolding; a later hardening
// pass must provide app-configurable TLS identity or PSK verification before
// recommending this backend for production sessions.
let parameters = NWParameters.tcp
internal static func parameters(security: PeerConnectionNetworkSecurity = .unauthenticated) -> NWParameters {
let parameters : NWParameters
switch security {
case .unauthenticated:
parameters = NWParameters.tcp
case .preSharedKey(let key):
precondition(!key.isEmpty, "PeerConnectivity: Network pre-shared key must not be empty")
let options = NWProtocolTLS.Options()
sec_protocol_options_add_pre_shared_key(options.securityProtocolOptions,
dispatchData(from: key),
dispatchData(from: Data("PeerConnectivity.NetworkFramework.PSK.v1".utf8)))
parameters = NWParameters(tls: options, tcp: NWProtocolTCP.Options())
}
parameters.includePeerToPeer = true
return parameters
}

fileprivate static func dispatchData(from data: Data) -> dispatch_data_t {
return data.withUnsafeBytes { buffer in
return DispatchData(bytes: buffer) as dispatch_data_t
}
}
}

@available(iOS 13.0, macOS 10.15, *)
Expand All @@ -126,13 +142,14 @@ internal final class NetworkPeerListener : NetworkPeerListening {
internal init(serviceType: ServiceType,
identity: PeerIdentity? = nil,
queue: DispatchQueue = DispatchQueue(label: "PeerConnectivity.NetworkPeerListener"),
security: PeerConnectionNetworkSecurity = .unauthenticated,
connectionHandler: ConnectionHandler? = nil,
stateHandler: StateHandler? = nil,
connectionFactory: @escaping (NWConnection, DispatchQueue) -> NetworkPeerConnection = { connection, queue in
return NetworkPeerConnection(connection: connection, queue: queue)
}) throws {
let service = PeerNetworkBonjourService(serviceType: serviceType)
let listener = try NWListener(using: NetworkPeerConnection.parameters())
let listener = try NWListener(using: NetworkPeerConnection.parameters(security: security))
if let identity = identity {
let txtRecord = NWTXTRecord(PeerNetworkDiscoveryInfo(identity: identity).txtRecordDictionary)
listener.service = NWListener.Service(name: nil, type: service.bonjourType, domain: nil, txtRecord: txtRecord)
Expand Down Expand Up @@ -177,11 +194,12 @@ internal final class NetworkPeerBrowser : NetworkPeerBrowsing {

internal init(serviceType: ServiceType,
queue: DispatchQueue = DispatchQueue(label: "PeerConnectivity.NetworkPeerBrowser"),
security: PeerConnectionNetworkSecurity = .unauthenticated,
resultHandler: ResultHandler? = nil,
stateHandler: StateHandler? = nil) {
let service = PeerNetworkBonjourService(serviceType: serviceType)
let descriptor = NWBrowser.Descriptor.bonjourWithTXTRecord(type: service.bonjourType, domain: nil)
browser = NWBrowser(for: descriptor, using: NetworkPeerConnection.parameters())
browser = NWBrowser(for: descriptor, using: NetworkPeerConnection.parameters(security: security))
self.queue = queue
self.resultHandler = resultHandler
self.stateHandler = stateHandler
Expand Down
34 changes: 24 additions & 10 deletions Sources/NetworkPeerTransportAdapters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,39 +14,47 @@ internal final class NetworkPeerSessionTransport : PeerSessionTransport {

internal let peer : Peer
fileprivate let coordinator : NetworkPeerCoordinator<NetworkPeerConnection>
fileprivate let security : PeerConnectionNetworkSecurity
fileprivate var listener : NetworkPeerListening

internal var connectedPeers : [Peer] {
return coordinator.connectedPeers
}

internal convenience init(peer: Peer, sessionObserver: Observable<PeerSessionEvent>) {
internal convenience init(peer: Peer,
sessionObserver: Observable<PeerSessionEvent>,
security: PeerConnectionNetworkSecurity = .unauthenticated) {
let browserObserver = Observable<PeerBrowserEvent>(.none)
let advertiserObserver = Observable<PeerAdvertiserEvent>(.none)
self.init(peer: peer,
sessionObserver: sessionObserver,
browserObserver: browserObserver,
advertiserObserver: advertiserObserver)
advertiserObserver: advertiserObserver,
security: security)
}

internal init(peer: Peer,
sessionObserver: Observable<PeerSessionEvent>,
browserObserver: Observable<PeerBrowserEvent>,
advertiserObserver: Observable<PeerAdvertiserEvent>) {
advertiserObserver: Observable<PeerAdvertiserEvent>,
security: PeerConnectionNetworkSecurity = .unauthenticated) {
self.peer = peer
let coordinator = NetworkPeerCoordinator<NetworkPeerConnection>(localPeer: peer,
sessionObserver: sessionObserver,
browserObserver: browserObserver,
advertiserObserver: advertiserObserver)
self.coordinator = coordinator
self.security = security
self.listener = FailedNetworkPeerListener()
}

internal init(peer: Peer,
coordinator: NetworkPeerCoordinator<NetworkPeerConnection>,
listener: NetworkPeerListening) {
listener: NetworkPeerListening,
security: PeerConnectionNetworkSecurity = .unauthenticated) {
self.peer = peer
self.coordinator = coordinator
self.security = security
self.listener = listener
}

Expand All @@ -57,7 +65,8 @@ internal final class NetworkPeerSessionTransport : PeerSessionTransport {
internal func configureListener(serviceType: ServiceType) {
listener = NetworkPeerSessionTransport.makeListener(peer: peer,
coordinator: coordinator,
serviceType: serviceType)
serviceType: serviceType,
security: security)
}

internal func stopSession() {
Expand Down Expand Up @@ -95,7 +104,7 @@ internal final class NetworkPeerSessionTransport : PeerSessionTransport {
}

internal func connect(to endpoint: NWEndpoint) {
let connection = NetworkPeerConnection(endpoint: endpoint)
let connection = NetworkPeerConnection(endpoint: endpoint, security: security)
connection.setDataHandler { [weak self, weak connection] frame in
guard let connection = connection else { return }
self?.coordinator.receiveFrame(frame, from: connection)
Expand All @@ -114,9 +123,13 @@ internal final class NetworkPeerSessionTransport : PeerSessionTransport {

fileprivate static func makeListener(peer: Peer,
coordinator: NetworkPeerCoordinator<NetworkPeerConnection>,
serviceType: ServiceType) -> NetworkPeerListening {
serviceType: ServiceType,
security: PeerConnectionNetworkSecurity) -> NetworkPeerListening {
do {
return try NetworkPeerListener(serviceType: serviceType, identity: peer.identity, connectionHandler: { connection in
return try NetworkPeerListener(serviceType: serviceType,
identity: peer.identity,
security: security,
connectionHandler: { connection in
connection.setDataHandler { [weak coordinator, weak connection] frame in
guard let connection = connection else { return }
coordinator?.receiveFrame(frame, from: connection)
Expand All @@ -140,8 +153,9 @@ internal final class NetworkPeerBrowserTransport : PeerBrowserTransport {

internal convenience init(session: NetworkPeerSessionTransport,
serviceType: ServiceType,
browserObserver: Observable<PeerBrowserEvent>) {
let browser = NetworkPeerBrowser(serviceType: serviceType)
browserObserver: Observable<PeerBrowserEvent>,
security: PeerConnectionNetworkSecurity = .unauthenticated) {
let browser = NetworkPeerBrowser(serviceType: serviceType, security: security)
self.init(session: session,
browser: browser,
browserObserver: browserObserver)
Expand Down
Loading
Loading