From b09dd810a46edc915fc949003076ac03279dc4ef Mon Sep 17 00:00:00 2001 From: andyhtran <76441965+andyhtran@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:21:35 -0400 Subject: [PATCH] Extract Secure Input detection into dedicated watcher with triage, HUD, and degraded paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Secure Input ownership out of PasteHandler into SecureInputWatcher — a purpose-built subsystem that classifies holds (expected, stuck loginwindow, terminal SKE, background holder, orphaned), enforces per-kind alert grace periods, and drives every user-facing surface (menu-bar icon badge, floating HUD toast, notification banner) from a single SecureInputPresentation. Adds IOHID-based paste-attempt sensor to detect ⌘V while the event tap is blind, enabling both an immediate blocked-paste toast and an experimental degraded paste path (typing the image path despite Secure Input when the clipboard has no text flavor the terminal would paste itself). Co-Authored-By: Claude Opus 4.6 --- Sources/CopyCat/CopyCatApp.swift | 103 ++++-- Sources/CopyCat/HotkeyBinding.swift | 8 +- Sources/CopyCat/Logger.swift | 1 + Sources/CopyCat/Notifications.swift | 19 +- Sources/CopyCat/PasteAttemptSensor.swift | 115 ++++++ Sources/CopyCat/PasteHandler.swift | 111 ++---- Sources/CopyCat/SecureInput.swift | 28 +- Sources/CopyCat/SecureInputHUD.swift | 298 ++++++++++++++++ Sources/CopyCat/SecureInputTriage.swift | 299 ++++++++++++++++ Sources/CopyCat/SecureInputWatcher.swift | 332 ++++++++++++++++++ Sources/CopyCat/SessionLock.swift | 38 ++ Sources/CopyCat/StatusModel.swift | 8 +- .../CopyCatTests/SecureInputTriageTests.swift | 189 ++++++++++ 13 files changed, 1421 insertions(+), 128 deletions(-) create mode 100644 Sources/CopyCat/PasteAttemptSensor.swift create mode 100644 Sources/CopyCat/SecureInputHUD.swift create mode 100644 Sources/CopyCat/SecureInputTriage.swift create mode 100644 Sources/CopyCat/SecureInputWatcher.swift create mode 100644 Sources/CopyCat/SessionLock.swift create mode 100644 Tests/CopyCatTests/SecureInputTriageTests.swift diff --git a/Sources/CopyCat/CopyCatApp.swift b/Sources/CopyCat/CopyCatApp.swift index d8b43fa..34feb50 100644 --- a/Sources/CopyCat/CopyCatApp.swift +++ b/Sources/CopyCat/CopyCatApp.swift @@ -12,10 +12,27 @@ struct CopyCatApp: App { // synchronously — that combination produces a tight transaction loop. @AppStorage("showMenuBarIcon") private var showMenuBarIcon: Bool = true - // Resolve once at startup. SwiftUI's MenuBarExtra(_:image:) form expects - // an asset-catalog name, which we don't have — feeding NSImage directly - // through the custom-label form sidesteps that lookup. - private let menuBarIcon: NSImage = { + var body: some Scene { + // Settings is hosted in an AppDelegate-owned NSWindowController, not + // a SwiftUI Settings scene. showSettingsWindow: dispatch is unreliable + // for LSUIElement apps — when the menu bar icon is hidden there's no + // key window in the responder chain, so applicationShouldHandleReopen + // can't surface it. + MenuBarExtra(isInserted: $showMenuBarIcon) { + CopyCatMenu() + .environment(\.updaterController, appDelegate.updaterController) + } label: { + MenuBarLabel() + } + } +} + +// Resolved once. SwiftUI's MenuBarExtra(_:image:) form expects an +// asset-catalog name, which we don't have — feeding NSImage directly through +// the custom-label form sidesteps that lookup. +@MainActor +private enum MenuBarIconFactory { + static let normal: NSImage = { if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "pdf"), let image = NSImage(contentsOf: url) { image.size = NSSize(width: 18, height: 18) @@ -29,19 +46,38 @@ struct CopyCatApp: App { return fallback }() - var body: some Scene { - // Settings is hosted in an AppDelegate-owned NSWindowController, not - // a SwiftUI Settings scene. showSettingsWindow: dispatch is unreliable - // for LSUIElement apps — when the menu bar icon is hidden there's no - // key window in the responder chain, so applicationShouldHandleReopen - // can't surface it. - MenuBarExtra(isInserted: $showMenuBarIcon) { - CopyCatMenu() - .environment(\.updaterController, appDelegate.updaterController) - } label: { - Image(nsImage: menuBarIcon) - .accessibilityLabel("CopyCat") + // Same paw with an exclamation badge in the corner: the persistent, + // glanceable "paste is broken" signal while Secure Input blocks the tap. + static let blocked: NSImage = { + let base = normal + let size = base.size == .zero ? NSSize(width: 18, height: 18) : base.size + let image = NSImage(size: size, flipped: false) { rect in + base.draw(in: rect) + let badge = NSRect(x: rect.maxX - 9, y: rect.minY, width: 9, height: 9) + // Punch a ring around the badge so it reads against the base + // glyph at menu-bar size (template images are alpha-only). + NSGraphicsContext.current?.compositingOperation = .destinationOut + NSColor.black.setFill() + NSBezierPath(ovalIn: badge.insetBy(dx: -1.5, dy: -1.5)).fill() + NSGraphicsContext.current?.compositingOperation = .sourceOver + if let symbol = NSImage(systemSymbolName: "exclamationmark.circle.fill", accessibilityDescription: nil) { + symbol.draw(in: badge) + } else { + NSBezierPath(ovalIn: badge).fill() + } + return true } + image.isTemplate = true + return image + }() +} + +private struct MenuBarLabel: View { + @ObservedObject private var status = StatusModel.shared + + var body: some View { + Image(nsImage: status.secureInputAlerting ? MenuBarIconFactory.blocked : MenuBarIconFactory.normal) + .accessibilityLabel(status.secureInputAlerting ? "CopyCat — paste blocked" : "CopyCat") } } @@ -82,6 +118,14 @@ private struct CopyCatMenu: View { NSWorkspace.shared.open(url) } } + // The paste-attempt sensor (toast at the exact moment ⌘V is + // pressed while blocked) needs Input Monitoring; hide the item + // once granted since the sensor then arms automatically. + if !SecureInputWatcher.shared.sensorAccessGranted { + Button("Enable paste-attempt alerts…") { + SecureInputWatcher.shared.requestSensorAccess() + } + } } Divider() @@ -178,10 +222,23 @@ private struct StatusHeader: View { // Secure Input silently blocks the tap for the whole session, so a // green "Tap on" alone would be misleading — call out the culprit. - if let blocker = status.secureInputBlocker { - Text("⚠ Blocked by Secure Input (\(blocker))") - .font(.caption) - .foregroundStyle(.orange) + // Alert-worthy blocks get the orange treatment plus a one-click fix; + // benign holds (focused password prompt) get a quiet gray note. + if let secureInput = status.secureInput { + if status.secureInputAlerting { + Text(secureInput.menuLabel) + .font(.caption) + .foregroundStyle(.orange) + if let action = secureInput.action { + Button(action.label) { + SecureInputActions.perform(action) + } + } + } else { + Text(secureInput.menuLabel) + .font(.caption) + .foregroundStyle(.secondary) + } } if store.enableBroadcast { @@ -308,10 +365,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { pasteHandler = PasteHandler() pasteHandler?.start() + + SecureInputWatcher.shared.tapEnabledProvider = { [weak self] in + self?.pasteHandler?.isTapEnabled ?? false + } + SecureInputWatcher.shared.start() } func applicationWillTerminate(_ notification: Notification) { Log.app.info("CopyCat terminating") + SecureInputWatcher.shared.stop() pasteHandler?.stop() } diff --git a/Sources/CopyCat/HotkeyBinding.swift b/Sources/CopyCat/HotkeyBinding.swift index bb0d4f9..9de9fef 100644 --- a/Sources/CopyCat/HotkeyBinding.swift +++ b/Sources/CopyCat/HotkeyBinding.swift @@ -31,7 +31,13 @@ struct HotkeyBinding: Equatable, Sendable { func matches(keyCode: Int64, flags: CGEventFlags) -> Bool { guard Int(keyCode) == self.keyCode else { return false } - return (flags.rawValue & modifierMask) == modifiers + return matchesModifiers(flags) + } + + /// Modifier-only match, for callers that already know the key (the IOHID + /// sensor reports V by HID usage, not by CGEvent keycode). + func matchesModifiers(_ flags: CGEventFlags) -> Bool { + (flags.rawValue & modifierMask) == modifiers } var displayString: String { diff --git a/Sources/CopyCat/Logger.swift b/Sources/CopyCat/Logger.swift index 51069ff..1dd988f 100644 --- a/Sources/CopyCat/Logger.swift +++ b/Sources/CopyCat/Logger.swift @@ -85,6 +85,7 @@ enum Log { static let cmdV = AppLogger(category: "Local") static let cmdOptV = AppLogger(category: "Broadcast") static let watchdog = AppLogger(category: "Watchdog") + static let secure = AppLogger(category: "SecureInput") } struct AppLogger { diff --git a/Sources/CopyCat/Notifications.swift b/Sources/CopyCat/Notifications.swift index 216d1a3..a5bc6a2 100644 --- a/Sources/CopyCat/Notifications.swift +++ b/Sources/CopyCat/Notifications.swift @@ -23,11 +23,13 @@ enum Notifier { } } - static func secureInputBlocked(_ owner: SecureInput.Owner?) { + static func secureInputBlocked(_ presentation: SecureInputPresentation) { guard available else { return } let content = UNMutableNotificationContent() - content.title = "CopyCat — paste blocked" - content.body = blockedBody(owner) + content.title = presentation.title + // Advice only — banners get ~4 visible lines and the title already + // names the culprit; the full cause stays in the menu and log. + content.body = presentation.advice content.sound = .default let request = UNNotificationRequest(identifier: secureInputID, content: content, trigger: nil) @@ -38,15 +40,4 @@ enum Notifier { guard available else { return } UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [secureInputID]) } - - private static func blockedBody(_ owner: SecureInput.Owner?) -> String { - let restore = HotkeyBinding.localPaste.displayString - if let owner, owner.isOrphaned { - return "An orphaned Secure Input lock (owner pid \(owner.pid) has exited) is suppressing \(restore) for every app. Log out and back in to clear it." - } - if let owner { - return "\(owner.description) is holding Secure Input, which suppresses \(restore) for every app. Quit or refocus it, or finish its password prompt." - } - return "Secure Input is suppressing \(restore) for every app. Log out and back in if it persists." - } } diff --git a/Sources/CopyCat/PasteAttemptSensor.swift b/Sources/CopyCat/PasteAttemptSensor.swift new file mode 100644 index 0000000..723f263 --- /dev/null +++ b/Sources/CopyCat/PasteAttemptSensor.swift @@ -0,0 +1,115 @@ +import AppKit +import IOKit.hid + +// Detects ⌘V while Secure Input is blocking the event tap. Secure Input hides +// keyboard events from CGEventTaps but not from IOHID device monitoring, so +// this is the only way to know the user just tried to paste while blocked — +// the tap literally never sees the keystroke. Listen-only (no seize): the +// original event still reaches the frontmost app untouched. Non-chord keys +// are discarded in the callback; nothing is stored or logged. +// +// Reports the full modifier set so the caller can distinguish the exact local +// paste chord (degradable) from broadcast variants (explain-only). +// +// Requires the Input Monitoring TCC grant (Accessibility approval typically +// satisfies it). The watcher arms this only while a blockage episode is live, +// so HID monitoring is off in normal operation. +@MainActor +final class PasteAttemptSensor { + private var manager: IOHIDManager? + /// HID usages (0xE0–0xE7) of modifiers currently held down. + private var downModifiers: Set = [] + private let onPasteAttempt: (CGEventFlags) -> Void + + init(onPasteAttempt: @escaping (CGEventFlags) -> Void) { + self.onPasteAttempt = onPasteAttempt + } + + static var accessGranted: Bool { + IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted + } + + /// Triggers the one-time OS consent prompt; returns whether access is + /// granted right now (a fresh prompt returns false until the user acts). + static func requestAccess() -> Bool { + IOHIDRequestAccess(kIOHIDRequestTypeListenEvent) + } + + func start() { + guard manager == nil else { return } + guard Self.accessGranted else { return } + + let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + let match: [[String: Any]] = [[ + kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop, + kIOHIDDeviceUsageKey: kHIDUsage_GD_Keyboard, + ]] + IOHIDManagerSetDeviceMatchingMultiple(manager, match as CFArray) + + let context = Unmanaged.passUnretained(self).toOpaque() + IOHIDManagerRegisterInputValueCallback(manager, { context, _, _, value in + guard let context else { return } + let sensor = Unmanaged.fromOpaque(context).takeUnretainedValue() + // Scheduled on the main runloop, so the callback lands on main. + MainActor.assumeIsolated { + sensor.handle(value: value) + } + }, context) + + IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + let rc = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + guard rc == kIOReturnSuccess else { + Log.secure.error("paste-attempt sensor: IOHIDManagerOpen failed (\(rc))") + IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + return + } + self.manager = manager + Log.secure.info("paste-attempt sensor armed") + } + + func stop() { + guard let manager else { return } + IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + self.manager = nil + downModifiers = [] + Log.secure.info("paste-attempt sensor disarmed") + } + + private func handle(value: IOHIDValue) { + let element = IOHIDValueGetElement(value) + guard IOHIDElementGetUsagePage(element) == UInt32(kHIDPage_KeyboardOrKeypad) else { return } + let usage = Int(IOHIDElementGetUsage(element)) + let pressed = IOHIDValueGetIntegerValue(value) != 0 + + switch usage { + case kHIDUsage_KeyboardLeftControl...kHIDUsage_KeyboardRightGUI: + if pressed { downModifiers.insert(usage) } else { downModifiers.remove(usage) } + case kHIDUsage_KeyboardV: + if pressed && currentFlags().contains(.maskCommand) { + onPasteAttempt(currentFlags()) + } + default: + break + } + } + + private func currentFlags() -> CGEventFlags { + var flags = CGEventFlags() + for usage in downModifiers { + switch usage { + case kHIDUsage_KeyboardLeftControl, kHIDUsage_KeyboardRightControl: + flags.insert(.maskControl) + case kHIDUsage_KeyboardLeftShift, kHIDUsage_KeyboardRightShift: + flags.insert(.maskShift) + case kHIDUsage_KeyboardLeftAlt, kHIDUsage_KeyboardRightAlt: + flags.insert(.maskAlternate) + case kHIDUsage_KeyboardLeftGUI, kHIDUsage_KeyboardRightGUI: + flags.insert(.maskCommand) + default: + break + } + } + return flags + } +} diff --git a/Sources/CopyCat/PasteHandler.swift b/Sources/CopyCat/PasteHandler.swift index 1d098ca..b396a28 100644 --- a/Sources/CopyCat/PasteHandler.swift +++ b/Sources/CopyCat/PasteHandler.swift @@ -8,17 +8,12 @@ final class PasteHandler: @unchecked Sendable { private var tap: CFMachPort? private var runloopSource: CFRunLoopSource? private var watchdog: Timer? - private var uiTimer: Timer? private var lastEventTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() private var starvedRebuilds = 0 - // Dedup flag so the blocked→restored transition is logged/notified once - // each, not every 30s tick. Menu state is pushed separately via publishStatus. - private var secureInputWarned = false func start() { installTap() startWatchdog() - startUIRefresh() NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main @@ -33,8 +28,6 @@ final class PasteHandler: @unchecked Sendable { NSWorkspace.shared.notificationCenter.removeObserver(self) watchdog?.invalidate() watchdog = nil - uiTimer?.invalidate() - uiTimer = nil teardownTap() } @@ -44,27 +37,18 @@ final class PasteHandler: @unchecked Sendable { return CGEvent.tapIsEnabled(tap: tap) } - // Push current tap + Secure Input state into the observable menu model. - // The menu can't read these live (the reads aren't observable, so SwiftUI - // froze them at launch — the old "Tap off" bug), so we publish on every - // state change and once per watchdog tick. Callers are always on the main - // thread (start / wake observer / main-runloop timer), so assumeIsolated is - // safe and avoids an async hop. Assigns only on change to skip churn. + // Push tap state into the observable menu model. The menu can't read it + // live (the read isn't observable, so SwiftUI froze it at launch — the old + // "Tap off" bug), so we publish on every state change and once per + // watchdog tick; SecureInputWatcher also refreshes it on its own poll. + // Callers are always on the main thread (start / wake observer / + // main-runloop timer), so assumeIsolated is safe and avoids an async hop. + // Secure Input state is owned end-to-end by SecureInputWatcher. private func publishStatus() { let enabled = isTapEnabled - // Gate the menu warning on the same allowlist as the alert, so the two - // surfaces stay consistent: no "blocked by " while you're just - // on a login page. - let blocker: String? - if case .blocked(let owner) = SecureInput.status(), frontmostIsTarget() { - blocker = owner?.description ?? "unknown source" - } else { - blocker = nil - } MainActor.assumeIsolated { let model = StatusModel.shared if model.tapEnabled != enabled { model.tapEnabled = enabled } - if model.secureInputBlocker != blocker { model.secureInputBlocker = blocker } } } @@ -130,16 +114,6 @@ final class PasteHandler: @unchecked Sendable { NSWorkspace.shared.frontmostApplication?.bundleIdentifier } - // The same allowlist gate CopyCat uses to decide whether to act on ⌘V. - // The watchdog reuses it so Secure Input alerts fire only when the user is - // in an app CopyCat handles: a focused password field in some other app - // (a browser, say) blocks the tap session-wide too, but isn't the user's - // concern at that moment, so alerting would just be noise. - private func frontmostIsTarget() -> Bool { - guard let id = frontmostBundleID() else { return false } - return Settings.targetBundleIDs.contains(id) - } - private func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { lastEventTime = CFAbsoluteTimeGetCurrent() @@ -202,18 +176,6 @@ final class PasteHandler: @unchecked Sendable { } } - // The 30s watchdog is too coarse for the menu: the Secure Input warning - // would lag up to 30s appearing and clearing. A short poll keeps the menu - // within a couple seconds of reality. publishStatus only writes the model - // on change, so steady state is just a cheap read with no re-render. - private static let uiRefreshInterval: TimeInterval = 2 - - private func startUIRefresh() { - uiTimer = Timer.scheduledTimer(withTimeInterval: Self.uiRefreshInterval, repeats: true) { [weak self] _ in - self?.publishStatus() - } - } - // How long the tap must be silent before we look for blocked delivery paths. // Silence is NOT proof of a dead tap — it's identical to the user simply not // typing — so it only gates checks that have an independent signal. Secure @@ -272,22 +234,15 @@ final class PasteHandler: @unchecked Sendable { } // Long silence with the tap still "enabled" has one confirmed cause: - // Secure Input swallowing key events. Idle looks identical, so we surface - // Secure Input but deliberately do NOT reinstall on silence — that just - // churned a healthy, merely-idle tap every 30s. A reinstall can't defeat - // Secure Input anyway. Only alert when the user is in an app CopyCat - // handles; otherwise stay quiet. + // Secure Input swallowing key events. A reinstall can't defeat Secure + // Input, and rebuilding on silence just churned a healthy, merely-idle + // tap every 30s — so skip the rebuild path entirely. Alerting the user + // is SecureInputWatcher's job; this branch only protects the tap. if enabled && silent > Self.staleTapInterval, case .blocked(let owner) = SecureInput.status() { - if frontmostIsTarget() { - logSecureInputBlocked(owner) - } else { - Log.watchdog.info("Secure Input active (\(owner?.description ?? "unknown source")); frontmost not a target app — not alerting") - } + Log.watchdog.info("tap silent \(Int(silent))s with Secure Input active (\(owner?.description ?? "unknown source")) — not rebuilding") return } - noteSecureInputCleared() - if enabled { starvedRebuilds = 0 Log.watchdog.info("tap.enabled=true") @@ -302,36 +257,6 @@ final class PasteHandler: @unchecked Sendable { } } - // Log the blocked edge once (prominent, actionable), then a quiet per-tick - // line so a `tail -f` keeps showing the live cause. Remediation differs by - // owner: a live app can be quit/refocused, but an orphaned lock survives - // that and needs a WindowServer reset. - private func logSecureInputBlocked(_ owner: SecureInput.Owner?) { - let restore = HotkeyBinding.localPaste.displayString - guard !secureInputWarned else { - Log.watchdog.info("still blocked by Secure Input (\(owner?.description ?? "unknown source"))") - return - } - secureInputWarned = true - if let owner, owner.isOrphaned { - Log.tap.error("blocked by an orphaned Secure Input lock — its owner (pid \(owner.pid)) exited without releasing it; \(restore) stays dead session-wide until a WindowServer reset (log out and back in) clears it") - } else if let owner { - Log.tap.error("blocked by Secure Input held by \(owner.description) — key events are suppressed for every app session-wide; quit/refocus \(owner.appName ?? "that process") or finish its password prompt to restore \(restore)") - } else { - Log.tap.error("blocked by Secure Input (owner unknown) — key events are suppressed session-wide; if it persists, log out and back in to restore \(restore)") - } - Notifier.secureInputBlocked(owner) - } - - // Log the blocked→restored edge exactly once so a `tail -f` shows a clear - // recovery line instead of events silently resuming. - private func noteSecureInputCleared() { - guard secureInputWarned else { return } - secureInputWarned = false - Log.tap.info("Secure Input cleared — \(HotkeyBinding.localPaste.displayString) interception restored") - Notifier.secureInputCleared() - } - private func ensureAccessibility(prompt: Bool) -> Bool { // Hardcoded value of kAXTrustedCheckOptionPrompt — referencing the // global var trips Swift 6 strict concurrency (it's non-Sendable). @@ -353,6 +278,18 @@ extension NSPasteboard { ] return !Set(types).isDisjoint(with: imageTypes) } + + // Any flavor the frontmost app might paste on its own for a raw ⌘V. The + // degraded (Secure Input) paste path can't swallow the original keystroke, + // so it must only run when the terminal would paste nothing itself — + // otherwise the terminal's paste and CopyCat's typed path both land. + var hasTextLikeType: Bool { + guard let types else { return false } + let textTypes: Set = [ + .string, .rtf, .html, .fileURL, .URL, + ] + return !Set(types).isDisjoint(with: textTypes) + } } enum Typer { diff --git a/Sources/CopyCat/SecureInput.swift b/Sources/CopyCat/SecureInput.swift index 3279435..c2ed7a4 100644 --- a/Sources/CopyCat/SecureInput.swift +++ b/Sources/CopyCat/SecureInput.swift @@ -11,12 +11,22 @@ import IOKit // explicitly instead of churning the tap. enum SecureInput { /// Who holds the lock, and whether that process still exists. - struct Owner: Equatable { + struct Owner: Equatable, Sendable { let pid: pid_t - /// Localized GUI app name (e.g. "Ghostty"); nil for daemons and for - /// owners that have already exited. + /// Localized GUI app name (e.g. "Ghostty") or the BSD process name for + /// daemons; nil only when the owner has already exited. let appName: String? let isRunning: Bool + /// Bundle identifier when the owner is a GUI app — the stable key the + /// triage layer matches against (loginwindow, target terminals). + let bundleID: String? + + init(pid: pid_t, appName: String?, isRunning: Bool, bundleID: String? = nil) { + self.pid = pid + self.appName = appName + self.isRunning = isRunning + self.bundleID = bundleID + } /// True when the lock points at a PID that no longer exists — the lock /// leaked because the owner died without balancing its enable. This is @@ -54,7 +64,17 @@ enum SecureInput { // PID reuse is a tolerated race here: if `pid` was recycled we may label // a dead orphan as "running", but the lock itself is what we report on. let running = app != nil || processExists(pid) - return Owner(pid: pid, appName: app?.localizedName, isRunning: running) + let name = app?.localizedName ?? (running ? processName(pid) : nil) + return Owner(pid: pid, appName: name, isRunning: running, bundleID: app?.bundleIdentifier) + } + + /// BSD process name for non-GUI holders (daemons, helpers) so the alert + /// can name the culprit instead of showing a bare pid. + private static func processName(_ pid: pid_t) -> String? { + var buffer = [CChar](repeating: 0, count: 128) + let length = proc_name(pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + return String(cString: buffer) } /// PID recorded as the Secure Input owner, or nil when the key is absent. diff --git a/Sources/CopyCat/SecureInputHUD.swift b/Sources/CopyCat/SecureInputHUD.swift new file mode 100644 index 0000000..087aeef --- /dev/null +++ b/Sources/CopyCat/SecureInputHUD.swift @@ -0,0 +1,298 @@ +import AppKit +import SwiftUI + +// Floating toast for Secure Input alerts. A notification banner alone is easy +// to miss (Focus modes, banner timeout); this panel is unmissable at alert +// time, then collapses to a small pill that stays up for the whole episode — +// so a paste attempt minutes later still lands next to a visible explanation. +// +// Lifecycle: showBlocked → expanded card (auto-collapses to pill) → +// showRestored (brief green confirmation) → hidden. Dismiss hides the panel +// for the rest of the episode; the paste-attempt sensor can resurface it. +@MainActor +final class SecureInputHUD { + static let shared = SecureInputHUD() + + enum Phase: Equatable { + case hidden + case expanded + case pill + /// Degraded paste in flight: the sensor caught ⌘V and CopyCat is + /// typing the path despite Secure Input. Brief, then back to the pill. + case attempting + case restored + } + + final class Model: ObservableObject { + @Published var phase: Phase = .hidden + @Published var presentation: SecureInputPresentation? + // Wired by the HUD so the SwiftUI views can drive panel-level behavior + // (resize + phase changes) without owning the panel. + var onExpandRequested: (() -> Void)? + var onDismissRequested: (() -> Void)? + } + + private let model = Model() + private var panel: NSPanel? + private var hosting: NSHostingView? + private var collapseTimer: Timer? + private var hideTimer: Timer? + + private static let collapseAfter: TimeInterval = 10 + private static let restoredVisibleFor: TimeInterval = 2.5 + private static let attemptVisibleFor: TimeInterval = 3 + + private init() { + model.onExpandRequested = { [weak self] in self?.expand() } + model.onDismissRequested = { [weak self] in self?.dismissEpisode() } + } + + // MARK: - Public surface + + func showBlocked(_ presentation: SecureInputPresentation) { + model.presentation = presentation + setPhase(.expanded) + restartCollapseTimer() + cancelHideTimer() + } + + func showRestored() { + setPhase(.restored) + cancelCollapseTimer() + hideTimer?.invalidate() + hideTimer = Timer.scheduledTimer(withTimeInterval: Self.restoredVisibleFor, repeats: false) { [weak self] _ in + MainActor.assumeIsolated { self?.hide() } + } + } + + /// Degraded-paste feedback: shows briefly, then settles on the blocked + /// pill (the episode is still live — only this one paste was attempted). + func showDegradedAttempt(_ presentation: SecureInputPresentation) { + model.presentation = presentation + setPhase(.attempting) + cancelCollapseTimer() + hideTimer?.invalidate() + hideTimer = Timer.scheduledTimer(withTimeInterval: Self.attemptVisibleFor, repeats: false) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.model.phase == .attempting else { return } + self.setPhase(.pill) + } + } + } + + func hide() { + cancelCollapseTimer() + cancelHideTimer() + setPhase(.hidden) + } + + // MARK: - Phase transitions + + private func expand() { + setPhase(.expanded) + restartCollapseTimer() + } + + private func dismissEpisode() { + hide() + } + + private func restartCollapseTimer() { + collapseTimer?.invalidate() + collapseTimer = Timer.scheduledTimer(withTimeInterval: Self.collapseAfter, repeats: false) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.model.phase == .expanded else { return } + self.setPhase(.pill) + } + } + } + + private func cancelCollapseTimer() { + collapseTimer?.invalidate() + collapseTimer = nil + } + + private func cancelHideTimer() { + hideTimer?.invalidate() + hideTimer = nil + } + + private func setPhase(_ phase: Phase) { + model.phase = phase + if phase == .hidden { + panel?.orderOut(nil) + return + } + ensurePanel() + // SwiftUI applies the published change on the next runloop turn, so + // measure and place the panel after that turn or fittingSize is stale. + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + self?.layoutAndShow() + } + } + } + + // MARK: - Panel plumbing + + private func ensurePanel() { + guard panel == nil else { return } + let hosting = NSHostingView(rootView: HUDRoot(model: model)) + let panel = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.becomesKeyOnlyIfNeeded = true + panel.contentView = hosting + self.panel = panel + self.hosting = hosting + } + + private func layoutAndShow() { + guard model.phase != .hidden, let panel, let hosting else { return } + hosting.layoutSubtreeIfNeeded() + let size = hosting.fittingSize + guard size.width > 0, size.height > 0, let screen = NSScreen.main else { return } + // Top-center, just under the menu bar: adjacent to the menu-bar icon + // that carries the persistent badge, and clear of the top-right corner + // where notification banners land. + let frame = screen.visibleFrame + let origin = NSPoint( + x: frame.midX - size.width / 2, + y: frame.maxY - size.height - 10) + panel.setFrame(NSRect(origin: origin, size: size), display: true) + panel.orderFrontRegardless() + } +} + +// MARK: - SwiftUI content + +private struct HUDRoot: View { + @ObservedObject var model: SecureInputHUD.Model + + var body: some View { + switch model.phase { + case .hidden: + EmptyView() + case .expanded: + if let presentation = model.presentation { + ExpandedCard(presentation: presentation, model: model) + } + case .pill: + if let presentation = model.presentation { + BlockedPill(presentation: presentation, model: model) + } + case .attempting: + AttemptingPill() + case .restored: + RestoredPill() + } + } +} + +private struct ExpandedCard: View { + let presentation: SecureInputPresentation + @ObservedObject var model: SecureInputHUD.Model + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 28)) + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 5) { + Text(presentation.title) + .font(.headline) + // Advice only — the full cause (presentation.detail) lives in + // the notification, menu, and log; the toast stays scannable. + Text(presentation.advice) + .font(.callout) + HStack(spacing: 8) { + if let action = presentation.action { + Button(action.label) { + SecureInputActions.perform(action) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + Button("Dismiss") { + model.onDismissRequested?() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.top, 3) + } + } + .padding(14) + .frame(maxWidth: 420, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + } +} + +private struct BlockedPill: View { + let presentation: SecureInputPresentation + @ObservedObject var model: SecureInputHUD.Model + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "lock.shield.fill") + .foregroundStyle(.orange) + Text("\(HotkeyBinding.localPaste.displayString) blocked (\(presentation.pillLabel))") + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(.regularMaterial)) + .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 1)) + .contentShape(Capsule()) + .onTapGesture { + model.onExpandRequested?() + } + } +} + +private struct AttemptingPill: View { + var body: some View { + HStack(spacing: 6) { + Image(systemName: "bolt.fill") + .foregroundStyle(.orange) + Text("\(HotkeyBinding.localPaste.displayString) caught — pasting anyway (experimental)") + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(.regularMaterial)) + .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 1)) + } +} + +private struct RestoredPill: View { + var body: some View { + HStack(spacing: 6) { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.green) + Text("\(HotkeyBinding.localPaste.displayString) restored — Secure Input cleared") + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(.regularMaterial)) + .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 1)) + } +} diff --git a/Sources/CopyCat/SecureInputTriage.swift b/Sources/CopyCat/SecureInputTriage.swift new file mode 100644 index 0000000..8ebc49e --- /dev/null +++ b/Sources/CopyCat/SecureInputTriage.swift @@ -0,0 +1,299 @@ +import AppKit +import Foundation + +// Classification layer between raw Secure Input state and user-facing alerts. +// A raw "blocked" is not actionable by itself: most holds are legitimate and +// transient (a focused password field, sudo in a terminal, the lock screen). +// Alerting on those trains the user to ignore the warning. Everything here is +// pure — state in, judgment out — so the noise policy is unit-testable. + +/// Judgment about the current Secure Input hold, ordered roughly by severity. +enum SecureInputAssessment: Equatable, Sendable { + case clear + /// A hold that's normal right now: the frontmost app owns it (focused + /// password field), a system auth dialog, or loginwindow while the screen + /// is actually locked / screensaver is up. Never alert on these. + case expected(SecureInput.Owner) + /// loginwindow holds Secure Input while the session is unlocked. That + /// combination is never legitimate — it's the well-known stuck state left + /// behind by an unlock (biometric unlocks especially). + case stuckLoginwindow(SecureInput.Owner) + /// The frontmost app holds it AND it's one of CopyCat's target terminals — + /// almost always the terminal's own "Secure Keyboard Entry" feature, which + /// blocks ⌘V exactly where CopyCat matters. Expected briefly during + /// password prompts, so this only alerts after a long grace. + case terminalSecureEntry(SecureInput.Owner) + /// A live, non-frontmost process holds it. Legitimate holders release + /// within seconds (background password prompt); persistent ones are the + /// classic "app forgot to release" bug. + case backgroundHolder(SecureInput.Owner) + /// The owning process is dead — the lock leaked and nothing can release it + /// except a session reset. + case orphaned(SecureInput.Owner) + /// Blocked, but no owner PID could be resolved. + case unknownHolder +} + +extension SecureInputAssessment { + /// Stable identity for alert dedup: same kind + same holder = same episode. + /// nil means "never alert for this state". + var alertKey: String? { + switch self { + case .clear, .expected: + return nil + case .stuckLoginwindow(let o): + return "stuck-loginwindow:\(o.pid)" + case .terminalSecureEntry(let o): + return "terminal-ske:\(o.pid)" + case .backgroundHolder(let o): + return "background:\(o.pid)" + case .orphaned(let o): + return "orphaned:\(o.pid)" + case .unknownHolder: + return "unknown" + } + } + + /// How long the state must persist before alerting. Graces are the + /// false-positive brake: every legitimate hold pattern must fit inside + /// its bucket's grace or the alert becomes noise. + var alertGrace: TimeInterval { + switch self { + case .clear, .expected: + return .infinity + // Never legitimate while unlocked; the grace only debounces the + // few seconds loginwindow properly holds it during the unlock + // handoff itself. + case .stuckLoginwindow: + return 3 + // Terminals auto-enable Secure Keyboard Entry around tty password + // prompts (sudo, ssh); those routinely last tens of seconds. Only a + // persistent hold — the manual menu toggle — should alert. + case .terminalSecureEntry: + return 30 + // Background password prompts (autofill dialogs etc.) come and go + // within a few seconds. + case .backgroundHolder: + return 12 + // The IOKit key can linger for a beat after an owner exits cleanly. + case .orphaned: + return 5 + case .unknownHolder: + return 20 + } + } +} + +enum SecureInputTriage { + static let loginwindowBundleID = "com.apple.loginwindow" + + // System processes that legitimately hold Secure Input without being + // frontmost (out-of-process auth dialogs). + private static let expectedSystemHolders: Set = [ + "com.apple.SecurityAgent", + ] + + static func classify( + status: SecureInput.Status, + screenLocked: Bool, + frontmostPID: pid_t?, + targetBundleIDs: Set + ) -> SecureInputAssessment { + guard case .blocked(let owner) = status else { return .clear } + guard let owner else { return .unknownHolder } + + // loginwindow before the orphan check — it's always running, and the + // locked/unlocked split is the entire judgment for it. + if isLoginwindow(owner) { + return screenLocked ? .expected(owner) : .stuckLoginwindow(owner) + } + if owner.isOrphaned { + return .orphaned(owner) + } + if let bundleID = owner.bundleID, expectedSystemHolders.contains(bundleID) { + return .expected(owner) + } + if owner.pid == frontmostPID { + if let bundleID = owner.bundleID, targetBundleIDs.contains(bundleID) { + return .terminalSecureEntry(owner) + } + return .expected(owner) + } + return .backgroundHolder(owner) + } + + static func isLoginwindow(_ owner: SecureInput.Owner) -> Bool { + owner.bundleID == loginwindowBundleID || owner.appName == "loginwindow" + } +} + +/// Edge-triggered alert gate: holds must survive their grace period before +/// alerting, each episode (kind+pid) alerts exactly once, and `cleared` fires +/// exactly once per alerted episode. Pure — the caller injects the clock. +struct SecureInputAlertPolicy { + enum Action: Equatable { + case none + /// The state survived its grace — surface it now. Also fires when the + /// holder changes mid-episode (new culprit deserves a fresh alert). + case alert + /// A previously alerted episode ended. + case cleared + } + + private var pendingKey: String? + private var pendingSince: TimeInterval? + private(set) var alertedKey: String? + + var isAlerting: Bool { alertedKey != nil } + + mutating func evaluate(key: String?, grace: TimeInterval, now: TimeInterval) -> Action { + guard let key else { + pendingKey = nil + pendingSince = nil + if alertedKey != nil { + alertedKey = nil + return .cleared + } + return .none + } + if key != pendingKey { + pendingKey = key + pendingSince = now + } + if alertedKey == key { return .none } + if now - (pendingSince ?? now) >= grace { + alertedKey = key + return .alert + } + return .none + } +} + +/// Everything the surfaces (menu, HUD, notification) need to render one +/// blocked state, built in one place so all three tell the same story. +struct SecureInputPresentation: Equatable, Sendable { + enum Kind: Equatable, Sendable { + case expected, stuckLoginwindow, terminalSecureEntry, backgroundHolder, orphaned, unknown + } + enum Action: Equatable, Sendable { + case lockScreen + case quitApp(pid: pid_t, name: String) + + var label: String { + switch self { + case .lockScreen: return "Lock Screen to Fix" + case .quitApp(_, let name): return "Quit \(name)" + } + } + } + + let kind: Kind + /// Short one-liner for the menu caption. + let menuLabel: String + /// HUD / notification headline. + let title: String + /// Who's holding it and why that breaks paste. + let detail: String + /// What the user should do about it. + let advice: String + let action: Action? + + /// Short culprit tag for the collapsed HUD pill. + let pillLabel: String + + static func make(for assessment: SecureInputAssessment) -> SecureInputPresentation? { + let paste = HotkeyBinding.localPaste.displayString + switch assessment { + case .clear: + return nil + + case .expected(let owner): + return SecureInputPresentation( + kind: .expected, + menuLabel: "Secure Input active (\(owner.description))", + title: "Secure Input active", + detail: "\(owner.description) is holding Secure Input.", + advice: "Normal while a password prompt is focused.", + action: nil, + pillLabel: owner.description) + + // "loginwindow" as holder is often misattribution: macOS pins Secure + // Input on loginwindow when a background agent (password manager, + // browser password prompt) actually grabbed it — and then only + // quitting that app releases it; lock/unlock cycles won't. + case .stuckLoginwindow: + return SecureInputPresentation( + kind: .stuckLoginwindow, + menuLabel: "⚠ Blocked by Secure Input — loginwindow (stuck)", + title: "Paste blocked — Secure Input is stuck", + detail: "Secure Input is stuck attributed to loginwindow — usually a background password app (1Password, a browser) or an unlock that didn't release it.", + advice: "Quit password apps (1Password, browser) — or lock the screen and unlock by typing your password.", + action: .lockScreen, + pillLabel: "loginwindow") + + case .terminalSecureEntry(let owner): + let name = owner.appName ?? owner.description + return SecureInputPresentation( + kind: .terminalSecureEntry, + menuLabel: "⚠ Blocked by Secure Input (\(name))", + title: "Paste blocked by \(name)", + detail: "\(name)'s Secure Keyboard Entry is hiding \(paste) from CopyCat.", + advice: "Turn off Secure Keyboard Entry in \(name)'s menu if it persists.", + action: nil, + pillLabel: name) + + case .backgroundHolder(let owner): + let name = owner.appName ?? owner.description + return SecureInputPresentation( + kind: .backgroundHolder, + menuLabel: "⚠ Blocked by Secure Input (\(owner.description))", + title: "Paste blocked by \(owner.description)", + detail: "\(owner.description) is holding Secure Input from the background, hiding \(paste) from every app.", + advice: "Finish its password prompt, or quit it.", + // Quit is offered only for GUI apps: terminate() needs an + // NSRunningApplication, which daemons/CLI holders don't have. + action: owner.bundleID != nil ? .quitApp(pid: owner.pid, name: name) : nil, + pillLabel: owner.description) + + case .orphaned(let owner): + return SecureInputPresentation( + kind: .orphaned, + menuLabel: "⚠ Blocked by Secure Input (orphaned lock)", + title: "Paste blocked — orphaned Secure Input lock", + detail: "The process holding Secure Input (pid \(owner.pid)) exited without releasing it.", + advice: "Lock and unlock the screen; log out if it persists.", + action: .lockScreen, + pillLabel: "orphaned lock") + + case .unknownHolder: + return SecureInputPresentation( + kind: .unknown, + menuLabel: "⚠ Blocked by Secure Input (unknown source)", + title: "Paste blocked by Secure Input", + detail: "Something is holding Secure Input, but macOS won't name it.", + advice: "Lock and unlock the screen; log out if it persists.", + action: .lockScreen, + pillLabel: "unknown source") + } + } +} + +/// Executes a presentation's remediation action. Kept out of the views so the +/// menu and HUD share one implementation. +@MainActor +enum SecureInputActions { + static func perform(_ action: SecureInputPresentation.Action) { + switch action { + case .lockScreen: + Log.secure.info("user requested lock-screen remediation") + SessionLock.lockScreen() + case .quitApp(let pid, let name): + Log.secure.info("user requested quit of Secure Input holder \(name) (pid \(pid))") + guard let app = NSRunningApplication(processIdentifier: pid) else { + Log.secure.error("holder pid \(pid) is not a running application — cannot terminate") + return + } + app.terminate() + } + } +} diff --git a/Sources/CopyCat/SecureInputWatcher.swift b/Sources/CopyCat/SecureInputWatcher.swift new file mode 100644 index 0000000..3e6a67f --- /dev/null +++ b/Sources/CopyCat/SecureInputWatcher.swift @@ -0,0 +1,332 @@ +import AppKit +import CoreGraphics + +// Owns Secure Input detection and every user-facing surface for it: menu +// model, menu-bar icon badge, HUD toast, and notification banner. PasteHandler +// keeps only tap health — it can't own alerting because a blocked tap receives +// nothing, so it can't even see the state change promptly. +// +// Detection is layered: +// - a 2s poll (1s while blocked, to announce recovery fast), +// - probes at the moments a block is born or becomes relevant: screen +// unlock (the stuck-loginwindow bug appears exactly there), wake, +// screensaver stop, and app activation, +// - optionally the IOHID paste-attempt sensor while blocked (the event tap +// is blind then, but IOHID still sees ⌘V), so the toast can fire at the +// exact moment the user tries to paste. +@MainActor +final class SecureInputWatcher { + static let shared = SecureInputWatcher() + + /// Lets one poll publish both halves of the menu header; the watcher has + /// no other reason to know about the tap. + var tapEnabledProvider: (@MainActor () -> Bool)? + + private var pollTimer: Timer? + private var pollingWhileBlocked = false + private var workspaceObservers: [NSObjectProtocol] = [] + private var distributedObservers: [NSObjectProtocol] = [] + + private var screenLocked = false + private var screensaverActive = false + + private var policy = SecureInputAlertPolicy() + private var currentPresentation: SecureInputPresentation? + /// True while the assessment is an alertable kind — including the grace + /// window before an alert fires. The sensor arms on this, not on + /// policy.isAlerting, so a paste attempt in the first blocked seconds + /// still gets caught. + private var currentAlertable = false + /// alertKey the HUD/notification were actually shown for. Distinct from + /// policy.alertedKey: an alert that fires while the user is outside a + /// target app stays undisplayed until they enter one. + private var displayedKey: String? + + private var sensor: PasteAttemptSensor? + private var lastDegradedPasteAt: TimeInterval = 0 + /// Physical double-taps aside, one ⌘V should produce one typed path. + private static let degradedPasteCooldown: TimeInterval = 1 + + private static let idleInterval: TimeInterval = 2 + private static let blockedInterval: TimeInterval = 1 + + func start() { + seedScreenLockState() + installObservers() + reschedulePoll(blocked: false) + evaluate(reason: "startup") + } + + func stop() { + pollTimer?.invalidate() + pollTimer = nil + workspaceObservers.forEach { NSWorkspace.shared.notificationCenter.removeObserver($0) } + workspaceObservers = [] + distributedObservers.forEach { DistributedNotificationCenter.default().removeObserver($0) } + distributedObservers = [] + disarmSensor() + SecureInputHUD.shared.hide() + } + + // MARK: - Signals + + // The lock-screen distributed notifications only fire on transitions, so + // seed the flag from the session dictionary in case we launch while locked + // (login item starting before first unlock completes). + private func seedScreenLockState() { + if let dict = CGSessionCopyCurrentDictionary() as? [String: Any] { + screenLocked = (dict["CGSSessionScreenIsLocked"] as? Bool) ?? false + } + } + + private func installObservers() { + let dnc = DistributedNotificationCenter.default() + func distributed(_ name: String, _ handler: @escaping @MainActor (SecureInputWatcher) -> Void) { + let token = dnc.addObserver( + forName: Notification.Name(name), object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + handler(self) + } + } + distributedObservers.append(token) + } + + distributed("com.apple.screenIsLocked") { watcher in + watcher.screenLocked = true + watcher.evaluate(reason: "screen locked") + } + distributed("com.apple.screenIsUnlocked") { watcher in + watcher.screenLocked = false + watcher.evaluate(reason: "screen unlocked") + // The stuck-loginwindow state is born at unlock: loginwindow holds + // Secure Input legitimately during the handoff, then either + // releases within a couple seconds or never does. Probe on both + // sides of the alert grace so a stuck hold alerts within ~5s. + watcher.scheduleProbe(after: 2, reason: "post-unlock probe") + watcher.scheduleProbe(after: 6, reason: "post-unlock probe") + } + distributed("com.apple.screensaver.didstart") { watcher in + watcher.screensaverActive = true + watcher.evaluate(reason: "screensaver started") + } + distributed("com.apple.screensaver.didstop") { watcher in + watcher.screensaverActive = false + watcher.evaluate(reason: "screensaver stopped") + watcher.scheduleProbe(after: 2, reason: "post-screensaver probe") + } + + let wnc = NSWorkspace.shared.notificationCenter + func workspace(_ name: Notification.Name, _ handler: @escaping @MainActor (SecureInputWatcher) -> Void) { + let token = wnc.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + handler(self) + } + } + workspaceObservers.append(token) + } + + // App activation matters twice over: the frontmost app is an input to + // classification, and entering a target terminal is when a deferred + // alert becomes worth displaying. + workspace(NSWorkspace.didActivateApplicationNotification) { watcher in + watcher.evaluate(reason: "app activated") + } + workspace(NSWorkspace.didWakeNotification) { watcher in + watcher.evaluate(reason: "wake") + watcher.scheduleProbe(after: 3, reason: "post-wake probe") + } + } + + private func scheduleProbe(after seconds: TimeInterval, reason: String) { + Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { [weak self] _ in + MainActor.assumeIsolated { + self?.evaluate(reason: reason) + } + } + } + + private func reschedulePoll(blocked: Bool) { + guard pollTimer == nil || blocked != pollingWhileBlocked else { return } + pollingWhileBlocked = blocked + pollTimer?.invalidate() + let interval = blocked ? Self.blockedInterval : Self.idleInterval + pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + self?.evaluate(reason: "poll") + } + } + } + + // MARK: - Core evaluation + + private func evaluate(reason: String) { + let status = SecureInput.status() + let frontmost = NSWorkspace.shared.frontmostApplication + let assessment = SecureInputTriage.classify( + status: status, + screenLocked: screenLocked || screensaverActive, + frontmostPID: frontmost?.processIdentifier, + targetBundleIDs: Settings.targetBundleIDs) + + let presentation = SecureInputPresentation.make(for: assessment) + currentPresentation = presentation + currentAlertable = assessment.alertKey != nil + publishModel(presentation) + + let action = policy.evaluate( + key: assessment.alertKey, + grace: assessment.alertGrace, + now: Date().timeIntervalSinceReferenceDate) + + switch action { + case .alert: + guard let presentation, let key = assessment.alertKey else { break } + Log.secure.error("Secure Input alert (\(reason)): \(presentation.title) — \(presentation.detail) \(presentation.advice)") + if isTargetFrontmost(frontmost) { + display(presentation, key: key) + } else { + // Not noise-worthy where the user is right now; the app + // activation probe displays it the moment they enter a + // terminal. The menu badge shows regardless via publishModel. + Log.secure.info("alert display deferred — frontmost is not a target app") + } + + case .cleared: + Log.secure.info("Secure Input cleared (\(reason)) — \(HotkeyBinding.localPaste.displayString) interception restored") + Notifier.secureInputCleared() + if displayedKey != nil { + SecureInputHUD.shared.showRestored() + } else { + SecureInputHUD.shared.hide() + } + displayedKey = nil + + case .none: + // Deferred display: alerted earlier while the user was elsewhere, + // and they've now entered a target app with the episode still live. + if let alerted = policy.alertedKey, + displayedKey != alerted, + assessment.alertKey == alerted, + let presentation, + isTargetFrontmost(frontmost) { + display(presentation, key: alerted) + } + } + + updateSensor(armed: currentAlertable) + reschedulePoll(blocked: assessment != .clear) + } + + private func display(_ presentation: SecureInputPresentation, key: String) { + displayedKey = key + SecureInputHUD.shared.showBlocked(presentation) + Notifier.secureInputBlocked(presentation) + } + + private func publishModel(_ presentation: SecureInputPresentation?) { + let tapEnabled = tapEnabledProvider?() + let alerting = policy.isAlerting + let model = StatusModel.shared + if let tapEnabled, model.tapEnabled != tapEnabled { model.tapEnabled = tapEnabled } + if model.secureInput != presentation { model.secureInput = presentation } + if model.secureInputAlerting != alerting { model.secureInputAlerting = alerting } + } + + private func isTargetFrontmost(_ app: NSRunningApplication?) -> Bool { + guard let id = app?.bundleIdentifier else { return false } + return Settings.targetBundleIDs.contains(id) + } + + // MARK: - Paste-attempt sensor + + // Armed only while a blockage episode is live (alertable kind, including + // its grace window): the sensor exists to catch "user pressed paste while + // blocked", and keeping HID monitoring off the rest of the time is both + // cheaper and the right privacy posture. + private func updateSensor(armed: Bool) { + if armed { + armSensor() + } else { + disarmSensor() + } + } + + private func armSensor() { + guard sensor == nil else { return } + guard PasteAttemptSensor.accessGranted else { + Log.secure.info("paste-attempt sensor unavailable — Input Monitoring not granted") + return + } + let sensor = PasteAttemptSensor { [weak self] flags in + self?.notePasteAttempt(flags: flags) + } + sensor.start() + self.sensor = sensor + } + + private func disarmSensor() { + sensor?.stop() + sensor = nil + } + + private func notePasteAttempt(flags: CGEventFlags) { + guard currentAlertable, let presentation = currentPresentation else { return } + guard isTargetFrontmost(NSWorkspace.shared.frontmostApplication) else { return } + let pasteboard = NSPasteboard.general + // If the clipboard has no image, CopyCat wouldn't have acted anyway — + // the failed paste the user is seeing isn't ours to explain. + guard pasteboard.hasImageType else { return } + + if degradedPasteAllowed(presentation: presentation, flags: flags, pasteboard: pasteboard) { + let now = Date().timeIntervalSinceReferenceDate + guard now - lastDegradedPasteAt > Self.degradedPasteCooldown else { return } + lastDegradedPasteAt = now + Log.secure.info("degraded paste: \(HotkeyBinding.localPaste.displayString) seen via HID while blocked — typing image path despite Secure Input (experimental)") + SecureInputHUD.shared.showDegradedAttempt(presentation) + DispatchQueue.global(qos: .userInitiated).async { + ImagePaste.handleLocal() + } + return + } + + Log.secure.info("paste attempt while blocked — surfacing HUD") + displayedKey = policy.alertedKey ?? displayedKey + SecureInputHUD.shared.showBlocked(presentation) + Notifier.secureInputBlocked(presentation) + } + + // The degraded path can't swallow the original ⌘V (that needs a seizing + // virtual HID device), so it runs only when the raw keystroke is a no-op + // for the terminal: exact local-paste chord, image-only clipboard (no + // text flavor the terminal would paste itself). Terminal-SKE blocks are + // excluded — that state usually means a password prompt is active in the + // very terminal we'd type into. + private func degradedPasteAllowed( + presentation: SecureInputPresentation, + flags: CGEventFlags, + pasteboard: NSPasteboard + ) -> Bool { + guard Settings.enableLocalPaste else { return false } + guard HotkeyBinding.localPaste.matchesModifiers(flags) else { return false } + guard presentation.kind != .terminalSecureEntry, presentation.kind != .expected else { return false } + return !pasteboard.hasTextLikeType + } + + /// Menu action: request Input Monitoring for the sensor. The OS shows its + /// consent prompt at most once; afterwards the toggle lives in System + /// Settings, so open the pane when the request doesn't grant immediately. + func requestSensorAccess() { + guard !PasteAttemptSensor.accessGranted else { return } + if !PasteAttemptSensor.requestAccess() { + let pane = "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" + if let url = URL(string: pane) { + NSWorkspace.shared.open(url) + } + } + } + + var sensorAccessGranted: Bool { PasteAttemptSensor.accessGranted } +} diff --git a/Sources/CopyCat/SessionLock.swift b/Sources/CopyCat/SessionLock.swift new file mode 100644 index 0000000..aec2d8d --- /dev/null +++ b/Sources/CopyCat/SessionLock.swift @@ -0,0 +1,38 @@ +import Foundation + +// Locks the screen programmatically for Secure Input remediation. There is no +// public API for this: posting synthetic ⌃⌘Q can be swallowed by the very +// Secure Input state we're trying to clear, so we call login.framework's +// private SACLockScreenImmediate — the same call the Apple menu's Lock Screen +// item makes. Private-API risk is acceptable here: the app is notarized but +// not sandboxed/MAS, and the failure mode is a logged no-op. +enum SessionLock { + private typealias LockFn = @convention(c) () -> Int32 + + // Resolved once and cached; the framework handle is deliberately never + // dlclosed (unloading system frameworks mid-process is riskier than the + // one-time leak). + private static let lockFn: LockFn? = { + let path = "/System/Library/PrivateFrameworks/login.framework/Versions/Current/login" + guard let handle = dlopen(path, RTLD_NOW) else { + Log.secure.error("SessionLock: dlopen(login.framework) failed — \(String(cString: dlerror()))") + return nil + } + guard let sym = dlsym(handle, "SACLockScreenImmediate") else { + Log.secure.error("SessionLock: SACLockScreenImmediate not found in login.framework") + return nil + } + return unsafeBitCast(sym, to: LockFn.self) + }() + + static func lockScreen() { + guard let lockFn else { + Log.secure.error("SessionLock: lock unavailable — lock manually with ⌃⌘Q") + return + } + let rc = lockFn() + if rc != 0 { + Log.secure.error("SessionLock: SACLockScreenImmediate returned \(rc)") + } + } +} diff --git a/Sources/CopyCat/StatusModel.swift b/Sources/CopyCat/StatusModel.swift index ccda282..356940c 100644 --- a/Sources/CopyCat/StatusModel.swift +++ b/Sources/CopyCat/StatusModel.swift @@ -12,8 +12,12 @@ final class StatusModel: ObservableObject { static let shared = StatusModel() @Published var tapEnabled = false - /// Label of whoever holds session-wide Secure Input; nil when clear. - @Published var secureInputBlocker: String? + /// Current Secure Input state for the menu; nil when clear. Includes + /// benign holds (expected kind) so the menu can explain them quietly. + @Published var secureInput: SecureInputPresentation? + /// True only for alert-worthy blocks — drives the menu-bar icon badge and + /// the orange menu treatment. + @Published var secureInputAlerting = false // "CopyCat" for the release build, "CopyCat Dev" for the dev build. Read // from CFBundleDisplayName (set per-config in build-app.sh) so the two diff --git a/Tests/CopyCatTests/SecureInputTriageTests.swift b/Tests/CopyCatTests/SecureInputTriageTests.swift new file mode 100644 index 0000000..e3c517c --- /dev/null +++ b/Tests/CopyCatTests/SecureInputTriageTests.swift @@ -0,0 +1,189 @@ +import XCTest +@testable import CopyCat + +final class SecureInputTriageTests: XCTestCase { + private let targets: Set = ["com.mitchellh.ghostty", "com.apple.Terminal"] + + private func owner( + pid: pid_t = 500, + appName: String? = "SomeApp", + isRunning: Bool = true, + bundleID: String? = "com.example.someapp" + ) -> SecureInput.Owner { + SecureInput.Owner(pid: pid, appName: appName, isRunning: isRunning, bundleID: bundleID) + } + + // MARK: - Classification + + func testClearStatusIsClear() { + XCTAssertEqual( + SecureInputTriage.classify( + status: .clear, screenLocked: false, frontmostPID: nil, targetBundleIDs: targets), + .clear) + } + + func testLoginwindowWhileUnlockedIsStuck() { + let lw = owner(pid: 179, appName: "loginwindow", bundleID: "com.apple.loginwindow") + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: lw), screenLocked: false, frontmostPID: 42, targetBundleIDs: targets), + .stuckLoginwindow(lw)) + } + + func testLoginwindowWhileLockedIsExpected() { + let lw = owner(pid: 179, appName: "loginwindow", bundleID: "com.apple.loginwindow") + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: lw), screenLocked: true, frontmostPID: 42, targetBundleIDs: targets), + .expected(lw)) + } + + // Daemon-style resolution can miss the bundle ID; the process name alone + // must still route loginwindow into the stuck bucket. + func testLoginwindowRecognizedByNameAlone() { + let lw = owner(pid: 179, appName: "loginwindow", bundleID: nil) + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: lw), screenLocked: false, frontmostPID: nil, targetBundleIDs: targets), + .stuckLoginwindow(lw)) + } + + func testFrontmostHolderIsExpected() { + let holder = owner(pid: 900, appName: "Safari", bundleID: "com.apple.Safari") + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: holder), screenLocked: false, frontmostPID: 900, targetBundleIDs: targets), + .expected(holder)) + } + + func testFrontmostTargetTerminalIsTerminalSecureEntry() { + let ghostty = owner(pid: 900, appName: "Ghostty", bundleID: "com.mitchellh.ghostty") + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: ghostty), screenLocked: false, frontmostPID: 900, targetBundleIDs: targets), + .terminalSecureEntry(ghostty)) + } + + func testSecurityAgentIsExpectedEvenInBackground() { + let agent = owner(pid: 700, appName: "SecurityAgent", bundleID: "com.apple.SecurityAgent") + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: agent), screenLocked: false, frontmostPID: 42, targetBundleIDs: targets), + .expected(agent)) + } + + func testBackgroundLiveHolder() { + let holder = owner(pid: 900) + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: holder), screenLocked: false, frontmostPID: 42, targetBundleIDs: targets), + .backgroundHolder(holder)) + } + + func testDeadOwnerIsOrphaned() { + let dead = owner(pid: 900, appName: nil, isRunning: false, bundleID: nil) + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: dead), screenLocked: false, frontmostPID: 42, targetBundleIDs: targets), + .orphaned(dead)) + } + + func testBlockedWithoutOwnerIsUnknown() { + XCTAssertEqual( + SecureInputTriage.classify( + status: .blocked(owner: nil), screenLocked: false, frontmostPID: 42, targetBundleIDs: targets), + .unknownHolder) + } + + // MARK: - Alert policy + + func testPolicyWaitsOutGraceThenAlertsOnce() { + var policy = SecureInputAlertPolicy() + XCTAssertEqual(policy.evaluate(key: "stuck:179", grace: 3, now: 0), .none) + XCTAssertEqual(policy.evaluate(key: "stuck:179", grace: 3, now: 2), .none) + XCTAssertEqual(policy.evaluate(key: "stuck:179", grace: 3, now: 3.5), .alert) + XCTAssertEqual(policy.evaluate(key: "stuck:179", grace: 3, now: 10), .none) + XCTAssertTrue(policy.isAlerting) + } + + func testPolicyClearsOncePerEpisode() { + var policy = SecureInputAlertPolicy() + _ = policy.evaluate(key: "stuck:179", grace: 0, now: 0) + XCTAssertEqual(policy.evaluate(key: nil, grace: 0, now: 1), .cleared) + XCTAssertEqual(policy.evaluate(key: nil, grace: 0, now: 2), .none) + XCTAssertFalse(policy.isAlerting) + } + + func testPolicyNoClearWhenNeverAlerted() { + var policy = SecureInputAlertPolicy() + XCTAssertEqual(policy.evaluate(key: "background:900", grace: 12, now: 0), .none) + XCTAssertEqual(policy.evaluate(key: nil, grace: 0, now: 1), .none) + } + + // A short flap back to clear restarts the grace clock — legitimate + // transient holds must never accumulate toward an alert. + func testPolicyFlapRestartsGrace() { + var policy = SecureInputAlertPolicy() + XCTAssertEqual(policy.evaluate(key: "background:900", grace: 12, now: 0), .none) + XCTAssertEqual(policy.evaluate(key: nil, grace: 12, now: 6), .none) + XCTAssertEqual(policy.evaluate(key: "background:900", grace: 12, now: 8), .none) + XCTAssertEqual(policy.evaluate(key: "background:900", grace: 12, now: 19), .none) + XCTAssertEqual(policy.evaluate(key: "background:900", grace: 12, now: 20), .alert) + } + + func testPolicyHolderChangeAlertsFresh() { + var policy = SecureInputAlertPolicy() + XCTAssertEqual(policy.evaluate(key: "background:900", grace: 0, now: 0), .alert) + XCTAssertEqual(policy.evaluate(key: "background:901", grace: 0, now: 1), .alert) + XCTAssertEqual(policy.alertedKey, "background:901") + } + + // MARK: - Presentation + + func testStuckLoginwindowPresentationOffersLockScreen() { + let lw = owner(pid: 179, appName: "loginwindow", bundleID: "com.apple.loginwindow") + let p = SecureInputPresentation.make(for: .stuckLoginwindow(lw)) + XCTAssertEqual(p?.kind, .stuckLoginwindow) + XCTAssertEqual(p?.action, .lockScreen) + XCTAssertTrue(p?.advice.contains("typing your password") ?? false) + } + + func testBackgroundHolderPresentationOffersQuit() { + let holder = owner(pid: 900, appName: "SomeApp") + let p = SecureInputPresentation.make(for: .backgroundHolder(holder)) + XCTAssertEqual(p?.action, .quitApp(pid: 900, name: "SomeApp")) + } + + func testBackgroundDaemonHolderHasNoQuitAction() { + let daemon = owner(pid: 900, appName: nil, bundleID: nil) + let p = SecureInputPresentation.make(for: .backgroundHolder(daemon)) + XCTAssertNil(p?.action) + } + + // CLI holders resolve a process name but no bundle — terminate() can't + // reach them, so no Quit button should be offered. + func testBackgroundCLIHolderHasNoQuitAction() { + let cli = owner(pid: 900, appName: "secure-hold", bundleID: nil) + let p = SecureInputPresentation.make(for: .backgroundHolder(cli)) + XCTAssertNil(p?.action) + XCTAssertEqual(p?.pillLabel, "secure-hold") + } + + func testTerminalSecureEntryHasNoAction() { + let ghostty = owner(pid: 900, appName: "Ghostty", bundleID: "com.mitchellh.ghostty") + let p = SecureInputPresentation.make(for: .terminalSecureEntry(ghostty)) + XCTAssertNil(p?.action) + XCTAssertTrue(p?.advice.contains("Secure Keyboard Entry") ?? false) + } + + func testClearHasNoPresentation() { + XCTAssertNil(SecureInputPresentation.make(for: .clear)) + } + + func testExpectedNeverAlerts() { + let holder = owner() + XCTAssertNil(SecureInputAssessment.expected(holder).alertKey) + XCTAssertNil(SecureInputAssessment.clear.alertKey) + XCTAssertNotNil(SecureInputAssessment.stuckLoginwindow(holder).alertKey) + } +}