diff options
| author | Hyun Kim <hyun@balancehero.com> | 2026-01-06 17:55:12 +0700 |
|---|---|---|
| committer | Hyun Kim <hyun@balancehero.com> | 2026-01-06 17:55:12 +0700 |
| commit | 2c75ecaafd30d6cf0cc34ca47ccc9d7109ffcefd (patch) | |
| tree | 21c9bdb3a749796b504f60bf08b8605f355e39a9 | |
Initial commit: Touch ID Keychain CLI tool
- Touch ID authentication for all commands
- Secure password input (no echo)
- macOS Keychain storage with encryption
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | .gitignore | 10 | ||||
| -rw-r--r-- | README.md | 113 | ||||
| -rw-r--r-- | main.swift | 297 |
3 files changed, 420 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..015eedd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Compiled binary +keychain-fingerprint + +# macOS +.DS_Store + +# Xcode +*.xcodeproj +*.xcworkspace +DerivedData/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..96ffdab --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# keychain-fingerprint + +Touch ID μΈμ¦μΌλ‘ macOS Keychain λΉλ°λ²νΈμ μμ νκ³ νΈλ¦¬νκ² μ κ·Όνλ CLI λꡬ + +## Why? + +macOS Keychainμ μ μ₯λ λΉλ°λ²νΈμ μ κ·Όν λ λ κ°μ§ λΆνΈν¨μ΄ μμ΅λλ€: + +### λ¬Έμ 1: 보μ vs νΈμμ± λλ λ§ + +`security find-generic-password` λͺ
λ Ήμ΄λ‘ λΉλ°λ²νΈμ μ κ·Όνλ©΄: + +``` +"security"κ° ν€μ²΄μΈμ "myapp"μ μ μ₯λ κΈ°λ° μ 보λ₯Ό μ¬μ©νλ €κ³ ν©λλ€. +[κ±°λΆ] [νμ©] [νμ νμ©] +``` + +- **"νμ©"**: λ§€λ² Mac λΉλ°λ²νΈλ₯Ό μ
λ ₯ν΄μΌ ν¨ β λ²κ±°λ‘μ +- **"νμ νμ©"**: μ΄ν μ΄λ€ μ±μμλ λΉλ°λ²νΈ μμ΄ μ κ·Ό κ°λ₯ β 보μ μ·¨μ½ + +### λ¬Έμ 2: λΉλ°λ²νΈ μ
λ ₯μ λΆνΈν¨ + +Mac λΉλ°λ²νΈλ λ³΄ν΅ κΈΈκ³ λ³΅μ‘ν΄μ λ§€λ² μ
λ ₯νκΈ° λ²κ±°λ‘μ΅λλ€. + +### ν΄κ²°μ±
: Touch ID + +μ΄ λꡬλ **Touch ID**λ‘ μΈμ¦νμ¬: +- **λΉ λ₯΄κ³ νΈλ¦¬ν¨**: μκ°λ½ ν λ²μΌλ‘ μΈμ¦ (λΉλ°λ²νΈ μ
λ ₯ λΆνμ) +- **보μ μ μ§**: λ€λ₯Έ μ±μμ μ κ·Ό μ μ¬μ ν Mac λΉλ°λ²νΈ νμ + +## Installation + +```bash +# Clone +git clone https://github.com/dss99911/keychain-fingerprint.git +cd keychain-fingerprint + +# Compile +swiftc -o keychain-fingerprint main.swift -framework LocalAuthentication -framework Security + +# Install (optional) +sudo cp keychain-fingerprint /usr/local/bin/ +``` + +## Usage + +```bash +# Save password (Touch ID β secure input) +keychain-fingerprint set myapp user@example.com + +# Get password (Touch ID β stdout) +keychain-fingerprint get myapp user@example.com + +# List saved items (Touch ID) +keychain-fingerprint list + +# Delete password (Touch ID) +keychain-fingerprint delete myapp user@example.com +``` + +### Shell Variable (Recommended) + +```bash +# Capture password in variable (not displayed on screen) +PASSWORD=$(keychain-fingerprint get myapp user@example.com) + +# Use the password +echo "Using password..." + +# Clear the variable when done +unset PASSWORD +``` + +## Security + +| Access Method | Authentication Required | +|---------------|------------------------| +| This app | Touch ID | +| Other apps / `security` command | Mac password | + +### How it works + +``` +βββββββββββββββββββββββββββββββββββββββββββ +β keychain-fingerprint β +βββββββββββββββββββββββββββββββββββββββββββ€ +β 1. Touch ID authentication β +β 2. Access Keychain (auto-authorized) β +βββββββββββββββββββββββββββββββββββββββββββ + +βββββββββββββββββββββββββββββββββββββββββββ +β Other apps / terminal β +βββββββββββββββββββββββββββββββββββββββββββ€ +β Keychain access β Mac password prompt β +βββββββββββββββββββββββββββββββββββββββββββ +``` + +### Features + +- All commands require Touch ID +- Passwords stored encrypted in macOS Keychain +- Password input is hidden (no echo) +- Passwords only output to stdout (for variable capture) +- Device-only access (`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`) + +## Requirements + +- macOS with Touch ID +- Xcode Command Line Tools (`xcode-select --install`) + +## License + +MIT diff --git a/main.swift b/main.swift new file mode 100644 index 0000000..d829bad --- /dev/null +++ b/main.swift @@ -0,0 +1,297 @@ +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: - Secure Input + +func readSecurePassword() -> String? { + // Disable echo for secure 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 <command> [options] + + Commands: + get <service> <account> Get password (requires Touch ID) + set <service> <account> Set password (requires Touch ID) + delete <service> <account> Delete password (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 list + keychain-fingerprint delete myapp user@example.com + + Shell variable usage: + PASSWORD=$(keychain-fingerprint get myapp user@example.com) + # use $PASSWORD + unset PASSWORD + """, 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 <service> and <account>\n", stderr) + exit(1) + } + + let service = args[2] + let account = args[3] + + // Touch ID authentication + guard authenticateWithTouchID(reason: "Keychain λΉλ°λ²νΈ μ‘°νλ₯Ό μν΄ μΈμ¦μ΄ νμν©λλ€") 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 <service> and <account>\n", stderr) + exit(1) + } + + let service = args[2] + let account = args[3] + + // Touch ID authentication first + guard authenticateWithTouchID(reason: "Keychain λΉλ°λ²νΈ μ μ₯μ μν΄ μΈμ¦μ΄ νμν©λλ€") 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 <service> and <account>\n", stderr) + exit(1) + } + + let service = args[2] + let account = args[3] + + // Touch ID authentication + guard authenticateWithTouchID(reason: "Keychain λΉλ°λ²νΈ μμ λ₯Ό μν΄ μΈμ¦μ΄ νμν©λλ€") else { + exit(1) + } + + if deleteKeychainPassword(service: service, account: account) { + fputs("Password deleted successfully\n", stderr) + } else { + exit(1) + } + + case "list": + // Touch ID authentication + guard authenticateWithTouchID(reason: "Keychain λͺ©λ‘ μ‘°νλ₯Ό μν΄ μΈμ¦μ΄ νμν©λλ€") else { + exit(1) + } + + let service = args.count >= 3 ? args[2] : nil + 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() |
