From 0280697651882e48f90ac77412e8d820eec12613 Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:58:30 +0900 Subject: [PATCH 1/9] feat(security): add optional SDK connection authentication --- .gitignore | 5 + ExampleApp/ConnectionView.swift | 2 +- ExampleApp/ExampleApp.swift | 10 +- .../xcshareddata/swiftpm/Package.resolved | 47 +- Necto/ContentView.swift | 4 + Necto/NectoAppModel.swift | 18 +- Necto/NectoSidebar.swift | 63 +++ Necto/en.lproj/Localizable.strings | 7 + Necto/ko.lproj/Localizable.strings | 7 + NectoMac/Package.resolved | 47 +- .../NectoConnectionCenter.swift | 141 +++++- .../NectoMacService/NectoControlBridge.swift | 19 +- .../NectoKeychainCredentialStore.swift | 257 +++++++++++ .../NectoMacService/NectoUSBChannel.swift | 11 +- .../NectoUnauthorizedApp.swift | 33 ++ NectoMac/Sources/necto-cli/PluginHelp.swift | 8 +- .../NectoCLITests/NectoCLIBlackBoxTests.swift | 36 ++ .../NectoControlBridgeTests.swift | 33 ++ .../Security/SecurityCompatibilityTests.swift | 155 +++++++ .../SecurityConnectionSecurityTests.swift | 261 +++++++++++ .../Security/SecurityCredentialTests.swift | 325 +++++++++++++ .../Security/SecurityFailureTests.swift | 199 ++++++++ .../Security/SecurityIntegrationTests.swift | 434 ++++++++++++++++++ .../Security/SecurityTestHandshake.swift | 86 ++++ .../Security/SecurityTestSupport.swift | 186 ++++++++ Package.resolved | 51 ++ Package.swift | 14 +- README-ko.md | 2 +- README.md | 2 +- SECURITY.md | 5 + Sources/NectoModel/NectoBridgeError.swift | 1 + Sources/NectoModel/NectoSecurityOffer.swift | 31 ++ Sources/NectoSDK/NectoSDK.swift | 10 +- Sources/NectoSDK/NectoSDKRuntime.swift | 81 +++- Sources/NectoTransport/NectoByteStream.swift | 44 +- .../NectoTransport/NectoMessageSession.swift | 20 + Sources/NectoTransport/NectoTLSIdentity.swift | 139 ++++++ Sources/NectoTransport/NectoTLSStream.swift | 228 +++++++++ Tests/Fixtures/SDKConsumer/Package.resolved | 51 ++ .../NectoKeychainSigningQueueTests.swift | 28 ++ .../NectoSocketConcurrencyTests.swift | 43 ++ WebPackages/Bridge/components.css | 1 + WebPackages/Bridge/src/types.ts | 1 + docs/.vitepress/config.mts | 10 + docs/architecture.md | 7 + docs/connection-security.md | 236 ++++++++++ docs/design/gallery.js | 15 + docs/install.md | 2 +- docs/ko/connection-security.md | 201 ++++++++ docs/ko/install.md | 2 +- docs/ko/setup.md | 4 + docs/plugin-manifest.md | 1 + docs/setup.md | 4 + docs/verification.md | 3 +- script/licenses.mjs | 19 +- script/licenses/BoringSSL-817ab07.txt | 252 ++++++++++ script/licenses/README.md | 8 +- script/tests/package.test.mjs | 3 +- script/tests/release.test.mjs | 23 +- 59 files changed, 3880 insertions(+), 56 deletions(-) create mode 100644 NectoMac/Sources/NectoMacService/NectoKeychainCredentialStore.swift create mode 100644 NectoMac/Sources/NectoMacService/NectoUnauthorizedApp.swift create mode 100644 NectoMac/Tests/NectoMacServiceTests/Security/SecurityCompatibilityTests.swift create mode 100644 NectoMac/Tests/NectoMacServiceTests/Security/SecurityConnectionSecurityTests.swift create mode 100644 NectoMac/Tests/NectoMacServiceTests/Security/SecurityCredentialTests.swift create mode 100644 NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift create mode 100644 NectoMac/Tests/NectoMacServiceTests/Security/SecurityIntegrationTests.swift create mode 100644 NectoMac/Tests/NectoMacServiceTests/Security/SecurityTestHandshake.swift create mode 100644 NectoMac/Tests/NectoMacServiceTests/Security/SecurityTestSupport.swift create mode 100644 Package.resolved create mode 100644 Sources/NectoModel/NectoSecurityOffer.swift create mode 100644 Sources/NectoTransport/NectoTLSIdentity.swift create mode 100644 Sources/NectoTransport/NectoTLSStream.swift create mode 100644 Tests/Fixtures/SDKConsumer/Package.resolved create mode 100644 Tests/NectoTransportTests/NectoKeychainSigningQueueTests.swift create mode 100644 docs/connection-security.md create mode 100644 docs/ko/connection-security.md create mode 100644 script/licenses/BoringSSL-817ab07.txt diff --git a/.gitignore b/.gitignore index 3528a5a..9fb85eb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ node_modules/ dist/ *.log +# Generated caches from local scripts and Android builds. +__pycache__/ +.gradle/ +.kotlin/ + # xcodebuild output Build/ *.xcworkspace/xcuserdata/ diff --git a/ExampleApp/ConnectionView.swift b/ExampleApp/ConnectionView.swift index b2fc9a8..fc27256 100644 --- a/ExampleApp/ConnectionView.swift +++ b/ExampleApp/ConnectionView.swift @@ -29,7 +29,7 @@ struct ConnectionView: View { Section { Button("Stop listening") { NectoSDK.stop() } - Button("Start listening") { NectoSDK.start() } + Button("Start listening") { ExampleApp.startNecto() } } footer: { Text("Necto reaches this app over USB on a device, and over loopback in a simulator.") } diff --git a/ExampleApp/ExampleApp.swift b/ExampleApp/ExampleApp.swift index d30a3bc..591eba2 100644 --- a/ExampleApp/ExampleApp.swift +++ b/ExampleApp/ExampleApp.swift @@ -37,7 +37,7 @@ struct ExampleApp: App { Self.seedPreferences() Self.seedFiles() - NectoSDK.start() + Self.startNecto() ExampleTelemetry.start() } @@ -47,6 +47,14 @@ struct ExampleApp: App { } } + static func startNecto() { + if let publicKey = ProcessInfo.processInfo.environment["NECTO_PUBLIC_KEY"] { + NectoSDK.start(publicKey: publicKey) + } else { + NectoSDK.start() + } + } + /// A few files of each shape a real app keeps, written once. private static func seedFiles() { guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { diff --git a/Necto.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Necto.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b6aa5ef..4d4640e 100644 --- a/Necto.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Necto.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "74abd2f06c852fcb43488b33e37f3fd48cd3673cd0a9a61da2b7a771a55e85b1", + "originHash" : "c8e6a2071d4f41515dbcce32903ef56c5194408671876a39ec5394c43c344cc2", "pins" : [ { "identity" : "swift-argument-parser", @@ -9,6 +9,51 @@ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", "version" : "1.8.2" } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } } ], "version" : 3 diff --git a/Necto/ContentView.swift b/Necto/ContentView.swift index 04b1cab..6ec96ff 100644 --- a/Necto/ContentView.swift +++ b/Necto/ContentView.swift @@ -159,6 +159,10 @@ struct ContentView: View { } .plainRow() } + ForEach(app.unauthorized) { device in + UnauthorizedDeviceRow(device: device, scale: scale) + .plainRow() + } } if !window.activeDevicePlugins.isEmpty || !window.disabledDevicePlugins.isEmpty { diff --git a/Necto/NectoAppModel.swift b/Necto/NectoAppModel.swift index e3e3dba..7c17e4e 100644 --- a/Necto/NectoAppModel.swift +++ b/Necto/NectoAppModel.swift @@ -67,6 +67,7 @@ final class NectoAppModel { var queuedAssets: Set { installer.queuedAssets } private(set) var connectedApps: [NectoConnectedApp] = [] + private(set) var unauthorizedApps: [NectoUnauthorizedApp] = [] let backgroundPanels = NectoBackgroundPanels() let cliInstaller = NectoCLIInstaller() @@ -151,6 +152,7 @@ final class NectoAppModel { private(set) var unreadIssueCount = 0 private let appBridge: NectoDeviceBridgeClient private var connectionTask: Task? + private var authorizationTask: Task? init() { shellAccess = NectoShellAccessController(snapshot: NectoPluginLibrary.shellPolicy) { @@ -405,6 +407,7 @@ final class NectoAppModel { let name: String /// The same app on each device it was found on, newest listing first. let devices: [NectoConnectedApp] + let unauthorized: [NectoUnauthorizedApp] var id: String { bundleID } } @@ -424,9 +427,12 @@ final class NectoAppModel { grouped[key, default: []].append(app) } - return order.compactMap { key in - guard let devices = grouped[key], let first = devices.first else { return nil } - return ConnectedApp(bundleID: key, name: first.appName, devices: devices) + let denied = Dictionary(grouping: unauthorizedApps, by: \.appBundleID) + for app in unauthorizedApps where !order.contains(app.appBundleID) { order.append(app.appBundleID) } + return order.map { key in + let devices = grouped[key] ?? [] + let unauthorized = denied[key] ?? [] + return ConnectedApp(bundleID: key, name: devices.first?.appName ?? unauthorized.first?.appName ?? key, devices: devices, unauthorized: unauthorized) } } @@ -439,6 +445,9 @@ final class NectoAppModel { connectedApps: { [weak self] in await MainActor.run { self?.connectedApps ?? [] } }, + unauthorizedApps: { [weak self] in + await MainActor.run { self?.unauthorizedApps ?? [] } + }, install: { [weak self] source in guard let self else { throw NectoBridgeError(code: .operationUnavailable, message: "Necto is closing.") @@ -478,6 +487,9 @@ final class NectoAppModel { private func startWatchingConnections() { guard connectionTask == nil else { return } + authorizationTask = Task { [connections] in + for await apps in await connections.authorizationUpdates() { unauthorizedApps = apps } + } // The transport reports facts; which of them is worth telling someone about is // decided here, where what the person was trying to do is known. diff --git a/Necto/NectoSidebar.swift b/Necto/NectoSidebar.swift index 417ca8c..bcda778 100644 --- a/Necto/NectoSidebar.swift +++ b/Necto/NectoSidebar.swift @@ -294,6 +294,69 @@ struct AppHeader: View { } } +struct UnauthorizedDeviceRow: View { + let device: NectoUnauthorizedApp + let scale: CGFloat + + @State private var isShowingGuidance = false + @State private var isHovering = false + + var body: some View { + Button { isShowingGuidance = true } label: { + HStack(spacing: 8) { + Image(systemName: device.connection == .simulator ? "laptopcomputer.and.iphone" : "iphone") + .foregroundStyle(NectoTheme.textTertiary) + .frame(width: 16) + Text(device.deviceName) + .foregroundStyle(NectoTheme.textSecondary) + .lineLimit(1) + .layoutPriority(1) + Spacer(minLength: 6) + Text("Unauthorized") + .font(.necto(.caption, scale: scale)) + .foregroundStyle(NectoTheme.textTertiary) + .lineLimit(1) + Circle().fill(NectoTheme.danger).frame(width: 6, height: 6) + } + .font(.necto(.label, scale: scale)) + .padding(.horizontal, 8) + .frame(height: NectoTheme.rowHeight * scale) + .background(isHovering ? NectoTheme.hover : .clear, in: RoundedRectangle(cornerRadius: NectoTheme.radiusControl)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .onHover { isHovering = $0 } + .help(tooltip) + .accessibilityLabel(device.deviceName + ", " + NectoL10n.text("Unauthorized")) + .accessibilityIdentifier("unauthorized-" + device.id) + .alert("Authentication required", isPresented: $isShowingGuidance) { + Button("Done", role: .cancel) {} + } message: { + Text([ + device.appName + "\n" + device.appBundleID, + failureReason, + NectoL10n.text("Debugging this app is blocked in both Necto and the CLI."), + NectoL10n.text("Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available."), + ].joined(separator: "\n\n")) + } + } + + private var failureReason: String { + let reason: String + switch device.reason { + case .missingKey: reason = "Install this app's connection key on your Mac to start debugging." + case .rejectedKey: reason = "Authentication failed. Check that the Mac key matches the app's public key." + case .credentialUnavailable: reason = "The connection key could not be read. Check your Mac Keychain access." + } + return NectoL10n.text(reason) + } + + private var tooltip: String { + [NectoL10n.text("Unauthorized"), device.appBundleID, failureReason].joined(separator: "\n") + } +} + /// One device the app is running on. Selecting it is what points a plugin somewhere. struct DeviceRow: View { let device: NectoConnectedApp diff --git a/Necto/en.lproj/Localizable.strings b/Necto/en.lproj/Localizable.strings index dcb4d0f..8258e89 100644 --- a/Necto/en.lproj/Localizable.strings +++ b/Necto/en.lproj/Localizable.strings @@ -232,3 +232,10 @@ "Search plugins" = "Search plugins"; "No plugins found" = "No plugins found"; +"Unauthorized" = "Unauthorized"; +"Install this app's connection key on your Mac to start debugging." = "Install this app's connection key on your Mac to start debugging."; +"Authentication failed. Check that the Mac key matches the app's public key." = "Authentication failed. Check that the Mac key matches the app's public key."; +"The connection key could not be read. Check your Mac Keychain access." = "The connection key could not be read. Check your Mac Keychain access."; +"Authentication required" = "Authentication required"; +"Debugging this app is blocked in both Necto and the CLI." = "Debugging this app is blocked in both Necto and the CLI."; +"Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available." = "Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available."; diff --git a/Necto/ko.lproj/Localizable.strings b/Necto/ko.lproj/Localizable.strings index 9e7d727..c6fa889 100644 --- a/Necto/ko.lproj/Localizable.strings +++ b/Necto/ko.lproj/Localizable.strings @@ -232,3 +232,10 @@ "Search plugins" = "플러그인 검색"; "No plugins found" = "검색 결과가 없어요"; +"Unauthorized" = "인증되지 않음"; +"Install this app's connection key on your Mac to start debugging." = "디버깅을 시작하려면 Mac에 이 앱의 연결 키를 설치하세요."; +"Authentication failed. Check that the Mac key matches the app's public key." = "인증에 실패했어요. Mac의 키가 앱의 공개키와 일치하는지 확인하세요."; +"The connection key could not be read. Check your Mac Keychain access." = "연결 키를 읽지 못했어요. Mac 키체인 접근 권한을 확인하세요."; +"Authentication required" = "연결 인증이 필요해요"; +"Debugging this app is blocked in both Necto and the CLI." = "Necto와 CLI에서 이 앱을 디버깅할 수 없어요."; +"Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available." = "설정 방법은 앱 담당자에게 문의하세요. 이 Mac에 올바른 연결 키를 설정하면 자동으로 다시 연결돼요."; diff --git a/NectoMac/Package.resolved b/NectoMac/Package.resolved index 63bed11..1b607e8 100644 --- a/NectoMac/Package.resolved +++ b/NectoMac/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "721f56e83ac4f699ed899d2fe240a3911d3d34e2b83e829cd363653058a3512b", + "originHash" : "0a5d67acf7a40c2ad3f626fbb4360f5c3e04424e677ac7645872094b2a828ab6", "pins" : [ { "identity" : "swift-argument-parser", @@ -9,6 +9,51 @@ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", "version" : "1.8.2" } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } } ], "version" : 3 diff --git a/NectoMac/Sources/NectoMacService/NectoConnectionCenter.swift b/NectoMac/Sources/NectoMacService/NectoConnectionCenter.swift index 55b3aa6..8f7642e 100644 --- a/NectoMac/Sources/NectoMacService/NectoConnectionCenter.swift +++ b/NectoMac/Sources/NectoMacService/NectoConnectionCenter.swift @@ -35,6 +35,7 @@ public actor NectoConnectionCenter { private let port: UInt16 private let probeInterval: Duration private let deviceEvents: @Sendable () -> AsyncThrowingStream + private let identity: @Sendable (String) async throws -> NectoTLSIdentity? private var onEnvelope: (@Sendable (NectoEnvelope, NectoTarget) -> Void)? private var connections: [String: Connection] = [:] @@ -42,6 +43,15 @@ public actor NectoConnectionCenter { private var probes: [Endpoint: Task] = [:] private var sessionTasks: [ObjectIdentifier: Task] = [:] private var observers: [UUID: AsyncStream<[NectoConnectedApp]>.Continuation] = [:] + private struct Denied { + let app: NectoUnauthorizedApp + let usbDeviceID: Int? + let port: UInt16? + } + private var denied: [String: Denied] = [:] + private var deniedObservers: [UUID: AsyncStream<[NectoUnauthorizedApp]>.Continuation] = [:] + private var publishedDenied: [NectoUnauthorizedApp] = [] + private var generation: UInt64 = 0 public init( port: UInt16 = NectoTransportDefaults.devicePort, @@ -53,17 +63,36 @@ public actor NectoConnectionCenter { init( port: UInt16, probeInterval: Duration, - deviceEvents: @escaping @Sendable () -> AsyncThrowingStream + deviceEvents: @escaping @Sendable () -> AsyncThrowingStream, + identity: @escaping @Sendable (String) async throws -> NectoTLSIdentity? = { bundleID in + try await Task.detached { try NectoKeychainCredentialStore().identity(bundleID: bundleID) }.value + } ) { self.port = port self.probeInterval = probeInterval self.deviceEvents = deviceEvents + self.identity = identity } public var connectedApps: [NectoConnectedApp] { connections.values.map(\.app).sorted { $0.id < $1.id } } + public var unauthorizedApps: [NectoUnauthorizedApp] { + denied.values.map(\.app).filter { connections[$0.id] == nil }.sorted { $0.id < $1.id } + } + + public func authorizationUpdates() -> AsyncStream<[NectoUnauthorizedApp]> { + AsyncStream { continuation in + let id = UUID() + deniedObservers[id] = continuation + continuation.yield(unauthorizedApps) + continuation.onTermination = { [weak self] _ in + Task { await self?.removeDeniedObserver(id) } + } + } + } + /// Emits the full list whenever it changes, starting with the current one. public func updates() -> AsyncStream<[NectoConnectedApp]> { AsyncStream { continuation in @@ -86,6 +115,9 @@ public actor NectoConnectionCenter { public func send(_ envelope: NectoEnvelope, to target: NectoTarget) async throws { let id = NectoConnectedApp.id(for: target) guard let session = connections[id]?.session else { + if denied[id] != nil { + throw NectoBridgeError(code: .unauthorized, message: "Authentication is required for '\(target.appBundleID)'.") + } throw NectoBridgeError( code: .targetDisconnected, message: "'\(target.appBundleID)' is not connected" @@ -130,6 +162,7 @@ public actor NectoConnectionCenter { } public func stop() { + generation &+= 1 deviceWatcher?.cancel() deviceWatcher = nil for probe in probes.values { probe.cancel() } @@ -138,6 +171,7 @@ public actor NectoConnectionCenter { for task in sessionTasks.values { task.cancel() } sessionTasks.removeAll() connections.removeAll() + denied.removeAll() usbDeviceIDs.removeAll() localPorts.removeAll() devicePorts.removeAll() @@ -156,6 +190,7 @@ public actor NectoConnectionCenter { case let .detached(deviceID): cancelUSBProbes { $0 == deviceID } removeConnections { $0.usbDeviceID == deviceID } + removeDenied { $0.usbDeviceID == deviceID } case .attached: break } @@ -167,6 +202,7 @@ public actor NectoConnectionCenter { notes?(.usbUnavailable(String(describing: error))) cancelUSBProbes { _ in true } removeConnections { $0.usbDeviceID != nil } + removeDenied { $0.usbDeviceID != nil } } } @@ -197,6 +233,7 @@ public actor NectoConnectionCenter { // device usually stays plugged in while its app is started, stopped and // started again, and waiting for another attach would mean asking someone // to unplug the cable. + removeDenied { $0.usbDeviceID == device.deviceID && $0.port == candidate } } } @@ -222,6 +259,7 @@ public actor NectoConnectionCenter { ) } catch { // Nothing listening there. Try again after the interval. + removeDenied { $0.usbDeviceID == nil && $0.port == candidate } } case let .usb(device, candidate) where !connectedPorts(usbDeviceID: device.deviceID).contains(candidate): await connectToDevice(device, port: candidate) @@ -257,13 +295,40 @@ public actor NectoConnectionCenter { localPort: UInt16? = nil, devicePort: UInt16? = nil ) async throws -> Task? { - let (hello, ack) = try await session.handshake { - let hello = try await session.receive(NectoHandshakeHello.self) - let ack = NectoHandshakeAck.evaluate(hello) - try await session.send(ack) - return (hello, ack) + let identity = self.identity + let generation = self.generation + let negotiated: (NectoMessageSession, NectoHandshakeHello, NectoHandshakeAck) + do { + let (active, hello) = try await Self.authenticate(session: session, identity: identity) + do { + negotiated = try await active.handshake { + let ack = NectoHandshakeAck.evaluate(hello) + try await active.send(ack) + return (active, hello, ack) + } + } catch { active.close(); throw error } + } catch let failure as AuthenticationFailure { + session.close() + guard !Task.isCancelled, generation == self.generation else { throw CancellationError() } + let offer = failure.offer + let resolvedID = connection == .simulator ? (offer.simulatorID ?? deviceID) : deviceID + let app = NectoUnauthorizedApp( + target: NectoTarget(deviceID: resolvedID, appBundleID: offer.appBundleID), + appName: offer.appName, deviceName: deviceName ?? offer.deviceName, + osVersion: offer.osVersion, connection: connection, reason: failure.reason + ) + if let port = localPort ?? devicePort { + denied = denied.filter { $0.value.usbDeviceID != usbDeviceID || $0.value.port != port } + } + denied[app.id] = Denied(app: app, usbDeviceID: usbDeviceID, port: localPort ?? devicePort) + publishDenied() + return nil + } catch { + session.close() + throw error } - guard !Task.isCancelled else { session.close(); throw CancellationError() } + let (session, hello, ack) = negotiated + guard !Task.isCancelled, generation == self.generation else { session.close(); throw CancellationError() } guard ack.accepted else { // The one failure a person can act on: their app and this Necto do not speak @@ -292,6 +357,10 @@ public actor NectoConnectionCenter { connections[app.id]?.session.close() connections[app.id] = Connection(app: app, session: session) + denied.removeValue(forKey: app.id) + if let port = localPort ?? devicePort { + denied = denied.filter { $0.value.usbDeviceID != usbDeviceID || $0.value.port != port } + } notes?(.connected(appBundleID: app.target.appBundleID, over: connection == .usb ? "USB" : "loopback")) usbDeviceIDs[app.id] = usbDeviceID localPorts[app.id] = localPort @@ -351,7 +420,65 @@ public actor NectoConnectionCenter { observers.removeValue(forKey: id) } + private struct AuthenticationFailure: Error { + let offer: NectoSecurityOffer + let reason: NectoUnauthorizedApp.Reason + } + + private static func authenticate( + session: NectoMessageSession, + identity: @escaping @Sendable (String) async throws -> NectoTLSIdentity? + ) async throws -> (NectoMessageSession, NectoHandshakeHello) { + let data = try await session.handshake { try await session.receive() } + let object = try JSONDecoder().decode(NectoJSONValue.self, from: data) + guard object["type"] != nil else { + return (session, try JSONDecoder().decode(NectoHandshakeHello.self, from: data)) + } + let offer = try JSONDecoder().decode(NectoSecurityOffer.self, from: data) + guard offer.isSupported else { throw NectoSecurityError.unsupportedNegotiation } + let credential: NectoTLSIdentity? + do { + credential = try await session.handshake(timeout: NectoTLSStream.authenticationTimeout) { + try await identity(offer.appBundleID) + } + } + catch { + throw AuthenticationFailure(offer: offer, reason: .credentialUnavailable) + } + guard let credential else { throw AuthenticationFailure(offer: offer, reason: .missingKey) } + do { + let secured = try await session.upgradingTLS(.host(credential)) + do { + let hello = try await secured.handshake { try await secured.receive(NectoHandshakeHello.self) } + guard hello.appBundleID == offer.appBundleID, hello.simulatorID == offer.simulatorID else { + throw NectoSecurityError.unsupportedNegotiation + } + return (secured, hello) + } catch { secured.close(); throw error } + } catch { + if Task.isCancelled { throw CancellationError() } + throw AuthenticationFailure(offer: offer, reason: .rejectedKey) + } + } + + private func removeDeniedObserver(_ id: UUID) { deniedObservers.removeValue(forKey: id) } + + private func removeDenied(where matches: (Denied) -> Bool) { + let ids = denied.filter { matches($0.value) }.map(\.key) + guard !ids.isEmpty else { return } + ids.forEach { denied.removeValue(forKey: $0) } + publishDenied() + } + + private func publishDenied() { + let snapshot = unauthorizedApps + guard snapshot != publishedDenied else { return } + publishedDenied = snapshot + deniedObservers.values.forEach { $0.yield(snapshot) } + } + private func publish() { + publishDenied() let snapshot = connectedApps for continuation in observers.values { continuation.yield(snapshot) diff --git a/NectoMac/Sources/NectoMacService/NectoControlBridge.swift b/NectoMac/Sources/NectoMacService/NectoControlBridge.swift index 9b5dd83..7fdf498 100644 --- a/NectoMac/Sources/NectoMacService/NectoControlBridge.swift +++ b/NectoMac/Sources/NectoMacService/NectoControlBridge.swift @@ -10,12 +10,14 @@ import Foundation public struct NectoControlBridge: NectoControlHandling { private let registry: NectoPluginRegistry private let connectedApps: @Sendable () async -> [NectoConnectedApp] + private let unauthorizedApps: @Sendable () async -> [NectoUnauthorizedApp] private let install: @Sendable (NectoPluginInstallSource) async throws -> NectoJSONValue private let delete: @Sendable (String) async throws -> NectoJSONValue public init( registry: NectoPluginRegistry, connectedApps: @escaping @Sendable () async -> [NectoConnectedApp], + unauthorizedApps: @escaping @Sendable () async -> [NectoUnauthorizedApp] = { [] }, install: @escaping @Sendable (NectoPluginInstallSource) async throws -> NectoJSONValue = { _ in throw NectoBridgeError(code: .operationUnavailable, message: "Plugin installation is unavailable.") }, @@ -25,6 +27,7 @@ public struct NectoControlBridge: NectoControlHandling { ) { self.registry = registry self.connectedApps = connectedApps + self.unauthorizedApps = unauthorizedApps self.install = install self.delete = delete } @@ -37,14 +40,23 @@ public struct NectoControlBridge: NectoControlHandling { public func targets() async -> NectoJSONValue { let apps = await connectedApps() - return ["targets": .array(apps.sorted { $0.id < $1.id }.map { app in + let connected: [NectoJSONValue] = apps.sorted { $0.id < $1.id }.map { app in [ "appBundleID": .string(app.appBundleID), "appName": .string(app.appName), "deviceID": .string(app.target.deviceID), "deviceName": .string(app.deviceName), + "status": "connected", ] - })] + } + let denied: [NectoJSONValue] = await unauthorizedApps().filter { blocked in + !apps.contains { $0.target == blocked.target } + }.map { app in + ["appBundleID": .string(app.appBundleID), "appName": .string(app.appName), + "deviceID": .string(app.target.deviceID), "deviceName": .string(app.deviceName), + "status": "unauthorized", "reason": .string(app.reason.rawValue)] + } + return ["targets": .array(connected + denied)] } public func plugins( @@ -148,6 +160,9 @@ public struct NectoControlBridge: NectoControlHandling { throw NectoBridgeError(code: .invalidInput, message: "Pass both --device and --app, or --desktop. Run necto device list. Connected targets: \(candidates.isEmpty ? "none" : candidates)") } let matches = apps.filter { $0.appBundleID == app && $0.target.deviceID == device } + if matches.isEmpty, await unauthorizedApps().contains(where: { $0.appBundleID == app && $0.target.deviceID == device }) { + throw NectoBridgeError(code: .unauthorized, message: "Authentication is required for '\(app)' on '\(device)'. Set up this app's key on this Mac.") + } guard matches.count == 1, let match = matches.first else { throw NectoBridgeError(code: .targetDisconnected, message: "No unique connected app '\(app)' on device '\(device)'. Run necto device list.") } diff --git a/NectoMac/Sources/NectoMacService/NectoKeychainCredentialStore.swift b/NectoMac/Sources/NectoMacService/NectoKeychainCredentialStore.swift new file mode 100644 index 0000000..3935465 --- /dev/null +++ b/NectoMac/Sources/NectoMacService/NectoKeychainCredentialStore.swift @@ -0,0 +1,257 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +#if os(macOS) +import Foundation +import Security +import NectoTransport + +/// Credentials provisioned by an organization for a particular app bundle ID. +public final class NectoKeychainCredentialStore: @unchecked Sendable { + // A failed install must not remove a key another install has just reused. + private static let installLock = NSLock() + private let keychain: SecKeychain + private let service = "im.necto.connection.certificate" + + public init(keychain: SecKeychain) { self.keychain = keychain } + + public convenience init() throws { + var keychain: SecKeychain? + let status = SecKeychainCopyDefault(&keychain) + guard status == errSecSuccess, let keychain else { throw NectoSecurityError.keychain(status) } + self.init(keychain: keychain) + } + + /// Include the Necto executable in trustedApplications when provisioning from a separate CLI. + public func install( + bundleID: String, privateKeyPEM: Data, certificateDER: Data, + trustedApplications: [URL] = [] + ) throws { + Self.installLock.lock() + defer { Self.installLock.unlock() } + guard !bundleID.isEmpty else { throw NectoSecurityError.invalidIdentity } + guard try registration(bundleID: bundleID) == nil else { throw NectoSecurityError.keychain(errSecDuplicateItem) } + let candidate = try importKey(privateKeyPEM) + _ = try NectoTLSIdentity(certificateDER: certificateDER, privateKey: candidate) + let applicationLabel = try applicationLabel(of: candidate) + var applications: [SecTrustedApplication] = [] + for path in [Optional.none] + trustedApplications.map({ Optional($0.path) }) { + var application: SecTrustedApplication? + let status = path.map { path in + path.withCString { SecTrustedApplicationCreateFromPath($0, &application) } + } ?? SecTrustedApplicationCreateFromPath(nil, &application) + try check(status) + guard let application else { throw NectoSecurityError.invalidIdentity } + applications.append(application) + } + var access: SecAccess? + try check(SecAccessCreate(label(bundleID) as CFString, applications as CFArray, &access)) + guard let access else { throw NectoSecurityError.invalidIdentity } + let key: SecKey + let imported: Bool + do { + key = try importKey(privateKeyPEM, into: keychain, access: access) + imported = true + } catch NectoSecurityError.keychain(errSecDuplicateItem) { + guard let existing = try privateKey(applicationLabel: applicationLabel), + let attributes = SecKeyCopyAttributes(existing) as? [CFString: Any], + attributes[kSecAttrIsExtractable] as? Bool == false, + attributes[kSecAttrIsSensitive] as? Bool == true + else { throw NectoSecurityError.invalidIdentity } + _ = try NectoTLSIdentity(certificateDER: certificateDER, privateKey: existing) + key = existing + imported = false + } + let keyQuery: [CFString: Any] = [ + kSecClass: kSecClassKey, kSecValueRef: key, kSecMatchSearchList: [keychain], + ] + var insertedCertificate: Data? + do { + if imported { + try check(SecItemUpdate(keyQuery as CFDictionary, [kSecAttrLabel: label(bundleID)] as CFDictionary)) + } else { + try verifySigningAccess(key: key, applications: applications) + } + let certificate = try storeCertificate(certificateDER, bundleID: bundleID, access: access) + if certificate.inserted { insertedCertificate = certificate.reference } + // Registration metadata is public. Reading a password payload would require a second approval. + try check(SecItemAdd([ + kSecClass: kSecClassGenericPassword, kSecAttrService: service, + kSecAttrAccount: bundleID, kSecAttrGeneric: certificate.reference, + kSecValueData: Data(), kSecUseKeychain: keychain, + kSecAttrAccess: access, + ] as CFDictionary, nil)) + } catch { + if let insertedCertificate { + SecItemDelete([ + kSecClass: kSecClassCertificate, kSecMatchItemList: [insertedCertificate], + kSecMatchSearchList: [keychain], + ] as CFDictionary) + } + if imported { SecItemDelete(keyQuery as CFDictionary) } + throw error + } + } + + public func identity(bundleID: String) throws -> NectoTLSIdentity? { + guard let certificate = try certificate(bundleID: bundleID) else { return nil } + guard let parsed = SecCertificateCreateWithData(nil, certificate as CFData), + let publicKey = SecCertificateCopyKey(parsed), + let key = try privateKey(applicationLabel: applicationLabel(of: publicKey)) + else { throw NectoSecurityError.invalidIdentity } + return try NectoTLSIdentity(certificateDER: certificate, privateKey: key) + } + + private func privateKey(applicationLabel: Data) throws -> SecKey? { + var key: CFTypeRef? + let status = SecItemCopyMatching([ + kSecClass: kSecClassKey, kSecAttrKeyClass: kSecAttrKeyClassPrivate, + kSecAttrApplicationLabel: applicationLabel, kSecMatchSearchList: [keychain], + kSecReturnRef: true, kSecMatchLimit: kSecMatchLimitOne, + kSecUseAuthenticationUI: kSecUseAuthenticationUIFail, + ] as CFDictionary, &key) + if status == errSecItemNotFound { return nil } + try check(status) + guard let key, CFGetTypeID(key) == SecKeyGetTypeID() else { throw NectoSecurityError.invalidIdentity } + return unsafeDowncast(key, to: SecKey.self) + } + + private func applicationLabel(of key: SecKey) throws -> Data { + guard let attributes = SecKeyCopyAttributes(key) as? [CFString: Any], + let label = attributes[kSecAttrApplicationLabel] as? Data + else { throw NectoSecurityError.invalidIdentity } + return label + } + + private func registrationQuery(_ bundleID: String) -> [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, kSecAttrService: service, + kSecAttrAccount: bundleID, kSecMatchSearchList: [keychain], + kSecUseAuthenticationUI: kSecUseAuthenticationUIFail, + ] + } + + private func certificate(bundleID: String) throws -> Data? { + guard let attributes = try registration(bundleID: bundleID) else { return nil } + let query: [CFString: Any] + if let reference = attributes[kSecAttrGeneric] as? Data, !reference.isEmpty { + query = [ + kSecClass: kSecClassCertificate, kSecMatchItemList: [reference], + kSecMatchSearchList: [keychain], kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, + ] + } else { + // Keep credentials installed by earlier builds readable until they are reprovisioned. + var legacy = registrationQuery(bundleID) + legacy[kSecReturnData] = true + legacy[kSecMatchLimit] = kSecMatchLimitOne + query = legacy + } + var certificate: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &certificate) + try check(status) + guard let certificate = certificate as? Data else { throw NectoSecurityError.invalidIdentity } + return certificate + } + + private func registration(bundleID: String) throws -> [CFString: Any]? { + var query = registrationQuery(bundleID) + query[kSecReturnAttributes] = true + query[kSecMatchLimit] = kSecMatchLimitOne + var attributes: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &attributes) + if status == errSecItemNotFound { return nil } + try check(status) + guard let attributes = attributes as? [CFString: Any] else { throw NectoSecurityError.invalidIdentity } + return attributes + } + + private func storeCertificate(_ data: Data, bundleID: String, access: SecAccess) throws -> (reference: Data, inserted: Bool) { + guard let certificate = SecCertificateCreateWithData(nil, data as CFData) else { + throw NectoSecurityError.invalidIdentity + } + var result: CFTypeRef? + let status = SecItemAdd([ + kSecClass: kSecClassCertificate, kSecValueRef: certificate, kSecUseKeychain: keychain, + kSecAttrLabel: "Necto Certificate: " + bundleID, kSecAttrAccess: access, + kSecReturnPersistentRef: true, + ] as CFDictionary, &result) + if status == errSecDuplicateItem { + guard let issuer = SecCertificateCopyNormalizedIssuerSequence(certificate), + let serial = SecCertificateCopySerialNumberData(certificate, nil) + else { throw NectoSecurityError.invalidIdentity } + try check(SecItemCopyMatching([ + kSecClass: kSecClassCertificate, kSecAttrIssuer: issuer, kSecAttrSerialNumber: serial, + kSecMatchSearchList: [keychain], kSecReturnData: true, kSecReturnPersistentRef: true, + kSecMatchLimit: kSecMatchLimitOne, + ] as CFDictionary, &result)) + guard let existing = result as? [CFString: Any], existing[kSecValueData] as? Data == data, + let reference = existing[kSecValuePersistentRef] as? Data + else { throw NectoSecurityError.invalidIdentity } + return (reference, false) + } + try check(status) + // The file-based Keychain returns an array for a certificate added by reference. + let references = result as? [Data] + guard let reference = (result as? Data) ?? (references?.count == 1 ? references?.first : nil) + else { throw NectoSecurityError.invalidIdentity } + return (reference, true) + } + + private func importKey(_ data: Data, into keychain: SecKeychain? = nil, access: SecAccess? = nil) throws -> SecKey { + var format = SecExternalFormat.formatUnknown + var type = SecExternalItemType.itemTypePrivateKey + var parameters = SecItemImportExportKeyParameters() + parameters.version = UInt32(SEC_KEY_IMPORT_EXPORT_PARAMS_VERSION) + parameters.accessRef = access.map { Unmanaged.passUnretained($0) } + let usage = [kSecAttrCanSign] as CFArray + let attributes = (keychain == nil ? [kSecAttrIsSensitive] : [kSecAttrIsPermanent, kSecAttrIsSensitive]) as CFArray + parameters.keyUsage = Unmanaged.passUnretained(usage) + // Passing nil would make imported keys extractable by default. + parameters.keyAttributes = Unmanaged.passUnretained(attributes) + var items: CFArray? + try check(withExtendedLifetime((usage, attributes, access)) { + SecItemImport(data as CFData, nil, &format, &type, [], ¶meters, keychain, &items) + }) + guard let keys = items as? [AnyObject], keys.count == 1, + let item = keys.first, CFGetTypeID(item) == SecKeyGetTypeID() + else { throw NectoSecurityError.invalidIdentity } + return unsafeDowncast(item, to: SecKey.self) + } + + private func verifySigningAccess(key: SecKey, applications: [SecTrustedApplication]) throws { + let item = unsafeBitCast(key, to: SecKeychainItem.self) + var access: SecAccess? + try check(SecKeychainItemCopyAccess(item, &access)) + guard let access else { throw NectoSecurityError.invalidIdentity } + let signingACLs = SecAccessCopyMatchingACLList(access, kSecACLAuthorizationSign) as? [SecACL] ?? [] + let requested = try applications.map(trustedApplicationData) + var allowed: [Data] = [] + for entry in signingACLs { + var trusted: CFArray? + var description: CFString? + var selector = SecKeychainPromptSelector() + try check(SecACLCopyContents(entry, &trusted, &description, &selector)) + // A nil application list permits every application; preserve the existing policy. + guard let trusted else { return } + allowed += try (trusted as? [SecTrustedApplication] ?? []).map(trustedApplicationData) + } + // Adding another bundle must not change permissions for apps already sharing this key. + guard requested.allSatisfy({ allowed.contains($0) }) else { + throw NectoSecurityError.keychain(errSecAuthFailed) + } + } + + private func trustedApplicationData(_ application: SecTrustedApplication) throws -> Data { + var data: CFData? + try check(SecTrustedApplicationCopyData(application, &data)) + guard let data else { throw NectoSecurityError.invalidIdentity } + return data as Data + } + + private func label(_ bundleID: String) -> String { "Necto Connection: " + bundleID } + private func check(_ status: OSStatus) throws { + guard status == errSecSuccess else { throw NectoSecurityError.keychain(status) } + } +} +#endif diff --git a/NectoMac/Sources/NectoMacService/NectoUSBChannel.swift b/NectoMac/Sources/NectoMacService/NectoUSBChannel.swift index f5784e8..cda22a3 100644 --- a/NectoMac/Sources/NectoMacService/NectoUSBChannel.swift +++ b/NectoMac/Sources/NectoMacService/NectoUSBChannel.swift @@ -7,14 +7,13 @@ import NectoTransport /// A connection to the usbmuxd unix socket. /// -/// Mirrors `PTUSBChannel` in PeerTalk (`PTUSBHub.m`), ported to Swift so this package -/// stays dependency free. Names are kept close to the original so upstream changes -/// remain easy to follow. +/// Mirrors `PTUSBChannel` in PeerTalk (`PTUSBHub.m`). Names stay close to the original +/// so upstream changes remain easy to follow. /// /// usbmuxd frames every control message as a 16 byte header followed by a plist. /// After a successful `Connect` the same socket becomes a raw pipe to the device, /// which is why raw reads and writes stay available once the control phase ends. -final class NectoUSBChannel: NectoByteStream, @unchecked Sendable { +final class NectoUSBChannel: NectoSocketTransferring, @unchecked Sendable { enum Failure: Error, CustomStringConvertible { case cannotOpen(String) case closed @@ -45,6 +44,10 @@ final class NectoUSBChannel: NectoByteStream, @unchecked Sendable { stream.close() } + func takeSocketDescriptor() throws -> Int32 { + try stream.takeSocketDescriptor() + } + // MARK: Control phase /// Sends a plist request. Mirrors `-sendRequest:callback:`. diff --git a/NectoMac/Sources/NectoMacService/NectoUnauthorizedApp.swift b/NectoMac/Sources/NectoMacService/NectoUnauthorizedApp.swift new file mode 100644 index 0000000..f1f81b0 --- /dev/null +++ b/NectoMac/Sources/NectoMacService/NectoUnauthorizedApp.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import NectoModel + +/// A discovered app with no admitted debugging session or plugin permissions. +public struct NectoUnauthorizedApp: Sendable, Hashable, Identifiable { + public enum Reason: String, Sendable, Codable { + case missingKey + case rejectedKey + case credentialUnavailable + } + + public let target: NectoTarget + public let appName: String + public let deviceName: String + public let osVersion: String + public let connection: NectoConnectedApp.Connection + public let reason: Reason + public var id: String { NectoConnectedApp.id(for: target) } + public var appBundleID: String { target.appBundleID } + + public init(target: NectoTarget, appName: String, deviceName: String, osVersion: String = "", connection: NectoConnectedApp.Connection, reason: Reason) { + self.target = target + self.appName = appName + self.deviceName = deviceName + self.osVersion = osVersion + self.connection = connection + self.reason = reason + } +} diff --git a/NectoMac/Sources/necto-cli/PluginHelp.swift b/NectoMac/Sources/necto-cli/PluginHelp.swift index 36362bf..6ab4bf1 100644 --- a/NectoMac/Sources/necto-cli/PluginHelp.swift +++ b/NectoMac/Sources/necto-cli/PluginHelp.swift @@ -62,7 +62,10 @@ struct Device: AsyncParsableCommand { let targets = groups[id] ?? [] return ["id": .string(id), "name": targets.first?["deviceName"] ?? .string(id), "apps": .array(targets.map { - ["bundleID": $0["appBundleID"] ?? .null, "name": $0["appName"] ?? .null] + var app: [String: NectoJSONValue] = ["bundleID": $0["appBundleID"] ?? .null, "name": $0["appName"] ?? .null] + if let status = $0["status"] { app["status"] = status } + if let reason = $0["reason"] { app["reason"] = reason } + return .object(app) }.sorted { ($0["bundleID"]?.stringValue ?? "") < ($1["bundleID"]?.stringValue ?? "") })] })] } @@ -75,7 +78,8 @@ struct Device: AsyncParsableCommand { for device in devices { print(displayText("\(device["id"]?.stringValue ?? "?") \(device["name"]?.stringValue ?? "")")) for app in device["apps"]?.arrayValue ?? [] { - print(displayText(" \(app["bundleID"]?.stringValue ?? "?") \(app["name"]?.stringValue ?? "")")) + let status = app["status"] == "unauthorized" ? " [unauthorized]" : "" + print(displayText(" \(app["bundleID"]?.stringValue ?? "?") \(app["name"]?.stringValue ?? "")\(status)")) } } } diff --git a/NectoMac/Tests/NectoCLITests/NectoCLIBlackBoxTests.swift b/NectoMac/Tests/NectoCLITests/NectoCLIBlackBoxTests.swift index e6b0de6..26b9931 100644 --- a/NectoMac/Tests/NectoCLITests/NectoCLIBlackBoxTests.swift +++ b/NectoMac/Tests/NectoCLITests/NectoCLIBlackBoxTests.swift @@ -11,6 +11,42 @@ import Testing @Suite("CLI process interface", .timeLimit(.minutes(1))) struct NectoCLIBlackBoxTests { + @Test("unauthorized status survives JSON and human device discovery", arguments: [false, true]) + func unauthorizedDiscovery(json: Bool) async throws { + let fixture = try CLIProcessFixture() + defer { fixture.close() } + let server = fixture.respond { request, session in + try await session.send(NectoControlResponse(id: request.id, kind: .result, value: ["targets": .array([ + ["deviceID": "sim-1", "deviceName": "Phone", "appBundleID": "protected.app", "appName": "Protected", + "status": "unauthorized", "reason": "missingKey"], + ])])) + } + let result = try await fixture.run(["device", "list"] + (json ? ["--json"] : [])) + try await server.value + #expect(result.status == 0) + if json { + let app = try result.json()["devices"]?.arrayValue?.first?["apps"]?.arrayValue?.first + #expect(app?["status"] == "unauthorized") + #expect(app?["reason"] == "missingKey") + } else { #expect(result.output.contains("[unauthorized]")) } + } + + @Test("unauthorized plugin calls return a failure, while the CLI remains available") + func unauthorizedCall() async throws { + let fixture = try CLIProcessFixture() + defer { fixture.close() } + let server = fixture.respond { request, session in + try await session.send(NectoControlResponse(id: request.id, kind: .error, + error: .init(code: "UNAUTHORIZED", message: "Install the connection key."))) + } + let result = try await fixture.run(["plugin", "list", "--device", "sim-1", "--app", "protected.app", "--json"]) + try await server.value + #expect(result.status != 0) + #expect(result.error.contains("UNAUTHORIZED")) + let help = try await fixture.run(["--help"]) + #expect(help.status == 0) + } + @Test("device discovery prints usable identifiers grouped by device") func deviceDiscovery() async throws { let fixture = try CLIProcessFixture() diff --git a/NectoMac/Tests/NectoMacServiceTests/NectoControlBridgeTests.swift b/NectoMac/Tests/NectoMacServiceTests/NectoControlBridgeTests.swift index 9c5874a..18e19a4 100644 --- a/NectoMac/Tests/NectoMacServiceTests/NectoControlBridgeTests.swift +++ b/NectoMac/Tests/NectoMacServiceTests/NectoControlBridgeTests.swift @@ -41,6 +41,39 @@ private func connected(_ target: NectoTarget) -> NectoConnectedApp { @Suite("Control bridge ownership", .timeLimit(.minutes(1))) struct NectoControlBridgeTests { + @Test("unauthorized targets stay discoverable but cannot list, invoke, or subscribe to plugins") + func unauthorizedTarget() async throws { + let target = NectoTarget(deviceID: "phone", appBundleID: "protected.app") + let denied = NectoUnauthorizedApp(target: target, appName: "Protected", deviceName: "Phone", + osVersion: "26", connection: .usb, reason: .missingKey) + let registry = NectoPluginRegistry() + let bridge = NectoControlBridge(registry: registry, connectedApps: { [] }, unauthorizedApps: { [denied] }) + let info = await bridge.targets() + #expect(info["targets"]?.arrayValue?.first?["status"] == "unauthorized") + #expect(info["targets"]?.arrayValue?.first?["reason"] == "missingKey") + for kind in 0..<4 { + do { + switch kind { + case 0: _ = try await bridge.plugins(app: target.appBundleID, device: target.deviceID, desktop: false) + case 1: _ = try await bridge.plugins(app: target.appBundleID, device: target.deviceID, desktop: false, pluginID: "shared") + case 2: _ = try await bridge.invoke(pluginID: "shared", operationID: "read", input: [:], + app: target.appBundleID, device: target.deviceID, desktop: false) + default: try await bridge.subscribe(pluginID: "shared", operationID: "observe", input: [:], + app: target.appBundleID, device: target.deviceID, desktop: false, onEvent: { _ in Issue.record("Unauthorized event") }) + } + Issue.record("Unauthorized operation succeeded") + } catch let error as NectoBridgeError { #expect(error.code == .unauthorized) } + } + try await registry.install(manifest: scopeManifest(version: "1.0.0"), sourceIdentity: "desktop") + let desktop = try await bridge.plugins(app: nil, device: nil, desktop: true) + #expect(desktop["plugins"]?.arrayValue?.count == 1) + + let recovered = NectoControlBridge(registry: registry, connectedApps: { [connected(target)] }, unauthorizedApps: { [denied] }) + let targets = await recovered.targets()["targets"]?.arrayValue + #expect(targets?.count == 1) + #expect(targets?.first?["status"] == "connected") + } + @Test func duplicateIDsStayScopedAcrossDiscoveryAndExecution() async throws { let registry = NectoPluginRegistry() let targets = [NectoTarget(deviceID: "one", appBundleID: "app.a"), diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityCompatibilityTests.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityCompatibilityTests.swift new file mode 100644 index 0000000..ac3bd19 --- /dev/null +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityCompatibilityTests.swift @@ -0,0 +1,155 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import NIOPosix +import NectoModel +import NectoTransport +import Testing +@testable import NectoSDK +@testable import NectoMacService +@testable import NectoTransport + +@Suite("Existing connection compatibility", .serialized, .timeLimit(.minutes(1))) +struct CompatibilityTests { + @Test("the unchanged SDK runtime and socket stream work with the new host gate") + func oldSDKToNewHost() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + do { + var descriptors: [Int32] = [-1, -1] + try #require(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0) + let app = NectoMessageSession(stream: NectoSocketStream(descriptor: descriptors[0])) + let stream = try await NectoTLSStream.adopt(descriptor: descriptors[1], group: group) + let runtime = NectoSDKRuntime() + let calls = SecurityLocked(0) + #expect(runtime.register(ProbePlugin(calls: calls))) + let worker = Task { await runtime.accept(session: app) } + do { + let host = try await SecurityTestHandshake.host(stream: stream) { _ in + Issue.record("A legacy SDK must not look up a private key") + return nil + } + try await acceptSDKHello(host) + try await invokeProbe(host) + #expect(calls.withValue { $0 } == 1) + #expect(!stream.isTLSReady) + } catch { + stream.close(); app.close(); runtime.stop(); worker.cancel() + await worker.value + throw error + } + stream.close(); app.close(); runtime.stop() + await worker.value + try await group.shutdownGracefully() + } catch { + try await group.shutdownGracefully() + throw error + } + } + + @Test("an unconfigured SDK works with old and new hosts", arguments: [false, true]) + func unconfiguredSDK(newHost: Bool) async throws { + let runtime = NectoSDKRuntime() + let calls = SecurityLocked(0) + #expect(runtime.register(ProbePlugin(calls: calls))) + defer { runtime.stop() } + try await withSockets { pair in + let worker = Task { + let session = try await SecurityTestHandshake.device(stream: pair.device, publicKey: nil, appBundleID: CredentialFixture.bundleID) + await runtime.accept(session: session) + } + defer { pair.close(); worker.cancel() } + let host: NectoMessageSession + if newHost { + host = try await SecurityTestHandshake.host(stream: pair.host) { _ in + Issue.record("Unconfigured apps must not request credentials") + return nil + } + } else { + host = NectoMessageSession(stream: pair.host) + } + try await acceptSDKHello(host) + try await invokeProbe(host) + #expect(!pair.device.isTLSReady) + #expect(!pair.host.isTLSReady) + #expect(calls.withValue { $0 } == 1) + host.close() + try await worker.value + } + } + + @Test("legacy first-frame bytes and a coalesced following frame are preserved exactly") + func coalescedLegacyFrames() async throws { + try await withSockets { pair in + let hello = Data(""" + { "appBundleID":"com.example.old", "appName":"Old", "deviceName":"iPhone", "osVersion":"17", "appVersion":"1", "sdkVersion":"0.0.1", "protocolVersion":1, "futureField":42 } + """.utf8) + let next = Data("legacy second frame".utf8) + try await pair.device.write(frame(hello) + frame(next)) + let host = try await SecurityTestHandshake.host(stream: pair.host) { _ in nil } + #expect(try await host.receive() == hello) + #expect(try await host.receive() == next) + try await host.send(Data("ack".utf8)) + #expect(try await NectoMessageSession(stream: pair.device).receive() == Data("ack".utf8)) + } + } + + @Test("unsupported legacy protocol versions still reach the existing version check") + func legacyVersionCheck() async throws { + try await withSockets { pair in + let hello = NectoHandshakeHello(protocolVersion: 999, appBundleID: "com.example.old", appName: "Old", deviceName: "iPhone", sdkVersion: "0.0.1") + try await NectoMessageSession(stream: pair.device).send(hello) + let host = try await SecurityTestHandshake.host(stream: pair.host) { _ in nil } + let received = try await host.receive(NectoHandshakeHello.self) + #expect(received == hello) + #expect(NectoHandshakeAck.evaluate(received).rejection == .unsupportedProtocolVersion) + } + } + + @Test("an old host cannot downgrade a protected SDK to plaintext") + func oldHostToProtectedSDK() async throws { + let fixture = try CredentialFixture() + try await withSockets { pair in + let attempt = Task { + try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID) + } + defer { pair.close(); attempt.cancel() } + let oldHost = NectoMessageSession(stream: pair.host) + await #expect(throws: DecodingError.self) { _ = try await oldHost.receive(NectoHandshakeHello.self) } + oldHost.close() + await #expect(throws: (any Error).self) { _ = try await attempt.value } + #expect(!pair.device.isTLSReady) + } + } + + @Test("disconnecting and reconnecting the same SDK needs no runtime restart", arguments: [false, true]) + func reconnect(protected: Bool) async throws { + let fixture = try CredentialFixture() + let runtime = NectoSDKRuntime() + let calls = SecurityLocked(0) + #expect(runtime.register(ProbePlugin(calls: calls))) + defer { runtime.stop() } + for _ in 0..<3 { + try await withSockets { pair in + let worker = Task { + let app = try await SecurityTestHandshake.device(stream: pair.device, publicKey: protected ? fixture.identity.publicKey : nil, appBundleID: CredentialFixture.bundleID) + await runtime.accept(session: app) + } + defer { pair.close(); worker.cancel() } + let host = try await SecurityTestHandshake.host(stream: pair.host) { _ in fixture.identity } + try await acceptSDKHello(host) + try await invokeProbe(host) + host.close() + try await worker.value + } + } + #expect(calls.withValue { $0 } == 3) + } +} + +func frame(_ payload: Data) -> Data { + var result = Data() + withUnsafeBytes(of: UInt32(payload.count).bigEndian) { result.append(contentsOf: $0) } + return result + payload +} diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityConnectionSecurityTests.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityConnectionSecurityTests.swift new file mode 100644 index 0000000..70543fe --- /dev/null +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityConnectionSecurityTests.swift @@ -0,0 +1,261 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import Security +import NectoModel +import NectoTransport +import Testing +@testable import NectoSDK +@testable import NectoMacService +@testable import NectoTransport + +@Suite("Optional connection security", .serialized, .timeLimit(.minutes(1))) +struct ConnectionSecurityTests { + @Test("a non-extractable Keychain key completes real TLS and bidirectional framing") + func keychainTLS() async throws { + let fixture = try CredentialFixture() + var error: Unmanaged? + #expect(SecKeyCopyExternalRepresentation(fixture.identity.privateKey, &error) == nil) + #expect(error != nil) + _ = error?.takeRetainedValue() + try await withSockets(tcp: true) { pair in + let (device, host) = try await secureSessions(pair, identity: fixture.identity) + #expect(pair.device.isTLSReady) + #expect(pair.host.isTLSReady) + #expect(try await pair.device.negotiatedTLSVersion() == .tlsv13) + #expect(try await pair.host.negotiatedTLSVersion() == .tlsv13) + let request = Data("request inside TLS".utf8) + try await host.send(request) + #expect(try await device.receive() == request) + let response = Data("response inside TLS".utf8) + try await device.send(response) + #expect(try await host.receive() == response) + } + } + + @Test("only an authenticated host can register plugins and invoke the actual SDK runtime") + func authenticatedRuntime() async throws { + let fixture = try CredentialFixture() + let runtime = NectoSDKRuntime() + let calls = SecurityLocked(0) + #expect(runtime.register(ProbePlugin(calls: calls))) + defer { runtime.stop() } + try await withSockets { pair in + let worker = Task { + let session = try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID) + await runtime.accept(session: session) + } + defer { pair.close(); worker.cancel() } + let host = try await SecurityTestHandshake.host(stream: pair.host) { try fixture.store.identity(bundleID: $0) } + try await acceptSDKHello(host) + try await invokeProbe(host) + #expect(calls.withValue { $0 } == 1) + host.close() + try await worker.value + } + } + + @Test("missing and wrong host keys never admit a session or invoke a plugin", arguments: [false, true]) + func unauthorizedHost(wrongKey: Bool) async throws { + let fixture = try CredentialFixture() + let impostor = try CredentialFixture() + let admitted = SecurityLocked(false) + let calls = SecurityLocked(0) + let runtime = NectoSDKRuntime() + #expect(runtime.register(ProbePlugin(calls: calls))) + defer { runtime.stop() } + try await withSockets { pair in + let worker = Task { + do { + let session = try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID) + admitted.withValue { $0 = true } + await runtime.accept(session: session) + return true + } catch { return false } + } + defer { pair.close(); worker.cancel() } + await #expect(throws: (any Error).self) { + _ = try await SecurityTestHandshake.host(stream: pair.host) { _ in wrongKey ? impostor.identity : nil } + } + #expect(await worker.value == false) + #expect(admitted.withValue { $0 } == false) + #expect(calls.withValue { $0 } == 0) + #expect(!pair.device.isTLSReady) + await #expect(throws: (any Error).self) { try await pair.device.write(Data("plaintext fallback".utf8)) } + } + } + + @Test("a plaintext imitation cannot call a plugin on a protected SDK") + func plaintextImpostor() async throws { + let fixture = try CredentialFixture() + let admitted = SecurityLocked(false) + let runtime = NectoSDKRuntime() + let calls = SecurityLocked(0) + #expect(runtime.register(ProbePlugin(calls: calls))) + defer { runtime.stop() } + try await withSockets { pair in + let worker = Task { + do { + let session = try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID) + admitted.withValue { $0 = true } + await runtime.accept(session: session) + } catch {} + } + defer { pair.close(); worker.cancel() } + let attacker = NectoMessageSession(stream: pair.host) + let offer = try await attacker.receive() + #expect(String(decoding: offer, as: UTF8.self).contains("necto.security")) + try await attacker.send(NectoHandshakeAck(accepted: true)) + try? await attacker.send(NectoEnvelope(type: .pluginInvoke, encoding: NectoPluginInvocation( + requestID: "attack", name: "necto.device.probe.echo", version: 1, kind: .once, input: [:] + ))) + await worker.value + #expect(!admitted.withValue { $0 }) + #expect(calls.withValue { $0 } == 0) + #expect(!pair.device.isTLSReady) + } + } + + @Test("invalid configured pins fail closed instead of silently disabling security", arguments: ["", "not-base64", Data(repeating: 0, count: 65).base64EncodedString()]) + func invalidPin(_ pin: String) async throws { + try await withSockets { pair in + await #expect(throws: NectoSecurityError.invalidPublicKey) { + _ = try await SecurityTestHandshake.device(stream: pair.device, publicKey: pin, appBundleID: CredentialFixture.bundleID) + } + await #expect(throws: (any Error).self) { _ = try await pair.host.read(count: 1) } + } + } + + @Test("debug payloads are encrypted in both directions, including large binary frames") + func encryptedWire() async throws { + let fixture = try CredentialFixture() + let hostWire = SecurityLocked(Data()) + let deviceWire = SecurityLocked(Data()) + try await withSockets(deviceWire: { direction, bytes in + if direction == .outgoing { deviceWire.withValue { $0.append(bytes) } } + return bytes + }, hostWire: { direction, bytes in + if direction == .outgoing { hostWire.withValue { $0.append(bytes) } } + return bytes + }) { pair in + let (device, host) = try await secureSessions(pair, identity: fixture.identity) + hostWire.withValue { $0.removeAll() } + deviceWire.withValue { $0.removeAll() } + let marker = Data("PRIVATE-DEBUG-PAYLOAD-".utf8) + var data = marker + data.append(contentsOf: (0..<(256 * 1024)).map { UInt8($0 % 251) }) + let payload = data + try await host.send(payload) + #expect(try await device.receive() == payload) + try await device.send(payload) + #expect(try await host.receive() == payload) + for wire in [hostWire, deviceWire] { + #expect(wire.withValue { !$0.isEmpty }) + #expect(wire.withValue { $0.range(of: marker) == nil }) + #expect(wire.withValue { $0.count > payload.count }) + } + } + } + + @Test("tampered and replayed TLS records are rejected", arguments: [false, true]) + func recordIntegrity(replay: Bool) async throws { + let fixture = try CredentialFixture() + let state = SecurityLocked((armed: false, previous: Data())) + try await withSockets(hostWire: { direction, bytes in + guard direction == .outgoing else { return bytes } + return state.withValue { state in + if state.armed { + state.armed = false + if replay { return state.previous } + var damaged = bytes + if !damaged.isEmpty { damaged[damaged.count - 1] ^= 1 } + return damaged + } + state.previous = bytes + return bytes + } + }) { pair in + let (device, host) = try await secureSessions(pair, identity: fixture.identity) + try await host.send(Data("first message".utf8)) + #expect(try await device.receive() == Data("first message".utf8)) + state.withValue { $0.armed = true } + try await host.send(Data("second message".utf8)) + do { + _ = try await device.handshake(timeout: .seconds(3)) { try await device.receive() } + Issue.record("An altered TLS record reached the application") + } catch { + #expect((error as? URLError)?.code != .timedOut) + #expect(!pair.device.isTLSReady) + } + } + } + + @Test("a failed competing authentication does not evict an established SDK session") + func failedCompetitor() async throws { + let fixture = try CredentialFixture() + let runtime = NectoSDKRuntime() + let calls = SecurityLocked(0) + #expect(runtime.register(ProbePlugin(calls: calls))) + defer { runtime.stop() } + try await withSockets { established in + let (app, host) = try await secureSessions(established, identity: fixture.identity) + let worker = Task { await runtime.accept(session: app) } + defer { established.close(); worker.cancel() } + try await acceptSDKHello(host) + try await invokeProbe(host) + try await withSockets { competitor in + let attempt = Task { + do { + let session = try await SecurityTestHandshake.device(stream: competitor.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID) + await runtime.accept(session: session) + return true + } catch { return false } + } + defer { competitor.close(); attempt.cancel() } + await #expect(throws: NectoSecurityError.unauthorized) { + _ = try await SecurityTestHandshake.host(stream: competitor.host) { _ in nil } + } + #expect(await attempt.value == false) + } + try await invokeProbe(host) + #expect(calls.withValue { $0 } == 2) + host.close() + await worker.value + } + } + + @Test("rewriting discovery as a legacy hello cannot disable SDK authentication") + func strippedSecurityOffer() async throws { + let fixture = try CredentialFixture() + let legacyHello = try JSONEncoder().encode(NectoHandshakeHello( + appBundleID: CredentialFixture.bundleID, appName: "Forged legacy offer", deviceName: "iPhone", sdkVersion: "0.0.1" + )) + let rewritten = SecurityLocked(false) + let admitted = SecurityLocked(false) + try await withSockets(deviceWire: { direction, bytes in + guard direction == .outgoing else { return bytes } + return rewritten.withValue { alreadyRewritten in + if alreadyRewritten { return bytes } + alreadyRewritten = true + return frame(legacyHello) + } + }) { pair in + let attempt = Task { + do { + _ = try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID) + admitted.withValue { $0 = true } + } catch {} + } + defer { pair.close(); attempt.cancel() } + let fooledHost = try await SecurityTestHandshake.host(stream: pair.host) { _ in nil } + #expect(try await fooledHost.receive() == legacyHello) + try await fooledHost.send(NectoHandshakeAck(accepted: true)) + await attempt.value + #expect(rewritten.withValue { $0 }) + #expect(!admitted.withValue { $0 }) + #expect(!pair.device.isTLSReady) + } + } +} diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityCredentialTests.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityCredentialTests.swift new file mode 100644 index 0000000..7c6727b --- /dev/null +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityCredentialTests.swift @@ -0,0 +1,325 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import Security +import Testing +@testable import NectoMacService +@testable import NectoTransport + +@Suite("Keychain credentials", .serialized, .timeLimit(.minutes(1))) +struct CredentialTests { + private func certificateReference(_ fixture: CredentialFixture, bundleID: String) throws -> Data { + var result: CFTypeRef? + try #require(SecItemCopyMatching([ + kSecClass: kSecClassGenericPassword, kSecAttrService: "im.necto.connection.certificate", + kSecAttrAccount: bundleID, kSecMatchSearchList: [fixture.keychain], kSecReturnAttributes: true, + ] as CFDictionary, &result) == errSecSuccess) + return try #require((result as? [CFString: Any])?[kSecAttrGeneric] as? Data) + } + + @Test("a missing certificate fails closed rather than selecting another stored identity") + func missingCertificate() throws { + let fixture = try CredentialFixture() + let reference = try certificateReference(fixture, bundleID: CredentialFixture.bundleID) + #expect(SecItemDelete([ + kSecClass: kSecClassCertificate, kSecMatchItemList: [reference], + kSecMatchSearchList: [fixture.keychain], + ] as CFDictionary) == errSecSuccess) + #expect(throws: (any Error).self) { _ = try fixture.store.identity(bundleID: CredentialFixture.bundleID) } + #expect(SecKeyCreateSignature(fixture.identity.privateKey, .ecdsaSignatureMessageX962SHA256, + Data("key-preserved".utf8) as CFData, nil) != nil) + } + + @Test("previous password-backed registrations remain readable") + func previousRegistration() throws { + let fixture = try CredentialFixture() + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, kSecAttrService: "im.necto.connection.certificate", + kSecAttrAccount: CredentialFixture.bundleID, kSecMatchSearchList: [fixture.keychain], + ] + #expect(SecItemDelete(query as CFDictionary) == errSecSuccess) + #expect(SecItemAdd([ + kSecClass: kSecClassGenericPassword, kSecAttrService: "im.necto.connection.certificate", + kSecAttrAccount: CredentialFixture.bundleID, kSecValueData: fixture.identity.certificateDER, + kSecUseKeychain: fixture.keychain, + ] as CFDictionary, nil) == errSecSuccess) + #expect(try fixture.store.identity(bundleID: CredentialFixture.bundleID)?.publicKey == fixture.identity.publicKey) + } + + @Test("two bundle IDs share one private key and retain independent registrations") + func sharedKeyAcrossApps() throws { + let fixture = try CredentialFixture() + let material = try CredentialFixture.generate(in: fixture.directory) + let first = CredentialFixture.bundleID + ".first" + let second = CredentialFixture.bundleID + ".second" + try fixture.store.install(bundleID: first, privateKeyPEM: material.key, certificateDER: material.certificate) + try fixture.store.install(bundleID: second, privateKeyPEM: material.key, certificateDER: material.certificate) + let reopened = NectoKeychainCredentialStore(keychain: fixture.keychain) + let firstIdentity = try #require(try reopened.identity(bundleID: first)) + let secondIdentity = try #require(try reopened.identity(bundleID: second)) + #expect(firstIdentity.publicKey == secondIdentity.publicKey) + #expect(CFEqual(firstIdentity.privateKey, secondIdentity.privateKey)) + #expect(try certificateReference(fixture, bundleID: first) == certificateReference(fixture, bundleID: second)) + #expect(SecKeyCopyExternalRepresentation(secondIdentity.privateKey, nil) == nil) + #expect(try reopened.identity(bundleID: "com.example.unregistered") == nil) + #expect(throws: NectoSecurityError.keychain(errSecDuplicateItem)) { + try reopened.install(bundleID: second, privateKeyPEM: material.key, certificateDER: material.certificate) + } + + #expect(SecItemDelete([ + kSecClass: kSecClassGenericPassword, kSecAttrService: "im.necto.connection.certificate", + kSecAttrAccount: first, kSecMatchSearchList: [fixture.keychain], + ] as CFDictionary) == errSecSuccess) + #expect(try reopened.identity(bundleID: first) == nil) + #expect(try reopened.identity(bundleID: second)?.publicKey == secondIdentity.publicKey) + } + + @Test("concurrent installers can register different bundles for the same private key") + func concurrentSharedKey() async throws { + let fixture = try CredentialFixture() + let material = try CredentialFixture.generate(in: fixture.directory) + let bundles = ["com.example.concurrent.first", "com.example.concurrent.second"] + try await withThrowingTaskGroup(of: Void.self) { group in + for bundle in bundles { + group.addTask { + let store = NectoKeychainCredentialStore(keychain: fixture.keychain) + try store.install(bundleID: bundle, privateKeyPEM: material.key, certificateDER: material.certificate) + } + } + try await group.waitForAll() + } + let first = try #require(try fixture.store.identity(bundleID: bundles[0])) + let second = try #require(try fixture.store.identity(bundleID: bundles[1])) + #expect(first.publicKey == second.publicKey) + #expect(CFEqual(first.privateKey, second.privateKey)) + } + + @Test("a mismatched shared key does not register an app or damage either existing identity") + func mismatchedSharedKey() throws { + let fixture = try CredentialFixture() + let material = try CredentialFixture.generate(in: fixture.directory) + let first = CredentialFixture.bundleID + ".first" + let second = CredentialFixture.bundleID + ".second" + try fixture.store.install(bundleID: first, privateKeyPEM: material.key, certificateDER: material.certificate) + let initial = try #require(try fixture.store.identity(bundleID: first)) + #expect(throws: NectoSecurityError.invalidIdentity) { + try fixture.store.install(bundleID: second, privateKeyPEM: material.key, + certificateDER: fixture.identity.certificateDER) + } + #expect(try fixture.store.identity(bundleID: second) == nil) + #expect(try fixture.store.identity(bundleID: first)?.publicKey == initial.publicKey) + #expect(try fixture.store.identity(bundleID: CredentialFixture.bundleID)?.publicKey == fixture.identity.publicKey) + try fixture.store.install(bundleID: second, privateKeyPEM: material.key, certificateDER: material.certificate) + #expect(try fixture.store.identity(bundleID: second)?.publicKey == initial.publicKey) + } + + @Test("an extractable existing key is not silently reused") + func extractableSharedKey() throws { + let fixture = try CredentialFixture() + let material = try CredentialFixture.generate(in: fixture.directory) + var format = SecExternalFormat.formatUnknown + var type = SecExternalItemType.itemTypePrivateKey + var items: CFArray? + try #require(SecItemImport(material.key as CFData, nil, &format, &type, [], nil, + fixture.keychain, &items) == errSecSuccess) + let bundle = CredentialFixture.bundleID + ".extractable" + #expect(throws: NectoSecurityError.invalidIdentity) { + try fixture.store.install(bundleID: bundle, privateKeyPEM: material.key, certificateDER: material.certificate) + } + #expect(try fixture.store.identity(bundleID: bundle) == nil) + #expect(try fixture.store.identity(bundleID: CredentialFixture.bundleID)?.publicKey == fixture.identity.publicKey) + } + + @Test("a failed shared-key registration preserves the key, permissions and earlier registration") + func sharedKeyRollback() throws { + let fixture = try CredentialFixture() + let material = try CredentialFixture.generate(in: fixture.directory) + let first = CredentialFixture.bundleID + ".first" + let second = CredentialFixture.bundleID + ".second" + try fixture.store.install(bundleID: first, privateKeyPEM: material.key, certificateDER: material.certificate) + let original = try #require(try fixture.store.identity(bundleID: first)) + #expect(throws: NectoSecurityError.keychain(errSecAuthFailed)) { + try fixture.store.install(bundleID: second, privateKeyPEM: material.key, + certificateDER: material.certificate, trustedApplications: [URL(fileURLWithPath: "/usr/bin/true")]) + } + #expect(try fixture.store.identity(bundleID: second) == nil) + let preserved = try #require(try fixture.store.identity(bundleID: first)) + #expect(preserved.publicKey == original.publicKey) + #expect(SecKeyCreateSignature(preserved.privateKey, .ecdsaSignatureMessageX962SHA256, + Data("still-authorized".utf8) as CFData, nil) != nil) + try fixture.store.install(bundleID: second, privateKeyPEM: material.key, certificateDER: material.certificate) + #expect(try fixture.store.identity(bundleID: second)?.publicKey == original.publicKey) + } + + @Test("a separately provisioned trusted executable can sign without a Keychain dialog") + func separateProcess() throws { + let fixture = try CredentialFixture() + let source = fixture.directory.appendingPathComponent("signer.swift") + let executable = fixture.directory.appendingPathComponent("signer") + // This process has a different code identity from the installer/test runner. + try Data(""" + import Foundation + import Security + SecKeychainSetUserInteractionAllowed(false) + var keychain: SecKeychain? + guard SecKeychainOpen(CommandLine.arguments[1], &keychain) == errSecSuccess, let keychain else { exit(1) } + var registration: CFTypeRef? + guard SecItemCopyMatching([ + kSecClass: kSecClassGenericPassword, kSecAttrService: "im.necto.connection.certificate", + kSecAttrAccount: CommandLine.arguments[2], kSecMatchSearchList: [keychain], kSecReturnAttributes: true, + ] as CFDictionary, ®istration) == errSecSuccess else { exit(3) } + guard let record = registration as? [CFString: Any], let reference = record[kSecAttrGeneric] as? Data else { exit(3) } + var certificate: CFTypeRef? + guard SecItemCopyMatching([ + kSecClass: kSecClassCertificate, kSecMatchItemList: [reference], + kSecMatchSearchList: [keychain], kSecReturnData: true, + ] as CFDictionary, &certificate) == errSecSuccess else { exit(3) } + guard let data = certificate as? Data, + let parsed = SecCertificateCreateWithData(nil, data as CFData), + let publicKey = SecCertificateCopyKey(parsed), + let attributes = SecKeyCopyAttributes(publicKey) as? [CFString: Any], + let label = attributes[kSecAttrApplicationLabel] as? Data else { exit(2) } + var key: CFTypeRef? + let status = SecItemCopyMatching([ + kSecClass: kSecClassKey, kSecAttrKeyClass: kSecAttrKeyClassPrivate, + kSecAttrApplicationLabel: label, + kSecMatchSearchList: [keychain], kSecReturnRef: true, + ] as CFDictionary, &key) + guard status == errSecSuccess, let key else { exit(2) } + let signature = SecKeyCreateSignature(unsafeDowncast(key, to: SecKey.self), + .ecdsaSignatureMessageX962SHA256, Data("cross-process".utf8) as CFData, nil) + if CommandLine.arguments.count > 3 { + // Public certificate reads must work without granting private-key use. + guard signature == nil else { exit(5) } + print("certificate-readable-signing-denied") + exit(0) + } + guard let signature else { exit(4) } + print((signature as Data).base64EncodedString()) + """.utf8).write(to: source) + let compile = Process() + compile.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + compile.arguments = ["swiftc", source.path, "-o", executable.path] + compile.standardError = FileHandle.nullDevice + try compile.run() + compile.waitUntilExit() + try #require(compile.terminationStatus == 0) + let material = try CredentialFixture.generate(in: fixture.directory) + let bundleID = CredentialFixture.bundleID + ".process" + try fixture.store.install(bundleID: bundleID, privateKeyPEM: material.key, + certificateDER: material.certificate, trustedApplications: [executable]) + let sharedBundleID = bundleID + ".shared" + try fixture.store.install(bundleID: sharedBundleID, privateKeyPEM: material.key, + certificateDER: material.certificate, trustedApplications: [executable]) + for bundle in [bundleID, sharedBundleID] { + let process = Process() + process.executableURL = executable + process.arguments = [fixture.directory.appendingPathComponent("test.keychain-db").path, bundle] + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + try #require(process.terminationStatus == 0) + let signature = try #require(Data(base64Encoded: String(decoding: output, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))) + let identity = try #require(try fixture.store.identity(bundleID: bundle)) + let key = try #require(SecKeyCopyPublicKey(identity.privateKey)) + #expect(SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, + Data("cross-process".utf8) as CFData, signature as CFData, nil)) + } + let untrusted = CredentialFixture.bundleID + ".untrusted" + let untrustedMaterial = try CredentialFixture.generate(in: fixture.directory) + try fixture.store.install(bundleID: untrusted, privateKeyPEM: untrustedMaterial.key, + certificateDER: untrustedMaterial.certificate) + let process = Process() + process.executableURL = executable + process.arguments = [fixture.directory.appendingPathComponent("test.keychain-db").path, untrusted, "untrusted"] + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + #expect(String(decoding: output, as: UTF8.self).contains("certificate-readable-signing-denied")) + } + + @Test("both Security export APIs refuse the installed private key") + func nonExtractable() throws { + let fixture = try CredentialFixture() + var error: Unmanaged? + #expect(SecKeyCopyExternalRepresentation(fixture.identity.privateKey, &error) == nil) + _ = error?.takeRetainedValue() + var exported: CFData? + let status = SecItemExport(fixture.identity.privateKey, .formatOpenSSL, .pemArmour, nil, &exported) + #expect(status != errSecSuccess) + #expect(exported == nil) + let publicKey = try #require(SecKeyCopyPublicKey(fixture.identity.privateKey)) + let bytes = try #require(SecKeyCopyExternalRepresentation(publicKey, nil) as Data?) + #expect(try NectoPublicKeyPin(fixture.identity.publicKey).bytes == bytes) + } + + @Test("each bundle ID selects its own credential, with no default-key fallback") + func bundleLookup() throws { + let fixture = try CredentialFixture() + let other = try CredentialFixture.generate(in: fixture.directory) + let bundle = CredentialFixture.bundleID + ".second" + try fixture.store.install(bundleID: bundle, privateKeyPEM: other.key, certificateDER: other.certificate) + let second = try #require(try fixture.store.identity(bundleID: bundle)) + #expect(second.publicKey != fixture.identity.publicKey) + #expect(try fixture.store.identity(bundleID: CredentialFixture.bundleID)?.publicKey == fixture.identity.publicKey) + #expect(try fixture.store.identity(bundleID: "com.example.unknown") == nil) + #expect(try fixture.store.identity(bundleID: "") == nil) + } + + @Test("duplicate installation refuses replacement and preserves the existing key") + func duplicateInstall() throws { + let fixture = try CredentialFixture() + let replacement = try CredentialFixture.generate(in: fixture.directory) + #expect(throws: NectoSecurityError.keychain(errSecDuplicateItem)) { + try fixture.store.install(bundleID: CredentialFixture.bundleID, privateKeyPEM: replacement.key, certificateDER: replacement.certificate) + } + #expect(try fixture.store.identity(bundleID: CredentialFixture.bundleID)?.publicKey == fixture.identity.publicKey) + } + + @Test("a mismatched certificate leaves no credential and allows a corrected retry") + func importRollback() throws { + let fixture = try CredentialFixture() + let replacement = try CredentialFixture.generate(in: fixture.directory) + let bundle = CredentialFixture.bundleID + ".retry" + #expect(throws: NectoSecurityError.invalidIdentity) { + try fixture.store.install(bundleID: bundle, privateKeyPEM: replacement.key, certificateDER: fixture.identity.certificateDER) + } + #expect(try fixture.store.identity(bundleID: bundle) == nil) + try fixture.store.install(bundleID: bundle, privateKeyPEM: replacement.key, certificateDER: replacement.certificate) + #expect(try fixture.store.identity(bundleID: bundle) != nil) + } + + @Test("malformed private keys are not installed") + func malformedImport() throws { + let fixture = try CredentialFixture() + let bundle = CredentialFixture.bundleID + ".invalid" + #expect(throws: (any Error).self) { + try fixture.store.install(bundleID: bundle, privateKeyPEM: Data("not a private key".utf8), certificateDER: fixture.identity.certificateDER) + } + #expect(try fixture.store.identity(bundleID: bundle) == nil) + } + + @Test("a public key cannot be used as the host's signing identity") + func publicKeyOnly() throws { + let fixture = try CredentialFixture() + let publicKey = try #require(SecKeyCopyPublicKey(fixture.identity.privateKey)) + #expect(throws: NectoSecurityError.invalidIdentity) { + _ = try NectoTLSIdentity(certificateDER: fixture.identity.certificateDER, privateKey: publicKey) + } + } + + @Test("empty bundle IDs cannot install credentials") + func emptyBundleID() throws { + let fixture = try CredentialFixture() + #expect(throws: NectoSecurityError.invalidIdentity) { + try fixture.store.install(bundleID: "", privateKeyPEM: Data(), certificateDER: Data()) + } + } +} diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift new file mode 100644 index 0000000..d9dd084 --- /dev/null +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift @@ -0,0 +1,199 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import NIOPosix +import NectoModel +import NectoTransport +import Testing +@testable import NectoMacService +@testable import NectoTransport + +@Suite("Negotiation and transport failures", .serialized, .timeLimit(.minutes(1))) +struct FailureTests { + @Test("releasing a stream closes its socket without an explicit close") + func releaseClosesSocket() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + do { + var descriptors: [Int32] = [-1, -1] + try #require(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0) + var device: NectoTLSStream? = try await NectoTLSStream.adopt(descriptor: descriptors[0], group: group) + weak var released = device + let host = NectoMessageSession(stream: NectoSocketStream(descriptor: descriptors[1])) + device = nil + #expect(released == nil) + do { + _ = try await host.handshake(timeout: .seconds(3)) { try await host.receive() } + Issue.record("A released stream left its socket open") + } catch { + #expect((error as? URLError)?.code != .timedOut) + } + host.close() + try await group.shutdownGracefully() + } catch { + try await group.shutdownGracefully() + throw error + } + } + + @Test("unknown, incomplete, and malformed offers cannot fall back to legacy", arguments: [ + "{\"type\":\"necto.security\",\"version\":2,\"appBundleID\":\"com.example.app\"}", + "{\"type\":\"necto.security\",\"appBundleID\":\"com.example.app\"}", + "{\"type\":\"necto.security\",\"version\":1,\"appBundleID\":\"\"}", + "{\"type\":\"other.security\",\"version\":1,\"appBundleID\":\"com.example.app\"}", + "{\"type\":null}", "{}", "[]", "not json", "", + ]) + func malformedOffer(_ offer: String) async throws { + try await withSockets { pair in + try await NectoMessageSession(stream: pair.device).send(Data(offer.utf8)) + await #expect(throws: (any Error).self) { + _ = try await SecurityTestHandshake.host(stream: pair.host) { _ in + Issue.record("Invalid offers must not look up a private key") + return nil + } + } + await #expect(throws: (any Error).self) { _ = try await pair.device.read(count: 1) } + } + } + + @Test("an unregistered bundle ID has no credential fallback") + func unregisteredBundleID() async throws { + let fixture = try CredentialFixture() + try await withSockets { pair in + let attempt = Task { + try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: "com.example.impostor") + } + defer { pair.close(); attempt.cancel() } + await #expect(throws: NectoSecurityError.unauthorized) { + _ = try await SecurityTestHandshake.host(stream: pair.host) { try fixture.store.identity(bundleID: $0) } + } + await #expect(throws: (any Error).self) { _ = try await attempt.value } + } + } + + @Test("silent peers time out in discovery and TLS", arguments: [false, true]) + func timeout(duringTLS: Bool) async throws { + let fixture = try CredentialFixture() + try await withSockets { pair in + do { + if duringTLS { + _ = try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID, timeout: .milliseconds(100)) + } else { + _ = try await SecurityTestHandshake.host(stream: pair.device, timeout: .milliseconds(100)) { _ in nil } + } + Issue.record("A silent peer passed negotiation") + } catch { + #expect((error as? URLError)?.code == .timedOut) + } + #expect(!pair.device.isTLSReady) + await #expect(throws: (any Error).self) { try await pair.device.write(Data([1])) } + } + } + + @Test("cancelling a pending TLS handshake closes the connection") + func cancelHandshake() async throws { + let fixture = try CredentialFixture() + try await withSockets { pair in + let task = Task { + try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID) + } + _ = try await NectoMessageSession(stream: pair.host).receive() + task.cancel() + await #expect(throws: (any Error).self) { _ = try await task.value } + #expect(!pair.device.isTLSReady) + await #expect(throws: (any Error).self) { _ = try await pair.device.read(count: 1) } + } + } + + @Test("peer EOF during negotiation never becomes an authorized session") + func peerEOF() async throws { + let fixture = try CredentialFixture() + try await withSockets { pair in + let task = Task { + try await SecurityTestHandshake.device(stream: pair.device, publicKey: fixture.identity.publicKey, appBundleID: CredentialFixture.bundleID) + } + _ = try await NectoMessageSession(stream: pair.host).receive() + pair.host.close() + await #expect(throws: (any Error).self) { _ = try await task.value } + #expect(!pair.device.isTLSReady) + } + } + + @Test("cancelling an encrypted receive unblocks it and closes its stream") + func cancelRead() async throws { + let fixture = try CredentialFixture() + try await withSockets { pair in + let (device, _) = try await secureSessions(pair, identity: fixture.identity) + let read = Task { try await device.receive() } + read.cancel() + await #expect(throws: (any Error).self) { _ = try await read.value } + #expect(!pair.device.isTLSReady) + await #expect(throws: (any Error).self) { try await device.send(Data([1])) } + } + } + + @Test("oversized length prefixes fail without reading their payload") + func oversizedFrame() async throws { + try await withSockets { pair in + try await pair.device.write(Data([255, 255, 255, 255])) + await #expect(throws: NectoMessageSession.Failure.self) { + _ = try await SecurityTestHandshake.host(stream: pair.host) { _ in nil } + } + await #expect(throws: (any Error).self) { _ = try await pair.device.read(count: 1) } + } + } + + @Test("truncated frames end on EOF instead of hanging") + func truncatedFrame() async throws { + try await withSockets { pair in + try await pair.device.write(Data([0, 0, 0, 10, 1, 2])) + pair.device.close() + await #expect(throws: (any Error).self) { + _ = try await SecurityTestHandshake.host(stream: pair.host) { _ in nil } + } + } + } + + @Test("buffer limits reject a stalled reader") + func bufferLimit() async throws { + try await withSockets(limit: 64) { pair in + let reading = Task { try await pair.host.read(count: 64) } + try await pair.device.write(Data(repeating: 7, count: 65)) + await #expect(throws: NectoSecurityError.bufferLimit) { _ = try await reading.value } + await #expect(throws: NectoSecurityError.bufferLimit) { _ = try await pair.host.read(count: 1) } + } + } + + @Test("fragmented plaintext headers and bodies retain exact framing") + func fragmentedFrames() async throws { + try await withSockets { pair in + let expected = Data("fragmented frame".utf8) + let sender = Task { + for byte in frame(expected) { try await pair.device.write(Data([byte])) } + } + #expect(try await NectoMessageSession(stream: pair.host).receive() == expected) + try await sender.value + } + } + + @Test("coalesced plaintext discovery and TLS ClientHello are upgraded without losing bytes") + func coalescedUpgrade() async throws { + let fixture = try CredentialFixture() + let state = SecurityLocked((held: Data(), joined: false)) + try await withSockets(deviceWire: { direction, bytes in + guard direction == .outgoing else { return bytes } + return state.withValue { state in + if state.joined { return bytes } + if state.held.isEmpty { state.held = bytes; return Data() } + state.joined = true + return state.held + bytes + } + }) { pair in + let (device, host) = try await secureSessions(pair, identity: fixture.identity) + #expect(state.withValue { $0.joined }) + try await host.send(Data("coalesced upgrade".utf8)) + #expect(try await device.receive() == Data("coalesced upgrade".utf8)) + } + } +} diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityIntegrationTests.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityIntegrationTests.swift new file mode 100644 index 0000000..b2a8994 --- /dev/null +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityIntegrationTests.swift @@ -0,0 +1,434 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import NectoModel +@testable import NectoTransport +import Testing +@testable import NectoSDK +@testable import NectoMacService + +private let securityHello = NectoHandshakeHello( + appBundleID: CredentialFixture.bundleID, appName: "Protected Example", + deviceName: "Test iPhone", osVersion: "26", sdkVersion: "1", simulatorID: "security-simulator" +) + +private func rawSessions(tcp: Bool = false) throws -> (device: NectoMessageSession, host: NectoMessageSession) { + let pair = try securitySocketDescriptors(tcp: tcp) + return (NectoMessageSession(stream: NectoSocketStream(descriptor: pair.0)), + NectoMessageSession(stream: NectoSocketStream(descriptor: pair.1))) +} + +private func securityCenter( + identity: @escaping @Sendable (String) async throws -> NectoTLSIdentity? +) -> NectoConnectionCenter { + NectoConnectionCenter(port: 0, probeInterval: .seconds(1), deviceEvents: { AsyncThrowingStream { $0.finish() } }, identity: identity) +} + +private func admit(_ center: NectoConnectionCenter, _ session: NectoMessageSession) async throws -> Task? { + try await center.accept(session: session, deviceID: "simulator", connection: .simulator, usbDeviceID: nil, localPort: 12345) +} + +private func nextEnvelope(_ stream: AsyncStream) async throws -> NectoEnvelope { + try await withThrowingTaskGroup(of: NectoEnvelope.self) { group in + group.addTask { + var iterator = stream.makeAsyncIterator() + return try #require(await iterator.next()) + } + group.addTask { try await Task.sleep(for: .seconds(3)); throw URLError(.timedOut) } + defer { group.cancelAll() } + return try await group.next()! + } +} + +@Suite("Production SDK and host authentication", .serialized, .timeLimit(.minutes(1))) +struct SecurityIntegrationTests { + @Test("delayed private-key approval preserves pending sessions and allows recovery after cancellation", arguments: [false, true]) + func delayedKeychainApproval(cancelBeforeApproval: Bool) async throws { + let fixture = try CredentialFixture() + let entered = AsyncStream.makeStream() + let release = DispatchSemaphore(value: 0) + defer { release.signal(); entered.continuation.finish() } + NectoKeychainSigningQueue.shared.submit(keyID: fixture.identity.publicKey) { + entered.continuation.yield(()) + release.wait() + } + var iterator = entered.stream.makeAsyncIterator() + _ = await iterator.next() + let calls = SecurityLocked(0) + let sdk = NectoSDKRuntime(hello: { securityHello }) + #expect(sdk.register(ProbePlugin(calls: calls))) + let credentialReady = AsyncStream.makeStream() + defer { credentialReady.continuation.finish() } + let center = securityCenter { + let identity = try fixture.store.identity(bundleID: $0) + credentialReady.continuation.yield(()) + return identity + } + let events = AsyncStream.makeStream() + let received = SecurityLocked(0) + defer { events.continuation.finish() } + await center.onMessage { envelope, _ in + received.withValue { $0 += 1 } + events.continuation.yield(envelope) + } + let raw = try rawSessions(tcp: true) + defer { raw.device.close(); raw.host.close(); sdk.stop() } + let pin = try NectoPublicKeyPin(fixture.identity.publicKey) + let worker = Task { await sdk.accept(session: raw.device, publicKey: pin) } + let admitting = Task { try await admit(center, raw.host) } + defer { worker.cancel(); admitting.cancel() } + var credentialIterator = credentialReady.stream.makeAsyncIterator() + _ = await credentialIterator.next() + // Exceed both the former 3-second TLS limit and 10-second outer handshake limit. + // Credential lookup confirms discovery, not that the TLS signature is already queued. + try await Task.sleep(for: .seconds(11)) + #expect(await center.connectedApps.isEmpty) + #expect(await center.unauthorizedApps.isEmpty) + #expect(calls.withValue { $0 } == 0) + #expect(received.withValue { $0 } == 0) + var recoveredWorker: Task? + var recoveredSessions: (device: NectoMessageSession, host: NectoMessageSession)? + defer { + recoveredWorker?.cancel() + recoveredSessions?.device.close() + recoveredSessions?.host.close() + } + let reader: Task + if cancelBeforeApproval { + admitting.cancel() + await #expect(throws: CancellationError.self) { try await admitting.value } + await worker.value + #expect(await center.connectedApps.isEmpty) + #expect(await center.unauthorizedApps.isEmpty) + #expect(received.withValue { $0 } == 0) + #expect(calls.withValue { $0 } == 0) + release.signal() + let fresh = try rawSessions(tcp: true) + recoveredSessions = fresh + recoveredWorker = Task { await sdk.accept(session: fresh.device, publicKey: pin) } + reader = try #require(try await admit(center, fresh.host)) + } else { + release.signal() + reader = try #require(try await admitting.value) + } + #expect(try await nextEnvelope(events.stream).type == .pluginRegister) + #expect(await center.connectedApps.count == 1) + let app = try #require(await center.connectedApps.first) + try await center.send(NectoEnvelope(type: .pluginInvoke, encoding: NectoPluginInvocation( + requestID: "after-approval", name: "necto.device.probe.echo", version: 1, + kind: .once, input: ["approved": true] + )), to: app.target) + let result = try await nextEnvelope(events.stream).decode(NectoPluginResult.self) + #expect(result.output == ["approved": true]) + #expect(calls.withValue { $0 } == 1) + await center.stop() + await worker.value + await recoveredWorker?.value + await reader.value + } + + @Test("two registered apps share a key while an unregistered app with the same pin is denied") + func sharedKeyAcrossApps() async throws { + let fixture = try CredentialFixture() + let material = try CredentialFixture.generate(in: fixture.directory) + let bundles = ["com.example.shared.first", "com.example.shared.second"] + for bundle in bundles { + try fixture.store.install(bundleID: bundle, privateKeyPEM: material.key, certificateDER: material.certificate) + } + let identity = try #require(try fixture.store.identity(bundleID: bundles[0])) + let pin = try NectoPublicKeyPin(identity.publicKey) + let center = securityCenter { try fixture.store.identity(bundleID: $0) } + let events = AsyncStream.makeStream() + await center.onMessage { envelope, _ in events.continuation.yield(envelope) } + var workers: [Task] = [] + var readers: [Task] = [] + var runtimes: [NectoSDKRuntime] = [] + defer { runtimes.forEach { $0.stop() } } + for (index, bundle) in (bundles + ["com.example.shared.unregistered"]).enumerated() { + let calls = SecurityLocked(0) + let sdk = NectoSDKRuntime(hello: { + NectoHandshakeHello(appBundleID: bundle, appName: "Protected", + deviceName: "Test iPhone", sdkVersion: "1", simulatorID: "shared-key-simulator") + }) + runtimes.append(sdk) + #expect(sdk.register(ProbePlugin(calls: calls))) + let pair = try rawSessions(tcp: true) + workers.append(Task { await sdk.accept(session: pair.device, publicKey: pin) }) + let reader = try await center.accept(session: pair.host, + deviceID: "simulator", connection: .simulator, usbDeviceID: nil, localPort: UInt16(12345 + index)) + if let reader { + readers.append(reader) + #expect(bundles.contains(bundle)) + #expect(try await nextEnvelope(events.stream).type == .pluginRegister) + let app = try #require(await center.connectedApps.first { $0.appBundleID == bundle }) + try await center.send(NectoEnvelope(type: .pluginInvoke, encoding: NectoPluginInvocation( + requestID: bundle, name: "necto.device.probe.echo", version: 1, kind: .once, input: ["app": .string(bundle)] + )), to: app.target) + let result = try await nextEnvelope(events.stream).decode(NectoPluginResult.self) + #expect(result.output == ["app": .string(bundle)]) + #expect(calls.withValue { $0 } == 1) + } else { + #expect(bundle == "com.example.shared.unregistered") + #expect(calls.withValue { $0 } == 0) + } + } + #expect(await center.connectedApps.map(\.appBundleID).sorted() == bundles) + #expect(await center.unauthorizedApps.map(\.appBundleID) == ["com.example.shared.unregistered"]) + #expect(await center.unauthorizedApps.first?.reason == .missingKey) + await center.stop() + for worker in workers { await worker.value } + for reader in readers { await reader.value } + } + + @Test("one device can run protected and unprotected apps with independent authorization") + func mixedAppsOnOneDevice() async throws { + let fixture = try CredentialFixture() + let credential = SecurityLocked(nil) + let lookups = SecurityLocked<[String]>([]) + let center = securityCenter { bundle in + lookups.withValue { $0.append(bundle) } + return credential.withValue { $0 } + } + let protected = NectoSDKRuntime(hello: { securityHello }) + let plain = NectoSDKRuntime(hello: { + NectoHandshakeHello(appBundleID: "com.example.unprotected", appName: "Unprotected", + deviceName: "Test iPhone", sdkVersion: "1", simulatorID: securityHello.simulatorID) + }) + defer { protected.stop(); plain.stop() } + let pin = try NectoPublicKeyPin(fixture.identity.publicKey) + let deniedPair = try rawSessions() + let deniedWorker = Task { await protected.accept(session: deniedPair.device, publicKey: pin) } + #expect(try await admit(center, deniedPair.host) == nil) + await deniedWorker.value + + let plainPair = try rawSessions() + let plainWorker = Task { await plain.accept(session: plainPair.device) } + let plainReader = try #require(try await center.accept(session: plainPair.host, + deviceID: "simulator", connection: .simulator, usbDeviceID: nil, localPort: 12346)) + #expect(await center.connectedApps.map(\.appBundleID) == ["com.example.unprotected"]) + #expect(await center.unauthorizedApps.map(\.appBundleID) == [CredentialFixture.bundleID]) + #expect(lookups.withValue { $0 } == [CredentialFixture.bundleID]) + + credential.withValue { $0 = fixture.identity } + let securedPair = try rawSessions() + let securedWorker = Task { await protected.accept(session: securedPair.device, publicKey: pin) } + let securedReader = try #require(try await admit(center, securedPair.host)) + #expect(await center.connectedApps.count == 2) + #expect(await center.unauthorizedApps.isEmpty) + #expect(lookups.withValue { $0 } == [CredentialFixture.bundleID, CredentialFixture.bundleID]) + await center.stop() + await plainWorker.value + await securedWorker.value + await plainReader.value + await securedReader.value + } + + @Test("the same bundle on two devices reuses the credential and authenticates each session") + func sameBundleOnTwoDevices() async throws { + let fixture = try CredentialFixture() + let lookups = SecurityLocked<[String]>([]) + let center = securityCenter { bundle in + lookups.withValue { $0.append(bundle) } + return try fixture.store.identity(bundleID: bundle) + } + let pin = try NectoPublicKeyPin(fixture.identity.publicKey) + var workers: [Task] = [] + var readers: [Task] = [] + var runtimes: [NectoSDKRuntime] = [] + for index in 0..<2 { + let sdk = NectoSDKRuntime(hello: { + NectoHandshakeHello(appBundleID: CredentialFixture.bundleID, appName: "Protected", + deviceName: "Phone \(index)", sdkVersion: "1", simulatorID: "sim-\(index)") + }) + runtimes.append(sdk) + let pair = try rawSessions() + workers.append(Task { await sdk.accept(session: pair.device, publicKey: pin) }) + readers.append(try #require(try await center.accept(session: pair.host, + deviceID: "simulator", connection: .simulator, usbDeviceID: nil, localPort: UInt16(12345 + index)))) + } + #expect(await center.connectedApps.map(\.target.deviceID).sorted() == ["sim-0", "sim-1"]) + #expect(lookups.withValue { $0 } == [CredentialFixture.bundleID, CredentialFixture.bundleID]) + await center.stop() + for worker in workers { await worker.value } + for reader in readers { await reader.value } + runtimes.forEach { $0.stop() } + } + + @Test("the SDK and host upgrade their existing socket and exchange plugin messages", arguments: [false, true]) + func productionConnection(tcp: Bool) async throws { + let fixture = try CredentialFixture() + let calls = SecurityLocked(0) + let sdk = NectoSDKRuntime(hello: { securityHello }) + #expect(sdk.register(ProbePlugin(calls: calls))) + let center = securityCenter { bundle in try fixture.store.identity(bundleID: bundle) } + let events = AsyncStream.makeStream() + await center.onMessage { envelope, _ in events.continuation.yield(envelope) } + let raw = try rawSessions(tcp: tcp) + defer { raw.device.close(); raw.host.close(); sdk.stop() } + let pin = try NectoPublicKeyPin(fixture.identity.publicKey) + let worker = Task { await sdk.accept(session: raw.device, publicKey: pin) } + let reader = try #require(try await admit(center, raw.host)) + #expect(try await nextEnvelope(events.stream).type == .pluginRegister) + let app = try #require(await center.connectedApps.first) + #expect(app.appBundleID == CredentialFixture.bundleID) + #expect(await center.unauthorizedApps.isEmpty) + try await center.send(NectoEnvelope(type: .pluginInvoke, encoding: NectoPluginInvocation( + requestID: "secure", name: "necto.device.probe.echo", version: 1, kind: .once, input: ["secret": "value"] + )), to: app.target) + let result = try await nextEnvelope(events.stream).decode(NectoPluginResult.self) + #expect(result.output == ["secret": "value"]) + #expect(calls.withValue { $0 } == 1) + await center.stop() + await worker.value + await reader.value + } + + @Test("missing, wrong, and unreadable keys deny a real session before plugin registration", arguments: [0, 1, 2]) + func deniedSession(mode: Int) async throws { + let fixture = try CredentialFixture() + let wrong = try CredentialFixture() + let calls = SecurityLocked(0) + let received = SecurityLocked(0) + let sdk = NectoSDKRuntime(hello: { securityHello }) + #expect(sdk.register(ProbePlugin(calls: calls))) + let center = securityCenter { _ in + if mode == 2 { throw NectoSecurityError.keychain(-1) } + return mode == 0 ? nil : wrong.identity + } + await center.onMessage { _, _ in received.withValue { $0 += 1 } } + let raw = try rawSessions() + defer { raw.device.close(); raw.host.close(); sdk.stop() } + let pin = try NectoPublicKeyPin(fixture.identity.publicKey) + let worker = Task { await sdk.accept(session: raw.device, publicKey: pin) } + #expect(try await admit(center, raw.host) == nil) + await worker.value + #expect(await center.connectedApps.isEmpty) + let app = try #require(await center.unauthorizedApps.first) + #expect(app.reason == [.missingKey, .rejectedKey, .credentialUnavailable][mode]) + #expect(calls.withValue { $0 } == 0) + #expect(received.withValue { $0 } == 0) + await #expect(throws: NectoBridgeError(code: .unauthorized, message: "Authentication is required for '\(app.appBundleID)'.")) { + try await center.send(NectoEnvelope(type: .pluginCancel, encoding: NectoJSONValue.null), to: app.target) + } + await center.stop() + #expect(await center.unauthorizedApps.isEmpty) + } + + @Test("installing a key recovers a denied app without restarting the SDK or host") + func recover() async throws { + let fixture = try CredentialFixture() + let credential = SecurityLocked(nil) + let center = securityCenter { _ in credential.withValue { $0 } } + let sdk = NectoSDKRuntime(hello: { securityHello }) + defer { sdk.stop() } + let pin = try NectoPublicKeyPin(fixture.identity.publicKey) + let first = try rawSessions() + let failed = Task { await sdk.accept(session: first.device, publicKey: pin) } + #expect(try await admit(center, first.host) == nil) + await failed.value + #expect(await center.unauthorizedApps.count == 1) + credential.withValue { $0 = fixture.identity } + let second = try rawSessions() + let succeeded = Task { await sdk.accept(session: second.device, publicKey: pin) } + let reader = try #require(try await admit(center, second.host)) + #expect(await center.connectedApps.count == 1) + #expect(await center.unauthorizedApps.isEmpty) + await center.stop() + await succeeded.value + await reader.value + } + + @Test("a plaintext imitation cannot invoke a protected SDK or replace its authenticated session") + func plaintextImitation() async throws { + let fixture = try CredentialFixture() + let sdk = NectoSDKRuntime(hello: { securityHello }) + let calls = SecurityLocked(0) + #expect(sdk.register(ProbePlugin(calls: calls))) + let pin = try NectoPublicKeyPin(fixture.identity.publicKey) + let original = try rawSessions() + let originalWorker = Task { await sdk.accept(session: original.device, publicKey: pin) } + _ = try await original.host.receive(NectoSecurityOffer.self) + let trusted = try await original.host.upgradingTLS(.host(fixture.identity)) + defer { trusted.close(); sdk.stop() } + try await acceptSDKHello(trusted) + + let attacker = try rawSessions() + defer { attacker.host.close() } + let attackWorker = Task { await sdk.accept(session: attacker.device, publicKey: pin) } + let offer = try await attacker.host.receive(NectoSecurityOffer.self) + #expect(offer.appBundleID == CredentialFixture.bundleID) + // An old host's acknowledgement is not TLS and cannot enable plugin dispatch. + try? await attacker.host.send(NectoHandshakeAck(accepted: true)) + try? await attacker.host.send(NectoEnvelope(type: .pluginInvoke, encoding: NectoPluginInvocation( + requestID: "attack", name: "necto.device.probe.echo", version: 1, kind: .once, input: [:] + ))) + await attackWorker.value + #expect(calls.withValue { $0 } == 0) + try await invokeProbe(trusted) + #expect(calls.withValue { $0 } == 1) + trusted.close() + await originalWorker.value + } + + @Test("an unconfigured SDK never queries host credentials and keeps legacy framing") + func legacyConnection() async throws { + let center = securityCenter { _ in Issue.record("Legacy connection read the Keychain"); return nil } + let sdk = NectoSDKRuntime(hello: { securityHello }) + let raw = try rawSessions() + let worker = Task { await sdk.accept(session: raw.device) } + let reader = try #require(try await admit(center, raw.host)) + #expect(await center.connectedApps.count == 1) + #expect(await center.unauthorizedApps.isEmpty) + await center.stop() + await worker.value + await reader.value + sdk.stop() + } + + @Test("invalid SDK configuration stops an existing listener rather than disabling protection", arguments: ["", "bad-key"]) + func invalidConfiguration(key: String) async throws { + let sdk = NectoSDKRuntime(hello: { securityHello }) + sdk.start(port: 0) + sdk.start(port: 0, publicKey: key) + guard case .failed = sdk.status else { Issue.record("Invalid pin did not fail startup"); sdk.stop(); return } + sdk.stop() + #expect(sdk.status == .stopped) + } + + @Test("a stopped center cannot publish an authentication attempt that completes later") + func stopDuringLookup() async throws { + let entered = AsyncStream.makeStream() + let release = AsyncStream.makeStream() + let center = securityCenter { _ in + entered.continuation.yield(()) + for await _ in release.stream {} + return nil + } + let raw = try rawSessions() + defer { raw.device.close(); raw.host.close() } + let accepting = Task { try await admit(center, raw.host) } + try await raw.device.send(NectoSecurityOffer(hello: securityHello)) + var iterator = entered.stream.makeAsyncIterator() + _ = await iterator.next() + await center.stop() + release.continuation.finish() + await #expect(throws: CancellationError.self) { try await accepting.value } + #expect(await center.unauthorizedApps.isEmpty) + #expect(await center.connectedApps.isEmpty) + } + + @Test("a malformed security offer is not admitted as a legacy app", arguments: [ + "{\"type\":\"necto.security\",\"version\":99}", "{\"type\":\"unknown\"}", "{}" + ]) + func malformedOffer(json: String) async throws { + let center = securityCenter { _ in Issue.record("Malformed offer queried credentials"); return nil } + let raw = try rawSessions() + defer { raw.device.close(); raw.host.close() } + try await raw.device.send(Data(json.utf8)) + await #expect(throws: (any Error).self) { try await admit(center, raw.host) } + #expect(await center.connectedApps.isEmpty) + #expect(await center.unauthorizedApps.isEmpty) + } +} diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityTestHandshake.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityTestHandshake.swift new file mode 100644 index 0000000..7c9fdc7 --- /dev/null +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityTestHandshake.swift @@ -0,0 +1,86 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import NectoModel +import NectoTransport + +/// A peer fixture for transport-level fault injection. Production admission is tested separately. +enum SecurityTestHandshake { + private struct Offer: Codable { + let type: String + let version: Int + let appBundleID: String + } + + /// Only return a session that the SDK runtime is allowed to accept. + static func device( + stream: NectoTLSStream, publicKey: String?, appBundleID: String, + timeout: Duration = .seconds(3) + ) async throws -> NectoMessageSession { + let session = NectoMessageSession(stream: stream) + guard let publicKey else { return session } + do { + let pin = try NectoPublicKeyPin(publicKey) + guard !appBundleID.isEmpty else { throw NectoSecurityError.unsupportedNegotiation } + return try await session.handshake(timeout: timeout) { + try await session.send(Offer(type: "necto.security", version: 1, appBundleID: appBundleID)) + try await stream.startTLS(.device(pin), timeout: timeout) + return session + } + } catch { + session.close() + throw error + } + } + + static func host( + stream: NectoTLSStream, timeout: Duration = .seconds(3), + identity: @escaping @Sendable (String) throws -> NectoTLSIdentity? + ) async throws -> NectoMessageSession { + let session = NectoMessageSession(stream: stream) + do { + return try await session.handshake(timeout: timeout) { + let first = try await session.receive() + let object = try JSONSerialization.jsonObject(with: first) as? [String: Any] + if object?["type"] != nil { + let offer = try JSONDecoder().decode(Offer.self, from: first) + guard offer.type == "necto.security", offer.version == 1, !offer.appBundleID.isEmpty else { + throw NectoSecurityError.unsupportedNegotiation + } + // The bundle ID only selects a key. It is not proof of the device app's identity. + guard let credential = try identity(offer.appBundleID) else { throw NectoSecurityError.unauthorized } + try await stream.startTLS(.host(credential), timeout: timeout) + return session + } + _ = try JSONDecoder().decode(NectoHandshakeHello.self, from: first) + var prefix = Data() + withUnsafeBytes(of: UInt32(first.count).bigEndian) { prefix.append(contentsOf: $0) } + prefix.append(first) + return NectoMessageSession(stream: PrefixedStream(prefix: prefix, base: stream)) + } + } catch { + session.close() + throw error + } + } +} + +private final class PrefixedStream: NectoByteStream, @unchecked Sendable { + private let lock = NSLock() + private var prefix: Data + private let base: NectoTLSStream + init(prefix: Data, base: NectoTLSStream) { self.prefix = prefix; self.base = base } + func read(count: Int) async throws -> Data { + let first = lock.withLock { + let first = Data(prefix.prefix(count)) + prefix.removeFirst(first.count) + return first + } + if first.count == count { return first } + return try await first + base.read(count: count - first.count) + } + func write(_ data: Data) async throws { try await base.write(data) } + func close() { base.close() } +} diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityTestSupport.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityTestSupport.swift new file mode 100644 index 0000000..ead9ded --- /dev/null +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityTestSupport.swift @@ -0,0 +1,186 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import Security +import NIOPosix +import NectoModel +import NectoTransport +import Testing +@testable import NectoSDK +@testable import NectoMacService +@testable import NectoTransport + +final class CredentialFixture: @unchecked Sendable { + let directory: URL + let keychain: SecKeychain + let store: NectoKeychainCredentialStore + let identity: NectoTLSIdentity + static let bundleID = "com.example.necto-security-poc" + + init() throws { + // Unexpected ACL prompts must fail the test instead of asking for a random test password. + SecKeychainSetUserInteractionAllowed(false) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("necto-security-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + var keychain: SecKeychain? + let password = UUID().uuidString + let status = password.withCString { + SecKeychainCreate(directory.appendingPathComponent("test.keychain-db").path, UInt32(password.utf8.count), $0, false, nil, &keychain) + } + guard status == errSecSuccess, let keychain else { + try? FileManager.default.removeItem(at: directory) + throw NectoSecurityError.keychain(status) + } + let store = NectoKeychainCredentialStore(keychain: keychain) + do { + let material = try Self.generate(in: directory) + try store.install(bundleID: Self.bundleID, privateKeyPEM: material.key, certificateDER: material.certificate) + identity = try #require(try store.identity(bundleID: Self.bundleID)) + } catch { + SecKeychainDelete(keychain) + try? FileManager.default.removeItem(at: directory) + throw error + } + self.directory = directory + self.keychain = keychain + self.store = store + } + + deinit { + SecKeychainDelete(keychain) + try? FileManager.default.removeItem(at: directory) + } + + static func generate(in directory: URL) throws -> (key: Data, certificate: Data) { + let key = directory.appendingPathComponent(UUID().uuidString + ".pem") + let certificate = directory.appendingPathComponent(UUID().uuidString + ".der") + defer { try? FileManager.default.removeItem(at: key); try? FileManager.default.removeItem(at: certificate) } + try openssl(["ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", key.path]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: key.path) + try openssl(["req", "-new", "-x509", "-key", key.path, "-subj", "/CN=Necto-POC", "-days", "1", "-outform", "DER", "-out", certificate.path]) + return try (Data(contentsOf: key), Data(contentsOf: certificate)) + } + + private static func openssl(_ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/openssl") + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + try #require(process.terminationStatus == 0) + } +} + +struct SocketPair: Sendable { + let device: NectoTLSStream + let host: NectoTLSStream + func close() { device.close(); host.close() } +} + +func withSockets( + tcp: Bool = false, + limit: Int = NectoMessageSession.maximumMessageBytes + 4, + deviceWire: NectoTLSStream.WireTransform? = nil, + hostWire: NectoTLSStream.WireTransform? = nil, + _ operation: @Sendable (SocketPair) async throws -> Void +) async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + do { + let descriptors = try securitySocketDescriptors(tcp: tcp) + let device = try await NectoTLSStream.adopt(descriptor: descriptors.0, group: group, maximumBufferedBytes: limit, wireTransform: deviceWire) + let host = try await NectoTLSStream.adopt(descriptor: descriptors.1, group: group, maximumBufferedBytes: limit, wireTransform: hostWire) + let pair = SocketPair(device: device, host: host) + do { try await operation(pair) } + catch { pair.close(); throw error } + pair.close() + try await group.shutdownGracefully() + } catch { + try await group.shutdownGracefully() + throw error + } +} + +func securitySocketDescriptors(tcp: Bool) throws -> (Int32, Int32) { + if !tcp { + var descriptors: [Int32] = [-1, -1] + try #require(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0) + return (descriptors[0], descriptors[1]) + } + let listener = socket(AF_INET, SOCK_STREAM, 0) + try #require(listener >= 0) + defer { Darwin.close(listener) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(listener, $0, socklen_t(MemoryLayout.size)) } + } + try #require(bound == 0) + try #require(listen(listener, 1) == 0) + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(listener, $0, &length) } + } + try #require(named == 0) + let client = socket(AF_INET, SOCK_STREAM, 0) + let connected = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(client, $0, length) } + } + guard connected == 0 else { Darwin.close(client); throw NectoSecurityError.closed } + let accepted = accept(listener, nil, nil) + guard accepted >= 0 else { Darwin.close(client); throw NectoSecurityError.closed } + return (accepted, client) +} + +func secureSessions(_ pair: SocketPair, identity: NectoTLSIdentity) async throws -> (NectoMessageSession, NectoMessageSession) { + async let device = SecurityTestHandshake.device(stream: pair.device, publicKey: identity.publicKey, appBundleID: CredentialFixture.bundleID) + async let host = SecurityTestHandshake.host(stream: pair.host) { bundleID in + #expect(bundleID == CredentialFixture.bundleID) + return identity + } + return try await (device, host) +} + +final class SecurityLocked: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + init(_ value: Value) { self.value = value } + func withValue(_ body: (inout Value) throws -> Result) rethrows -> Result { try lock.withLock { try body(&value) } } +} + +struct ProbePlugin: NectoPluginable { + let id = "com.example.poc-probe" + let calls: SecurityLocked + func register(_ necto: NectoHandler) { + necto.handle("probe.echo") { input in calls.withValue { $0 += 1 }; return input } + } +} + +func acceptSDKHello(_ host: NectoMessageSession) async throws { + try await host.handshake(timeout: .seconds(3)) { + _ = try await host.receive(NectoHandshakeHello.self) + // The SwiftPM runner has no app bundle ID, as in the existing SDK runtime tests. + try await host.send(NectoHandshakeAck(accepted: true)) + let registration = try await host.receive(NectoEnvelope.self) + #expect(registration.type == .pluginRegister) + #expect(try registration.decode(NectoPluginRegistration.self).pluginID == "com.example.poc-probe") + } +} + +func invokeProbe(_ host: NectoMessageSession, id: String = UUID().uuidString) async throws { + try await host.handshake(timeout: .seconds(3)) { + try await host.send(NectoEnvelope(type: .pluginInvoke, encoding: NectoPluginInvocation( + requestID: id, name: "necto.device.probe.echo", version: 1, kind: .once, input: ["id": .string(id)] + ))) + let envelope = try await host.receive(NectoEnvelope.self) + #expect(envelope.type == .pluginResult) + let result = try envelope.decode(NectoPluginResult.self) + #expect(result.requestID == id) + #expect(result.output == ["id": .string(id)]) + } +} diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..1d8a1ed --- /dev/null +++ b/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "558d66d40f4e769ad1d913a4636b322d76928a5f69163ca660ab0cdb113e78ff", + "pins" : [ + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 5f22942..cb2b5e1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 import PackageDescription @@ -14,10 +14,20 @@ let package = Package( "NectoURLSessionCapture", "NectoModel", "NectoTransport", ]), ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio-ssl.git", exact: "2.37.5"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.98.0"), + ], targets: [ .target(name: "NectoModel"), .target( - name: "NectoTransport" + name: "NectoTransport", + dependencies: [ + .product(name: "NIOSSL", package: "swift-nio-ssl"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOTLS", package: "swift-nio"), + ] ), .target( name: "NectoSDK", diff --git a/README-ko.md b/README-ko.md index b6a4f5d..fc0c9a1 100644 --- a/README-ko.md +++ b/README-ko.md @@ -29,7 +29,7 @@ Necto는 어떤 환경에서든 바로 연결되는 iOS 디버깅 플랫폼이 Necto를 응용 프로그램 폴더로 옮기세요. 체크섬 확인과 ad-hoc 서명 정책은 [설치 안내](docs/ko/install.md)에 있어요. -소스에서 빌드하려면 macOS 14 이상, Swift 6.0 이상을 지원하는 Xcode, +소스에서 빌드하려면 macOS 14 이상, Swift 6.1 이상을 지원하는 Xcode, Node.js 22.12 이상과 Yarn 4.6.0이 필요해요. iOS SDK는 iOS 16 이상을 지원해요. ```bash diff --git a/README.md b/README.md index b4297b4..3a327e6 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Download the DMG from [Releases](https://github.com/toss/necto/releases) and drag Necto into Applications. See the [installation guide](docs/install.md) for checksum verification and the app's ad-hoc signing policy. -Building from source requires macOS 14 or later, Xcode with Swift 6.0 or later, +Building from source requires macOS 14 or later, Xcode with Swift 6.1 or later, Node.js 22.12 or later and Yarn 4.6.0. The iOS SDK supports iOS 16 or later. ```bash diff --git a/SECURITY.md b/SECURITY.md index 8256d40..07d065d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,3 +17,8 @@ plugins require approval of their source and requested bridges. Shell execution modify files and run processes as the logged-in user. See the [plugin trust policy](docs/plugin-manifest.md#identity-and-trust) and [production build guidance](docs/setup.md#keeping-it-away-from-users). + +For optional SDK connection authentication, see the +[connection authentication and Keychain registration guide](docs/connection-security.md) +([한국어](docs/ko/connection-security.md)). It covers app public keys, private setup +tools, Mac Keychain entries and troubleshooting unauthorized connections. diff --git a/Sources/NectoModel/NectoBridgeError.swift b/Sources/NectoModel/NectoBridgeError.swift index 4818fe5..135196f 100644 --- a/Sources/NectoModel/NectoBridgeError.swift +++ b/Sources/NectoModel/NectoBridgeError.swift @@ -12,6 +12,7 @@ public enum NectoBridgeErrorCode: String, Sendable, Codable, CaseIterable { case operationNotFound = "OPERATION_NOT_FOUND" case operationUnavailable = "OPERATION_UNAVAILABLE" case permissionDenied = "PERMISSION_DENIED" + case unauthorized = "UNAUTHORIZED" case targetDisconnected = "TARGET_DISCONNECTED" case timeout = "TIMEOUT" case cancelled = "CANCELLED" diff --git a/Sources/NectoModel/NectoSecurityOffer.swift b/Sources/NectoModel/NectoSecurityOffer.swift new file mode 100644 index 0000000..67fd587 --- /dev/null +++ b/Sources/NectoModel/NectoSecurityOffer.swift @@ -0,0 +1,31 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation + +/// Public discovery metadata used only to select a host credential and show denied apps. +/// None of these fields proves the device app's identity. +public struct NectoSecurityOffer: Codable, Sendable, Equatable { + public let type: String + public let version: Int + public let appBundleID: String + public let appName: String + public let deviceName: String + public let osVersion: String + public let simulatorID: String? + + public init(hello: NectoHandshakeHello) { + type = "necto.security" + version = 1 + appBundleID = hello.appBundleID + appName = hello.appName + deviceName = hello.deviceName + osVersion = hello.osVersion + simulatorID = hello.simulatorID + } + + public var isSupported: Bool { + type == "necto.security" && version == 1 && !appBundleID.isEmpty + } +} diff --git a/Sources/NectoSDK/NectoSDK.swift b/Sources/NectoSDK/NectoSDK.swift index f75d61e..62d2429 100644 --- a/Sources/NectoSDK/NectoSDK.swift +++ b/Sources/NectoSDK/NectoSDK.swift @@ -49,8 +49,14 @@ public enum NectoSDK { public static var plugins: [any NectoPluginable] { runtime.plugins } /// Starts listening. Safe to call at app start up; it does not block the caller. - public static func start(port: UInt16 = NectoDeviceListener.defaultPort) { - runtime.start(port: port) + public static func start() { + runtime.start(port: NectoDeviceListener.defaultPort) + } + + /// Requires a host holding the private key for this Base64 P-256 public key. + /// Invalid keys fail startup; authentication failures never fall back to plaintext. + public static func start(publicKey: String) { + runtime.start(port: NectoDeviceListener.defaultPort, publicKey: publicKey) } public static func stop() { diff --git a/Sources/NectoSDK/NectoSDKRuntime.swift b/Sources/NectoSDK/NectoSDKRuntime.swift index 7fe34e6..46b2b0b 100644 --- a/Sources/NectoSDK/NectoSDKRuntime.swift +++ b/Sources/NectoSDK/NectoSDKRuntime.swift @@ -17,6 +17,13 @@ import UIKit /// changing this file. final class NectoSDKRuntime: @unchecked Sendable { private let lock = NSLock() + private let lifecycleLock = NSLock() + private var configuredPublicKey: NectoPublicKeyPin? + private let helloProvider: @Sendable () async -> NectoHandshakeHello + + init(hello: (@Sendable () async -> NectoHandshakeHello)? = nil) { + helloProvider = hello ?? { await Self.hello() } + } private var listener: NectoDeviceListener? private var task: Task? private var generation: UUID? @@ -133,14 +140,33 @@ final class NectoSDKRuntime: @unchecked Sendable { /// coming back to the foreground feels immediate. private static let retryDelay = Duration.seconds(1) - func start(port: UInt16) { + func start(port: UInt16, publicKey: String? = nil) { + lifecycleLock.withLock { + let pin: NectoPublicKeyPin? + do { pin = try publicKey.map(NectoPublicKeyPin.init) } + catch { + stopRuntime() + status = .failed("Invalid Necto public key. Expected a Base64 P-256 public key.") + return + } + let unchanged = lock.withLock { + task != nil && configuredPublicKey?.bytes == pin?.bytes + } + guard !unchanged else { return } + stopRuntime() + startRuntime(port: port, publicKey: pin) + } + } + + private func startRuntime(port: UInt16, publicKey: NectoPublicKeyPin?) { lock.withLock { guard task == nil else { return } + configuredPublicKey = publicKey let generation = UUID() self.generation = generation task = Task { [self] in while !Task.isCancelled { - await serve(port: port, generation: generation) + await serve(port: port, generation: generation, publicKey: publicKey) guard !Task.isCancelled else { return } try? await Task.sleep(for: Self.retryDelay) } @@ -149,7 +175,7 @@ final class NectoSDKRuntime: @unchecked Sendable { } /// Accepts connections until the socket goes away. - private func serve(port basePort: UInt16, generation: UUID) async { + private func serve(port basePort: UInt16, generation: UUID, publicKey: NectoPublicKeyPin?) async { var bound: NectoDeviceListener? var lastFailure = "No port was available from \(basePort)" for offset in 0 ..< Int(NectoDeviceListener.portSpan) { @@ -181,7 +207,7 @@ final class NectoSDKRuntime: @unchecked Sendable { for try await session in listener.sessions() { guard !Task.isCancelled else { session.close(); break } - await handle(session: session) + await accept(session: session, publicKey: publicKey) } } catch { lock.withLock { @@ -195,11 +221,16 @@ final class NectoSDKRuntime: @unchecked Sendable { } func stop() { + lifecycleLock.withLock { stopRuntime() } + } + + private func stopRuntime() { lock.lock() let task = task let listener = listener let session = session self.task = nil + configuredPublicKey = nil generation = nil self.listener = nil self.session = nil @@ -222,12 +253,27 @@ final class NectoSDKRuntime: @unchecked Sendable { /// The listener path calls this for every connection. Keeping it separate means /// the message handling can be exercised over a socket pair, without binding a /// port or leaving a listener thread behind. - func accept(session: NectoMessageSession) async { - await handle(session: session) + func accept(session: NectoMessageSession, publicKey: NectoPublicKeyPin? = nil) async { + guard let publicKey else { + await handle(session: session) + return + } + do { + let hello = await helloProvider() + guard !hello.appBundleID.isEmpty else { throw NectoSecurityError.unsupportedNegotiation } + try await session.handshake { + try await session.send(NectoSecurityOffer(hello: hello)) + } + let secured = try await session.upgradingTLS(.device(publicKey)) + // Authenticate before handle() can replace an existing session or register plugins. + await handle(session: secured, hello: hello) + } catch { + session.close() + } } /// Says hello, tells the host what this app offers, then reads until it goes away. - private func handle(session: NectoMessageSession) async { + private func handle(session: NectoMessageSession, hello suppliedHello: NectoHandshakeHello? = nil) async { let previous = lock.withLock { () -> (NectoMessageSession?, [Task])? in guard !Task.isCancelled else { return nil } let previous = (self.session, requests.values.map(\.task)) @@ -241,16 +287,9 @@ final class NectoSDKRuntime: @unchecked Sendable { previous.1.forEach { $0.cancel() } defer { disconnect(session) } do { - let hello = await NectoHandshakeHello( - appBundleID: Self.appBundleID, - appName: Self.appName, - appVersion: Self.appVersion, - deviceName: Self.deviceName(), - osVersion: Self.osVersion(), - sdkVersion: NectoSDK.version, - appIcon: Self.appIcon(), - simulatorID: ProcessInfo.processInfo.environment["SIMULATOR_UDID"] - ) + let hello: NectoHandshakeHello + if let provided = suppliedHello { hello = provided } + else { hello = await helloProvider() } let ack = try await session.handshake { try await session.send(hello) return try await session.receive(NectoHandshakeAck.self) @@ -493,6 +532,14 @@ final class NectoSDKRuntime: @unchecked Sendable { // MARK: App identity + private static func hello() async -> NectoHandshakeHello { + await NectoHandshakeHello( + appBundleID: appBundleID, appName: appName, appVersion: appVersion, + deviceName: deviceName(), osVersion: osVersion(), sdkVersion: NectoSDK.version, + appIcon: appIcon(), simulatorID: ProcessInfo.processInfo.environment["SIMULATOR_UDID"] + ) + } + /// A bundle without an identifier cannot complete the handshake, and a test bundle /// is exactly that, so the identity is overridable rather than always read from /// `Bundle.main`. diff --git a/Sources/NectoTransport/NectoByteStream.swift b/Sources/NectoTransport/NectoByteStream.swift index 9ac3044..81e1d46 100644 --- a/Sources/NectoTransport/NectoByteStream.swift +++ b/Sources/NectoTransport/NectoByteStream.swift @@ -12,11 +12,22 @@ public protocol NectoByteStream: Sendable { func close() } +/// Transfers an idle socket to another transport. The old stream must not be used afterwards. +public protocol NectoSocketTransferring: NectoByteStream { + func takeSocketDescriptor() throws -> Int32 +} + /// Adapts asynchronous Dispatch I/O to Swift concurrency. -public final class NectoSocketStream: NectoByteStream, @unchecked Sendable { +public final class NectoSocketStream: NectoSocketTransferring, @unchecked Sendable { public enum Failure: Error, CustomStringConvertible { case closed - public var description: String { "The connection closed" } + case busy + public var description: String { + switch self { + case .closed: "The connection closed" + case .busy: "The socket still has pending I/O" + } + } } private let descriptor: Int32 @@ -24,6 +35,7 @@ public final class NectoSocketStream: NectoByteStream, @unchecked Sendable { private let queue = DispatchQueue(label: "im.toss.necto.socket") private let lock = NSLock() private var closed = false + private var pendingIO = 0 /// Takes ownership of a connected descriptor, including its eventual close. public init(descriptor: Int32) { @@ -37,6 +49,30 @@ public final class NectoSocketStream: NectoByteStream, @unchecked Sendable { deinit { close() } + public func takeSocketDescriptor() throws -> Int32 { + let transferred = try lock.withLock { + guard !closed else { throw Failure.closed } + guard pendingIO == 0 else { throw Failure.busy } + let duplicate = fcntl(descriptor, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + closed = true + return duplicate + } + // DispatchIO still owns the original descriptor. Close it without shutdown, + // which would also shut down the duplicate now owned by TLS. + channel.close(flags: .stop) + return transferred + } + + private func beginIO() throws { + try lock.withLock { + guard !closed else { throw Failure.closed } + pendingIO += 1 + } + } + + private func endIO() { lock.withLock { pendingIO -= 1 } } + public func close() { lock.withLock { guard !closed else { return } @@ -50,6 +86,8 @@ public final class NectoSocketStream: NectoByteStream, @unchecked Sendable { public func write(_ data: Data) async throws { try await withTaskCancellationHandler { try Task.checkCancellation() + try beginIO() + defer { endIO() } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let bytes = data.withUnsafeBytes { DispatchData(bytes: $0) } channel.write(offset: 0, data: bytes, queue: queue) { done, _, error in @@ -67,6 +105,8 @@ public final class NectoSocketStream: NectoByteStream, @unchecked Sendable { guard count > 0 else { return Data() } return try await withTaskCancellationHandler { try Task.checkCancellation() + try beginIO() + defer { endIO() } let data = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let buffer = ReadBuffer() channel.read(offset: 0, length: count, queue: queue) { done, bytes, error in diff --git a/Sources/NectoTransport/NectoMessageSession.swift b/Sources/NectoTransport/NectoMessageSession.swift index b3f6060..ac227a6 100644 --- a/Sources/NectoTransport/NectoMessageSession.swift +++ b/Sources/NectoTransport/NectoMessageSession.swift @@ -37,6 +37,26 @@ public final class NectoMessageSession: @unchecked Sendable { stream.close() } + /// Call only between completed messages, with no concurrent reader or writer. + /// The returned session exclusively owns the socket, even if TLS fails. + public func upgradingTLS(_ role: NectoTLSStream.Role) async throws -> NectoMessageSession { + let descriptor = try receiveLock.withLock { + guard !receiving else { throw Failure.concurrentReceive } + guard let source = stream as? any NectoSocketTransferring else { + throw NectoSecurityError.unsupportedNegotiation + } + return try source.takeSocketDescriptor() + } + let secured = try await NectoTLSStream.adopt(descriptor: descriptor) + do { + try await secured.startTLS(role) + return NectoMessageSession(stream: secured) + } catch { + secured.close() + throw error + } + } + /// Sends one message, framed as a 4 byte big endian length followed by the payload. public func send(_ message: Data) async throws { guard message.count <= Self.maximumMessageBytes else { diff --git a/Sources/NectoTransport/NectoTLSIdentity.swift b/Sources/NectoTransport/NectoTLSIdentity.swift new file mode 100644 index 0000000..ddfb883 --- /dev/null +++ b/Sources/NectoTransport/NectoTLSIdentity.swift @@ -0,0 +1,139 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import Security +import NIOCore +import NIOSSL + +public enum NectoSecurityError: Error, Equatable { + case invalidPublicKey + case invalidIdentity + case unauthorized + case unsupportedNegotiation + case closed + case concurrentRead + case bufferLimit + case keychain(OSStatus) +} + +public struct NectoPublicKeyPin: Sendable { + public let bytes: Data + + public init(_ base64: String) throws { + guard let data = Data(base64Encoded: base64), data.count == 65, + SecKeyCreateWithData(data as CFData, [ + kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits: 256, + ] as CFDictionary, nil) != nil else { throw NectoSecurityError.invalidPublicKey } + bytes = data + } + + func matches(_ certificate: NIOSSLCertificate) -> Bool { + guard let der = try? certificate.toDERBytes(), + let certificate = SecCertificateCreateWithData(nil, Data(der) as CFData), + let key = SecCertificateCopyKey(certificate), + let data = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return false } + return data == bytes + } +} + +public struct NectoTLSIdentity: @unchecked Sendable { + public let certificateDER: Data + public let privateKey: SecKey + public let publicKey: String + + public init(certificateDER: Data, privateKey: SecKey) throws { + guard let certificate = SecCertificateCreateWithData(nil, certificateDER as CFData), + let certificateKey = SecCertificateCopyKey(certificate), + let privatePublicKey = SecKeyCopyPublicKey(privateKey), + let expected = SecKeyCopyExternalRepresentation(certificateKey, nil) as Data?, + let actual = SecKeyCopyExternalRepresentation(privatePublicKey, nil) as Data?, + expected == actual, + SecKeyIsAlgorithmSupported(privateKey, .sign, .ecdsaSignatureMessageX962SHA256) + else { throw NectoSecurityError.invalidIdentity } + _ = try NectoPublicKeyPin(actual.base64EncodedString()) + self.certificateDER = certificateDER + self.privateKey = privateKey + publicKey = actual.base64EncodedString() + } + + func configuration() throws -> TLSConfiguration { + let certificate = try NIOSSLCertificate(bytes: Array(certificateDER), format: .der) + var config = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(certificate)], + privateKey: .privateKey(NIOSSLPrivateKey(customPrivateKey: KeychainSigner(key: privateKey, id: publicKey))) + ) + config.minimumTLSVersion = .tlsv13 + return config + } +} + +private struct KeychainSigner: NIOSSLCustomPrivateKey, Hashable, @unchecked Sendable { + let key: SecKey + let id: String + var signatureAlgorithms: [SignatureAlgorithm] { [.ecdsaSecp256R1Sha256] } + + static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } + + func sign(channel: any Channel, algorithm: SignatureAlgorithm, data: ByteBuffer) -> EventLoopFuture { + let promise = channel.eventLoop.makePromise(of: ByteBuffer.self) + let bytes = Data(data.readableBytesView) + // Keychain access can block; never perform it on the network event loop. + NectoKeychainSigningQueue.shared.submit(keyID: id) { + guard channel.isActive else { + promise.fail(NectoSecurityError.closed) + return + } + guard algorithm == .ecdsaSecp256R1Sha256 else { + promise.fail(NectoSecurityError.invalidIdentity) + return + } + var error: Unmanaged? + if let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, bytes as CFData, &error) { + promise.succeed(ByteBuffer(bytes: signature as Data)) + } else { + let failure = error?.takeRetainedValue() as Error? ?? NectoSecurityError.unauthorized + promise.fail(failure) + } + } + return promise.futureResult + } + + func decrypt(channel: any Channel, data: ByteBuffer) -> EventLoopFuture { + channel.eventLoop.makeFailedFuture(NectoSecurityError.invalidIdentity) + } +} + +/// One pending Keychain approval per key, even when several apps or devices share it. +final class NectoKeychainSigningQueue: @unchecked Sendable { + static let shared = NectoKeychainSigningQueue() + private let lock = NSLock() + private var pending: [String: [@Sendable () -> Void]] = [:] + + func submit(keyID: String, operation: @escaping @Sendable () -> Void) { + let start = lock.withLock { + let start = pending[keyID] == nil + pending[keyID, default: []].append(operation) + return start + } + if start { DispatchQueue.global().async { self.drain(keyID) } } + } + + private func drain(_ keyID: String) { + while true { + let operation: (@Sendable () -> Void)? = lock.withLock { + guard let queue = pending[keyID], !queue.isEmpty else { + pending.removeValue(forKey: keyID) + return nil + } + return pending[keyID]?.removeFirst() + } + guard let operation else { return } + operation() + } + } +} diff --git a/Sources/NectoTransport/NectoTLSStream.swift b/Sources/NectoTransport/NectoTLSStream.swift new file mode 100644 index 0000000..79ab532 --- /dev/null +++ b/Sources/NectoTransport/NectoTLSStream.swift @@ -0,0 +1,228 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import NIOCore +import NIOPosix +import NIOSSL +import NIOTLS + +public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { + /// Protected connections may need a person to approve private-key use in Keychain. + public static let authenticationTimeout: Duration = .seconds(120) + + public enum Role: Sendable { + case device(NectoPublicKeyPin) + case host(NectoTLSIdentity) + } + + enum Direction: Sendable { case incoming, outgoing } + typealias WireTransform = @Sendable (Direction, Data) -> Data + + private let lock = NSLock() + private var channel: (any Channel)? + private var buffer = Data() + private var failure: (any Error)? + private var pending: (count: Int, continuation: CheckedContinuation)? + private var tlsPromise: EventLoopPromise? + private var tlsFinished = false + private let limit: Int + + private init(limit: Int) { self.limit = limit } + deinit { close() } + + /// Ownership passes to NIO. Callers must not read, write or close the descriptor again. + static func adopt( + descriptor: Int32, + group: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton, + maximumBufferedBytes: Int = NectoMessageSession.maximumMessageBytes + 4, + wireTransform: WireTransform? = nil + ) async throws -> NectoTLSStream { + let stream = NectoTLSStream(limit: maximumBufferedBytes) + let channel = try await ClientBootstrap(group: group) + .channelInitializer { channel in + channel.pipeline.addHandlers([ + WireHandler(transform: wireTransform), + StreamHandler(stream: stream), + ]) + } + .withConnectedSocket(descriptor).get() + stream.lock.withLock { stream.channel = channel } + return stream + } + + public func startTLS(_ role: Role, timeout: Duration = NectoTLSStream.authenticationTimeout) async throws { + guard let channel = lock.withLock({ channel }) else { throw NectoSecurityError.closed } + let session = NectoMessageSession(stream: self) + try await session.handshake(timeout: timeout) { [self] in + let future = try await channel.eventLoop.submit { + let promise = try self.lock.withLock { + if let failure = self.failure { throw failure } + guard self.tlsPromise == nil, self.pending == nil else { throw NectoSecurityError.concurrentRead } + let promise = channel.eventLoop.makePromise(of: Void.self) + self.tlsPromise = promise + return promise + } + do { + let handler: NIOSSLHandler + switch role { + case let .device(pin): + var config = TLSConfiguration.makeClientConfiguration() + config.minimumTLSVersion = .tlsv13 + config.certificateVerification = .noHostnameVerification + handler = try NIOSSLClientHandler( + context: NIOSSLContext(configuration: config), serverHostname: nil, + customVerificationCallback: { certificates, result in + result.succeed(certificates.first.map(pin.matches) == true ? .certificateVerified : .failed) + } + ) + case let .host(identity): + handler = NIOSSLServerHandler(context: try NIOSSLContext(configuration: identity.configuration())) + } + let pipeline = channel.pipeline.syncOperations + let wire = try pipeline.context(handlerType: WireHandler.self) + try pipeline.addHandler(handler, position: .after(wire.handler)) + // TLS records may arrive in the same socket read as the public discovery frame. + let buffered = self.lock.withLock { + let bytes = self.buffer + self.buffer.removeAll(keepingCapacity: false) + return bytes + } + if !buffered.isEmpty { wire.fireChannelRead(NIOAny(ByteBuffer(bytes: buffered))) } + } catch { + self.fail(error) + } + return promise.futureResult + }.get() + try await future.get() + } + } + + public var isTLSReady: Bool { lock.withLock { tlsFinished && failure == nil } } + + func negotiatedTLSVersion() async throws -> TLSVersion? { + guard let channel = lock.withLock({ channel }) else { throw NectoSecurityError.closed } + return try await channel.nioSSL_tlsVersion().get() + } + + public func write(_ data: Data) async throws { + try await withTaskCancellationHandler { + try Task.checkCancellation() + let channel = try lock.withLock { + if let failure { throw failure } + guard let channel = self.channel else { throw NectoSecurityError.closed } + return channel + } + try await channel.writeAndFlush(ByteBuffer(bytes: data)).get() + } onCancel: { close() } + } + + public func read(count: Int) async throws -> Data { + precondition(count >= 0) + guard count > 0 else { return Data() } + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + lock.withLock { + if let failure { continuation.resume(throwing: failure) } + else if pending != nil { continuation.resume(throwing: NectoSecurityError.concurrentRead) } + else if count > limit { continuation.resume(throwing: NectoSecurityError.bufferLimit) } + else if buffer.count >= count { continuation.resume(returning: consume(count)) } + else { pending = (count, continuation) } + } + } + } onCancel: { close() } + } + + public func close() { + fail(NectoSecurityError.closed) + let closing = lock.withLock { + let closing = channel + channel = nil + return closing + } + closing?.close(promise: nil) + } + + private func consume(_ count: Int) -> Data { + let result = Data(buffer.prefix(count)) + buffer.removeFirst(count) + return result + } + + fileprivate func receive(_ data: Data) { + let overflow = lock.withLock { + guard failure == nil else { return false } + guard data.count <= limit - buffer.count else { return true } + buffer.append(data) + if let pending, buffer.count >= pending.count { + self.pending = nil + pending.continuation.resume(returning: consume(pending.count)) + } + return false + } + if overflow { + fail(NectoSecurityError.bufferLimit) + close() + } + } + + fileprivate func completeTLS() { + lock.withLock { + guard failure == nil, !tlsFinished else { return } + tlsFinished = true + tlsPromise?.succeed(()) + } + } + + fileprivate func fail(_ error: any Error) { + lock.withLock { + guard failure == nil else { return } + failure = error + buffer.removeAll() + pending?.continuation.resume(throwing: error) + pending = nil + if !tlsFinished { tlsPromise?.fail(error) } + } + } +} + +private final class StreamHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + // The stream owns the channel. A strong reference here would keep both alive after session release. + weak var stream: NectoTLSStream? + init(stream: NectoTLSStream) { self.stream = stream } + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + stream?.receive(Data(unwrapInboundIn(data).readableBytesView)) + } + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if case TLSUserEvent.handshakeCompleted = event { stream?.completeTLS() } + context.fireUserInboundEventTriggered(event) + } + func errorCaught(context: ChannelHandlerContext, error: any Error) { + stream?.fail(error) + context.close(promise: nil) + } + func channelInactive(context: ChannelHandlerContext) { + stream?.fail(NectoSecurityError.closed) + context.fireChannelInactive() + } +} + +private final class WireHandler: ChannelDuplexHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + typealias InboundOut = ByteBuffer + typealias OutboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + let transform: NectoTLSStream.WireTransform? + init(transform: NectoTLSStream.WireTransform?) { self.transform = transform } + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let bytes = Data(unwrapInboundIn(data).readableBytesView) + context.fireChannelRead(wrapInboundOut(ByteBuffer(bytes: transform?(.incoming, bytes) ?? bytes))) + } + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + let bytes = Data(unwrapOutboundIn(data).readableBytesView) + context.write(wrapOutboundOut(ByteBuffer(bytes: transform?(.outgoing, bytes) ?? bytes)), promise: promise) + } +} diff --git a/Tests/Fixtures/SDKConsumer/Package.resolved b/Tests/Fixtures/SDKConsumer/Package.resolved new file mode 100644 index 0000000..e5684e9 --- /dev/null +++ b/Tests/Fixtures/SDKConsumer/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "05bd0c777067691f6dccc0a082eb8901866b72030d738e663dc46f6536a942a9", + "pins" : [ + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + } + ], + "version" : 3 +} diff --git a/Tests/NectoTransportTests/NectoKeychainSigningQueueTests.swift b/Tests/NectoTransportTests/NectoKeychainSigningQueueTests.swift new file mode 100644 index 0000000..0d4ca4b --- /dev/null +++ b/Tests/NectoTransportTests/NectoKeychainSigningQueueTests.swift @@ -0,0 +1,28 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import Testing +@testable import NectoTransport + +@Test("one key's approval cannot overlap or block another key", .timeLimit(.minutes(1))) +func keychainSigningSerialization() async throws { + let queue = NectoKeychainSigningQueue() + let events = AsyncStream.makeStream() + let release = DispatchSemaphore(value: 0) + defer { release.signal(); events.continuation.finish() } + queue.submit(keyID: "shared-key") { + events.continuation.yield("first-started") + release.wait() + events.continuation.yield("first-finished") + } + var iterator = events.stream.makeAsyncIterator() + #expect(await iterator.next() == "first-started") + queue.submit(keyID: "shared-key") { events.continuation.yield("second-started") } + queue.submit(keyID: "other-key") { events.continuation.yield("other-started") } + #expect(await iterator.next() == "other-started") + release.signal() + #expect(await iterator.next() == "first-finished") + #expect(await iterator.next() == "second-started") +} diff --git a/Tests/NectoTransportTests/NectoSocketConcurrencyTests.swift b/Tests/NectoTransportTests/NectoSocketConcurrencyTests.swift index ff55213..584be34 100644 --- a/Tests/NectoTransportTests/NectoSocketConcurrencyTests.swift +++ b/Tests/NectoTransportTests/NectoSocketConcurrencyTests.swift @@ -16,6 +16,49 @@ private func socketPair() throws -> (NectoSocketStream, NectoSocketStream) { @Suite("Asynchronous socket I/O", .timeLimit(.minutes(1))) struct NectoSocketConcurrencyTests { + @Test("socket transfer preserves unread bytes and the old stream cannot close its successor", arguments: 0..<10) + func transferSocket(_: Int) async throws { + let (peer, source) = try socketPair() + defer { peer.close() } + try await peer.write(Data([1, 2, 3, 4, 5, 6])) + #expect(try await source.read(count: 2) == Data([1, 2])) + let successor = NectoSocketStream(descriptor: try source.takeSocketDescriptor()) + defer { successor.close() } + source.close() + #expect(try await successor.read(count: 4) == Data([3, 4, 5, 6])) + try await successor.write(Data([7, 8])) + #expect(try await peer.read(count: 2) == Data([7, 8])) + #expect(throws: NectoSocketStream.Failure.self) { try source.takeSocketDescriptor() } + await #expect(throws: NectoSocketStream.Failure.self) { try await source.write(Data([9])) } + await #expect(throws: NectoSocketStream.Failure.self) { try await source.read(count: 1) } + } + + @Test("a closed socket cannot transfer ownership") + func transferClosedSocket() throws { + let (peer, source) = try socketPair() + defer { peer.close() } + source.close() + #expect(throws: NectoSocketStream.Failure.self) { try source.takeSocketDescriptor() } + } + + @Test("socket transfer refuses an in-flight write without losing ownership") + func transferDuringWrite() async throws { + let (source, peer) = try socketPair() + defer { source.close(); peer.close() } + let writing = Task { try await source.write(Data(repeating: 42, count: 1024 * 1024)) } + // The peer has consumed only one byte, so the small socket buffer keeps the write pending. + #expect(try await peer.read(count: 1) == Data([42])) + do { + let descriptor = try source.takeSocketDescriptor() + Darwin.close(descriptor) + Issue.record("An active write transferred its socket") + } catch let error as NectoSocketStream.Failure { + guard case .busy = error else { Issue.record("Expected busy: \(error)"); return } + } + source.close() + await #expect(throws: NectoSocketStream.Failure.self) { try await writing.value } + } + @Test("acceptor rejects a descriptor it cannot make nonblocking") func invalidAcceptorDescriptor() { #expect(throws: POSIXError.self) { try NectoSocketAcceptor(descriptor: -1) } diff --git a/WebPackages/Bridge/components.css b/WebPackages/Bridge/components.css index 2393591..330acd7 100644 --- a/WebPackages/Bridge/components.css +++ b/WebPackages/Bridge/components.css @@ -328,6 +328,7 @@ th.necto-numeric { text-align: right; } which kind of target it is belongs to the icon beside the name. */ .necto-conn { width: 6px; height: 6px; flex: none; border-radius: 50%; } .necto-conn-on { background: var(--necto-success); } +.necto-conn-denied { background: var(--necto-danger); } .necto-conn-off { background: none; box-shadow: inset 0 0 0 1px var(--necto-text-tertiary); } /* Detail ---------------------------------------------------------- */ diff --git a/WebPackages/Bridge/src/types.ts b/WebPackages/Bridge/src/types.ts index 1e29d82..62e505d 100644 --- a/WebPackages/Bridge/src/types.ts +++ b/WebPackages/Bridge/src/types.ts @@ -56,6 +56,7 @@ export type NectoBridgeErrorCode = | "OPERATION_NOT_FOUND" | "OPERATION_UNAVAILABLE" | "PERMISSION_DENIED" + | "UNAUTHORIZED" | "TARGET_DISCONNECTED" | "TIMEOUT" | "CANCELLED" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7e1efaf..0c316cb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -17,6 +17,12 @@ function sidebar(prefix: string, labels: Record) { { text: labels.install, link: link("install") }, ], }, + { + text: labels.security, + items: [ + { text: labels.connectionSecurity, link: link("connection-security") }, + ], + }, { text: labels.tutorial, items: [ @@ -49,6 +55,8 @@ const en = sidebar("", { start: "Start", setup: "Connect Necto to your app", install: "Install the Necto app", + security: "Security", + connectionSecurity: "Connection authentication and Keychain", tutorial: "Adding a plugin", tutorialDesktop: "Part 1 — A desktop plugin", tutorialDevice: "Part 2 — A device plugin", @@ -68,6 +76,8 @@ const ko = sidebar("/ko", { start: "시작하기", setup: "앱에 Necto 연결하기", install: "Necto 앱 설치하기", + security: "보안", + connectionSecurity: "연결 인증과 키체인 등록", tutorial: "플러그인을 추가하고 싶다면", tutorialDesktop: "1부 — 데스크톱 플러그인", tutorialDevice: "2부 — 디바이스 플러그인", diff --git a/docs/architecture.md b/docs/architecture.md index 52036dc..128822f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,6 +90,13 @@ stable one for itself. A hello with a different protocol version is rejected. Necto does not negotiate protocol versions. +An SDK configured with `start(publicKey:)` first sends public `NectoSecurityOffer` +metadata, then requires TLS 1.3 with a pinned host public key before the normal hello. +The Mac selects a Keychain credential by bundle ID. Refused sessions are displayed +separately from connected targets and never reach plugin registration or dispatch. +SDKs configured with `start()` keep the existing transport. See +[connection security](connection-security.md) for roles and trust boundaries. + ## Plugin identity `NectoApp` owns one `NectoAppModel`, including the registry, control server, installer, diff --git a/docs/connection-security.md b/docs/connection-security.md new file mode 100644 index 0000000..66b5497 --- /dev/null +++ b/docs/connection-security.md @@ -0,0 +1,236 @@ +# Optional connection authentication + +Keep Necto out of customer Release builds. Authentication is an additional control +for development and internal builds, not a substitute for excluding the SDK. + +## App configuration + +The package requires Swift 6.1 or later. + +```swift +#if DEBUG +NectoSDK.start(publicKey: "") +#endif +``` + +The String is the Base64 encoding of a 65-byte ANSI X9.63 uncompressed P-256 public +key (the output of `SecKeyCopyExternalRepresentation` for that public key). +It is public information and can be checked into the app repository. An invalid +String stops the listener and reports a failed SDK status; it never enables a +plaintext connection. + +`NectoSDK.start()` keeps the existing unencrypted protocol. Both entry points choose an available +port internally from 9979 through 9986; the public `port:` parameter is removed. + +Authentication belongs to each connection, identified by device and app bundle ID. +An unconfigured app on the same device continues to work normally. The Keychain +credential belongs to the bundle ID: the same app on several devices can use one +credential, but every connection performs its own TLS handshake. Builds with the +same bundle ID must use the same pinned key to share that Mac credential. + +Different bundle IDs may also share a key. Call `install` for each bundle ID with +the same private key and matching certificate. Each app gets its own registration; +the private key is stored once. An unregistered bundle ID cannot use it. + +## Registering a key in Mac Keychain + +### 1. Prepare the matching key and certificate + +Use the following values to register the credential. The private key must match +the public key configured in the app. Keep it out of the SDK and public repository. + +| Value | Where it is used | +| --- | --- | +| App bundle ID, such as `com.example.internal-app` | The app's actual bundle ID and the Mac registration must match. | +| Base64 P-256 public key | Pass it to `NectoSDK.start(publicKey:)` in the app. | +| P-256 private key in PEM format | Give it only to the private setup tool used by authorized developers. | +| Matching certificate, DER encoded as a Base64 String | The setup tool registers it together with the private key. | +| Installed Necto executable path | Grant that executable access to the key. | + +A matching self-signed certificate is sufficient; no CA service is required. The +public key returned by `store.identity(bundleID:)` below is in the SDK's expected +format. The setup tool may receive its key and certificate as Strings; users do +not need to manage certificate files. + +### 2. Register the credential in Mac Keychain + +Install Necto first. Use the executable inside the app bundle, normally +`/Applications/Necto.app/Contents/MacOS/Necto`, as the trusted application. When +testing a locally built GUI, use that build's executable path instead. + +Implement key registration in a setup tool that fits your project. The example +uses `NectoMacService` from the `NectoMac` subdirectory package. Adjust the package +path to your local checkout. + +```swift +// In the private setup tool's Package.swift: +dependencies: [ + .package(path: "/path/to/necto/NectoMac"), +], +targets: [ + .executableTarget( + name: "CompanyNectoSetup", + dependencies: [.product(name: "NectoMacService", package: "NectoMac")] + ), +] +``` + +Replace the placeholders with the matching values and run the code in your setup +tool. Do not generate a different key on each Mac. + +```swift +import Foundation +import NectoMacService + +let bundleID = "com.example.internal-app" +let privateKeyString = "" +let certificateString = "" +guard let certificateData = Data(base64Encoded: certificateString) else { + fatalError("Invalid Base64 certificate") +} + +let store = try NectoKeychainCredentialStore() +try store.install( + bundleID: bundleID, + privateKeyPEM: Data(privateKeyString.utf8), + certificateDER: certificateData, + trustedApplications: [ + URL(fileURLWithPath: "/Applications/Necto.app/Contents/MacOS/Necto") + ] +) +if let identity = try store.identity(bundleID: bundleID) { + print("SDK publicKey: \(identity.publicKey)") +} +``` + +The printed value is public. Set it in the app's `NectoSDK.start(publicKey:)` and +rebuild the app if it was not configured already. Never print the private key. +Distribute any setup tool containing the private key only to authorized users. + +### 3. Check the registered items + +The installer imports the private key as sensitive and non-extractable, allows +signing, and grants access to the installer and specified Necto executable. Open +**Keychain Access**, select the default keychain (usually **login**) and **All +Items**, then search for the following names: + +| Item | Lookup | +| --- | --- | +| Private key | Label: `Necto Connection: ` | +| Certificate | Label: `Necto Certificate: ` | +| App registration | Service: `im.necto.connection.certificate`; account: `` | + +For example, the private key for `com.example.internal-app` is named +`Necto Connection: com.example.internal-app`. If another app shares this key, the +key keeps its original name and access permissions. The matching certificate is +also shared. Search for the registration's service name to check each app's bundle ID. +The registration stores a certificate reference in its public attributes. Neither +the registration lookup nor the certificate read needs permission to use the private +key; Keychain approval applies when Necto signs with that key. +Creating a password entry with the same name does not install a private key; use +the registration API so the key type and executable permissions are correct. + +If the items do not appear, check whether the setup tool actually ran successfully. +Installing the GUI alone does not create them. Other bundle IDs have no fallback +credential. Duplicate installation is rejected and does not overwrite the old +key; the provisioning tool owns replacement and removal policy. +When removing one app's registration, keep a shared private key and certificate while +other apps still use them. Reusing a key preserves its access permissions. The installer and +specified Necto executable must already have access; otherwise registration fails. +The provisioning tool must handle any change to the shared key's permissions separately. + +### 4. Confirm the connection + +Necto reads the credential on each connection attempt, so installing a missing key +does not require restarting Necto or the SDK. The CLI talks to Necto's existing +local control socket; only the GUI process needs the connection key. + +```bash +necto-cli device list --json +necto-cli plugin list --device --app com.example.internal-app --json +``` + +Take `` from the first command. The app should have `status: "connected"` +and its plugins should be listed. While `status` is `unauthorized`, plugin list, +help, commands and subscriptions return `UNAUTHORIZED`; listing devices remains +available. + +| Reason | What to check | +| --- | --- | +| `missingKey` | Register the key for this app's exact bundle ID in the Mac user's default keychain. | +| `rejectedKey` | Check the SDK public key and Mac private key are a pair; also check the connection if TLS still fails. | +| `credentialUnavailable` | Check that the default keychain is unlocked, the registration, certificate and private key exist, and the running Necto executable has access. | + +## Connection behavior + +1. The SDK sends `NectoSecurityOffer` with public discovery information: bundle ID, + app/device names, OS version, and simulator ID when available. +2. The host selects that bundle's Keychain identity and upgrades the existing USB + or loopback socket to TLS 1.3. The socket-connecting Mac is the **TLS server**; + the socket-listening SDK is the **TLS client**, verifying the pinned public key. +3. Only after TLS succeeds do `NectoHandshakeHello`, acknowledgement, plugin + registrations, commands, results, events, and panel data cross the session. + +The TLS implementation is SwiftNIO SSL. The private key stays in Keychain; TLS +signatures use `SecKeyCreateSignature` through `NIOSSLCustomPrivateKey`. Failed +authentication closes the session and never falls back to plaintext. A plaintext +host cannot enable a protected SDK by sending a normal handshake acknowledgement. + +Protected connections allow up to two minutes for the TLS handshake, including +Keychain approval. Requests that share a private key sign one at a time; a queued +request for a closed connection is discarded before accessing the key. Discovery +and post-authentication messages keep their ordinary network deadlines. + +The sidebar keeps a refused app visible with a red **Unauthorized** indicator and +a tooltip explaining a missing, rejected, or unreadable key. Clicking the row opens +setup guidance without selecting it as a connected target. The guidance explains +that both GUI and CLI debugging are blocked and that Necto retries automatically +after the key is configured. `necto-cli device list --json` reports `status: "unauthorized"` +and a reason; plugin discovery, commands, and subscriptions fail with `UNAUTHORIZED`. +Other connected apps and desktop plugins remain usable. + +## Protection scope + +Apps configured with a public key receive two protections: + +- **Access control:** Without the matching private key, neither the GUI nor CLI + can debug that app. Failed authentication closes the connection. +- **Encrypted communication:** After authentication, plugin information, data, + and commands travel inside TLS 1.3. Authentication failure never falls back to + an unencrypted connection. + +The app checks that the Mac holds the matching private key. + +Distribute the private key only to authorized users. A shared key cannot block +one user or device separately. If it leaks, replace both the app's public key and +the private key on authorized Macs. + +::: details Encryption scope and key management details + +App names, bundle IDs, device names, and other discovery fields are sent before +authentication and are not encrypted. They must not contain sensitive debugging +data. The separate GUI–CLI Unix control socket does not use TLS. + +The SDK checks the certificate's public key against the configured key. It does +not check the issuer, hostname, or expiration date. Reissuing a certificate with +the same key keeps the app's public key setting valid. + +Non-extractable Keychain storage blocks normal private key export APIs. It does +not hide the original key in a setup tool, prevent an authorized process from +signing, or protect against a compromised Mac. Distribute tools containing the +key only to authorized users, even if the tools are obfuscated. + +::: + +## Verification + +`script/test swift` runs transport, TLS, Keychain, production SDK/host integration, +and CLI tests. Security tests use temporary keys and Keychains, real sockets, and a +separate trusted signing executable. They cover missing/wrong keys, plaintext +imitation, ciphertext tampering/replay, non-extractable keys, cancellation, legacy +connections, mixed protected/unprotected apps, and reconnecting after installation. + +For a simulator check, launch ExampleApp with `SIMCTL_CHILD_NECTO_PUBLIC_KEY` set +to a test public key. The example's Start listening button preserves that setting. +Use an isolated app bundle ID and remove its test Keychain entries afterward. +Simulator tests do not verify the physical USB path. diff --git a/docs/design/gallery.js b/docs/design/gallery.js index 4d2190a..2f334ac 100644 --- a/docs/design/gallery.js +++ b/docs/design/gallery.js @@ -245,6 +245,10 @@ const sidebar = (onSettings = false) => ` ${phoneIcon}iPad Pro 11" iOS 27.0 + @@ -262,6 +266,17 @@ const sidebar = (onSettings = false) => ` `; +document.addEventListener("click", (event) => { + if (!event.target.closest(".gallery-unauthorized")) return; + window.alert([ + "Authentication required", + "Necto Example\nim.toss.necto.example", + "Install this app's connection key on your Mac to start debugging.", + "Debugging this app is blocked in both Necto and the CLI.", + "Ask the app's maintainer how to set up its connection key on this Mac. Necto reconnects automatically once the correct key is available.", + ].join("\n\n")); +}); + const pluginSearch = document.createElement("div"); pluginSearch.id = "gallery-plugin-search-popover"; pluginSearch.className = "gallery-plugin-search-popover"; diff --git a/docs/install.md b/docs/install.md index c13560b..999235d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -28,7 +28,7 @@ compromised repository account; the release repository is the trust source. ## Source build requirements -- macOS 14 or later, with an Xcode toolchain that provides Swift 6.0 or later. +- macOS 14 or later, with an Xcode toolchain that provides Swift 6.1 or later. - Node.js 22.12.0 or later and Yarn 4.6.0 to run `script/build`. ## Build and launch diff --git a/docs/ko/connection-security.md b/docs/ko/connection-security.md new file mode 100644 index 0000000..2f91f34 --- /dev/null +++ b/docs/ko/connection-security.md @@ -0,0 +1,201 @@ +# 연결 인증과 키체인 등록 + +연결 인증을 켜면 **Mac에 올바른 키를 등록해야 앱을 디버깅할 수 있어요.** +앱에는 공개키를 넣고 Mac에는 개인키를 등록해요. 두 키는 짝이 맞아야 해요. +연결되면 디버깅 데이터와 명령은 TLS 1.3으로 암호화해요. + +고객에게 배포하는 Release 빌드에는 SDK를 포함하지 않는 것이 기본이에요. +연결 인증은 개발용·사내 배포용 앱에 추가하는 보호 장치예요. + +## 1. 앱에 공개키 설정하기 + +Swift 6.1 이상이 필요해요. 공개키는 P-256이고 X9.63 형식으로 내보내면 +65바이트예요. 이 값을 Base64로 인코딩해 String으로 사용해요. +아래 등록 코드를 실행하면 `SDK publicKey` 값을 출력해요. +이 값을 그대로 사용할 수 있어요. + +```swift +#if DEBUG +NectoSDK.start(publicKey: "") +#endif +``` + +공개키는 앱 저장소에 넣어도 돼요. 개인키는 넣으면 안 돼요. +잘못된 공개키를 설정하면 SDK는 연결을 받지 않아요. + +`NectoSDK.start()`를 쓰는 앱은 기존 방식으로 연결돼요. +이 연결에는 인증과 암호화가 적용되지 않아요. 포트는 SDK가 9979~9986 중에서 +자동으로 선택하므로 직접 지정할 필요가 없어요. + +## 2. Mac에 등록할 값 준비하기 + +Mac의 개인키는 앱에 설정한 공개키와 짝이 맞아야 해요. +Mac마다 새 키를 만들면 연결할 수 없어요. + +여러 앱에서 같은 키를 써도 돼요. 같은 개인키와 인증서로 각 앱의 `bundleID`를 +등록하세요. 개인키는 한 번만 저장하고 앱별 등록 정보는 따로 보관해요. +등록하지 않은 `bundleID`에는 이 키를 사용하지 않아요. + +| 값 | 용도 | +| --- | --- | +| 앱의 `bundleID` | 어떤 앱의 키인지 구분해요. 실제 앱의 값과 같아야 해요. | +| P-256 개인키의 PEM String | Mac이 연결할 권한을 증명할 때 사용해요. 허용된 사용자에게만 배포하세요. | +| 같은 키의 인증서 | DER 데이터를 Base64 String으로 전달해요. 자체 서명 인증서로 충분하며 CA 서버는 필요 없어요. | +| Necto 실행 파일 경로 | 이 실행 파일에 키 사용 권한을 줘요. | + +인증서와 개인키를 String으로 전달할 수 있으므로 사용자가 별도 인증서 파일을 +관리할 필요는 없어요. + +키체인 등록은 **`bundleID` 기준**이에요. 같은 앱이 여러 iPhone에 있어도 +Mac에는 한 번 등록하면 돼요. 인증 성공 여부는 각 기기의 앱 연결에서 따로 +확인해요. 같은 `bundleID`의 빌드끼리 키를 공유하려면 공개키도 같아야 해요. + +## 3. Mac 키체인에 등록하기 + +먼저 Mac에 Necto를 설치하세요. 일반적인 실행 파일 경로는 +`/Applications/Necto.app/Contents/MacOS/Necto`예요. 로컬 빌드를 실행한다면 +그 빌드 안의 `Necto` 실행 파일 경로를 사용해야 해요. + +키 등록 도구는 프로젝트 환경에 맞게 구현하세요. 아래 예제는 `NectoMac` 하위 +패키지의 `NectoMacService`를 사용해요. 패키지 경로는 로컬 체크아웃에 맞게 +수정하세요. + +```swift +// In the private setup tool's Package.swift: +dependencies: [ + .package(path: "/path/to/necto/NectoMac"), +], +targets: [ + .executableTarget( + name: "CompanyNectoSetup", + dependencies: [.product(name: "NectoMacService", package: "NectoMac")] + ), +] +``` + +예시의 String을 등록할 값으로 바꾸고 설정 도구에서 실행하세요. +개인키가 포함된 설정 도구는 공개 저장소에 올리지 마세요. + +```swift +import Foundation +import NectoMacService + +let bundleID = "com.example.internal-app" +let privateKeyString = "" +let certificateString = "" +guard let certificateData = Data(base64Encoded: certificateString) else { + fatalError("Invalid Base64 certificate") +} + +let store = try NectoKeychainCredentialStore() +try store.install( + bundleID: bundleID, + privateKeyPEM: Data(privateKeyString.utf8), + certificateDER: certificateData, + trustedApplications: [ + URL(fileURLWithPath: "/Applications/Necto.app/Contents/MacOS/Necto") + ] +) +if let identity = try store.identity(bundleID: bundleID) { + print("SDK publicKey: \(identity.publicKey)") +} +``` + +출력된 공개키를 앱에 설정하고 다시 빌드하세요. 이미 같은 공개키가 설정되어 +있다면 앱을 다시 빌드할 필요는 없어요. 개인키는 출력하지 마세요. + +등록 API는 개인키를 일반적인 내보내기 API로 추출할 수 없게 저장하고 +설정 도구와 지정한 Necto 실행 파일에 사용 권한을 줘요. + +## 4. 키체인에서 등록 확인하기 + +Mac의 **키체인 접근** 앱을 열고 기본 키체인(보통 **로그인**)에서 +**모든 항목**을 선택하세요. 다음 이름으로 각각 검색하면 돼요. + +| 항목 | 검색할 이름 | +| --- | --- | +| 개인키 | `Necto Connection: <처음 등록한 앱의 bundleID>` | +| 인증서 | `Necto Certificate: <처음 등록한 앱의 bundleID>` | +| 앱 등록 정보 | `im.necto.connection.certificate` — 계정 값이 ``인지 확인하세요. | + +예를 들어 앱의 `bundleID`가 `com.example.internal-app`이면 개인키 이름은 +`Necto Connection: com.example.internal-app`이에요. +다른 앱이 같은 키를 쓰더라도 개인키 이름은 바뀌지 않아요. +인증서도 함께 사용해요. 각 앱의 등록 여부는 앱 등록 정보의 계정 값으로 확인하세요. + +같은 Necto 실행 파일에 앱을 추가할 때는 기존 키의 접근 권한을 그대로 사용해요. +설정 도구나 Necto 실행 파일에 기존 키를 쓸 권한이 없으면 등록에 실패해요. +권한 변경은 설정 도구에서 별도로 처리하세요. +앱 하나의 등록을 삭제하더라도 다른 앱이 쓰고 있는 개인키와 인증서는 지우지 마세요. + +인증서와 앱 등록 정보는 공개 정보라서 읽을 때 승인이 필요하지 않아요. +개인키로 서명할 때 키체인 사용 승인을 받아요. 같은 이름으로 암호 항목을 +직접 만들어도 개인키는 등록되지 않으니 위 등록 API를 사용하세요. + +아무것도 나오지 않으면 설정 도구가 성공했는지 확인하세요. +Necto 앱만 설치한 상태에서는 이 항목들이 없어요. + +## 5. 연결 확인하기 + +키를 등록하면 Necto가 다음 연결 시도에 읽어 사용해요. Necto나 iPhone 앱을 +다시 실행할 필요는 없어요. GUI와 CLI 중 어느 쪽을 사용하든 Mac의 Necto 앱이 +실행 중이어야 해요. + +인증 연결은 키체인 승인 시간을 포함해 최대 2분 동안 기다려요. 여러 앱이나 +기기가 같은 개인키를 쓰면 서명 요청을 하나씩 처리해요. + +```bash +necto-cli device list --json +necto-cli plugin list --device --app com.example.internal-app --json +``` + +첫 번째 명령에서 ``를 확인해 두 번째 명령에 넣으세요. +해당 앱이 `connected` 상태이고 플러그인 목록이 나오면 연결된 거예요. + +인증 전에는 앱 목록에 `unauthorized`가 표시돼요. 해당 앱의 플러그인 조회, +도움말, 명령 실행, 구독은 모두 `UNAUTHORIZED`로 거부돼요. +다른 앱과 데스크톱 플러그인은 계속 사용할 수 있어요. + +## 연결되지 않을 때 + +Necto의 **인증되지 않음** 행을 누르면 이유를 확인할 수 있어요. + +| 이유 | 확인할 것 | +| --- | --- | +| `missingKey` | 해당 앱의 정확한 `bundleID`로 Mac 사용자의 기본 키체인에 키를 등록했는지 확인하세요. | +| `rejectedKey` | 앱의 공개키와 Mac의 개인키가 짝이 맞는지 확인하세요. 맞는데도 TLS 연결에 실패하면 연결 상태도 확인하세요. | +| `credentialUnavailable` | 기본 키체인이 잠겨 있지 않은지, 앱 등록 정보·인증서·개인키가 모두 있는지, 실행 중인 Necto에 키 사용 권한이 있는지 확인하세요. | + +등록을 다시 실행해 중복 오류가 나면 기존 키는 그대로 남아 있어요. +교체가 필요하면 설정 도구는 자신이 등록한 항목을 삭제한 뒤 다시 등록하거나 +접근 권한을 갱신해야 해요. `install`을 다시 호출해도 기존 키를 덮어쓰지는 않아요. + +## 보호 범위 + +공개키를 설정한 앱에는 두 가지 보호가 적용돼요. + +- **접근 제한:** 올바른 개인키가 없으면 GUI와 CLI 모두 해당 앱을 디버깅할 수 + 없어요. 인증에 실패하면 연결을 종료해요. +- **통신 암호화:** 인증 후에는 플러그인 정보, 데이터, 명령을 TLS 1.3으로 + 암호화해서 주고받아요. 인증에 실패했다고 암호화 없는 연결을 허용하지는 않아요. + +앱은 접속한 Mac이 올바른 개인키를 가졌는지 확인해요. + +개인키는 허용된 사용자에게만 배포하세요. 같은 키를 공유하면 특정 사용자만 +차단할 수 없어요. 키가 유출되면 앱의 공개키와 Mac의 개인키를 함께 교체해야 해요. + +::: details 암호화 범위와 키 관리 세부 사항 + +앱 이름, `bundleID`, 기기 이름 등은 연결할 앱을 찾을 때 써요. +이 정보는 인증 전에 전달하므로 암호화하지 않아요. Mac의 GUI와 CLI는 +로컬 소켓으로 통신하며 여기에 TLS를 추가하지 않아요. + +SDK는 인증서의 공개키가 앱에 설정한 값과 일치하는지 확인해요. 인증서의 +발급자, 호스트 이름, 만료일은 확인하지 않아요. 인증서만 갱신하고 키를 +유지하면 앱의 공개키 설정도 유지할 수 있어요. + +키체인은 저장된 개인키의 내보내기를 막아요. 설정 도구 속의 개인키 +원본까지 숨겨주지는 않아요. 난독화한 도구라도 개인키가 들어 있다면 +허용된 사용자에게만 배포하세요. + +::: diff --git a/docs/ko/install.md b/docs/ko/install.md index 1442a63..9e049f9 100644 --- a/docs/ko/install.md +++ b/docs/ko/install.md @@ -28,7 +28,7 @@ GitHub가 해당 파일에 표시하는 SHA-256 값과 비교하세요. 체크 ## 소스 빌드 요구 사항 -- macOS 14 이상, Swift 6.0 이상을 제공하는 Xcode 툴체인이 필요해요. +- macOS 14 이상, Swift 6.1 이상을 제공하는 Xcode 툴체인이 필요해요. - `script/build`를 실행하려면 Node.js 22.12.0 이상과 Yarn 4.6.0도 필요해요. ## 빌드하고 실행하기 diff --git a/docs/ko/setup.md b/docs/ko/setup.md index 4332021..bf9adf2 100644 --- a/docs/ko/setup.md +++ b/docs/ko/setup.md @@ -68,6 +68,10 @@ Swift 패키지의 소스를 제외할 수는 없어요. ## 연결 방식 +앱에 `NectoSDK.start(publicKey:)`를 설정하면 올바른 키를 등록한 Mac만 +연결할 수 있어요. 앱 설정과 Mac 키체인 등록 방법은 +[연결 인증과 키체인 등록](connection-security.md)을 참고하세요. + - **시뮬레이터** — Mac이 루프백으로 앱에 접근해요. - **실기기** — USB 케이블로 연결하면 `usbmuxd`를 거쳐요. - SDK는 로컬 TCP 포트에서 수신 대기해요. 다른 시뮬레이터 앱이 포트를 쓰고 diff --git a/docs/plugin-manifest.md b/docs/plugin-manifest.md index e623a26..ab2af57 100644 --- a/docs/plugin-manifest.md +++ b/docs/plugin-manifest.md @@ -331,6 +331,7 @@ before it is delivered to the caller. | `OPERATION_UNAVAILABLE` | Not usable in the current state or surface | | `PERMISSION_DENIED` | The caller's identity or shell policy does not allow the request | | `TARGET_DISCONNECTED` | No connected app is selected, or it disconnected | +| `UNAUTHORIZED` | The selected app requires a valid Mac connection key | | `TIMEOUT` | The provider exceeded `timeoutMs` | | `CANCELLED` | The call was cancelled | | `PROVIDER_FAILED` | The provider raised an error | diff --git a/docs/setup.md b/docs/setup.md index c4dea67..426642f 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -70,6 +70,10 @@ dependencies and inspect the actual Release output for remaining code and resour ## What connecting involves +Internal apps can require a provisioned Mac key with `NectoSDK.start(publicKey:)`. +See [optional connection authentication](connection-security.md) for setup, TLS, +compatibility, and security boundaries. + - **Simulator**: the Mac reaches the app over loopback. - **Device**: over the USB cable, through `usbmuxd`. - The SDK listens on a local TCP port. If another simulator app holds it, the SDK diff --git a/docs/verification.md b/docs/verification.md index 8b28032..e8b72c4 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -105,8 +105,7 @@ xcrun simctl launch im.toss.necto.example The app shows the listening port, and the Mac app lists it under the sidebar within a couple of seconds. `lsof -nP -iTCP:9979-9986 -sTCP:LISTEN` checks the default SDK -port range when it does not appear. If you configured another base port, check that -range instead. +port range when it does not appear. The public SDK API selects its port automatically. For concurrent connections, launch SDK-enabled apps on two booted simulators. The SDK chooses a free port within the eight-port range and the Mac probes all eight. diff --git a/script/licenses.mjs b/script/licenses.mjs index 7db301b..c2af030 100644 --- a/script/licenses.mjs +++ b/script/licenses.mjs @@ -31,12 +31,29 @@ async function notices(sections) { export async function writeAppNotices(app) { const peerTalkLicense = await readFile(join(root, "LICENSES/PeerTalk.txt"), "utf8"); const swiftLicense = await readFile(join(root, "NectoMac/.build/checkouts/swift-argument-parser/LICENSE.txt"), "utf8"); + const boringSSLRevision = "817ab07ebb53da35afea409ab9328f578492832d"; + const sslManifest = await readFile(join(root, "NectoMac/.build/checkouts/swift-nio-ssl/Package.swift"), "utf8"); + // swift-nio-ssl links to BoringSSL's license but omits its text from the checkout. + if (!sslManifest.includes(`// BoringSSL Commit: ${boringSSLRevision}\n`)) { + throw new Error("BoringSSL revision changed. Review and update its supplemental license before packaging."); + } + const boringSSLLicense = await readFile(join(root, "script/licenses/BoringSSL-817ab07.txt"), "utf8"); + const transportLicenses = await Promise.all([ + "swift-nio", "swift-nio-ssl", "swift-atomics", "swift-collections", "swift-system", + ].map(async name => { + const directory = join(root, "NectoMac/.build/checkouts", name); + const license = await readFile(join(directory, "LICENSE.txt"), "utf8"); + const notice = ["swift-nio", "swift-nio-ssl"].includes(name) + ? await readFile(join(directory, "NOTICE.txt"), "utf8") : ""; + return `${name}\n\n${license}\n${notice}`; + })); const vite = await packageNotice(dirname(require.resolve("vite/package.json"))); const resources = join(app, "Contents/Resources"); await mkdir(resources, { recursive: true }); await writeFile(join(resources, "THIRD_PARTY_NOTICES.txt"), await notices([ `PeerTalk — https://github.com/rsms/peertalk\n\n${peerTalkLicense}`, - `swift-argument-parser\n\n${swiftLicense}`, vite, + `swift-argument-parser\n\n${swiftLicense}`, ...transportLicenses, + `BoringSSL ${boringSSLRevision}\n\n${boringSSLLicense}`, vite, ])); } diff --git a/script/licenses/BoringSSL-817ab07.txt b/script/licenses/BoringSSL-817ab07.txt new file mode 100644 index 0000000..3aa5aa6 --- /dev/null +++ b/script/licenses/BoringSSL-817ab07.txt @@ -0,0 +1,252 @@ +BoringSSL is a fork of OpenSSL. As such, large parts of it fall under OpenSSL +licensing. Files that are completely new have a Google copyright and an ISC +license. This license is reproduced at the bottom of this file. + +Contributors to BoringSSL are required to follow the CLA rules for Chromium: +https://cla.developers.google.com/clas + +Files in third_party/ have their own licenses, as described therein. The MIT +license, for third_party/fiat, which, unlike other third_party directories, is +compiled into non-test libraries, is included below. + +The OpenSSL toolkit stays under a dual license, i.e. both the conditions of the +OpenSSL License and the original SSLeay license apply to the toolkit. See below +for the actual license texts. Actually both licenses are BSD-style Open Source +licenses. In case of any license issues related to OpenSSL please contact +openssl-core@openssl.org. + +The following are Google-internal bug numbers where explicit permission from +some authors is recorded for use of their work. (This is purely for our own +record keeping.) + 27287199 + 27287880 + 27287883 + 263291445 + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */ + + +ISC license used for completely new code in BoringSSL: + +/* Copyright 2015 The BoringSSL Authors + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ + + +The code in third_party/fiat carries the MIT license: + +Copyright (c) 2015-2016 the fiat-crypto authors (see +https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS). + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +Licenses for support code +------------------------- + +Parts of the TLS test suite are under the Go license. This code is not included +in BoringSSL (i.e. libcrypto and libssl) when compiled, however, so +distributing code linked against BoringSSL does not trigger this license: + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +BoringSSL uses the Chromium test infrastructure to run a continuous build, +trybots etc. The scripts which manage this, and the script for generating build +metadata, are under the Chromium license. Distributing code linked against +BoringSSL does not trigger this license. + +Copyright 2015 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/script/licenses/README.md b/script/licenses/README.md index 23fa98b..5da2b5a 100644 --- a/script/licenses/README.md +++ b/script/licenses/README.md @@ -1,9 +1,13 @@ # Supplemental licenses -These upstream texts cover assets whose npm packages do not include a license file: +These upstream texts cover bundled dependencies and assets whose packages omit +the required license files: - `DocSearch-3.8.2.txt`: [`@docsearch/css` 3.8.2](https://github.com/algolia/docsearch/blob/v3.8.2/LICENSE). - `Inter.txt`: [Inter](https://github.com/rsms/inter/blob/master/LICENSE.txt), bundled by VitePress as WOFF2 fonts. +- `BoringSSL-817ab07.txt`: [BoringSSL revision 817ab07](https://raw.githubusercontent.com/google/boringssl/817ab07ebb53da35afea409ab9328f578492832d/LICENSE), vendored by `swift-nio-ssl` 2.37.5. Includes its OpenSSL, SSLeay, ISC and fiat-crypto MIT terms. `script/licenses.mjs` reads other licenses from the installed dependencies. Review -these copies when upgrading DocSearch or VitePress; builds do not download licenses. +these copies when upgrading DocSearch, VitePress or `swift-nio-ssl`; builds do not +download licenses. App packaging stops if the vendored BoringSSL revision no longer +matches this copy. diff --git a/script/tests/package.test.mjs b/script/tests/package.test.mjs index e274f0e..859d83c 100644 --- a/script/tests/package.test.mjs +++ b/script/tests/package.test.mjs @@ -33,7 +33,8 @@ test("default plugin interfaces do not pull in capture implementations", () => { const name = pending.pop(); if (visited.has(name)) continue; visited.add(name); - pending.push(...dependencies(name)); + pending.push(...dependencies(name).filter(dependency => + manifest.targets.some(target => target.name === dependency))); } for (const capture of ["NectoURLSessionCapture", "NectoProcessMetrics"]) { assert.ok(!visited.has(capture), `${capture} must remain an opt-in implementation`); diff --git a/script/tests/release.test.mjs b/script/tests/release.test.mjs index e3c19e8..534dcfe 100644 --- a/script/tests/release.test.mjs +++ b/script/tests/release.test.mjs @@ -70,7 +70,7 @@ function fakeTool() { throw new Error(`Unexpected release command: ${name} ${args.join(" ")}`); } -function runRelease(t, { flags = [], env = {}, expectedStatus = 0, externalScript = false } = {}) { +function runRelease(t, { flags = [], env = {}, expectedStatus = 0, externalScript = false, boringSSLRevision = "817ab07ebb53da35afea409ab9328f578492832d" } = {}) { const root = mkdtempSync(join(tmpdir(), "necto-release test-")); t.after(() => rmSync(root, { recursive: true, force: true })); for (const path of ["script", "bin", "tmp", "Build/bin", "Build/Products/Necto.app/Contents/MacOS"]) { @@ -78,11 +78,20 @@ function runRelease(t, { flags = [], env = {}, expectedStatus = 0, externalScrip } copyFileSync(new URL("../release", import.meta.url), join(root, "script/release")); copyFileSync(new URL("../licenses.mjs", import.meta.url), join(root, "script/licenses.mjs")); + mkdirSync(join(root, "script/licenses")); + copyFileSync(new URL("../licenses/BoringSSL-817ab07.txt", import.meta.url), join(root, "script/licenses/BoringSSL-817ab07.txt")); copyFileSync(new URL("../../LICENSE", import.meta.url), join(root, "LICENSE")); mkdirSync(join(root, "LICENSES")); copyFileSync(new URL("../../LICENSES/PeerTalk.txt", import.meta.url), join(root, "LICENSES/PeerTalk.txt")); mkdirSync(join(root, "NectoMac/.build/checkouts/swift-argument-parser"), { recursive: true }); writeFileSync(join(root, "NectoMac/.build/checkouts/swift-argument-parser/LICENSE.txt"), "fixture Swift license\n"); + for (const name of ["swift-nio", "swift-nio-ssl", "swift-atomics", "swift-collections", "swift-system"]) { + const directory = join(root, "NectoMac/.build/checkouts", name); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "LICENSE.txt"), `fixture ${name} license\n`); + if (["swift-nio", "swift-nio-ssl"].includes(name)) writeFileSync(join(directory, "NOTICE.txt"), `fixture ${name} notice\n`); + } + writeFileSync(join(root, "NectoMac/.build/checkouts/swift-nio-ssl/Package.swift"), `// BoringSSL Commit: ${boringSSLRevision}\n`); mkdirSync(join(root, "node_modules/vite"), { recursive: true }); writeFileSync(join(root, "node_modules/vite/package.json"), JSON.stringify({ name: "vite", version: "1.0.0" })); writeFileSync(join(root, "node_modules/vite/LICENSE.md"), "fixture Vite license\n"); @@ -125,6 +134,12 @@ function runRelease(t, { flags = [], env = {}, expectedStatus = 0, externalScrip function verifyArtifacts(root) { assert.equal(readFileSync(join(root, "Build/Products/Necto.app/Contents/Resources/NectoMac_necto-cli.bundle/Skills/necto/SKILL.md"), "utf8"), "fixture skill\n"); + const notices = readFileSync(join(root, "Build/Products/Necto.app/Contents/Resources/THIRD_PARTY_NOTICES.txt"), "utf8"); + const boringSSLLicense = readFileSync(new URL("../licenses/BoringSSL-817ab07.txt", import.meta.url), "utf8"); + for (const term of ["OpenSSL License", "Original SSLeay License", "ISC license", "fiat carries the MIT license"]) { + assert.ok(boringSSLLicense.includes(term), `BoringSSL supplemental license must include ${term}`); + } + assert.ok(notices.includes(boringSSLLicense), "the app must contain the complete vendored BoringSSL license"); for (const name of artifacts) assert.ok(readFileSync(join(root, "Build", name)).length > 0); const hash = createHash("sha256").update(readFileSync(join(root, "Build", artifacts[0]))).digest("hex"); assert.equal(readFileSync(join(root, "Build", `${artifacts[0]}.sha256`), "utf8"), `${hash} ${artifacts[0]}\n`); @@ -148,6 +163,12 @@ test("a dry run builds the app, checksum and web packages without publishing", ( ); }); +test("a changed BoringSSL revision prevents publishing with stale license notices", (t) => { + const root = runRelease(t, { boringSSLRevision: "different-revision", expectedStatus: 1 }); + assert.equal(readFileSync(join(root, "publications.jsonl"), "utf8"), ""); + assert.equal(existsSync(join(root, "Build", artifacts[0])), false); +}); + test("a release publishes the tag and artifacts to the official repository", (t) => { const root = runRelease(t); verifyArtifacts(root); From a83be81272f26960b68bfc116b66469d79467a75 Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:45:24 +0900 Subject: [PATCH 2/9] Fix TLS EOF handling and add simulator authentication coverage --- Necto.xcodeproj/project.pbxproj | 10 +- NectoE2ETests/AppFixture.swift | 35 +++--- NectoE2ETests/ConnectionCredentials.swift | 107 ++++++++++++++++++ NectoE2ETests/ConnectionTests.swift | 65 +++++++++++ NectoE2ETests/SecurityConnectionHost.swift | 95 ++++++++++++++++ .../Security/SecurityFailureTests.swift | 57 ++++++++++ Sources/NectoTransport/NectoTLSStream.swift | 26 ++++- docs/verification.md | 9 ++ 8 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 NectoE2ETests/ConnectionCredentials.swift create mode 100644 NectoE2ETests/SecurityConnectionHost.swift diff --git a/Necto.xcodeproj/project.pbxproj b/Necto.xcodeproj/project.pbxproj index 5ffa37b..80e5645 100644 --- a/Necto.xcodeproj/project.pbxproj +++ b/Necto.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + AE00000000000000000009 /* NectoMacService in Frameworks */ = {isa = PBXBuildFile; productRef = 99A1DE268E90C14804203FA5 /* NectoMacService */; }; AF1000000000000000000018 /* NectoAgentSkills.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1000000000000000000019 /* NectoAgentSkills.swift */; }; AF1000000000000000000016 /* NectoCLIInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1000000000000000000017 /* NectoCLIInstaller.swift */; }; AF1000000000000000000000 /* NectoDevicePluginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1000000000000000000001 /* NectoDevicePluginStore.swift */; }; @@ -52,6 +53,12 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + AE00000000000000000010 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = (AE00000000000000000009 /* NectoMacService in Frameworks */,); + runOnlyForDeploymentPostprocessing = 0; + }; AF00000000000000000006 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -123,11 +130,12 @@ AE00000000000000000003 /* NectoE2ETests */ = { isa = PBXNativeTarget; buildConfigurationList = AE00000000000000000004 /* Build configuration list for PBXNativeTarget "NectoE2ETests" */; - buildPhases = (AE00000000000000000005 /* Sources */,); + buildPhases = (AE00000000000000000005 /* Sources */, AE00000000000000000010 /* Frameworks */,); buildRules = (); dependencies = (); fileSystemSynchronizedGroups = (AE00000000000000000002 /* NectoE2ETests */,); name = NectoE2ETests; + packageProductDependencies = (99A1DE268E90C14804203FA5 /* NectoMacService */,); productName = NectoE2ETests; productReference = AE00000000000000000001 /* NectoE2ETests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; diff --git a/NectoE2ETests/AppFixture.swift b/NectoE2ETests/AppFixture.swift index c1c3879..16c6fcc 100644 --- a/NectoE2ETests/AppFixture.swift +++ b/NectoE2ETests/AppFixture.swift @@ -9,11 +9,12 @@ import Testing @MainActor final class AppFixture { - static let exampleID = "im.toss.necto.e2e.example" + nonisolated static let exampleID = "im.toss.necto.e2e.example" private let products: URL private let executable: URL private let home: URL private let logs: URL + private var publicKey: String? private var simulator: String? private var commands: [Command] = [] @@ -26,11 +27,12 @@ final class AppFixture { // Keep the Unix socket path below sockaddr_un's limit, including on CI runners. home = URL(filePath: "/tmp/necto-e2e-\(UUID().uuidString.prefix(8))") try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) print("E2E command logs: \(logs.path)") } - func start() async throws { + func start(publicKey: String? = nil, hostStart: (@MainActor () async throws -> Void)? = nil) async throws { + self.publicKey = publicKey let running = NSWorkspace.shared.runningApplications.filter { $0.executableURL?.lastPathComponent == "Necto" } try #require(running.isEmpty, "Quit other Necto instances before E2E; they share the SDK's loopback ports.") let host = products.appending(path: "Debug/Necto.app") @@ -55,8 +57,12 @@ final class AppFixture { simulator = id _ = try await run("/usr/bin/xcrun", ["simctl", "install", id, example.path], timeout: .seconds(120)) print("E2E: Example installed") - let hostCommand = try launch(host.appending(path: "Contents/MacOS/Necto"), [], isolated: true) - try await waitForControlSocket(hostCommand) + if let hostStart { + try await hostStart() + } else { + let hostCommand = try launch(host.appending(path: "Contents/MacOS/Necto"), [], isolated: true) + try await waitForControlSocket(hostCommand) + } try await launchExample() } @@ -94,7 +100,10 @@ final class AppFixture { } func launchExample() async throws { - _ = try await run("/usr/bin/xcrun", ["simctl", "launch", try #require(simulator), Self.exampleID], timeout: .seconds(120)) + let environment = publicKey.map { ["SIMCTL_CHILD_NECTO_PUBLIC_KEY": $0] } ?? [:] + let command = try launch(URL(filePath: "/usr/bin/xcrun"), + ["simctl", "launch", try #require(simulator), Self.exampleID], environment: environment) + try await finish(command, timeout: .seconds(120)).requireSuccess() } func terminateExample() async throws { @@ -154,7 +163,7 @@ final class AppFixture { } } - private func wait(_ description: String, timeout: Duration = .seconds(30), until condition: () async throws -> Bool) async throws { + func wait(_ description: String, timeout: Duration = .seconds(30), until condition: () async throws -> Bool) async throws { let deadline = ContinuousClock.now + timeout while !(try await condition()) { guard ContinuousClock.now < deadline else { @@ -208,15 +217,15 @@ final class AppFixture { } } - func launch(_ url: URL, _ arguments: [String], isolated: Bool = false) throws -> Command { + func launch(_ url: URL, _ arguments: [String], isolated: Bool = false, environment overrides: [String: String] = [:]) throws -> Command { let process = Process() process.executableURL = url process.arguments = arguments - if isolated { - var environment = ProcessInfo.processInfo.environment - environment["CFFIXED_USER_HOME"] = home.path - process.environment = environment - } + var environment = ProcessInfo.processInfo.environment + environment.removeValue(forKey: "SIMCTL_CHILD_NECTO_PUBLIC_KEY") + if isolated { environment["CFFIXED_USER_HOME"] = home.path } + environment.merge(overrides) { _, override in override } + process.environment = environment let name = "\(commands.count)-\(url.lastPathComponent)" let output = logs.appending(path: "\(name).stdout") let error = logs.appending(path: "\(name).stderr") diff --git a/NectoE2ETests/ConnectionCredentials.swift b/NectoE2ETests/ConnectionCredentials.swift new file mode 100644 index 0000000..b17383c --- /dev/null +++ b/NectoE2ETests/ConnectionCredentials.swift @@ -0,0 +1,107 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import NectoMacService +import NectoTransport +import Security +import Testing + +enum ConnectionSecurity: String, CaseIterable { + case plaintext, matchingKey, missingKey, mismatchedKey +} + +@MainActor +final class ConnectionCredentials { + let publicKey: String? + let directory: URL + private let keychain: SecKeychain + private let material: (key: Data, certificate: Data)? + private(set) var lookups: [String] = [] + + init(security: ConnectionSecurity) throws { + SecKeychainSetUserInteractionAllowed(false) + let directory = FileManager.default.temporaryDirectory.appending(path: "necto-security-e2e-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + self.directory = directory + let path = directory.appending(path: "connection.keychain-db").path + let password = UUID().uuidString + var created: SecKeychain? + let status = password.withCString { + SecKeychainCreate(path, UInt32(password.utf8.count), $0, false, nil, &created) + } + guard status == errSecSuccess, let created else { + try? FileManager.default.removeItem(at: directory) + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + keychain = created + do { + if security == .plaintext { + material = nil + publicKey = nil + } else { + let generated = try Self.generate(in: directory) + material = generated + let certificate = try #require(SecCertificateCreateWithData(nil, generated.certificate as CFData)) + let key = try #require(SecCertificateCopyKey(certificate)) + publicKey = try #require(SecKeyCopyExternalRepresentation(key, nil) as Data?).base64EncodedString() + } + if security == .matchingKey { + try installMatchingKey() + } else if security == .mismatchedKey { + let other = try Self.generate(in: directory) + try install(other) + } + } catch { + SecKeychainDelete(keychain) + try? FileManager.default.removeItem(at: directory) + throw error + } + } + + func installMatchingKey() throws { + try install(try #require(material)) + } + + func identity(bundleID: String) throws -> NectoTLSIdentity? { + lookups.append(bundleID) + return try NectoKeychainCredentialStore(keychain: keychain).identity(bundleID: bundleID) + } + + private func install(_ material: (key: Data, certificate: Data)) throws { + try NectoKeychainCredentialStore(keychain: keychain).install( + bundleID: AppFixture.exampleID, privateKeyPEM: material.key, + certificateDER: material.certificate + ) + } + + func close() { + #expect(SecKeychainDelete(keychain) == errSecSuccess) + try? FileManager.default.removeItem(at: directory) + } + + private static func generate(in directory: URL) throws -> (key: Data, certificate: Data) { + let key = directory.appending(path: UUID().uuidString + ".pem") + let certificate = directory.appending(path: UUID().uuidString + ".der") + defer { + try? FileManager.default.removeItem(at: key) + try? FileManager.default.removeItem(at: certificate) + } + try openssl(["ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", key.path]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: key.path) + try openssl(["req", "-new", "-x509", "-key", key.path, "-subj", "/CN=Necto-E2E", "-days", "1", "-outform", "DER", "-out", certificate.path]) + return try (Data(contentsOf: key), Data(contentsOf: certificate)) + } + + private static func openssl(_ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/openssl") + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + try #require(process.terminationStatus == 0) + } +} diff --git a/NectoE2ETests/ConnectionTests.swift b/NectoE2ETests/ConnectionTests.swift index 74f9b5c..0d1804c 100644 --- a/NectoE2ETests/ConnectionTests.swift +++ b/NectoE2ETests/ConnectionTests.swift @@ -3,6 +3,8 @@ // import Foundation +import NectoMacService +import NectoModel import Testing @Suite("Simulator connection and CLI", .serialized, .timeLimit(.minutes(15))) @@ -54,6 +56,69 @@ struct ConnectionTests { await app.close() } + @Test("simulator SDK enforces optional public-key authentication", arguments: ConnectionSecurity.allCases) + func securityConnection(security: ConnectionSecurity) async throws { + let credentials = try ConnectionCredentials(security: security) + defer { credentials.close() } + let host = SecurityConnectionHost { bundleID in + guard bundleID == AppFixture.exampleID else { return nil } + return try await credentials.identity(bundleID: bundleID) + } + let app = try AppFixture() + do { + try await app.start(publicKey: credentials.publicKey) { await host.start() } + if security == .missingKey || security == .mismatchedKey { + let reason: NectoUnauthorizedApp.Reason = security == .missingKey ? .missingKey : .rejectedKey + for attempt in 0..<2 { + try await app.wait("SDK authentication rejection") { + await host.center.unauthorizedApps.contains { $0.appBundleID == AppFixture.exampleID && $0.reason == reason } + } + #expect(await host.center.connectedApps.allSatisfy { $0.appBundleID != AppFixture.exampleID }) + #expect(host.plugins.keys.allSatisfy { $0.appBundleID != AppFixture.exampleID }) + let denied = try #require(await host.center.unauthorizedApps.first { $0.appBundleID == AppFixture.exampleID }) + do { + _ = try await host.invoke("preferences.suites", input: [:], target: denied.target) + Issue.record("An unauthorized app accepted a plugin call") + } catch let error as NectoBridgeError { #expect(error.code == .unauthorized) } + if attempt == 0 { + try await app.terminateExample() + try await app.wait("denied app removal") { + await host.center.unauthorizedApps.allSatisfy { $0.appBundleID != AppFixture.exampleID } + } + try await app.launchExample() + } + } + if security == .missingKey { try credentials.installMatchingKey() } + } + if security != .mismatchedKey { + var target = try await host.waitForPlugins(app) + try await checkSecureCall(host, target: target) + try await app.terminateExample() + try await app.wait("SDK disconnection") { + await host.center.connectedApps.allSatisfy { $0.appBundleID != AppFixture.exampleID } + && host.plugins.keys.allSatisfy { $0.appBundleID != AppFixture.exampleID } + } + try await app.launchExample() + target = try await host.waitForPlugins(app) + try await checkSecureCall(host, target: target) + } + #expect(credentials.lookups.isEmpty == (security == .plaintext)) + } catch { + await host.stop() + await app.close() + throw error + } + await host.stop() + await app.close() + } + + private func checkSecureCall(_ host: SecurityConnectionHost, target: NectoTarget) async throws { + let value = NectoJSONValue.string(UUID().uuidString) + _ = try await host.invoke("preferences.set", input: ["key": "necto.e2e.security", "value": value], target: target) + let result = try await host.invoke("preferences.detail", input: ["key": "necto.e2e.security"], target: target) + #expect(result["entry"]?["value"] == value) + } + private func checkOnce(_ app: AppFixture) async throws { let value = UUID().uuidString let input = String(decoding: try JSONSerialization.data(withJSONObject: [ diff --git a/NectoE2ETests/SecurityConnectionHost.swift b/NectoE2ETests/SecurityConnectionHost.swift new file mode 100644 index 0000000..f343caa --- /dev/null +++ b/NectoE2ETests/SecurityConnectionHost.swift @@ -0,0 +1,95 @@ +// +// Copyright (c) 2026 Viva Republica, Inc. +// + +import Foundation +import NectoModel +import NectoTransport +import Testing +@testable import NectoMacService + +@MainActor +final class SecurityConnectionHost { + let center: NectoConnectionCenter + let bridge: NectoDeviceBridgeClient + private(set) var plugins: [NectoTarget: Set] = [:] + private var messages: Task? + private var connections: Task? + private let envelopes = AsyncStream<(NectoEnvelope, NectoTarget)>.makeStream() + + init(identity: @escaping @Sendable (String) async throws -> NectoTLSIdentity?) { + center = NectoConnectionCenter( + port: NectoTransportDefaults.devicePort, probeInterval: .milliseconds(100), + deviceEvents: { AsyncThrowingStream { $0.finish() } }, identity: identity + ) + bridge = NectoDeviceBridgeClient(messenger: Messenger(center: center)) + } + + func start() async { + await center.onMessage { [envelopes] envelope, target in + guard target.appBundleID == AppFixture.exampleID else { return } + envelopes.continuation.yield((envelope, target)) + } + messages = Task { + for await (envelope, target) in envelopes.stream { + do { + switch envelope.type { + case .pluginRegister: + let registration = try envelope.decode(NectoPluginRegistration.self) + plugins[target, default: []].insert(registration.pluginID) + case .pluginResult: + await bridge.receive(try envelope.decode(NectoPluginResult.self)) + default: break + } + } catch { Issue.record(error) } + } + } + connections = Task { + var known: Set = [] + for await apps in await center.updates() { + let current = Set(apps.map(\.target)) + for target in known.subtracting(current) { + plugins.removeValue(forKey: target) + await bridge.targetDisconnected(target) + } + known = current + } + } + await center.start() + } + + func stop() async { + for app in await center.connectedApps { await bridge.targetDisconnected(app.target) } + await center.stop() + envelopes.continuation.finish() + messages?.cancel() + connections?.cancel() + await messages?.value + await connections?.value + } + + func waitForPlugins(_ app: AppFixture) async throws -> NectoTarget { + try await app.wait("SDK plugin registration") { + self.plugins.contains { $0.key.appBundleID == AppFixture.exampleID && $0.value.isSuperset(of: ["preferences", "performance-monitor"]) } + } + return try #require(plugins.keys.first { $0.appBundleID == AppFixture.exampleID }) + } + + func invoke(_ operation: String, input: NectoJSONValue, target: NectoTarget) async throws -> NectoJSONValue { + try await withThrowingTaskGroup(of: NectoJSONValue.self) { group in + group.addTask { [bridge] in + try await bridge.invoke(name: "necto.device." + operation, version: 1, kind: .once, input: input, target: target) + } + group.addTask { try await Task.sleep(for: .seconds(10)); throw URLError(.timedOut) } + defer { group.cancelAll() } + return try await group.next()! + } + } + + private struct Messenger: NectoDeviceMessenger { + let center: NectoConnectionCenter + func send(_ envelope: NectoEnvelope, to target: NectoTarget) async throws { + try await center.send(envelope, to: target) + } + } +} diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift index d9dd084..e474033 100644 --- a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift @@ -120,6 +120,63 @@ struct FailureTests { } } + @Test("complete encrypted frames remain readable after peer EOF", arguments: [false, true], [false, true]) + func bufferedFramesAfterEOF(tcp: Bool, sentByDevice: Bool) async throws { + let fixture = try CredentialFixture() + try await withSockets(tcp: tcp) { pair in + let (device, host) = try await secureSessions(pair, identity: fixture.identity) + let sender = sentByDevice ? device : host + let receiver = sentByDevice ? host : device + let receivingStream = sentByDevice ? pair.host : pair.device + let messages = [Data("last response".utf8), Data("last event".utf8)] + for message in messages { try await sender.send(message) } + sender.close() + try await waitForPeerClose(receivingStream) + + for message in messages { #expect(try await receiver.receive() == message) } + await #expect(throws: NectoSecurityError.closed) { _ = try await receiver.receive() } + await #expect(throws: NectoSecurityError.closed) { try await receiver.send(Data([1])) } + } + } + + @Test("EOF preserves complete encrypted frames but rejects a truncated following frame") + func completeFrameBeforeTruncatedFrame() async throws { + let fixture = try CredentialFixture() + try await withSockets { pair in + let (device, host) = try await secureSessions(pair, identity: fixture.identity) + let message = Data("complete response".utf8) + try await device.send(message) + try await pair.device.write(Data([0, 0, 0, 10, 1, 2])) + device.close() + try await waitForPeerClose(pair.host) + + #expect(try await host.receive() == message) + await #expect(throws: NectoSecurityError.closed) { _ = try await host.receive() } + } + } + + @Test("explicit close discards unread encrypted frames even after peer EOF") + func closeAfterEOFDiscardsFrames() async throws { + let fixture = try CredentialFixture() + try await withSockets { pair in + let (device, host) = try await secureSessions(pair, identity: fixture.identity) + try await device.send(Data("unread response".utf8)) + device.close() + try await waitForPeerClose(pair.host) + host.close() + + await #expect(throws: NectoSecurityError.closed) { _ = try await host.receive() } + } + } + + private func waitForPeerClose(_ stream: NectoTLSStream) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while stream.isTLSReady, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(5)) + } + try #require(!stream.isTLSReady) + } + @Test("cancelling an encrypted receive unblocks it and closes its stream") func cancelRead() async throws { let fixture = try CredentialFixture() diff --git a/Sources/NectoTransport/NectoTLSStream.swift b/Sources/NectoTransport/NectoTLSStream.swift index 79ab532..4a13e83 100644 --- a/Sources/NectoTransport/NectoTLSStream.swift +++ b/Sources/NectoTransport/NectoTLSStream.swift @@ -24,6 +24,7 @@ public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { private var channel: (any Channel)? private var buffer = Data() private var failure: (any Error)? + private var reachedEOF = false private var pending: (count: Int, continuation: CheckedContinuation)? private var tlsPromise: EventLoopPromise? private var tlsFinished = false @@ -59,6 +60,7 @@ public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { let future = try await channel.eventLoop.submit { let promise = try self.lock.withLock { if let failure = self.failure { throw failure } + guard !self.reachedEOF else { throw NectoSecurityError.closed } guard self.tlsPromise == nil, self.pending == nil else { throw NectoSecurityError.concurrentRead } let promise = channel.eventLoop.makePromise(of: Void.self) self.tlsPromise = promise @@ -99,7 +101,7 @@ public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { } } - public var isTLSReady: Bool { lock.withLock { tlsFinished && failure == nil } } + public var isTLSReady: Bool { lock.withLock { tlsFinished && failure == nil && !reachedEOF } } func negotiatedTLSVersion() async throws -> TLSVersion? { guard let channel = lock.withLock({ channel }) else { throw NectoSecurityError.closed } @@ -111,6 +113,7 @@ public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { try Task.checkCancellation() let channel = try lock.withLock { if let failure { throw failure } + guard !reachedEOF else { throw NectoSecurityError.closed } guard let channel = self.channel else { throw NectoSecurityError.closed } return channel } @@ -129,6 +132,7 @@ public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { else if pending != nil { continuation.resume(throwing: NectoSecurityError.concurrentRead) } else if count > limit { continuation.resume(throwing: NectoSecurityError.bufferLimit) } else if buffer.count >= count { continuation.resume(returning: consume(count)) } + else if reachedEOF { continuation.resume(throwing: NectoSecurityError.closed) } else { pending = (count, continuation) } } } @@ -153,7 +157,7 @@ public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { fileprivate func receive(_ data: Data) { let overflow = lock.withLock { - guard failure == nil else { return false } + guard failure == nil, !reachedEOF else { return false } guard data.count <= limit - buffer.count else { return true } buffer.append(data) if let pending, buffer.count >= pending.count { @@ -170,12 +174,26 @@ public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { fileprivate func completeTLS() { lock.withLock { - guard failure == nil, !tlsFinished else { return } + guard failure == nil, !reachedEOF, !tlsFinished else { return } tlsFinished = true tlsPromise?.succeed(()) } } + fileprivate func endOfStream() { + lock.withLock { + guard failure == nil, !reachedEOF else { return } + reachedEOF = true + // A clean peer close must not discard complete frames awaiting a reader. + pending?.continuation.resume(throwing: NectoSecurityError.closed) + pending = nil + if !tlsFinished { + tlsPromise?.fail(NectoSecurityError.closed) + tlsPromise = nil + } + } + } + fileprivate func fail(_ error: any Error) { lock.withLock { guard failure == nil else { return } @@ -205,7 +223,7 @@ private final class StreamHandler: ChannelInboundHandler, @unchecked Sendable { context.close(promise: nil) } func channelInactive(context: ChannelHandlerContext) { - stream?.fail(NectoSecurityError.closed) + stream?.endOfStream() context.fireChannelInactive() } } diff --git a/docs/verification.md b/docs/verification.md index e8b72c4..999e2a1 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -64,6 +64,15 @@ events as JSONL (`stream`). It then terminates ExampleApp during a live subscrip checks that the CLI exits with an error and the target disappears, and relaunches ExampleApp to repeat both operations without restarting Necto. +Security cases run the simulator SDK against `NectoConnectionCenter`, with its +credential lookup supplied by the test. They use a disposable Keychain and real +TLS connections, without changing the GUI's startup code or the user's Keychain. +They cover no public key, matching keys, missing keys, and mismatched keys. +Rejected apps must expose no plugins or accept commands, including after relaunch. +Installing a missing key must allow automatic reconnection. Successful cases also +write and read a value through the SDK before and after reconnecting. These cases +test the connection service; the GUI and CLI flow above runs separately. + Both CI and local runs use `iPhone 17 Pro` on the newest available iOS runtime that has one, regardless of boot state. If none exists, the test fails immediately. It opens Simulator and waits for boot to finish before installing the test ExampleApp From 7d2b96fdb1d703df119f29e292d28b73f3f60449 Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:35:00 +0900 Subject: [PATCH 3/9] Accept successful E2E commands with stderr diagnostics --- NectoE2ETests/AppFixture.swift | 1 - NectoE2ETests/CommandDeadlineTests.swift | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/NectoE2ETests/AppFixture.swift b/NectoE2ETests/AppFixture.swift index 624ad03..cb2aedb 100644 --- a/NectoE2ETests/AppFixture.swift +++ b/NectoE2ETests/AppFixture.swift @@ -190,7 +190,6 @@ final class AppFixture { func requireSuccess() throws { try #require(status == 0, "Command exited \(status): \(error)") - #expect(error.isEmpty) } func json() throws -> [String: Any] { diff --git a/NectoE2ETests/CommandDeadlineTests.swift b/NectoE2ETests/CommandDeadlineTests.swift index 8af8e32..5c89dbc 100644 --- a/NectoE2ETests/CommandDeadlineTests.swift +++ b/NectoE2ETests/CommandDeadlineTests.swift @@ -8,6 +8,13 @@ import Testing @Suite("E2E command deadlines", .serialized) @MainActor struct CommandDeadlineTests { + @Test("successful commands may write diagnostics to stderr") + func successfulCommandWithStderr() throws { + let result = AppFixture.Result(status: 0, output: "ok", error: "warning") + try result.requireSuccess() + #expect(result.error == "warning") + } + @Test("an exhausted preparation budget does not launch the next command") func expiredBeforeLaunch() async throws { let app = try AppFixture() From 26fd248de175a974ea8f24adb2d18eee9f14d83a Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:01:11 +0900 Subject: [PATCH 4/9] Include simulator discovery in E2E preparation deadline --- NectoE2ETests/AppFixture.swift | 14 +++++++------- docs/ko/verification.md | 4 ++-- docs/verification.md | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/NectoE2ETests/AppFixture.swift b/NectoE2ETests/AppFixture.swift index cb2aedb..ae80c9a 100644 --- a/NectoE2ETests/AppFixture.swift +++ b/NectoE2ETests/AppFixture.swift @@ -41,17 +41,17 @@ final class AppFixture { try #require(Bundle(url: example)?.bundleIdentifier == Self.exampleID) try #require(FileManager.default.isExecutableFile(atPath: executable.path)) - let available = try await run("/usr/bin/xcrun", ["simctl", "list", "devices", "available", "--json"]) - let devices = try #require(available.json()["devices"] as? [String: [[String: Any]]]) - let phone = try #require(Self.selectSimulator(devices), "No available iPhone 17 Pro simulator. Add one in Xcode before running E2E.") - let id = try #require(phone["udid"] as? String) - try #require(UUID(uuidString: id) != nil) - print("E2E simulator: iPhone 17 Pro (\(id))") - // First-boot services can still delay installation after bootstatus completes. + // Discovery and first-boot services can stall on a cold CI runner. // Share one preparation deadline; the connection test does not need Simulator.app. let preparationDeadline = ContinuousClock.now + .seconds(600) let preparationStart = commands.count do { + let available = try await run("/usr/bin/xcrun", ["simctl", "list", "devices", "available", "--json"], deadline: preparationDeadline) + let devices = try #require(available.json()["devices"] as? [String: [[String: Any]]]) + let phone = try #require(Self.selectSimulator(devices), "No available iPhone 17 Pro simulator. Add one in Xcode before running E2E.") + let id = try #require(phone["udid"] as? String) + try #require(UUID(uuidString: id) != nil) + print("E2E simulator: iPhone 17 Pro (\(id))") _ = try await run("/usr/bin/xcrun", ["simctl", "bootstatus", id, "-b"], deadline: preparationDeadline) print("E2E: simulator booted") simulator = id diff --git a/docs/ko/verification.md b/docs/ko/verification.md index d9279e7..1160e59 100644 --- a/docs/ko/verification.md +++ b/docs/ko/verification.md @@ -58,8 +58,8 @@ CLI로 기기와 플러그인을 조회하고 `plugin help`를 확인해요. Use CI와 로컬 모두 부팅 상태와 관계없이 `iPhone 17 Pro`를 사용해요. 여러 iOS 버전에 있으면 최신 버전을 선택하고, 해당 기기가 없으면 바로 실패해요. -Simulator 앱은 열지 않고 `simctl bootstatus -b`로 부팅을 기다린 뒤 테스트용 -ExampleApp(`im.toss.necto.e2e.example`)을 설치해요. 부팅과 설치에는 합쳐서 10분의 +Simulator 앱은 열지 않고 기기를 조회한 뒤 `simctl bootstatus -b`로 부팅을 기다려요. +그다음 테스트용 ExampleApp(`im.toss.necto.e2e.example`)을 설치해요. 조회·부팅·설치에는 합쳐서 10분의 제한 시간을 적용해요. 중단된 실행에서 남은 앱은 덮어쓰고, 검증할 값은 테스트에서 직접 저장해요. 끝나면 해당 앱과 임시 Mac 홈을 제거해요. 시뮬레이터는 켜둔 채로 유지하며 초기화하거나 삭제하지 않아요. 다른 앱도 그대로 두어요. 호스트끼리 SDK의 diff --git a/docs/verification.md b/docs/verification.md index 57d757e..005af32 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -75,10 +75,10 @@ test the connection service; the GUI and CLI flow above runs separately. Both CI and local runs use `iPhone 17 Pro` on the newest available iOS runtime that has one, regardless of boot state. If none exists, the test fails immediately. -It waits for `simctl bootstatus -b` before installing the test ExampleApp -(`im.toss.necto.e2e.example`), without opening Simulator.app. Boot and installation -share a 10-minute preparation deadline, so first-boot services can finish without a -separate 120-second installation cutoff. An interrupted run's installation is replaced; +It discovers the simulator, waits for `simctl bootstatus -b`, then installs the test +ExampleApp (`im.toss.necto.e2e.example`) without opening Simulator.app. Discovery, +boot, and installation share a 10-minute preparation deadline, so cold-start services +can finish within one bounded budget. An interrupted run's installation is replaced; each test writes its own values. The app and temporary Mac home are removed afterward. The simulator is left booted; it is never erased or deleted, and other installed apps are left intact. @@ -88,7 +88,7 @@ Launching ExampleApp and waiting for the Mac host's control socket each get a checks retain their 30-second deadline. If the host exits during startup, the test fails immediately. Startup failures include the host output and the last CLI probe's output in the test log. Simulator preparation -failures include both the boot and installation command output. +failures include discovery, boot, and installation command output. Other Necto instances must be closed because hosts share the SDK's loopback ports. Waits check observable state with deadlines, not fixed startup delays or performance From fdaab004adf37e603f9f2a9e3e063a45ea36d514 Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:35:55 +0900 Subject: [PATCH 5/9] Serve CLI discovery from connection center --- Necto/NectoAppModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Necto/NectoAppModel.swift b/Necto/NectoAppModel.swift index 7c17e4e..94bf4a7 100644 --- a/Necto/NectoAppModel.swift +++ b/Necto/NectoAppModel.swift @@ -442,11 +442,11 @@ final class NectoAppModel { private func startControlServer() async { let server = NectoControlServer(handler: NectoControlBridge( registry: registry, - connectedApps: { [weak self] in - await MainActor.run { self?.connectedApps ?? [] } + connectedApps: { [connections] in + await connections.connectedApps }, - unauthorizedApps: { [weak self] in - await MainActor.run { self?.unauthorizedApps ?? [] } + unauthorizedApps: { [connections] in + await connections.unauthorizedApps }, install: { [weak self] source in guard let self else { From 5290dcca094adc5b7314b57c78ad399bb90066c1 Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:12:34 +0900 Subject: [PATCH 6/9] Wait for simulator plugin readiness in E2E --- NectoE2ETests/ConnectionTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/NectoE2ETests/ConnectionTests.swift b/NectoE2ETests/ConnectionTests.swift index 0d1804c..80d83f1 100644 --- a/NectoE2ETests/ConnectionTests.swift +++ b/NectoE2ETests/ConnectionTests.swift @@ -16,6 +16,7 @@ struct ConnectionTests { do { try await app.start() try await app.waitForPlugins() + try await waitForPreferencesReady(app) let onceHelp = try await app.json(["plugin", "help", "preferences", "preferences.detail", "--json"]) let once = try #require(onceHelp["operation"] as? [String: Any]) @@ -47,6 +48,7 @@ struct ConnectionTests { try await app.launchExample() try await app.waitForPlugins() + try await waitForPreferencesReady(app) try await checkOnce(app) try await checkStream(app) } catch { @@ -119,6 +121,16 @@ struct ConnectionTests { #expect(result["entry"]?["value"] == value) } + private func waitForPreferencesReady(_ app: AppFixture) async throws { + // Registration can arrive while a cold simulator is still unable to answer a device call. + try await app.wait("preferences provider readiness", timeout: .seconds(120)) { + let result = try await app.cli(["plugin", "send", "preferences", "preferences.suites"]) + if result.status == 0 { return true } + try #require(result.error.hasPrefix("Error: TIMEOUT:"), "Unexpected provider response: \(result.error)") + return false + } + } + private func checkOnce(_ app: AppFixture) async throws { let value = UUID().uuidString let input = String(decoding: try JSONSerialization.data(withJSONObject: [ From 2e9cd58409ac05012ba73c00bd8a763c242807ba Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:46:08 +0900 Subject: [PATCH 7/9] Capture process samples on E2E CLI timeouts --- NectoE2ETests/AppFixture.swift | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/NectoE2ETests/AppFixture.swift b/NectoE2ETests/AppFixture.swift index ae80c9a..e48f8a2 100644 --- a/NectoE2ETests/AppFixture.swift +++ b/NectoE2ETests/AppFixture.swift @@ -16,6 +16,7 @@ final class AppFixture { private let logs: URL private var publicKey: String? private var simulator: String? + private var hostCommand: Command? private var commands: [Command] = [] init() throws { @@ -70,6 +71,7 @@ final class AppFixture { try await hostStart() } else { let hostCommand = try launch(host.appending(path: "Contents/MacOS/Necto"), [], isolated: true) + self.hostCommand = hostCommand try await waitForControlSocket(hostCommand) } try await launchExample() @@ -259,10 +261,12 @@ final class AppFixture { func finish(_ command: Command, deadline: ContinuousClock.Instant) async throws -> Result { while command.process.isRunning { if ContinuousClock.now >= deadline { - kill(command.process.processIdentifier, SIGKILL) + let diagnostics = await captureStalledProcesses(for: command) + if command.process.isRunning { kill(command.process.processIdentifier, SIGKILL) } throw Failure(message: """ Command timed out: \(command.process.arguments ?? []). Logs: \(logs.path) \(command.logTail()) + \(diagnostics.joined(separator: "\n")) """) } do { try await Task.sleep(for: .milliseconds(50)) } @@ -274,6 +278,27 @@ final class AppFixture { return try command.result() } + private func captureStalledProcesses(for command: Command) async -> [String] { + guard command.process.executableURL?.lastPathComponent == "necto-cli" else { return [] } + let processes: [(String, Process)] = [("cli", command.process)] + + (hostCommand.map { [("host", $0.process)] } ?? []) + var diagnostics: [String] = [] + for (name, process) in processes where process.isRunning { + let path = logs.appending(path: "\(name)-\(command.process.processIdentifier).sample.txt") + do { + let profiler = try launch(URL(filePath: "/usr/bin/sample"), [ + String(process.processIdentifier), "1", "-file", path.path, + ]) + let result = try await finish(profiler, timeout: .seconds(10)) + diagnostics.append(result.status == 0 ? "Process sample: \(path.path)" : + "Process sample failed: \(result.error)") + } catch { + diagnostics.append("Process sample failed: \(error)") + } + } + return diagnostics + } + private func run(_ path: String, _ arguments: [String], timeout: Duration = .seconds(30)) async throws -> Result { try await run(path, arguments, deadline: ContinuousClock.now + timeout) } From 900ee123248dba41cb4e041b7d8803e25c0262ca Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:46:07 +0900 Subject: [PATCH 8/9] Keep target discovery responsive during socket close --- .../NectoConnectionCenter.swift | 7 +- .../NectoConnectionCenterTests.swift | 73 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/NectoMac/Sources/NectoMacService/NectoConnectionCenter.swift b/NectoMac/Sources/NectoMacService/NectoConnectionCenter.swift index 8f7642e..1be75b3 100644 --- a/NectoMac/Sources/NectoMacService/NectoConnectionCenter.swift +++ b/NectoMac/Sources/NectoMacService/NectoConnectionCenter.swift @@ -407,13 +407,18 @@ public actor NectoConnectionCenter { } guard !removable.isEmpty else { return } + var closing: [NectoMessageSession] = [] for id in removable { - connections.removeValue(forKey: id)?.session.close() + if let session = connections.removeValue(forKey: id)?.session { closing.append(session) } usbDeviceIDs.removeValue(forKey: id) localPorts.removeValue(forKey: id) devicePorts.removeValue(forKey: id) } publish() + // A slow socket close must not hold the actor that answers CLI target queries. + for session in closing { + Task.detached(priority: .utility) { session.close() } + } } private func removeObserver(_ id: UUID) { diff --git a/NectoMac/Tests/NectoMacServiceTests/NectoConnectionCenterTests.swift b/NectoMac/Tests/NectoMacServiceTests/NectoConnectionCenterTests.swift index b4b5bd8..19e6c37 100644 --- a/NectoMac/Tests/NectoMacServiceTests/NectoConnectionCenterTests.swift +++ b/NectoMac/Tests/NectoMacServiceTests/NectoConnectionCenterTests.swift @@ -9,6 +9,34 @@ import Testing @testable import NectoMacService +private func signalled(_ semaphore: DispatchSemaphore, within seconds: Int) -> Bool { + semaphore.wait(timeout: .now() + .seconds(seconds)) == .success +} + +private final class DelayedCloseStream: NectoByteStream, @unchecked Sendable { + let entered = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + private let base: NectoSocketStream + private let lock = NSLock() + private var closing = false + + init(descriptor: Int32) { base = NectoSocketStream(descriptor: descriptor) } + + func write(_ data: Data) async throws { try await base.write(data) } + func read(count: Int) async throws -> Data { try await base.read(count: count) } + func close() { + let first = lock.withLock { + guard !closing else { return false } + closing = true + return true + } + guard first else { return } + entered.signal() + release.wait() + base.close() + } +} + @Suite("Which ports a transport dials") struct NectoConnectionCenterTests { @Test("a port range near UInt16.max is clipped rather than overflowing") @@ -55,6 +83,51 @@ struct NectoConnectionCenterTests { @Suite("Host connection lifecycle", .timeLimit(.minutes(1))) struct NectoHostConnectionLifecycleTests { + @Test("a slow socket close cannot block target discovery after disconnect") + func targetDiscoveryDuringClose() async throws { + let center = NectoConnectionCenter() + var descriptors: [Int32] = [-1, -1] + try #require(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0) + let app = NectoMessageSession(stream: NectoSocketStream(descriptor: descriptors[0])) + let delayed = DelayedCloseStream(descriptor: descriptors[1]) + let host = NectoMessageSession(stream: delayed) + defer { + delayed.release.signal() + app.close() + host.close() + } + + let accepting = Task { + try await center.accept(session: host, deviceID: "simulator", connection: .simulator, usbDeviceID: nil) + } + try await app.send(NectoHandshakeHello( + appBundleID: "com.example.close", appName: "Close test", appVersion: "1.0.0", + deviceName: "Test simulator", sdkVersion: "0.4.2", simulatorID: "test-simulator" + )) + #expect(try await app.receive(NectoHandshakeAck.self).accepted) + let reader = try #require(try await accepting.value) + app.close() + + let closing = await Task.detached { + signalled(delayed.entered, within: 5) + }.value + try #require(closing, "The host did not start closing the disconnected socket") + + let answered = DispatchSemaphore(value: 0) + let discovery = Task.detached { + let apps = await center.connectedApps + answered.signal() + return apps + } + let responsive = await Task.detached { + signalled(answered, within: 3) + }.value + delayed.release.signal() + #expect(responsive, "Target discovery waited for socket close") + #expect(await discovery.value.isEmpty) + await reader.value + } + @Test("a silent hello is bounded by the production handshake deadline") func silentHandshake() async throws { let center = NectoConnectionCenter() From ee7cda9f5ac41a178e71770b4b158a196dda134d Mon Sep 17 00:00:00 2001 From: heoblitz <25315898+heoblitz@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:12:14 +0900 Subject: [PATCH 9/9] Fix TLS buffer overflow on consecutive maximum-size frames --- .../Security/SecurityFailureTests.swift | 25 +++++++++++++++++-- Sources/NectoTransport/NectoTLSStream.swift | 16 +++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift index e474033..3f43f02 100644 --- a/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift +++ b/NectoMac/Tests/NectoMacServiceTests/Security/SecurityFailureTests.swift @@ -212,16 +212,37 @@ struct FailureTests { } } - @Test("buffer limits reject a stalled reader") + @Test("buffer limits reject excess data even with a pending read") func bufferLimit() async throws { try await withSockets(limit: 64) { pair in let reading = Task { try await pair.host.read(count: 64) } - try await pair.device.write(Data(repeating: 7, count: 65)) + try await pair.device.write(Data(repeating: 7, count: 129)) await #expect(throws: NectoSecurityError.bufferLimit) { _ = try await reading.value } await #expect(throws: NectoSecurityError.bufferLimit) { _ = try await pair.host.read(count: 1) } } } + @Test("a maximum-size TLS frame followed by another frame preserves both messages", arguments: [false, true], [false, true]) + func maximumFrameBoundary(sentByDevice: Bool, closeAfterWrite: Bool) async throws { + let fixture = try CredentialFixture() + try await withSockets(tcp: true) { pair in + let (device, host) = try await secureSessions(pair, identity: fixture.identity) + let sender = sentByDevice ? device : host + let receiver = sentByDevice ? host : device + let large = Data(repeating: 0x71, count: NectoMessageSession.maximumMessageBytes) + let following = Data("following frame".utf8) + let sending = Task { + try await sender.send(large) + try await sender.send(following) + if closeAfterWrite { sender.close() } + } + defer { sending.cancel() } + #expect(try await receiver.receive() == large) + #expect(try await receiver.receive() == following) + try await sending.value + } + } + @Test("fragmented plaintext headers and bodies retain exact framing") func fragmentedFrames() async throws { try await withSockets { pair in diff --git a/Sources/NectoTransport/NectoTLSStream.swift b/Sources/NectoTransport/NectoTLSStream.swift index 4a13e83..86195ca 100644 --- a/Sources/NectoTransport/NectoTLSStream.swift +++ b/Sources/NectoTransport/NectoTLSStream.swift @@ -158,11 +158,19 @@ public final class NectoTLSStream: NectoByteStream, @unchecked Sendable { fileprivate func receive(_ data: Data) { let overflow = lock.withLock { guard failure == nil, !reachedEOF else { return false } - guard data.count <= limit - buffer.count else { return true } - buffer.append(data) - if let pending, buffer.count >= pending.count { + if let pending, data.count >= pending.count - buffer.count { + let needed = pending.count - buffer.count + // One TLS read can finish a maximum-size frame and begin the next. + // Deliver the pending read before applying the limit to unread bytes. + guard data.count - needed <= limit else { return true } + buffer.append(data.prefix(needed)) + let result = Data(buffer) + buffer = Data(data.dropFirst(needed)) self.pending = nil - pending.continuation.resume(returning: consume(pending.count)) + pending.continuation.resume(returning: result) + } else { + guard data.count <= limit - buffer.count else { return true } + buffer.append(data) } return false }