curtain/Sources/CurtainHelper/main.swift
Aric Camarata 8c19e960d2 Detection root-cause fix + audit batch: netstat path, UDP activator, settings coherence, refactor, docs
Detection: netstat lives at /usr/sbin/netstat, not /usr/bin — the hardcoded wrong
path silently killed the ESTABLISHED-TCP activator (root cause of the failed live
test). Fixed and live-verified. Added peered-UDP activator (5900-5902) for
High-Performance sessions, per-signal transition logging, unconditional error
logging for dead probe helpers, and probe v2 with full CGSession dictionary
diffing. 7 new parser tests (32 total).

Fixes from a full audit + adversarial review: idle source setting honored
(default now Remote session activity), cover scope reduced to a coherent
two-mode model with legacy migration (per-display toggle was inverted in
onlyMarked and dead in all), curtain test no longer schedules a teardown over a
live session, specific-display password box placement gets a real picker,
refuse-to-arm enforced, activation notification posts a real banner, menu
password gate bypassed when the event tap is dead, shared single-decoder aerial
player with stale-task guard and async playability check, password buffer zeroed
on successful unlock and Esc, XPC interruption/invalidation handlers, modern
Accessibility settings URL, launchPath modernized, codesign failures now abort
release.sh, monotonic CFBundleVersion, install.sh temp cleanup, dead
armDisarmHotkey setting removed.

Refactor: Curtain.swift and PreferencesWindow.swift split into focused files
(largest now 479 lines). Wiki, README, and contributing docs updated to match.
Build clean at 0 warnings, 32/32 tests pass.
2026-06-09 20:36:30 -04:00

169 lines
8.5 KiB
Swift

