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
158 changes: 158 additions & 0 deletions Sources/ClavisCore/Protocol/ApplicationCallerResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import Foundation
import Security
import CryptoKit
import Darwin

public struct VerifiedApplicationCaller: Equatable, Sendable {
public let identity: ApplicationIdentity
public let application: RegisteredApplication
public let pid: pid_t
public let startTime: UInt64
public let path: String
}

/// Returns uncertainty rather than guessing whether an interactive shell is human.
public final class ApplicationCallerResolver {
public struct Process: Equatable, Sendable {
public let pid: pid_t
public let parent: pid_t
public let start: UInt64
public let path: String
public init(pid: pid_t, parent: pid_t, start: UInt64, path: String) {
self.pid = pid; self.parent = parent; self.start = start; self.path = path
}
}

private let inspect: (pid_t) -> Process?
private let verify: (ApplicationIdentity, Process) -> Bool
private let transparentHelper: (Process) -> Bool

public init(
inspect: @escaping (pid_t) -> Process? = ApplicationCallerResolver.inspectProcess,
verify: @escaping (ApplicationIdentity, Process) -> Bool = ApplicationIdentityVerifier.matches,
transparentHelper: @escaping (Process) -> Bool = ApplicationCallerResolver.isTransportHelper
) {
self.inspect = inspect
self.verify = verify
self.transparentHelper = transparentHelper
}

public static func inspectProcess(_ pid: pid_t) -> Process? {
guard let before = ProcessInspector.processParentSnapshot(pid: pid),
let path = ProcessInspector.getProcessPath(pid: pid),
let after = ProcessInspector.processParentSnapshot(pid: pid), before == after else { return nil }
return Process(pid: pid, parent: before.parentPid, start: before.startTime, path: path)
}

public static func isTransportHelper(_ process: Process) -> Bool {
// Only Apple's signed transports are transparent to Personal attribution.
// Shells, interpreters, and arbitrary executables break that attribution.
guard ["/usr/bin/ssh", "/usr/bin/git", "/usr/bin/ssh-keygen"].contains(process.path),
let identity = try? ApplicationIdentity(kind: .designatedRequirement, value: "anchor apple") else { return false }
return ApplicationIdentityVerifier.matches(identity, process)
}

public func resolve(
peerPID: pid_t, peerPath: String, peerStart: UInt64,
registry: ApplicationRegistrySnapshot
) -> VerifiedApplicationCaller? {
var pid = peerPID
var visited = Set<pid_t>()
var personalAttributionIntact = true
var chain: [Process] = []
var candidate: (RegisteredApplication, Process)?
for _ in 0..<32 {
if pid <= 1 {
guard let candidate else { return nil }
return validated(candidate.0, process: candidate.1, chain: chain)
}
guard visited.insert(pid).inserted, let process = inspect(pid) else { return nil }
if chain.isEmpty && (process.path != peerPath || process.start != peerStart) { return nil }
chain.append(process)
// Block and Bot classifications may constrain requests even across an
// interpreter. Personal authority never crosses an unknown ancestor.
let matches = registry.applications.filter { verify($0.identity, process) }
if let blocked = matches.first(where: { $0.classification == .block }) {
return validated(blocked, process: process, chain: chain)
}
if let bot = matches.first(where: { $0.classification == .bot }) {
candidate = (bot, process)
} else if candidate == nil, personalAttributionIntact, !transparentHelper(process),
let personal = matches.first(where: { $0.classification == .personal }) {
candidate = (personal, process)
}
personalAttributionIntact = personalAttributionIntact && transparentHelper(process)
pid = process.parent
}
return nil
}

private func validated(_ app: RegisteredApplication, process: Process, chain: [Process]) -> VerifiedApplicationCaller? {
// Revalidate every sampled ancestor before returning attribution.
guard chain.allSatisfy({ inspect($0.pid) == $0 }), verify(app.identity, process) else { return nil }
return VerifiedApplicationCaller(identity: app.identity, application: app, pid: process.pid, startTime: process.start, path: process.path)
}
}

