Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 42 additions & 19 deletions Sources/Clavis/State/PanelLockController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public final class LAPanelAuthenticator: PanelAuthenticating {
public init() {}

public func authenticate(reason: String) async throws -> LAContext {
#if DEBUG
guard !ClavisEnvironment.isTesting else {
throw UserAuthenticationError.testAuthenticationNotInjected
}
#endif
ClavisLogger.promptDebug("calvis-ui", "LAPanelAuthenticator: prompting system authentication (reason: \"\(reason)\")")
let context = LAContext()
return try await withCheckedThrowingContinuation { continuation in
Expand Down Expand Up @@ -215,40 +220,40 @@ public final class PanelLockController: ObservableObject {
return
}

let generation = promptLifecycle.beganManualAttempt()
isUnlocking = true
defer { isUnlocking = false }

do {
ClavisLogger.promptDebug("calvis-ui", "PanelLockController: requesting panel unlock prompt: \"\(ClavisUIStrings.PanelLock.reason)\"")
let context = try await authenticator.authenticate(reason: ClavisUIStrings.PanelLock.reason)
guard promptLifecycle.acceptsCompletion(from: generation) else {
context.invalidate()
return
}
applyUnlock(context: context)
} catch {
// Cancelled or failed unlock stays locked without error alert
lastPromptDismissedAt = now()
// Cancellation stays locked. Focus restoration is not a retry.
}
}

private var lastPromptDismissedAt: Date?

/// Prompts for biometric / system authentication when the user opens or focuses the locked window,
/// matching Bitwarden's auto-prompt behavior.
/// - Parameter isWindowFocusEvent: When true, suppresses immediate re-prompting right after the macOS
/// system authentication sheet closes and returns key-window status to the Clavis window.
public func unlockIfNeededOnWindowFocus(isWindowFocusEvent: Bool = false) async {
guard isEffectivelyLocked, !isUnlocking else { return }
if isWindowFocusEvent,
let dismissedAt = lastPromptDismissedAt,
now().timeIntervalSince(dismissedAt) < 1.0 {
return
}
private var promptLifecycle = PanelUnlockPromptLifecycle()

/// One automatic attempt per locked period; explicit reopening may retry.
public func unlockIfNeededOnWindowFocus(
isWindowFocusEvent: Bool = false,
isUserInitiatedOpen: Bool = false
) async {
let trigger: PanelUnlockPromptLifecycle.Trigger = isUserInitiatedOpen
? .explicitOpen : (isWindowFocusEvent ? .windowFocus : .presentation)
guard promptLifecycle.claimAutomaticAttempt(
trigger: trigger, isLocked: isEffectivelyLocked, isAuthenticating: isUnlocking
) else { return }
switch currentMode {
case .passwordOrBiometry:
await unlock()
case .biometryOrPIN:
let outcome = await unlockWithPIN(nil)
if outcome != .unlocked {
lastPromptDismissedAt = now()
}
_ = await unlockWithPIN(nil)
case .biometryAndPIN:
// Requires PIN entry first
break
Expand All @@ -258,7 +263,12 @@ public final class PanelLockController: ObservableObject {
@discardableResult
public func authenticateUser(reason: String = ClavisUIStrings.PanelLock.reason) async throws -> LAContext {
ClavisLogger.promptDebug("calvis-ui", "PanelLockController: authenticating user: \"\(reason)\"")
let generation = promptLifecycle.beganManualAttempt()
let context = try await authenticator.authenticate(reason: reason)
guard promptLifecycle.acceptsCompletion(from: generation) else {
context.invalidate()
throw LAError(.appCancel)
}
applyUnlock(context: context)
return context
}
Expand All @@ -272,7 +282,12 @@ public final class PanelLockController: ObservableObject {
isUnlocking = true
defer { isUnlocking = false }

let generation = promptLifecycle.beganManualAttempt()
let (outcome, context) = await service.unlock(pin: pin)
guard promptLifecycle.acceptsCompletion(from: generation) else {
context?.invalidate()
return .cancelled
}
if outcome == .unlocked {
applyUnlock(context: context)
}
Expand All @@ -288,7 +303,12 @@ public final class PanelLockController: ObservableObject {
isUnlocking = true
defer { isUnlocking = false }

let generation = promptLifecycle.beganManualAttempt()
let (outcome, context) = await service.unlockWithPassword()
guard promptLifecycle.acceptsCompletion(from: generation) else {
context?.invalidate()
return .cancelled
}
if outcome == .unlocked {
applyUnlock(context: context)
}
Expand All @@ -310,11 +330,13 @@ public final class PanelLockController: ObservableObject {
defer { isUnlocking = false }

ClavisLogger.promptDebug("calvis-ui", "PanelLockController: verifying Mac login password via OpenDirectory")
let generation = promptLifecycle.beganManualAttempt()
let verifier = self.loginPasswordVerifier
let verified = await Task.detached {
verifier(password)
}.value

guard promptLifecycle.acceptsCompletion(from: generation) else { return false }
if verified {
applyUnlock(context: nil)
return true
Expand Down Expand Up @@ -347,6 +369,7 @@ public final class PanelLockController: ObservableObject {
}

public func lock(reason: PanelLockReason) {
promptLifecycle.didLock(wasAlreadyLocked: isLocked, suppressAutomaticAttempt: reason == .lockNow || reason == .idle)
lastLockReason = reason
unlockContext?.invalidate()
unlockContext = nil
Expand Down
48 changes: 48 additions & 0 deletions Sources/Clavis/State/PanelUnlockPromptLifecycle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

/// Authentication-sheet dismissal returns focus to the locked window. That
/// focus is not a new request to authenticate, regardless of elapsed time.
struct PanelUnlockPromptLifecycle {
enum Trigger {
case presentation
case windowFocus
case explicitOpen
}

private(set) var generation = UUID()
private(set) var automaticAttemptConsumed = false

mutating func claimAutomaticAttempt(
trigger: Trigger,
isLocked: Bool,
isAuthenticating: Bool
) -> Bool {
guard isLocked, !isAuthenticating else { return false }
if trigger == .explicitOpen {
automaticAttemptConsumed = false
}
guard !automaticAttemptConsumed else { return false }
automaticAttemptConsumed = true
return true
}

/// Manual attempts remain available, but their dismissal must not start an
/// additional automatic attempt when the window becomes key again.
mutating func beganManualAttempt() -> UUID {
automaticAttemptConsumed = true
return generation
}

mutating func didLock(wasAlreadyLocked: Bool, suppressAutomaticAttempt: Bool = false) {
generation = UUID()
if suppressAutomaticAttempt {
automaticAttemptConsumed = true
} else if !wasAlreadyLocked {
automaticAttemptConsumed = false
}
}

func acceptsCompletion(from generation: UUID) -> Bool {
self.generation == generation
}
}
7 changes: 2 additions & 5 deletions Sources/Clavis/Windows/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,10 @@ public class WindowManager: NSObject, NSWindowDelegate {
}

if let window = keyManagerWindow {
let wasKeyWindow = window.isKeyWindow && (NSApp?.isActive == true)
window.makeKeyAndOrderFront(nil)
NSApp?.activate(ignoringOtherApps: true)
if wasKeyWindow {
Task {
await PanelLockController.shared.unlockIfNeededOnWindowFocus()
}
Task {
await PanelLockController.shared.unlockIfNeededOnWindowFocus(isUserInitiatedOpen: true)
}
return
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/ClavisCore/Audit/AuditEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ public struct AuditEvent: Equatable, Sendable {
public static func isUserCancellation(_ error: Error) -> Bool {
if let authError = error as? UserAuthenticationError {
switch authError {
case .testAuthenticationNotInjected:
return false
case .timedOut:
return true
case .rejected(let underlying):
Expand Down
13 changes: 13 additions & 0 deletions Sources/ClavisCore/Crypto/UserAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ public extension UserAuthenticating {

public enum UserAuthenticationError: LocalizedError {
case timedOut
case testAuthenticationNotInjected
case rejected(Error?)

public var errorDescription: String? {
switch self {
case .testAuthenticationNotInjected:
return "Tests must inject an authenticator; system authentication is unavailable in tests."
case .timedOut:
return ClavisUIStrings.Auth.errorTimedOut
case .rejected(let error):
Expand All @@ -40,6 +43,11 @@ public final class LocalUserAuthenticator: UserAuthenticating {

@discardableResult
public func authenticate(reason: String, policy: LAPolicy = .deviceOwnerAuthentication) throws -> LAContext {
#if DEBUG
guard !ClavisEnvironment.isTesting else {
throw UserAuthenticationError.testAuthenticationNotInjected
}
#endif
ClavisLogger.promptDebug("clavis-code", "LocalUserAuthenticator: prompting system authentication (policy: \(policy), reason: \"\(reason)\")")
let context = LAContext()
context.localizedReason = reason
Expand Down Expand Up @@ -79,6 +87,11 @@ public final class LocalUserAuthenticator: UserAuthenticating {

@discardableResult
public func authenticate(reason: String, policy: LAPolicy = .deviceOwnerAuthentication) async throws -> LAContext {
#if DEBUG
guard !ClavisEnvironment.isTesting else {
throw UserAuthenticationError.testAuthenticationNotInjected
}
#endif
ClavisLogger.promptDebug("clavis-code", "LocalUserAuthenticator (async): prompting system authentication (policy: \(policy), reason: \"\(reason)\")")
let context = LAContext()
context.localizedReason = reason
Expand Down
7 changes: 6 additions & 1 deletion Tests/ClavisAppTests/PanelLockTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,15 @@ final class PanelLockTests {
await controller.unlockIfNeededOnWindowFocus(isWindowFocusEvent: true)
#expect(fakeAuth.authCount == 1)

// Focusing the locked window again after cooldown prompts Touch ID and unlocks when approved
// Elapsed time does not turn restored focus into consent to retry.
currentTime += 2.0
fakeAuth.shouldFail = false
await controller.unlockIfNeededOnWindowFocus(isWindowFocusEvent: true)
#expect(fakeAuth.authCount == 1)
#expect(controller.isEffectivelyLocked)

// Explicitly opening Clavis again is a deliberate retry.
await controller.unlockIfNeededOnWindowFocus(isUserInitiatedOpen: true)
#expect(fakeAuth.authCount == 2)
#expect(!controller.isEffectivelyLocked)
}
Expand Down
Loading
Loading