From cab0e0a2d39298c15005b874c4bdb28a4e7fca37 Mon Sep 17 00:00:00 2001 From: Damir Garifullin Date: Sat, 24 Oct 2026 12:56:52 +0300 Subject: [PATCH] Add authenticated Personal Bot Block application registry --- .../Models/ApplicationRegistry.swift | 181 ++++++++++++++++++ .../KeychainApplicationRegistry.swift | 80 ++++++++ .../ApplicationRegistryTests.swift | 156 +++++++++++++++ 3 files changed, 417 insertions(+) create mode 100644 Sources/ClavisCore/Models/ApplicationRegistry.swift create mode 100644 Sources/ClavisCore/Services/KeychainApplicationRegistry.swift create mode 100644 Tests/ClavisCoreTests/ApplicationRegistryTests.swift diff --git a/Sources/ClavisCore/Models/ApplicationRegistry.swift b/Sources/ClavisCore/Models/ApplicationRegistry.swift new file mode 100644 index 0000000..dd5b1f9 --- /dev/null +++ b/Sources/ClavisCore/Models/ApplicationRegistry.swift @@ -0,0 +1,181 @@ +import Foundation +import LocalAuthentication +import Security + +public enum ApplicationClassification: String, Codable, CaseIterable, Sendable { + case personal + case bot + case block + + public var title: String { rawValue.capitalized } + + public func permits(_ type: KeyType) -> Bool { + switch self { + case .personal: return true + case .bot: return type == .bot + case .block: return false + } + } +} + +/// Identity evidence is compared with a running process by the daemon. +/// A display name, executable path, socket, or environment variable is not identity. +public struct ApplicationIdentity: Codable, Equatable, Hashable, Sendable { + public enum Kind: String, Codable, Sendable { + case designatedRequirement + case executableSHA256 + } + public let kind: Kind + public let value: String + + public init(kind: Kind, value: String) throws { + guard !value.isEmpty, value.utf8.count <= 16_384, + !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { + throw ApplicationRegistryError.invalidIdentity + } + if kind == .executableSHA256 { + guard value.count == 64, value.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else { + throw ApplicationRegistryError.invalidIdentity + } + } + self.kind = kind + self.value = value + } + + private enum CodingKeys: CodingKey { case kind, value } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init(kind: values.decode(Kind.self, forKey: .kind), value: values.decode(String.self, forKey: .value)) + } +} + +public struct RegisteredApplication: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let identity: ApplicationIdentity + public var name: String + public var path: String + public var classification: ApplicationClassification + public var removed: Bool + + public init( + id: UUID = UUID(), identity: ApplicationIdentity, name: String, path: String, + classification: ApplicationClassification, removed: Bool = false + ) { + self.id = id + self.identity = identity + self.name = name + self.path = path + self.classification = classification + self.removed = removed + } +} + +public struct ApplicationRegistrySnapshot: Codable, Equatable, Sendable { + public let version: Int + public let revision: UUID + public var applications: [RegisteredApplication] + + public init(revision: UUID = UUID(), applications: [RegisteredApplication] = []) { + version = 1 + self.revision = revision + self.applications = applications + } + + public func validate() throws { + guard version == 1, applications.count <= 512, + Set(applications.map(\.id)).count == applications.count, + Set(applications.map(\.identity)).count == applications.count else { + throw ApplicationRegistryError.corrupt + } + for app in applications { + guard !app.name.isEmpty, app.name.utf8.count <= 1_024, + app.path.hasPrefix("/"), app.path.utf8.count <= 4_096, + !app.name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + !app.removed || app.classification == .block else { + throw ApplicationRegistryError.corrupt + } + } + } + + public func classification(for verifiedIdentity: ApplicationIdentity?) -> ApplicationClassification? { + guard let verifiedIdentity else { return nil } + return applications.first { $0.identity == verifiedIdentity }?.classification + } +} + +public enum ApplicationEligibility: Equatable, Sendable { + case eligible(ApplicationClassification) + case authenticatedOnceOnly + case denied + + public static func evaluate( + profile: KeyAccessProfile, operation: KeyOperation, + classification: ApplicationClassification?, restrictedBotEndpoint: Bool + ) -> ApplicationEligibility { + guard profile.capabilities.permits(operation), + !restrictedBotEndpoint || profile.type == .bot else { return .denied } + guard let classification else { return .authenticatedOnceOnly } + return classification.permits(profile.type) ? .eligible(classification) : .denied + } +} + +public protocol ApplicationRegistryStoring: Sendable { + func load() throws -> ApplicationRegistrySnapshot + /// Compare-and-save prevents a policy prompt from overwriting a newer decision. + func save(_ snapshot: ApplicationRegistrySnapshot, replacing revision: UUID) throws +} + +public enum ApplicationRegistryError: Error, Equatable { + case invalidIdentity + case corrupt + case staleRevision + case persistence(OSStatus) +} + +/// Invoked on the authoritative control path, never as a UI-only permission check. +/// It intentionally has no "already authenticated" shortcut. +public final class AuthenticatedApplicationRegistry { + private let store: ApplicationRegistryStoring + private let authenticator: UserAuthenticating + private let invalidateApprovals: () throws -> Void + + public init( + store: ApplicationRegistryStoring, + authenticator: UserAuthenticating, + invalidateApprovals: @escaping () throws -> Void + ) { + self.store = store + self.authenticator = authenticator + self.invalidateApprovals = invalidateApprovals + } + + public func set(_ application: RegisteredApplication) throws { + try commit(application, replacing: store.load()) + } + + 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) + try updated.validate() + let context = try authenticator.authenticate( + reason: "Change access for \(application.name) to \(application.classification.title)", + policy: .deviceOwnerAuthentication + ) + defer { context.invalidate() } + // Revoke first. A persistence failure may revoke approvals but never grants + // broader access; authentication failure leaves both policy and grants alone. + try invalidateApprovals() + 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 { + throw ApplicationRegistryError.invalidIdentity + } + app.classification = .block + app.removed = true + try commit(app, replacing: old) + } +} diff --git a/Sources/ClavisCore/Services/KeychainApplicationRegistry.swift b/Sources/ClavisCore/Services/KeychainApplicationRegistry.swift new file mode 100644 index 0000000..826e421 --- /dev/null +++ b/Sources/ClavisCore/Services/KeychainApplicationRegistry.swift @@ -0,0 +1,80 @@ +import Foundation +import Security + +/// One device-local Keychain item; no allow-by-default classification on read errors. +public final class KeychainApplicationRegistry: ApplicationRegistryStoring, @unchecked Sendable { + public static let shared = KeychainApplicationRegistry() + private let lock = NSLock() + private let service: String + private let copyItem: (CFDictionary) -> (OSStatus, AnyObject?) + private let addItem: (CFDictionary) -> OSStatus + private let updateItem: (CFDictionary, CFDictionary) -> OSStatus + + public init( + service: String = "com.clavis.application-registry.v1", + copyItem: @escaping (CFDictionary) -> (OSStatus, AnyObject?) = { query in + var value: CFTypeRef? + let status = SecItemCopyMatching(query, &value) + return (status, value) + }, + addItem: @escaping (CFDictionary) -> OSStatus = { SecItemAdd($0, nil) }, + updateItem: @escaping (CFDictionary, CFDictionary) -> OSStatus = { SecItemUpdate($0, $1) } + ) { + self.service = service + self.copyItem = copyItem + self.addItem = addItem + self.updateItem = updateItem + } + + private var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "registry"] + } + + private func readLocked() throws -> ApplicationRegistrySnapshot? { + var request = query + request[kSecReturnData as String] = true + request[kSecMatchLimit as String] = kSecMatchLimitOne + let (status, value) = copyItem(request as CFDictionary) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw ApplicationRegistryError.persistence(status) } + guard let data = value as? Data, data.count <= 10_000_000 else { + throw ApplicationRegistryError.corrupt + } + let snapshot = try JSONDecoder().decode(ApplicationRegistrySnapshot.self, from: data) + try snapshot.validate() + return snapshot + } + + public func load() throws -> ApplicationRegistrySnapshot { + lock.lock() + defer { lock.unlock() } + if let existing = try readLocked() { return existing } + // Persist the empty revision so simultaneous first-use mutations cannot + // silently overwrite one another. Duplicate creation is reread below. + let initial = ApplicationRegistrySnapshot() + let status = try insert(initial) + if status == errSecDuplicateItem, let existing = try readLocked() { return existing } + guard status == errSecSuccess else { throw ApplicationRegistryError.persistence(status) } + return initial + } + + private func insert(_ snapshot: ApplicationRegistrySnapshot) throws -> OSStatus { + var item = query + item[kSecValueData as String] = try JSONEncoder().encode(snapshot) + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + return addItem(item as CFDictionary) + } + + public func save(_ snapshot: ApplicationRegistrySnapshot, replacing revision: UUID) throws { + try snapshot.validate() + lock.lock() + defer { lock.unlock() } + guard let current = try readLocked(), current.revision == revision, + snapshot.revision != revision else { throw ApplicationRegistryError.staleRevision } + let data = try JSONEncoder().encode(snapshot) + let status = updateItem(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + guard status == errSecSuccess else { throw ApplicationRegistryError.persistence(status) } + } +} diff --git a/Tests/ClavisCoreTests/ApplicationRegistryTests.swift b/Tests/ClavisCoreTests/ApplicationRegistryTests.swift new file mode 100644 index 0000000..347d92d --- /dev/null +++ b/Tests/ClavisCoreTests/ApplicationRegistryTests.swift @@ -0,0 +1,156 @@ +import Foundation +import LocalAuthentication +import Security +import Testing +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite struct ApplicationRegistryTests { + private func application(_ classification: ApplicationClassification = .bot) throws -> RegisteredApplication { + RegisteredApplication( + identity: try ApplicationIdentity(kind: .executableSHA256, value: String(repeating: "a", count: 64)), + name: "Test Bot", path: "/Applications/Test.app", classification: classification + ) + } + + @Test(arguments: ApplicationClassification.allCases, KeyType.allCases) + func permissionMatrix(_ classification: ApplicationClassification, _ type: KeyType) { + let profile = KeyAccessProfile(type: type, capabilities: .init(ssh: true, signature: true)) + for operation in KeyOperation.allCases { + let actual = ApplicationEligibility.evaluate(profile: profile, operation: operation, classification: classification, restrictedBotEndpoint: false) + let allowed = classification == .personal || (classification == .bot && type == .bot) + #expect(actual == (allowed ? .eligible(classification) : .denied)) + } + } + + @Test func unknownIsOnceOnlyAndCompatibilityEndpointCannotRoutePersonal() { + for type in KeyType.allCases { + let profile = KeyAccessProfile(type: type, capabilities: .init(ssh: true, signature: true)) + #expect(ApplicationEligibility.evaluate(profile: profile, operation: .ssh, classification: nil, restrictedBotEndpoint: false) == .authenticatedOnceOnly) + let restricted = ApplicationEligibility.evaluate(profile: profile, operation: .signature, classification: .personal, restrictedBotEndpoint: true) + #expect(restricted == (type == .bot ? .eligible(.personal) : .denied)) + } + let disabled = KeyAccessProfile(type: .bot, capabilities: .init(ssh: false, signature: false)) + #expect(ApplicationEligibility.evaluate(profile: disabled, operation: .ssh, classification: .personal, restrictedBotEndpoint: false) == .denied) + } + + @Test func everyClassificationChangeAuthenticatesAndRevokes() throws { + let store = MemoryRegistry() + let auth = CountingAuthenticator() + var revocations = 0 + let registry = AuthenticatedApplicationRegistry(store: store, authenticator: auth, invalidateApprovals: { revocations += 1 }) + var app = try application() + for classification in ApplicationClassification.allCases { + app.classification = classification + try registry.set(app) + #expect(try store.load().classification(for: app.identity) == classification) + } + #expect(auth.authenticationCount == 3) + #expect(auth.lastPolicy == .deviceOwnerAuthentication) + #expect(revocations == 3) + } + + @Test func cancelledOrFailedAuthPreservesPolicyAndApprovals() throws { + let original = ApplicationRegistrySnapshot(applications: [try application()]) + let store = MemoryRegistry(original) + var revoked = false + let auth = RegistryAuthenticator { throw UserAuthenticationError.rejected(nil) } + let registry = AuthenticatedApplicationRegistry(store: store, authenticator: auth, invalidateApprovals: { revoked = true }) + #expect(throws: (any Error).self) { try registry.set(application(.personal)) } + #expect(try store.load() == original) + #expect(!revoked) + } + + @Test func removalLeavesBlockTombstone() throws { + let app = try application(.personal) + let store = MemoryRegistry(.init(applications: [app])) + let registry = AuthenticatedApplicationRegistry(store: store, authenticator: AllowingAuthenticator(), invalidateApprovals: {}) + try registry.remove(identity: app.identity) + let snapshot = try store.load() + #expect(snapshot.applications.count == 1) + #expect(snapshot.applications.first?.removed == true) + #expect(snapshot.classification(for: app.identity) == .block) + #expect(snapshot.classification(for: nil) == nil) + } + + @Test func promptCannotOverwriteNewerPolicy() throws { + let store = MemoryRegistry() + let auth = RegistryAuthenticator { + let current = try store.load() + try store.save(.init(applications: [application(.block)]), replacing: current.revision) + return LAContext() + } + let registry = AuthenticatedApplicationRegistry(store: store, authenticator: auth, invalidateApprovals: {}) + #expect(throws: ApplicationRegistryError.staleRevision) { try registry.set(application(.personal)) } + #expect(try store.load().applications.first?.classification == .block) + } + + @Test func revocationFailurePreventsPolicyCommit() throws { + let store = MemoryRegistry() + let original = try store.load() + let registry = AuthenticatedApplicationRegistry(store: store, authenticator: AllowingAuthenticator(), invalidateApprovals: { throw ApplicationRegistryError.corrupt }) + #expect(throws: ApplicationRegistryError.corrupt) { try registry.set(application()) } + #expect(try store.load() == original) + } + + @Test func invalidAndDuplicateIdentitiesAreRejected() throws { + for digest in ["", "BAD", String(repeating: "A", count: 64), String(repeating: "0", count: 63)] { + #expect(throws: ApplicationRegistryError.invalidIdentity) { try ApplicationIdentity(kind: .executableSHA256, value: digest) } + } + #expect(throws: ApplicationRegistryError.invalidIdentity) { try ApplicationIdentity(kind: .designatedRequirement, value: "a\nb") } + let app = try application() + #expect(throws: ApplicationRegistryError.corrupt) { try ApplicationRegistrySnapshot(applications: [app, app]).validate() } + var removed = app + removed.removed = true + #expect(throws: ApplicationRegistryError.corrupt) { try ApplicationRegistrySnapshot(applications: [removed]).validate() } + } + + @Test func keychainReadFailureNeverReturnsPersonalDefaults() { + let store = KeychainApplicationRegistry(copyItem: { _ in (errSecAuthFailed, nil) }) + #expect(throws: ApplicationRegistryError.persistence(errSecAuthFailed)) { try store.load() } + } + + @Test func persistentSnapshotRoundTripsAndRejectsStaleWrites() throws { + var bytes: Data? + let store = KeychainApplicationRegistry( + copyItem: { _ in bytes.map { (errSecSuccess, $0 as AnyObject) } ?? (errSecItemNotFound, nil) }, + addItem: { query in + bytes = (query as NSDictionary)[kSecValueData] as? Data + return errSecSuccess + }, + updateItem: { _, attributes in + bytes = (attributes as NSDictionary)[kSecValueData] as? Data + return errSecSuccess + } + ) + let initial = try store.load() + #expect(try store.load() == initial) + let next = ApplicationRegistrySnapshot(applications: [try application()]) + try store.save(next, replacing: initial.revision) + #expect(try store.load() == next) + #expect(throws: ApplicationRegistryError.staleRevision) { try store.save(.init(), replacing: initial.revision) } + bytes = Data("malformed".utf8) + #expect(throws: (any Error).self) { try store.load() } + } +} + +private final class MemoryRegistry: ApplicationRegistryStoring, @unchecked Sendable { + private let lock = NSLock() + private var snapshot: ApplicationRegistrySnapshot + init(_ snapshot: ApplicationRegistrySnapshot = .init()) { self.snapshot = snapshot } + func load() throws -> ApplicationRegistrySnapshot { + lock.lock(); defer { lock.unlock() } + return snapshot + } + func save(_ updated: ApplicationRegistrySnapshot, replacing revision: UUID) throws { + lock.lock(); defer { lock.unlock() } + guard snapshot.revision == revision else { throw ApplicationRegistryError.staleRevision } + snapshot = updated + } +} + +private struct RegistryAuthenticator: UserAuthenticating { + let action: () throws -> LAContext + func authenticate(reason: String, policy: LAPolicy) throws -> LAContext { try action() } + func authenticate(reason: String, policy: LAPolicy) async throws -> LAContext { try action() } +}