public enum ApplicationIdentityVerifier {
/// Called by the native add flow; selecting a path does not classify it.
public static func identity(forExecutable url: URL) throws -> ApplicationIdentity {
let canonical = url.resolvingSymlinksInPath()
var code: SecStaticCode?
if SecStaticCodeCreateWithPath(canonical as CFURL, [], &code) == errSecSuccess,
let code, SecStaticCodeCheckValidity(code, [], nil) == errSecSuccess {
var requirement: SecRequirement?
var text: CFString?
if SecCodeCopyDesignatedRequirement(code, [], &requirement) == errSecSuccess,
let requirement, SecRequirementCopyString(requirement, [], &text) == errSecSuccess,
let text {
return try ApplicationIdentity(kind: .designatedRequirement, value: text as String)
}
}
return try ApplicationIdentity(kind: .executableSHA256, value: digest(canonical))
}

public static func matches(_ identity: ApplicationIdentity, _ process: ApplicationCallerResolver.Process) -> Bool {
guard ProcessInspector.peerProcessUnchanged(pid: process.pid, path: process.path, startTime: process.start) else { return false }
let result: Bool
switch identity.kind {
case .designatedRequirement:
var requirement: SecRequirement?
var guest: SecCode?
let attributes = [kSecGuestAttributePid as String: process.pid] as CFDictionary
result = SecRequirementCreateWithString(identity.value as CFString, [], &requirement) == errSecSuccess
&& requirement != nil
&& SecCodeCopyGuestWithAttributes(nil, attributes, [], &guest) == errSecSuccess
&& guest != nil
&& SecCodeCheckValidity(guest!, [], requirement) == errSecSuccess
case .executableSHA256:
// A file hash alone does not identify the code already executing.
// Require dynamic code validity as well; truly unsigned processes
// remain uncertain and can only receive authenticated one-shot access.
var guest: SecCode?
let attributes = [kSecGuestAttributePid as String: process.pid] as CFDictionary
result = SecCodeCopyGuestWithAttributes(nil, attributes, [], &guest) == errSecSuccess
&& guest != nil && SecCodeCheckValidity(guest!, [], nil) == errSecSuccess
&& (try? digest(URL(fileURLWithPath: process.path))) == identity.value
}
return result && ProcessInspector.peerProcessUnchanged(pid: process.pid, path: process.path, startTime: process.start)
}

private static func digest(_ url: URL) throws -> String {
// Refuse bundles, devices, huge files, or files changing during hashing.
let keys: Set<URLResourceKey> = [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey, .fileResourceIdentifierKey]
let before = try url.resourceValues(forKeys: keys)
guard before.isRegularFile == true, let size = before.fileSize, size > 0, size <= 268_435_456 else {
throw ApplicationRegistryError.invalidIdentity
}
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let hash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
let after = try url.resourceValues(forKeys: keys)
guard before.fileSize == after.fileSize,
before.contentModificationDate == after.contentModificationDate,
String(describing: before.fileResourceIdentifier) == String(describing: after.fileResourceIdentifier) else {
throw ApplicationRegistryError.invalidIdentity
}
return hash
}
}
145 changes: 145 additions & 0 deletions Tests/ClavisCoreTests/ApplicationCallerResolverTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Foundation
import Testing
@testable import ClavisCore

