From 0fdfba5adca4049092916b0d97f8e1e121430d4e Mon Sep 17 00:00:00 2001 From: Damir Garifullin Date: Sat, 24 Oct 2026 13:56:17 +0300 Subject: [PATCH] Enforce application eligibility before agent signing and grant reuse --- .../ClavisCore/Crypto/KeychainManager.swift | 2 +- .../ClavisCore/Protocol/AgentSocketRole.swift | 4 +- .../ApplicationRequestAuthorization.swift | 44 ++++++ .../ClavisCore/Protocol/SSHAgentServer.swift | 14 +- .../Protocol/SigningPolicyEvaluator.swift | 104 ++++++++++++- .../AppStateAgentSessionTests.swift | 2 +- .../ClavisAppTests/DaemonLifecycleTests.swift | 12 +- .../LockAllFailClosedTests.swift | 6 +- .../SettingsViewModelTests.swift | 2 +- .../AgentApprovalModeTests.swift | 14 +- .../ClavisCoreTests/AgentKeyPolicyTests.swift | 16 +- .../AgentLifecycleManagerTests.swift | 2 +- .../AgentPathSeparationTests.swift | 18 +-- .../AgentSessionLeaseTests.swift | 6 +- .../AgentSessionProtocolTests.swift | 26 ++-- ...ApplicationRequestAuthorizationTests.swift | 137 ++++++++++++++++++ .../GitSigningGraceTests.swift | 14 +- .../KeyLifecycleAuditTests.swift | 2 +- .../SSHAgentServerAuditTests.swift | 2 +- .../ClavisCoreTests/SSHAgentServerTests.swift | 46 +++--- Tests/ClavisCoreTests/SessionBindTests.swift | 42 +++--- .../SigningPolicyEvaluatorTests.swift | 2 +- .../SigningPromptGateTests.swift | 2 +- Tests/ClavisCoreTests/TokenBucketTests.swift | 4 +- .../TestApplicationAccess.swift | 14 ++ 25 files changed, 421 insertions(+), 116 deletions(-) create mode 100644 Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift create mode 100644 Tests/ClavisCoreTests/ApplicationRequestAuthorizationTests.swift create mode 100644 Tests/ClavisTestSupport/TestApplicationAccess.swift diff --git a/Sources/ClavisCore/Crypto/KeychainManager.swift b/Sources/ClavisCore/Crypto/KeychainManager.swift index 445866e..4070848 100644 --- a/Sources/ClavisCore/Crypto/KeychainManager.swift +++ b/Sources/ClavisCore/Crypto/KeychainManager.swift @@ -656,7 +656,7 @@ public class KeychainManager { } let isGitSigningRequest = SSHSIGPayload.parse(from: data) != nil - let operation: KeyOperation = isGitSigningRequest ? .signature : .ssh + let operation: KeyOperation = data.starts(with: SSHSIGPayload.magic) ? .signature : .ssh if key.purpose == .gitSigningOnly && !isGitSigningRequest { throw PrivateKeyRecordError.purposeNotAllowed( purpose: key.purpose.rawValue, diff --git a/Sources/ClavisCore/Protocol/AgentSocketRole.swift b/Sources/ClavisCore/Protocol/AgentSocketRole.swift index f8b9623..a5f854e 100644 --- a/Sources/ClavisCore/Protocol/AgentSocketRole.swift +++ b/Sources/ClavisCore/Protocol/AgentSocketRole.swift @@ -7,7 +7,7 @@ public enum AgentSocketRole: String, Sendable { public var allowedPurposes: Set { switch self { case .personal: - return [.general, .gitSigningOnly] + return [.general, .gitSigningOnly, .agent] case .agent: return [.agent] } @@ -16,7 +16,7 @@ public enum AgentSocketRole: String, Sendable { public var listedPurposes: Set { switch self { case .personal: - return [.general] + return [.general, .agent] case .agent: return [.agent] } diff --git a/Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift b/Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift new file mode 100644 index 0000000..7c07f10 --- /dev/null +++ b/Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift @@ -0,0 +1,44 @@ +import Foundation +import Darwin + +public struct ApplicationRequestLease: Equatable, Sendable { + public let eligibility: ApplicationEligibility + public let revision: UUID + public let caller: VerifiedApplicationCaller? + public init(eligibility: ApplicationEligibility, revision: UUID, caller: VerifiedApplicationCaller?) { + self.eligibility = eligibility; self.revision = revision; self.caller = caller + } + public var approvalIdentity: String? { + caller.map { "\($0.identity.kind.rawValue):\($0.identity.value)" } + } +} + +public protocol ApplicationRequestAuthorizing { + func authorize(key: SSHKeyInfo, operation: KeyOperation, role: AgentSocketRole, pid: pid_t, path: String, start: UInt64) throws -> ApplicationRequestLease + func validate(_ lease: ApplicationRequestLease, pid: pid_t, path: String, start: UInt64) throws -> Bool +} + +public final class ApplicationRequestAuthorization: ApplicationRequestAuthorizing { + private let store: ApplicationRegistryStoring + private let resolver: ApplicationCallerResolver + public init(store: ApplicationRegistryStoring = KeychainApplicationRegistry.shared, resolver: ApplicationCallerResolver = .init()) { + self.store = store; self.resolver = resolver + } + + public func authorize(key: SSHKeyInfo, operation: KeyOperation, role: AgentSocketRole, pid: pid_t, path: String, start: UInt64) throws -> ApplicationRequestLease { + let snapshot = try store.load() + let caller = resolver.resolve(peerPID: pid, peerPath: path, peerStart: start, registry: snapshot) + let eligibility = ApplicationEligibility.evaluate( + profile: key.effectiveAccessProfile, operation: operation, + classification: caller?.application.classification, restrictedBotEndpoint: role == .agent + ) + return ApplicationRequestLease(eligibility: eligibility, revision: snapshot.revision, caller: caller) + } + + public func validate(_ lease: ApplicationRequestLease, pid: pid_t, path: String, start: UInt64) throws -> Bool { + let snapshot = try store.load() + guard snapshot.revision == lease.revision else { return false } + let caller = resolver.resolve(peerPID: pid, peerPath: path, peerStart: start, registry: snapshot) + return caller == lease.caller + } +} diff --git a/Sources/ClavisCore/Protocol/SSHAgentServer.swift b/Sources/ClavisCore/Protocol/SSHAgentServer.swift index 171949f..18c6110 100644 --- a/Sources/ClavisCore/Protocol/SSHAgentServer.swift +++ b/Sources/ClavisCore/Protocol/SSHAgentServer.swift @@ -74,6 +74,7 @@ public class SSHAgentServer { private let signingPolicyEvaluator: SigningPolicyEvaluator private let controlDispatcher: AgentControlDispatcher private let keyManager: KeychainManager + private let applicationAccess: ApplicationRequestAuthorizing #if DEBUG public var testContext: TestContext? #endif @@ -89,6 +90,7 @@ public class SSHAgentServer { internal var activeClientCount: Int { socketServer.activeClientCount } public init( + applicationAccess: ApplicationRequestAuthorizing = ApplicationRequestAuthorization(), socketPath: String = SSHAgentServer.defaultSocketPath, role: AgentSocketRole = .personal, maxConcurrentClients: Int = 32, @@ -140,7 +142,9 @@ public class SSHAgentServer { gitSigningGraceManager: gitSigningGraceManager ) + self.applicationAccess = applicationAccess self.signingPolicyEvaluator = SigningPolicyEvaluator( + applicationAccess: applicationAccess, keyManager: keyManager, gitSigningGraceManager: gitSigningGraceManager, promptGate: promptGate, @@ -358,7 +362,15 @@ public class SSHAgentServer { } ClavisLogger.log("SSH_AGENT_IDENTITIES", "Listing active SSH identities for \(clientDesc)...") // Filter keys according to socket role (personal lists general; agent lists agent) - let keys = ((try? keyManager.listKeys()) ?? []).filter { role.listedPurposes.contains($0.purpose) } + let keys = ((try? keyManager.listKeys()) ?? []).filter { key in + guard key.effectiveAccessProfile.capabilities.ssh, + let pid = clientPid, + let path = clientExecutablePath ?? ProcessInspector.getProcessPath(pid: pid), + let start = ProcessInspector.processStartTime(pid: pid), + let lease = try? applicationAccess.authorize(key: key, operation: .ssh, role: role, pid: pid, path: path, start: start), + case .eligible = lease.eligibility else { return false } + return true + } var response = Data() response.append(12) // SSH2_AGENT_IDENTITIES_ANSWER diff --git a/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift b/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift index 39812bf..a0f008f 100644 --- a/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift +++ b/Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift @@ -36,6 +36,7 @@ public final class SigningPolicyEvaluator { public let notificationPoster: (String, [String: String]) -> Void public let peerProcessValidator: (pid_t, String, UInt64) -> Bool + private let applicationAccess: ApplicationRequestAuthorizing private let stateLock = NSLock() private var lastRateLimitAlertTimes: [String: Date] = [:] @@ -68,6 +69,7 @@ public final class SigningPolicyEvaluator { } public init( + applicationAccess: ApplicationRequestAuthorizing = ApplicationRequestAuthorization(), keyManager: KeychainManager = .shared, gitSigningGraceManager: GitSigningGraceManager = .shared, promptGate: SigningPromptGate = SigningPromptGate(), @@ -85,6 +87,7 @@ public final class SigningPolicyEvaluator { }, peerProcessValidator: ((pid_t, String, UInt64) -> Bool)? = nil ) { + self.applicationAccess = applicationAccess self.keyManager = keyManager self.gitSigningGraceManager = gitSigningGraceManager self.promptGate = promptGate @@ -106,6 +109,101 @@ public final class SigningPolicyEvaluator { clientExecutablePath: String? = nil, clientStartTime: UInt64? = nil, clientSocket: Int32? = nil + ) throws -> SigningDecision { + // Eligibility precedes every session/grant path, including direct sign + // requests that never enumerated identities. + guard flags == 0, let key = try keyManager.listKeys().first(where: { $0.publicKeyBlob == keyBlob }) else { + return try evaluateAuthorizedSigningRequest(keyBlob: keyBlob, dataToSign: dataToSign, flags: flags, role: role, clientPid: clientPid, connection: connection, clientExecutablePath: clientExecutablePath, clientStartTime: clientStartTime, clientSocket: clientSocket) + } + guard let path = clientExecutablePath ?? ProcessInspector.getProcessPath(pid: clientPid), + let start = clientStartTime ?? ProcessInspector.processStartTime(pid: clientPid) else { + recordApplicationDecision(key: key, result: .denied, reason: .noPeerAttribution, pid: clientPid, path: clientExecutablePath ?? "") + return .denied + } + let operation: KeyOperation = dataToSign.starts(with: SSHSIGPayload.magic) ? .signature : .ssh + if key.purpose == .gitSigningOnly { + guard SSHSIGPayload.parse(from: dataToSign) != nil else { + recordApplicationDecision(key: key, result: .denied, reason: .gitOnlyKeyNonGitPayload, pid: clientPid, path: path) + return .denied + } + guard ProcessInspector.isTrustedGitExecutable(path: path) || ProcessInspector.isSignerHelper(path: path) else { + recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path) + return .denied + } + } + let lease: ApplicationRequestLease + do { + lease = try applicationAccess.authorize(key: key, operation: operation, role: role, pid: clientPid, path: path, start: start) + } catch { + recordApplicationDecision(key: key, result: .denied, reason: .policyUnavailable, pid: clientPid, path: path) + return .denied + } + let decision: SigningDecision + switch lease.eligibility { + case .denied: + recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path) + return .denied + case .authenticatedOnceOnly: + // No cache lookup, history heuristic, or remembered classification. + guard !(role == .agent && (connection?.hasForwarding == true || connection?.hasFailedBind == true)) else { return .denied } + do { + let signature = try promptGate.run( + requester: .init(executablePath: path, pid: clientPid), clientSocket: clientSocket + ) { + let context = try authenticator.authenticate( + reason: "Allow one \(operation == .ssh ? "SSH" : "Signature") request for '\(key.label)'? Caller identity is uncertain (\(Self.safeProcessPath(path))).", + policy: .deviceOwnerAuthentication + ) + defer { context.invalidate() } + return try keyManager.signSSH(key: key, data: dataToSign, prompt: "", useCache: false, existingContext: context, allowedPurposes: role.allowedPurposes) + } + decision = .approved(signature: signature) + } catch { + recordApplicationDecision(key: key, result: AuditEvent.isUserCancellation(error) ? .cancelled : .denied, reason: .authenticationFailed, pid: clientPid, path: path) + return .denied + } + case .eligible: + decision = try evaluateAuthorizedSigningRequest( + keyBlob: keyBlob, dataToSign: dataToSign, flags: flags, role: role, clientPid: clientPid, + connection: connection, clientExecutablePath: path, clientStartTime: start, clientSocket: clientSocket, + approvalIdentity: lease.approvalIdentity, callerName: lease.caller?.application.name + ) + } + if case .approved = decision { + guard let currentKey = try? keyManager.listKeys().first(where: { $0.fingerprint == key.fingerprint }), + currentKey.effectiveAccessProfile == key.effectiveAccessProfile, + peerProcessValidator(clientPid, path, start), + (try? applicationAccess.validate(lease, pid: clientPid, path: path, start: start)) == true else { + gitSigningGraceManager.invalidateAll(broadcast: false) + agentSessions.endAll(reason: .keyChanged) + recordApplicationDecision(key: key, result: .denied, reason: .peerChanged, pid: clientPid, path: path) + return .dropConnection + } + if lease.eligibility == .authenticatedOnceOnly { + recordApplicationDecision(key: key, result: .allowed, reason: .viaPrompt, pid: clientPid, path: path) + } + } + return decision + } + + private func recordApplicationDecision(key: SSHKeyInfo, result: AuditResult, reason: AuditReason, pid: pid_t, path: String) { + auditRecorder.record(AuditEvent(type: .signature, result: result, reason: reason, + keyFingerprint: key.fingerprint, keyKind: key.purpose.auditKind, count: 1, + sensitive: .init(keyLabel: key.label, processChain: AuditProcessChain.build(pid: pid, executablePath: path)))) + } + + private func evaluateAuthorizedSigningRequest( + keyBlob: Data, + dataToSign: Data, + flags: UInt32, + role: AgentSocketRole, + clientPid: pid_t, + connection: ClientConnectionContext? = nil, + clientExecutablePath: String? = nil, + clientStartTime: UInt64? = nil, + clientSocket: Int32? = nil, + approvalIdentity: String? = nil, + callerName: String? = nil ) throws -> SigningDecision { var audit = PendingSignAudit() defer { auditRecorder.record(audit.makeEvent()) } @@ -151,7 +249,7 @@ public final class SigningPolicyEvaluator { // Domain verification: Parse signed payload as OpenSSH SSHSIG (strict Git format) let gitSSHSIG = SSHSIGPayload.parse(from: dataToSign) - if matchingKey.purpose == .agent && gitSSHSIG != nil { + if !matchingKey.effectiveAccessProfile.capabilities.signature && gitSSHSIG != nil { audit.result = .denied audit.reason = .wrongKeyKind ClavisLogger.log("SECURITY_ALERT", "Agent key '\(matchingKey.label)' cannot be used for SSHSIG signing. Refusing request from \(clientDesc).") @@ -277,9 +375,9 @@ public final class SigningPolicyEvaluator { } } - let requester = Self.requesterDescription(processPath: processPath, pid: pid) + let requester = callerName ?? Self.requesterDescription(processPath: processPath, pid: pid) let requesterIdentity = SigningPromptGate.Requester(executablePath: processPath, pid: pid) - let clientIdentity = ProcessInspector.resolveClientIdentity(pid: pid, processPath: processPath) + let clientIdentity = approvalIdentity ?? ProcessInspector.resolveClientIdentity(pid: pid, processPath: processPath) let attributedStartTime = clientStartTime ?? ProcessInspector.processStartTime(pid: pid) let peerProcess = attributedStartTime.map { GitApprovedProcess(pid: pid, startTime: $0, path: processPath) diff --git a/Tests/ClavisAppTests/AppStateAgentSessionTests.swift b/Tests/ClavisAppTests/AppStateAgentSessionTests.swift index ee8baee..24de667 100644 --- a/Tests/ClavisAppTests/AppStateAgentSessionTests.swift +++ b/Tests/ClavisAppTests/AppStateAgentSessionTests.swift @@ -23,7 +23,7 @@ struct AppStateAgentSessionTests { let registry = AgentSessionRegistry(auditRecorder: auditRecorder) let sockPath = fixture.testRootURL.appendingPathComponent("appstate-agent.sock").path - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: sockPath, role: .personal, keyManager: manager, diff --git a/Tests/ClavisAppTests/DaemonLifecycleTests.swift b/Tests/ClavisAppTests/DaemonLifecycleTests.swift index 57ae3f1..c3d9f1d 100644 --- a/Tests/ClavisAppTests/DaemonLifecycleTests.swift +++ b/Tests/ClavisAppTests/DaemonLifecycleTests.swift @@ -241,7 +241,7 @@ final class DaemonLifecycleTests { #expect(!SSHAgentServer.isSocketListening(atPath: testSockPath)) // 2. Start primary server - let primaryServer = SSHAgentServer(socketPath: testSockPath) + let primaryServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath) try primaryServer.start() defer { primaryServer.stop() } @@ -258,7 +258,7 @@ final class DaemonLifecycleTests { #expect(listening, "Server should be actively listening on socket") // 3. Attempting to start a second server on the same socket must fail with socketAlreadyInUse - let secondaryServer = SSHAgentServer(socketPath: testSockPath) + let secondaryServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath) #expect(throws: SSHAgentServerError.socketAlreadyInUse(testSockPath)) { try secondaryServer.start() } @@ -277,7 +277,7 @@ final class DaemonLifecycleTests { #expect(!manager.isAgentRunning) - let server = SSHAgentServer(socketPath: testSockPath) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath) try server.start() defer { server.stop() } @@ -301,7 +301,7 @@ final class DaemonLifecycleTests { fixture.runSync { let appState = AppState( keyManager: makeKeyManager(), - sshAgentServer: SSHAgentServer(socketPath: testRootURL.appendingPathComponent("app-state.sock").path) + sshAgentServer: SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testRootURL.appendingPathComponent("app-state.sock").path) ) #expect(appState.errorMessage == nil) @@ -333,7 +333,7 @@ final class DaemonLifecycleTests { @MainActor @Test func testAppTerminationClearsCachesStopsServerAndStopsAgent() throws { let socketPath = testRootURL.appendingPathComponent("termination.sock").path - let server = SSHAgentServer(socketPath: socketPath) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: socketPath) try server.start() var agentStopCount = 0 @@ -474,7 +474,7 @@ final class DaemonLifecycleTests { @MainActor @Test func testAppStateIgnoresSpoofedGitGraceNotificationUserInfo() throws { let socketPath = testRootURL.appendingPathComponent("spoof.sock").path - let server = SSHAgentServer(socketPath: socketPath) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: socketPath) let lifecycle = AgentLifecycleManager(socketPath: socketPath) let appState = AppState( keyManager: makeKeyManager(), diff --git a/Tests/ClavisAppTests/LockAllFailClosedTests.swift b/Tests/ClavisAppTests/LockAllFailClosedTests.swift index be86ef0..9111a62 100644 --- a/Tests/ClavisAppTests/LockAllFailClosedTests.swift +++ b/Tests/ClavisAppTests/LockAllFailClosedTests.swift @@ -258,7 +258,7 @@ final class LockAllFailClosedTests { let manager = AgentLifecycleManager(socketPath: sockPath, isTrustedServerPeer: { _ in true }) let appState = AppState( keyManager: makeKeyManager(), - sshAgentServer: SSHAgentServer(socketPath: testRootURL.appendingPathComponent("app-srv-fail.sock").path), + sshAgentServer: SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testRootURL.appendingPathComponent("app-srv-fail.sock").path), agentLifecycle: manager ) @@ -298,7 +298,7 @@ final class LockAllFailClosedTests { let manager = AgentLifecycleManager(socketPath: sockPath, isTrustedServerPeer: { _ in true }) let appState = AppState( keyManager: makeKeyManager(), - sshAgentServer: SSHAgentServer(socketPath: testRootURL.appendingPathComponent("app-srv-ok.sock").path), + sshAgentServer: SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testRootURL.appendingPathComponent("app-srv-ok.sock").path), agentLifecycle: manager ) @@ -318,7 +318,7 @@ final class LockAllFailClosedTests { let manager = AgentLifecycleManager(socketPath: sockPath, isTrustedServerPeer: { _ in true }) let appState = AppState( keyManager: makeKeyManager(), - sshAgentServer: SSHAgentServer(socketPath: testRootURL.appendingPathComponent("app-srv-locknow-fail.sock").path), + sshAgentServer: SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testRootURL.appendingPathComponent("app-srv-locknow-fail.sock").path), agentLifecycle: manager ) diff --git a/Tests/ClavisAppTests/SettingsViewModelTests.swift b/Tests/ClavisAppTests/SettingsViewModelTests.swift index b7a6a4d..7c1d96a 100644 --- a/Tests/ClavisAppTests/SettingsViewModelTests.swift +++ b/Tests/ClavisAppTests/SettingsViewModelTests.swift @@ -18,7 +18,7 @@ final class SettingsViewModelTests { self.mockAgent = MockAgentController() let sockPath = fixture.testRootURL.appendingPathComponent("settings-test.sock").path self.sockPath = sockPath - self.server = SSHAgentServer(socketPath: sockPath, keyManager: fixture.makeKeyManager()) + self.server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: sockPath, keyManager: fixture.makeKeyManager()) } deinit { diff --git a/Tests/ClavisCoreTests/AgentApprovalModeTests.swift b/Tests/ClavisCoreTests/AgentApprovalModeTests.swift index d7e7a8f..bc8ea72 100644 --- a/Tests/ClavisCoreTests/AgentApprovalModeTests.swift +++ b/Tests/ClavisCoreTests/AgentApprovalModeTests.swift @@ -50,7 +50,7 @@ struct AgentApprovalModeTests { watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -64,7 +64,7 @@ struct AgentApprovalModeTests { ) // Register session on control server (or directly register with server) - let regServer = SSHAgentServer( + let regServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -120,7 +120,7 @@ struct AgentApprovalModeTests { watchRootExit: false ) - let regServer = SSHAgentServer( + let regServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: customManager, controlPeerValidator: { _ in true }, @@ -141,7 +141,7 @@ struct AgentApprovalModeTests { let initialAuthCount = countingAuthenticator.authenticationCount - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: customManager, controlPeerValidator: { _ in true }, @@ -210,7 +210,7 @@ struct AgentApprovalModeTests { ) registry.add(session) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: customManager, controlPeerValidator: { _ in true }, @@ -249,7 +249,7 @@ struct AgentApprovalModeTests { watchRootExit: false ) - let regServer = SSHAgentServer( + let regServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: customManager, controlPeerValidator: { _ in true }, @@ -269,7 +269,7 @@ struct AgentApprovalModeTests { ) #expect(regResp.first == 6) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: customManager, controlPeerValidator: { _ in true }, diff --git a/Tests/ClavisCoreTests/AgentKeyPolicyTests.swift b/Tests/ClavisCoreTests/AgentKeyPolicyTests.swift index 2ef45a3..f3f5610 100644 --- a/Tests/ClavisCoreTests/AgentKeyPolicyTests.swift +++ b/Tests/ClavisCoreTests/AgentKeyPolicyTests.swift @@ -239,7 +239,7 @@ struct AgentKeyPolicyTests { let key = try manager.generateKey(label: "agent-presence-key", biometricPolicy: .userPresence, keyPurpose: .agent) let policyStore = InMemoryAgentPolicyStore() - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -284,7 +284,7 @@ struct AgentKeyPolicyTests { let key = try manager.generateKey(label: "agent-invalid-key", biometricPolicy: .userPresence, keyPurpose: .agent) let policyStore = InMemoryAgentPolicyStore() - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -332,7 +332,7 @@ struct AgentKeyPolicyTests { let personalKey = try manager.generateKey(label: "personal-key-for-policy", biometricPolicy: .userPresence, keyPurpose: .general) let policyStore = InMemoryAgentPolicyStore() - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -369,7 +369,7 @@ struct AgentKeyPolicyTests { let policyStore = InMemoryAgentPolicyStore() policyStore.corruptFingerprints.insert(key.fingerprint) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -406,7 +406,7 @@ struct AgentKeyPolicyTests { let customPolicy = AgentKeyPolicy(mode: .ask, leaseMinutes: 240, burst: 50, refillPerMinute: 10) try policyStore.save(customPolicy, forFingerprint: key.fingerprint) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -441,7 +441,7 @@ struct AgentKeyPolicyTests { let manager = fixture.makeKeyManager(authenticator: AllowingAuthenticator(), auditRecorder: auditRecorder) let policyStore = InMemoryAgentPolicyStore() - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -514,7 +514,7 @@ struct AgentKeyPolicyTests { let manager = fixture.makeKeyManager(authenticator: AllowingAuthenticator(), auditRecorder: auditRecorder) let policyStore = InMemoryAgentPolicyStore() - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -637,7 +637,7 @@ struct AgentKeyPolicyTests { ) let policyStore = InMemoryAgentPolicyStore() - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, diff --git a/Tests/ClavisCoreTests/AgentLifecycleManagerTests.swift b/Tests/ClavisCoreTests/AgentLifecycleManagerTests.swift index 6701e89..92944e1 100644 --- a/Tests/ClavisCoreTests/AgentLifecycleManagerTests.swift +++ b/Tests/ClavisCoreTests/AgentLifecycleManagerTests.swift @@ -201,7 +201,7 @@ struct AgentLifecycleManagerTests { func testControlClientRejectsUntrustedServerPeer() throws { defer { fixture.cleanup() } let sockPath = fixture.testRootURL.appendingPathComponent("untrusted-server.sock").path - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: sockPath, role: .personal, controlPeerValidator: { _ in true } diff --git a/Tests/ClavisCoreTests/AgentPathSeparationTests.swift b/Tests/ClavisCoreTests/AgentPathSeparationTests.swift index d1bceeb..7f571d3 100644 --- a/Tests/ClavisCoreTests/AgentPathSeparationTests.swift +++ b/Tests/ClavisCoreTests/AgentPathSeparationTests.swift @@ -29,7 +29,7 @@ struct AgentPathSeparationTests { _ = try keyManager.generateKey(label: "personal-git-only", keyPurpose: .gitSigningOnly) _ = try keyManager.generateKey(label: "agent-key", keyPurpose: .agent) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: keyManager, peerProcessValidator: { _, _, _ in true } @@ -67,7 +67,7 @@ struct AgentPathSeparationTests { let keyManager = fixture.makeKeyManager(authenticator: auth, auditRecorder: recorder) let agentKey = try keyManager.generateKey(label: "agent-key-prompt-test", keyPurpose: .agent) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: keyManager, peerProcessValidator: { _, _, _ in true }, @@ -129,7 +129,7 @@ struct AgentPathSeparationTests { } // 2. SSHAgentServer on personal socket must deny and audit wrongKeyKind - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: keyManager, peerProcessValidator: { _, _, _ in true }, @@ -206,7 +206,7 @@ struct AgentPathSeparationTests { _ = try keyManager.generateKey(label: "personal-git-only", keyPurpose: .gitSigningOnly) let agentKey = try keyManager.generateKey(label: "agent-specific", keyPurpose: .agent) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: keyManager, peerProcessValidator: { _, _, _ in true } @@ -243,7 +243,7 @@ struct AgentPathSeparationTests { let keyManager = fixture.makeKeyManager(auditRecorder: recorder) let agentKey = try keyManager.generateKey(label: "agent-nosession", keyPurpose: .agent) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: keyManager, peerProcessValidator: { _, _, _ in true }, @@ -275,7 +275,7 @@ struct AgentPathSeparationTests { let keyManager = fixture.makeKeyManager(auditRecorder: recorder) let personalKey = try keyManager.generateKey(label: "personal-on-agent", keyPurpose: .general) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: keyManager, peerProcessValidator: { _, _, _ in true }, @@ -301,7 +301,7 @@ struct AgentPathSeparationTests { @Test func test_005_T3_agentSocketRefusesControlOpcodes() throws { - let server = SSHAgentServer(role: .agent) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent) for opcode: UInt8 in [240, 241, 242] { var payload = Data([opcode]) @@ -322,8 +322,8 @@ struct AgentPathSeparationTests { let personalSock = fixture.testRootURL.appendingPathComponent("p.sock").path let invalidAgentSock = "/dev/null/impossible/agent.sock" - let personalServer = SSHAgentServer(socketPath: personalSock, role: .personal) - let failingAgentServer = SSHAgentServer(socketPath: invalidAgentSock, role: .agent) + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: personalSock, role: .personal) + let failingAgentServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: invalidAgentSock, role: .agent) defer { AgentServers.stopAll(personal: personalServer, agent: failingAgentServer) diff --git a/Tests/ClavisCoreTests/AgentSessionLeaseTests.swift b/Tests/ClavisCoreTests/AgentSessionLeaseTests.swift index 12b7feb..d280997 100644 --- a/Tests/ClavisCoreTests/AgentSessionLeaseTests.swift +++ b/Tests/ClavisCoreTests/AgentSessionLeaseTests.swift @@ -85,7 +85,7 @@ struct AgentSessionLeaseTests { watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -185,7 +185,7 @@ struct AgentSessionLeaseTests { watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, @@ -226,7 +226,7 @@ struct AgentSessionLeaseTests { watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: { _ in true }, peerProcessValidator: { _, _, _ in true }, diff --git a/Tests/ClavisCoreTests/AgentSessionProtocolTests.swift b/Tests/ClavisCoreTests/AgentSessionProtocolTests.swift index 289ef3b..5d7f40a 100644 --- a/Tests/ClavisCoreTests/AgentSessionProtocolTests.swift +++ b/Tests/ClavisCoreTests/AgentSessionProtocolTests.swift @@ -38,7 +38,7 @@ struct AgentSessionProtocolTests { } private func makeServer(controlPeerValidator: @escaping (Int32) -> Bool = { _ in true }) -> SSHAgentServer { - SSHAgentServer( + SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, controlPeerValidator: controlPeerValidator, peerProcessValidator: { _, _, _ in true }, @@ -248,7 +248,7 @@ struct AgentSessionProtocolTests { let snap = SSHAgentServer.processParentSnapshot(pid: currentPid) let currentStartTime = snap?.startTime ?? 1000 - let personalServer = SSHAgentServer( + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: countingManager, controlPeerValidator: { _ in true }, @@ -256,7 +256,7 @@ struct AgentSessionProtocolTests { auditRecorder: auditRecorder, agentSessions: registry ) - let agentServer = SSHAgentServer( + let agentServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: countingManager, controlPeerValidator: { _ in true }, @@ -313,7 +313,7 @@ struct AgentSessionProtocolTests { defer { fixture.cleanup() } try await fixture.run { let key = try manager.generateKey(label: "agent-ac2-key", biometricPolicy: .userPresence, keyPurpose: .agent) - let agentServer = SSHAgentServer( + let agentServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, auditRecorder: auditRecorder, @@ -340,7 +340,7 @@ struct AgentSessionProtocolTests { defer { fixture.cleanup() } try await fixture.run { let key = try manager.generateKey(label: "agent-ac3-key", biometricPolicy: .userPresence, keyPurpose: .agent) - let personalServer = SSHAgentServer( + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -348,7 +348,7 @@ struct AgentSessionProtocolTests { auditRecorder: auditRecorder, agentSessions: registry ) - let agentServer = SSHAgentServer( + let agentServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -395,7 +395,7 @@ struct AgentSessionProtocolTests { let snap = SSHAgentServer.processParentSnapshot(pid: currentPid) let currentStartTime = snap?.startTime ?? 1000 - let personalServer = SSHAgentServer( + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: countingManager, controlPeerValidator: { _ in true }, @@ -431,7 +431,7 @@ struct AgentSessionProtocolTests { let snap = SSHAgentServer.processParentSnapshot(pid: currentPid) let currentStartTime = snap?.startTime ?? 1000 - let personalServer = SSHAgentServer( + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -439,7 +439,7 @@ struct AgentSessionProtocolTests { auditRecorder: auditRecorder, agentSessions: registry ) - let agentServer = SSHAgentServer( + let agentServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -471,7 +471,7 @@ struct AgentSessionProtocolTests { defer { fixture.cleanup() } try await fixture.run { let key = try manager.generateKey(label: "agent-lockall-key", biometricPolicy: .userPresence, keyPurpose: .agent) - let personalServer = SSHAgentServer( + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -508,7 +508,7 @@ struct AgentSessionProtocolTests { let keyA = try manager.generateKey(label: "agent-key-a", biometricPolicy: .userPresence, keyPurpose: .agent) let keyB = try manager.generateKey(label: "agent-key-b", biometricPolicy: .userPresence, keyPurpose: .agent) - let personalServer = SSHAgentServer( + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -552,7 +552,7 @@ struct AgentSessionProtocolTests { let keyA = try manager.generateKey(label: "agent-rev-a", biometricPolicy: .userPresence, keyPurpose: .agent) let keyB = try manager.generateKey(label: "agent-rev-b", biometricPolicy: .userPresence, keyPurpose: .agent) - let personalServer = SSHAgentServer( + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -596,7 +596,7 @@ struct AgentSessionProtocolTests { try await fixture.run { let key = try manager.generateKey(label: "agent-lifecycle-key", biometricPolicy: .userPresence, keyPurpose: .agent) let sockPath = fixture.testRootURL.appendingPathComponent("lifecycle.sock").path - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: sockPath, role: .personal, keyManager: manager, diff --git a/Tests/ClavisCoreTests/ApplicationRequestAuthorizationTests.swift b/Tests/ClavisCoreTests/ApplicationRequestAuthorizationTests.swift new file mode 100644 index 0000000..0cf56b8 --- /dev/null +++ b/Tests/ClavisCoreTests/ApplicationRequestAuthorizationTests.swift @@ -0,0 +1,137 @@ +import Foundation +import LocalAuthentication +import Testing +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite struct ApplicationRequestAuthorizationTests { + private func setup(_ classification: ApplicationClassification?, start: UInt64 = 7) throws -> (RequestRegistry, ApplicationRequestAuthorization) { + let identity = try ApplicationIdentity(kind: .designatedRequirement, value: "test-caller") + let app = RegisteredApplication(identity: identity, name: "Caller", path: "/caller", classification: classification ?? .block) + let store = RequestRegistry(.init(applications: classification == nil ? [] : [app])) + let resolver = ApplicationCallerResolver( + inspect: { pid in .init(pid: pid, parent: 1, start: start, path: "/caller") }, + verify: { _, _ in true }, transparentHelper: { _ in false } + ) + return (store, ApplicationRequestAuthorization(store: store, resolver: resolver)) + } + + @Test(arguments: ApplicationClassification.allCases, [KeyPurpose.general, .agent]) + func directSigningMatrix(_ classification: ApplicationClassification, _ purpose: KeyPurpose) async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let (_, access) = try setup(classification) + let auth = CountingAuthenticator() + let manager = fixture.makeKeyManager(authenticator: auth) + let key = try manager.generateKey(label: "matrix", keyPurpose: purpose) + let evaluator = SigningPolicyEvaluator(applicationAccess: access, keyManager: manager, authenticator: auth, peerProcessValidator: { _, _, _ in true }) + let decision = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), flags: 0, role: .personal, clientPid: getpid(), clientExecutablePath: "/caller", clientStartTime: 7) + let allowed = classification == .personal || (classification == .bot && purpose == .agent) + if allowed { + guard case .approved = decision else { Issue.record("Expected approved direct request"); return } + } else { + #expect(decision == .denied) + #expect(auth.authenticationCount == 0) + } + } + } + + @Test func uncertainCallerAuthenticatesEveryRequestWithoutCreatingGrant() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let (_, access) = try setup(nil) + let auth = CountingAuthenticator() + let grants = GitSigningGraceManager(observeSystemEvents: false) + let manager = fixture.makeKeyManager(authenticator: auth, gitSigningGraceManager: grants) + let key = try manager.generateKey(label: "once") + let evaluator = SigningPolicyEvaluator(applicationAccess: access, keyManager: manager, gitSigningGraceManager: grants, authenticator: auth, peerProcessValidator: { _, _, _ in true }) + for _ in 0..<2 { + let result = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), flags: 0, role: .personal, clientPid: getpid(), clientExecutablePath: "/caller", clientStartTime: 7) + guard case .approved = result else { Issue.record("Expected authenticated one-shot request"); return } + } + #expect(auth.authenticationCount == 2) + #expect(auth.lastPolicy == .deviceOwnerAuthentication) + #expect(auth.lastReason?.contains("identity is uncertain") == true) + #expect(grants.allActiveGrants().isEmpty) + } + } + + @Test(arguments: [ApplicationClassification.personal, .bot, .block]) + func identitiesAreFilteredByVerifiedCaller(_ classification: ApplicationClassification) async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let pid = getpid() + let start = try #require(ProcessInspector.processStartTime(pid: pid)) + let (_, access) = try setup(classification, start: start) + let manager = fixture.makeKeyManager() + _ = try manager.generateKey(label: "personal") + _ = try manager.generateKey(label: "bot", keyPurpose: .agent) + let server = SSHAgentServer(applicationAccess: access, keyManager: manager) + let response = server.handleRequestIdentities(clientPid: pid, clientExecutablePath: "/caller") + var reader = DataReader(data: Data(response.dropFirst())) + let expected: UInt32 = classification == .personal ? 2 : (classification == .bot ? 1 : 0) + #expect(reader.readUInt32() == expected) + let unknown = SSHAgentServer(applicationAccess: try setup(nil, start: start).1, keyManager: manager) + let unknownResponse = unknown.handleRequestIdentities(clientPid: pid, clientExecutablePath: "/caller") + var unknownReader = DataReader(data: Data(unknownResponse.dropFirst())) + #expect(unknownReader.readUInt32() == 0) + } + } + + @Test func compatibilityEndpointDeniesPersonalEvenForPersonalCaller() throws { + let (_, access) = try setup(.personal) + let key = SSHKeyInfo(label: "personal", publicKeyOpenSSH: "", publicKeyBlob: Data(), fingerprint: "") + #expect(try access.authorize(key: key, operation: .ssh, role: .agent, pid: 100, path: "/caller", start: 7).eligibility == .denied) + } + + @Test func policyChangeDuringPromptDropsSignature() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let (store, access) = try setup(nil) + let auth = ChangingRequestAuthenticator { store.replace(.init()) } + let manager = fixture.makeKeyManager() + let key = try manager.generateKey(label: "changed") + let evaluator = SigningPolicyEvaluator(applicationAccess: access, keyManager: manager, authenticator: auth, peerProcessValidator: { _, _, _ in true }) + #expect(try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: Data("ssh".utf8), flags: 0, role: .personal, clientPid: getpid(), clientExecutablePath: "/caller", clientStartTime: 7) == .dropConnection) + } + } + + @Test func uncertainCallerCannotReuseExistingGrantOrEscalationHistory() async throws { + let fixture = try ClavisTestFixture() + defer { fixture.cleanup() } + try await fixture.run { + let (_, access) = try setup(nil) + let auth = CountingAuthenticator() + let grants = GitSigningGraceManager(observeSystemEvents: false) + let manager = fixture.makeKeyManager(authenticator: auth, gitSigningGraceManager: grants) + let key = try manager.generateKey(label: "no-cache") + let pid = getpid() + let snapshot = try #require(ProcessInspector.processParentSnapshot(pid: pid)) + grants.recordGrant(keyLabel: key.label, clientIdentity: "/caller", duration: 300, maxOperations: 100, context: LAContext(), approvedProcess: .init(pid: pid, startTime: snapshot.startTime, path: "/caller")) + grants.recordGitSignature(for: key.label, clientIdentity: "/caller") + let evaluator = SigningPolicyEvaluator(applicationAccess: access, keyManager: manager, gitSigningGraceManager: grants, authenticator: auth, peerProcessValidator: { _, _, _ in true }) + let payload = SSHSIGPayload(namespace: "git", hashAlgorithm: "sha256", messageHash: Data(repeating: 0x42, count: 32)).serialize() + _ = try evaluator.evaluateSigningRequest(keyBlob: key.publicKeyBlob, dataToSign: payload, flags: 0, role: .personal, clientPid: pid, clientExecutablePath: "/caller", clientStartTime: 7) + #expect(auth.authenticationCount == 1) + #expect(grants.allActiveGrants().first?.remainingOperations == 100) + } + } +} + +private final class RequestRegistry: ApplicationRegistryStoring, @unchecked Sendable { + private let lock = NSLock() + private var snapshot: ApplicationRegistrySnapshot + init(_ snapshot: ApplicationRegistrySnapshot) { self.snapshot = snapshot } + func load() throws -> ApplicationRegistrySnapshot { lock.lock(); defer { lock.unlock() }; return snapshot } + func save(_ snapshot: ApplicationRegistrySnapshot, replacing revision: UUID) throws { replace(snapshot) } + func replace(_ snapshot: ApplicationRegistrySnapshot) { lock.lock(); defer { lock.unlock() }; self.snapshot = snapshot } +} +private struct ChangingRequestAuthenticator: UserAuthenticating { + let action: () -> Void + func authenticate(reason: String, policy: LAPolicy) throws -> LAContext { action(); return LAContext() } + func authenticate(reason: String, policy: LAPolicy) async throws -> LAContext { action(); return LAContext() } +} diff --git a/Tests/ClavisCoreTests/GitSigningGraceTests.swift b/Tests/ClavisCoreTests/GitSigningGraceTests.swift index 1f17ed7..0d0d54a 100644 --- a/Tests/ClavisCoreTests/GitSigningGraceTests.swift +++ b/Tests/ClavisCoreTests/GitSigningGraceTests.swift @@ -189,7 +189,7 @@ final class GitSigningGraceTests { #expect(record.purpose == .gitSigningOnly) // SSH Agent identities answer must EXCLUDE gitSigningOnly keys - let server = SSHAgentServer(keyManager: manager) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager) let identitiesData = server.handleRequestIdentities() #expect(!identitiesData.isEmpty) let blobInIdentities = identitiesData.range(of: keyInfo.publicKeyBlob) != nil @@ -313,7 +313,7 @@ final class GitSigningGraceTests { privateKeyStore: store, gitSigningGraceManager: customGraceManager ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, gitSigningGraceManager: customGraceManager ) @@ -884,7 +884,7 @@ final class GitSigningGraceTests { signingProvider: signingProvider, gitSigningGraceManager: customGraceManager ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, gitSigningGraceManager: customGraceManager ) @@ -964,7 +964,7 @@ final class GitSigningGraceTests { privateKeyStore: store, gitSigningGraceManager: customGraceManager ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, gitSigningGraceManager: customGraceManager, peerProcessValidator: { _, _, _ in true } @@ -1027,7 +1027,7 @@ final class GitSigningGraceTests { privateKeyStore: store, gitSigningGraceManager: customGraceManager ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, gitSigningGraceManager: customGraceManager, peerProcessValidator: { _, _, _ in true } @@ -1100,7 +1100,7 @@ final class GitSigningGraceTests { privateKeyStore: store, gitSigningGraceManager: customGraceManager ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: manager, gitSigningGraceManager: customGraceManager ) @@ -1676,7 +1676,7 @@ final class GitSigningGraceTests { privateKeyStore: store, gitSigningGraceManager: customGraceManager ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: keyManager, gitSigningGraceManager: customGraceManager ) diff --git a/Tests/ClavisCoreTests/KeyLifecycleAuditTests.swift b/Tests/ClavisCoreTests/KeyLifecycleAuditTests.swift index 9eab6f7..74df7b5 100644 --- a/Tests/ClavisCoreTests/KeyLifecycleAuditTests.swift +++ b/Tests/ClavisCoreTests/KeyLifecycleAuditTests.swift @@ -82,7 +82,7 @@ import LocalAuthentication defer { fixture.cleanup() } await fixture.run { let recorder = InMemoryAuditRecorder() - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: fixture.makeKeyManager(), auditRecorder: recorder ) diff --git a/Tests/ClavisCoreTests/SSHAgentServerAuditTests.swift b/Tests/ClavisCoreTests/SSHAgentServerAuditTests.swift index 728c348..f7df4cb 100644 --- a/Tests/ClavisCoreTests/SSHAgentServerAuditTests.swift +++ b/Tests/ClavisCoreTests/SSHAgentServerAuditTests.swift @@ -40,7 +40,7 @@ final class SSHAgentServerAuditTests { peerProcessValidator: ((pid_t, String, UInt64) -> Bool)? = nil, auditRecorder: AuditRecording ) -> SSHAgentServer { - SSHAgentServer( + SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: keyManager, promptGate: SigningPromptGate(), gitSigningGraceManager: GitSigningGraceManager(observeSystemEvents: false), diff --git a/Tests/ClavisCoreTests/SSHAgentServerTests.swift b/Tests/ClavisCoreTests/SSHAgentServerTests.swift index b2c807f..ac4c9b1 100644 --- a/Tests/ClavisCoreTests/SSHAgentServerTests.swift +++ b/Tests/ClavisCoreTests/SSHAgentServerTests.swift @@ -101,7 +101,7 @@ final class SSHAgentServerTests { var request = Data([SSHAgentServer.invalidateKeyRequest]) request.appendWireString(label) - let response = SSHAgentServer( + let response = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: makeKeyManager(gitSigningGraceManager: graceManager), gitSigningGraceManager: graceManager ).processAgentRequest( @@ -123,7 +123,7 @@ final class SSHAgentServerTests { clientIdentity: "/usr/bin/git", context: LAContext() ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: makeKeyManager(gitSigningGraceManager: graceManager), gitSigningGraceManager: graceManager ) @@ -143,7 +143,7 @@ final class SSHAgentServerTests { } @Test func testTrustCallbackIsNotEvaluatedForStandardAgentMessages() { - let server = SSHAgentServer(keyManager: makeKeyManager()) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: makeKeyManager()) var evaluated = false _ = server.processAgentRequest(payload: Data([11]), isTrustedControlPeer: { evaluated = true; return true }) #expect(!evaluated) @@ -151,7 +151,7 @@ final class SSHAgentServerTests { @Test func testControlOpcodeOverRealSocketIsRefusedForNonClavisPeer() throws { let socketPath = testRootURL.appendingPathComponent("control.sock").path - let server = SSHAgentServer(socketPath: socketPath, keyManager: makeKeyManager()) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: socketPath, keyManager: makeKeyManager()) try server.start() defer { server.stop() } @@ -175,7 +175,7 @@ final class SSHAgentServerTests { @Test func testSSHAgentServerLifecycle() throws { let testSockPath = testRootURL.appendingPathComponent("clavis-unittest.sock").path - let server = SSHAgentServer(socketPath: testSockPath) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath) #expect(!server.isSocketActive) try server.start() @@ -193,7 +193,7 @@ final class SSHAgentServerTests { @Test func testSSHAgentServerBoundsIdleClients() throws { let socketPath = testRootURL.appendingPathComponent("clavis-client-limit.sock").path - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: socketPath, maxConcurrentClients: 1, clientIdleTimeout: 0.6 @@ -225,7 +225,7 @@ final class SSHAgentServerTests { @Test func testSSHAgentServerRequestIdentitiesSocket() throws { try fixture.runSync { let testSockPath = testRootURL.appendingPathComponent("clavis-req-ident.sock").path - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath, keyManager: makeKeyManager(), testContext: fixture.context @@ -262,7 +262,7 @@ final class SSHAgentServerTests { @Test func testSSHAgentServerSignRequestMissingFlags() throws { let testSockPath = testRootURL.appendingPathComponent("clavis-sign-req.sock").path - let server = SSHAgentServer(socketPath: testSockPath, keyManager: makeKeyManager()) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath, keyManager: makeKeyManager()) try server.start() defer { server.stop() } @@ -301,7 +301,7 @@ final class SSHAgentServerTests { @Test func testSSHAgentServerRequestIdentities() { fixture.runSync { - let server = SSHAgentServer(keyManager: makeKeyManager(), testContext: fixture.context) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: makeKeyManager(), testContext: fixture.context) let requestPayload = Data([11]) // SSH2_AGENTC_REQUEST_IDENTITIES let response = server.processAgentRequest(payload: requestPayload) @@ -317,7 +317,7 @@ final class SSHAgentServerTests { @Test func testSSHAgentServerSignRequestParsing() { fixture.runSync { - let server = SSHAgentServer(keyManager: makeKeyManager(), testContext: fixture.context) + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), keyManager: makeKeyManager(), testContext: fixture.context) // 1. Invalid payload: empty or unknown msg type #expect(server.processAgentRequest(payload: Data()) == Data([5])) @@ -393,7 +393,7 @@ final class SSHAgentServerTests { } @Test func testSSHAgentServerPerPIDClientLimit() { - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), maxConcurrentClients: 10, maxConcurrentClientsPerPID: 2 ) @@ -481,12 +481,12 @@ final class SSHAgentServerTests { @Test func testStopDoesNotUnlinkForeignSocket() throws { let testSockPath = testRootURL.appendingPathComponent("clavis-foreign-stop.sock").path - let serverA = SSHAgentServer(socketPath: testSockPath) + let serverA = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath) try serverA.start() defer { serverA.stop() } // Instance B points to the same path but never started (never bound) - let serverB = SSHAgentServer(socketPath: testSockPath) + let serverB = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath) serverB.stop() // Socket file must still exist because serverB does not own it @@ -505,7 +505,7 @@ final class SSHAgentServerTests { @Test func testStopDoesNotUnlinkReplacedSocket() throws { let testSockPath = testRootURL.appendingPathComponent("clavis-replaced-stop.sock").path - let serverA = SSHAgentServer(socketPath: testSockPath) + let serverA = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: testSockPath) try serverA.start() // Replace socket file with a newly created socket (different inode) @@ -565,7 +565,7 @@ final class SSHAgentServerTests { } var server: SSHAgentServer! - server = SSHAgentServer( + server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: socketPath, keyManager: makeKeyManager(), backoffHandler: { duration in @@ -608,7 +608,7 @@ final class SSHAgentServerTests { // 2. Request cap limit (200 requests) let capSocketPath = testRootURL.appendingPathComponent("req-cap.sock").path - let capServer = SSHAgentServer( + let capServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: capSocketPath, maxRequestsPerConnection: 200, keyManager: makeKeyManager(), @@ -650,7 +650,7 @@ final class SSHAgentServerTests { // 3. Connection lifetime limit let lifeSocketPath = testRootURL.appendingPathComponent("lifetime.sock").path - let lifeServer = SSHAgentServer( + let lifeServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: lifeSocketPath, maxConnectionLifetime: 0.05, keyManager: makeKeyManager(), @@ -698,7 +698,7 @@ final class SSHAgentServerTests { defer { try? keyManager.deleteKey(label: label) } let keyInfo = try keyManager.generateKey(label: label) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: socketPath, keyManager: keyManager, peerProcessValidator: { pid, path, startTime in @@ -775,7 +775,7 @@ final class SSHAgentServerTests { } } - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: socketPath, keyManager: makeKeyManager(), backoffHandler: { _ in @@ -833,7 +833,7 @@ final class SSHAgentServerTests { } var server: SSHAgentServer! - server = SSHAgentServer( + server = SSHAgentServer(applicationAccess: TestApplicationAccess(), socketPath: socketPath, keyManager: makeKeyManager(), backoffHandler: { duration in @@ -1042,7 +1042,7 @@ final class SSHAgentServerTests { let gitOnlyKey = try keyMgr.generateKey(label: "git-only-key", biometricPolicy: .userPresence, keyPurpose: .gitSigningOnly) let generalKey = try keyMgr.generateKey(label: "general-key", biometricPolicy: .userPresence, keyPurpose: .general) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: keyMgr, controlPeerValidator: { _ in true }, @@ -1205,7 +1205,7 @@ final class SSHAgentServerTests { watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: keyMgr, controlPeerValidator: { _ in true }, @@ -1277,7 +1277,7 @@ final class SSHAgentServerTests { #expect(countingAuth.lastReason!.contains("5678")) // 4. .ask mode on agent socket enforces key's biometricPolicy.laPolicy and contextual prompt - let agentServer = SSHAgentServer( + let agentServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: keyMgr, controlPeerValidator: { _ in true }, diff --git a/Tests/ClavisCoreTests/SessionBindTests.swift b/Tests/ClavisCoreTests/SessionBindTests.swift index 422943b..920e989 100644 --- a/Tests/ClavisCoreTests/SessionBindTests.swift +++ b/Tests/ClavisCoreTests/SessionBindTests.swift @@ -251,7 +251,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -260,7 +260,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -334,7 +334,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -343,7 +343,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -424,7 +424,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -433,7 +433,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -492,7 +492,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -501,7 +501,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -570,7 +570,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -579,7 +579,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -642,7 +642,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -651,7 +651,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -697,7 +697,7 @@ final class SessionBindTests { let sessionIDA = Data("session-host-pers".utf8) let (hostAKeyBlob, hostASig) = try makeHostKeyAndSig(priv: hostA, sessionID: sessionIDA) - let personalServer = SSHAgentServer( + let personalServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -755,7 +755,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -764,7 +764,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -842,7 +842,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -851,7 +851,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -1056,7 +1056,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -1065,7 +1065,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -1170,7 +1170,7 @@ final class SessionBindTests { auditRecorder: auditRecorder, watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, @@ -1179,7 +1179,7 @@ final class SessionBindTests { agentSessions: registry, agentPolicies: policyStore ) - let controlServer = SSHAgentServer( + let controlServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, diff --git a/Tests/ClavisCoreTests/SigningPolicyEvaluatorTests.swift b/Tests/ClavisCoreTests/SigningPolicyEvaluatorTests.swift index ec14e14..8b1b196 100644 --- a/Tests/ClavisCoreTests/SigningPolicyEvaluatorTests.swift +++ b/Tests/ClavisCoreTests/SigningPolicyEvaluatorTests.swift @@ -21,7 +21,7 @@ final class SigningPolicyEvaluatorTests { authenticator: AllowingAuthenticator(), gitSigningGraceManager: graceManager ) - let evaluator = SigningPolicyEvaluator( + let evaluator = SigningPolicyEvaluator(applicationAccess: TestApplicationAccess(), keyManager: keyManager, gitSigningGraceManager: graceManager, auditRecorder: recorder, diff --git a/Tests/ClavisCoreTests/SigningPromptGateTests.swift b/Tests/ClavisCoreTests/SigningPromptGateTests.swift index cf2b630..c8b432d 100644 --- a/Tests/ClavisCoreTests/SigningPromptGateTests.swift +++ b/Tests/ClavisCoreTests/SigningPromptGateTests.swift @@ -525,7 +525,7 @@ struct SigningPromptGateTests { watchRootExit: false ) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: keyMgr, controlPeerValidator: { _ in true }, diff --git a/Tests/ClavisCoreTests/TokenBucketTests.swift b/Tests/ClavisCoreTests/TokenBucketTests.swift index 5d80268..6d58f49 100644 --- a/Tests/ClavisCoreTests/TokenBucketTests.swift +++ b/Tests/ClavisCoreTests/TokenBucketTests.swift @@ -95,7 +95,7 @@ struct TokenBucketTests { watchRootExit: false ) - let regServer = SSHAgentServer( + let regServer = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .personal, keyManager: manager, controlPeerValidator: { _ in true }, @@ -114,7 +114,7 @@ struct TokenBucketTests { ) #expect(regResp.first == 6) - let server = SSHAgentServer( + let server = SSHAgentServer(applicationAccess: TestApplicationAccess(), role: .agent, keyManager: manager, controlPeerValidator: { _ in true }, diff --git a/Tests/ClavisTestSupport/TestApplicationAccess.swift b/Tests/ClavisTestSupport/TestApplicationAccess.swift new file mode 100644 index 0000000..192df30 --- /dev/null +++ b/Tests/ClavisTestSupport/TestApplicationAccess.swift @@ -0,0 +1,14 @@ +import Foundation +import ClavisCore + +/// Existing protocol tests exercise their original socket/session policy in +/// isolation. Application authorization has separate integration coverage. +struct TestApplicationAccess: ApplicationRequestAuthorizing { + private let revision = UUID() + func authorize(key: SSHKeyInfo, operation: KeyOperation, role: AgentSocketRole, pid: pid_t, path: String, start: UInt64) throws -> ApplicationRequestLease { + let legacyAllowed = role == .agent ? key.purpose == .agent : key.purpose != .agent + let result: ApplicationEligibility = legacyAllowed && key.effectiveAccessProfile.capabilities.permits(operation) ? .eligible(.personal) : .denied + return .init(eligibility: result, revision: revision, caller: nil) + } + func validate(_ lease: ApplicationRequestLease, pid: pid_t, path: String, start: UInt64) throws -> Bool { true } +}