From e02a15594d3883e7d3fda54e934dce47c7a97c58 Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Wed, 3 Jun 2026 21:21:27 -0700 Subject: [PATCH 01/10] docs: add security improvements plan --- PeerConnectivity-security-plan.md | 209 ++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 PeerConnectivity-security-plan.md diff --git a/PeerConnectivity-security-plan.md b/PeerConnectivity-security-plan.md new file mode 100644 index 0000000..f1888bf --- /dev/null +++ b/PeerConnectivity-security-plan.md @@ -0,0 +1,209 @@ +# PeerConnectivity Security Improvements Plan + +## Goal + +Add practical, well-documented security abstractions around MultipeerConnectivity features that PeerConnectivity currently omits or weakens, while preserving backward compatibility for existing users. + +## Current State + +- `PeerSession` creates `MCSession` with `securityIdentity: nil` and `encryptionPreference: .optional`. +- `MCSessionDelegate.session(_:didReceiveCertificate:fromPeer:certificateHandler:)` is surfaced as `.receivedCertificate`. +- `PeerConnectionManager` currently installs an internal listener that always calls `handler(true)` for certificate events. +- `.automatic` mode auto-invites found peers and auto-accepts incoming invitations. +- `MCNearbyServiceAdvertiser` and `MCAdvertiserAssistant` use `discoveryInfo: nil`. +- Browser receives `discoveryInfo`, but `PeerBrowserEvent.foundPeer` discards it. +- `invitePeer(_:withContext:timeout:)` and `.receivedInvitation(peer:withContext:invitationHandler:)` already expose invitation context. +- Service type and display name constraints are documented but not validated. + +## Design Principles + +1. Preserve existing initializer behavior where possible. +2. Prefer explicit security configuration over event-listener side effects. +3. Make insecure defaults visible in documentation. +4. Avoid overbuilding certificate generation/keychain helpers in the first pass. +5. Treat discovery info, display names, and invitation context as public or unauthenticated metadata unless documented otherwise. +6. Keep implementation aligned with the existing Observable/EventProducer architecture. + +## Phase 1 — Session Security Configuration + +### Add `PeerSecurityConfiguration` + +Proposed API: + +```swift +public struct PeerSecurityConfiguration { + public var encryptionPreference : MCEncryptionPreference + public var securityIdentity : [Any]? + public var certificatePolicy : PeerCertificatePolicy + + public static let `default` = PeerSecurityConfiguration( + encryptionPreference: .optional, + securityIdentity: nil, + certificatePolicy: .acceptAll + ) + + public static let encrypted = PeerSecurityConfiguration( + encryptionPreference: .required, + securityIdentity: nil, + certificatePolicy: .acceptAll + ) +} +``` + +### Add `PeerCertificatePolicy` + +Proposed API: + +```swift +public enum PeerCertificatePolicy { + case acceptAll + case rejectAll + case requireCertificate + case custom((Peer, [Any]?, @escaping (Bool) -> Void) -> Void) +} +``` + +### Implementation Tasks + +- Add security configuration to `PeerConnectionManager` initializer, defaulting to backward-compatible behavior. +- Pass security configuration into `PeerSession`. +- Update `PeerSession` to initialize `MCSession` with configured `securityIdentity` and `encryptionPreference`. +- Remove the hardcoded always-accept certificate listener from `PeerConnectionManager.init`. +- Handle `.didReceiveCertificate` directly using `PeerCertificatePolicy`. +- Keep `.receivedCertificate` event only if useful for custom/manual handling, but avoid competing calls to the same certificate handler. + +### Tests + +- Verify default config maps to `.optional`, `nil`, `.acceptAll`. +- Verify `.encrypted` maps to `.required`. +- Verify `.acceptAll`, `.rejectAll`, and `.requireCertificate` call the handler correctly. +- Verify `.custom` receives peer/certificate and controls acceptance. + +## Phase 2 — Discovery Metadata + +### Add Discovery Info Support + +Proposed additions: + +```swift +public typealias PeerDiscoveryInfo = [String:String] +``` + +- Add `discoveryInfo: PeerDiscoveryInfo?` to `PeerConnectionManager` init/config. +- Pass discovery info to `PeerAdvertiser` and `PeerAdvertiserAssisstant`. +- Preserve browser-provided discovery info in internal browser events. +- Surface discovery info publicly. + +Possible public event shape: + +```swift +case foundPeer(peer: Peer, discoveryInfo: PeerDiscoveryInfo?) +case nearbyPeersChanged(foundPeers: [Peer]) +``` + +Alternative non-breaking approach: + +- Keep existing `.foundPeer(peer:)`. +- Add a new event: + +```swift +case foundPeerWithDiscoveryInfo(peer: Peer, discoveryInfo: PeerDiscoveryInfo?) +``` + +### Documentation Notes + +Document that discovery info is advertised over Bonjour TXT records and should not contain secrets, tokens, emails, stable user IDs, or sensitive device information. + +Good examples: + +- Protocol version +- Non-secret capability flags +- Room/session label that is not secret + +## Phase 3 — Invitation Policy + +### Add Invitation Policy + +Proposed API: + +```swift +public enum PeerInvitationPolicy { + case manual + case acceptAll + case rejectAll + case custom((Peer, Data?) -> Bool) +} +``` + +### Implementation Tasks + +- Add invitation policy to manager/config. +- In `.automatic`, replace blind accept with policy-based accept. +- Preserve `.receivedInvitation` for manual/custom modes. +- Consider typed Codable helpers for invitation context later. + +### Security Notes + +Invitation context is received before session establishment and should be considered unauthenticated. It can support pairing flows, protocol negotiation, and signed challenges, but should not carry raw secrets. + +## Phase 4 — Validation and Privacy Hardening + +### Service Type Validation + +Apple constraints: + +- Up to 15 characters. +- ASCII lowercase letters, numbers, and hyphen. +- Bonjour-style service type. + +Possible API: + +```swift +public static func isValidServiceType(_ serviceType: String) -> Bool +``` + +Later option: + +```swift +public struct PeerServiceType { + public let rawValue : String +} +``` + +### Display Name Validation + +- Validate 63-byte UTF-8 maximum for `MCPeerID(displayName:)`. +- Document that display names are visible to nearby peers. +- Consider adding an anonymous/random display-name helper. +- Reconsider UI convenience default using `UIDevice.current.name`, since that may reveal personal names. + +## Phase 5 — UI Filtering + +In `PeerConnectivityUI`, expose `MCBrowserViewControllerDelegate.browserViewController(_:shouldPresentNearbyPeer:withDiscoveryInfo:)`. + +Use cases: + +- Hide peers with incompatible protocol versions. +- Hide peers lacking required advertised capabilities. +- Hide peers outside the intended app room/session. + +## Suggested Initial Implementation Scope + +Start with Phase 1 only: + +1. Add `PeerSecurityConfiguration`. +2. Add `PeerCertificatePolicy`. +3. Thread config into `PeerSession`. +4. Replace hardcoded certificate acceptance. +5. Add focused tests. +6. Update docs/API comments. + +This provides the largest security improvement with the least API churn. + +## Open Questions + +1. Should the default remain `.optional + acceptAll` for backward compatibility, or should the next release default to `.required`? +2. Should `.receivedCertificate` remain a public event, or should certificate handling move entirely to `PeerCertificatePolicy.custom`? +3. Should discovery info be added as a new event to avoid breaking existing switch statements? +4. Should service type validation fail initialization, log warnings, or be exposed only as a helper? +5. Should the iOS UI convenience initializer continue to use `UIDevice.current.name` by default? From 978ca3288243df768769968ee76360285f7183de Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Wed, 3 Jun 2026 21:21:27 -0700 Subject: [PATCH 02/10] feat: add peer security configuration --- PeerConnectivity.xcodeproj/project.pbxproj | 8 + .../PeerSecurityConfigurationTests.swift | 139 ++++++++++++++++++ Sources/PeerConnectionManager.swift | 42 +++--- Sources/PeerConnectionResponder.swift | 3 + Sources/PeerSecurityConfiguration.swift | 100 +++++++++++++ Sources/PeerSession.swift | 10 +- 6 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 PeerConnectivityTests/PeerSecurityConfigurationTests.swift create mode 100644 Sources/PeerSecurityConfiguration.swift diff --git a/PeerConnectivity.xcodeproj/project.pbxproj b/PeerConnectivity.xcodeproj/project.pbxproj index ff1328c..1d6a43f 100644 --- a/PeerConnectivity.xcodeproj/project.pbxproj +++ b/PeerConnectivity.xcodeproj/project.pbxproj @@ -26,6 +26,8 @@ 3086CD2D1D09FB9900E269A3 /* PeerConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3086CD221D09FB9800E269A3 /* PeerConnectivity.framework */; }; 3086CD321D09FB9900E269A3 /* PeerConnectivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3086CD311D09FB9900E269A3 /* PeerConnectivityTests.swift */; }; 30PEERMSG2602020000000002 /* PeerMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30PEERMSG2602020000000001 /* PeerMessageTests.swift */; }; + 30SECURITY26060300000001 /* PeerSecurityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30SECURITY26060300000002 /* PeerSecurityConfiguration.swift */; }; + 30SECURITY26060300000003 /* PeerSecurityConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30SECURITY26060300000004 /* PeerSecurityConfigurationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,6 +63,8 @@ 3086CD2C1D09FB9900E269A3 /* PeerConnectivityTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PeerConnectivityTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3086CD311D09FB9900E269A3 /* PeerConnectivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerConnectivityTests.swift; sourceTree = ""; }; 3086CD331D09FB9900E269A3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 30SECURITY26060300000002 /* PeerSecurityConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PeerSecurityConfiguration.swift; path = Sources/PeerSecurityConfiguration.swift; sourceTree = ""; }; + 30SECURITY26060300000004 /* PeerSecurityConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerSecurityConfigurationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -100,6 +104,7 @@ 3080C7E71D80A1D600AF9EA3 /* PeerConnectionManager.swift */, 3080C7E81D80A1D600AF9EA3 /* PeerConnectionResponder.swift */, 3080C7E91D80A1D600AF9EA3 /* PeerConnectivity.h */, + 30SECURITY26060300000002 /* PeerSecurityConfiguration.swift */, 3080C7EA1D80A1D600AF9EA3 /* PeerSession.swift */, 3080C7EB1D80A1D600AF9EA3 /* PeerSessionEventProducer.swift */, ); @@ -129,6 +134,7 @@ children = ( 3086CD311D09FB9900E269A3 /* PeerConnectivityTests.swift */, 30PEERMSG2602020000000001 /* PeerMessageTests.swift */, + 30SECURITY26060300000004 /* PeerSecurityConfigurationTests.swift */, 3086CD331D09FB9900E269A3 /* Info.plist */, ); path = PeerConnectivityTests; @@ -258,6 +264,7 @@ 3080C7F31D80A1D700AF9EA3 /* PeerAdvertiserEventProducer.swift in Sources */, 3080C7FC1D80A1D700AF9EA3 /* PeerSessionEventProducer.swift in Sources */, 3080C7F51D80A1D700AF9EA3 /* PeerBrowserAssisstant.swift in Sources */, + 30SECURITY26060300000001 /* PeerSecurityConfiguration.swift in Sources */, 3080C7FB1D80A1D700AF9EA3 /* PeerSession.swift in Sources */, 3080C7EE1D80A1D700AF9EA3 /* Observable.swift in Sources */, 3080C7ED1D80A1D700AF9EA3 /* MultiObservable.swift in Sources */, @@ -271,6 +278,7 @@ files = ( 3086CD321D09FB9900E269A3 /* PeerConnectivityTests.swift in Sources */, 30PEERMSG2602020000000002 /* PeerMessageTests.swift in Sources */, + 30SECURITY26060300000003 /* PeerSecurityConfigurationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PeerConnectivityTests/PeerSecurityConfigurationTests.swift b/PeerConnectivityTests/PeerSecurityConfigurationTests.swift new file mode 100644 index 0000000..406c681 --- /dev/null +++ b/PeerConnectivityTests/PeerSecurityConfigurationTests.swift @@ -0,0 +1,139 @@ +// +// PeerSecurityConfigurationTests.swift +// PeerConnectivityTests +// +// Created by Reid Chatham on 6/3/26. +// Copyright © 2026 Reid Chatham. All rights reserved. +// + +import XCTest +import MultipeerConnectivity +@testable import PeerConnectivity + +// MARK: - PeerSecurityConfigurationTests + +class PeerSecurityConfigurationTests: XCTestCase { + + // MARK: - Configuration Tests + + func testDefaultSecurityConfigurationMapsToBackwardCompatibleValues() { + let configuration = PeerSecurityConfiguration.default + + XCTAssertEqual(configuration.encryptionPreference, .optional) + XCTAssertNil(configuration.securityIdentity) + + switch configuration.certificatePolicy { + case .acceptAll: + break + default: + XCTFail("Default certificate policy should accept all certificates") + } + } + + func testEncryptedSecurityConfigurationRequiresEncryption() { + let configuration = PeerSecurityConfiguration.encrypted + + XCTAssertEqual(configuration.encryptionPreference, .required) + XCTAssertNil(configuration.securityIdentity) + + switch configuration.certificatePolicy { + case .acceptAll: + break + default: + XCTFail("Encrypted convenience configuration should preserve accept-all certificate behavior") + } + } + + func testManagerStoresSecurityConfiguration() { + let configuration = PeerSecurityConfiguration.encrypted + let manager = PeerConnectionManager(serviceType: "security-test", securityConfiguration: configuration) + + XCTAssertEqual(manager.securityConfiguration.encryptionPreference, .required) + XCTAssertNil(manager.securityConfiguration.securityIdentity) + manager.stop() + } + + // MARK: - Certificate Policy Tests + + func testAcceptAllCertificatePolicyAcceptsMissingCertificate() { + let manager = makeManager(policy: .acceptAll) + let result = evaluateCertificatePolicy(manager: manager, certificate: nil) + + XCTAssertEqual(result, true) + manager.stop() + } + + func testRejectAllCertificatePolicyRejectsCertificate() { + let manager = makeManager(policy: .rejectAll) + let result = evaluateCertificatePolicy(manager: manager, certificate: ["certificate"]) + + XCTAssertEqual(result, false) + manager.stop() + } + + func testRequireCertificatePolicyRejectsMissingCertificate() { + let manager = makeManager(policy: .requireCertificate) + let result = evaluateCertificatePolicy(manager: manager, certificate: nil) + + XCTAssertEqual(result, false) + manager.stop() + } + + func testRequireCertificatePolicyRejectsEmptyCertificate() { + let manager = makeManager(policy: .requireCertificate) + let result = evaluateCertificatePolicy(manager: manager, certificate: []) + + XCTAssertEqual(result, false) + manager.stop() + } + + func testRequireCertificatePolicyAcceptsPresentCertificate() { + let manager = makeManager(policy: .requireCertificate) + let result = evaluateCertificatePolicy(manager: manager, certificate: ["certificate"]) + + XCTAssertEqual(result, true) + manager.stop() + } + + func testCustomCertificatePolicyReceivesPeerAndCertificateAndControlsAcceptance() { + var receivedPeer: Peer? + var receivedCertificate: [Any]? + let expectedCertificate: [Any] = ["certificate"] + let manager = makeManager(policy: .custom { peer, certificate, handler in + receivedPeer = peer + receivedCertificate = certificate + handler(false) + }) + + let result = evaluateCertificatePolicy(manager: manager, certificate: expectedCertificate) + + XCTAssertEqual(result, false) + XCTAssertEqual(receivedPeer, manager.peer) + XCTAssertEqual(receivedCertificate?.first as? String, "certificate") + manager.stop() + } + + // MARK: - Helper Methods + + /// Builds a manager with a specific certificate policy. + private func makeManager(policy: PeerCertificatePolicy) -> PeerConnectionManager { + let configuration = PeerSecurityConfiguration( + encryptionPreference: .optional, + securityIdentity: nil, + certificatePolicy: policy + ) + return PeerConnectionManager( + serviceType: "sec-\(UUID().uuidString.prefix(8).lowercased())", + securityConfiguration: configuration + ) + } + + /// Applies the manager certificate policy and returns the resulting handler value. + private func evaluateCertificatePolicy(manager: PeerConnectionManager, certificate: [Any]?) -> Bool? { + var result: Bool? + manager.handleCertificate(peer: manager.peer, certificate: certificate) { accepted in + result = accepted + } + return result + } +} diff --git a/Sources/PeerConnectionManager.swift b/Sources/PeerConnectionManager.swift index 968f294..719815e 100644 --- a/Sources/PeerConnectionManager.swift +++ b/Sources/PeerConnectionManager.swift @@ -17,10 +17,7 @@ public typealias ServiceType = String /** Struct representing specified keys for configuring a connection manager. */ -public struct PeerConnectivityKeys { - static fileprivate let CertificateListener = "CertificateRecievedListener" -} - +public struct PeerConnectivityKeys {} // MARK: - Modern Type-Safe Messaging API @@ -97,6 +94,11 @@ public class PeerConnectionManager { Access to the local peer representing the user. */ public let peer : Peer + + /** + Security settings used to create the underlying MultipeerConnectivity session. + */ + public let securityConfiguration : PeerSecurityConfiguration /** Returns the peers that are connected on the current session. @@ -166,6 +168,7 @@ public class PeerConnectionManager { - parameter serviceType: The requested service type describing the channel on which peers are able to connect. - parameter connectionType: Takes a PeerConnectionType case determining the default behavior of the framework. - parameter displayName: The local user's display name to other peers. + - parameter securityConfiguration: Security settings used to create the underlying MultipeerConnectivity session. - Returns: A fully initialized `PeerConnectionManager`. */ @@ -177,34 +180,26 @@ public class PeerConnectionManager { #else return ProcessInfo.processInfo.hostName #endif - }()) { + }(), + securityConfiguration: PeerSecurityConfiguration = .default) { self.connectionType = connectionType self.serviceType = serviceType self.peer = Peer(displayName: displayName) + self.securityConfiguration = securityConfiguration sessionEventProducer = PeerSessionEventProducer(observer: sessionObserver) browserEventProducer = PeerBrowserEventProducer(observer: browserObserver) advertiserEventProducer = PeerAdvertiserEventProducer(observer: advertiserObserver) advertiserAssisstantEventProducer = PeerAdvertiserAssisstantEventProducer(observer: advertiserAssisstantObserver) - session = PeerSession(peer: peer, eventProducer: sessionEventProducer) + session = PeerSession(peer: peer, securityConfiguration: securityConfiguration, eventProducer: sessionEventProducer) browser = PeerBrowser(session: session, serviceType: serviceType, eventProducer: browserEventProducer) advertiser = PeerAdvertiser(session: session, serviceType: serviceType, eventProducer: advertiserEventProducer) advertiserAssisstant = PeerAdvertiserAssisstant(session: session, serviceType: serviceType, eventProducer: advertiserAssisstantEventProducer) responder = PeerConnectionResponder(observer: observer) - // Currently checking security certificates is not yet supported. - responder.addListener({ (event) in - switch event { - case .receivedCertificate(peer: _, certificate: _, handler: let handler): - //print("PeerConnectionManager: listenOn: certificateReceived") - handler(true) - default: break - } - }, forKey: PeerConnectivityKeys.CertificateListener) - // Prevent mingling signals from the same device if let existing = PeerConnectionManager.shared[serviceType] { existing.stop() @@ -220,6 +215,19 @@ public class PeerConnectionManager { PeerConnectionManager.shared.removeValue(forKey: serviceType) } } + + internal func handleCertificate(peer: Peer, certificate: [Any]?, handler: @escaping (Bool) -> Void) { + switch securityConfiguration.certificatePolicy { + case .acceptAll: + handler(true) + case .rejectAll: + handler(false) + case .requireCertificate: + handler(certificate?.isEmpty == false) + case .custom(let certificateHandler): + certificateHandler(peer, certificate, handler) + } + } } extension PeerConnectionManager { @@ -493,7 +501,7 @@ extension PeerConnectionManager { ) as? [String: Any] else { return } self?.observer.value = .receivedEvent(peer: peer, eventInfo: eventInfo) case .didReceiveCertificate(peer: let peer, certificate: let certificate, handler: let handler): - self?.observer.value = .receivedCertificate(peer: peer, certificate: certificate, handler: handler) + self?.handleCertificate(peer: peer, certificate: certificate, handler: handler) case .didReceiveStream(peer: let peer, stream: let stream, name: let name): self?.observer.value = .receivedStream(peer: peer, stream: stream, name: name) case .startedReceivingResource(peer: let peer, name: let name, progress: let progress): diff --git a/Sources/PeerConnectionResponder.swift b/Sources/PeerConnectionResponder.swift index 5073a0b..bb6c81c 100644 --- a/Sources/PeerConnectionResponder.swift +++ b/Sources/PeerConnectionResponder.swift @@ -59,6 +59,9 @@ public enum PeerConnectionEvent { case finishedReceivingResource(peer: Peer, name: String, url: URL?, error: Error?) /** Received security certificate from `Peer` with handler. + + Certificate decisions are handled by `PeerSecurityConfiguration.certificatePolicy`. + This event remains for API compatibility. */ case receivedCertificate(peer: Peer, certificate: [Any]?, handler: (Bool)->Void) /** diff --git a/Sources/PeerSecurityConfiguration.swift b/Sources/PeerSecurityConfiguration.swift new file mode 100644 index 0000000..615d05a --- /dev/null +++ b/Sources/PeerSecurityConfiguration.swift @@ -0,0 +1,100 @@ +// +// PeerSecurityConfiguration.swift +// PeerConnectivity +// +// Created by Reid Chatham on 6/3/26. +// Copyright © 2026 Reid Chatham. All rights reserved. +// + +import Foundation +import MultipeerConnectivity + +/** + Policy used to decide whether a peer certificate should be accepted during session establishment. + + Certificate data is supplied by MultipeerConnectivity and should be treated as unauthenticated until + your policy validates it. The default `.acceptAll` policy preserves PeerConnectivity's historical + behavior. + */ +public enum PeerCertificatePolicy { + /** + Accept every peer certificate, including missing certificates. + + This preserves PeerConnectivity's historical behavior and is convenient for local discovery, but + it does not authenticate peers. + */ + case acceptAll + /** + Reject every peer certificate. + */ + case rejectAll + /** + Accept only peers that provide a non-empty certificate chain. + */ + case requireCertificate + /** + Delegate certificate handling to caller-provided logic. + + The custom handler must call the supplied completion exactly once with the acceptance decision. + + - parameter peer: The peer presenting the certificate. + - parameter certificate: The certificate chain supplied by MultipeerConnectivity. + - parameter handler: Completion that accepts or rejects the certificate. + */ + case custom((Peer, [Any]?, @escaping (Bool) -> Void) -> Void) +} + +/** + Security settings used when creating the underlying `MCSession`. + + The default configuration intentionally preserves the library's previous behavior: + optional encryption, no local security identity, and accepting all peer certificates. + For stronger transport protection, use `.encrypted` or provide a custom configuration. + */ +public struct PeerSecurityConfiguration { + /** + Encryption preference passed to `MCSession`. + */ + public var encryptionPreference : MCEncryptionPreference + /** + Optional identity passed to `MCSession`. + */ + public var securityIdentity : [Any]? + /** + Certificate policy applied when `MCSessionDelegate` receives a peer certificate. + */ + public var certificatePolicy : PeerCertificatePolicy + + /** + Creates a security configuration. + + - parameter encryptionPreference: Encryption preference passed to `MCSession`. + - parameter securityIdentity: Optional identity passed to `MCSession`. + - parameter certificatePolicy: Policy applied to incoming peer certificates. + */ + public init(encryptionPreference: MCEncryptionPreference, + securityIdentity: [Any]?, + certificatePolicy: PeerCertificatePolicy) { + self.encryptionPreference = encryptionPreference + self.securityIdentity = securityIdentity + self.certificatePolicy = certificatePolicy + } + + /** + Backward-compatible default configuration. + */ + public static let `default` = PeerSecurityConfiguration( + encryptionPreference: .optional, + securityIdentity: nil, + certificatePolicy: .acceptAll + ) + + /** + Convenience configuration requiring encrypted sessions while preserving certificate behavior. + */ + public static let encrypted = PeerSecurityConfiguration( + encryptionPreference: .required, + securityIdentity: nil, + certificatePolicy: .acceptAll + ) +} diff --git a/Sources/PeerSession.swift b/Sources/PeerSession.swift index 181f0bf..50fa1ed 100644 --- a/Sources/PeerSession.swift +++ b/Sources/PeerSession.swift @@ -13,16 +13,22 @@ internal struct PeerSession { internal let peer : Peer internal let session : MCSession + internal let securityConfiguration : PeerSecurityConfiguration fileprivate let eventProducer: PeerSessionEventProducer internal var connectedPeers : [Peer] { return session.connectedPeers.map { Peer(peerID: $0, status: .connected) } } - internal init(peer: Peer, eventProducer: PeerSessionEventProducer) { + internal init(peer: Peer, + securityConfiguration: PeerSecurityConfiguration = .default, + eventProducer: PeerSessionEventProducer) { self.peer = peer + self.securityConfiguration = securityConfiguration self.eventProducer = eventProducer - session = MCSession(peer: peer.peerID, securityIdentity: nil, encryptionPreference: .optional) + session = MCSession(peer: peer.peerID, + securityIdentity: securityConfiguration.securityIdentity, + encryptionPreference: securityConfiguration.encryptionPreference) session.delegate = eventProducer } From 88457aec4e4946d62b07ffbf2ed6cc0da3f016a1 Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Wed, 3 Jun 2026 21:21:27 -0700 Subject: [PATCH 03/10] fix: use local package path for demo workspace --- PeerConnectivityDemo.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PeerConnectivityDemo.xcodeproj/project.pbxproj b/PeerConnectivityDemo.xcodeproj/project.pbxproj index e09302f..b2254e1 100644 --- a/PeerConnectivityDemo.xcodeproj/project.pbxproj +++ b/PeerConnectivityDemo.xcodeproj/project.pbxproj @@ -123,7 +123,7 @@ ); mainGroup = 3098372E1D8A8D600002338A; packageReferences = ( - B1E4E4E62F30592D00AE11AA /* XCLocalSwiftPackageReference "../PeerConnectivity" */, + B1E4E4E62F30592D00AE11AA /* XCLocalSwiftPackageReference "." */, ); productRefGroup = 309837381D8A8D600002338A /* Products */; projectDirPath = ""; @@ -355,9 +355,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - B1E4E4E62F30592D00AE11AA /* XCLocalSwiftPackageReference "../PeerConnectivity" */ = { + B1E4E4E62F30592D00AE11AA /* XCLocalSwiftPackageReference "." */ = { isa = XCLocalSwiftPackageReference; - relativePath = ../PeerConnectivity; + relativePath = .; }; /* End XCLocalSwiftPackageReference section */ From 3c23111f420831898b0dd171a8abc50f24e7d386 Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Wed, 3 Jun 2026 21:25:27 -0700 Subject: [PATCH 04/10] docs: rename security roadmap as plan file --- ...onnectivity-security-plan.md => PeerConnectivity-security.plan | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename PeerConnectivity-security-plan.md => PeerConnectivity-security.plan (100%) diff --git a/PeerConnectivity-security-plan.md b/PeerConnectivity-security.plan similarity index 100% rename from PeerConnectivity-security-plan.md rename to PeerConnectivity-security.plan From 0dfa543babb1388a230d616627af4181d99fdcce Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Thu, 4 Jun 2026 11:51:06 -0700 Subject: [PATCH 05/10] feat: add discovery metadata support --- .../PeerConnectivityTests.swift | 75 +++++++++++++++++++ Sources/PeerAdvertiser.swift | 11 ++- Sources/PeerAdvertiserAssisstant.swift | 11 ++- Sources/PeerBrowserEventProducer.swift | 4 +- Sources/PeerConnectionManager.swift | 39 ++++++++-- Sources/PeerConnectionResponder.swift | 7 ++ 6 files changed, 135 insertions(+), 12 deletions(-) diff --git a/PeerConnectivityTests/PeerConnectivityTests.swift b/PeerConnectivityTests/PeerConnectivityTests.swift index 51d8225..d7b1239 100644 --- a/PeerConnectivityTests/PeerConnectivityTests.swift +++ b/PeerConnectivityTests/PeerConnectivityTests.swift @@ -7,6 +7,7 @@ // import XCTest +import MultipeerConnectivity @testable import PeerConnectivity class PeerConnectivityTests: XCTestCase { @@ -40,5 +41,79 @@ class PeerConnectivityTests: XCTestCase { // Put the code you want to measure the time of here. } } + + func testManagerStoresDiscoveryInfo() { + let discoveryInfo: PeerDiscoveryInfo = ["version": "1", "room": "lobby"] + let manager = PeerConnectionManager(serviceType: "disc-test", discoveryInfo: discoveryInfo) + + XCTAssertEqual(manager.discoveryInfo?["version"], "1") + XCTAssertEqual(manager.discoveryInfo?["room"], "lobby") + manager.stop() + } + + func testAdvertiserAndAssisstantStoreDiscoveryInfo() { + let discoveryInfo: PeerDiscoveryInfo = ["version": "1", "capability": "chat"] + let observer = Observable(.none) + let producer = PeerSessionEventProducer(observer: observer) + let session = PeerSession(peer: Peer(displayName: "discovery-test"), eventProducer: producer) + let advertiserObserver = Observable(.none) + let advertiserProducer = PeerAdvertiserEventProducer(observer: advertiserObserver) + let assisstantObserver = Observable(.none) + let assisstantProducer = PeerAdvertiserAssisstantEventProducer(observer: assisstantObserver) + + let advertiser = PeerAdvertiser( + session: session, + serviceType: "disc-test", + discoveryInfo: discoveryInfo, + eventProducer: advertiserProducer + ) + let assisstant = PeerAdvertiserAssisstant( + session: session, + serviceType: "disc-test", + discoveryInfo: discoveryInfo, + eventProducer: assisstantProducer + ) + + XCTAssertEqual(advertiser.discoveryInfo?["version"], "1") + XCTAssertEqual(assisstant.discoveryInfo?["capability"], "chat") + } + + func testBrowserEventPreservesDiscoveryInfo() { + let observer = Observable(.none) + let producer = PeerBrowserEventProducer(observer: observer) + let browser = MCNearbyServiceBrowser(peer: MCPeerID(displayName: "local"), serviceType: "disc-test") + let remotePeerID = MCPeerID(displayName: "remote") + let discoveryInfo: PeerDiscoveryInfo = ["version": "1", "room": "lobby"] + var receivedDiscoveryInfo: PeerDiscoveryInfo? + + observer.addObserver { event in + switch event { + case .foundPeer(_, let info): + receivedDiscoveryInfo = info + default: break + } + } + + producer.browser(browser, foundPeer: remotePeerID, withDiscoveryInfo: discoveryInfo) + + XCTAssertEqual(receivedDiscoveryInfo?["version"], "1") + XCTAssertEqual(receivedDiscoveryInfo?["room"], "lobby") + } + + func testPublicFoundPeerWithDiscoveryInfoEventCarriesMetadata() { + let peer = Peer(displayName: "remote") + let discoveryInfo: PeerDiscoveryInfo = ["version": "1"] + let event = PeerConnectionEvent.foundPeerWithDiscoveryInfo(peer: peer, discoveryInfo: discoveryInfo) + var receivedDiscoveryInfo: PeerDiscoveryInfo? + + switch event { + case .foundPeerWithDiscoveryInfo(_, let info): + receivedDiscoveryInfo = info + default: + XCTFail("Expected foundPeerWithDiscoveryInfo event") + } + + XCTAssertEqual(receivedDiscoveryInfo?["version"], "1") + } } diff --git a/Sources/PeerAdvertiser.swift b/Sources/PeerAdvertiser.swift index 351c7c7..ec80ba5 100644 --- a/Sources/PeerAdvertiser.swift +++ b/Sources/PeerAdvertiser.swift @@ -13,12 +13,19 @@ internal struct PeerAdvertiser { fileprivate let session : PeerSession fileprivate let advertiser : MCNearbyServiceAdvertiser + internal let discoveryInfo : PeerDiscoveryInfo? fileprivate let eventProducer : PeerAdvertiserEventProducer - internal init(session: PeerSession, serviceType: ServiceType, eventProducer: PeerAdvertiserEventProducer) { + internal init(session: PeerSession, + serviceType: ServiceType, + discoveryInfo: PeerDiscoveryInfo? = nil, + eventProducer: PeerAdvertiserEventProducer) { self.session = session + self.discoveryInfo = discoveryInfo self.eventProducer = eventProducer - advertiser = MCNearbyServiceAdvertiser(peer: session.peer.peerID, discoveryInfo: nil, serviceType: serviceType) + advertiser = MCNearbyServiceAdvertiser(peer: session.peer.peerID, + discoveryInfo: discoveryInfo, + serviceType: serviceType) advertiser.delegate = eventProducer } diff --git a/Sources/PeerAdvertiserAssisstant.swift b/Sources/PeerAdvertiserAssisstant.swift index c17d6fb..327ce3f 100644 --- a/Sources/PeerAdvertiserAssisstant.swift +++ b/Sources/PeerAdvertiserAssisstant.swift @@ -13,12 +13,19 @@ internal struct PeerAdvertiserAssisstant { fileprivate let session : PeerSession fileprivate let assisstant : MCAdvertiserAssistant + internal let discoveryInfo : PeerDiscoveryInfo? fileprivate let eventProducer : PeerAdvertiserAssisstantEventProducer? - internal init(session: PeerSession, serviceType: ServiceType, eventProducer: PeerAdvertiserAssisstantEventProducer? = nil) { + internal init(session: PeerSession, + serviceType: ServiceType, + discoveryInfo: PeerDiscoveryInfo? = nil, + eventProducer: PeerAdvertiserAssisstantEventProducer? = nil) { self.session = session + self.discoveryInfo = discoveryInfo self.eventProducer = eventProducer - assisstant = MCAdvertiserAssistant(serviceType: serviceType, discoveryInfo: nil, session: session.session) + assisstant = MCAdvertiserAssistant(serviceType: serviceType, + discoveryInfo: discoveryInfo, + session: session.session) if let eventProducer = eventProducer { assisstant.delegate = eventProducer } } diff --git a/Sources/PeerBrowserEventProducer.swift b/Sources/PeerBrowserEventProducer.swift index 5588fe4..f3fbc6b 100644 --- a/Sources/PeerBrowserEventProducer.swift +++ b/Sources/PeerBrowserEventProducer.swift @@ -12,7 +12,7 @@ import MultipeerConnectivity internal enum PeerBrowserEvent { case none case didNotStartBrowsingForPeers(Error) - case foundPeer(Peer) + case foundPeer(Peer, discoveryInfo: PeerDiscoveryInfo?) case lostPeer(Peer) } @@ -38,7 +38,7 @@ extension PeerBrowserEventProducer: MCNearbyServiceBrowserDelegate { NSLog("%@", "foundPeer: \(peerID)") let peer = Peer(peerID: peerID, status: .notConnected) - let event : PeerBrowserEvent = .foundPeer(peer) + let event : PeerBrowserEvent = .foundPeer(peer, discoveryInfo: info) self.observer.value = event } diff --git a/Sources/PeerConnectionManager.swift b/Sources/PeerConnectionManager.swift index 719815e..76155e8 100644 --- a/Sources/PeerConnectionManager.swift +++ b/Sources/PeerConnectionManager.swift @@ -14,6 +14,15 @@ import MultipeerConnectivity */ public typealias ServiceType = String +/** + Discovery metadata advertised over Bonjour TXT records. + + Treat discovery info as public, unauthenticated metadata. Do not include secrets, tokens, + emails, stable user IDs, or sensitive device information. Prefer non-secret values such as + protocol versions, capability flags, or non-secret room/session labels. + */ +public typealias PeerDiscoveryInfo = [String:String] + /** Struct representing specified keys for configuring a connection manager. */ @@ -99,6 +108,14 @@ public class PeerConnectionManager { Security settings used to create the underlying MultipeerConnectivity session. */ public let securityConfiguration : PeerSecurityConfiguration + + /** + Public discovery metadata advertised to nearby browsers. + + This metadata is unauthenticated and visible to nearby peers. Do not include secrets, + tokens, emails, stable user IDs, or sensitive device information. + */ + public let discoveryInfo : PeerDiscoveryInfo? /** Returns the peers that are connected on the current session. @@ -169,6 +186,7 @@ public class PeerConnectionManager { - parameter connectionType: Takes a PeerConnectionType case determining the default behavior of the framework. - parameter displayName: The local user's display name to other peers. - parameter securityConfiguration: Security settings used to create the underlying MultipeerConnectivity session. + - parameter discoveryInfo: Public, unauthenticated metadata advertised to nearby browsers. - Returns: A fully initialized `PeerConnectionManager`. */ @@ -181,12 +199,14 @@ public class PeerConnectionManager { return ProcessInfo.processInfo.hostName #endif }(), - securityConfiguration: PeerSecurityConfiguration = .default) { + securityConfiguration: PeerSecurityConfiguration = .default, + discoveryInfo: PeerDiscoveryInfo? = nil) { self.connectionType = connectionType self.serviceType = serviceType self.peer = Peer(displayName: displayName) self.securityConfiguration = securityConfiguration + self.discoveryInfo = discoveryInfo sessionEventProducer = PeerSessionEventProducer(observer: sessionObserver) browserEventProducer = PeerBrowserEventProducer(observer: browserObserver) @@ -195,8 +215,14 @@ public class PeerConnectionManager { session = PeerSession(peer: peer, securityConfiguration: securityConfiguration, eventProducer: sessionEventProducer) browser = PeerBrowser(session: session, serviceType: serviceType, eventProducer: browserEventProducer) - advertiser = PeerAdvertiser(session: session, serviceType: serviceType, eventProducer: advertiserEventProducer) - advertiserAssisstant = PeerAdvertiserAssisstant(session: session, serviceType: serviceType, eventProducer: advertiserAssisstantEventProducer) + advertiser = PeerAdvertiser(session: session, + serviceType: serviceType, + discoveryInfo: discoveryInfo, + eventProducer: advertiserEventProducer) + advertiserAssisstant = PeerAdvertiserAssisstant(session: session, + serviceType: serviceType, + discoveryInfo: discoveryInfo, + eventProducer: advertiserAssisstantEventProducer) responder = PeerConnectionResponder(observer: observer) @@ -449,8 +475,9 @@ extension PeerConnectionManager { if includeBrowserObservers { browserObserver.addObserver { [weak self] event in switch event { - case .foundPeer(let peer): + case .foundPeer(let peer, let discoveryInfo): self?.observer.value = .foundPeer(peer: peer) + self?.observer.value = .foundPeerWithDiscoveryInfo(peer: peer, discoveryInfo: discoveryInfo) case .lostPeer(let peer): self?.observer.value = .lostPeer(peer: peer) case .didNotStartBrowsingForPeers(let error): @@ -516,7 +543,7 @@ extension PeerConnectionManager { browserObserver.addObserver { [weak self] event in DispatchQueue.main.async { switch event { - case .foundPeer(let peer): + case .foundPeer(let peer, _): guard let peers = self?.foundPeers , !peers.contains(peer) else { break } self?.foundPeers.append(peer) case .lostPeer(let peer): @@ -552,7 +579,7 @@ extension PeerConnectionManager { browserObserver.addObserver { [unowned self] event in DispatchQueue.main.async { switch event { - case .foundPeer(let peer): + case .foundPeer(let peer, _): self.browser.invitePeer(peer) default: break } diff --git a/Sources/PeerConnectionResponder.swift b/Sources/PeerConnectionResponder.swift index bb6c81c..1210519 100644 --- a/Sources/PeerConnectionResponder.swift +++ b/Sources/PeerConnectionResponder.swift @@ -76,6 +76,13 @@ public enum PeerConnectionEvent { Found nearby `Peer`. */ case foundPeer(peer: Peer) + /** + Found nearby `Peer` with advertised discovery metadata. + + Discovery info is public, unauthenticated Bonjour TXT record metadata. Do not treat it as secret + or trusted without additional validation. + */ + case foundPeerWithDiscoveryInfo(peer: Peer, discoveryInfo: PeerDiscoveryInfo?) /** Lost nearby `Peer`. */ From adc008f606e884bd28a63f62e01112b8990a2a93 Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Fri, 5 Jun 2026 00:24:41 -0700 Subject: [PATCH 06/10] feat: add invitation policy support --- .../PeerSecurityConfigurationTests.swift | 123 ++++++++++++++++++ Sources/PeerConnectionManager.swift | 64 ++++++--- Sources/PeerSecurityConfiguration.swift | 30 +++++ 3 files changed, 198 insertions(+), 19 deletions(-) diff --git a/PeerConnectivityTests/PeerSecurityConfigurationTests.swift b/PeerConnectivityTests/PeerSecurityConfigurationTests.swift index 406c681..c038098 100644 --- a/PeerConnectivityTests/PeerSecurityConfigurationTests.swift +++ b/PeerConnectivityTests/PeerSecurityConfigurationTests.swift @@ -53,6 +53,30 @@ class PeerSecurityConfigurationTests: XCTestCase { manager.stop() } + func testManagerDefaultsToAcceptAllInvitationPolicy() { + let manager = PeerConnectionManager(serviceType: "invite-default") + + switch manager.invitationPolicy { + case .acceptAll: + break + default: + XCTFail("Default invitation policy should accept all invitations") + } + manager.stop() + } + + func testManagerStoresInvitationPolicy() { + let manager = makeManager(invitationPolicy: .rejectAll) + + switch manager.invitationPolicy { + case .rejectAll: + break + default: + XCTFail("Manager should store the configured invitation policy") + } + manager.stop() + } + // MARK: - Certificate Policy Tests func testAcceptAllCertificatePolicyAcceptsMissingCertificate() { @@ -113,6 +137,86 @@ class PeerSecurityConfigurationTests: XCTestCase { manager.stop() } + // MARK: - Invitation Policy Tests + + func testAcceptAllInvitationPolicyAcceptsInvitation() { + let manager = makeManager(invitationPolicy: .acceptAll) + let result = evaluateInvitationPolicy(manager: manager, context: nil) + + XCTAssertEqual(result, true) + manager.stop() + } + + func testRejectAllInvitationPolicyRejectsInvitation() { + let manager = makeManager(invitationPolicy: .rejectAll) + let result = evaluateInvitationPolicy(manager: manager, context: nil) + + XCTAssertEqual(result, false) + manager.stop() + } + + func testCustomInvitationPolicyReceivesPeerAndContextAndControlsAcceptance() { + var receivedPeer: Peer? + var receivedContext: Data? + let expectedContext = "pairing".data(using: .utf8) + let manager = makeManager(invitationPolicy: .custom { peer, context in + receivedPeer = peer + receivedContext = context + return false + }) + + let result = evaluateInvitationPolicy(manager: manager, context: expectedContext) + + XCTAssertEqual(result, false) + XCTAssertEqual(receivedPeer, manager.peer) + XCTAssertEqual(receivedContext, expectedContext) + manager.stop() + } + + func testManualInvitationPolicyPreservesReceivedInvitationEvent() { + let manager = makeManager(invitationPolicy: .manual) + var receivedPeer: Peer? + var receivedContext: Data? + let expectedContext = "manual".data(using: .utf8) + + manager.listenOn({ event in + switch event { + case .receivedInvitation(let peer, let context, let invitationHandler): + receivedPeer = peer + receivedContext = context + invitationHandler(false) + default: break + } + }, performListenerInBackground: true, withKey: "manual-invitation") + + let result = evaluateInvitationPolicy(manager: manager, context: expectedContext) + + XCTAssertEqual(result, false) + XCTAssertEqual(receivedPeer, manager.peer) + XCTAssertEqual(receivedContext, expectedContext) + manager.stop() + } + + func testCustomConnectionTypePreservesReceivedInvitationEvent() { + let manager = makeManager(invitationPolicy: .acceptAll, connectionType: .custom) + var receivedInvitation = false + + manager.listenOn({ event in + switch event { + case .receivedInvitation(_, _, let invitationHandler): + receivedInvitation = true + invitationHandler(false) + default: break + } + }, performListenerInBackground: true, withKey: "custom-invitation") + + let result = evaluateInvitationPolicy(manager: manager, context: nil) + + XCTAssertEqual(receivedInvitation, true) + XCTAssertEqual(result, false) + manager.stop() + } + // MARK: - Helper Methods /// Builds a manager with a specific certificate policy. @@ -128,6 +232,16 @@ class PeerSecurityConfigurationTests: XCTestCase { ) } + /// Builds a manager with a specific invitation policy. + private func makeManager(invitationPolicy: PeerInvitationPolicy, + connectionType: PeerConnectionType = .automatic) -> PeerConnectionManager { + return PeerConnectionManager( + serviceType: "inv-\(UUID().uuidString.prefix(8).lowercased())", + connectionType: connectionType, + invitationPolicy: invitationPolicy + ) + } + /// Applies the manager certificate policy and returns the resulting handler value. private func evaluateCertificatePolicy(manager: PeerConnectionManager, certificate: [Any]?) -> Bool? { var result: Bool? @@ -136,4 +250,13 @@ class PeerSecurityConfigurationTests: XCTestCase { } return result } + + /// Applies the manager invitation policy and returns the resulting handler value. + private func evaluateInvitationPolicy(manager: PeerConnectionManager, context: Data?) -> Bool? { + var result: Bool? + manager.handleInvitation(peer: manager.peer, context: context) { accepted, _ in + result = accepted + } + return result + } } diff --git a/Sources/PeerConnectionManager.swift b/Sources/PeerConnectionManager.swift index 76155e8..2b7b879 100644 --- a/Sources/PeerConnectionManager.swift +++ b/Sources/PeerConnectionManager.swift @@ -116,6 +116,14 @@ public class PeerConnectionManager { tokens, emails, stable user IDs, or sensitive device information. */ public let discoveryInfo : PeerDiscoveryInfo? + + /** + Policy used to decide whether incoming invitations are accepted in `.automatic` mode. + + Invitation context is unauthenticated metadata received before session establishment. + Do not treat it as trusted or include raw secrets. + */ + public let invitationPolicy : PeerInvitationPolicy /** Returns the peers that are connected on the current session. @@ -187,6 +195,7 @@ public class PeerConnectionManager { - parameter displayName: The local user's display name to other peers. - parameter securityConfiguration: Security settings used to create the underlying MultipeerConnectivity session. - parameter discoveryInfo: Public, unauthenticated metadata advertised to nearby browsers. + - parameter invitationPolicy: Policy used to decide whether incoming invitations are accepted in `.automatic` mode. - Returns: A fully initialized `PeerConnectionManager`. */ @@ -200,13 +209,15 @@ public class PeerConnectionManager { #endif }(), securityConfiguration: PeerSecurityConfiguration = .default, - discoveryInfo: PeerDiscoveryInfo? = nil) { + discoveryInfo: PeerDiscoveryInfo? = nil, + invitationPolicy: PeerInvitationPolicy = .acceptAll) { self.connectionType = connectionType self.serviceType = serviceType self.peer = Peer(displayName: displayName) self.securityConfiguration = securityConfiguration self.discoveryInfo = discoveryInfo + self.invitationPolicy = invitationPolicy sessionEventProducer = PeerSessionEventProducer(observer: sessionObserver) browserEventProducer = PeerBrowserEventProducer(observer: browserObserver) @@ -254,6 +265,38 @@ public class PeerConnectionManager { certificateHandler(peer, certificate, handler) } } + + internal func handleInvitation(peer: Peer, + context: Data?, + invitationHandler: @escaping (Bool, PeerSession) -> Void) { + let completeInvitation = { [weak self] (accept: Bool) -> Void in + guard let strongSelf = self else { return } + invitationHandler(accept, strongSelf.session) + if accept && strongSelf.connectionType == .automatic { + strongSelf.advertiser.stopAdvertising() + } + } + + guard connectionType == .automatic else { + observer.value = .receivedInvitation(peer: peer, + withContext: context, + invitationHandler: completeInvitation) + return + } + + switch invitationPolicy { + case .manual: + observer.value = .receivedInvitation(peer: peer, + withContext: context, + invitationHandler: completeInvitation) + case .acceptAll: + completeInvitation(true) + case .rejectAll: + completeInvitation(false) + case .custom(let invitationPolicy): + completeInvitation(invitationPolicy(peer, context)) + } + } } extension PeerConnectionManager { @@ -491,12 +534,7 @@ extension PeerConnectionManager { advertiserObserver.addObserver { [weak self] event in switch event { case .didReceiveInvitationFromPeer(peer: let peer, withContext: let context, invitationHandler: let invite): - let invitationReceiver = { - [weak self] (accept: Bool) -> Void in - guard let session = self?.session else { return } - invite(accept, session) - } - self?.observer.value = .receivedInvitation(peer: peer, withContext: context, invitationHandler: invitationReceiver) + self?.handleInvitation(peer: peer, context: context, invitationHandler: invite) case .didNotStartAdvertisingPeer(let error): self?.observer.value = .error(error) default: break @@ -586,18 +624,6 @@ extension PeerConnectionManager { } } } - if shouldAdvertise { - advertiserObserver.addObserver { [unowned self] event in - DispatchQueue.main.async { - switch event { - case .didReceiveInvitationFromPeer(peer: _, withContext: _, invitationHandler: let handler): - handler(true, self.session) - self.advertiser.stopAdvertising() - default: break - } - } - } - } case .inviteOnly where shouldAdvertise: advertiserAssisstant.startAdvertisingAssisstant() case .inviteOnly, .custom: diff --git a/Sources/PeerSecurityConfiguration.swift b/Sources/PeerSecurityConfiguration.swift index 615d05a..c777169 100644 --- a/Sources/PeerSecurityConfiguration.swift +++ b/Sources/PeerSecurityConfiguration.swift @@ -44,6 +44,36 @@ public enum PeerCertificatePolicy { case custom((Peer, [Any]?, @escaping (Bool) -> Void) -> Void) } +/** + Policy used to decide whether incoming invitations should be accepted. + + Invitation context is received before session establishment and should be treated as + public, unauthenticated metadata. Do not include raw secrets in invitation context. + */ +public enum PeerInvitationPolicy { + /** + Surface invitations through `.receivedInvitation` for caller-managed decisions. + */ + case manual + /** + Accept every incoming invitation. + + This preserves PeerConnectivity's historical `.automatic` behavior. + */ + case acceptAll + /** + Reject every incoming invitation. + */ + case rejectAll + /** + Delegate invitation decisions to caller-provided logic. + + - parameter peer: The peer sending the invitation. + - parameter context: Optional invitation context supplied by the inviting peer. + */ + case custom((Peer, Data?) -> Bool) +} + /** Security settings used when creating the underlying `MCSession`. From 76af29b5736ba3363297da49a01e102336116d99 Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Fri, 5 Jun 2026 20:32:26 -0700 Subject: [PATCH 07/10] feat: add peer identifier validation helpers --- .../PeerConnectivityTests.swift | 22 +++++++++++++ Sources/Peer.swift | 17 ++++++++-- Sources/PeerConnectionManager.swift | 31 +++++++++++++++++-- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/PeerConnectivityTests/PeerConnectivityTests.swift b/PeerConnectivityTests/PeerConnectivityTests.swift index d7b1239..13c1e13 100644 --- a/PeerConnectivityTests/PeerConnectivityTests.swift +++ b/PeerConnectivityTests/PeerConnectivityTests.swift @@ -115,5 +115,27 @@ class PeerConnectivityTests: XCTestCase { XCTAssertEqual(receivedDiscoveryInfo?["version"], "1") } + + func testServiceTypeValidationAcceptsSupportedValues() { + XCTAssertTrue(PeerConnectionManager.isValidServiceType("chat")) + XCTAssertTrue(PeerConnectionManager.isValidServiceType("chat-1")) + XCTAssertTrue(PeerConnectionManager.isValidServiceType("abcdefghijklmn1")) + } + + func testServiceTypeValidationRejectsUnsupportedValues() { + XCTAssertFalse(PeerConnectionManager.isValidServiceType("")) + XCTAssertFalse(PeerConnectionManager.isValidServiceType("abcdefghijklmnop")) + XCTAssertFalse(PeerConnectionManager.isValidServiceType("Chat")) + XCTAssertFalse(PeerConnectionManager.isValidServiceType("chat_room")) + XCTAssertFalse(PeerConnectionManager.isValidServiceType("chat.room")) + } + + func testDisplayNameValidationUsesUtf8ByteLength() { + XCTAssertTrue(Peer.isValidDisplayName("peer")) + XCTAssertTrue(Peer.isValidDisplayName(String(repeating: "a", count: 63))) + XCTAssertFalse(Peer.isValidDisplayName("")) + XCTAssertFalse(Peer.isValidDisplayName(String(repeating: "a", count: 64))) + XCTAssertFalse(Peer.isValidDisplayName(String(repeating: "é", count: 32))) + } } diff --git a/Sources/Peer.swift b/Sources/Peer.swift index 49f32b7..fd3a292 100644 --- a/Sources/Peer.swift +++ b/Sources/Peer.swift @@ -13,6 +13,19 @@ import MultipeerConnectivity Struct reperesenting a user available for mesh-networking on the PeerConnectivity framework. */ public struct Peer { + + /** + Returns whether a display name satisfies MultipeerConnectivity's documented constraints. + + Display names are visible to nearby peers. Avoid personal device names, email addresses, + stable user identifiers, or other sensitive information when choosing a display name. + + - parameter displayName: Display name string to validate. + - Returns: `true` when the display name is non-empty and no more than 63 bytes when UTF-8 encoded. + */ + public static func isValidDisplayName(_ displayName: String) -> Bool { + return !displayName.isEmpty && displayName.lengthOfBytes(using: .utf8) <= 63 + } /** Peer connection status. @@ -37,7 +50,7 @@ public struct Peer { } /** - The peer's display name + The peer's display name. Display names are visible to nearby peers. */ public var displayName : String { return peerID.displayName @@ -58,7 +71,7 @@ public struct Peer { } /** - Initializer for the local peer. DisplayName Must not be longer than 63 bytes in UTF8 Encoding according to the Apple documentation. ( xcdoc://?url=developer.apple.com/library/ios/documentation/MultipeerConnectivity/Reference/MCPeerID_class/index.html#//apple_ref/swift/cl/c:objc(cs)MCPeerID ) + Initializer for the local peer. Display names must not be longer than 63 bytes in UTF-8 encoding and are visible to nearby peers. */ internal init(displayName: String) { peerID = MCPeerID(displayName: displayName) diff --git a/Sources/PeerConnectionManager.swift b/Sources/PeerConnectionManager.swift index 2b7b879..b4b2839 100644 --- a/Sources/PeerConnectionManager.swift +++ b/Sources/PeerConnectionManager.swift @@ -11,6 +11,9 @@ import MultipeerConnectivity /** The service type describing the channel over which connections are made. + + MultipeerConnectivity service types must be short Bonjour-style identifiers. Use + `PeerConnectionManager.isValidServiceType(_:)` to validate caller-provided values. */ public typealias ServiceType = String @@ -92,6 +95,30 @@ public class PeerConnectionManager { Access to shared connection managers by their service type. */ public fileprivate(set) static var shared : [ServiceType:PeerConnectionManager] = [:] + + /** + Returns whether a service type satisfies MultipeerConnectivity's documented constraints. + + Valid service types are 1 to 15 characters and contain only ASCII lowercase letters, + numbers, and hyphens. Invalid values may cause MultipeerConnectivity objects to fail + during initialization. + + - parameter serviceType: Service type string to validate. + - Returns: `true` when the service type matches the supported format. + */ + public static func isValidServiceType(_ serviceType: ServiceType) -> Bool { + guard !serviceType.isEmpty && serviceType.count <= 15 else { return false } + + for scalar in serviceType.unicodeScalars { + switch scalar.value { + case 45, 48...57, 97...122: + continue + default: + return false + } + } + return true + } // MARK: Properties /** @@ -190,9 +217,9 @@ public class PeerConnectionManager { /** Initializer for a connection manager. Requires the requested service type. If the connectionType and displayName are not specified the connection manager defaults to .Automatic and using the localized host name where available, falling back to the process host name. The `PeerConnectivityUI` product provides an iOS convenience initializer that uses the current device name. - - parameter serviceType: The requested service type describing the channel on which peers are able to connect. + - parameter serviceType: The requested service type describing the channel on which peers are able to connect. Use `isValidServiceType(_:)` to validate caller-provided values before initialization. - parameter connectionType: Takes a PeerConnectionType case determining the default behavior of the framework. - - parameter displayName: The local user's display name to other peers. + - parameter displayName: The local user's display name to other peers. Display names are visible to nearby peers and must be no more than 63 bytes when UTF-8 encoded. - parameter securityConfiguration: Security settings used to create the underlying MultipeerConnectivity session. - parameter discoveryInfo: Public, unauthenticated metadata advertised to nearby browsers. - parameter invitationPolicy: Policy used to decide whether incoming invitations are accepted in `.automatic` mode. From 0f7433e0c6bc12396b40eaa65bb7385f08e72c0c Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Fri, 5 Jun 2026 20:57:35 -0700 Subject: [PATCH 08/10] feat: add browser peer filtering --- .../PeerConnectivityTests.swift | 8 ++++++ README.md | 11 ++++++++ Sources/Peer.swift | 8 +++++- .../PeerBrowserAssisstant.swift | 5 ++-- ...erBrowserViewControllerEventProducer.swift | 27 ++++++++++++++----- .../PeerConnectionManager+UI.swift | 6 +++-- 6 files changed, 54 insertions(+), 11 deletions(-) diff --git a/PeerConnectivityTests/PeerConnectivityTests.swift b/PeerConnectivityTests/PeerConnectivityTests.swift index 13c1e13..05f582f 100644 --- a/PeerConnectivityTests/PeerConnectivityTests.swift +++ b/PeerConnectivityTests/PeerConnectivityTests.swift @@ -137,5 +137,13 @@ class PeerConnectivityTests: XCTestCase { XCTAssertFalse(Peer.isValidDisplayName(String(repeating: "a", count: 64))) XCTAssertFalse(Peer.isValidDisplayName(String(repeating: "é", count: 32))) } + + func testPeerCanWrapExistingPeerIdentifier() { + let peerID = MCPeerID(displayName: "remote") + let peer = Peer(peerID: peerID, status: .notConnected) + + XCTAssertEqual(peer.displayName, "remote") + XCTAssertEqual(peer.status, .notConnected) + } } diff --git a/README.md b/README.md index ad97319..bf0e940 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,17 @@ let browserViewController = pcm.browserViewController { event in } ``` +Use the optional `peerFilter` to hide nearby peers before they are presented by +`MCBrowserViewController`. Discovery info is public, unauthenticated metadata, so +only use it for non-secret values such as protocol versions, public capabilities, +or non-secret room labels. + +```swift +let filteredBrowserViewController = pcm.browserViewController({ _ in }, peerFilter: { peer, discoveryInfo in + return discoveryInfo?["protocol"] == "2" +}) +``` + ## Sending Events to Peers ```swift diff --git a/Sources/Peer.swift b/Sources/Peer.swift index fd3a292..6611ccb 100644 --- a/Sources/Peer.swift +++ b/Sources/Peer.swift @@ -65,7 +65,13 @@ public struct Peer { */ public let status : Status - internal init(peerID: MCPeerID, status: Status) { + /** + Initializer for a peer backed by an existing MultipeerConnectivity peer identifier. + + - parameter peerID: Existing MultipeerConnectivity peer identifier. + - parameter status: The peer's connection status. + */ + public init(peerID: MCPeerID, status: Status) { self.peerID = peerID self.status = status } diff --git a/Sources/PeerConnectivityUI/PeerBrowserAssisstant.swift b/Sources/PeerConnectivityUI/PeerBrowserAssisstant.swift index ecd46c7..9d99c75 100644 --- a/Sources/PeerConnectivityUI/PeerBrowserAssisstant.swift +++ b/Sources/PeerConnectivityUI/PeerBrowserAssisstant.swift @@ -38,8 +38,9 @@ internal struct PeerBrowserAssisstant { self.serviceType = serviceType } - internal func peerBrowserViewController(_ callback: @escaping (PeerBrowserViewControllerEvent) -> Void) -> MCBrowserViewController { - let eventProducer = PeerBrowserViewControllerEventProducer(callback: callback) + internal func peerBrowserViewController(_ callback: @escaping (PeerBrowserViewControllerEvent) -> Void, + peerFilter: PeerBrowserViewControllerPeerFilter? = nil) -> MCBrowserViewController { + let eventProducer = PeerBrowserViewControllerEventProducer(callback: callback, peerFilter: peerFilter) return PeerBrowserViewController(serviceType: serviceType, session: session, eventProducer: eventProducer) } } diff --git a/Sources/PeerConnectivityUI/PeerBrowserViewControllerEventProducer.swift b/Sources/PeerConnectivityUI/PeerBrowserViewControllerEventProducer.swift index b1e3f5d..6a5acf6 100644 --- a/Sources/PeerConnectivityUI/PeerBrowserViewControllerEventProducer.swift +++ b/Sources/PeerConnectivityUI/PeerBrowserViewControllerEventProducer.swift @@ -8,6 +8,7 @@ import Foundation import MultipeerConnectivity +import PeerConnectivity #if canImport(UIKit) import UIKit @@ -25,21 +26,35 @@ public enum PeerBrowserViewControllerEvent { case didFinish /// The user did cancel their interaction with the browser view controller. case wasCancelled - -// case shouldPresentNearbyPeer } +/** + Synchronous filter used by `MCBrowserViewController` before presenting a nearby peer. + + Discovery info is advertised before a session is established and should be treated as + public, unauthenticated metadata. Use this only for non-secret filtering, such as protocol + versions, public capability flags, or non-secret room labels. + */ +public typealias PeerBrowserViewControllerPeerFilter = (Peer, PeerDiscoveryInfo?) -> Bool + internal final class PeerBrowserViewControllerEventProducer: NSObject, MCBrowserViewControllerDelegate { fileprivate let callback: (PeerBrowserViewControllerEvent) -> Void + fileprivate let peerFilter: PeerBrowserViewControllerPeerFilter? - internal init(callback: @escaping (PeerBrowserViewControllerEvent) -> Void) { + internal init(callback: @escaping (PeerBrowserViewControllerEvent) -> Void, + peerFilter: PeerBrowserViewControllerPeerFilter? = nil) { self.callback = callback + self.peerFilter = peerFilter } -// func browserViewController(browserViewController: MCBrowserViewController, shouldPresentNearbyPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) -> Bool { -// return true -// } + internal func browserViewController(_ browserViewController: MCBrowserViewController, + shouldPresentNearbyPeer peerID: MCPeerID, + withDiscoveryInfo info: [String : String]?) -> Bool { + guard let peerFilter = peerFilter else { return true } + let peer = Peer(peerID: peerID, status: .notConnected) + return peerFilter(peer, info) + } internal func browserViewControllerDidFinish(_ browserViewController: MCBrowserViewController) { diff --git a/Sources/PeerConnectivityUI/PeerConnectionManager+UI.swift b/Sources/PeerConnectivityUI/PeerConnectionManager+UI.swift index ced9a90..9fd30da 100644 --- a/Sources/PeerConnectivityUI/PeerConnectionManager+UI.swift +++ b/Sources/PeerConnectivityUI/PeerConnectionManager+UI.swift @@ -29,14 +29,16 @@ extension PeerConnectionManager { Returns a browser view controller if the connectionType was set to `.InviteOnly` or returns `nil` if not. - parameter callback: Events sent back with cases `.DidFinish` and `.DidCancel`. + - parameter peerFilter: Optional synchronous filter used before nearby peers are presented. - Returns: A browser view controller for inviting available peers nearby if connection type is `.InviteOnly` or `nil` otherwise. */ - public func browserViewController(_ callback: @escaping (PeerBrowserViewControllerEvent)->Void) -> UIViewController? { + public func browserViewController(_ callback: @escaping (PeerBrowserViewControllerEvent)->Void, + peerFilter: PeerBrowserViewControllerPeerFilter? = nil) -> UIViewController? { switch connectionType { case .inviteOnly: let browserAssisstant = PeerBrowserAssisstant(session: multipeerSession, serviceType: peerServiceType) - return browserAssisstant.peerBrowserViewController(callback) + return browserAssisstant.peerBrowserViewController(callback, peerFilter: peerFilter) default: return nil } } From 14d3a82109d7236a8772950d1222603558275f5d Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Sat, 6 Jun 2026 14:53:01 -0700 Subject: [PATCH 09/10] fix: preserve certificate event compatibility --- .../PeerSecurityConfigurationTests.swift | 27 +++++++++++++++++++ Sources/PeerConnectionManager.swift | 2 ++ Sources/PeerConnectionResponder.swift | 6 +++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/PeerConnectivityTests/PeerSecurityConfigurationTests.swift b/PeerConnectivityTests/PeerSecurityConfigurationTests.swift index c038098..5a6f188 100644 --- a/PeerConnectivityTests/PeerSecurityConfigurationTests.swift +++ b/PeerConnectivityTests/PeerSecurityConfigurationTests.swift @@ -137,6 +137,33 @@ class PeerSecurityConfigurationTests: XCTestCase { manager.stop() } + func testCertificatePolicyStillEmitsCompatibilityEvent() { + let manager = makeManager(policy: .acceptAll) + var result: Bool? + var receivedPeer: Peer? + var receivedCertificate: [Any]? + let expectedCertificate: [Any] = ["certificate"] + + manager.listenOn({ event in + switch event { + case .receivedCertificate(let peer, let certificate, let handler): + receivedPeer = peer + receivedCertificate = certificate + handler(false) + default: break + } + }, performListenerInBackground: true, withKey: "certificate-compatibility") + + manager.handleCertificate(peer: manager.peer, certificate: expectedCertificate) { accepted in + result = accepted + } + + XCTAssertEqual(result, true) + XCTAssertEqual(receivedPeer, manager.peer) + XCTAssertEqual(receivedCertificate?.first as? String, "certificate") + manager.stop() + } + // MARK: - Invitation Policy Tests func testAcceptAllInvitationPolicyAcceptsInvitation() { diff --git a/Sources/PeerConnectionManager.swift b/Sources/PeerConnectionManager.swift index b4b2839..57f3ad9 100644 --- a/Sources/PeerConnectionManager.swift +++ b/Sources/PeerConnectionManager.swift @@ -291,6 +291,8 @@ public class PeerConnectionManager { case .custom(let certificateHandler): certificateHandler(peer, certificate, handler) } + + observer.value = .receivedCertificate(peer: peer, certificate: certificate, handler: { _ in }) } internal func handleInvitation(peer: Peer, diff --git a/Sources/PeerConnectionResponder.swift b/Sources/PeerConnectionResponder.swift index 1210519..c8e7ca5 100644 --- a/Sources/PeerConnectionResponder.swift +++ b/Sources/PeerConnectionResponder.swift @@ -61,7 +61,8 @@ public enum PeerConnectionEvent { Received security certificate from `Peer` with handler. Certificate decisions are handled by `PeerSecurityConfiguration.certificatePolicy`. - This event remains for API compatibility. + This event is emitted for observation/API compatibility only. Calling the supplied + handler does not affect the certificate decision. */ case receivedCertificate(peer: Peer, certificate: [Any]?, handler: (Bool)->Void) /** @@ -80,7 +81,8 @@ public enum PeerConnectionEvent { Found nearby `Peer` with advertised discovery metadata. Discovery info is public, unauthenticated Bonjour TXT record metadata. Do not treat it as secret - or trusted without additional validation. + or trusted without additional validation. Callers with exhaustive switches over + `PeerConnectionEvent` should handle this case or include a `default` case. */ case foundPeerWithDiscoveryInfo(peer: Peer, discoveryInfo: PeerDiscoveryInfo?) /** From 847a0204cf49ff161bf824c3e95139a08965474c Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Mon, 8 Jun 2026 18:52:55 -0700 Subject: [PATCH 10/10] feat(demo): add security configuration examples --- .../project.pbxproj | 6 + PeerConnectivityDemo/ViewController.swift | 347 +++++++++++++++--- README.md | 14 + 3 files changed, 311 insertions(+), 56 deletions(-) diff --git a/PeerConnectivityDemo.xcodeproj/project.pbxproj b/PeerConnectivityDemo.xcodeproj/project.pbxproj index b2254e1..2c0b98b 100644 --- a/PeerConnectivityDemo.xcodeproj/project.pbxproj +++ b/PeerConnectivityDemo.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 309837421D8A8D600002338A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 309837411D8A8D600002338A /* Assets.xcassets */; }; 309837451D8A8D600002338A /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 309837431D8A8D600002338A /* LaunchScreen.storyboard */; }; B1E4E4E82F30592D00AE11AA /* PeerConnectivity in Frameworks */ = {isa = PBXBuildFile; productRef = B1E4E4E72F30592D00AE11AA /* PeerConnectivity */; }; + B1E4E4EA2F30592D00AE11AA /* PeerConnectivityUI in Frameworks */ = {isa = PBXBuildFile; productRef = B1E4E4E92F30592D00AE11AA /* PeerConnectivityUI */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -32,6 +33,7 @@ buildActionMask = 2147483647; files = ( B1E4E4E82F30592D00AE11AA /* PeerConnectivity in Frameworks */, + B1E4E4EA2F30592D00AE11AA /* PeerConnectivityUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -366,6 +368,10 @@ isa = XCSwiftPackageProductDependency; productName = PeerConnectivity; }; + B1E4E4E92F30592D00AE11AA /* PeerConnectivityUI */ = { + isa = XCSwiftPackageProductDependency; + productName = PeerConnectivityUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3098372F1D8A8D600002338A /* Project object */; diff --git a/PeerConnectivityDemo/ViewController.swift b/PeerConnectivityDemo/ViewController.swift index d1cabb4..46335ae 100644 --- a/PeerConnectivityDemo/ViewController.swift +++ b/PeerConnectivityDemo/ViewController.swift @@ -8,71 +8,291 @@ import UIKit import PeerConnectivity +import PeerConnectivityUI class ViewController: UIViewController { - - fileprivate lazy var pcm : PeerConnectionManager = { - var pcm = PeerConnectionManager(serviceType: "local") - pcm.listenOn({ [weak self] (event) in - - switch event { - case .devicesChanged(let peer, let connectedPeers): - - _ = connectedPeers.map { print($0.displayName) } - - defer { - if let origin = self?.userStatusLabel?.frame.origin, - let size = self?.userStatusLabel?.intrinsicContentSize { - self?.userStatusLabel?.frame = CGRect(origin: origin, size: size) - } - } - - guard !connectedPeers.isEmpty else { - self?.userStatusLabel?.text = "Not Connected!" - return - } - - if peer.status == .connected || peer.status == .notConnected { - self?.userStatusLabel?.text = connectedPeers.map { $0.displayName }.reduce("Connected to:") { $0 + "\n" + $1 } - } - - default: break + + fileprivate enum DemoMode: Int, CaseIterable { + case open + case encrypted + case manualInvitation + case filteredBrowser + case rejectCertificate + + fileprivate var title : String { + switch self { + case .open: return "Open" + case .encrypted: return "Encrypted" + case .manualInvitation: return "Manual Invite" + case .filteredBrowser: return "Filtered Browser" + case .rejectCertificate: return "Reject Cert" + } + } + + fileprivate var instructions : String { + switch self { + case .open: + return "Backward-compatible automatic mode: optional encryption, accept-all certificates, accept-all invitations." + case .encrypted: + return "Requires encrypted MCSession transport and uses a custom certificate policy that logs and accepts." + case .manualInvitation: + return "Automatic discovery, but incoming invitations show an accept/reject alert before joining." + case .filteredBrowser: + return "Invite-only mode with discoveryInfo protocol=2. Tap Open Browser to test peerFilter." + case .rejectCertificate: + return "Rejects all peer certificates. Use this to verify failed session establishment." + } + } + + fileprivate var discoveryInfo : PeerDiscoveryInfo { + switch self { + case .filteredBrowser: + return ["protocol": "2", "mode": "filtered"] + case .encrypted: + return ["protocol": "2", "mode": "encrypted"] + case .manualInvitation: + return ["protocol": "1", "mode": "manual"] + case .rejectCertificate: + return ["protocol": "1", "mode": "reject"] + case .open: + return ["protocol": "1", "mode": "open"] } - - }, withKey: "configurationKey") - return pcm - }() - + } + } + + fileprivate let serviceType : ServiceType = "local" + fileprivate var currentMode : DemoMode = .open + fileprivate var pcm : PeerConnectionManager! fileprivate var isConnecting = false - + + fileprivate var modeControl : UISegmentedControl! fileprivate var connectionButton : UIButton! + fileprivate var browserButton : UIButton! fileprivate var userStatusLabel : UILabel! + fileprivate var logTextView : UITextView! override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view, typically from a nib. - + view.backgroundColor = .white + + configureControls() + rebuildManager() + updateInterfaceForCurrentMode() + appendLog("Service type valid: \(PeerConnectionManager.isValidServiceType(serviceType))") + appendLog("Display name valid: \(Peer.isValidDisplayName(UIDevice.current.name))") + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + } + + fileprivate func configureControls() { + modeControl = UISegmentedControl(items: DemoMode.allCases.map { $0.title }) + modeControl.selectedSegmentIndex = currentMode.rawValue + modeControl.addTarget(self, action: #selector(changedMode(sender:)), for: .valueChanged) + modeControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(modeControl) + connectionButton = UIButton(type: UIButton.ButtonType.system) - connectionButton.setTitle("Start networking!", for: .normal) + connectionButton.setTitle("Start networking", for: .normal) connectionButton.setTitleColor(.blue, for: .normal) - connectionButton.sizeToFit() - connectionButton.center = view.center connectionButton.addTarget(self, action: #selector(tappedConnectionButton(sender:)), for: UIControl.Event.touchUpInside) + connectionButton.translatesAutoresizingMaskIntoConstraints = false view.addSubview(connectionButton) - + + browserButton = UIButton(type: UIButton.ButtonType.system) + browserButton.setTitle("Open Filtered Browser", for: .normal) + browserButton.addTarget(self, action: #selector(tappedBrowserButton(sender:)), for: UIControl.Event.touchUpInside) + browserButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(browserButton) + userStatusLabel = UILabel() userStatusLabel.numberOfLines = 0 + userStatusLabel.textAlignment = .center userStatusLabel.text = "Not Connected!" - userStatusLabel.sizeToFit() - userStatusLabel.center = view.center - let frame = userStatusLabel.frame - userStatusLabel.frame = frame.offsetBy(dx: 0, dy: frame.size.height*2) + userStatusLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(userStatusLabel) + + logTextView = UITextView() + logTextView.isEditable = false + logTextView.font = UIFont.preferredFont(forTextStyle: .footnote) + logTextView.layer.borderColor = UIColor.lightGray.cgColor + logTextView.layer.borderWidth = 1 + logTextView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(logTextView) + + let guide = view.safeAreaLayoutGuide + NSLayoutConstraint.activate([ + modeControl.topAnchor.constraint(equalTo: guide.topAnchor, constant: 20), + modeControl.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 16), + modeControl.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -16), + + connectionButton.topAnchor.constraint(equalTo: modeControl.bottomAnchor, constant: 20), + connectionButton.centerXAnchor.constraint(equalTo: guide.centerXAnchor), + + browserButton.topAnchor.constraint(equalTo: connectionButton.bottomAnchor, constant: 12), + browserButton.centerXAnchor.constraint(equalTo: guide.centerXAnchor), + + userStatusLabel.topAnchor.constraint(equalTo: browserButton.bottomAnchor, constant: 20), + userStatusLabel.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 16), + userStatusLabel.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -16), + + logTextView.topAnchor.constraint(equalTo: userStatusLabel.bottomAnchor, constant: 20), + logTextView.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 16), + logTextView.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -16), + logTextView.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -16), + ]) } - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. + fileprivate func rebuildManager() { + pcm?.stop() + pcm?.removeAllListeners() + pcm = makeManager(mode: currentMode) + configureListeners(for: pcm) + } + + fileprivate func makeManager(mode: DemoMode) -> PeerConnectionManager { + switch mode { + case .open: + return PeerConnectionManager( + serviceType: serviceType, + connectionType: .automatic, + displayName: UIDevice.current.name, + discoveryInfo: mode.discoveryInfo + ) + case .encrypted: + let configuration = PeerSecurityConfiguration( + encryptionPreference: .required, + securityIdentity: nil, + certificatePolicy: .custom { [weak self] peer, certificate, handler in + self?.appendLog("Custom certificate policy for \(peer.displayName); certificate count: \(certificate?.count ?? 0)") + handler(true) + } + ) + return PeerConnectionManager( + serviceType: serviceType, + connectionType: .automatic, + displayName: UIDevice.current.name, + securityConfiguration: configuration, + discoveryInfo: mode.discoveryInfo, + invitationPolicy: .acceptAll + ) + case .manualInvitation: + return PeerConnectionManager( + serviceType: serviceType, + connectionType: .automatic, + displayName: UIDevice.current.name, + discoveryInfo: mode.discoveryInfo, + invitationPolicy: .manual + ) + case .filteredBrowser: + return PeerConnectionManager( + serviceType: serviceType, + connectionType: .inviteOnly, + displayName: UIDevice.current.name, + securityConfiguration: .encrypted, + discoveryInfo: mode.discoveryInfo, + invitationPolicy: .acceptAll + ) + case .rejectCertificate: + let configuration = PeerSecurityConfiguration( + encryptionPreference: .optional, + securityIdentity: nil, + certificatePolicy: .rejectAll + ) + return PeerConnectionManager( + serviceType: serviceType, + connectionType: .automatic, + displayName: UIDevice.current.name, + securityConfiguration: configuration, + discoveryInfo: mode.discoveryInfo, + invitationPolicy: .acceptAll + ) + } + } + + fileprivate func configureListeners(for manager: PeerConnectionManager) { + manager.listenOn({ [weak self] event in + switch event { + case .started: + self?.appendLog("Started \(self?.currentMode.title ?? "mode")") + case .devicesChanged(let peer, let connectedPeers): + self?.appendLog("Device changed: \(peer.displayName) -> \(peer.status)") + self?.updateConnectedPeers(connectedPeers) + case .foundPeer(let peer): + self?.appendLog("Found peer: \(peer.displayName)") + case .foundPeerWithDiscoveryInfo(let peer, let discoveryInfo): + self?.appendLog("Found metadata for \(peer.displayName): \(discoveryInfo ?? [:])") + case .receivedInvitation(let peer, let context, let invitationHandler): + self?.presentInvitationPrompt(peer: peer, context: context, invitationHandler: invitationHandler) + case .receivedCertificate(let peer, let certificate, _): + self?.appendLog("Observed certificate from \(peer.displayName); count: \(certificate?.count ?? 0)") + case .error(let error): + self?.appendLog("Error: \(error.localizedDescription)") + default: break + } + }, withKey: "demo-listener") + } + + fileprivate func updateInterfaceForCurrentMode() { + browserButton.isHidden = currentMode != .filteredBrowser + userStatusLabel.text = "Not Connected!\n\n\(currentMode.instructions)" + connectionButton.setTitle(isConnecting ? "Stop networking" : "Start networking", for: .normal) + connectionButton.setTitleColor(isConnecting ? .red : .blue, for: .normal) + } + + fileprivate func updateConnectedPeers(_ connectedPeers: [Peer]) { + guard !connectedPeers.isEmpty else { + userStatusLabel.text = "Not Connected!\n\n\(currentMode.instructions)" + return + } + userStatusLabel.text = connectedPeers.map { $0.displayName }.reduce("Connected to:") { $0 + "\n" + $1 } + } + + fileprivate func presentInvitationPrompt(peer: Peer, context: Data?, invitationHandler: @escaping (Bool)->Void) { + let contextText: String + if let context = context, let string = String(data: context, encoding: .utf8) { + contextText = string + } else { + contextText = "No context" + } + + let alert = UIAlertController( + title: "Invitation from \(peer.displayName)", + message: "Context: \(contextText)", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Reject", style: .destructive) { _ in + self.appendLog("Rejected invitation from \(peer.displayName)") + invitationHandler(false) + }) + alert.addAction(UIAlertAction(title: "Accept", style: .default) { _ in + self.appendLog("Accepted invitation from \(peer.displayName)") + invitationHandler(true) + }) + present(alert, animated: true) + } + + fileprivate func appendLog(_ message: String) { + DispatchQueue.main.async { + let existing = self.logTextView.text ?? "" + let line = "• \(message)" + self.logTextView.text = existing.isEmpty ? line : existing + "\n" + line + let bottom = NSRange(location: max(self.logTextView.text.count - 1, 0), length: 1) + self.logTextView.scrollRangeToVisible(bottom) + } + } + + @objc internal func changedMode(sender: UISegmentedControl) { + guard let mode = DemoMode(rawValue: sender.selectedSegmentIndex) else { return } + if isConnecting { + pcm.stop() + isConnecting = false + } + currentMode = mode + rebuildManager() + appendLog("Selected mode: \(mode.title)") + updateInterfaceForCurrentMode() } @objc internal func tappedConnectionButton(sender: UIButton) { @@ -80,19 +300,34 @@ class ViewController: UIViewController { case false: pcm.start() isConnecting = true - - connectionButton.setTitle("Stop networking!", for: .normal) - connectionButton.setTitleColor(.red, for: .normal) - case true: pcm.stop() isConnecting = false - - connectionButton.setTitle("Start networking!", for: .normal) - connectionButton.setTitleColor(.blue, for: .normal) - - userStatusLabel.text = "Not Connected!" + userStatusLabel.text = "Not Connected!\n\n\(currentMode.instructions)" + appendLog("Stopped networking") } + updateInterfaceForCurrentMode() } -} + @objc internal func tappedBrowserButton(sender: UIButton) { + guard let browserViewController = pcm.browserViewController({ [weak self] event in + switch event { + case .didFinish: + self?.appendLog("Browser finished") + self?.dismiss(animated: true) + case .wasCancelled: + self?.appendLog("Browser cancelled") + self?.dismiss(animated: true) + default: break + } + }, peerFilter: { [weak self] peer, discoveryInfo in + let allowed = discoveryInfo?["protocol"] == "2" + self?.appendLog("Filter \(allowed ? "allowed" : "blocked") \(peer.displayName): \(discoveryInfo ?? [:])") + return allowed + }) else { + appendLog("Browser is only available in Filtered Browser mode") + return + } + present(browserViewController, animated: true) + } +} diff --git a/README.md b/README.md index bf0e940..ee05eb1 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,20 @@ let filteredBrowserViewController = pcm.browserViewController({ _ in }, peerFilt }) ``` +## Security Demo App + +The demo app includes selectable modes for exercising the security APIs: + +- **Open**: backward-compatible automatic networking with optional encryption. +- **Encrypted**: required encryption with a custom certificate policy that logs and accepts. +- **Manual Invite**: automatic discovery with manual incoming invitation approval. +- **Filtered Browser**: invite-only browsing with a `peerFilter` requiring `discoveryInfo["protocol"] == "2"`. +- **Reject Cert**: rejects all peer certificates to verify failed session establishment. + +Run `PeerConnectivityDemo.xcodeproj` on two simulators or devices, pick the same mode on both, +and tap **Start networking**. In **Filtered Browser** mode, tap **Open Filtered Browser** after +starting to test discovery metadata and peer filtering. + ## Sending Events to Peers ```swift