kc 0.1.0 β†’ 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +22 -8
  3. data/kc.gemspec +20 -21
  4. data/lib/kc/keychain.rb +273 -191
  5. data/lib/kc/version.rb +1 -1
  6. metadata +6 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acd1a1a4d4b98a8f4972a7be12a56485af6364a446ad9cc0981b92c0bae38e3b
4
- data.tar.gz: 510e50ff16ddf681620775f0a1488615b5c68bafb8345d10e1447571762d68c9
3
+ metadata.gz: 8d9fd3f9d9435dac8fd474111770fdc9648407d6880a8399a4d642c99b528907
4
+ data.tar.gz: e45b7d10dde259a3d44f661848dcaf282b6b3779d99bf5f647e34f3e89eb4d07
5
5
  SHA512:
6
- metadata.gz: 70d120e84e72ef0c79c63c72eb69c21f1a14950c52a8cbc9d4765a6c555a4c163bf441668892a2f642c4dc3136c6466c7d577416352630ba7f912feed258f897
7
- data.tar.gz: 5d05fae5c846ee424b5627b04770b4a3b3c4af5b1614985f48d18226ffab562a92366511ef61f4bea1f289993016f1920703978bf9e4e983ef7875385c31ac5a
6
+ metadata.gz: b571902e2874562153524032e6ec2addcb26c8e6e84ebabd2ba6ab153aad5b1ecbab639faaa163caa9c1fc7d32088e7184a1efe87754da15e6d75df7155589c6
7
+ data.tar.gz: bbe081e64f7098788f833f3127c5ccdab1fe5c87881956dac76ac93a6680fa479f4610d4e1cde8152fabd560f333e75a7d878d9447cf0e16b77244074565a3ba
data/README.md CHANGED
@@ -1,13 +1,13 @@
1
- # kc - Keychain Manager for direnv
1
+ # kc - Keychain Manager
2
2
 
3
- A CLI tool to securely store and retrieve secrets from macOS Keychain with namespace support, designed to work seamlessly with direnv.
3
+ A CLI tool to securely store and retrieve secrets from macOS Keychain with namespace support and automatic iCloud sync.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - πŸ” Securely store any secrets in macOS Keychain
8
+ - ☁️ **iCloud Keychain sync** - automatically sync secrets across all your Macs
8
9
  - 🏷️ **Namespace support** - organize secrets by type (env, ssh, token, etc.)
9
10
  - πŸš€ Native implementation using FFI (no shell command overhead)
10
- - 🎯 Designed for direnv integration
11
11
  - πŸ“¦ Simple CLI interface
12
12
  - πŸ“‹ List and filter secrets by namespace
13
13
 
@@ -117,10 +117,24 @@ You can create custom namespaces as needed.
117
117
 
118
118
  ## How it works
119
119
 
120
- `kc` uses macOS Security framework via FFI to directly interact with the Keychain, avoiding shell command overhead. All entries are stored under:
120
+ `kc` uses macOS Security framework via FFI to directly interact with the Keychain, avoiding shell command overhead. All entries are stored as **Internet Passwords** with:
121
121
 
122
- - Service name: `kc`
123
- - Account name: `<namespace>:<name>` (e.g., `env:myproject`)
122
+ - Server: `kc`
123
+ - Account: `<namespace>:<name>` (e.g., `env:myproject`)
124
+ - Protocol: HTTPS
125
+
126
+ ### iCloud Keychain Sync
127
+
128
+ All secrets saved by `kc` are automatically synchronized across your Macs via **iCloud Keychain** (if enabled in System Settings). This means:
129
+
130
+ - πŸ’Ύ Save a secret on one Mac β†’ Access it instantly on all your other Macs
131
+ - πŸ”„ Changes and deletions are synced automatically
132
+ - πŸ” End-to-end encryption ensures your secrets remain secure during sync
133
+ - 🌐 No manual export/import needed
134
+
135
+ To verify sync is working, open **Keychain Access.app** and select the **iCloud** keychain. Look for entries with server name `kc`.
136
+
137
+ **Note:** Internet Passwords (used by `kc`) are automatically synced by macOS when iCloud Keychain is enabled. You don't need to do anything special!
124
138
 
125
139
  ## Full Workflow Example
126
140
 
@@ -186,7 +200,7 @@ bundle exec rspec
186
200
 