import Foundation
import Security
import CurtainShared
/// Purpose: The privileged LaunchDaemon. Runs as root (installed via
/// SMAppService.daemon), vends the CurtainDisconnectXPC interface over a
/// mach service, and ends the active remote Screen Sharing session by
/// signalling the connection processes that a user process cannot touch.
/// Inputs: XPC connections from the Curtain app on the well-known mach service.
/// Outputs: Replies true/false over XPC; sends SIGTERM to screensharingd and the
/// subscriber processes (launchd respawns the idle listener).
/// Constraints: Separate process with its own entry point. Daemon never returns
/// from main it runs the run loop forever. The listener delegate is
/// nonisolated (XPC callbacks arrive off the main actor). Every
/// incoming connection is validated against a Developer-ID code
/// requirement before it is accepted.
/// SPORT: MASTER-DISCONNECT
/// Implements the XPC contract. One instance is exported per accepted connection.
final class DisconnectService: NSObject, CurtainDisconnectXPC, @unchecked Sendable {
func endScreenSharingSession(reply: @escaping (Bool) -> Void) {
// launchd owns the screensharingd listener and respawns it, so terminating
// these processes drops the live session without killing the service itself.
let targets: [[String]] = [
["pkill", "-f", "ScreenSharingSubscriber"],
["pkill", "-x", "screensharingd"],
["pkill", "-f", "RemoteManagement.*[Ss]creen"],
]
var matched = false
for argv in targets where runMatched(argv) { matched = true }
reply(matched)
}
/// Run a pkill-style command. Returns true when pkill exits 0 (a process matched).
private func runMatched(_ argv: [String]) -> Bool {
guard let tool = argv.first else { return false }
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/" + tool)
p.arguments = Array(argv.dropFirst())
do {
try p.run()
p.waitUntilExit()
return p.terminationStatus == 0
} catch {
NSLog("CurtainHelper: failed to run %@: %@", tool, String(describing: error))
return false
}
}
}
/// Accepts XPC connections only from a copy of the Curtain app signed by the same
/// Developer-ID identity. Rejects everything else.
final class ListenerDelegate: NSObject, NSXPCListenerDelegate {
nonisolated func listener(_ listener: NSXPCListener,
shouldAcceptNewConnection conn: NSXPCConnection) -> Bool {
guard isTrustedCaller(conn) else {
NSLog("CurtainHelper: rejected connection — caller failed code-signature check")
return false
}
conn.exportedInterface = NSXPCInterface(with: CurtainDisconnectXPC.self)
conn.exportedObject = DisconnectService()
conn.resume()
return true
}
/// Validate the connecting client by PID: the caller must be a valid code signed
/// with our bundle identifier and an Apple-issued anchor, AND when both sides
/// expose a Team ID the Team IDs must match. This means only a properly-signed
/// copy of Curtain (our identity) can drive the privileged helper. (NSXPCConnection
/// does not surface its audit token to Swift, so we resolve the guest code from the
/// connecting PID, which the kernel reports for the live XPC peer.)
///
/// SECURITY NOTE PID-reuse TOCTOU race: SecCodeCopyGuestWithAttributes keyed on
/// a PID is inherently subject to a time-of-check/time-of-use race. Between the
/// moment the kernel delivers the PID to our listener and the moment we call
/// SecCodeCopyGuestWithAttributes, the original process could exit and a different
/// (potentially hostile) process could occupy the same PID. The gold-standard fix is
/// to key on the XPC audit token instead, but NSXPCConnection does not expose its
/// audit token via a public Swift API in the current SDK. Migrating to an
/// audit-token-keyed check (via a private/ObjC bridging shim or a future SDK
/// addition) is queued for the notarized public build at that point the Helper will
/// also carry a Developer-ID signature, making the Team-ID pinning the primary
/// guard and the audit token the defense-in-depth layer. Until then, the PID check
/// is the best available option and the risk is accepted under the assumption that
/// only trusted admins install the daemon.
///
/// Relaxation path for local dev ONLY: an ad-hoc / unsigned build has no Team ID.
/// When neither this helper nor the caller has a Team ID, we fall back to the
/// identifier+anchor check alone so a locally-built ad-hoc app can still be tested.
/// That relaxation is LOGGED loudly. In the signed case (this helper has a Team ID)
/// a Team-ID mismatch or a missing caller Team ID is always rejected.
private func isTrustedCaller(_ conn: NSXPCConnection) -> Bool {
let attrs: [String: Any] = [
kSecGuestAttributePid as String: NSNumber(value: conn.processIdentifier)
]
var code: SecCode?
guard SecCodeCopyGuestWithAttributes(nil, attrs as CFDictionary, [], &code) == errSecSuccess,
let guestCode = code else {
return false
}
let ourTeamID = Self.ownTeamID()
let callerTeamID = Self.teamID(of: guestCode)
// Build the code-signing requirement. When a Team ID is available on both
// sides, pin it so only our signed identity is accepted.
var reqString = "identifier \"io.acamarata.curtain\" and anchor apple generic"
if let ourTeamID, !ourTeamID.isEmpty {
// Signed helper: require the caller to carry a matching Team ID.
guard let callerTeamID, callerTeamID == ourTeamID else {
NSLog("CurtainHelper: rejected connection — caller Team ID mismatch (ours=%@, caller=%@)",
ourTeamID, callerTeamID ?? "<none>")
return false
}
reqString += " and certificate leaf[subject.OU] = \"\(ourTeamID)\""
} else {
// Ad-hoc / unsigned local dev: no Team ID on this helper. Accept the
// identifier+anchor check alone, and log the relaxation loudly.
NSLog("CurtainHelper: WARNING — accepting caller without Team-ID pinning (ad-hoc/local dev build)")
}
var requirement: SecRequirement?
guard SecRequirementCreateWithString(reqString as CFString, [], &requirement) == errSecSuccess,
let req = requirement else {
return false
}
return SecCodeCheckValidity(guestCode, [], req) == errSecSuccess
}
/// Team ID of this running helper, or nil for an ad-hoc/unsigned build.
private static func ownTeamID() -> String? {
var codeRef: SecCode?
guard SecCodeCopySelf([], &codeRef) == errSecSuccess, let code = codeRef else { return nil }
var staticRef: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticRef) == errSecSuccess,
let staticCode = staticRef else { return nil }
return teamID(ofStatic: staticCode)
}
/// Team ID of a connecting guest code, or nil if it carries none (ad-hoc/unsigned).
private static func teamID(of code: SecCode) -> String? {
var staticRef: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticRef) == errSecSuccess,
let staticCode = staticRef else { return nil }
return teamID(ofStatic: staticCode)
}
private static func teamID(ofStatic staticCode: SecStaticCode) -> String? {
var infoRef: CFDictionary?
guard SecCodeCopySigningInformation(staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation),
&infoRef) == errSecSuccess,
let info = infoRef as? [String: Any],
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String,
!teamID.isEmpty else {
return nil
}
return teamID
}
}
let delegate = ListenerDelegate()
let listener = NSXPCListener(machServiceName: CurtainHelperInfo.machServiceName)
listener.delegate = delegate
listener.resume()
NSLog("CurtainHelper: listening on %@", CurtainHelperInfo.machServiceName)
// Daemons never return from main; park on the run loop forever.
RunLoop.current.run()