aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorHyun Kim <hyun@balancehero.com>2026-01-06 17:55:12 +0700
committerHyun Kim <hyun@balancehero.com>2026-01-06 17:55:12 +0700
commit2c75ecaafd30d6cf0cc34ca47ccc9d7109ffcefd (patch)
tree21c9bdb3a749796b504f60bf08b8605f355e39a9
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--.gitignore10
-rw-r--r--README.md113
-rw-r--r--main.swift297
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()