mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
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.
169 lines
8.5 KiB
Swift
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()
|