187
201
  ## Contributing
188
202
 
189
- Bug reports and pull requests are welcome on GitHub at https://github.com/aileron-inc/kc.
203
+ Bug reports and pull requests are welcome on GitHub at https://github.com/aileron-inc/tools.
190
204
 
191
205
  ## License
192
206
 
@@ -194,4 +208,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
194
208
 
195
209
  ## Code of Conduct
196
210
 
197
- Everyone interacting in the Kc project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/aileron-inc/kc/blob/master/CODE_OF_CONDUCT.md).
211
+ Everyone interacting in the Kc project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/aileron-inc/tools/blob/main/CODE_OF_CONDUCT.md).
data/kc.gemspec CHANGED
@@ -1,41 +1,40 @@
1
-
2
- lib = File.expand_path("../lib", __FILE__)
1
+ lib = File.expand_path('lib', __dir__)
3
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require "kc/version"
3
+ require 'kc/version'
5
4
 
6
5
  Gem::Specification.new do |spec|
7
- spec.name = "kc"
6
+ spec.name = 'kc'
8
7
  spec.version = Kc::VERSION
9
- spec.authors = ["AILERON"]
10
- spec.email = ["masa@aileron.cc"]
8
+ spec.authors = ['AILERON']
9
+ spec.email = ['masa@aileron.cc']
11
10
 
12
- spec.summary = %q{Manage .env files in Mac Keychain with direnv}
13
- spec.description = %q{A CLI tool to save and load .env files from Mac Keychain, designed to work seamlessly with direnv}
14
- spec.homepage = "https://github.com/aileron/kc"
15
- spec.license = "MIT"
11
+ spec.summary = 'Manage secrets in macOS Keychain with iCloud sync and namespace support'
12
+ spec.description = 'A CLI tool to securely store and retrieve secrets from macOS Keychain with automatic iCloud sync across your Macs. Features namespace support for organizing different types of secrets (env files, SSH keys, API tokens, etc.) and works seamlessly with direnv.'
13
+ spec.homepage = 'https://github.com/aileron/kc'
14
+ spec.license = 'MIT'
16
15
 
17
16
  # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
17
  # to allow pushing to a single host or delete this section to allow pushing to any host.
19
18
  if spec.respond_to?(:metadata)
20
- spec.metadata["homepage_uri"] = spec.homepage
21
- spec.metadata["source_code_uri"] = "https://github.com/aileron/kc"
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/aileron/kc'
22
21
  else
23
- raise "RubyGems 2.0 or newer is required to protect against " \
24
- "public gem pushes."
22
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
23
+ 'public gem pushes.'
25
24
  end
26
25
 
27
26
  # Specify which files should be added to the gem when it is released.
28
27
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
29
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
28
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
30
29
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
31
30
  end
32
- spec.bindir = "exe"
31
+ spec.bindir = 'exe'
33
32
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
- spec.require_paths = ["lib"]
33
+ spec.require_paths = ['lib']
35
34
 
36
- spec.add_dependency "ffi", "~> 1.15"
35
+ spec.add_dependency 'ffi', '~> 1.15'
37
36
 
38
- spec.add_development_dependency "bundler"
39
- spec.add_development_dependency "rake"
40
- spec.add_development_dependency "rspec", "~> 3.0"
37
+ spec.add_development_dependency 'bundler'
38
+ spec.add_development_dependency 'rake'
39
+ spec.add_development_dependency 'rspec', '~> 3.0'
41
40
  end
data/lib/kc/keychain.rb CHANGED
@@ -1,226 +1,308 @@
1
- require "ffi"
1
+ require 'ffi'
2
2
 
3
3
  module Kc
4
4
  class Keychain
5
- SERVICE_NAME = "kc"
5
+ SERVICE_NAME = 'kc'
6
6
 
7
- module SecurityFramework
7
+ module CoreFoundation
8
8
  extend FFI::Library
