kc 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acd1a1a4d4b98a8f4972a7be12a56485af6364a446ad9cc0981b92c0bae38e3b
4
- data.tar.gz: 510e50ff16ddf681620775f0a1488615b5c68bafb8345d10e1447571762d68c9
3
+ metadata.gz: a784b3ff753a30bc3f560d8cd417b6597fe77996da2cc95a96d58f431aa9bff1
4
+ data.tar.gz: d3c844cf4e13e797ee98cde972fc52706bdeb08724e19b94de3c2cebe5b1c928
5
5
  SHA512:
6
- metadata.gz: 70d120e84e72ef0c79c63c72eb69c21f1a14950c52a8cbc9d4765a6c555a4c163bf441668892a2f642c4dc3136c6466c7d577416352630ba7f912feed258f897
7
- data.tar.gz: 5d05fae5c846ee424b5627b04770b4a3b3c4af5b1614985f48d18226ffab562a92366511ef61f4bea1f289993016f1920703978bf9e4e983ef7875385c31ac5a
6
+ metadata.gz: 60b2e62994b43f2d2396c930a046e0bac7a4834cb60d3be101e8615da7c5e0e651a7031aaa5f947ed2cd27c34918aa5d8dc49682379489ebe698d1956e746d29
7
+ data.tar.gz: 5db7687b87fdcab2b7b295b3c7a183394142291230561f673a49ce7969c8557fda062da156f26c95f4f1f3404ce34bc872c85ab47e4c3e6cb7e1e9ab89a56447
data/README.md CHANGED
@@ -1,15 +1,16 @@
1
- # kc - Keychain Manager for direnv
1
+ # kc - Secure Secrets Manager with iCloud Sync
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 with automatic iCloud Drive synchronization across all your Macs.
4
4
 
5
5
  ## Features
6
6
 
7
- - 🔐 Securely store any secrets in macOS Keychain
7
+ - 🔐 **AES-256 encryption** - military-grade encryption for your secrets
8
+ - ☁️ **iCloud Drive sync** - automatically sync encrypted secrets across all your Macs
9
+ - 🔑 **Master password in local keychain** - password stored securely, never synced
8
10
  - 🏷️ **Namespace support** - organize secrets by type (env, ssh, token, etc.)
9
- - 🚀 Native implementation using FFI (no shell command overhead)
10
- - 🎯 Designed for direnv integration
11
- - 📦 Simple CLI interface
12
- - 📋 List and filter secrets by namespace
11
+ - 📝 **Append-only log** - automatic conflict resolution for concurrent edits
12
+ - 🚀 **Simple CLI interface** - easy to use command-line tool
13
+ - 📋 **Filter and search** - list secrets by namespace prefix
13
14
 
14
15
  ## Installation
15
16
 
@@ -23,11 +24,31 @@ Or add to your Gemfile:
23
24
  gem 'kc'
24
25
  ```
25
26
 
27
+ ## Quick Start
28
+
29
+ ### First Time Setup
30
+
31
+ Initialize kc with a master password:
32
+
33
+ ```bash
34
+ $ kc init
35
+ Enter master password: ****
36
+ Confirm master password: ****
37
+
38
+ ✓ Master password saved to local keychain
39
+ ✓ iCloud Drive sync enabled at:
40
+ ~/Library/Mobile Documents/com~apple~CloudDocs/kc
41
+
42
+ Your secrets will be encrypted and synced across your Macs!
43
+ ```
44
+
45
+ **Important**: You'll need to run `kc init` on each Mac with the same master password.
46
+
26
47
  ## Usage
27
48
 
28
- All commands require a **namespace** in the format `<namespace>:<name>`. Namespaces help organize different types of secrets.
49
+ All commands use the format `<namespace>:<name>`. Namespaces help organize different types of secrets.
29
50
 
30
- ### Save to Keychain
51
+ ### Save Secrets
31
52
 
32
53
  Read from stdin and save to keychain with namespace:
33
54
 
@@ -96,10 +117,11 @@ source_env .env
96
117
 
97
118
  ## Commands
98
119
 
99
- - `kc save <namespace>:<name>` - Read from stdin and save to keychain
100
- - `kc load <namespace>:<name>` - Load from keychain and output to stdout
101
- - `kc delete <namespace>:<name>` - Delete entry from keychain
102
- - `kc list [prefix]` - List all entries (optionally filter by prefix)
120
+ - `kc init` - Initialize with master password (first time setup)
121
+ - `kc save <namespace>:<name>` - Read from stdin and save encrypted
122
+ - `kc load <namespace>:<name>` - Decrypt and output to stdout
123
+ - `kc delete <namespace>:<name>` - Mark entry as deleted
124
+ - `kc list [prefix]` - List all current entries (optionally filter by prefix)
103
125
 
104
126
  ## Namespaces
105
127
 
@@ -117,10 +139,48 @@ You can create custom namespaces as needed.
117
139
 
118
140
  ## How it works
119
141
 
120
- `kc` uses macOS Security framework via FFI to directly interact with the Keychain, avoiding shell command overhead. All entries are stored under:
142
+ ### Architecture
143
+
144
+ `kc` uses a hybrid approach combining local keychain security with iCloud Drive synchronization:
145
+
146
+ 1. **Master Password**: Stored securely in your local macOS Keychain (never synced)
147
+ 2. **Encrypted Secrets**: Stored in `~/Library/Mobile Documents/com~apple~CloudDocs/kc/secrets.jsonl`
148
+ 3. **Encryption**: AES-256-CBC with PBKDF2 key derivation (10,000 iterations)
149
+ 4. **Sync**: iCloud Drive automatically syncs the encrypted file across your Macs
121
150
 
122
- - Service name: `kc`
123
- - Account name: `<namespace>:<name>` (e.g., `env:myproject`)
151
+ ### Data Format
152
+
153
+ Secrets are stored in an append-only JSONL (JSON Lines) file:
154
+
155
+ ```json
156
+ {"ts":"2026-01-05T10:00:00.123Z","op":"set","ns":"aws","key":"ACCESS_KEY","val":"<encrypted>"}
157
+ {"ts":"2026-01-05T11:30:00.456Z","op":"set","ns":"hubot","key":"TOKEN","val":"<encrypted>"}
158
+ {"ts":"2026-01-05T12:00:00.789Z","op":"del","ns":"aws","key":"OLD_KEY"}
159
+ ```
160
+
161
+ - **Append-only**: New entries are always appended, never modified
162
+ - **Timestamps**: ISO8601 format with millisecond precision
163
+ - **Operations**: `set` (save/update) or `del` (delete)
164
+ - **Conflict Resolution**: Automatic merge based on timestamps
165
+
166
+ ### Security
167
+
168
+ - ✅ **Master password** stored in local keychain (not synced)
169
+ - ✅ **AES-256 encryption** for all secret values
170
+ - ✅ **Unique salt and IV** for each encrypted value
171
+ - ✅ **PBKDF2 key derivation** with 10,000 iterations
172
+ - ✅ **End-to-end encryption** - iCloud only sees encrypted data
173
+ - ❌ **Metadata not encrypted** - namespace and key names are visible (but not values)
174
+
175
+ ### Conflict Resolution
176
+
177
+ If you edit secrets on multiple Macs simultaneously:
178
+
179
+ 1. iCloud Drive creates "conflicted copy" files
180
+ 2. `kc` automatically detects and merges them
181
+ 3. Entries are sorted by timestamp (oldest first)
182
+ 4. Latest value for each key wins
183
+ 5. Conflicted copies are deleted after merge
124
184
 
125
185
  ## Full Workflow Example
126
186
 
@@ -181,12 +241,15 @@ bundle exec rspec
181
241
 
182
242
  ## Requirements
183
243
 
184
- - macOS (uses macOS Keychain)
185
- - Ruby 2.5 or later
244
+ - **macOS** - uses macOS Keychain for master password storage
245
+ - **iCloud Drive** - enabled in System Settings for sync
246
+ - **Ruby 2.5 or later**
247
+
248
+ **Note**: Each Mac needs to run `kc init` with the same master password to decrypt synced secrets.
186
249
 
187
250
  ## Contributing
188
251
 
189
- Bug reports and pull requests are welcome on GitHub at https://github.com/aileron-inc/kc.
252
+ Bug reports and pull requests are welcome on GitHub at https://github.com/aileron-inc/tools.
190
253
 
191
254
  ## License
192
255
 
@@ -194,4 +257,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
194
257
 
195
258
  ## Code of Conduct
196
259
 
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).
260
+ 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 = 'Secure secrets manager with iCloud Drive sync and AES-256 encryption'
12
+ spec.description = 'A CLI tool to securely store and retrieve secrets with AES-256 encryption and automatic iCloud Drive synchronization. Master password stored in local keychain, secrets encrypted and synced via iCloud Drive. Features namespace support, automatic conflict resolution, and append-only JSONL format.'
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/cli.rb CHANGED
@@ -13,13 +13,15 @@ module Kc
13
13
  name = @argv[1]
14
14
 
15
15
  case command
16
- when "save"
16
+ when 'init'
17
+ handle_init
18
+ when 'save'
17
19
  handle_save(name)
18
- when "load"
20
+ when 'load'
19
21
  handle_load(name)
20
- when "delete"
22
+ when 'delete'
21
23
  handle_delete(name)
22
- when "list"
24
+ when 'list'
23
25
  handle_list(name)
24
26
  else
25
27
  show_usage
@@ -29,30 +31,64 @@ module Kc
29
31
 
30
32
  private
31
33
 
34
+ def handle_init
35
+ require 'io/console'
36
+
37
+ puts 'Setting up kc with iCloud Drive sync...'
38
+ puts
39
+ print 'Enter master password: '
40
+ password = STDIN.noecho(&:gets).chomp
41
+ puts
42
+ print 'Confirm master password: '
43
+ password_confirm = STDIN.noecho(&:gets).chomp
44
+ puts
45
+
46
+ unless password == password_confirm
47
+ puts 'Error: Passwords do not match'
48
+ exit 1
49
+ end
50
+
51
+ if password.empty?
52
+ puts 'Error: Password cannot be empty'
53
+ exit 1
54
+ end
55
+
56
+ Keychain.init(password)
57
+ puts
58
+ puts '✓ Master password saved to local keychain'
59
+ puts '✓ iCloud Drive sync enabled at:'
60
+ puts " #{Keychain::ICLOUD_DRIVE_PATH}"
61
+ puts
62
+ puts 'Your secrets will be encrypted and synced across your Macs!'
63
+ rescue StandardError => e
64
+ puts "Error: #{e.message}"
65
+ exit 1
66
+ end
67
+
32
68
  def validate_namespace(name)
33
- unless name&.include?(":")
34
- puts "Error: Namespace required. Format: <namespace>:<name>"
35
- puts "Examples: env:myproject, ssh:id_rsa, token:github"
69
+ unless name&.include?(':')
70
+ puts 'Error: Namespace required. Format: <namespace>:<name>'
71
+ puts 'Examples: env:myproject, ssh:id_rsa, token:github'
36
72
  exit 1
37
73
  end
38
74
 
39
- namespace, key = name.split(":", 2)
40
-
75
+ namespace, key = name.split(':', 2)
76
+
41
77
  if namespace.empty? || key.empty?
42
- puts "Error: Invalid format. Use <namespace>:<name>"
78
+ puts 'Error: Invalid format. Use <namespace>:<name>'
43
79
  exit 1
44
80
  end
45
81
 
46
82
  # Validate namespace format (alphanumeric and hyphen only)
47
- unless namespace.match?(/^[a-z0-9-]+$/)
48
- puts "Error: Namespace must contain only lowercase letters, numbers, and hyphens"
49
- exit 1
50
- end
83
+ return if namespace.match?(/^[a-z0-9-]+$/)
84
+
85
+ puts 'Error: Namespace must contain only lowercase letters, numbers, and hyphens'
86
+ exit 1
51
87
  end
52
88
 
53
89
  def handle_save(name)
54
90
  unless name
55
- puts "Error: name is required"
91
+ puts 'Error: name is required'
56
92
  show_usage
57
93
  exit 1
58
94
  end
@@ -61,26 +97,26 @@ module Kc
61
97
 
62
98
  # Read from stdin
63
99
  if STDIN.tty?
64
- puts "Error: No input provided. Use: cat file | kc save <namespace>:<name>"
100
+ puts 'Error: No input provided. Use: cat file | kc save <namespace>:<name>'
65
101
  exit 1
66
102
  end
67
103
 
68
104
  content = STDIN.read
69
105
  if content.empty?
70
- puts "Error: Input is empty"
106
+ puts 'Error: Input is empty'
71
107
  exit 1
72
108
  end
73
109
 
74
110
  Keychain.save(name, content)
75
111
  puts "Successfully saved to keychain as '#{name}'"
76
- rescue => e
112
+ rescue StandardError => e
77
113
  puts "Error: #{e.message}"
78
114
  exit 1
79
115
  end
80
116
 
81
117
  def handle_load(name)
82
118
  unless name
83
- puts "Error: name is required"
119
+ puts 'Error: name is required'
84
120
  show_usage
85
121
  exit 1
86
122
  end
@@ -89,14 +125,14 @@ module Kc
89
125
 
90
126
  content = Keychain.load(name)
91
127
  puts content
92
- rescue => e
128
+ rescue StandardError => e
93
129
  puts "Error: #{e.message}"
94
130
  exit 1
95
131
  end
96
132
 
97
133
  def handle_delete(name)
98
134
  unless name
99
- puts "Error: name is required"
135
+ puts 'Error: name is required'
100
136
  show_usage
101
137
  exit 1
102
138
  end
@@ -105,24 +141,24 @@ module Kc
105
141
 
106
142
  Keychain.delete(name)
107
143
  puts "Successfully deleted '#{name}' from keychain"
108
- rescue => e
144
+ rescue StandardError => e
109
145
  puts "Error: #{e.message}"
110
146
  exit 1
111
147
  end
112
148
 
113
149
  def handle_list(prefix)
114
150
  items = Keychain.list(prefix)
115
-
151
+
116
152
  if items.empty?
117
153
  if prefix
118
154
  puts "No items found with prefix '#{prefix}'"
119
155
  else
120
- puts "No items found in keychain"
156
+ puts 'No items found in keychain'
121
157
  end
122
158
  else
123
159
  items.each { |item| puts item }
124
160
  end
125
- rescue => e
161
+ rescue StandardError => e
126
162
  puts "Error: #{e.message}"
127
163
  exit 1
128
164
  end
@@ -130,12 +166,14 @@ module Kc
130
166
  def show_usage
131
167
  puts <<~USAGE
132
168
  Usage:
133
- kc save <namespace>:<name> Save from stdin to keychain
134
- kc load <namespace>:<name> Load from keychain to stdout
135
- kc delete <namespace>:<name> Delete from keychain
169
+ kc init Initialize kc with master password
170
+ kc save <namespace>:<name> Save from stdin
171
+ kc load <namespace>:<name> Load to stdout
172
+ kc delete <namespace>:<name> Delete entry
136
173
  kc list [prefix] List all items (optionally filter by prefix)
137
174
 
138
175
  Examples:
176
+ kc init # First time setup
139
177
  cat .env | kc save env:myproject
140
178
  kc load env:myproject > .env
141
179
  kc save ssh:id_rsa < ~/.ssh/id_rsa
data/lib/kc/keychain.rb CHANGED
@@ -1,227 +1,352 @@
1
- require "ffi"
1
+ require 'ffi'
2
+ require 'json'
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'fileutils'
6
+ require 'time'
2
7
 
3
8
  module Kc
4
9
  class Keychain
5
- SERVICE_NAME = "kc"
10
+ MASTER_PASSWORD_SERVICE = 'kc-master-password'
11
+ MASTER_PASSWORD_ACCOUNT = 'default'
12
+ ICLOUD_DRIVE_PATH = File.expand_path('~/Library/Mobile Documents/com~apple~CloudDocs/kc')
13
+ SECRETS_FILE = File.join(ICLOUD_DRIVE_PATH, 'secrets.jsonl')
6
14
 
7
15
  module SecurityFramework
8
16
  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
17
+ ffi_lib '/System/Library/Frameworks/Security.framework/Security'
31
18
 
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)
19
+ # OSStatus SecKeychainAddGenericPassword(...)
20
+ attach_function :SecKeychainAddGenericPassword, %i[
21
+ pointer uint32 string uint32 string
22
+ uint32 pointer pointer
51
23
  ], :int
52
24
 
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)
25
+ # OSStatus SecKeychainFindGenericPassword(...)
26
+ attach_function :SecKeychainFindGenericPassword, %i[
27
+ pointer uint32 string uint32 string
28
+ pointer pointer pointer
94
29
  ], :int
95
30
 
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
31
+ # OSStatus SecKeychainItemDelete(...)
32
+ attach_function :SecKeychainItemDelete, [:pointer], :int
112
33
 
113
- # OSStatus SecKeychainItemFreeAttributesAndData(
114
- # SecKeychainAttributeList *attrList,
115
- # void *data
116
- # )
117
- attach_function :SecKeychainItemFreeAttributesAndData, [:pointer, :pointer], :int
34
+ # void SecKeychainItemFreeContent(...)
35
+ attach_function :SecKeychainItemFreeContent, %i[pointer pointer], :int
118
36
 
119
- # void CFRelease(CFTypeRef cf)
120
- attach_function :CFRelease, [:pointer], :void
37
+ ErrSecSuccess = 0
38
+ ErrSecItemNotFound = -25_300
121
39
  end
122
40
 
123
41
  class << self
124
- def save(account_name, content)
125
- # Try to delete existing entry first
126
- delete(account_name) rescue nil
42
+ # Initialize master password
43
+ def init(password)
44
+ # Delete existing if any
45
+ begin
46
+ delete_master_password
47
+ rescue StandardError
48
+ nil
49
+ end
127
50
 
128
- # Add new entry
51
+ # Save new master password to local keychain
129
52
  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
53
+ nil,
54
+ MASTER_PASSWORD_SERVICE.bytesize,
55
+ MASTER_PASSWORD_SERVICE,
56
+ MASTER_PASSWORD_ACCOUNT.bytesize,
57
+ MASTER_PASSWORD_ACCOUNT,
58
+ password.bytesize,
59
+ FFI::MemoryPointer.from_string(password),
60
+ nil
138
61
  )
139
62
 
140
- unless status.zero?
141
- raise Error, "Failed to save to keychain (status: #{status})"
63
+ unless status == SecurityFramework::ErrSecSuccess
64
+ raise Error, "Failed to save master password (status: #{status})"
142
65
  end
66
+
67
+ # Ensure iCloud Drive directory exists
68
+ FileUtils.mkdir_p(ICLOUD_DRIVE_PATH)
69
+
70
+ # Create empty secrets file if it doesn't exist
71
+ File.write(SECRETS_FILE, '') unless File.exist?(SECRETS_FILE)
143
72
  end
144
73
 
145
- def load(account_name)
74
+ # Get master password from local keychain
75
+ def master_password
146
76
  password_length = FFI::MemoryPointer.new(:uint32)
147
77
  password_data = FFI::MemoryPointer.new(:pointer)
148
78
 
149
79
  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
80
+ nil,
81
+ MASTER_PASSWORD_SERVICE.bytesize,
82
+ MASTER_PASSWORD_SERVICE,
83
+ MASTER_PASSWORD_ACCOUNT.bytesize,
84
+ MASTER_PASSWORD_ACCOUNT,
85
+ password_length,
86
+ password_data,
87
+ nil
158
88
  )
159
89
 
160
- unless status.zero?
161
- raise Error, "Failed to load from keychain (status: #{status})"
90
+ if status == SecurityFramework::ErrSecItemNotFound
91
+ raise Error, "Master password not found. Please run 'kc init' first."
92
+ end
93
+
94
+ unless status == SecurityFramework::ErrSecSuccess
95
+ raise Error, "Failed to load master password (status: #{status})"
162
96
  end
163
97
 
164
- # Read the password data
165
98
  length = password_length.read_uint32
166
99
  data_ptr = password_data.read_pointer
167
100
  password = data_ptr.read_string(length)
168
101
 
169
- # Free the memory allocated by Security framework
170
102
  SecurityFramework.SecKeychainItemFreeContent(nil, data_ptr)
171
103
 
172
104
  password
173
105
  end
174
106
 
175
- def delete(account_name)
107
+ # Delete master password
108
+ def delete_master_password
176
109
  item_ref = FFI::MemoryPointer.new(:pointer)
177
110
 
178
111
  status = SecurityFramework.SecKeychainFindGenericPassword(
179
112
  nil,
180
- SERVICE_NAME.bytesize,
181
- SERVICE_NAME,
182
- account_name.bytesize,
183
- account_name,
113
+ MASTER_PASSWORD_SERVICE.bytesize,
114
+ MASTER_PASSWORD_SERVICE,
115
+ MASTER_PASSWORD_ACCOUNT.bytesize,
116
+ MASTER_PASSWORD_ACCOUNT,
184
117
  nil,
185
118
  nil,
186
119
  item_ref
187
120
  )
188
121
 
189
- unless status.zero?
190
- raise Error, "Entry '#{account_name}' not found in keychain"
122
+ return if status == SecurityFramework::ErrSecItemNotFound
123
+
124
+ unless status == SecurityFramework::ErrSecSuccess
125
+ raise Error, "Failed to find master password (status: #{status})"
191
126
  end
192
127
 
193
128
  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})"
129
+ return if delete_status == SecurityFramework::ErrSecSuccess
130
+
131
+ raise Error, "Failed to delete master password (status: #{delete_status})"
132
+ end
133
+
134
+ # Encrypt data with master password
135
+ def encrypt(data)
136
+ password = master_password
137
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
138
+ cipher.encrypt
139
+
140
+ # Derive key from password
141
+ salt = OpenSSL::Random.random_bytes(16)
142
+ key = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, 10_000, cipher.key_len, 'sha256')
143
+ cipher.key = key
144
+
145
+ iv = cipher.random_iv
146
+ encrypted = cipher.update(data) + cipher.final
147
+
148
+ # Return: salt + iv + encrypted_data (all base64 encoded)
149
+ result = {
150
+ salt: Base64.strict_encode64(salt),
151
+ iv: Base64.strict_encode64(iv),
152
+ data: Base64.strict_encode64(encrypted)
153
+ }
154
+ Base64.strict_encode64(result.to_json)
155
+ end
156
+
157
+ # Decrypt data with master password
158
+ def decrypt(encrypted_str)
159
+ password = master_password
160
+
161
+ # Decode outer base64
162
+ payload = JSON.parse(Base64.strict_decode64(encrypted_str))
163
+
164
+ salt = Base64.strict_decode64(payload['salt'])
165
+ iv = Base64.strict_decode64(payload['iv'])
166
+ encrypted_data = Base64.strict_decode64(payload['data'])
167
+
168
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
169
+ cipher.decrypt
170
+
171
+ key = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, 10_000, cipher.key_len, 'sha256')
172
+ cipher.key = key
173
+ cipher.iv = iv
174
+
175
+ cipher.update(encrypted_data) + cipher.final
176
+ end
177
+
178
+ # Append entry to JSONL
179
+ def append_entry(namespace, key, value, operation: 'set')
180
+ ensure_secrets_file
181
+
182
+ entry = {
183
+ ts: Time.now.utc.iso8601(3),
184
+ op: operation,
185
+ ns: namespace,
186
+ key: key
187
+ }
188
+
189
+ entry[:val] = encrypt(value) if operation == 'set'
190
+
191
+ File.open(SECRETS_FILE, 'a') do |f|
192
+ f.puts(entry.to_json)
197
193
  end
198
194
  end
199
195
 
200
- def list(prefix = nil)
201
- 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
196
+ # Detect and merge conflicted copies
197
+ def detect_and_merge_conflicts
198
+ return unless File.exist?(SECRETS_FILE)
199
+
200
+ dir = File.dirname(SECRETS_FILE)
201
+ base = File.basename(SECRETS_FILE, '.jsonl')
202
+
203
+ # Find all conflicted copies
204
+ conflicted = Dir.glob(File.join(dir, "#{base} (*conflicted copy*).jsonl"))
205
+ return if conflicted.empty?
206
+
207
+ # Read all files
208
+ all_entries = []
209
+
210
+ # Main file
211
+ if File.exist?(SECRETS_FILE)
212
+ File.readlines(SECRETS_FILE).each do |line|
213
+ line = line.strip
214
+ next if line.empty?
215
+
216
+ begin
217
+ all_entries << JSON.parse(line, symbolize_names: true)
218
+ rescue JSON::ParserError
219
+ # Skip malformed lines
220
220
  end
221
221
  end
222
222
  end
223
223
 
224
- accounts.sort.uniq
224
+ # Conflicted files
225
+ conflicted.each do |file|
226
+ File.readlines(file).each do |line|
227
+ line = line.strip
228
+ next if line.empty?
229
+
230
+ begin
231
+ all_entries << JSON.parse(line, symbolize_names: true)
232
+ rescue JSON::ParserError
233
+ # Skip malformed lines
234
+ end
235
+ end
236
+ end
237
+
238
+ # Sort by timestamp (oldest first)
239
+ all_entries.sort_by! { |e| e[:ts] }
240
+
241
+ # Write merged entries back to main file
242
+ File.open(SECRETS_FILE, 'w') do |f|
243
+ all_entries.each { |e| f.puts(e.to_json) }
244
+ end
245
+
246
+ # Delete conflicted copies
247
+ conflicted.each { |file| File.delete(file) }
248
+ end
249
+
250
+ # Read all entries from JSONL
251
+ def read_entries
252
+ # First, detect and merge any conflicts
253
+ detect_and_merge_conflicts
254
+
255
+ return [] unless File.exist?(SECRETS_FILE)
256
+
257
+ entries = []
258
+ File.readlines(SECRETS_FILE).each do |line|
259
+ line = line.strip
260
+ next if line.empty?
261
+
262
+ begin
263
+ entries << JSON.parse(line, symbolize_names: true)
264
+ rescue JSON::ParserError
265
+ # Skip malformed lines
266
+ end
267
+ end
268
+
269
+ entries
270
+ end
271
+
272
+ # Get current state (latest values for each key)
273
+ def current_state
274
+ entries = read_entries
275
+ state = {}
276
+
277
+ entries.each do |entry|
278
+ ns_key = "#{entry[:ns]}:#{entry[:key]}"
279
+
280
+ if entry[:op] == 'set'
281
+ state[ns_key] = {
282
+ namespace: entry[:ns],
283
+ key: entry[:key],
284
+ encrypted_value: entry[:val],
285
+ timestamp: entry[:ts]
286
+ }
287
+ elsif entry[:op] == 'del'
288
+ state.delete(ns_key)
289
+ end
290
+ end
291
+
292
+ state
293
+ end
294
+
295
+ # Save a secret
296
+ def save(account_name, content)
297
+ namespace, key = parse_account_name(account_name)
298
+ append_entry(namespace, key, content, operation: 'set')
299
+ end
300
+
301
+ # Load a secret
302
+ def load(account_name)
303
+ namespace, key = parse_account_name(account_name)
304
+ state = current_state
305
+ ns_key = "#{namespace}:#{key}"
306
+
307
+ entry = state[ns_key]
308
+ raise Error, "Entry '#{account_name}' not found" unless entry
309
+
310
+ decrypt(entry[:encrypted_value])
311
+ end
312
+
313
+ # Delete a secret
314
+ def delete(account_name)
315
+ namespace, key = parse_account_name(account_name)
316
+
317
+ # Check if exists
318
+ state = current_state
319
+ ns_key = "#{namespace}:#{key}"
320
+ raise Error, "Entry '#{account_name}' not found" unless state[ns_key]
321
+
322
+ append_entry(namespace, key, nil, operation: 'del')
323
+ end
324
+
325
+ # List secrets
326
+ def list(prefix = nil)
327
+ state = current_state
328
+ keys = state.keys
329
+
330
+ keys = keys.select { |k| k.start_with?(prefix) } if prefix
331
+
332
+ keys.sort
333
+ end
334
+
335
+ private
336
+
337
+ def parse_account_name(account_name)
338
+ raise Error, 'Invalid format. Use <namespace>:<name>' unless account_name&.include?(':')
339
+
340
+ namespace, key = account_name.split(':', 2)
341
+
342
+ raise Error, 'Invalid format. Use <namespace>:<name>' if namespace.empty? || key.empty?
343
+
344
+ [namespace, key]
345
+ end
346
+
347
+ def ensure_secrets_file
348
+ FileUtils.mkdir_p(ICLOUD_DRIVE_PATH) unless Dir.exist?(ICLOUD_DRIVE_PATH)
349
+ File.write(SECRETS_FILE, '') unless File.exist?(SECRETS_FILE)
225
350
  end
226
351
  end
227
352
  end
data/lib/kc/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kc
2
- VERSION = "0.1.0"
2
+ VERSION = '0.3.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.3.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 with AES-256 encryption
69
+ and automatic iCloud Drive synchronization. Master password stored in local keychain,
70
+ secrets encrypted and synced via iCloud Drive. Features namespace support, automatic
71
+ conflict resolution, and append-only JSONL format.
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: Secure secrets manager with iCloud Drive sync and AES-256 encryption
117
119
  test_files: []