diff --git a/Artifacts/peer-browser-network-demo.png b/Artifacts/peer-browser-network-demo.png index 3358ea2..ebcd037 100644 Binary files a/Artifacts/peer-browser-network-demo.png and b/Artifacts/peer-browser-network-demo.png differ diff --git a/DemoSecurityAndInteroperabilityPlan.md b/DemoSecurityAndInteroperabilityPlan.md new file mode 100644 index 0000000..cbc6174 --- /dev/null +++ b/DemoSecurityAndInteroperabilityPlan.md @@ -0,0 +1,256 @@ +# Demo Security and Backend Interoperability Plan + +## Purpose + +Define the implementation needed to expose existing transport security in the demo and clarify whether MultipeerConnectivity and Network.framework peers can communicate with each other. + +## Executive summary + +- Security exists in both backends, but the demo currently hardcodes their least-disruptive defaults and exposes no security controls. +- The Network backend already supports TLS with a pre-shared key (PSK). The current demo always selects plaintext `.unauthenticated` transport. +- The MultipeerConnectivity backend already supports encryption preference, security identity, certificate policy, and invitation policy. The current demo uses optional encryption, no identity, and accepts certificates/invitations by default. +- The two backends are not wire-compatible and cannot directly discover, invite, authenticate, or exchange framed messages with each other. +- This work adds demo security controls for both backends without changing public library APIs. +- Interoperability remains report-only. No bridge, gateway, adapter, or common-wire-protocol code is in scope. + +## Current implementation + +### MultipeerConnectivity security + +Public configuration is defined in `Sources/PeerSecurityConfiguration.swift` and passed through `PeerConnectionManager`. + +| Capability | Existing API | Current demo value | +|---|---|---| +| Session encryption | `PeerSecurityConfiguration.encryptionPreference` | `.optional` through `.default` | +| Required encryption preset | `PeerSecurityConfiguration.encrypted` | Not exposed | +| Local certificate identity | `securityIdentity` | `nil` | +| Remote certificate policy | `PeerCertificatePolicy` | `.acceptAll` | +| Invitation policy | `PeerInvitationPolicy` | `.acceptAll` | + +`PeerSecurityConfiguration.encrypted` requires transport encryption, but it does not by itself provide a local certificate identity or authenticated peer identity. + +### Network.framework security + +`PeerConnectionNetworkSecurity` is defined in `Sources/PeerConnectionManager.swift`: + +- `.unauthenticated`: plain TCP; this is the current default and the value hardcoded by the demo. +- `.preSharedKey(Data)`: TLS-PSK configured in `Sources/NetworkPeerTransport.swift`. + +The Network PSK implementation is end-to-end and already covered by loopback tests, including mismatched-key rejection. Its trust boundary is limited: + +- A sufficiently random shared key authenticates membership in a group. +- It does not authenticate an individual peer. +- Bonjour TXT metadata and handshake names/identifiers remain self-asserted. +- Any group-key holder can impersonate another member's display name or identifier. + +See `NetworkTrustModelPlan.md` for the complete threat model. + +### Current demo behavior + +`PeerConnectivityDemo/ViewController.swift` currently creates the manager with: + +```swift +securityConfiguration: .default +invitationPolicy: .acceptAll +networkSecurity: .unauthenticated +``` + +The demo labels the Network path as unauthenticated, but users cannot select the implemented PSK path or require Multipeer encryption. + +## Proposed demo security UI + +Add a **Security** section beneath Backend and Connection Behavior. Security selection is backend-specific, remembered per backend during the demo session, and disabled while networking runs. + +### Multipeer options + +1. **Compatible (Optional Encryption)** — existing `.default` behavior. +2. **Require Encryption** — use `.encrypted`. + +Initial scope should not expose certificate identity or custom certificate callbacks. Those require credential provisioning, certificate inspection, and a more deliberate trust UX than a demo toggle. + +Display copy: + +- Compatible: `Optional session encryption · nil identity and accept-all certificates do not authenticate peers` +- Require Encryption: `Required session encryption · peers remain unauthenticated and MITM-vulnerable with nil identity and accept-all certificates` + +Required Multipeer encryption protects transport confidentiality but does not authenticate peers in this demo. Its nil local identity and accept-all remote certificate policy leave the session vulnerable to man-in-the-middle attacks. + +### Network options + +1. **Unauthenticated** — existing plaintext demo path. +2. **TLS with Shared Key** — use `.preSharedKey(Data)`. + +When TLS with Shared Key is selected, show a secure text field accepting a Base64-encoded key. + +Validation requirements: + +- Decode Base64 explicitly; reject malformed input. +- Require at least 32 decoded bytes for the demo, even though the library currently only requires a non-empty key. +- Do not silently fall back to unauthenticated transport after validation failure. +- Both peers must use identical key bytes. +- Disable editing while networking runs. +- Clear the key on **Reset Demo**, which drops the demo's references without claiming secure memory erasure. +- Preserve the selected mode and in-memory key across ordinary **Stop** so a session can restart. +- Never persist, log, export, include in status/accessibility text, copy into screenshots, or place the key in Bonjour metadata. +- Harden the field against autocorrection, spell checking, smart substitutions, and password autofill. + +Display copy: + +- Unauthenticated: `Plain TCP · non-sensitive local testing only` +- TLS-PSK: `TLS shared-key group authentication · not individual peer identity` + +### Key generation + +Provide a **Generate Test Key** button that creates 32 random bytes with `SecRandomCopyBytes`, stores them only in memory, and shows the Base64 value in the secure field. + +For two-instance simulator testing, support a launch argument such as: + +```text +PCNetworkPSKBase64 +``` + +`PCNetworkPSKBase64` is compiled and read only under `#if DEBUG` and passes through the same strict validation as manual input. It is for local validation only: process arguments can be inspected by other tooling and are not appropriate production secret storage. Manual identical input or this debug argument is the transfer mechanism; the demo does not add key copying. + +`SecRandomCopyBytes` failure must produce no key and an actionable inline error. Keep generation injectable/testable where practical and cover both successful 32-byte output shape and injected failure. + +### Interaction with Connection Behavior + +Security and connection behavior are independent: + +- Automatic + Multipeer encryption +- Require Invitation + Multipeer encryption +- Automatic + Network TLS-PSK +- Require Invitation + Network TLS-PSK + +`PeerInvitationPolicy` should remain separate from the demo's connection behavior selector. Manual connection mode already provides explicit app-owned invitations for both backends. + +## Implementation tasks + +### PR A — Demo security controls + +Suggested branch/base: + +```text +feature/network-migration-demo-security + -> feature/network-migration-demo-network-mode +``` + +Files: + +- `PeerConnectivityDemo/ViewController.swift` +- `PeerConnectivityDemo/README.md` +- `NetworkBackendGuide.md` +- `README.md` +- `Artifacts/peer-browser-network-demo.png` +- `docs/images/better-demo-app-home.png` + +Tasks: + +1. Add backend-specific demo security enums/state. +2. Add security segmented control or menu and Network PSK field/generator. +3. Map Multipeer selection to `.default` or `.encrypted`. +4. Map Network selection to `.unauthenticated` or `.preSharedKey(decodedKey)`. +5. Validate before manager construction and present an actionable inline error. +6. Keep controls disabled while networking runs. +7. Keep the selected security/key across ordinary Stop; clear it on Reset by dropping references without claiming secure erasure. +8. Keep `invitationPolicy: .acceptAll`; verify Automatic and Require Invitation remain independent from security selection. +9. Update status, troubleshooting, accessibility labels, logs, and docs without exposing key bytes. +10. Refresh both Multipeer and Network screenshots with the key field empty or redacted; real keys must not appear. +11. Do not implement any interoperability bridge code. + +### Tests + +Existing library tests already verify core security behavior. Add only focused gaps: + +- Add an internal pure Base64/length validation helper under `Sources` without changing public API. Cover empty, malformed, strict whitespace rejection, 31-byte rejection, 32-byte acceptance, and fail-closed configuration mapping. +- Invalid TLS input must never map or fall back to `.unauthenticated` and must be blocked before the existing Network transport non-empty-key precondition. +- Cover generated-key success shape and injected random-generation failure where practical. +- Keep existing Network matching-PSK connection/message tests. +- Keep existing mismatched-PSK rejection tests. +- Keep existing Multipeer security-configuration mapping tests. + +Manual simulator verification: + +1. Network + same 32-byte PSK: connect and exchange typed messages. +2. Network + mismatched PSKs: no connected peer; show/log a useful failure without key material. +3. Network + malformed/short key: Start is blocked locally. +4. Multipeer + Require Encryption: connect and exchange typed messages. +5. Each security mode combined with Automatic and Require Invitation. +6. Reset clears the Network key. + +Required commands: + +```bash +swift test +xcodebuild test -project PeerConnectivity.xcodeproj \ + -scheme PeerConnectivity \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.3.1' \ + -configuration Debug +xcodebuild -project PeerConnectivityDemo.xcodeproj \ + -scheme PeerConnectivityDemo \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.3.1' \ + -configuration Debug build CODE_SIGNING_ALLOWED=NO +``` + +Security review is required before push. + +## Backend interoperability assessment (report only) + +This section documents the current boundary. It does not authorize implementation of bridge code in this work. + +### Direct interoperability + +Direct MultipeerConnectivity-to-Network.framework interoperability is not possible with the current backends. + +| Layer | MultipeerConnectivity | Network.framework | Incompatibility | +|---|---|---|---| +| Discovery | `MCNearbyServiceBrowser` / advertiser | Bonjour `NWBrowser` / `NWListener` | Separate discovery planes | +| Transport | Private `MCSession` transport | TCP | Different wire transport | +| Framing | Apple-managed/opaque | Custom kind + length framing | No shared decoder | +| Handshake | MC invitation/session setup | `PeerNetworkHandshake` frame | Different state machines | +| Invitations | MC invitation callback | Direct Network connection | No common invitation exchange | +| Identity | Archived `MCPeerID` | UUID-backed `PeerIdentity` | No shared identity representation | +| Security | MC encryption/certificates | Plain TCP or TLS-PSK | No common negotiation or credentials | + +A Multipeer peer cannot simply connect to `_local._tcp`, and a Network peer cannot join an `MCSession`. + +### Possible approaches + +#### 1. Require both apps to select the same backend — recommended + +This is the migration model already implemented. It is simple, testable, and avoids pretending the protocols interoperate. + +#### 2. Dual-stack app with app-level bridging + +One process could run one manager for each backend and relay application messages between them. This is a gateway, not direct interoperability. + +Required design work: + +- Run two simultaneous managers without peer/event collisions. +- Define bridge routing, loop prevention, deduplication, and delivery semantics. +- Map unrelated peer identities safely. +- Define whether bridged peers are visible and how trust is represented. +- Prevent a weak/unauthenticated side from silently downgrading a secure side. +- Decide whether invitations and connection state are bridged or only messages. +- Add multi-process/device end-to-end tests. + +Security risk: a bridge terminates both security domains and becomes a trusted message relay. End-to-end identity and confidentiality do not carry across it automatically. + +#### 3. Common public wire protocol + +Replacing both transports with a shared protocol would require reimplementing the Multipeer side rather than using `MCSession` as-is. Apple's Multipeer wire protocol is opaque, so this is effectively a new transport, not an adapter. + +### Recommendation + +Do not combine interoperability work with demo security controls. + +- Implement demo controls for the security capabilities that already exist. +- Continue requiring peers to select the same backend. +- If mixed-fleet communication is a real product requirement, create a separate architecture decision record for a dual-stack gateway and define its threat model before implementation. + +## Readiness and sequencing + +1. **Now:** demo security controls using existing APIs. +2. **Before calling Network stable:** individual peer authentication and production provisioning, as required by `NetworkMigrationReadinessAudit.md`. TLS-PSK in this demo authenticates group membership only, never individual identity. +3. **Only if product-required:** dual-stack gateway design and prototype. +4. **Do not claim backend interoperability** unless a separately tested bridge is shipped and its security boundary is documented. diff --git a/NetworkBackendGuide.md b/NetworkBackendGuide.md index 0869d68..f57d41e 100644 --- a/NetworkBackendGuide.md +++ b/NetworkBackendGuide.md @@ -180,24 +180,25 @@ Apps that need to exchange bounded in-memory payloads should use `sendData` or ` ## Demo app -The expanded demo shows the active backend and lets you choose **Multipeer** or **Network**, plus **Automatic** or **Require Invitation**, before starting. The default backend remains MultipeerConnectivity. Each backend remembers its connection-behavior choice for the demo session, with defaults that preserve prior behavior: Multipeer uses `.automatic`, while Network uses `.custom` for **Require Invitation**. Automatic mode uses each backend's automatic behavior. Require Invitation uses `PeerBrowserModel` for app-owned discovery and exposes manual **Invite _peer name_** actions for either backend. **Send Typed Message** exercises `PeerMessage` delivery while preserving message history, structured logging, troubleshooting, and the test checklist. +The expanded demo shows the active backend and lets you choose **Multipeer** or **Network**, **Automatic** or **Require Invitation**, and backend-specific security before starting. Choices are remembered per backend and disabled while networking runs. Multipeer maps **Compatible** to `.default` and **Require Encryption** to `.encrypted`; neither authenticates peers because the demo uses a nil identity and accepts all certificates, so it remains MITM-vulnerable. Network maps **Unauthenticated** to plain TCP and **TLS Shared Key** to `.preSharedKey` only after strict Base64 validation of at least 32 decoded bytes. Invalid TLS input blocks Start and never downgrades. The same path can be selected with launch arguments: - `PCNetworkBackend` — select `.networkFramework` instead of the default MultipeerConnectivity backend. - `PCAutoStart` — start the manager on launch. - `PCDisplayName ` — set a deterministic display name such as `Alice` or `Bob`. +- `PCNetworkPSKBase64 ` — in `#if DEBUG` builds only, select Network TLS and pass the value through the same strict validation as manual input. Example arguments for two simulator or device instances: ```text -PCNetworkBackend PCAutoStart PCDisplayName Alice -PCNetworkBackend PCAutoStart PCDisplayName Bob +PCNetworkBackend PCNetworkPSKBase64 PCAutoStart PCDisplayName Alice +PCNetworkBackend PCNetworkPSKBase64 PCAutoStart PCDisplayName Bob ``` -For **Require Invitation**, tap the enabled invite action for a discovered peer, wait for its status to become **Connected**, then enter and send a typed message or ping. The backend and connection-behavior selectors remain disabled until networking is stopped. See [`PeerConnectivityDemo/README.md`](PeerConnectivityDemo/README.md) for the complete walkthrough. +For **Require Invitation**, tap the enabled invite action for a discovered peer, wait for its status to become **Connected**, then enter and send a typed message or ping. Security remains independent from invitation flow; the manager continues to use `invitationPolicy: .acceptAll`. See [`PeerConnectivityDemo/README.md`](PeerConnectivityDemo/README.md) for the complete walkthrough. -This demo Network path is intentionally unauthenticated, visibly labels that limitation, and is only for non-sensitive local migration validation. Production apps should use app-managed `.preSharedKey` material and an appropriate trust model. +The secure field and generated test key are in-memory only, are excluded from logs/status/accessibility/export, survive ordinary Stop for restart, and are dropped on Reset without claiming secure erasure. The process argument can be inspected by local tooling and is debug-only test convenience, never production provisioning. TLS-PSK authenticates group membership, not individual identity. Both peers must select the same backend because MultipeerConnectivity and Network.framework are not wire-compatible; the demo contains no bridge code. ## Network path and device caveats diff --git a/PeerConnectivity.xcodeproj/project.pbxproj b/PeerConnectivity.xcodeproj/project.pbxproj index 4cdc9bb..f19b7f9 100644 --- a/PeerConnectivity.xcodeproj/project.pbxproj +++ b/PeerConnectivity.xcodeproj/project.pbxproj @@ -54,6 +54,9 @@ 30BROWSERMODELTEST2608082 /* PeerBrowserModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30BROWSERMODELTEST2608081 /* PeerBrowserModelTests.swift */; }; 30SECURITY26060300000001 /* PeerSecurityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30SECURITY26060300000002 /* PeerSecurityConfiguration.swift */; }; 30SECURITY26060300000003 /* PeerSecurityConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30SECURITY26060300000004 /* PeerSecurityConfigurationTests.swift */; }; + 30PSKBASE6426081000000002 /* PeerNetworkPSKBase64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30PSKBASE6426081000000001 /* PeerNetworkPSKBase64.swift */; }; + 30PSKBASE6426081000000004 /* PeerNetworkPSKBase64Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30PSKBASE6426081000000003 /* PeerNetworkPSKBase64Tests.swift */; }; + 30PSKCONFIG26081000000002 /* PeerNetworkPSKConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30PSKCONFIG26081000000001 /* PeerNetworkPSKConfiguration.swift */; }; B20000022F30600000000001 /* ObservableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000022F30600000000002 /* ObservableTests.swift */; }; B20000022F30600000000003 /* PeerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000022F30600000000004 /* PeerTests.swift */; }; /* End PBXBuildFile section */ @@ -121,6 +124,9 @@ 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 = ""; }; + 30PSKBASE6426081000000001 /* PeerNetworkPSKBase64.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PeerNetworkPSKBase64.swift; path = Sources/PeerNetworkPSKBase64.swift; sourceTree = ""; }; + 30PSKBASE6426081000000003 /* PeerNetworkPSKBase64Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerNetworkPSKBase64Tests.swift; sourceTree = ""; }; + 30PSKCONFIG26081000000001 /* PeerNetworkPSKConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PeerNetworkPSKConfiguration.swift; path = Sources/PeerNetworkPSKConfiguration.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -174,6 +180,8 @@ 30TRANS2606230000000001 /* PeerConnectionTransports.swift */, 3080C7E91D80A1D600AF9EA3 /* PeerConnectivity.h */, 30SECURITY26060300000002 /* PeerSecurityConfiguration.swift */, + 30PSKBASE6426081000000001 /* PeerNetworkPSKBase64.swift */, + 30PSKCONFIG26081000000001 /* PeerNetworkPSKConfiguration.swift */, 3080C7EA1D80A1D600AF9EA3 /* PeerSession.swift */, 3080C7EB1D80A1D600AF9EA3 /* PeerSessionEventProducer.swift */, ); @@ -217,6 +225,7 @@ 30BROWSERMODELTEST2608081 /* PeerBrowserModelTests.swift */, 30PEERMSG2602020000000001 /* PeerMessageTests.swift */, 30SECURITY26060300000004 /* PeerSecurityConfigurationTests.swift */, + 30PSKBASE6426081000000003 /* PeerNetworkPSKBase64Tests.swift */, B20000022F30600000000002 /* ObservableTests.swift */, B20000022F30600000000004 /* PeerTests.swift */, 3086CD331D09FB9900E269A3 /* Info.plist */, @@ -359,6 +368,8 @@ 3080C7FC1D80A1D700AF9EA3 /* PeerSessionEventProducer.swift in Sources */, 3080C7F51D80A1D700AF9EA3 /* PeerBrowserAssisstant.swift in Sources */, 30SECURITY26060300000001 /* PeerSecurityConfiguration.swift in Sources */, + 30PSKBASE6426081000000002 /* PeerNetworkPSKBase64.swift in Sources */, + 30PSKCONFIG26081000000002 /* PeerNetworkPSKConfiguration.swift in Sources */, 3080C7FB1D80A1D700AF9EA3 /* PeerSession.swift in Sources */, 3080C7EE1D80A1D700AF9EA3 /* Observable.swift in Sources */, 30ASYNCOBS260721002 /* AsyncObservable.swift in Sources */, @@ -389,6 +400,7 @@ 30BROWSERMODELTEST2608082 /* PeerBrowserModelTests.swift in Sources */, 30PEERMSG2602020000000002 /* PeerMessageTests.swift in Sources */, 30SECURITY26060300000003 /* PeerSecurityConfigurationTests.swift in Sources */, + 30PSKBASE6426081000000004 /* PeerNetworkPSKBase64Tests.swift in Sources */, B20000022F30600000000001 /* ObservableTests.swift in Sources */, B20000022F30600000000003 /* PeerTests.swift in Sources */, ); diff --git a/PeerConnectivityDemo.xcodeproj/project.pbxproj b/PeerConnectivityDemo.xcodeproj/project.pbxproj index 097cadd..e84c8b7 100644 --- a/PeerConnectivityDemo.xcodeproj/project.pbxproj +++ b/PeerConnectivityDemo.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ 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 */; }; + 30DEMOPSK260810000000002 /* PeerNetworkPSKBase64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DEMOPSK260810000000001 /* PeerNetworkPSKBase64.swift */; }; + 30DEMOPSK260810000000004 /* PeerNetworkPSKConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DEMOPSK260810000000003 /* PeerNetworkPSKConfiguration.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -35,6 +37,8 @@ 309837441D8A8D600002338A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 309837461D8A8D600002338A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 309837621D8A8E6A0002338A /* PeerConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PeerConnectivity.framework; path = "../../Library/Developer/Xcode/DerivedData/PeerConnectivity-gpwpzbfgjijtrdbmsejdioisajvt/Build/Products/Debug-iphoneos/PeerConnectivity.framework"; sourceTree = ""; }; + 30DEMOPSK260810000000001 /* PeerNetworkPSKBase64.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PeerNetworkPSKBase64.swift; path = Sources/PeerNetworkPSKBase64.swift; sourceTree = SOURCE_ROOT; }; + 30DEMOPSK260810000000003 /* PeerNetworkPSKConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PeerNetworkPSKConfiguration.swift; path = Sources/PeerNetworkPSKConfiguration.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -77,6 +81,8 @@ B20000012F30600000000006 /* DemoMessage.swift */, B20000012F30600000000008 /* MessageHistoryEntry.swift */, B20000012F3060000000000A /* DemoChecklistItem.swift */, + 30DEMOPSK260810000000001 /* PeerNetworkPSKBase64.swift */, + 30DEMOPSK260810000000003 /* PeerNetworkPSKConfiguration.swift */, 3098373E1D8A8D600002338A /* Main.storyboard */, 309837411D8A8D600002338A /* Assets.xcassets */, 309837431D8A8D600002338A /* LaunchScreen.storyboard */, @@ -175,6 +181,8 @@ B20000012F30600000000005 /* DemoMessage.swift in Sources */, B20000012F30600000000007 /* MessageHistoryEntry.swift in Sources */, B20000012F30600000000009 /* DemoChecklistItem.swift in Sources */, + 30DEMOPSK260810000000002 /* PeerNetworkPSKBase64.swift in Sources */, + 30DEMOPSK260810000000004 /* PeerNetworkPSKConfiguration.swift in Sources */, 3098373B1D8A8D600002338A /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/PeerConnectivityDemo/README.md b/PeerConnectivityDemo/README.md index 19211bd..b8c1acd 100644 --- a/PeerConnectivityDemo/README.md +++ b/PeerConnectivityDemo/README.md @@ -1,39 +1,62 @@ # PeerConnectivity Demo -The expanded demo exposes both transport backends without changing the library default. Choose **Multipeer** or **Network**, then choose **Automatic** or **Require Invitation** before starting. Both backends support both connection behaviors using the same public APIs. The demo remembers each backend's choice while it runs; defaults preserve the existing walkthroughs: Multipeer uses **Automatic**, while Network uses **Require Invitation**. All combinations retain the connection-mode controls, peer status, typed message history, raw data and resource exercises, structured logs, troubleshooting guidance, and physical-test checklist. +The demo exposes both transport backends, connection behavior, and backend-specific security without changing library defaults. Choose **Multipeer** or **Network**, **Automatic** or **Require Invitation**, and a security mode before starting. Each backend remembers its choices while the app runs. Security and connection behavior are independent, and all related controls are disabled while networking runs. -## Connection behavior +## Security + +### MultipeerConnectivity + +- **Compatible** maps to `PeerSecurityConfiguration.default` (optional encryption). +- **Require Encryption** maps to `.encrypted` (required session encryption). + +Neither mode authenticates individual peers in this demo. The manager has no local certificate identity and accepts all remote certificates, so even required encryption remains vulnerable to man-in-the-middle attacks. + +### Network.framework + +- **Unauthenticated** maps to `.unauthenticated` plain TCP and is only for non-sensitive local testing. +- **TLS Shared Key** maps to `.preSharedKey` only after strict Base64 validation succeeds. -- **Automatic** creates the selected backend's manager with `.automatic`, so discovered peers connect automatically. -- **Require Invitation** creates it with `.custom`. `PeerBrowserModel` supplies app-owned discovery state and the demo renders an explicit **Invite _peer name_** action for both backends. +TLS input rejects whitespace and malformed Base64 and must decode to at least 32 bytes. Invalid input displays an inline error and blocks **Start**; it never silently downgrades to unauthenticated transport. TLS-PSK authenticates membership in the shared-key group, not an individual person, device, account, or installation. Both peers must use identical key bytes. -### Require-invitation flow +**Generate Test Key** creates 32 bytes with `SecRandomCopyBytes`. The secure field disables autocorrection, spell checking, smart substitutions, and password autofill. Key material stays in memory, is excluded from logs/status/accessibility/export, and is never placed in Bonjour metadata. **Stop** preserves the current selection and key for restart. **Reset Demo** drops the demo's key references; this is not a claim of secure memory erasure. -The demo app target declares `NSLocalNetworkUsageDescription` and `_local._tcp` in `NSBonjourServices`, matching its `serviceType: "local"`. Adopting apps must add equivalent values for every service type to their own app target. +Enter an identical key manually on both instances or use the debug-only launch argument below. The demo intentionally provides no copy/export action for keys. -1. Run the demo on two instances. Simulators are useful for a quick UI/API check; use two physical devices for Local Network permission and release-topology validation. -2. Select the same backend and **Require Invitation** on both instances, then tap **Start**. When using Network on physical devices, allow Local Network access when prompted. -3. Under **Peers**, tap the enabled **Invite _peer name_** action supplied from `PeerBrowserModel` discovery state. -4. Wait for the peer to report **Connected**. -5. Enter a message or ping, optionally choose a target, and tap **Send Typed Message**. The receiving instance records the `PeerMessage` in message history and the structured event log. +## Connection behavior + +- **Automatic** creates the selected backend's manager with `.automatic`. +- **Require Invitation** creates it with `.custom`; `PeerBrowserModel` supplies app-owned discovery state and explicit **Invite _peer name_** actions. -The Network backend deliberately uses `.unauthenticated` transport and labels this in the UI; use it only for non-sensitive local migration testing. Production apps should provide app-managed `.preSharedKey` material and an appropriate trust model. Multipeer remains the default backend and retains the backward-compatible default `PeerSecurityConfiguration`. +The manager keeps `invitationPolicy: .acceptAll`; the selector remains an independent connection-flow choice for both security modes and both backends. -For physical-device testing, keep Wi-Fi enabled. The backend opts in to Apple peer-to-peer Wi-Fi, but Network.framework does not guarantee a particular interface or expose AWDL selection, and this is not a Bluetooth LE or Bluetooth-only transport. Same-Wi-Fi discovery can also be blocked by guest/client isolation, VPNs, firewalls, or managed-network policy. Follow the [production physical-device checklist](../NetworkBackendGuide.md#manual-physical-device-validation) before shipping. +The app target declares `NSLocalNetworkUsageDescription` and `_local._tcp` in `NSBonjourServices`, matching `serviceType: "local"`. Adopting apps must declare equivalent values for every service type in their own target. ## Launch arguments | Argument | Effect | |---|---| -| `PCNetworkBackend` | Select the Network.framework backend. Without it, MultipeerConnectivity remains selected. | -| `PCAutoStart` | Start advertising and browsing after launch. | +| `PCNetworkBackend` | Select Network.framework. Without it, MultipeerConnectivity remains selected. | +| `PCAutoStart` | Start advertising and browsing after launch. Invalid TLS input still blocks Start. | | `PCDisplayName ` | Use a deterministic local display name. | +| `PCNetworkPSKBase64 ` | **Debug builds only:** select Network TLS and validate the supplied Base64 key. | -Example arguments for two instances: +Example for two debug instances (replace the placeholder with the same test value on each process): ```text -PCNetworkBackend PCAutoStart PCDisplayName Alice -PCNetworkBackend PCAutoStart PCDisplayName Bob +PCNetworkBackend PCNetworkPSKBase64 PCAutoStart PCDisplayName Alice +PCNetworkBackend PCNetworkPSKBase64 PCAutoStart PCDisplayName Bob ``` -The backend and connection-behavior selectors are disabled while networking is running. Tap **Stop** before changing either setting. Network mode does not support the demo resource exercise; that action logs the backend's explicit unsupported-operation error. +Process arguments can be inspected by local tooling and are not production secret storage. This argument is only a debug test-transfer convenience. Never place a production key in arguments, source, screenshots, logs, or the app bundle. + +## Validation flow + +1. Run two instances and select the same backend, security mode, and compatible connection behavior. +2. For Network TLS, enter the same valid 32+-byte Base64 key on both instances. +3. Tap **Start**. For **Require Invitation**, tap an enabled invite action after discovery. +4. Wait for **Connected**, then exchange typed messages or raw data. +5. Confirm malformed/short/mismatched keys do not establish a session and no key material appears in logs. + +Use physical devices for Local Network permission and release-topology validation. Keep Wi-Fi enabled. Network.framework's peer-to-peer opt-in does not guarantee a particular interface and is not a Bluetooth-only transport. Network mode does not support the demo resource exercise; that action reports the existing unsupported-operation error. + +The two backends are not wire-compatible. This demo requires both peers to select the same backend and includes no bridge or relay code. See the [Network backend guide](../NetworkBackendGuide.md) for production guidance and limitations. diff --git a/PeerConnectivityDemo/ViewController.swift b/PeerConnectivityDemo/ViewController.swift index a54d277..97445d7 100644 --- a/PeerConnectivityDemo/ViewController.swift +++ b/PeerConnectivityDemo/ViewController.swift @@ -44,12 +44,25 @@ class ViewController: UIViewController { } } + fileprivate enum MultipeerSecurity : String { + case compatible = "Compatible" + case requireEncryption = "Require Encryption" + } + + fileprivate enum NetworkSecurity : String { + case unauthenticated = "Unauthenticated" + case tlsSharedKey = "TLS Shared Key" + } + fileprivate var pcm : PeerConnectionManager! fileprivate var browserModel : PeerBrowserModel! fileprivate var isNetworking = false fileprivate var mode : ConnectionMode = .advertisingAndBrowsing fileprivate var multipeerConnectionBehavior : ConnectionBehavior = .automatic fileprivate var networkConnectionBehavior : ConnectionBehavior = .requireInvitation + fileprivate var multipeerSecurity : MultipeerSecurity = .compatible + fileprivate var networkSecurity : NetworkSecurity = .unauthenticated + fileprivate var securityValidationMessage : String? fileprivate var discoveredPeers : [Peer] = [] fileprivate var connectedPeers : [Peer] = [] fileprivate var selectedTargetPeer : Peer? @@ -73,7 +86,13 @@ class ViewController: UIViewController { fileprivate let localPeerLabel = UILabel() fileprivate let backendControl = UISegmentedControl(items: ["Multipeer", "Network"]) fileprivate let connectionBehaviorControl = UISegmentedControl(items: ["Automatic", "Require Invitation"]) + fileprivate let securityControl = UISegmentedControl() fileprivate let backendDetailLabel = UILabel() + fileprivate let securityDetailLabel = UILabel() + fileprivate let networkPSKStack = UIStackView() + fileprivate let networkPSKTextField = UITextField() + fileprivate let generateTestKeyButton = UIButton(type: .system) + fileprivate let securityErrorLabel = UILabel() fileprivate let modeButton = UIButton(type: .system) fileprivate let startStopButton = UIButton(type: .system) fileprivate let refreshButton = UIButton(type: .system) @@ -102,9 +121,15 @@ class ViewController: UIViewController { backendControl.selectedSegmentIndex = ProcessInfo.processInfo.arguments.contains("PCNetworkBackend") ? 1 : 0 configureLayout() configureActions() - configureManager() + _ = configureManager() + applyDebugNetworkPSKArgument() + validateSelectedSecurity() + if securityValidationMessage == nil { _ = configureManager() } refreshUI() appendLog(kind: "app.ready", detail: "Local peer: \(pcm.peer.displayName); backend: \(backendName)") + DispatchQueue.main.async { [weak self] in + self?.scrollView.setContentOffset(.zero, animated: false) + } if ProcessInfo.processInfo.arguments.contains("PCAutoStart") { startNetworking() } @@ -121,7 +146,12 @@ class ViewController: UIViewController { extension ViewController : UITextFieldDelegate { internal func textFieldShouldReturn(_ textField: UITextField) -> Bool { - sendMessage() + if textField === networkPSKTextField { + textField.resignFirstResponder() + validateSelectedSecurity() + } else { + sendMessage() + } return true } } @@ -145,6 +175,13 @@ private extension ViewController { return isNetworkBackend ? "Network.framework" : "MultipeerConnectivity" } + var securitySummary : String { + if isNetworkBackend { + return networkSecurity == .unauthenticated ? "Network unauthenticated" : "Network TLS shared key" + } + return multipeerSecurity == .compatible ? "Multipeer compatible encryption" : "Multipeer required encryption" + } + var selectedConnectionBehavior : ConnectionBehavior { get { return isNetworkBackend ? networkConnectionBehavior : multipeerConnectionBehavior @@ -158,7 +195,32 @@ private extension ViewController { } } - func configureManager() { + var selectedSecurityIndex : Int { + if isNetworkBackend { + return networkSecurity == .unauthenticated ? 0 : 1 + } + return multipeerSecurity == .compatible ? 0 : 1 + } + + var selectedMultipeerSecurityConfiguration : PeerSecurityConfiguration { + return multipeerSecurity == .compatible ? .default : .encrypted + } + + func selectedNetworkSecurity() -> PeerConnectionNetworkSecurity? { + guard isNetworkBackend, networkSecurity == .tlsSharedKey else { return .unauthenticated } + switch PeerNetworkPSKConfiguration.networkSecurity(networkPSKTextField.text ?? "") { + case .success(let security): + return security + case .failure(let error): + securityValidationMessage = validationMessage(for: error) + return nil + } + } + + @discardableResult + func configureManager() -> Bool { + guard let selectedNetworkSecurity = selectedNetworkSecurity() else { return false } + securityValidationMessage = nil browserModel?.stopObserving() pcm?.stop() pcm?.removeAllListeners() @@ -168,10 +230,10 @@ private extension ViewController { serviceType: "local", connectionType: selectedConnectionBehavior.connectionType, displayName: displayName, - securityConfiguration: .default, + securityConfiguration: selectedMultipeerSecurityConfiguration, invitationPolicy: .acceptAll, backend: selectedBackend, - networkSecurity: .unauthenticated + networkSecurity: selectedNetworkSecurity ) pcm.listenOn({ [weak self] event in self?.handlePeerConnectionEvent(event) @@ -185,6 +247,29 @@ private extension ViewController { self?.refreshUI() } browserModel.startObserving() + return true + } + + func validationMessage(for error: PeerNetworkPSKBase64Error) -> String { + switch error { + case .empty: + return "Enter or generate a Base64 shared key before starting." + case .containsWhitespace: + return "Remove spaces and line breaks; the Base64 key must be entered exactly." + case .malformed: + return "Enter a valid Base64 shared key." + case .tooShort(let actualByteCount, let minimumByteCount): + return "Decoded key is \(actualByteCount) bytes; use at least \(minimumByteCount) bytes." + } + } + + func applyDebugNetworkPSKArgument() { + #if DEBUG + guard let value = ViewController.argumentValue(for: "PCNetworkPSKBase64") else { return } + backendControl.selectedSegmentIndex = 1 + networkSecurity = .tlsSharedKey + networkPSKTextField.text = value + #endif } func configureLayout() { @@ -210,9 +295,15 @@ private extension ViewController { contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), ]) - [localPeerLabel, backendDetailLabel, discoveredPeersLabel, connectedPeersLabel, troubleshootingLabel].forEach { $0.numberOfLines = 0 } - backendDetailLabel.font = UIFont.preferredFont(forTextStyle: .footnote) - backendDetailLabel.textColor = .secondaryLabel + [localPeerLabel, backendDetailLabel, securityDetailLabel, securityErrorLabel, discoveredPeersLabel, connectedPeersLabel, troubleshootingLabel].forEach { $0.numberOfLines = 0 } + [backendDetailLabel, securityDetailLabel].forEach { + $0.font = UIFont.preferredFont(forTextStyle: .footnote) + $0.textColor = .secondaryLabel + } + securityErrorLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + securityErrorLabel.textColor = .systemRed + networkPSKStack.axis = .vertical + networkPSKStack.spacing = 8 inviteButtonsStack.axis = .vertical inviteButtonsStack.spacing = 8 configureMenuButton(modeButton) @@ -221,6 +312,7 @@ private extension ViewController { configurePrimaryButton(startStopButton, title: "Start") configureSecondaryButton(refreshButton, title: "Refresh") configureSecondaryButton(resetButton, title: "Reset Demo") + configureSecondaryButton(generateTestKeyButton, title: "Generate Test Key") configurePrimaryButton(sendButton, title: "Send Typed Message") configureSecondaryButton(rawDataButton, title: "Send Raw Data Ping") configureSecondaryButton(resourceButton, title: "Send Demo Resource") @@ -228,6 +320,21 @@ private extension ViewController { configureSecondaryButton(shareButton, title: "Share Logs") configureSecondaryButton(clearLogButton, title: "Clear Logs") + networkPSKTextField.borderStyle = .roundedRect + networkPSKTextField.placeholder = "Base64 shared key (32+ decoded bytes)" + networkPSKTextField.isSecureTextEntry = true + networkPSKTextField.autocorrectionType = .no + networkPSKTextField.spellCheckingType = .no + networkPSKTextField.smartDashesType = .no + networkPSKTextField.smartQuotesType = .no + networkPSKTextField.smartInsertDeleteType = .no + networkPSKTextField.textContentType = nil + networkPSKTextField.autocapitalizationType = .none + networkPSKTextField.returnKeyType = .done + networkPSKTextField.delegate = self + networkPSKStack.addArrangedSubview(networkPSKTextField) + networkPSKStack.addArrangedSubview(generateTestKeyButton) + messageTextField.borderStyle = .roundedRect messageTextField.placeholder = "Typed message or ping to selected target" messageTextField.returnKeyType = .send @@ -244,6 +351,11 @@ private extension ViewController { contentStack.addArrangedSubview(backendControl) contentStack.addArrangedSubview(connectionBehaviorControl) contentStack.addArrangedSubview(backendDetailLabel) + contentStack.addArrangedSubview(sectionTitle("Security")) + contentStack.addArrangedSubview(securityControl) + contentStack.addArrangedSubview(securityDetailLabel) + contentStack.addArrangedSubview(networkPSKStack) + contentStack.addArrangedSubview(securityErrorLabel) contentStack.addArrangedSubview(statusCardRow()) contentStack.addArrangedSubview(modeButton) contentStack.addArrangedSubview(buttonRow([startStopButton, refreshButton])) @@ -279,10 +391,18 @@ private extension ViewController { func configureActions() { backendControl.addTarget(self, action: #selector(changedBackend(_:)), for: .valueChanged) connectionBehaviorControl.addTarget(self, action: #selector(changedConnectionBehavior(_:)), for: .valueChanged) + securityControl.addTarget(self, action: #selector(changedSecurity(_:)), for: .valueChanged) + networkPSKTextField.addTarget(self, action: #selector(changedNetworkPSK(_:)), for: .editingChanged) + generateTestKeyButton.addTarget(self, action: #selector(tappedGenerateTestKey(_:)), for: .touchUpInside) backendControl.accessibilityLabel = "Networking backend" backendControl.accessibilityHint = "Selects the transport backend while networking is stopped" connectionBehaviorControl.accessibilityLabel = "Connection behavior" connectionBehaviorControl.accessibilityHint = "Choose automatic connections or explicit peer invitations while networking is stopped" + securityControl.accessibilityLabel = "Transport security" + securityControl.accessibilityHint = "Selects security for the current backend while networking is stopped" + networkPSKTextField.accessibilityLabel = "Base64 shared key" + networkPSKTextField.accessibilityHint = "Secure field requiring at least 32 decoded bytes" + generateTestKeyButton.accessibilityHint = "Generates a new in-memory 32-byte test key" startStopButton.accessibilityHint = "Starts or stops peer advertising and browsing" startStopButton.addTarget(self, action: #selector(tappedStartStop(_:)), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(tappedRefresh(_:)), for: .touchUpInside) @@ -367,13 +487,19 @@ private extension ViewController { localPeerLabel.text = "Local: \(pcm.peer.displayName)" connectionBehaviorControl.selectedSegmentIndex = selectedConnectionBehavior == .automatic ? 0 : 1 - if selectedConnectionBehavior == .automatic { - backendDetailLabel.text = "\(backendName) · automatic discovery and connections\(isNetworkBackend ? " · unauthenticated demo transport" : " · default optional encryption and certificate policy")" - } else { - backendDetailLabel.text = "\(backendName) · app-owned discovery and explicit invitations via PeerBrowserModel\(isNetworkBackend ? " · unauthenticated demo transport" : "")" - } + backendDetailLabel.text = selectedConnectionBehavior == .automatic + ? "\(backendName) · automatic discovery and connections" + : "\(backendName) · app-owned discovery and explicit invitations via PeerBrowserModel" + updateSecurityControl() backendControl.isEnabled = !isNetworking connectionBehaviorControl.isEnabled = !isNetworking + securityControl.isEnabled = !isNetworking + networkPSKTextField.isEnabled = !isNetworking + generateTestKeyButton.isEnabled = !isNetworking + networkPSKStack.isHidden = !isNetworkBackend || networkSecurity != .tlsSharedKey + securityErrorLabel.text = securityValidationMessage + securityErrorLabel.isHidden = securityValidationMessage == nil + networkPSKTextField.accessibilityValue = (networkPSKTextField.text ?? "").isEmpty ? "Empty" : "Entered" statusBadgeLabel.text = isNetworking ? "Running" : "Stopped" advertisingBadgeLabel.text = isNetworking && mode.isAdvertising ? "Advertising" : "Not Advertising" browsingBadgeLabel.text = isNetworking && mode.isBrowsing ? "Browsing" : "Not Browsing" @@ -382,6 +508,7 @@ private extension ViewController { rebuildInviteButtons() connectedPeersLabel.text = peerList(title: "Connected", peers: connectedPeers) startStopButton.configuration?.title = isNetworking ? "Stop" : "Start" + startStopButton.isEnabled = isNetworking || securityValidationMessage == nil refreshButton.isEnabled = isNetworking sendButton.isEnabled = !connectedPeers.isEmpty rawDataButton.isEnabled = !connectedPeers.isEmpty @@ -395,6 +522,39 @@ private extension ViewController { rebuildChecklist() } + func updateSecurityControl() { + securityControl.removeAllSegments() + if isNetworkBackend { + securityControl.insertSegment(withTitle: NetworkSecurity.unauthenticated.rawValue, at: 0, animated: false) + securityControl.insertSegment(withTitle: NetworkSecurity.tlsSharedKey.rawValue, at: 1, animated: false) + securityDetailLabel.text = networkSecurity == .unauthenticated + ? "Plain TCP · non-sensitive local testing only" + : "TLS shared-key group membership authentication · not individual peer identity" + } else { + securityControl.insertSegment(withTitle: MultipeerSecurity.compatible.rawValue, at: 0, animated: false) + securityControl.insertSegment(withTitle: MultipeerSecurity.requireEncryption.rawValue, at: 1, animated: false) + securityDetailLabel.text = multipeerSecurity == .compatible + ? "Optional session encryption · nil identity and accept-all certificates do not authenticate peers" + : "Required session encryption · peers remain unauthenticated and MITM-vulnerable with nil identity and accept-all certificates" + } + securityControl.selectedSegmentIndex = selectedSecurityIndex + } + + func validateSelectedSecurity() { + guard isNetworkBackend, networkSecurity == .tlsSharedKey else { + securityValidationMessage = nil + refreshUI() + return + } + switch PeerNetworkPSKBase64.decode(networkPSKTextField.text ?? "") { + case .success: + securityValidationMessage = nil + case .failure(let error): + securityValidationMessage = validationMessage(for: error) + } + refreshUI() + } + func peerList(title: String, peers: [Peer]) -> String { guard !peers.isEmpty else { return "\(title): none" } return peers.map { "• \($0.displayName) [\(statusText($0.status))]" }.reduce("\(title):") { $0 + "\n" + $1 } @@ -425,9 +585,13 @@ private extension ViewController { hints.append("Automatic connects to discovered peers without an app-owned Invite action.") } if isNetworkBackend { - hints.append("Network demo traffic is unauthenticated; do not send sensitive data.") + if networkSecurity == .unauthenticated { + hints.append("Network traffic is plaintext and unauthenticated; do not send sensitive data.") + } else { + hints.append("TLS-PSK authenticates shared-key group membership, not an individual peer. Both peers need identical key bytes.") + } } else { - hints.append("Multipeer remains the default backend.") + hints.append("Multipeer encryption alone does not authenticate peers; the demo uses a nil identity and accepts all certificates, leaving it vulnerable to MITM attacks.") } if isNetworking && discoveredPeers.isEmpty && connectedPeers.isEmpty { hints.append("No peers yet. Confirm both devices use the same service type, are on the same Wi‑Fi or have Bluetooth enabled, and accepted Local Network permission.") @@ -446,6 +610,7 @@ private extension ViewController { } func startNetworking() { + guard configureManager() else { return } switch mode { case .advertisingAndBrowsing: pcm.startAdvertisingAndBrowsing() @@ -457,7 +622,7 @@ private extension ViewController { isNetworking = true if mode.isAdvertising { checkedItems.insert(.deviceAAdvertising) } if mode.isBrowsing { checkedItems.insert(.deviceBBrowsing) } - appendLog(kind: "session.start.requested", detail: "\(backendName), \(selectedConnectionBehavior.rawValue), \(mode.rawValue)") + appendLog(kind: "session.start.requested", detail: "\(backendName), \(selectedConnectionBehavior.rawValue), \(securitySummary), \(mode.rawValue)") refreshUI() } @@ -817,6 +982,11 @@ private extension ViewController { messageHistory = [] checkedItems = [] messageTextField.text = "" + networkPSKTextField.text = nil + multipeerSecurity = .compatible + networkSecurity = .unauthenticated + securityValidationMessage = nil + _ = configureManager() logStore.clear() appendLog(kind: "demo.reset", detail: "Demo reset; local peer: \(pcm.peer.displayName)") appendLog(kind: "app.ready", detail: "Local peer: \(pcm.peer.displayName)") @@ -827,7 +997,8 @@ private extension ViewController { discoveredPeers = [] connectedPeers = [] selectedTargetPeer = nil - configureManager() + validateSelectedSecurity() + if securityValidationMessage == nil { _ = configureManager() } appendLog(kind: "session.backend.changed", detail: "\(backendName), \(selectedConnectionBehavior.rawValue)") } @@ -837,10 +1008,40 @@ private extension ViewController { discoveredPeers = [] connectedPeers = [] selectedTargetPeer = nil - configureManager() + if securityValidationMessage == nil { _ = configureManager() } appendLog(kind: "session.connection.behavior.changed", detail: "\(backendName), \(selectedConnectionBehavior.rawValue)") } + @objc func changedSecurity(_ sender: UISegmentedControl) { + guard !isNetworking else { return } + if isNetworkBackend { + networkSecurity = sender.selectedSegmentIndex == 0 ? .unauthenticated : .tlsSharedKey + } else { + multipeerSecurity = sender.selectedSegmentIndex == 0 ? .compatible : .requireEncryption + } + validateSelectedSecurity() + if securityValidationMessage == nil { _ = configureManager() } + appendLog(kind: "session.security.changed", detail: securitySummary) + } + + @objc func changedNetworkPSK(_ sender: UITextField) { + guard !isNetworking else { return } + validateSelectedSecurity() + } + + @objc func tappedGenerateTestKey(_ sender: UIButton) { + guard !isNetworking else { return } + switch PeerNetworkTestKeyGenerator.generateBase64() { + case .success(let value): + networkPSKTextField.text = value + securityValidationMessage = nil + case .failure(let error): + networkPSKTextField.text = nil + securityValidationMessage = "Could not generate a test key (Security status \(error.status)). Try again." + } + refreshUI() + } + @objc func tappedStartStop(_ sender: UIButton) { isNetworking ? stopNetworking() : startNetworking() } diff --git a/PeerConnectivityTests/PeerNetworkPSKBase64Tests.swift b/PeerConnectivityTests/PeerNetworkPSKBase64Tests.swift new file mode 100644 index 0000000..a61dc05 --- /dev/null +++ b/PeerConnectivityTests/PeerNetworkPSKBase64Tests.swift @@ -0,0 +1,69 @@ +// +// PeerNetworkPSKBase64Tests.swift +// PeerConnectivityTests +// +// Created by Reid Chatham on 8/10/26. +// Copyright © 2026 Reid Chatham. All rights reserved. +// + +import XCTest +import Security +@testable import PeerConnectivity + +class PeerNetworkPSKBase64Tests : XCTestCase { + + internal func testEmptyValueIsRejected() { + XCTAssertEqual(PeerNetworkPSKBase64.decode(""), .failure(.empty)) + } + + internal func testMalformedValueIsRejected() { + XCTAssertEqual(PeerNetworkPSKBase64.decode("not-base64!"), .failure(.malformed)) + } + + internal func testWhitespaceIsStrictlyRejected() { + let value = Data(repeating: 1, count: 32).base64EncodedString() + "\n" + XCTAssertEqual(PeerNetworkPSKBase64.decode(value), .failure(.containsWhitespace)) + } + + internal func testThirtyOneByteValueIsRejected() { + let value = Data(repeating: 2, count: 31).base64EncodedString() + XCTAssertEqual( + PeerNetworkPSKBase64.decode(value), + .failure(.tooShort(actualByteCount: 31, minimumByteCount: 32))) + } + + internal func testThirtyTwoByteValueIsAccepted() { + let data = Data(repeating: 3, count: 32) + XCTAssertEqual(PeerNetworkPSKBase64.decode(data.base64EncodedString()), .success(data)) + } + + internal func testInvalidConfigurationMappingFailsClosed() { + switch PeerNetworkPSKConfiguration.networkSecurity("") { + case .success: + XCTFail("Invalid TLS selection must not map to any network security configuration") + case .failure(let error): + XCTAssertEqual(error, .empty) + } + } + + internal func testValidConfigurationMapsToPreSharedKey() { + let data = Data(repeating: 4, count: 32) + XCTAssertEqual( + PeerNetworkPSKConfiguration.networkSecurity(data.base64EncodedString()), + .success(.preSharedKey(data))) + } + + internal func testGeneratedKeyHasValidThirtyTwoByteShape() { + let result = PeerNetworkTestKeyGenerator.generateBase64 { buffer in + buffer.initializeMemory(as: UInt8.self, repeating: 5) + return errSecSuccess + } + guard case .success(let value) = result else { return XCTFail("Expected generated key") } + XCTAssertEqual(PeerNetworkPSKBase64.decode(value), .success(Data(repeating: 5, count: 32))) + } + + internal func testGeneratorFailureProducesNoKey() { + let result = PeerNetworkTestKeyGenerator.generateBase64 { _ in -50 } + XCTAssertEqual(result, .failure(PeerNetworkTestKeyGenerationError(status: -50))) + } +} diff --git a/README.md b/README.md index b5039b3..ba37be5 100644 --- a/README.md +++ b/README.md @@ -159,14 +159,16 @@ A reusable Network browser view is intentionally deferred until app-owned integr ## Demo App Run `PeerConnectivityDemo.xcodeproj` on two simulators or devices. Before starting, select the -backend (**Multipeer** or **Network**) and connection behavior (**Automatic** or **Require -Invitation**). Automatic uses the selected backend's `.automatic` behavior. Require Invitation -uses `.custom`, `PeerBrowserModel`, and visible app-owned invite actions for either backend. The -demo remembers each backend's selection while running and preserves the existing defaults: -Multipeer automatic, Network require invitation. All combinations retain advertising and browsing -controls, connection state, typed message history, raw data and resource exercises, structured -event logging, troubleshooting guidance, and the physical-test checklist. Discovery metadata -events are logged as `peer.found.metadata` when nearby peers advertise Bonjour TXT record values. +backend (**Multipeer** or **Network**), connection behavior (**Automatic** or **Require +Invitation**), and backend-specific security. Multipeer offers compatible optional encryption or +required encryption; because the demo has a nil identity and accepts all certificates, neither +setting authenticates peers and both remain MITM-vulnerable. Network offers unauthenticated plain +TCP or TLS with a strictly validated 32+-byte Base64 shared key. TLS-PSK authenticates group +membership, not individual identity, and invalid input blocks Start without downgrading. The demo +remembers each backend's selection while running; security and connection behavior remain +independent and disabled while networking runs. See [`PeerConnectivityDemo/README.md`](PeerConnectivityDemo/README.md) +for key handling, debug-only launch arguments, and the full validation flow. Both peers must select +the same backend; no interoperability bridge is included. ## API Compatibility Notes diff --git a/Sources/PeerNetworkPSKBase64.swift b/Sources/PeerNetworkPSKBase64.swift new file mode 100644 index 0000000..d2c9815 --- /dev/null +++ b/Sources/PeerNetworkPSKBase64.swift @@ -0,0 +1,56 @@ +// +// PeerNetworkPSKBase64.swift +// PeerConnectivity +// +// Created by Reid Chatham on 8/10/26. +// Copyright © 2026 Reid Chatham. All rights reserved. +// + +import Foundation +import Security + +internal enum PeerNetworkPSKBase64Error : Error, Equatable { + case empty + case containsWhitespace + case malformed + case tooShort(actualByteCount: Int, minimumByteCount: Int) +} + +internal enum PeerNetworkPSKBase64 { + internal static let minimumByteCount = 32 + + /// Strictly validates and decodes demo TLS-PSK input without accepting whitespace. + internal static func decode(_ value: String) -> Result { + guard !value.isEmpty else { return .failure(.empty) } + guard value.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { + return .failure(.containsWhitespace) + } + guard let data = Data(base64Encoded: value), !data.isEmpty else { return .failure(.malformed) } + guard data.count >= minimumByteCount else { + return .failure(.tooShort(actualByteCount: data.count, minimumByteCount: minimumByteCount)) + } + return .success(data) + } +} + +internal struct PeerNetworkTestKeyGenerationError : Error, Equatable { + internal let status : Int32 +} + +internal enum PeerNetworkTestKeyGenerator { + internal typealias FillRandomBytes = (UnsafeMutableRawBufferPointer) -> Int32 + + /// Generates in-memory Base64 test key material. The caller controls its lifetime. + internal static func generateBase64( + byteCount: Int = PeerNetworkPSKBase64.minimumByteCount, + fillRandomBytes: FillRandomBytes = { buffer in + return SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!) + }) -> Result { + var data = Data(count: byteCount) + let status = data.withUnsafeMutableBytes(fillRandomBytes) + guard status == errSecSuccess else { + return .failure(PeerNetworkTestKeyGenerationError(status: status)) + } + return .success(data.base64EncodedString()) + } +} diff --git a/Sources/PeerNetworkPSKConfiguration.swift b/Sources/PeerNetworkPSKConfiguration.swift new file mode 100644 index 0000000..e343bb7 --- /dev/null +++ b/Sources/PeerNetworkPSKConfiguration.swift @@ -0,0 +1,19 @@ +// +// PeerNetworkPSKConfiguration.swift +// PeerConnectivity +// +// Created by Reid Chatham on 8/10/26. +// Copyright © 2026 Reid Chatham. All rights reserved. +// + +import Foundation +#if canImport(PeerConnectivity) +import PeerConnectivity +#endif + +internal enum PeerNetworkPSKConfiguration { + /// Maps validated input to TLS-PSK and fails closed for every invalid value. + internal static func networkSecurity(_ value: String) -> Result { + return PeerNetworkPSKBase64.decode(value).map(PeerConnectionNetworkSecurity.preSharedKey) + } +} diff --git a/docs/images/better-demo-app-home.png b/docs/images/better-demo-app-home.png index 1f08c88..fc5a82b 100644 Binary files a/docs/images/better-demo-app-home.png and b/docs/images/better-demo-app-home.png differ