import Foundation import LocalAuthentication import Security // MARK: - Touch ID Authentication func authenticateWithTouchID(reason: String) -> Bool { let context = LAContext() var error: NSError? // Check if Touch ID is available guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { if let error = error { fputs("Touch ID not available: \(error.localizedDescription)\n", stderr) } return false } let semaphore = DispatchSemaphore(value: 0) var success = false context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { result, authError in success = result if let authError = authError { fputs("Authentication failed: \(authError.localizedDescription)\n", stderr) } semaphore.signal() } semaphore.wait() return success } // MARK: - Keychain Operations func getKeychainPassword(service: String, account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecSuccess, let data = result as? Data { return String(data: data, encoding: .utf8) } else if status == errSecItemNotFound { fputs("Error: Password not found\n", stderr) } else if status == errSecAuthFailed { fputs("Error: Authentication failed\n", stderr) } else { fputs("Error: Keychain error (status: \(status))\n", stderr) } return nil } func setKeychainPassword(service: String, account: String, password: String) -> Bool { // First, try to delete existing item let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] SecItemDelete(deleteQuery as CFDictionary) // Add new item (without Access Control - requires paid Developer Program) // Security is provided at app level via Touch ID authentication guard let passwordData = password.data(using: .utf8) else { fputs("Error: Failed to encode password\n", stderr) return false } let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: passwordData, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly ] let status = SecItemAdd(addQuery as CFDictionary, nil) if status != errSecSuccess { fputs("Error: Failed to save password (status: \(status))\n", stderr) return false } return true } func deleteKeychainPassword(service: String, account: String) -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { return true } fputs("Error: Failed to delete password (status: \(status))\n", stderr) return false } func listKeychainItems(service: String? = nil) -> [(service: String, account: String)] { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitAll ] if let service = service { query[kSecAttrService as String] = service } var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) var items: [(service: String, account: String)] = [] if status == errSecSuccess, let itemList = result as? [[String: Any]] { for item in itemList { if let service = item[kSecAttrService as String] as? String, let account = item[kSecAttrAccount as String] as? String { items.append((service: service, account: account)) } } } return items } // MARK: - Editor Integration func createSecureTempFile(content: String) -> URL? { let url = FileManager.default.temporaryDirectory .appendingPathComponent("keychain-fingerprint-\(UUID().uuidString).tmp") guard FileManager.default.createFile( atPath: url.path, contents: content.data(using: .utf8), attributes: [.posixPermissions: 0o600] ) else { return nil } return url } func secureDeleteFile(at url: URL) { if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attrs[.size] as? Int, size > 0, let handle = FileHandle(forWritingAtPath: url.path) { handle.write(Data(count: size)) try? handle.close() } try? FileManager.default.removeItem(at: url) } func trimSingleTrailingNewline(_ string: String) -> String { var string = string if string.hasSuffix("\n") { string.removeLast() } return string } func openInEditor(filePath: String) -> Bool { let editorCommand = ProcessInfo.processInfo.environment["EDITOR"] ?? "vi" var tokens = editorCommand.split(separator: " ").map(String.init) if tokens.isEmpty { tokens = ["vi"] } let executable = tokens.removeFirst() let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [executable] + tokens + [filePath] process.standardInput = FileHandle.standardInput process.standardOutput = FileHandle.standardOutput process.standardError = FileHandle.standardError do { try process.run() } catch { fputs("Error: Failed to launch editor '\(executable)': \(error.localizedDescription)\n", stderr) return false } process.waitUntilExit() return process.terminationStatus == 0 } // MARK: - Secure Input func readSecurePassword() -> String? { // If stdin isn't a terminal (piped/redirected), read everything until // EOF so multiline secrets (SSH keys, certs, etc.) come through intact. guard isatty(FileHandle.standardInput.fileDescriptor) != 0 else { let data = FileHandle.standardInput.readDataToEndOfFile() guard var input = String(data: data, encoding: .utf8) else { return nil } if input.hasSuffix("\n") { input.removeLast() } return input } // Disable echo for secure interactive input var oldTermios = termios() tcgetattr(FileHandle.standardInput.fileDescriptor, &oldTermios) var newTermios = oldTermios newTermios.c_lflag &= ~UInt(ECHO) tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &newTermios) defer { // Restore echo tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &oldTermios) print("") // New line after hidden input } return readLine() } // MARK: - Main func printUsage() { fputs(""" Usage: keychain-fingerprint [options] Commands: get Get password (requires Touch ID) set Set password (requires Touch ID) delete Delete password (requires Touch ID) edit Edit password in $EDITOR (requires Touch ID) list [service] List items (requires Touch ID) Security: - All commands require Touch ID authentication - Passwords stored in macOS Keychain (encrypted) - Password input is hidden (no echo) - Other apps require Mac password to access Examples: keychain-fingerprint get myapp user@example.com keychain-fingerprint set myapp user@example.com keychain-fingerprint edit myapp user@example.com keychain-fingerprint list keychain-fingerprint delete myapp user@example.com Shell variable usage: PASSWORD=$(keychain-fingerprint get myapp user@example.com) # use $PASSWORD unset PASSWORD Multiline secrets (SSH keys, certs, etc.): keychain-fingerprint set myapp user@example.com < secret.txt cat secret.txt | keychain-fingerprint set myapp user@example.com """, stderr) } func main() { let args = CommandLine.arguments guard args.count >= 2 else { printUsage() exit(1) } let command = args[1] switch command { case "get": guard args.count >= 4 else { fputs("Error: 'get' requires and \n", stderr) exit(1) } let service = args[2] let account = args[3] // Touch ID authentication guard authenticateWithTouchID(reason: "Authentication is required to retrieve the password for \(service) (\(account))") else { exit(1) } if let password = getKeychainPassword(service: service, account: account) { print(password) } else { exit(1) } case "set": guard args.count >= 4 else { fputs("Error: 'set' requires and \n", stderr) exit(1) } let service = args[2] let account = args[3] // Touch ID authentication first guard authenticateWithTouchID(reason: "Authentication is required to save the password for \(service) (\(account))") else { exit(1) } fputs("Enter password: ", stderr) guard let password = readSecurePassword(), !password.isEmpty else { fputs("Error: No password provided\n", stderr) exit(1) } if setKeychainPassword(service: service, account: account, password: password) { fputs("Password saved successfully\n", stderr) } else { exit(1) } case "delete": guard args.count >= 4 else { fputs("Error: 'delete' requires and \n", stderr) exit(1) } let service = args[2] let account = args[3] // Touch ID authentication guard authenticateWithTouchID(reason: "Authentication is required to delete the password for \(service) (\(account))") else { exit(1) } if deleteKeychainPassword(service: service, account: account) { fputs("Password deleted successfully\n", stderr) } else { exit(1) } case "edit": guard args.count >= 4 else { fputs("Error: 'edit' requires and \n", stderr) exit(1) } let service = args[2] let account = args[3] // Touch ID authentication guard authenticateWithTouchID(reason: "Authentication is required to edit the password for \(service) (\(account))") else { exit(1) } guard let currentPassword = getKeychainPassword(service: service, account: account) else { exit(1) } guard let tempURL = createSecureTempFile(content: currentPassword) else { fputs("Error: Failed to create temporary file\n", stderr) exit(1) } let editSucceeded = openInEditor(filePath: tempURL.path) guard editSucceeded, let editedContent = try? String(contentsOf: tempURL, encoding: .utf8) else { secureDeleteFile(at: tempURL) fputs("Error: Editor exited abnormally or content could not be read; changes discarded\n", stderr) exit(1) } secureDeleteFile(at: tempURL) let newPassword = trimSingleTrailingNewline(editedContent) guard !newPassword.isEmpty else { fputs("Error: Edited content is empty; not saving\n", stderr) exit(1) } if newPassword == currentPassword { fputs("No changes made\n", stderr) } else if setKeychainPassword(service: service, account: account, password: newPassword) { fputs("Password updated successfully\n", stderr) } else { exit(1) } case "list": let service = args.count >= 3 ? args[2] : nil // Touch ID authentication let listReason = service.map { "Authentication is required to list Keychain items for \($0)" } ?? "Authentication is required to list Keychain items" guard authenticateWithTouchID(reason: listReason) else { exit(1) } let items = listKeychainItems(service: service) if items.isEmpty { fputs("No items found\n", stderr) } else { fputs("Service\t\t\tAccount\n", stderr) fputs("-------\t\t\t-------\n", stderr) for item in items { fputs("\(item.service)\t\t\(item.account)\n", stderr) } } case "help", "-h", "--help": printUsage() default: fputs("Unknown command: \(command)\n", stderr) printUsage() exit(1) } } main()