import CryptoKit import Foundation import LocalAuthentication import Security // Swift marks SecAccessCreate deprecated, but macOS still needs kSecAttrAccess // here to avoid legacy keychain authorization UI during generic-password creation. @_silgen_name("SecAccessCreate") private func clipBoredSecAccessCreate( _ descriptor: CFString, _ trustedList: CFArray?, _ accessRef: UnsafeMutablePointer ) -> OSStatus final class ClipboardEncryptionService { static let marker = "clipbored:v1:" private static let markerData = Data(marker.utf8) private let keyProvider: () -> SymmetricKey? private let resetProvider: () -> Void init() { if Self.shouldBypassSystemKeychain() { keyProvider = { nil } resetProvider = {} } else { keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() } resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() } } } init(keyProvider: @escaping () -> SymmetricKey?, resetProvider: @escaping () -> Void = {}) { self.keyProvider = keyProvider self.resetProvider = resetProvider } var isAvailable: Bool { keyProvider() != nil } func protect(_ value: String?) -> String? { guard let value else { return nil } guard let key = keyProvider() else { return value } guard let sealed = try? AES.GCM.seal(Data(value.utf8), using: key), let combined = sealed.combined else { return value } return Self.marker + combined.base64EncodedString() } func unprotect(_ value: String?) -> String? { guard let value else { return nil } guard Self.isProtected(value) else { return value } let encoded = String(value.dropFirst(Self.marker.count)) guard let data = Data(base64Encoded: encoded), let sealed = try? AES.GCM.SealedBox(combined: data) else { return value } guard let key = keyProvider(), let decrypted = try? AES.GCM.open(sealed, using: key) else { return nil } return String(data: decrypted, encoding: .utf8) } func protectData(_ data: Data) -> Data { guard let key = keyProvider() else { return data } guard let sealed = try? AES.GCM.seal(data, using: key), let combined = sealed.combined else { return data } var output = Self.markerData output.append(combined) return output } func unprotectData(_ data: Data) -> Data? { guard Self.isProtected(data) else { return data } let encrypted = data.dropFirst(Self.markerData.count) guard let sealed = try? AES.GCM.SealedBox(combined: encrypted), let key = keyProvider(), let decrypted = try? AES.GCM.open(sealed, using: key) else { return nil } return decrypted } static func isProtected(_ value: String) -> Bool { value.hasPrefix(marker) } static func isProtected(_ data: Data) -> Bool { data.starts(with: markerData) } func resetStoredKey() { resetProvider() } static func shouldBypassSystemKeychain( environment: [String: String] = ProcessInfo.processInfo.environment, arguments: [String] = ProcessInfo.processInfo.arguments ) -> Bool { if environment["CLIPBORED_DISABLE_KEYCHAIN"] == "1" || environment["XCTestConfigurationFilePath"] != nil { return true } return arguments.contains { argument in argument.contains(".xctest") || argument.hasSuffix("/xctest") } } } private enum ClipboardEncryptionKeychain { static let shared = KeychainBackedKeyProvider() } private final class KeychainBackedKeyProvider { private let queue = DispatchQueue(label: "clipboard.encryption-keychain") private let keychainTimeout: TimeInterval = 0.35 private var cachedKey: SymmetricKey? func symmetricKey() -> SymmetricKey? { queue.sync { if let cachedKey { return cachedKey } if let fallback = readFallbackKeyData() { let key = SymmetricKey(data: fallback) cachedKey = key return key } if let existing = readKeyData() { let key = SymmetricKey(data: existing) cachedKey = key return key } let generated = SymmetricKey(size: .bits256) let data = generated.withUnsafeBytes { Data($0) } if saveKeyData(data) { cachedKey = generated return generated } guard let fallback = loadOrCreateFallbackKeyData() else { return nil } let fallbackKey = SymmetricKey(data: fallback) cachedKey = fallbackKey return fallbackKey } } func resetStoredKey() { queue.sync { cachedKey = nil _ = deleteKeyData() deleteFallbackKeyData() } } private func readKeyData() -> Data? { runKeychainOperation { var query = self.baseQuery() query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne query[kSecUseAuthenticationContext as String] = self.nonInteractiveContext() var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess else { return nil } return result as? Data } } private func saveKeyData(_ data: Data) -> Bool { runKeychainOperation { var query = self.baseQuery() query[kSecValueData as String] = data query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly query[kSecUseAuthenticationContext as String] = self.nonInteractiveContext() if let access = self.keychainAccess() { query[kSecAttrAccess as String] = access } let status = SecItemAdd(query as CFDictionary, nil) if status == errSecSuccess { return true } if status == errSecDuplicateItem { return self.readKeyData() != nil } return false } ?? false } private func runKeychainOperation(_ operation: @escaping () -> T?) -> T? { let lock = NSLock() var result: T? let semaphore = DispatchSemaphore(value: 0) DispatchQueue.global(qos: .utility).async { let value = operation() lock.lock() result = value lock.unlock() semaphore.signal() } guard semaphore.wait(timeout: .now() + keychainTimeout) == .success else { return nil } lock.lock() defer { lock.unlock() } return result } private func baseQuery() -> [String: Any] { [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "com.local.clipbored.encryption", kSecAttrAccount as String: "history-v1" ] } private func nonInteractiveContext() -> LAContext { let context = LAContext() context.interactionNotAllowed = true return context } private func keychainAccess() -> SecAccess? { var access: SecAccess? let status = clipBoredSecAccessCreate("ClipBored encryption key" as CFString, nil, &access) guard status == errSecSuccess else { return nil } return access } private func loadOrCreateFallbackKeyData() -> Data? { if let existing = readFallbackKeyData() { return existing } let generated = SymmetricKey(size: .bits256) let data = generated.withUnsafeBytes { Data($0) } guard saveFallbackKeyData(data) else { return nil } return data } private func readFallbackKeyData() -> Data? { let url = fallbackKeyURL() guard let data = try? Data(contentsOf: url), data.count == 32 else { return nil } return data } private func saveFallbackKeyData(_ data: Data) -> Bool { let url = fallbackKeyURL() let directory = url.deletingLastPathComponent() do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) try data.write(to: url, options: [.atomic]) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) return true } catch { return false } } private func deleteKeyData() -> Bool { runKeychainOperation { let status = SecItemDelete(self.baseQuery() as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } ?? false } private func deleteFallbackKeyData() { try? FileManager.default.removeItem(at: fallbackKeyURL()) } private func fallbackKeyURL() -> URL { let base = ClipboardStore.storageDirectory() return base.appendingPathComponent("history-encryption.key") } }