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
20 changes: 18 additions & 2 deletions Sources/ClavisCore/Models/ApplicationRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,14 @@ public struct ApplicationRegistrySnapshot: Codable, Equatable, Sendable {
public let version: Int
public let revision: UUID
public var applications: [RegisteredApplication]
public let requestsPaused: Bool?
public var isPaused: Bool { requestsPaused ?? false }

public init(revision: UUID = UUID(), applications: [RegisteredApplication] = []) {
public init(revision: UUID = UUID(), applications: [RegisteredApplication] = [], requestsPaused: Bool = false) {
version = 1
self.revision = revision
self.applications = applications
self.requestsPaused = requestsPaused
}

public func validate() throws {
Expand Down Expand Up @@ -156,7 +159,7 @@ public final class AuthenticatedApplicationRegistry {
private func commit(_ application: RegisteredApplication, replacing old: ApplicationRegistrySnapshot) throws {
var entries = old.applications.filter { $0.identity != application.identity }
entries.append(application)
let updated = ApplicationRegistrySnapshot(applications: entries)
let updated = ApplicationRegistrySnapshot(applications: entries, requestsPaused: old.isPaused)
try updated.validate()
let context = try authenticator.authenticate(
reason: "Change access for \(application.name) to \(application.classification.title)",
Expand All @@ -169,6 +172,19 @@ public final class AuthenticatedApplicationRegistry {
try store.save(updated, replacing: old.revision)
}

/// Pausing stops requests without waiting for authentication. Resuming is
/// authenticated and never recreates revoked approvals.
public func setPaused(_ paused: Bool) throws {
let old = try store.load()
if !paused {
let context = try authenticator.authenticate(reason: "Resume Clavis requests", policy: .deviceOwnerAuthentication)
context.invalidate()
}
try invalidateApprovals()
let updated = ApplicationRegistrySnapshot(applications: old.applications, requestsPaused: paused)
try store.save(updated, replacing: old.revision)
}

public func remove(identity: ApplicationIdentity) throws {
let old = try store.load()
guard var app = old.applications.first(where: { $0.identity == identity }) else {
Expand Down
13 changes: 11 additions & 2 deletions Sources/ClavisCore/Protocol/AgentControlDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation
import Darwin

internal struct AgentControlDispatcher {
let applicationRegistry: ApplicationRegistryStoring
let keyManager: KeychainManager
let auditRecorder: AuditRecording
let agentSessions: AgentSessionRegistry
Expand All @@ -19,8 +20,10 @@ internal struct AgentControlDispatcher {
authenticator: UserAuthenticating,
promptGate: SigningPromptGate,
peerProcessValidator: @escaping (pid_t, String, UInt64) -> Bool,
gitSigningGraceManager: GitSigningGraceManager = .shared
gitSigningGraceManager: GitSigningGraceManager = .shared,
applicationRegistry: ApplicationRegistryStoring = KeychainApplicationRegistry.shared
) {
self.applicationRegistry = applicationRegistry
self.keyManager = keyManager
self.auditRecorder = auditRecorder
self.agentSessions = agentSessions
Expand All @@ -32,7 +35,9 @@ internal struct AgentControlDispatcher {
}

static func isControlOpcode(_ msgType: UInt8) -> Bool {
msgType == SSHAgentServer.invalidateKeyRequest
msgType == SSHAgentServer.applicationRegistryRequest
|| msgType == SSHAgentServer.applicationPolicyChangeRequest
|| msgType == SSHAgentServer.invalidateKeyRequest
|| msgType == SSHAgentServer.queryGitGraceRequest
|| msgType == SSHAgentServer.lockAllRequest
|| msgType == SSHAgentServer.registerAgentSessionRequest
Expand All @@ -53,6 +58,10 @@ internal struct AgentControlDispatcher {
clientSocket: Int32?
) -> Data {
switch msgType {
case SSHAgentServer.applicationRegistryRequest:
return handleApplicationRegistry(payload: payload)
case SSHAgentServer.applicationPolicyChangeRequest:
return handleApplicationPolicyChange(payload: Data(payload.dropFirst()), clientPid: clientPid, clientExecutablePath: clientExecutablePath, clientStartTime: clientStartTime, clientSocket: clientSocket)
case SSHAgentServer.invalidateKeyRequest:
return handleInvalidateKey(payload: Data(payload.dropFirst()))
case SSHAgentServer.queryGitGraceRequest:
Expand Down
62 changes: 62 additions & 0 deletions Sources/ClavisCore/Protocol/ApplicationPolicyControl.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import Darwin

public enum ApplicationPolicyChange: Codable, Equatable, Sendable {
case set(RegisteredApplication)
case remove(ApplicationIdentity)
case pause
case resume
}

extension AgentControlDispatcher {
func handleApplicationRegistry(payload: Data) -> Data {
guard payload.count == 1,
let snapshot = try? applicationRegistry.load(),
let data = try? JSONEncoder().encode(snapshot) else { return Data([5]) }
var response = Data([6])
response.appendWireData(data)
return response
}

func handleApplicationPolicyChange(
payload: Data, clientPid: pid_t?, clientExecutablePath: String?,
clientStartTime: UInt64?, clientSocket: Int32?
) -> Data {
var reader = DataReader(data: payload)
guard let json = reader.readWireData(), json.count <= 65_536, reader.isEOF,
let change = try? JSONDecoder().decode(ApplicationPolicyChange.self, from: json),
let pid = clientPid, let path = clientExecutablePath, let start = clientStartTime,
peerProcessValidator(pid, path, start) else { return Data([5]) }
let registry = AuthenticatedApplicationRegistry(
store: applicationRegistry, authenticator: authenticator,
invalidateApprovals: {
guard self.peerProcessValidator(pid, path, start) else { throw ApplicationRegistryError.staleRevision }
self.gitSigningGraceManager.invalidateAll(broadcast: false)
self.agentSessions.endAll(reason: .keyChanged)
}
)
do {
if change == .pause {
try registry.setPaused(true)
} else {
try promptGate.run(requester: .init(executablePath: path, pid: pid), clientSocket: clientSocket) {
switch change {
case .set(let app): try registry.set(app)
case .remove(let identity): try registry.remove(identity: identity)
case .pause: break
case .resume: try registry.setPaused(false)
}
}
}
auditRecorder.record(AuditEvent(type: .policyChange, result: .info,
sensitive: .init(processChain: AuditProcessChain.build(pid: pid, executablePath: path))))
return Data([6])
} catch {
auditRecorder.record(AuditEvent(type: .policyChange,
result: AuditEvent.isUserCancellation(error) ? .cancelled : .denied,
reason: .policyUnavailable,
sensitive: .init(processChain: AuditProcessChain.build(pid: pid, executablePath: path))))
return Data([5])
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public final class ApplicationRequestAuthorization: ApplicationRequestAuthorizin

public func authorize(key: SSHKeyInfo, operation: KeyOperation, role: AgentSocketRole, pid: pid_t, path: String, start: UInt64) throws -> ApplicationRequestLease {
let snapshot = try store.load()
guard !snapshot.isPaused else {
return .init(eligibility: .denied, revision: snapshot.revision, caller: nil)
}
let caller = resolver.resolve(peerPID: pid, peerPath: path, peerStart: start, registry: snapshot)
let eligibility = ApplicationEligibility.evaluate(
profile: key.effectiveAccessProfile, operation: operation,
Expand All @@ -37,7 +40,7 @@ public final class ApplicationRequestAuthorization: ApplicationRequestAuthorizin

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 }
guard !snapshot.isPaused, snapshot.revision == lease.revision else { return false }
let caller = resolver.resolve(peerPID: pid, peerPath: path, peerStart: start, registry: snapshot)
return caller == lease.caller
}
Expand Down
9 changes: 7 additions & 2 deletions Sources/ClavisCore/Protocol/SSHAgentServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public class SSHAgentServer {
internal static let extendAgentSessionRequest: UInt8 = 247
internal static let setAgentPolicyRequest: UInt8 = 248
internal static let getAgentPolicyRequest: UInt8 = 249
internal static let applicationRegistryRequest: UInt8 = 250
internal static let applicationPolicyChangeRequest: UInt8 = 251
public static let defaultSocketPath = NSString(string: "~/.ssh/clavis.sock").expandingTildeInPath
public static let defaultAgentSocketPath = NSString(string: "~/.ssh/clavis-agent.sock").expandingTildeInPath
public static let shared = SSHAgentServer(promptGate: .shared)
Expand Down Expand Up @@ -90,7 +92,8 @@ public class SSHAgentServer {
internal var activeClientCount: Int { socketServer.activeClientCount }

public init(
applicationAccess: ApplicationRequestAuthorizing = ApplicationRequestAuthorization(),
applicationAccess: ApplicationRequestAuthorizing? = nil,
applicationRegistry: ApplicationRegistryStoring = KeychainApplicationRegistry.shared,
socketPath: String = SSHAgentServer.defaultSocketPath,
role: AgentSocketRole = .personal,
maxConcurrentClients: Int = 32,
Expand Down Expand Up @@ -131,6 +134,7 @@ public class SSHAgentServer {

let resolvedPeerValidator = peerProcessValidator ?? { SSHAgentServer.peerProcessUnchanged(pid: $0, path: $1, startTime: $2) }

let applicationAccess = applicationAccess ?? ApplicationRequestAuthorization(store: applicationRegistry)
self.controlDispatcher = AgentControlDispatcher(
keyManager: keyManager,
auditRecorder: auditRecorder,
Expand All @@ -139,7 +143,8 @@ public class SSHAgentServer {
authenticator: authenticator,
promptGate: promptGate,
peerProcessValidator: resolvedPeerValidator,
gitSigningGraceManager: gitSigningGraceManager
gitSigningGraceManager: gitSigningGraceManager,
applicationRegistry: applicationRegistry
)

self.applicationAccess = applicationAccess
Expand Down
6 changes: 6 additions & 0 deletions Sources/ClavisCore/Services/AgentControlling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ public protocol AgentControlling: Sendable {
func revokeAllAgentSessions() -> Int
func extendAgentSession(id: String, minutes: Int) -> Date?

func applicationRegistry() -> ApplicationRegistrySnapshot?
func changeApplicationPolicy(_ change: ApplicationPolicyChange) -> Bool

func getAgentPolicy(target: String) -> String?
func setAgentPolicy(target: String, policyJson: String) -> Bool
}

public extension AgentControlling {
func applicationRegistry() -> ApplicationRegistrySnapshot? { nil }
func changeApplicationPolicy(_ change: ApplicationPolicyChange) -> Bool { false }

func queryAllAgentGitGraces() -> [(keyLabel: String, remainingSeconds: Int, remainingOperations: Int)] {
if let single = queryAgentGitGrace() {
return [single]
Expand Down
17 changes: 17 additions & 0 deletions Sources/ClavisCore/Services/AgentLifecycleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,23 @@ public final class AgentLifecycleManager: @unchecked Sendable {
return Date(timeIntervalSince1970: TimeInterval(newExpirySeconds))
}

public func applicationRegistry() -> ApplicationRegistrySnapshot? {
guard let response = try? sendControlRequest(Data([SSHAgentServer.applicationRegistryRequest])),
response.first == 6 else { return nil }
var reader = DataReader(data: Data(response.dropFirst()))
guard let data = reader.readWireData(), reader.isEOF,
let snapshot = try? JSONDecoder().decode(ApplicationRegistrySnapshot.self, from: data),
(try? snapshot.validate()) != nil else { return nil }
return snapshot
}

public func changeApplicationPolicy(_ change: ApplicationPolicyChange) -> Bool {
guard let json = try? JSONEncoder().encode(change) else { return false }
var payload = Data([SSHAgentServer.applicationPolicyChangeRequest])
payload.appendWireData(json)
return (try? sendControlRequest(payload, timeout: 75))?.first == 6
}

public func setAgentPolicy(target: String, policyJson: String) -> Bool {
guard FileManager.default.fileExists(atPath: socketPath) else { return false }
var payload = Data([SSHAgentServer.setAgentPolicyRequest])
Expand Down
Loading
Loading