9
- ffi_lib "/System/Library/Frameworks/Security.framework/Security"
10
-
11
- # OSStatus SecKeychainAddGenericPassword(
12
- # SecKeychainRef keychain,
13
- # UInt32 serviceNameLength,
14
- # const char *serviceName,
15
- # UInt32 accountNameLength,
16
- # const char *accountName,
17
- # UInt32 passwordLength,
18
- # const void *passwordData,
19
- # SecKeychainItemRef *itemRef
20
- # )
21
- attach_function :SecKeychainAddGenericPassword, [
22
- :pointer, # keychain (NULL for default)
23
- :uint32, # serviceNameLength
24
- :string, # serviceName
25
- :uint32, # accountNameLength
26
- :string, # accountName
27
- :uint32, # passwordLength
28
- :pointer, # passwordData
29
- :pointer # itemRef (can be NULL)
30
- ], :int
31
-
32
- # OSStatus SecKeychainFindGenericPassword(
33
- # CFTypeRef keychainOrArray,
34
- # UInt32 serviceNameLength,
35
- # const char *serviceName,
36
- # UInt32 accountNameLength,
37
- # const char *accountName,
38
- # UInt32 *passwordLength,
39
- # void **passwordData,
40
- # SecKeychainItemRef *itemRef
41
- # )
42
- attach_function :SecKeychainFindGenericPassword, [
43
- :pointer, # keychainOrArray (NULL for default)
44
- :uint32, # serviceNameLength
45
- :string, # serviceName
46
- :uint32, # accountNameLength
47
- :string, # accountName
48
- :pointer, # passwordLength (output)
49
- :pointer, # passwordData (output)
50
- :pointer # itemRef (output, can be NULL if not needed)
51
- ], :int
52
-
53
- # OSStatus SecKeychainItemDelete(SecKeychainItemRef itemRef)
54
- attach_function :SecKeychainItemDelete, [:pointer], :int
55
-
56
- # void SecKeychainItemFreeContent(SecKeychainAttributeList *attrList, void *data)
57
- attach_function :SecKeychainItemFreeContent, [:pointer, :pointer], :int
58
-
59
- # Constants for attribute tags
60
- KSecAccountItemAttr = 0x61636374 # 'acct' in FourCC
61
-
62
- # Attribute structure
63
- class SecKeychainAttribute < FFI::Struct
64
- layout :tag, :uint32,
65
- :length, :uint32,
66
- :data, :pointer
67
- end
68
-
69
- class SecKeychainAttributeList < FFI::Struct
70
- layout :count, :uint32,
71
- :attr, :pointer # Array of SecKeychainAttribute
72
- end
73
-
74
- # OSStatus SecKeychainSearchCreateFromAttributes(
75
- # CFTypeRef keychainOrArray,
76
- # SecItemClass itemClass,
77
- # const SecKeychainAttributeList *attrList,
78
- # SecKeychainSearchRef *searchRef
79
- # )
80
- attach_function :SecKeychainSearchCreateFromAttributes, [
81
- :pointer, # keychainOrArray
82
- :uint32, # itemClass (kSecGenericPasswordItemClass = 'genp')
83
- :pointer, # attrList
84
- :pointer # searchRef (output)
85
- ], :int
86
-
87
- # OSStatus SecKeychainSearchCopyNext(
88
- # SecKeychainSearchRef searchRef,
89
- # SecKeychainItemRef *itemRef
90
- # )
91
- attach_function :SecKeychainSearchCopyNext, [
92
- :pointer, # searchRef
93
- :pointer # itemRef (output)
94
- ], :int
95
-
96
- # OSStatus SecKeychainItemCopyAttributesAndData(
97
- # SecKeychainItemRef itemRef,
98
- # SecKeychainAttributeInfo *info,
99
- # SecItemClass *itemClass,
100
- # SecKeychainAttributeList **attrList,
101
- # UInt32 *length,
102
- # void **outData
103
- # )
104
- attach_function :SecKeychainItemCopyAttributesAndData, [
105
- :pointer, # itemRef
106
- :pointer, # info (NULL for default keychain)
107
- :pointer, # itemClass (can be NULL)
108
- :pointer, # attrList (output)
109
- :pointer, # length (can be NULL)
110
- :pointer # outData (can be NULL)
111
- ], :int
112
-
113
- # OSStatus SecKeychainItemFreeAttributesAndData(
114
- # SecKeychainAttributeList *attrList,
115
- # void *data
116
- # )
117
- attach_function :SecKeychainItemFreeAttributesAndData, [:pointer, :pointer], :int
118
-
119
- # void CFRelease(CFTypeRef cf)
9
+ ffi_lib '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation'
10
+
11
+ # CFString functions
12
+ attach_function :CFStringCreateWithCString, %i[pointer string uint32], :pointer
13
+ attach_function :CFStringGetCString, %i[pointer pointer long uint32], :bool
14
+ attach_function :CFStringGetLength, [:pointer], :long
15
+
16
+ # CFData functions
17
+ attach_function :CFDataCreate, %i[pointer pointer long], :pointer
18
+ attach_function :CFDataGetLength, [:pointer], :long
19
+ attach_function :CFDataGetBytePtr, [:pointer], :pointer
20
+
21
+ # CFDictionary functions
22
+ attach_function :CFDictionaryCreateMutable, %i[pointer long pointer pointer], :pointer
23
+ attach_function :CFDictionarySetValue, %i[pointer pointer pointer], :void
24
+ attach_function :CFDictionaryGetValue, %i[pointer pointer], :pointer
25
+
26
+ # CFArray functions
27
+ attach_function :CFArrayGetCount, [:pointer], :long
28
+ attach_function :CFArrayGetValueAtIndex, %i[pointer long], :pointer
29
+
30
+ # CFNumber functions
31
+ attach_function :CFNumberCreate, %i[pointer int pointer], :pointer
32
+ attach_function :CFBooleanGetValue, [:pointer], :bool
33
+
34
+ # CFRelease
120
35
  attach_function :CFRelease, [:pointer], :void
36
+
37
+ # Constants
38
+ KCFStringEncodingUTF8 = 0x08000100
39
+ KCFNumberSInt32Type = 3
40
+
41
+ # Get Boolean constants using symbols
42
+ def self.kCFBooleanTrue
43
+ @kCFBooleanTrue ||= begin
44
+ ptr_ptr = FFI::DynamicLibrary.open(
45
+ '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation',
46
+ FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_GLOBAL
47
+ ).find_symbol('kCFBooleanTrue')
48
+ FFI::Pointer.new(:pointer, ptr_ptr.address).read_pointer
49
+ end
50
+ end
51
+
52
+ def self.kCFBooleanFalse
53
+ @kCFBooleanFalse ||= begin
54
+ ptr_ptr = FFI::DynamicLibrary.open(
55
+ '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation',
56
+ FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_GLOBAL
57
+ ).find_symbol('kCFBooleanFalse')
58
+ FFI::Pointer.new(:pointer, ptr_ptr.address).read_pointer
59
+ end
60
+ end
61
+ end
62
+
63
+ module SecurityFramework
64
+ extend FFI::Library
65
+ ffi_lib '/System/Library/Frameworks/Security.framework/Security'
66
+
67
+ # Modern SecItem API
68
+ attach_function :SecItemAdd, %i[pointer pointer], :int
69
+ attach_function :SecItemCopyMatching, %i[pointer pointer], :int
70
+ attach_function :SecItemUpdate, %i[pointer pointer], :int
71
+ attach_function :SecItemDelete, [:pointer], :int
72
+
73
+ # Error codes
74
+ ErrSecSuccess = 0
75
+ ErrSecItemNotFound = -25_300
76
+ ErrSecDuplicateItem = -25_299
77
+
78
+ # Helper to get constant addresses from library
79
+ def self.get_constant(name)
80
+ ptr = FFI::DynamicLibrary.open(
81
+ '/System/Library/Frameworks/Security.framework/Security',
82
+ FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_GLOBAL
83
+ ).find_variable(name)
84
+ ptr.read_pointer
85
+ end
121
86
  end
122
87
 
123
88
  class << self
89
+ # Helper methods for CoreFoundation
90
+ def create_cf_string(str)
91
+ CoreFoundation.CFStringCreateWithCString(nil, str, CoreFoundation::KCFStringEncodingUTF8)
92
+ end
93
+
94
+ def create_cf_data(data)
95
+ ptr = FFI::MemoryPointer.from_string(data)
96
+ CoreFoundation.CFDataCreate(nil, ptr, data.bytesize)
97
+ end
98
+
99
+ def create_cf_boolean(value)
100
+ value ? CoreFoundation.kCFBooleanTrue : CoreFoundation.kCFBooleanFalse
101
+ end
102
+
103
+ def cf_string_to_ruby(cf_string)
104
+ return nil if cf_string.null?
105
+
106
+ length = CoreFoundation.CFStringGetLength(cf_string)
107
+ buffer = FFI::MemoryPointer.new(:char, length * 4 + 1)
108
+ if CoreFoundation.CFStringGetCString(cf_string, buffer, buffer.size, CoreFoundation::KCFStringEncodingUTF8)
109
+ buffer.read_string
110
+ end
111
+ end
112
+
113
+ def cf_data_to_ruby(cf_data)
114
+ return nil if cf_data.null?
115
+
116
+ length = CoreFoundation.CFDataGetLength(cf_data)
117
+ ptr = CoreFoundation.CFDataGetBytePtr(cf_data)
118
+ ptr.read_bytes(length)
119
+ end
120
+
121
+ # Get Security framework constants
122
+ def kSecClass
123
+ @kSecClass ||= SecurityFramework.get_constant('kSecClass')
124
+ end
125
+
126
+ def kSecClassInternetPassword
127
+ @kSecClassInternetPassword ||= SecurityFramework.get_constant('kSecClassInternetPassword')
128
+ end
129
+
130
+ def kSecAttrServer
131
+ @kSecAttrServer ||= SecurityFramework.get_constant('kSecAttrServer')
132
+ end
133
+
134
+ def kSecAttrAccount
135
+ @kSecAttrAccount ||= SecurityFramework.get_constant('kSecAttrAccount')
136
+ end
137
+
138
+ def kSecAttrProtocol
139
+ @kSecAttrProtocol ||= SecurityFramework.get_constant('kSecAttrProtocol')
140
+ end
141
+
142
+ def kSecAttrProtocolHTTPS
143
+ @kSecAttrProtocolHTTPS ||= SecurityFramework.get_constant('kSecAttrProtocolHTTPS')
144
+ end
145
+
146
+ def kSecValueData
147
+ @kSecValueData ||= SecurityFramework.get_constant('kSecValueData')
148
+ end
149
+
150
+ def kSecReturnData
151
+ @kSecReturnData ||= SecurityFramework.get_constant('kSecReturnData')
152
+ end
153
+
154
+ def kSecMatchLimit
155
+ @kSecMatchLimit ||= SecurityFramework.get_constant('kSecMatchLimit')
156
+ end
157
+
158
+ def kSecMatchLimitAll
159
+ @kSecMatchLimitAll ||= SecurityFramework.get_constant('kSecMatchLimitAll')
160
+ end
161
+
162
+ def kSecReturnAttributes
163
+ @kSecReturnAttributes ||= SecurityFramework.get_constant('kSecReturnAttributes')
164
+ end
165
+
124
166
  def save(account_name, content)
125
- # Try to delete existing entry first
167
+ # Try to delete existing entry first (update semantics)
126
168
  delete(account_name) rescue nil
127
169
 
128
- # Add new entry
129
- status = SecurityFramework.SecKeychainAddGenericPassword(
130
- nil, # default keychain
131
- SERVICE_NAME.bytesize, # service name length
132
- SERVICE_NAME, # service name
133
- account_name.bytesize, # account name length
134
- account_name, # account name
135
- content.bytesize, # password length
136
- FFI::MemoryPointer.from_string(content), # password data
137
- nil # don't need item ref
138
- )
139
-
140
- unless status.zero?
170
+ # Create query dictionary for Internet Password
171
+ # Note: Internet Passwords are automatically synced via iCloud Keychain when enabled
172
+ query = CoreFoundation.CFDictionaryCreateMutable(nil, 0, nil, nil)
173
+
174
+ server_str = create_cf_string(SERVICE_NAME)
175
+ account_str = create_cf_string(account_name)
176
+ data_obj = create_cf_data(content)
177
+
178
+ CoreFoundation.CFDictionarySetValue(query, kSecClass, kSecClassInternetPassword)
179
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrServer, server_str)
180
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrAccount, account_str)
181
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrProtocol, kSecAttrProtocolHTTPS)
182
+ CoreFoundation.CFDictionarySetValue(query, kSecValueData, data_obj)
183
+
184
+ status = SecurityFramework.SecItemAdd(query, nil)
185
+
186
+ # Cleanup
187
+ CoreFoundation.CFRelease(server_str)
188
+ CoreFoundation.CFRelease(account_str)
189
+ CoreFoundation.CFRelease(data_obj)
190
+ CoreFoundation.CFRelease(query)
191
+
192
+ unless status == SecurityFramework::ErrSecSuccess
141
193
  raise Error, "Failed to save to keychain (status: #{status})"
142
194
  end
143
195
  end
144
196
 
145
197
  def load(account_name)
146
- password_length = FFI::MemoryPointer.new(:uint32)
147
- password_data = FFI::MemoryPointer.new(:pointer)
148
-
149
- status = SecurityFramework.SecKeychainFindGenericPassword(
150
- nil, # default keychain
151
- SERVICE_NAME.bytesize, # service name length
152
- SERVICE_NAME, # service name
153
- account_name.bytesize, # account name length
154
- account_name, # account name
155
- password_length, # password length (output)
156
- password_data, # password data (output)
157
- nil # don't need item ref
158
- )
159
-
160
- unless status.zero?
161
- raise Error, "Failed to load from keychain (status: #{status})"
162
- end
198
+ # Create query dictionary for Internet Password
199
+ query = CoreFoundation.CFDictionaryCreateMutable(nil, 0, nil, nil)
200
+
201
+ server_str = create_cf_string(SERVICE_NAME)
202
+ account_str = create_cf_string(account_name)
163
203
 
164
- # Read the password data
165
- length = password_length.read_uint32
166
- data_ptr = password_data.read_pointer
167
- password = data_ptr.read_string(length)
204
+ CoreFoundation.CFDictionarySetValue(query, kSecClass, kSecClassInternetPassword)
205
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrServer, server_str)
206
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrAccount, account_str)
207
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrProtocol, kSecAttrProtocolHTTPS)
208
+ CoreFoundation.CFDictionarySetValue(query, kSecReturnData, create_cf_boolean(true))
168
209
 
169
- # Free the memory allocated by Security framework
170
- SecurityFramework.SecKeychainItemFreeContent(nil, data_ptr)
210
+ result_ptr = FFI::MemoryPointer.new(:pointer)
211
+ status = SecurityFramework.SecItemCopyMatching(query, result_ptr)
171
212
 
172
- password
213
+ # Cleanup query
214
+ CoreFoundation.CFRelease(server_str)
215
+ CoreFoundation.CFRelease(account_str)
216
+ CoreFoundation.CFRelease(query)
217
+
218
+ unless status == SecurityFramework::ErrSecSuccess
219
+ if status == SecurityFramework::ErrSecItemNotFound
220
+ raise Error, "Entry '#{account_name}' not found in keychain"
221
+ else
222
+ raise Error, "Failed to load from keychain (status: #{status})"
223
+ end
224
+ end
225
+
226
+ # Extract data
227
+ result = result_ptr.read_pointer
228
+ data = cf_data_to_ruby(result)
229
+ CoreFoundation.CFRelease(result)
230
+
231
+ data
173
232
  end
174
233
 
175
234
  def delete(account_name)
176
- item_ref = FFI::MemoryPointer.new(:pointer)
177
-
178
- status = SecurityFramework.SecKeychainFindGenericPassword(
179
- nil,
180
- SERVICE_NAME.bytesize,
181
- SERVICE_NAME,
182
- account_name.bytesize,
183
- account_name,
184
- nil,
185
- nil,
186
- item_ref
187
- )
188
-
189
- unless status.zero?
190
- raise Error, "Entry '#{account_name}' not found in keychain"
191
- end
235
+ # Create query dictionary for Internet Password
236
+ query = CoreFoundation.CFDictionaryCreateMutable(nil, 0, nil, nil)
237
+
238
+ server_str = create_cf_string(SERVICE_NAME)
239
+ account_str = create_cf_string(account_name)
240
+
241
+ CoreFoundation.CFDictionarySetValue(query, kSecClass, kSecClassInternetPassword)
242
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrServer, server_str)
243
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrAccount, account_str)
244
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrProtocol, kSecAttrProtocolHTTPS)
192
245
 
193
- delete_status = SecurityFramework.SecKeychainItemDelete(item_ref.read_pointer)
194
-
195
- unless delete_status.zero?
196
- raise Error, "Failed to delete from keychain (status: #{delete_status})"
246
+ status = SecurityFramework.SecItemDelete(query)
247
+
248
+ # Cleanup
249
+ CoreFoundation.CFRelease(server_str)
250
+ CoreFoundation.CFRelease(account_str)
251
+ CoreFoundation.CFRelease(query)
252
+
253
+ unless status == SecurityFramework::ErrSecSuccess
254
+ if status == SecurityFramework::ErrSecItemNotFound
255
+ raise Error, "Entry '#{account_name}' not found in keychain"
256
+ else
257
+ raise Error, "Failed to delete from keychain (status: #{status})"
258
+ end
197
259
  end