@Suite struct ApplicationCallerResolverTests {
typealias Process = ApplicationCallerResolver.Process
private func app(_ classification: ApplicationClassification, path: String) throws -> RegisteredApplication {
RegisteredApplication(
identity: try ApplicationIdentity(kind: .designatedRequirement, value: path),
name: path, path: path, classification: classification
)
}
private func resolver(_ chain: [Process], changed: Bool = false) -> ApplicationCallerResolver {
ApplicationCallerResolver(
inspect: { pid in
guard let process = chain.first(where: { $0.pid == pid }) else { return nil }
return changed ? Process(pid: pid, parent: process.parent, start: process.start + 1, path: process.path) : process
},
verify: { identity, process in identity.value == process.path },
transparentHelper: { ["/ssh", "/git", "/ssh-keygen"].contains($0.path) }
)
}
private func resolve(_ chain: [Process], apps: [RegisteredApplication]) -> VerifiedApplicationCaller? {
resolver(chain).resolve(peerPID: 100, peerPath: chain[0].path, peerStart: 7, registry: .init(applications: apps))
}

@Test func knownDirectClientResolvesAndRevalidates() throws {
let client = try app(.personal, path: "/Editor")
let chain = [Process(pid: 100, parent: 1, start: 7, path: "/Editor")]
let result = resolve(chain, apps: [client])
#expect(result?.application == client)
#expect(result?.pid == 100)
#expect(result?.startTime == 7)
}

@Test func botInPersonalTerminalCannotInheritPersonalAuthority() throws {
let chain = [
Process(pid: 100, parent: 101, start: 7, path: "/ssh"),
Process(pid: 101, parent: 102, start: 8, path: "/bot"),
Process(pid: 102, parent: 103, start: 9, path: "/shell"),
Process(pid: 103, parent: 1, start: 10, path: "/Terminal")
]
#expect(resolve(chain, apps: [try app(.personal, path: "/Terminal")]) == nil)
#expect(resolve(chain, apps: [try app(.bot, path: "/bot"), try app(.personal, path: "/Terminal")])?.application.classification == .bot)
}

@Test func interactiveShellIsAlsoUncertainRatherThanAssumedPersonal() throws {
let chain = [
Process(pid: 100, parent: 101, start: 7, path: "/ssh"),
Process(pid: 101, parent: 102, start: 8, path: "/shell"),
Process(pid: 102, parent: 1, start: 9, path: "/Terminal")
]
#expect(resolve(chain, apps: [try app(.personal, path: "/Terminal")]) == nil)
}

@Test func verifiedTransportChainCanAttributeGUIClient() throws {
let chain = [
Process(pid: 100, parent: 101, start: 7, path: "/ssh-keygen"),
Process(pid: 101, parent: 102, start: 8, path: "/git"),
Process(pid: 102, parent: 1, start: 9, path: "/Editor")
]
#expect(resolve(chain, apps: [try app(.personal, path: "/Editor")])?.application.classification == .personal)
}

@Test func transportClassificationCannotReplaceActualCaller() throws {
let chain = [
Process(pid: 100, parent: 101, start: 7, path: "/ssh"),
Process(pid: 101, parent: 1, start: 8, path: "/bot")
]
#expect(resolve(chain, apps: [try app(.personal, path: "/ssh")]) == nil)
#expect(resolve(chain, apps: [try app(.personal, path: "/ssh"), try app(.bot, path: "/bot")])?.application.classification == .bot)
}

@Test func blockedAncestorOverridesPersonalOrBotDescendant() throws {
let chain = [
Process(pid: 100, parent: 101, start: 7, path: "/Editor"),
Process(pid: 101, parent: 1, start: 8, path: "/blocked")
]
#expect(resolve(chain, apps: [try app(.personal, path: "/Editor"), try app(.block, path: "/blocked")])?.application.classification == .block)
}

@Test func cyclesMissingAncestorsAndPIDReuseReturnUncertainty() throws {
let app = try app(.personal, path: "/Editor")
let cycle = [Process(pid: 100, parent: 100, start: 7, path: "/Editor")]
#expect(resolve(cycle, apps: [app]) == nil)
let missing = [Process(pid: 100, parent: 101, start: 7, path: "/Editor")]
#expect(resolve(missing, apps: [app]) == nil)
#expect(resolver(missing, changed: true).resolve(peerPID: 100, peerPath: "/Editor", peerStart: 7, registry: .init(applications: [app])) == nil)
}

@Test func replacedAncestorDuringResolutionInvalidatesCandidate() throws {
let app = try app(.personal, path: "/Editor")
var calls = 0
let resolver = ApplicationCallerResolver(inspect: { pid in
calls += 1
return Process(pid: pid, parent: 1, start: calls == 1 ? 7 : 8, path: "/Editor")
}, verify: { _, _ in true }, transparentHelper: { _ in false })
#expect(resolver.resolve(peerPID: 100, peerPath: "/Editor", peerStart: 7, registry: .init(applications: [app])) == nil)
}

@Test func boundedTraversalAndWrongPeerPathFailClosed() throws {
let app = try app(.personal, path: "/Editor")
var chain: [Process] = []
for pid in 100...140 {
let path = pid == 140 ? "/Editor" : "/ssh"
let start: UInt64 = pid == 100 ? 7 : 8
chain.append(Process(pid: Int32(pid), parent: Int32(pid + 1), start: start, path: path))
}
#expect(resolve(chain, apps: [app]) == nil)
let direct = [Process(pid: 100, parent: 1, start: 7, path: "/Editor")]
#expect(resolver(direct).resolve(peerPID: 100, peerPath: "/imposter", peerStart: 7, registry: .init(applications: [app])) == nil)
}

@Test func failedIdentityVerificationCannotUseRegisteredNameOrPath() throws {
let app = try app(.personal, path: "/Editor")
let resolver = ApplicationCallerResolver(inspect: { pid in
Process(pid: pid, parent: 1, start: 7, path: "/Editor")
}, verify: { _, _ in false }, transparentHelper: { _ in false })
#expect(resolver.resolve(peerPID: 100, peerPath: "/Editor", peerStart: 7, registry: .init(applications: [app])) == nil)
}

@Test func appleBinaryRegistrationDoesNotMatchUnrelatedRunningProcess() throws {
let identity = try ApplicationIdentityVerifier.identity(forExecutable: URL(fileURLWithPath: "/usr/bin/ssh"))
#expect(identity.kind == .designatedRequirement)
let current = try #require(ApplicationCallerResolver.inspectProcess(getpid()))
#expect(!ApplicationIdentityVerifier.matches(identity, current))
let forged = Process(pid: current.pid, parent: current.parent, start: current.start + 1, path: current.path)
#expect(!ApplicationIdentityVerifier.matches(identity, forged))
}

@Test func executableIdentityRequiresRealFileAndRoundTrips() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
#expect(throws: (any Error).self) { try ApplicationIdentityVerifier.identity(forExecutable: directory) }
let file = directory.appendingPathComponent("unsigned")
try Data("not executable code".utf8).write(to: file)
let identity = try ApplicationIdentityVerifier.identity(forExecutable: file)
#expect(identity.kind == .executableSHA256)
#expect(identity.value.count == 64)
#expect(try ApplicationIdentityVerifier.identity(forExecutable: file) == identity)
try Data("changed".utf8).write(to: file)
#expect(try ApplicationIdentityVerifier.identity(forExecutable: file) != identity)
}
}
Loading