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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/ClavisCore/Crypto/KeychainManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions Sources/ClavisCore/Protocol/AgentSocketRole.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public enum AgentSocketRole: String, Sendable {
public var allowedPurposes: Set<KeyPurpose> {
switch self {
case .personal:
return [.general, .gitSigningOnly]
return [.general, .gitSigningOnly, .agent]
case .agent:
return [.agent]
}
Expand All @@ -16,7 +16,7 @@ public enum AgentSocketRole: String, Sendable {
public var listedPurposes: Set<KeyPurpose> {
switch self {
case .personal:
return [.general]
return [.general, .agent]
case .agent:
return [.agent]
}
Expand Down
44 changes: 44 additions & 0 deletions Sources/ClavisCore/Protocol/ApplicationRequestAuthorization.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
14 changes: 13 additions & 1 deletion Sources/ClavisCore/Protocol/SSHAgentServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -140,7 +142,9 @@ public class SSHAgentServer {
gitSigningGraceManager: gitSigningGraceManager
)

self.applicationAccess = applicationAccess
self.signingPolicyEvaluator = SigningPolicyEvaluator(
applicationAccess: applicationAccess,
keyManager: keyManager,
gitSigningGraceManager: gitSigningGraceManager,
promptGate: promptGate,
Expand Down Expand Up @@ -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

Expand Down
104 changes: 101 additions & 3 deletions Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]

Expand Down Expand Up @@ -68,6 +69,7 @@ public final class SigningPolicyEvaluator {
}

public init(
applicationAccess: ApplicationRequestAuthorizing = ApplicationRequestAuthorization(),
keyManager: KeychainManager = .shared,
gitSigningGraceManager: GitSigningGraceManager = .shared,
promptGate: SigningPromptGate = SigningPromptGate(),
Expand All @@ -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
Expand All @@ -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()) }
Expand Down Expand Up @@ -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).")
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Tests/ClavisAppTests/AppStateAgentSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 6 additions & 6 deletions Tests/ClavisAppTests/DaemonLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

Expand All @@ -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()
}
Expand All @@ -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() }

Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions Tests/ClavisAppTests/LockAllFailClosedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
)

Expand All @@ -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
)

Expand Down
2 changes: 1 addition & 1 deletion Tests/ClavisAppTests/SettingsViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading