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.
- checksums.yaml +4 -4
- data/README.md +22 -8
- data/kc.gemspec +20 -21
- data/lib/kc/keychain.rb +273 -191
- data/lib/kc/version.rb +1 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d9fd3f9d9435dac8fd474111770fdc9648407d6880a8399a4d642c99b528907
|
|
4
|
+
data.tar.gz: e45b7d10dde259a3d44f661848dcaf282b6b3779d99bf5f647e34f3e89eb4d07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b571902e2874562153524032e6ec2addcb26c8e6e84ebabd2ba6ab153aad5b1ecbab639faaa163caa9c1fc7d32088e7184a1efe87754da15e6d75df7155589c6
|
|
7
|
+
data.tar.gz: bbe081e64f7098788f833f3127c5ccdab1fe5c87881956dac76ac93a6680fa479f4610d4e1cde8152fabd560f333e75a7d878d9447cf0e16b77244074565a3ba
|
data/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
# kc - Keychain Manager
|
|
1
|
+
# kc - Keychain Manager
|
|
2
2
|
|
|
3
|
-
A CLI tool to securely store and retrieve secrets from macOS Keychain with namespace support
|
|
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
|
|
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
|
-
-
|
|
123
|
-
- Account
|
|
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/
|
|
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/
|
|
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
|
|
3
|
+
require 'kc/version'
|
|
5
4
|
|
|
6
5
|
Gem::Specification.new do |spec|
|
|
7
|
-
spec.name =
|
|
6
|
+
spec.name = 'kc'
|
|
8
7
|
spec.version = Kc::VERSION
|
|
9
|
-
spec.authors = [
|
|
10
|
-
spec.email = [
|
|
8
|
+
spec.authors = ['AILERON']
|
|
9
|
+
spec.email = ['masa@aileron.cc']
|
|
11
10
|
|
|
12
|
-
spec.summary =
|
|
13
|
-
spec.description =
|
|
14
|
-
spec.homepage =
|
|
15
|
-
spec.license =
|
|
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[
|
|
21
|
-
spec.metadata[
|
|
19
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
20
|
+
spec.metadata['source_code_uri'] = 'https://github.com/aileron/kc'
|
|
22
21
|
else
|
|
23
|
-
raise
|
|
24
|
-
|
|
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
|
|
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 =
|
|
31
|
+
spec.bindir = 'exe'
|
|
33
32
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
34
|
-
spec.require_paths = [
|
|
33
|
+
spec.require_paths = ['lib']
|
|
35
34
|
|
|
36
|
-
spec.add_dependency
|
|
35
|
+
spec.add_dependency 'ffi', '~> 1.15'
|
|
37
36
|
|
|
38
|
-
spec.add_development_dependency
|
|
39
|
-
spec.add_development_dependency
|
|
40
|
-
spec.add_development_dependency
|
|
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
|
|
1
|
+
require 'ffi'
|
|
2
2
|
|
|
3
3
|
module Kc
|
|
4
4
|
class Keychain
|
|
5
|
-
SERVICE_NAME =
|
|
5
|
+
SERVICE_NAME = 'kc'
|
|
6
6
|
|
|
7
|
-
module
|
|
7
|
+
module CoreFoundation
|
|
8
8
|
extend FFI::Library
|
|
9
|
-
ffi_lib
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
#
|
|
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
|
-
#
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
SecurityFramework.
|
|
210
|
+
result_ptr = FFI::MemoryPointer.new(:pointer)
|
|
211
|
+
status = SecurityFramework.SecItemCopyMatching(query, result_ptr)
|
|
171
212
|
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
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.
|
|
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
|
|
69
|
-
|
|
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
|
|
118
|
+
summary: Manage secrets in macOS Keychain with iCloud sync and namespace support
|
|
117
119
|
test_files: []
|