198
260
  end
199
261
 
200
262
  def list(prefix = nil)
263
+ # Create query dictionary for Internet Password
264
+ query = CoreFoundation.CFDictionaryCreateMutable(nil, 0, nil, nil)
265
+
266
+ server_str = create_cf_string(SERVICE_NAME)
267
+
268
+ CoreFoundation.CFDictionarySetValue(query, kSecClass, kSecClassInternetPassword)
269
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrServer, server_str)
270
+ CoreFoundation.CFDictionarySetValue(query, kSecAttrProtocol, kSecAttrProtocolHTTPS)
271
+ CoreFoundation.CFDictionarySetValue(query, kSecMatchLimit, kSecMatchLimitAll)
272
+ CoreFoundation.CFDictionarySetValue(query, kSecReturnAttributes, create_cf_boolean(true))
273
+
274
+ result_ptr = FFI::MemoryPointer.new(:pointer)
275
+ status = SecurityFramework.SecItemCopyMatching(query, result_ptr)
276
+
277
+ # Cleanup query
278
+ CoreFoundation.CFRelease(server_str)
279
+ CoreFoundation.CFRelease(query)
280
+
281
+ if status == SecurityFramework::ErrSecItemNotFound
282
+ return []
283
+ end
284
+
285
+ unless status == SecurityFramework::ErrSecSuccess
286
+ raise Error, "Failed to list keychain items (status: #{status})"
287
+ end
288
+
289
+ # Parse results
290
+ result = result_ptr.read_pointer
291
+ count = CoreFoundation.CFArrayGetCount(result)
201
292
  accounts = []
202
-
203
- # Use security dump-keychain and parse output
204
- # We need to look for entries with service="kc"
205
- output = `security dump-keychain login.keychain-db 2>/dev/null`
206
-
207
- # Split by keychain entries
208
- entries = output.split(/^keychain:/).drop(1)
209
-
210
- entries.each do |entry|
211
- # Check if this entry has svce="kc"
212
- if entry =~ /"svce"<blob>="#{SERVICE_NAME}"/
213
- # Extract account name
214
- if entry =~ /"acct"<blob>="([^"]+)"/
215
- account_name = $1
216
- # Filter by prefix if provided
217
- if prefix.nil? || account_name.start_with?(prefix)
218
- accounts << account_name
219
- end
220
- end
293
+
294
+ count.times do |i|
295
+ item = CoreFoundation.CFArrayGetValueAtIndex(result, i)
296
+ account_cf = CoreFoundation.CFDictionaryGetValue(item, kSecAttrAccount)
297
+ account_name = cf_string_to_ruby(account_cf)
298
+
299
+ if account_name && (prefix.nil? || account_name.start_with?(prefix))
300
+ accounts << account_name
221
301
  end
222
302
  end
223
303
 
304
+ CoreFoundation.CFRelease(result)
305
+
224
306
  accounts.sort.uniq
225
307
  end
226
308
  end
data/lib/kc/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kc
2
- VERSION = "0.1.0"
2
+ VERSION = '0.2.0'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AILERON
@@ -65,8 +65,10 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '3.0'
68
- description: A CLI tool to save and load .env files from Mac Keychain, designed to
69
- work seamlessly with direnv
68
+ description: A CLI tool to securely store and retrieve secrets from macOS Keychain
69
+ with automatic iCloud sync across your Macs. Features namespace support for organizing
70
+ different types of secrets (env files, SSH keys, API tokens, etc.) and works seamlessly
71
+ with direnv.
70
72
  email:
71
73
  - masa@aileron.cc
72
74
  executables:
@@ -113,5 +115,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
113
115
  requirements: []
114
116
  rubygems_version: 3.6.9
115
117
  specification_version: 4
116
- summary: Manage .env files in Mac Keychain with direnv
118
+ summary: Manage secrets in macOS Keychain with iCloud sync and namespace support
117
119
  test_files: []