From 78a30595330af3c1643b0754a117a017bf50db5b Mon Sep 17 00:00:00 2001 From: Damir Garifullin Date: Sat, 24 Oct 2026 13:12:30 +0300 Subject: [PATCH] Resolve caller identity conservatively across process ancestry --- .../Protocol/ApplicationCallerResolver.swift | 158 ++++++++++++++++++ .../ApplicationCallerResolverTests.swift | 145 ++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 Sources/ClavisCore/Protocol/ApplicationCallerResolver.swift create mode 100644 Tests/ClavisCoreTests/ApplicationCallerResolverTests.swift diff --git a/Sources/ClavisCore/Protocol/ApplicationCallerResolver.swift b/Sources/ClavisCore/Protocol/ApplicationCallerResolver.swift new file mode 100644 index 0000000..62c813c --- /dev/null +++ b/Sources/ClavisCore/Protocol/ApplicationCallerResolver.swift @@ -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() + 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 = [.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 + } +} diff --git a/Tests/ClavisCoreTests/ApplicationCallerResolverTests.swift b/Tests/ClavisCoreTests/ApplicationCallerResolverTests.swift new file mode 100644 index 0000000..e38b8a2 --- /dev/null +++ b/Tests/ClavisCoreTests/ApplicationCallerResolverTests.swift @@ -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) + } +}