diff --git a/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift b/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift index 4bb366a..e7481ff 100644 --- a/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift +++ b/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift @@ -15,8 +15,10 @@ extension AppDelegate { screenNotifications: DistributedNotificationCenter.default, workspaceNotifications: NSWorkspace.shared.notificationCenter, screenState: { AgentApprovalScreenSession.current() }, - updateSession: { active, ending, suspended, sleeping in - Vault.shared.updateTimedAllowanceSession(active: active, ending: ending, suspended: suspended, sleeping: sleeping) + beginEndingEvent: { Vault.shared.approvalRequests.beginTimedAllowanceSessionEvent(sleeping: $0) }, + updateSession: { active, ending, suspended, sleeping, sleepOffsetUpperBound in + Vault.shared.updateTimedAllowanceSession(active: active, ending: ending, suspended: suspended, + sleeping: sleeping, sleepOffsetUpperBound: sleepOffsetUpperBound) } ) Vault.shared.approvalRequests.configureAuthentication { purpose in diff --git a/Sources/AskKeyAppKit/TimedAllowanceSessionObserver.swift b/Sources/AskKeyAppKit/TimedAllowanceSessionObserver.swift index d3eb804..9e1689b 100644 --- a/Sources/AskKeyAppKit/TimedAllowanceSessionObserver.swift +++ b/Sources/AskKeyAppKit/TimedAllowanceSessionObserver.swift @@ -1,4 +1,5 @@ import AppKit +import AskKeyBroker /// Listens without changing pending approvals or the management session. final class TimedAllowanceSessionObserver: NSObject { @@ -12,11 +13,14 @@ final class TimedAllowanceSessionObserver: NSObject { screenNotifications: NotificationCenter, workspaceNotifications: NotificationCenter, screenState: @escaping @Sendable () -> AgentApprovalScreenState, - updateSession: @escaping @Sendable (Bool, Bool, Bool, Bool?) -> Void + queue: DispatchQueue = DispatchQueue(label: "com.sudohg.askkey.timed-allowance-session"), + beginEndingEvent: @escaping @Sendable (Bool?) -> BrokerTimedAllowanceSessionEvent? = { _ in nil }, + updateSession: @escaping @Sendable (Bool, Bool, Bool, Bool?, UInt64?) -> Void ) { self.screenNotifications = screenNotifications self.workspaceNotifications = workspaceNotifications - state = TimedAllowanceSessionState(screenState: screenState, updateSession: updateSession) + state = TimedAllowanceSessionState(screenState: screenState, queue: queue, + beginEndingEvent: beginEndingEvent, updateSession: updateSession) super.init() let state = self.state if let distributed = screenNotifications as? DistributedNotificationCenter { @@ -48,8 +52,8 @@ final class TimedAllowanceSessionObserver: NSObject { state.update(sessionActive: true, refreshScreen: true) } ] - // Register first, then sample under the event lock to avoid missing a - // lock between the initial screen-state read and observer installation. + // Register first, then enqueue the initial sample in the same FIFO as + // notifications. Sampling and policy work never hold the event lock. state.update(refreshScreen: true) } @@ -70,14 +74,19 @@ final class TimedAllowanceSessionObserver: NSObject { private final class TimedAllowanceSessionState: @unchecked Sendable { private let lock = NSLock() + private let queue: DispatchQueue private let screenState: @Sendable () -> AgentApprovalScreenState - private let updateSession: @Sendable (Bool, Bool, Bool, Bool?) -> Void + private let beginEndingEvent: @Sendable (Bool?) -> BrokerTimedAllowanceSessionEvent? + private let updateSession: @Sendable (Bool, Bool, Bool, Bool?, UInt64?) -> Void private var screenUnlocked = false private var sessionActive = true - init(screenState: @escaping @Sendable () -> AgentApprovalScreenState, - updateSession: @escaping @Sendable (Bool, Bool, Bool, Bool?) -> Void) { + init(screenState: @escaping @Sendable () -> AgentApprovalScreenState, queue: DispatchQueue, + beginEndingEvent: @escaping @Sendable (Bool?) -> BrokerTimedAllowanceSessionEvent?, + updateSession: @escaping @Sendable (Bool, Bool, Bool, Bool?, UInt64?) -> Void) { self.screenState = screenState + self.queue = queue + self.beginEndingEvent = beginEndingEvent self.updateSession = updateSession } @@ -85,10 +94,15 @@ private final class TimedAllowanceSessionState: @unchecked Sendable { refreshScreen: Bool = false, ending: Bool = false) { lock.lock() defer { lock.unlock() } - if let screenUnlocked { self.screenUnlocked = screenUnlocked } - if let sessionActive { self.sessionActive = sessionActive } - if refreshScreen { self.screenUnlocked = screenState() == .unlocked } - // Sleep is a transition owned by the Broker, never an observer level. - updateSession(self.screenUnlocked && self.sessionActive, ending, !self.sessionActive, sleeping) + let event = ending ? beginEndingEvent(sleeping) : nil + queue.async { [self] in + defer { event?.finish() } + if let screenUnlocked { self.screenUnlocked = screenUnlocked } + if let sessionActive { self.sessionActive = sessionActive } + if refreshScreen { self.screenUnlocked = screenState() == .unlocked } + // Sleep is a transition owned by the Broker, never an observer level. + updateSession(self.screenUnlocked && self.sessionActive, ending, !self.sessionActive, sleeping, + event?.sleepOffsetUpperBound) + } } } diff --git a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Allowances.swift b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Allowances.swift index ee129af..4c700ad 100644 --- a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Allowances.swift +++ b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Allowances.swift @@ -6,13 +6,14 @@ extension BrokerApprovalStateMachine { /// sleeping: true enters sleep, false reports didWake, nil leaves it unchanged. @discardableResult package func updateTimedAllowanceSession(active: Bool, endOnLock: Bool, suspended: Bool = false, - sleeping: Bool? = nil) -> Bool { + sleeping: Bool? = nil, sleepOffsetUpperBound: UInt64? = nil) -> Bool { let revoked: ([String], @Sendable (String) -> Void)? = mutate { + timedAllowanceSessionRevision = UUID() timedAllowanceSessionActive = active timedAllowanceSessionSuspended = suspended if let sleeping { timedAllowanceSessionSleeping = sleeping - timedAllowanceSleepOffsetAtSleep = sleeping ? timedAllowanceSleepOffset().upper : nil + timedAllowanceSleepOffsetAtSleep = sleeping ? (sleepOffsetUpperBound ?? timedAllowanceSleepOffset().upper) : nil } else { clearTimedAllowanceSleepIfProvenLocked(unlocked: active) } diff --git a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Authentication.swift b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Authentication.swift index f825067..b8c23a8 100644 --- a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Authentication.swift +++ b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Authentication.swift @@ -7,12 +7,18 @@ extension BrokerApprovalStateMachine { timedAllowanceDuration: TimeInterval, endTimedAllowancesOnLock: Bool = false ) { - let revoked: ([String], @Sendable (String) -> Void)? = mutate { + let revoked: ([String], @Sendable (String) -> Void)? = mutate(endOnLock: endTimedAllowancesOnLock) { + let duration = Self.validDuration(timedAllowanceDuration) ? timedAllowanceDuration : 30 * 60 + if self.endTimedAllowancesOnLock != endTimedAllowancesOnLock { + timedAllowanceSessionRevision = UUID() + } self.readAuthenticationEnabled = readAuthenticationEnabled self.timedAllowanceEnabled = timedAllowanceEnabled self.endTimedAllowancesOnLock = endTimedAllowancesOnLock - defaultTimedAllowance = Self.validDuration(timedAllowanceDuration) ? timedAllowanceDuration : 30 * 60 - if endTimedAllowancesOnLock && !timedAllowanceSessionPermitsGrantLocked() { + defaultTimedAllowance = duration + if endTimedAllowancesOnLock, + !timedAllowanceScreenSampleFailed || timedAllowanceSessionFence.snapshot.pending, + !timedAllowanceSessionPermitsGrantLocked() { timedAllowanceSessionGeneration = UUID() return revokeAllTimedAllowancesLocked() } diff --git a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Consumption.swift b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Consumption.swift index 9d7da41..cd70e39 100644 --- a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Consumption.swift +++ b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Consumption.swift @@ -124,6 +124,8 @@ extension BrokerApprovalStateMachine { if entry.state == .consumed { throw BrokerApprovalError.alreadyConsumed } if entry.committing { throw BrokerApprovalError.commitInProgress } guard entry.state == .approved else { throw BrokerApprovalError.invalidDecision } + guard !entry.approvedByTimedAllowance || !endTimedAllowancesOnLock + || timedAllowanceSessionPermitsGrantLocked() else { throw BrokerApprovalError.invalidDecision } entries.append(entry) } for var entry in entries { @@ -169,6 +171,8 @@ extension BrokerApprovalStateMachine { if entry.state == .consumed { throw BrokerApprovalError.alreadyConsumed } if entry.committing { throw BrokerApprovalError.commitInProgress } guard entry.state == .approved else { throw BrokerApprovalError.invalidDecision } + guard !entry.approvedByTimedAllowance || !endTimedAllowancesOnLock + || timedAllowanceSessionPermitsGrantLocked() else { throw BrokerApprovalError.invalidDecision } let result = try perform() entry.state = .consumed entriesByOperationID[operationID] = entry @@ -192,6 +196,8 @@ extension BrokerApprovalStateMachine { } if entry.state == .consumed { throw BrokerApprovalError.alreadyConsumed } guard entry.state == .approved else { throw BrokerApprovalError.invalidDecision } + guard !entry.approvedByTimedAllowance || !endTimedAllowancesOnLock + || timedAllowanceSessionPermitsGrantLocked() else { throw BrokerApprovalError.invalidDecision } guard !entry.committing else { throw BrokerApprovalError.commitInProgress } entry.committing = true } diff --git a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Observers.swift b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Observers.swift index d0e0589..78ddf36 100644 --- a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Observers.swift +++ b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Observers.swift @@ -32,8 +32,9 @@ extension BrokerApprovalStateMachine { observerQueue.sync {} } - func mutate(_ body: () throws -> T) rethrows -> T { - lock.lock() + func mutate(screenProvider: (@Sendable () -> Bool)? = nil, endOnLock: Bool? = nil, + _ body: () throws -> T) rethrows -> T { + lockWithTimedAllowanceScreenSample(screenProvider: screenProvider, endOnLock: endOnLock) let schedulingNow = clock() let previousEntries = entriesByOperationID.mapValues { ($0.state, $0.request) } let screenRevocation = revokeForScreenBackstopLocked() @@ -44,6 +45,7 @@ extension BrokerApprovalStateMachine { let terminalTransitions = terminalTransitionsLocked(from: previousEntries) let cleanups = runtimeInvalidationCleanupsLocked(now: schedulingNow) rescheduleExpirationLocked(now: schedulingNow) + clearTimedAllowanceScreenSampleLocked() lock.unlock() cleanups.forEach { $0() } if let screenRevocation { screenRevocation.0.forEach(screenRevocation.1) } @@ -56,6 +58,7 @@ extension BrokerApprovalStateMachine { let terminalTransitions = terminalTransitionsLocked(from: previousEntries) let cleanups = runtimeInvalidationCleanupsLocked(now: schedulingNow) rescheduleExpirationLocked(now: schedulingNow) + clearTimedAllowanceScreenSampleLocked() lock.unlock() cleanups.forEach { $0() } if let screenRevocation { screenRevocation.0.forEach(screenRevocation.1) } diff --git a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Requests.swift b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Requests.swift index 35bf192..95971ae 100644 --- a/Sources/AskKeyBroker/BrokerApprovalStateMachine+Requests.swift +++ b/Sources/AskKeyBroker/BrokerApprovalStateMachine+Requests.swift @@ -92,6 +92,7 @@ extension BrokerApprovalStateMachine { throw BrokerApprovalError.capacityReached } let allowanceEnd = request.operation == .read && timedAllowanceEnabled + && (!endTimedAllowancesOnLock || timedAllowanceSessionPermitsGrantLocked()) ? allowanceLocked(credentialID: request.credentialID, digest: request.readProjectionDigest)?.expiresAt : nil let state: BrokerRequestState = allowanceEnd != nil ? .approved : .pending diff --git a/Sources/AskKeyBroker/BrokerApprovalStateMachine+RuntimeAuthorization.swift b/Sources/AskKeyBroker/BrokerApprovalStateMachine+RuntimeAuthorization.swift index bc5b323..00986e0 100644 --- a/Sources/AskKeyBroker/BrokerApprovalStateMachine+RuntimeAuthorization.swift +++ b/Sources/AskKeyBroker/BrokerApprovalStateMachine+RuntimeAuthorization.swift @@ -3,13 +3,17 @@ import Foundation extension BrokerApprovalStateMachine { func withRuntimeAuthorization(id: UUID, _ body: () throws -> T) throws -> T { lock.lock() - if endTimedAllowancesOnLock { + if endTimedAllowancesOnLock, let authorization = runtimeAuthorizations[id], + !authorization.timedCredentialIDs.isEmpty { lock.unlock() - return try mutate { - try runtimeAuthorizationLocked(id: id, body) - } + lockWithTimedAllowanceScreenSample(screenProvider: nil, endOnLock: nil) } - defer { lock.unlock() } + defer { + clearTimedAllowanceScreenSampleLocked() + lock.unlock() + } + // The caller may hold the Vault spawn gate and delivery lease lock. + // Refusal here must not publish observers, revoke or run any cleanup. return try runtimeAuthorizationLocked(id: id, body) } @@ -18,6 +22,10 @@ extension BrokerApprovalStateMachine { !paused, clock() < authorization.expiresAt else { throw BrokerProviderError.requestRejected } + if endTimedAllowancesOnLock, !authorization.timedCredentialIDs.isEmpty, + !timedAllowanceSessionPermitsGrantLocked() { + throw BrokerProviderError.requestRejected + } // No observer, file IO, or acquisition of the Vault gate while locked. return try body() } diff --git a/Sources/AskKeyBroker/BrokerApprovalStateMachine+ScreenSession.swift b/Sources/AskKeyBroker/BrokerApprovalStateMachine+ScreenSession.swift index 465d3a2..0262521 100644 --- a/Sources/AskKeyBroker/BrokerApprovalStateMachine+ScreenSession.swift +++ b/Sources/AskKeyBroker/BrokerApprovalStateMachine+ScreenSession.swift @@ -2,16 +2,68 @@ import Foundation import Darwin extension BrokerApprovalStateMachine { - /// App-supplied live session check. The provider must be pure, nonblocking, - /// and must not call back into the Broker or Vault while the lock is held. + /// App-supplied live session check, called outside the approval lock. + /// Providers must not recursively start another screen-sampling mutation. package func configureTimedAllowanceScreenState(_ unlocked: @escaping @Sendable () -> Bool) { - mutate { timedAllowanceScreenUnlocked = unlocked } + mutate(screenProvider: unlocked) { + timedAllowanceScreenUnlocked = unlocked + timedAllowanceSessionRevision = UUID() + } } package func configureTimedAllowanceSleepOffset( _ offset: @escaping @Sendable () -> (lower: UInt64, upper: UInt64) ) { - mutate { timedAllowanceSleepOffset = offset } + mutate { + timedAllowanceSessionRevision = UUID() + timedAllowanceSleepOffset = offset + timedAllowanceSessionFence.configureSleepOffset(offset) + } + } + + /// The observer calls this at receipt of each ending event, never for resume. + /// Only that event's completion can release its fence. + package func beginTimedAllowanceSessionEvent(sleeping: Bool?) -> BrokerTimedAllowanceSessionEvent { + timedAllowanceSessionFence.begin(sleeping: sleeping) + } + + /// Returns with the approval lock held. An unlocked sample is usable only + /// for this mutation and only if no state or event changed during sampling. + /// One retry bounds contention; a second race refuses timed authority. + func lockWithTimedAllowanceScreenSample( + screenProvider: (@Sendable () -> Bool)?, endOnLock: Bool? + ) { + for attempt in 0...1 { + lock.lock() + let enabled = endOnLock ?? endTimedAllowancesOnLock + let provider = enabled ? (screenProvider ?? timedAllowanceScreenUnlocked) : nil + guard let provider else { + timedAllowanceScreenSample = nil + timedAllowanceScreenSampleFailed = false + return + } + let revision = timedAllowanceSessionRevision + let fenceRevision = timedAllowanceSessionFence.snapshot.revision + lock.unlock() + let sample = provider() + lock.lock() + if timedAllowanceSessionRevision == revision, timedAllowanceSessionFence.snapshot.revision == fenceRevision { + timedAllowanceScreenSample = sample + timedAllowanceScreenSampleFailed = false + return + } + if attempt == 1 { + timedAllowanceScreenSample = nil + timedAllowanceScreenSampleFailed = true + return + } + lock.unlock() + } + } + + func clearTimedAllowanceScreenSampleLocked() { + timedAllowanceScreenSample = nil + timedAllowanceScreenSampleFailed = false } static func currentTimedAllowanceSleepOffset() -> (lower: UInt64, upper: UInt64) { @@ -26,7 +78,10 @@ extension BrokerApprovalStateMachine { } func timedAllowanceSessionPermitsGrantLocked() -> Bool { - if let unlocked = timedAllowanceScreenUnlocked?() { + guard !timedAllowanceSessionFence.snapshot.pending, !timedAllowanceScreenSampleFailed else { return false } + if let unlocked = timedAllowanceScreenSample { + // Proven sleep may be cleared here even on the runtime gate path; + // this changes no authority records and publishes no callbacks. clearTimedAllowanceSleepIfProvenLocked(unlocked: unlocked) return unlocked && !timedAllowanceSessionSuspended && !timedAllowanceSessionSleeping } @@ -47,7 +102,10 @@ extension BrokerApprovalStateMachine { /// and clear a missed wake after proven sleep, but cannot undo an inactive /// console session. func revokeForScreenBackstopLocked() -> ([String], @Sendable (String) -> Void)? { - guard endTimedAllowancesOnLock, timedAllowanceScreenUnlocked != nil, + guard endTimedAllowancesOnLock, + !timedAllowanceScreenSampleFailed || timedAllowanceSessionFence.snapshot.pending, + timedAllowanceScreenSample != nil || timedAllowanceScreenUnlocked != nil + || timedAllowanceSessionFence.snapshot.pending, !timedAllowanceSessionPermitsGrantLocked() else { return nil } timedAllowanceSessionGeneration = UUID() return revokeAllTimedAllowancesLocked() diff --git a/Sources/AskKeyBroker/BrokerApprovalStateMachine.swift b/Sources/AskKeyBroker/BrokerApprovalStateMachine.swift index f78d4d8..7a905d0 100644 --- a/Sources/AskKeyBroker/BrokerApprovalStateMachine.swift +++ b/Sources/AskKeyBroker/BrokerApprovalStateMachine.swift @@ -54,6 +54,10 @@ public final class BrokerApprovalStateMachine: @unchecked Sendable { BrokerApprovalStateMachine.currentTimedAllowanceSleepOffset() } var timedAllowanceScreenUnlocked: (@Sendable () -> Bool)? + var timedAllowanceScreenSample: Bool? + var timedAllowanceScreenSampleFailed = false + let timedAllowanceSessionFence = TimedAllowanceSessionFence() + var timedAllowanceSessionRevision = UUID() var timedAllowanceSessionGeneration = UUID() var entriesByOperationID: [String: Entry] = [:] var operationOrder: [String] = [] diff --git a/Sources/AskKeyBroker/BrokerTimedAllowanceSessionEvent.swift b/Sources/AskKeyBroker/BrokerTimedAllowanceSessionEvent.swift new file mode 100644 index 0000000..09285dd --- /dev/null +++ b/Sources/AskKeyBroker/BrokerTimedAllowanceSessionEvent.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A receipt-time fence remains closed until its queued session update finishes. +package final class BrokerTimedAllowanceSessionEvent: @unchecked Sendable { + package let sleepOffsetUpperBound: UInt64? + private let lock = NSLock() + private var completed = false + private let complete: @Sendable () -> Void + + init(sleepOffsetUpperBound: UInt64?, complete: @escaping @Sendable () -> Void) { + self.sleepOffsetUpperBound = sleepOffsetUpperBound + self.complete = complete + } + + package func finish() { + lock.lock() + let shouldComplete = !completed + completed = true + lock.unlock() + if shouldComplete { complete() } + } +} + +/// Independent of the approval lock: recording an event never waits for a +/// WindowServer query, Vault policy read, runtime cleanup or approval mutation. +final class TimedAllowanceSessionFence: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + private var revision = UUID() + private var sleepOffset: @Sendable () -> (lower: UInt64, upper: UInt64) = { + BrokerApprovalStateMachine.currentTimedAllowanceSleepOffset() + } + + var snapshot: (pending: Bool, revision: UUID) { + lock.lock() + defer { lock.unlock() } + return (count > 0, revision) + } + + func configureSleepOffset(_ offset: @escaping @Sendable () -> (lower: UInt64, upper: UInt64)) { + lock.lock() + sleepOffset = offset + lock.unlock() + } + + func begin(sleeping: Bool?) -> BrokerTimedAllowanceSessionEvent { + lock.lock() + count += 1 + revision = UUID() + let offset = sleepOffset + lock.unlock() + return BrokerTimedAllowanceSessionEvent(sleepOffsetUpperBound: sleeping == true ? offset().upper : nil) { + self.lock.lock() + self.count -= 1 + self.revision = UUID() + self.lock.unlock() + } + } +} diff --git a/Sources/AskKeyVault/Vault+AgentAccess.swift b/Sources/AskKeyVault/Vault+AgentAccess.swift index 1e447ec..f4e1fad 100644 --- a/Sources/AskKeyVault/Vault+AgentAccess.swift +++ b/Sources/AskKeyVault/Vault+AgentAccess.swift @@ -256,18 +256,20 @@ extension Vault { } package func updateTimedAllowanceSession(active: Bool, ending: Bool = true, suspended: Bool = false, - sleeping: Bool? = nil) { + sleeping: Bool? = nil, sleepOffsetUpperBound: UInt64? = nil) { // Callers reporting sleeping: true must also pass ending: true so a // remote opt-in reaches the Keychain fallback when both local flags are off. if active && sleeping != true { - approvalRequests.updateTimedAllowanceSession(active: true, endOnLock: false, suspended: suspended, sleeping: sleeping) + approvalRequests.updateTimedAllowanceSession(active: true, endOnLock: false, suspended: suspended, + sleeping: sleeping, sleepOffsetUpperBound: sleepOffsetUpperBound) return } // Revoke immediately from either local flag, without Keychain work. // A failed read cannot undo a previously verified opt-in. let memory = try? savedAgentAccessPolicyState().policy if approvalRequests.updateTimedAllowanceSession(active: active, - endOnLock: memory?.endTimedAllowancesOnLock == true, suspended: suspended, sleeping: sleeping) { + endOnLock: memory?.endTimedAllowancesOnLock == true, suspended: suspended, + sleeping: sleeping, sleepOffsetUpperBound: sleepOffsetUpperBound) { return } guard ending else { return } @@ -277,7 +279,8 @@ extension Vault { // the session generation. This preserves pre-adoption remote opt-in. let persisted = try? loadAgentAccessPolicy(key: requireKey(), keyStore: policyKeyStore(), created: false).policy if persisted?.endTimedAllowancesOnLock == true { - approvalRequests.updateTimedAllowanceSession(active: active, endOnLock: true, suspended: suspended, sleeping: sleeping) + approvalRequests.updateTimedAllowanceSession(active: active, endOnLock: true, suspended: suspended, + sleeping: sleeping, sleepOffsetUpperBound: sleepOffsetUpperBound) } } diff --git a/Tests/AskKeyAppTests/GroupTimedAllowanceSessionTests.swift b/Tests/AskKeyAppTests/GroupTimedAllowanceSessionTests.swift index d5df504..468c1cd 100644 --- a/Tests/AskKeyAppTests/GroupTimedAllowanceSessionTests.swift +++ b/Tests/AskKeyAppTests/GroupTimedAllowanceSessionTests.swift @@ -95,8 +95,10 @@ final class GroupTimedAllowanceSessionTests: XCTestCase { try fixture.fire(event) XCTAssertThrowsError(try lease.performAuthorizedSpawn { XCTFail("A revoked pending sibling must not launch") }) - wait(for: [cleaned], timeout: 1) + // A missed event refuses spawn immediately; the next ordinary + // mutation performs cleanup after leaving the spawn gate. XCTAssertEqual(try fixture.approvals.status(requestID: ticket.requestID, capability: ticket.capability), .cancelled) + wait(for: [cleaned], timeout: 1) XCTAssertThrowsError(try fixture.approvals.consumeApproved(requestID: ticket.requestID, capability: ticket.capability, operationRequest: read, perform: { XCTFail("A revoked pending sibling must not deliver") })) XCTAssertTrue(fixture.approvals.activeTimedAllowances().isEmpty) @@ -255,13 +257,13 @@ final class GroupTimedAllowanceSessionTests: XCTestCase { let screen: NotificationCenter let workspace: NotificationCenter let probe: Probe - let observer: TimedAllowanceSessionObserver + let observer: SynchronousTimedAllowanceSessionObserver init(enabled: Bool) throws { let library = try AgentAccessPolicyTestLibrary() self.library = library - let screen = NotificationCenter() - let workspace = NotificationCenter() + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() let probe = Probe() self.screen = screen self.workspace = workspace @@ -277,9 +279,9 @@ final class GroupTimedAllowanceSessionTests: XCTestCase { vault.approvalRequests.configureAuthentication { _ in true } vault.approvalRequests.configureTimedAllowanceScreenState { probe.unlocked } vault.approvalRequests.configureTimedAllowanceSleepOffset { (100, 100) } - observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, screenState: { probe.unlocked ? .unlocked : .locked }, - updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) } func pendingGroup() throws -> BrokerApprovalTicket { diff --git a/Tests/AskKeyAppTests/TimedAllowanceAsyncSessionTests.swift b/Tests/AskKeyAppTests/TimedAllowanceAsyncSessionTests.swift new file mode 100644 index 0000000..b579181 --- /dev/null +++ b/Tests/AskKeyAppTests/TimedAllowanceAsyncSessionTests.swift @@ -0,0 +1,195 @@ +import AppKit +import XCTest +@testable import AskKeyBroker +@testable import AskKeyAppKit +@testable import AskKeyVault + +final class TimedAllowanceAsyncSessionTests: XCTestCase { + func testBlockedVaultCallbackDoesNotBlockPostingAndEventsKeepReceiptOrder() throws { + let library = try AgentAccessPolicyTestLibrary() + defer { try? library.close() } + let vault = library.vault + try vault.updateEndTimedAllowancesOnLock(true, using: .allow) + let approvals = vault.approvalRequests + approvals.configureAuthentication { _ in true } + approvals.configureTimedAllowanceScreenState { true } + let operation = request("runtime") + let timed = try approvals.submit(operation, trustedCredentialDeadline: .none) + _ = try approvals.decide(requestID: timed.requestID, capability: timed.capability, decision: .timedAllow(duration: 120)) + let lease = try XCTUnwrap(approvals.consumeForRuntime([ + .init(requestID: timed.requestID, capability: timed.capability, operationRequest: operation) + ])) + defer { lease.finish() } + let cleanup = expectation(description: "pending event cleans prepared timed runtime authority") + try lease.registerCleanup { cleanup.fulfill() } + let onceRequest = request("once") + let once = try approvals.submit(onceRequest, trustedCredentialDeadline: .none) + _ = try approvals.decide(requestID: once.requestID, capability: once.capability, decision: .once) + let screen = NotificationCenter() + let workspace = NotificationCenter() + let queue = DispatchQueue(label: "askkey.tests.blocked-session-vault") + let trace = SessionTrace() + let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, queue: queue, + beginEndingEvent: { approvals.beginTimedAllowanceSessionEvent(sleeping: $0) }, + updateSession: { active, ending, suspended, sleeping, bound in + XCTAssertFalse(Thread.isMainThread) + trace.waitIfArmed(ending: ending) + vault.updateTimedAllowanceSession(active: active, ending: ending, suspended: suspended, + sleeping: sleeping, sleepOffsetUpperBound: bound) + trace.record(active: active, ending: ending, suspended: suspended, sleeping: sleeping) + }) + defer { trace.release.signal(); queue.sync {}; withExtendedLifetime(observer) {} } + queue.sync {} + trace.arm() + screen.post(name: .init("com.apple.screenIsLocked"), object: nil) + XCTAssertEqual(trace.entered.wait(timeout: .now() + 2), .success) + // The first Vault callback is still held open while all later posts return. + screen.post(name: .init("com.apple.screenIsUnlocked"), object: nil) + workspace.post(name: NSWorkspace.willSleepNotification, object: nil) + workspace.post(name: NSWorkspace.didWakeNotification, object: nil) + workspace.post(name: NSWorkspace.sessionDidResignActiveNotification, object: nil) + workspace.post(name: NSWorkspace.sessionDidBecomeActiveNotification, object: nil) + XCTAssertEqual(trace.values.count, 1, "Only the initial active update has completed") + XCTAssertTrue(approvals.activeTimedAllowances().isEmpty) + XCTAssertThrowsError(try lease.performAuthorizedSpawn { XCTFail("Pending ending events must refuse timed spawn") }) + wait(for: [cleanup], timeout: 2) + let pending = try approvals.submit(request("pending"), trustedCredentialDeadline: .none) + XCTAssertThrowsError(try approvals.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120))) + XCTAssertEqual(try approvals.consume(requestID: once.requestID, capability: once.capability, + operationRequest: onceRequest), .consumed) + trace.release.signal() + queue.sync {} + XCTAssertEqual(trace.values, [ + "true,false,false,nil", "false,true,false,nil", "true,false,false,nil", + "true,true,false,true", "true,false,false,false", "false,true,true,nil", "true,false,false,nil" + ]) + XCTAssertEqual(try approvals.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120)).state, .approved) + XCTAssertThrowsError(try lease.validate(), "Resuming must never revive revoked authority") + } + + func testDelayedWillSleepUsesReceiptBoundAndRequiresRealSleepOrDidWake() throws { + for realSleep in [false, true] { + let library = try AgentAccessPolicyTestLibrary() + defer { try? library.close() } + let vault = library.vault + try vault.updateEndTimedAllowancesOnLock(true, using: .allow) + let approvals = vault.approvalRequests + approvals.configureAuthentication { _ in true } + approvals.configureTimedAllowanceScreenState { true } + let offset = SessionSleepOffset() + approvals.configureTimedAllowanceSleepOffset { offset.current } + let workspace = NotificationCenter() + let queue = DispatchQueue(label: "askkey.tests.delayed-will-sleep") + let trace = SessionTrace() + let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, + screenState: { .unlocked }, queue: queue, + beginEndingEvent: { approvals.beginTimedAllowanceSessionEvent(sleeping: $0) }, + updateSession: { active, ending, suspended, sleeping, bound in + trace.waitIfArmed(ending: ending) + vault.updateTimedAllowanceSession(active: active, ending: ending, suspended: suspended, + sleeping: sleeping, sleepOffsetUpperBound: bound) + }) + defer { trace.release.signal(); queue.sync {}; withExtendedLifetime(observer) {} } + queue.sync {} + trace.arm() + workspace.post(name: NSWorkspace.willSleepNotification, object: nil) + XCTAssertEqual(trace.entered.wait(timeout: .now() + 2), .success) + // Both iterations change the execution-time upper bound. Only the + // real-sleep iteration separates the lower bound from receipt time. + offset.advance(realSleep: realSleep) + let pending = try approvals.submit(request("sleep-pending"), trustedCredentialDeadline: .none) + XCTAssertThrowsError(try approvals.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120)), "Even clock proof must not bypass a queued ending event") + trace.release.signal() + queue.sync {} + XCTAssertEqual(approvals.timedAllowanceSleepOffsetAtSleep, 100, "Use receipt time, not delayed callback time") + if !realSleep { + XCTAssertThrowsError(try approvals.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120))) + workspace.post(name: NSWorkspace.didWakeNotification, object: nil) + queue.sync {} + } + XCTAssertEqual(try approvals.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120)).state, .approved) + } + } + + func testOffPolicyReadFailureFinishesFenceAndDoesNotStopLaterEventsOrReadScreenProvider() throws { + let library = try AgentAccessPolicyTestLibrary() + defer { try? library.close() } + let vault = library.vault + let approvals = vault.approvalRequests + approvals.configureAuthentication { _ in true } + approvals.configureTimedAllowanceScreenState { XCTFail("Default-off events must not call the Broker provider"); return false } + let screen = NotificationCenter() + let workspace = NotificationCenter() + let queue = DispatchQueue(label: "askkey.tests.session-policy-read-error") + let trace = SessionTrace() + let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, queue: queue, + beginEndingEvent: { approvals.beginTimedAllowanceSessionEvent(sleeping: $0) }, + updateSession: { active, ending, suspended, sleeping, bound in + vault.updateTimedAllowanceSession(active: active, ending: ending, suspended: suspended, + sleeping: sleeping, sleepOffsetUpperBound: bound) + trace.record(active: active, ending: ending, suspended: suspended, sleeping: sleeping) + }) + defer { queue.sync {}; withExtendedLifetime(observer) {} } + queue.sync {} + let reads = library.keys.policyReads + library.keys.loadFailure = VaultError.managementAuthenticationRequired + screen.post(name: .init("com.apple.screenIsLocked"), object: nil) + workspace.post(name: NSWorkspace.willSleepNotification, object: nil) + screen.post(name: .init("com.apple.screenIsUnlocked"), object: nil) + workspace.post(name: NSWorkspace.didWakeNotification, object: nil) + queue.sync {} + XCTAssertEqual(library.keys.policyReads, reads + 2, "Both off ending events still check for remote opt-in") + XCTAssertEqual(trace.values.count, 5, "A failed policy read must not stop resume events") + library.keys.loadFailure = nil + approvals.configureTimedAllowanceScreenState { true } + try vault.updateEndTimedAllowancesOnLock(true, using: .allow) + let pending = try approvals.submit(request("after-error"), trustedCredentialDeadline: .none) + XCTAssertEqual(try approvals.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120)).state, .approved, "Completed error paths must not leak a fence") + } + + private func request(_ credential: String) -> BrokerApprovalOperationRequest { + .init(operationID: UUID().uuidString, credentialID: credential, targetID: credential, operation: .read, + payloadDigest: String(repeating: "a", count: 64)) + } +} + +private final class SessionTrace: @unchecked Sendable { + private let lock = NSLock() + private var block = false + private var recorded: [String] = [] + let entered = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + var values: [String] { lock.lock(); defer { lock.unlock() }; return recorded } + func arm() { lock.lock(); block = true; lock.unlock() } + func waitIfArmed(ending: Bool) { + lock.lock() + let shouldWait = ending && block + if shouldWait { block = false } + lock.unlock() + if shouldWait { + entered.signal() + XCTAssertEqual(release.wait(timeout: .now() + 5), .success) + } + } + func record(active: Bool, ending: Bool, suspended: Bool, sleeping: Bool?) { + lock.lock() + recorded.append("\(active),\(ending),\(suspended),\(sleeping.map(String.init) ?? "nil")") + lock.unlock() + } +} + +private final class SessionSleepOffset: @unchecked Sendable { + private let lock = NSLock() + private var lower: UInt64 = 100 + private var upper: UInt64 = 100 + var current: (UInt64, UInt64) { lock.lock(); defer { lock.unlock() }; return (lower, upper) } + func advance(realSleep: Bool) { lock.lock(); upper += 1; if realSleep { lower += 1 }; lock.unlock() } +} diff --git a/Tests/AskKeyAppTests/TimedAllowanceDistributedObserverTests.swift b/Tests/AskKeyAppTests/TimedAllowanceDistributedObserverTests.swift index a579fa7..dcf9526 100644 --- a/Tests/AskKeyAppTests/TimedAllowanceDistributedObserverTests.swift +++ b/Tests/AskKeyAppTests/TimedAllowanceDistributedObserverTests.swift @@ -11,8 +11,8 @@ final class TimedAllowanceDistributedObserverTests: XCTestCase { let vault = library.vault try vault.updateEndTimedAllowancesOnLock(true, using: .allow) let center = RecordingDistributedCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: center, workspaceNotifications: .init(), - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: center, workspaceNotifications: .init(), + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } XCTAssertEqual(Set(center.registrations.map(\.name)), Set([Notification.Name("com.apple.screenIsLocked"), .init("com.apple.screenIsUnlocked")])) @@ -23,9 +23,11 @@ final class TimedAllowanceDistributedObserverTests: XCTestCase { _ = try vault.decideAgentApproval(requestID: first.requestID, capability: first.capability, decision: .timedAllow(duration: 120)) XCTAssertNotNil(approvals.timedAllowanceDeadline(credentialID: "timed")) center.deliver(.init("com.apple.screenIsLocked")) + timedAllowanceSessionTestQueue.sync {} XCTAssertNil(approvals.timedAllowanceDeadline(credentialID: "timed")) XCTAssertEqual(try approvals.status(requestID: first.requestID, capability: first.capability), .cancelled) center.deliver(.init("com.apple.screenIsUnlocked")) + timedAllowanceSessionTestQueue.sync {} let fresh = try submit(approvals) XCTAssertEqual(try vault.decideAgentApproval(requestID: fresh.requestID, capability: fresh.capability, decision: .timedAllow(duration: 120)).state, .approved) diff --git a/Tests/AskKeyAppTests/TimedAllowanceScreenBackstopTests.swift b/Tests/AskKeyAppTests/TimedAllowanceScreenBackstopTests.swift index 16dc0bd..51a37d3 100644 --- a/Tests/AskKeyAppTests/TimedAllowanceScreenBackstopTests.swift +++ b/Tests/AskKeyAppTests/TimedAllowanceScreenBackstopTests.swift @@ -115,6 +115,8 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { // No notification, status query, submission or other Broker mutation // can revoke this lease before the actual final-spawn check. XCTAssertThrowsError(try lease.performAuthorizedSpawn(afterAuthorization: {}, spawn: { XCTFail("Must not spawn") })) + XCTAssertTrue(FileManager.default.fileExists(atPath: path), "Refusal inside the spawn gate must not perform cleanup") + _ = approvals.pendingRequests() // The next ordinary mutation performs global revocation and cleanup. XCTAssertFalse(FileManager.default.fileExists(atPath: path)) probe.set(.unlocked) XCTAssertThrowsError(try lease.performAuthorizedSpawn(afterAuthorization: {}, spawn: { XCTFail("Revoked authority cannot revive") })) @@ -128,8 +130,8 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { let probe = ScreenProbe(.unknown) let approvals = vault.approvalRequests approvals.configureTimedAllowanceScreenState { probe.unlocked() } - let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: .init(), - screenState: { probe.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: .init(), + screenState: { probe.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } approvals.configureAuthentication { _ in true } probe.set(.unlocked) @@ -148,9 +150,9 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { approvals.configureTimedAllowanceSleepOffset { offset.current } approvals.configureTimedAllowanceScreenState { true } approvals.configureAuthentication { _ in true } - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let previous = try submit(approvals, credential: "timed") _ = try vault.decideAgentApproval(requestID: previous.requestID, capability: previous.capability, @@ -185,9 +187,9 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { approvals.configureTimedAllowanceSleepOffset { offset.current } approvals.configureTimedAllowanceScreenState { screen.unlocked() } approvals.configureAuthentication { _ in true } - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, - screenState: { screen.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, + screenState: { screen.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } workspace.post(name: NSWorkspace.willSleepNotification, object: nil) let pending = try submit(approvals, credential: "sleep-window") @@ -219,10 +221,10 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { approvals.configureTimedAllowanceSleepOffset { offset.current } if liveProvider { approvals.configureTimedAllowanceScreenState { true } } approvals.configureAuthentication { _ in true } - let screen = NotificationCenter() - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let previous = try submit(approvals, credential: "timed") _ = try vault.decideAgentApproval(requestID: previous.requestID, capability: previous.capability, @@ -263,10 +265,10 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { approvals.configureTimedAllowanceSleepOffset { offset.current } if liveProvider { approvals.configureTimedAllowanceScreenState { state.unlocked() } } approvals.configureAuthentication { _ in true } - let screen = NotificationCenter() - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { state.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { state.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } workspace.post(name: NSWorkspace.willSleepNotification, object: nil) offset.set(lower: 101, upper: 101) // Actual sleep, with no didWake. @@ -301,9 +303,9 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { let approvals = vault.approvalRequests approvals.configureTimedAllowanceScreenState { true } approvals.configureAuthentication { _ in true } - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } workspace.post(name: NSWorkspace.sessionDidResignActiveNotification, object: nil) let pending = try submit(approvals, credential: "inactive-session") @@ -323,9 +325,9 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { approvals.configureTimedAllowanceScreenState { probe.unlocked() } approvals.configureTimedAllowanceSleepOffset { (100, 100) } approvals.configureAuthentication { _ in true } - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let ticket = try submit(approvals, credential: "timed") _ = try vault.decideAgentApproval(requestID: ticket.requestID, capability: ticket.capability, @@ -352,10 +354,10 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { approvals.configureTimedAllowanceSleepOffset { offset.current } if liveProvider { approvals.configureTimedAllowanceScreenState { true } } approvals.configureAuthentication { _ in true } - let screen = NotificationCenter() - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let previous = try submit(approvals, credential: "previous") _ = try vault.decideAgentApproval(requestID: previous.requestID, capability: previous.capability, @@ -402,10 +404,10 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { approvals.configureTimedAllowanceSleepOffset { (100, 100) } if liveProvider { approvals.configureTimedAllowanceScreenState { true } } approvals.configureAuthentication { _ in true } - let screen = NotificationCenter() - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } workspace.post(name: NSWorkspace.willSleepNotification, object: nil) screen.post(name: .init("com.apple.screenIsLocked"), object: nil) @@ -432,9 +434,9 @@ final class TimedAllowanceScreenBackstopTests: XCTestCase { approvals.configureTimedAllowanceScreenState { true } approvals.configureTimedAllowanceSleepOffset { (100, 100) } approvals.configureAuthentication { _ in true } - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } workspace.post(name: NSWorkspace.sessionDidResignActiveNotification, object: nil) workspace.post(name: NSWorkspace.willSleepNotification, object: nil) diff --git a/Tests/AskKeyAppTests/TimedAllowanceSessionObserverTests.swift b/Tests/AskKeyAppTests/TimedAllowanceSessionObserverTests.swift index 2d57ff3..20eadb0 100644 --- a/Tests/AskKeyAppTests/TimedAllowanceSessionObserverTests.swift +++ b/Tests/AskKeyAppTests/TimedAllowanceSessionObserverTests.swift @@ -26,11 +26,11 @@ final class TimedAllowanceSessionObserverTests: XCTestCase { for ticket in [once, write] { _ = try approvals.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .once) } - let screen = NotificationCenter() - let workspace = NotificationCenter() + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() let vault = library.vault - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) withExtendedLifetime(observer) { (event == NSWorkspace.willSleepNotification ? workspace : screen).post(name: event, object: nil) } @@ -51,11 +51,13 @@ final class TimedAllowanceSessionObserverTests: XCTestCase { func testEachEventReadsTheCurrentSettingAndUnlockOrWakeDoesNotRevoke() throws { let library = try AgentAccessPolicyTestLibrary() defer { try? library.close() } - let screen = NotificationCenter() - let workspace = NotificationCenter() + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() let vault = library.vault - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, + beginEndingEvent: { vault.approvalRequests.beginTimedAllowanceSessionEvent(sleeping: $0) }, + updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let approvals = vault.approvalRequests approvals.configureAuthentication { _ in true } diff --git a/Tests/AskKeyAppTests/TimedAllowanceSessionPolicyTests.swift b/Tests/AskKeyAppTests/TimedAllowanceSessionPolicyTests.swift index 59438f0..39838e4 100644 --- a/Tests/AskKeyAppTests/TimedAllowanceSessionPolicyTests.swift +++ b/Tests/AskKeyAppTests/TimedAllowanceSessionPolicyTests.swift @@ -33,11 +33,11 @@ final class TimedAllowanceSessionPolicyTests: XCTestCase { let library = try AgentAccessPolicyTestLibrary() defer { try? library.close() } let vault = library.vault - let screen = NotificationCenter() - let workspace = NotificationCenter() + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() let reads = library.keys.policyReads - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } XCTAssertEqual(library.keys.policyReads, reads, "Initial active session must not read Keychain") let approvals = vault.approvalRequests @@ -83,10 +83,10 @@ final class TimedAllowanceSessionPolicyTests: XCTestCase { defer { lease.finish(); lease.cleanup() } let unconsumed = try submit(approvals, credential: "unconsumed") _ = try vault.decideAgentApproval(requestID: unconsumed.requestID, capability: unconsumed.capability, decision: .timedAllow(duration: 120)) - let screen = NotificationCenter() - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } (sleep ? workspace : screen).post( name: sleep ? NSWorkspace.willSleepNotification : .init("com.apple.screenIsLocked"), object: nil) diff --git a/Tests/AskKeyAppTests/TimedAllowanceSessionRaceTests.swift b/Tests/AskKeyAppTests/TimedAllowanceSessionRaceTests.swift index f12366a..db5a150 100644 --- a/Tests/AskKeyAppTests/TimedAllowanceSessionRaceTests.swift +++ b/Tests/AskKeyAppTests/TimedAllowanceSessionRaceTests.swift @@ -32,10 +32,10 @@ final class TimedAllowanceSessionRaceTests: XCTestCase { } let pendingWrite = try submit(approvals, credential: "pending-write", operation: .modify) let timed = try submit(approvals, credential: "timed") - let screen = NotificationCenter() - let workspace = NotificationCenter() - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let decide = { try approvals.decide(requestID: timed.requestID, capability: timed.capability, @@ -87,10 +87,10 @@ final class TimedAllowanceSessionRaceTests: XCTestCase { defer { try? library.close() } let vault = library.vault try vault.updateEndTimedAllowancesOnLock(enabled, using: .allow) - let screen = NotificationCenter() + let screen = TimedAllowanceTestNotificationCenter() let screenState = TimedAllowanceTestScreenState(initialState) - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: .init(), - screenState: { screenState.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: .init(), + screenState: { screenState.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let approvals = vault.approvalRequests approvals.configureAuthentication { _ in true } @@ -117,8 +117,8 @@ final class TimedAllowanceSessionRaceTests: XCTestCase { let library = try AgentAccessPolicyTestLibrary() defer { try? library.close() } let vault = library.vault - let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: .init(), - screenState: { .locked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: .init(), + screenState: { .locked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let approvals = vault.approvalRequests approvals.configureAuthentication { _ in true } @@ -139,11 +139,11 @@ final class TimedAllowanceSessionRaceTests: XCTestCase { defer { try? library.close() } let vault = library.vault try vault.updateEndTimedAllowancesOnLock(true, using: .allow) - let screen = NotificationCenter() - let workspace = NotificationCenter() + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() let screenState = TimedAllowanceTestScreenState(.locked) - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { screenState.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { screenState.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let approvals = vault.approvalRequests approvals.configureAuthentication { _ in true } @@ -168,12 +168,12 @@ final class TimedAllowanceSessionRaceTests: XCTestCase { defer { try? library.close() } let vault = library.vault try vault.updateEndTimedAllowancesOnLock(true, using: .allow) - let screen = NotificationCenter() - let workspace = NotificationCenter() + let screen = TimedAllowanceTestNotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() let state = TimedAllowanceTestScreenState(.unlocked) vault.approvalRequests.configureTimedAllowanceSleepOffset { state.sleepOffset } - let observer = TimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, - screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: screen, workspaceNotifications: workspace, + screenState: { .unlocked }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } vault.approvalRequests.configureAuthentication { _ in true } workspace.post(name: NSWorkspace.willSleepNotification, object: nil) @@ -190,10 +190,10 @@ final class TimedAllowanceSessionRaceTests: XCTestCase { defer { try? library.close() } let vault = library.vault try vault.updateEndTimedAllowancesOnLock(enabled, using: .allow) - let workspace = NotificationCenter() + let workspace = TimedAllowanceTestNotificationCenter() let screenState = TimedAllowanceTestScreenState(.unlocked) - let observer = TimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, - screenState: { screenState.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3) }) + let observer = SynchronousTimedAllowanceSessionObserver(screenNotifications: .init(), workspaceNotifications: workspace, + screenState: { screenState.current }, updateSession: { vault.updateTimedAllowanceSession(active: $0, ending: $1, suspended: $2, sleeping: $3, sleepOffsetUpperBound: $4) }) defer { withExtendedLifetime(observer) {} } let approvals = vault.approvalRequests approvals.configureAuthentication { _ in true } diff --git a/Tests/AskKeyAppTests/TimedAllowanceSessionTestDelivery.swift b/Tests/AskKeyAppTests/TimedAllowanceSessionTestDelivery.swift new file mode 100644 index 0000000..2982fac --- /dev/null +++ b/Tests/AskKeyAppTests/TimedAllowanceSessionTestDelivery.swift @@ -0,0 +1,28 @@ +import AppKit +import AskKeyBroker +@testable import AskKeyAppKit + +let timedAllowanceSessionTestQueue = DispatchQueue(label: "askkey.tests.timed-session-delivery") + +/// Existing policy scenarios wait for the real asynchronous observer before +/// their next assertion. Concurrency tests use ordinary centers instead. +final class TimedAllowanceTestNotificationCenter: NotificationCenter, @unchecked Sendable { + override func post(name: Notification.Name, object: Any?, userInfo: [AnyHashable: Any]? = nil) { + super.post(name: name, object: object, userInfo: userInfo) + timedAllowanceSessionTestQueue.sync {} + } +} + +final class SynchronousTimedAllowanceSessionObserver { + private let observer: TimedAllowanceSessionObserver + + init(screenNotifications: NotificationCenter, workspaceNotifications: NotificationCenter, + screenState: @escaping @Sendable () -> AgentApprovalScreenState, + beginEndingEvent: @escaping @Sendable (Bool?) -> BrokerTimedAllowanceSessionEvent? = { _ in nil }, + updateSession: @escaping @Sendable (Bool, Bool, Bool, Bool?, UInt64?) -> Void) { + observer = TimedAllowanceSessionObserver(screenNotifications: screenNotifications, + workspaceNotifications: workspaceNotifications, screenState: screenState, + queue: timedAllowanceSessionTestQueue, beginEndingEvent: beginEndingEvent, updateSession: updateSession) + timedAllowanceSessionTestQueue.sync {} + } +} diff --git a/Tests/AskKeyBrokerTests/ApprovalStateMachineRuntimeTests.swift b/Tests/AskKeyBrokerTests/ApprovalStateMachineRuntimeTests.swift index 4645a57..f28056d 100644 --- a/Tests/AskKeyBrokerTests/ApprovalStateMachineRuntimeTests.swift +++ b/Tests/AskKeyBrokerTests/ApprovalStateMachineRuntimeTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import AskKeyBroker final class ApprovalStateMachineRuntimeTests: ApprovalStateMachineTestCase { - func testSettingOnRuntimeBackstopRunsReentrantCleanupAfterUnlocking() throws { + func testSettingOnRuntimeRefusesImmediatelyAndDefersReentrantCleanupUntilMutation() throws { for spawn in [false, true] { let screen = NotificationRecorder() let machine = configuredMachine(authenticate: { _ in true }) @@ -16,13 +16,19 @@ final class ApprovalStateMachineRuntimeTests: ApprovalStateMachineTestCase { .init(requestID: ticket.requestID, capability: ticket.capability, operationRequest: operation) ])) defer { lease.finish() } + let cleanups = NotificationRecorder() + let callbacks = NotificationRecorder() + machine.setTimedAllowanceRevokedHandler { _ in callbacks.record(count: 1) } + machine.configureOperationStateChanged { _, _, _ in callbacks.record(count: 2) } let cleaned = expectation(description: "screen backstop cleanup can reenter the Broker") try lease.registerCleanup { _ = machine.pendingRequests() + cleanups.record(count: 1) cleaned.fulfill() } + let revision = machine.stateRevision screen.record(count: 1) // No observer or other Broker mutation. - let finished = expectation(description: "runtime check returns after cleanup") + let finished = expectation(description: "runtime check refuses without cleanup or callbacks") DispatchQueue.global().async { do { if spawn { @@ -36,7 +42,17 @@ final class ApprovalStateMachineRuntimeTests: ApprovalStateMachineTestCase { } finished.fulfill() } - wait(for: [cleaned, finished], timeout: 2) + wait(for: [finished], timeout: 2) + XCTAssertEqual(machine.stateRevision, revision) + XCTAssertTrue(cleanups.counts.isEmpty) + XCTAssertTrue(callbacks.counts.isEmpty) + XCTAssertFalse(machine.timedAllowances.isEmpty) + _ = machine.pendingRequests() + wait(for: [cleaned], timeout: 2) + XCTAssertEqual(cleanups.counts, [1]) + XCTAssertFalse(callbacks.counts.isEmpty) + machine.configureTimedAllowanceScreenState { true } + XCTAssertThrowsError(try lease.validate(), "Later unlocked samples must not revive revoked authority") } } diff --git a/Tests/AskKeyBrokerTests/TimedAllowanceConsumptionSamplingTests.swift b/Tests/AskKeyBrokerTests/TimedAllowanceConsumptionSamplingTests.swift new file mode 100644 index 0000000..a93ad54 --- /dev/null +++ b/Tests/AskKeyBrokerTests/TimedAllowanceConsumptionSamplingTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import AskKeyBroker + +final class TimedAllowanceConsumptionSamplingTests: ApprovalStateMachineTestCase { + func testFailedSampleRefusesEveryTimedConsumptionPathAndPreservesOnceOnlyConsumption() throws { + let now = Date(timeIntervalSince1970: 100_000) + for path in ["runtime", "reservation", "explicit-time"] { + for timed in [false, true] { + let machine = configuredMachine(clock: { now }, authenticate: { _ in true }) + machine.configureTimedAllowanceScreenState { true } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let operation = request(operationID: "consumption-\(path)-\(timed)") + let ticket = try machine.submit(operation) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, + decision: timed ? .timedAllow(duration: 120) : .once) + let generation = machine.timedAllowanceSessionGeneration + installRacingProvider(machine) + let consume = { + switch path { + case "runtime": + let lease = try machine.consumeForRuntime([ + .init(requestID: ticket.requestID, capability: ticket.capability, operationRequest: operation) + ]) + XCTAssertNotNil(lease) + defer { lease?.finish() } + try lease?.validate() + case "reservation": + try machine.consumeApproved(requestID: ticket.requestID, capability: ticket.capability, + operationRequest: operation, perform: { XCTAssertFalse(timed, "A timed reservation must not run") }) + default: + // The explicit-time seam reaches consumeApproved(now:). + let state = try machine.consume(requestID: ticket.requestID, capability: ticket.capability, + operationRequest: operation, now: now) + XCTAssertEqual(state, .consumed) + } + } + if timed { + XCTAssertThrowsError(try consume()) { XCTAssertEqual($0 as? BrokerApprovalError, .invalidDecision) } + XCTAssertFalse(machine.timedAllowances.isEmpty) + } else { + XCTAssertNoThrow(try consume()) + } + XCTAssertEqual(machine.entriesByOperationID[operation.operationID]?.state, timed ? .approved : .consumed) + XCTAssertEqual(machine.timedAllowanceSessionGeneration, generation) + } + } + } + + func testFailedSampleWithPendingFenceStillRevokesAtTheBackstop() throws { + let machine = configuredMachine(authenticate: { _ in true }) + machine.configureTimedAllowanceScreenState { true } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let ticket = try machine.submit(request(operationID: "backstop-failure-with-fence")) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + installRacingProvider(machine) + let generation = machine.timedAllowanceSessionGeneration + let event = machine.beginTimedAllowanceSessionEvent(sleeping: nil) + defer { event.finish() } + XCTAssertTrue(machine.activeTimedAllowances().isEmpty) + XCTAssertNotEqual(machine.timedAllowanceSessionGeneration, generation) + XCTAssertEqual(machine.entriesByOperationID["backstop-failure-with-fence"]?.state, .cancelled) + } + + func testFailedSampleWithPendingFenceStillRevokesWhenEnablingThePolicy() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let ticket = try machine.submit(request(operationID: "policy-failure-with-fence")) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + installRacingProvider(machine) + let generation = machine.timedAllowanceSessionGeneration + let event = machine.beginTimedAllowanceSessionEvent(sleeping: nil) + defer { event.finish() } + // The old policy is off, so only policy apply can perform this revocation. + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + XCTAssertTrue(machine.timedAllowances.isEmpty) + XCTAssertNotEqual(machine.timedAllowanceSessionGeneration, generation) + XCTAssertEqual(machine.entriesByOperationID["policy-failure-with-fence"]?.state, .cancelled) + } + + private func installRacingProvider(_ machine: BrokerApprovalStateMachine) { + machine.configureTimedAllowanceScreenState { [weak machine] in + if let machine { + machine.lock.lock() + machine.timedAllowanceSessionRevision = UUID() + machine.lock.unlock() + } + return true + } + } +} diff --git a/Tests/AskKeyBrokerTests/TimedAllowanceRuntimeGateTests.swift b/Tests/AskKeyBrokerTests/TimedAllowanceRuntimeGateTests.swift new file mode 100644 index 0000000..338ae91 --- /dev/null +++ b/Tests/AskKeyBrokerTests/TimedAllowanceRuntimeGateTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import AskKeyBroker + +final class TimedAllowanceRuntimeGateTests: ApprovalStateMachineTestCase { + func testSettingOnOnceRuntimeValidationAndSpawnMakeZeroProviderReads() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let reads = NotificationRecorder() + machine.configureTimedAllowanceScreenState { reads.record(count: 1); return true } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let operation = request(operationID: "once-runtime-with-provider") + let ticket = try machine.submit(operation) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .once) + let runtime = try XCTUnwrap(machine.consumeForRuntime([ + .init(requestID: ticket.requestID, capability: ticket.capability, operationRequest: operation) + ])) + defer { runtime.finish() } + let lease = BrokerTextDeliveryLease(beginSpawnAuthorization: {}, validate: {}, endSpawnAuthorization: {}, + spawnDeadline: nil, runtimeAuthorization: runtime, finish: {}) + defer { lease.finish(); lease.cleanup() } + let before = reads.counts.count + XCTAssertGreaterThan(before, 0, "The provider must be installed and exercised before the runtime check") + XCTAssertNoThrow(try runtime.validate()) + XCTAssertEqual(try lease.performAuthorizedSpawn(afterAuthorization: {}, spawn: { "spawned" }), "spawned") + XCTAssertEqual(reads.counts.count, before) + } + + func testSettingOffRuntimeWithConfiguredProviderMakesZeroProviderReads() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let reads = NotificationRecorder() + machine.configureTimedAllowanceScreenState { reads.record(count: 1); return false } + for (credential, decision) in [("off-once", BrokerApprovalDecision.once), ("off-timed", .timedAllow(duration: 120))] { + let operation = request(operationID: credential, credentialID: credential) + let ticket = try machine.submit(operation) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: decision) + let runtime = try XCTUnwrap(machine.consumeForRuntime([ + .init(requestID: ticket.requestID, capability: ticket.capability, operationRequest: operation) + ])) + XCTAssertNoThrow(try runtime.validate()) + XCTAssertEqual(try runtime.performAuthorizedSpawn { "spawned" }, "spawned") + runtime.finish() + } + XCTAssertTrue(reads.counts.isEmpty) + } + + func testSettingOnOnceSpawnSurvivesFailedSampleWithoutCallbacksOrCleanupInsideGate() throws { + let machine = configuredMachine(authenticate: { _ in true }) + machine.configureTimedAllowanceScreenState { true } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let gate = RuntimeGateProbe() + let callbacks = NotificationRecorder() + var authorizations: [BrokerRuntimeReadAuthorization] = [] + for (credential, decision) in [("once", BrokerApprovalDecision.once), ("timed", .timedAllow(duration: 120))] { + let operation = request(operationID: credential, credentialID: credential) + let ticket = try machine.submit(operation) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: decision) + authorizations.append(try XCTUnwrap(machine.consumeForRuntime([ + .init(requestID: ticket.requestID, capability: ticket.capability, operationRequest: operation) + ]))) + } + defer { authorizations.forEach { $0.finish() } } + try authorizations[1].registerCleanup { + XCTAssertFalse(gate.isInside, "Cleanup must run after the spawn gate is released") + callbacks.record(count: 1) + } + machine.setTimedAllowanceRevokedHandler { _ in + XCTAssertFalse(gate.isInside) + callbacks.record(count: 2) + } + machine.configureOperationStateChanged { _, _, _ in + XCTAssertFalse(gate.isInside) + callbacks.record(count: 3) + } + machine.configureTimedAllowanceScreenState { [weak machine] in + if let machine { + machine.lock.lock() + machine.timedAllowanceSessionRevision = UUID() + machine.lock.unlock() + } + return true + } + let revision = machine.stateRevision + let generation = machine.timedAllowanceSessionGeneration + var deliveryLeases: [BrokerTextDeliveryLease] = [] + defer { deliveryLeases.forEach { $0.cleanup() } } + for (kind, runtime) in [("once", authorizations[0]), ("timed", authorizations[1])] { + let lease = BrokerTextDeliveryLease(beginSpawnAuthorization: { gate.enter() }, validate: {}, + endSpawnAuthorization: { gate.leave() }, spawnDeadline: nil, runtimeAuthorization: runtime, finish: {}) + deliveryLeases.append(lease) + if kind == "timed" { + XCTAssertThrowsError(try lease.performAuthorizedSpawn(afterAuthorization: {}, spawn: { + XCTFail("Unvalidated timed authority must not spawn") + })) + } else { + XCTAssertEqual(try lease.performAuthorizedSpawn(afterAuthorization: {}, spawn: { "spawned" }), "spawned") + } + lease.finish() + XCTAssertFalse(gate.isInside) + XCTAssertTrue(callbacks.counts.isEmpty) + } + gate.enter() + XCTAssertNoThrow(try authorizations[0].validate()) + XCTAssertThrowsError(try authorizations[1].validate()) + gate.leave() + XCTAssertEqual(machine.stateRevision, revision, "Runtime checks must not run mutate inside the gate") + XCTAssertEqual(machine.timedAllowanceSessionGeneration, generation) + XCTAssertTrue(callbacks.counts.isEmpty) + XCTAssertNil(machine.timedAllowanceScreenSample) + XCTAssertFalse(machine.timedAllowanceScreenSampleFailed) + machine.configureTimedAllowanceScreenState { false } + XCTAssertEqual(callbacks.counts, [1, 2]) + machine.configureTimedAllowanceScreenState { true } + XCTAssertThrowsError(try authorizations[1].validate(), "Global revocation must never be undone") + XCTAssertNoThrow(try authorizations[0].validate()) + } +} + +private final class RuntimeGateProbe: @unchecked Sendable { + private let lock = NSLock() + private var inside = false + var isInside: Bool { lock.lock(); defer { lock.unlock() }; return inside } + func enter() { lock.lock(); inside = true; lock.unlock() } + func leave() { lock.lock(); inside = false; lock.unlock() } +} diff --git a/Tests/AskKeyBrokerTests/TimedAllowanceScreenSamplingTests.swift b/Tests/AskKeyBrokerTests/TimedAllowanceScreenSamplingTests.swift new file mode 100644 index 0000000..1fe03ba --- /dev/null +++ b/Tests/AskKeyBrokerTests/TimedAllowanceScreenSamplingTests.swift @@ -0,0 +1,209 @@ +import XCTest +@testable import AskKeyBroker + +final class TimedAllowanceScreenSamplingTests: ApprovalStateMachineTestCase { + func testEveryLiveSampleIncludingPolicyEnableAndReplacementRunsOutsideApprovalLock() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let probe = SamplingProbe() + machine.configureTimedAllowanceScreenState { [weak machine] in + if let machine { + let acquired = machine.lock.try() + XCTAssertTrue(acquired, "WindowServer sampling must not hold the approval lock") + if acquired { machine.lock.unlock() } + } + return probe.sample() + } + XCTAssertEqual(probe.reads, 0) + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + XCTAssertEqual(probe.reads, 1, "Enabling the policy must sample the configured provider") + let ticket = try machine.submit(request(operationID: "sample-outside-lock")) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + XCTAssertNotNil(machine.timedAllowanceDeadline(credentialID: "credential-1")) + machine.configureTimedAllowanceScreenState { [weak machine] in + if let machine { + let acquired = machine.lock.try() + XCTAssertTrue(acquired) + if acquired { machine.lock.unlock() } + } + return false + } + XCTAssertTrue(machine.timedAllowances.isEmpty, "Replacing the provider must use its new sample immediately") + } + + func testLockReceivedDuringUnlockedSampleCannotGrantOrConsumeTimedAuthority() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let probe = SamplingProbe() + machine.configureTimedAllowanceScreenState { probe.sample() } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let operation = request(operationID: "cached-before-sampling") + let ticket = try machine.submit(operation) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + probe.blockNextSample() + let finished = expectation(description: "stale unlocked sample rejected") + DispatchQueue.global().async { + do { + _ = try machine.consumeForRuntime([ + .init(requestID: ticket.requestID, capability: ticket.capability, operationRequest: operation) + ]) + XCTFail("A lock received during sampling must refuse timed consumption") + } catch { + XCTAssertEqual(error as? BrokerApprovalError, .invalidDecision) + } + finished.fulfill() + } + XCTAssertEqual(probe.entered.wait(timeout: .now() + 2), .success) + let event = machine.beginTimedAllowanceSessionEvent(sleeping: nil) + defer { event.finish() } + probe.release.signal() + wait(for: [finished], timeout: 2) + XCTAssertTrue(machine.activeTimedAllowances().isEmpty) + let pending = try machine.submit(request(operationID: "pending-during-fence", credentialID: "fresh")) + XCTAssertThrowsError(try machine.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120))) + XCTAssertEqual(try machine.status(requestID: pending.requestID, capability: pending.capability), .pending) + } + + func testOrdinaryConcurrentMutationsDoNotInvalidateUnlockedSamplesOrRevokeAuthority() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let probe = SamplingProbe() + machine.configureTimedAllowanceScreenState { probe.sample() } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let ticket = try machine.submit(request(operationID: "before-ordinary-contention")) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + let deadline = machine.timedAllowanceDeadline(credentialID: "credential-1") + let generation = machine.timedAllowanceSessionGeneration + let reads = probe.reads + probe.blockNextSample() + let finished = expectation(description: "ordinary contention preserves the unlocked sample") + DispatchQueue.global().async { + XCTAssertEqual(machine.timedAllowanceDeadline(credentialID: "credential-1"), deadline) + finished.fulfill() + } + XCTAssertEqual(probe.entered.wait(timeout: .now() + 2), .success) + _ = machine.pendingRequests() + _ = machine.activeTimedAllowances() + probe.release.signal() + wait(for: [finished], timeout: 2) + XCTAssertEqual(probe.reads - reads, 3, "Read-only mutations must not force another screen sample") + XCTAssertEqual(machine.timedAllowanceSessionGeneration, generation) + XCTAssertFalse(machine.timedAllowances.isEmpty) + XCTAssertNil(machine.timedAllowanceScreenSample) + XCTAssertFalse(machine.timedAllowanceScreenSampleFailed) + } + + func testRepeatedSessionRacesRefuseOnlyThisOperationWithoutRevokingAllowances() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let ticket = try machine.submit(request(operationID: "before-contention")) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let generation = machine.timedAllowanceSessionGeneration + let probe = SamplingProbe() + machine.configureTimedAllowanceScreenState { [weak machine] in + if let machine { + // Model a session configuration change completing during each sample. + machine.lock.lock() + machine.timedAllowanceSessionRevision = UUID() + machine.lock.unlock() + } + return probe.sample() + } + XCTAssertEqual(probe.reads, 2, "Only one sampling retry is permitted") + XCTAssertFalse(machine.timedAllowances.isEmpty) + XCTAssertEqual(machine.timedAllowanceSessionGeneration, generation) + let before = probe.reads + XCTAssertNotNil(machine.timedAllowanceDeadline(credentialID: "credential-1")) + XCTAssertEqual(probe.reads - before, 2) + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + XCTAssertEqual(machine.timedAllowanceSessionGeneration, generation) + XCTAssertFalse(machine.timedAllowances.isEmpty, "A failed policy-apply sample must not revoke globally either") + let pending = try machine.submit(request(operationID: "sample-race-submission")) + XCTAssertEqual(pending.state, .pending, "A failed sample must not auto-approve from a surviving allowance") + XCTAssertThrowsError(try machine.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120))) { XCTAssertEqual($0 as? BrokerApprovalError, .invalidDecision) } + XCTAssertEqual(machine.timedAllowanceSessionGeneration, generation) + XCTAssertFalse(machine.timedAllowances.isEmpty) + XCTAssertNil(machine.timedAllowanceScreenSample) + XCTAssertFalse(machine.timedAllowanceScreenSampleFailed) + machine.configureTimedAllowanceScreenState { true } + XCTAssertEqual(try machine.submit(request(operationID: "stable-sample-submission")).state, .approved) + } + + func testFenceBeginAndFinishInsideOneSampleStillInvalidatesThatSample() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let ticket = try machine.submit(request(operationID: "before-fence-roundtrip")) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let generation = machine.timedAllowanceSessionGeneration + let reads = SamplingProbe() + machine.configureTimedAllowanceScreenState { [weak machine] in + let event = machine?.beginTimedAllowanceSessionEvent(sleeping: nil) + event?.finish() + return reads.sample() + } + XCTAssertEqual(reads.reads, 2) + let pending = try machine.submit(request(operationID: "fence-roundtrip-submission")) + XCTAssertEqual(pending.state, .pending) + XCTAssertEqual(machine.timedAllowanceSessionGeneration, generation) + XCTAssertFalse(machine.timedAllowances.isEmpty) + } + + func testUnrelatedPolicyChangesDuringSamplingPreserveTheSampleAndAllowance() throws { + let machine = configuredMachine(authenticate: { _ in true }) + let probe = SamplingProbe() + machine.configureTimedAllowanceScreenState { probe.sample() } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let ticket = try machine.submit(request(operationID: "before-unrelated-policy-change")) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + let deadline = machine.timedAllowanceDeadline(credentialID: "credential-1") + let generation = machine.timedAllowanceSessionGeneration + let revision = machine.timedAllowanceSessionRevision + let reads = probe.reads + probe.blockNextSample() + let finished = expectation(description: "unrelated policy changes do not invalidate the sample") + DispatchQueue.global().async { + XCTAssertEqual(machine.timedAllowanceDeadline(credentialID: "credential-1"), deadline) + finished.fulfill() + } + XCTAssertEqual(probe.entered.wait(timeout: .now() + 2), .success) + machine.configureAgentAccessPolicy(readAuthenticationEnabled: false, timedAllowanceEnabled: true, + timedAllowanceDuration: 900, endTimedAllowancesOnLock: true) + probe.release.signal() + wait(for: [finished], timeout: 2) + XCTAssertEqual(probe.reads - reads, 2) + XCTAssertEqual(machine.timedAllowanceSessionRevision, revision) + XCTAssertEqual(machine.timedAllowanceSessionGeneration, generation) + XCTAssertFalse(machine.timedAllowances.isEmpty) + } +} + +private final class SamplingProbe: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + private var blockNext = false + let entered = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + + var reads: Int { lock.lock(); defer { lock.unlock() }; return count } + + func blockNextSample() { lock.lock(); blockNext = true; lock.unlock() } + + func sample() -> Bool { + lock.lock() + count += 1 + let block = blockNext + blockNext = false + lock.unlock() + if block { + entered.signal() + XCTAssertEqual(release.wait(timeout: .now() + 3), .success) + } + return true + } +} diff --git a/Tests/AskKeyBrokerTests/TimedAllowanceSessionFenceTests.swift b/Tests/AskKeyBrokerTests/TimedAllowanceSessionFenceTests.swift new file mode 100644 index 0000000..604a969 --- /dev/null +++ b/Tests/AskKeyBrokerTests/TimedAllowanceSessionFenceTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import AskKeyBroker + +final class TimedAllowanceSessionFenceTests: ApprovalStateMachineTestCase { + func testOutstandingEndingEventsAreCountedAndResumeCannotClearThem() throws { + let machine = configuredMachine(authenticate: { _ in true }) + machine.configureTimedAllowanceScreenState { true } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let ticket = try machine.submit(request(operationID: "before-events")) + _ = try machine.decide(requestID: ticket.requestID, capability: ticket.capability, decision: .timedAllow(duration: 120)) + let first = machine.beginTimedAllowanceSessionEvent(sleeping: nil) + let second = machine.beginTimedAllowanceSessionEvent(sleeping: true) + defer { first.finish(); second.finish() } + XCTAssertTrue(machine.activeTimedAllowances().isEmpty) + machine.updateTimedAllowanceSession(active: true, endOnLock: false, sleeping: false) + first.finish() + first.finish() // Idempotence must not release the second event's fence. + let pending = try machine.submit(request(operationID: "during-events", credentialID: "fresh")) + XCTAssertThrowsError(try machine.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120))) + second.finish() + XCTAssertEqual(try machine.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120)).state, .approved) + XCTAssertEqual(try machine.status(requestID: ticket.requestID, capability: ticket.capability), .cancelled) + } + + func testUnfinishedEventFailsClosedAndErrorDeferReleasesOnlyItsOwnFence() throws { + let machine = configuredMachine(authenticate: { _ in true }) + machine.configureTimedAllowanceScreenState { true } + machine.configureAgentAccessPolicy(readAuthenticationEnabled: true, timedAllowanceEnabled: true, + timedAllowanceDuration: 1_800, endTimedAllowancesOnLock: true) + let unfinished = machine.beginTimedAllowanceSessionEvent(sleeping: nil) + defer { unfinished.finish() } + let pending = try machine.submit(request(operationID: "error-fence")) + func failedUpdate() throws { + let event = machine.beginTimedAllowanceSessionEvent(sleeping: nil) + defer { event.finish() } + throw BrokerProviderError.requestRejected + } + XCTAssertThrowsError(try failedUpdate()) + machine.updateTimedAllowanceSession(active: true, endOnLock: false, sleeping: false) + XCTAssertThrowsError(try machine.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120)), "An unfinished event cannot be cleared by another event or an error") + unfinished.finish() + XCTAssertEqual(try machine.decide(requestID: pending.requestID, capability: pending.capability, + decision: .timedAllow(duration: 120)).state, .approved, "The error path must not leak its own counter") + } +}