kc 0.2.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: 8d9fd3f9d9435dac8fd474111770fdc9648407d6880a8399a4d642c99b528907
4
- data.tar.gz: e45b7d10dde259a3d44f661848dcaf282b6b3779d99bf5f647e34f3e89eb4d07
3
+ metadata.gz: a784b3ff753a30bc3f560d8cd417b6597fe77996da2cc95a96d58f431aa9bff1
4
+ data.tar.gz: d3c844cf4e13e797ee98cde972fc52706bdeb08724e19b94de3c2cebe5b1c928
5
5
  SHA512:
6
- metadata.gz: b571902e2874562153524032e6ec2addcb26c8e6e84ebabd2ba6ab153aad5b1ecbab639faaa163caa9c1fc7d32088e7184a1efe87754da15e6d75df7155589c6
7
- data.tar.gz: bbe081e64f7098788f833f3127c5ccdab1fe5c87881956dac76ac93a6680fa479f4610d4e1cde8152fabd560f333e75a7d878d9447cf0e16b77244074565a3ba
6
+ metadata.gz: 60b2e62994b43f2d2396c930a046e0bac7a4834cb60d3be101e8615da7c5e0e651a7031aaa5f947ed2cd27c34918aa5d8dc49682379489ebe698d1956e746d29
7
+ data.tar.gz: 5db7687b87fdcab2b7b295b3c7a183394142291230561f673a49ce7969c8557fda062da156f26c95f4f1f3404ce34bc872c85ab47e4c3e6cb7e1e9ab89a56447
data/README.md CHANGED
@@ -1,15 +1,16 @@
1
- # kc - Keychain Manager
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 and automatic iCloud sync.
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
8
- - ☁️ **iCloud Keychain sync** - automatically sync secrets across all your Macs
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
9
10
  - 🏷️ **Namespace support** - organize secrets by type (env, ssh, token, etc.)
10
- - 🚀 Native implementation using FFI (no shell command overhead)
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,24 +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 as **Internet Passwords** with:
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
150
+
151
+ ### Data Format
121
152
 
122
- - Server: `kc`
123
- - Account: `<namespace>:<name>` (e.g., `env:myproject`)
124
- - Protocol: HTTPS
153
+ Secrets are stored in an append-only JSONL (JSON Lines) file:
125
154
 
126
- ### iCloud Keychain Sync
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
127
165
 
128
- All secrets saved by `kc` are automatically synchronized across your Macs via **iCloud Keychain** (if enabled in System Settings). This means:
166
+ ### Security
129
167
 
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
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)
134
174
 
135
- To verify sync is working, open **Keychain Access.app** and select the **iCloud** keychain. Look for entries with server name `kc`.
175
+ ### Conflict Resolution
136
176
 
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!
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
138
184
 
139
185
  ## Full Workflow Example
140
186
 
@@ -195,8 +241,11 @@ bundle exec rspec
195
241
 
196
242
  ## Requirements
197
243
 
198
- - macOS (uses macOS Keychain)
199
- - 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.
200
249
 
201
250
  ## Contributing
202
251
 
data/kc.gemspec CHANGED
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ['AILERON']
9
9
  spec.email = ['masa@aileron.cc']
10
10
 
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.'
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
13
  spec.homepage = 'https://github.com/aileron/kc'
14
14
  spec.license = 'MIT'
15
15
 
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,309 +1,352 @@
1
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'
6
-
7
- module CoreFoundation
8
- extend FFI::Library
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
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
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')
62
14
 
63
15
  module SecurityFramework
64
16
  extend FFI::Library
65
17
  ffi_lib '/System/Library/Frameworks/Security.framework/Security'
66
18
 
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
19
+ # OSStatus SecKeychainAddGenericPassword(...)
20
+ attach_function :SecKeychainAddGenericPassword, %i[
21
+ pointer uint32 string uint32 string
22
+ uint32 pointer pointer
23
+ ], :int
24
+
25
+ # OSStatus SecKeychainFindGenericPassword(...)
26
+ attach_function :SecKeychainFindGenericPassword, %i[
27
+ pointer uint32 string uint32 string
28
+ pointer pointer pointer
29
+ ], :int
30
+
31
+ # OSStatus SecKeychainItemDelete(...)
32
+ attach_function :SecKeychainItemDelete, [:pointer], :int
33
+
34
+ # void SecKeychainItemFreeContent(...)
35
+ attach_function :SecKeychainItemFreeContent, %i[pointer pointer], :int
72
36
 
73
- # Error codes
74
37
  ErrSecSuccess = 0
75
38
  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
86
39
  end
87
40
 
88
41
  class << self
89
- # Helper methods for CoreFoundation
90
- def create_cf_string(str)
91
- CoreFoundation.CFStringCreateWithCString(nil, str, CoreFoundation::KCFStringEncodingUTF8)
92
- end
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
93
50
 
94
- def create_cf_data(data)
95
- ptr = FFI::MemoryPointer.from_string(data)
96
- CoreFoundation.CFDataCreate(nil, ptr, data.bytesize)
97
- end
51
+ # Save new master password to local keychain
52
+ status = SecurityFramework.SecKeychainAddGenericPassword(
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
61
+ )
62
+
63
+ unless status == SecurityFramework::ErrSecSuccess
64
+ raise Error, "Failed to save master password (status: #{status})"
65
+ end
98
66
 
99
- def create_cf_boolean(value)
100
- value ? CoreFoundation.kCFBooleanTrue : CoreFoundation.kCFBooleanFalse
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)
101
72
  end
102
73
 
103
- def cf_string_to_ruby(cf_string)
104
- return nil if cf_string.null?
74
+ # Get master password from local keychain
75
+ def master_password
76
+ password_length = FFI::MemoryPointer.new(:uint32)
77
+ password_data = FFI::MemoryPointer.new(:pointer)
78
+
79
+ status = SecurityFramework.SecKeychainFindGenericPassword(
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
88
+ )
105
89
 
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
90
+ if status == SecurityFramework::ErrSecItemNotFound
91
+ raise Error, "Master password not found. Please run 'kc init' first."
110
92
  end
111
- end
112
93
 
113
- def cf_data_to_ruby(cf_data)
114
- return nil if cf_data.null?
94
+ unless status == SecurityFramework::ErrSecSuccess
95
+ raise Error, "Failed to load master password (status: #{status})"
96
+ end
115
97
 
116
- length = CoreFoundation.CFDataGetLength(cf_data)
117
- ptr = CoreFoundation.CFDataGetBytePtr(cf_data)
118
- ptr.read_bytes(length)
119
- end
98
+ length = password_length.read_uint32
99
+ data_ptr = password_data.read_pointer
100
+ password = data_ptr.read_string(length)
120
101
 
121
- # Get Security framework constants
122
- def kSecClass
123
- @kSecClass ||= SecurityFramework.get_constant('kSecClass')
124
- end
102
+ SecurityFramework.SecKeychainItemFreeContent(nil, data_ptr)
125
103
 
126
- def kSecClassInternetPassword
127
- @kSecClassInternetPassword ||= SecurityFramework.get_constant('kSecClassInternetPassword')
104
+ password
128
105
  end
129
106
 
130
- def kSecAttrServer
131
- @kSecAttrServer ||= SecurityFramework.get_constant('kSecAttrServer')
132
- end
107
+ # Delete master password
108
+ def delete_master_password
109
+ item_ref = FFI::MemoryPointer.new(:pointer)
133
110
 
134
- def kSecAttrAccount
135
- @kSecAttrAccount ||= SecurityFramework.get_constant('kSecAttrAccount')
136
- end
111
+ status = SecurityFramework.SecKeychainFindGenericPassword(
112
+ nil,
113
+ MASTER_PASSWORD_SERVICE.bytesize,
114
+ MASTER_PASSWORD_SERVICE,
115
+ MASTER_PASSWORD_ACCOUNT.bytesize,
116
+ MASTER_PASSWORD_ACCOUNT,
117
+ nil,
118
+ nil,
119
+ item_ref
120
+ )
137
121
 
138
- def kSecAttrProtocol
139
- @kSecAttrProtocol ||= SecurityFramework.get_constant('kSecAttrProtocol')
140
- end
122
+ return if status == SecurityFramework::ErrSecItemNotFound
141
123
 
142
- def kSecAttrProtocolHTTPS
143
- @kSecAttrProtocolHTTPS ||= SecurityFramework.get_constant('kSecAttrProtocolHTTPS')
144
- end
124
+ unless status == SecurityFramework::ErrSecSuccess
125
+ raise Error, "Failed to find master password (status: #{status})"
126
+ end
145
127
 
146
- def kSecValueData
147
- @kSecValueData ||= SecurityFramework.get_constant('kSecValueData')
148
- end
128
+ delete_status = SecurityFramework.SecKeychainItemDelete(item_ref.read_pointer)
129
+ return if delete_status == SecurityFramework::ErrSecSuccess
149
130
 
150
- def kSecReturnData
151
- @kSecReturnData ||= SecurityFramework.get_constant('kSecReturnData')
131
+ raise Error, "Failed to delete master password (status: #{delete_status})"
152
132
  end
153
133
 
154
- def kSecMatchLimit
155
- @kSecMatchLimit ||= SecurityFramework.get_constant('kSecMatchLimit')
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)
156
155
  end
157
156
 
158
- def kSecMatchLimitAll
159
- @kSecMatchLimitAll ||= SecurityFramework.get_constant('kSecMatchLimitAll')
160
- end
157
+ # Decrypt data with master password
158
+ def decrypt(encrypted_str)
159
+ password = master_password
161
160
 
162
- def kSecReturnAttributes
163
- @kSecReturnAttributes ||= SecurityFramework.get_constant('kSecReturnAttributes')
164
- end
161
+ # Decode outer base64
162
+ payload = JSON.parse(Base64.strict_decode64(encrypted_str))
165
163
 
166
- def save(account_name, content)
167
- # Try to delete existing entry first (update semantics)
168
- delete(account_name) rescue nil
164
+ salt = Base64.strict_decode64(payload['salt'])
165
+ iv = Base64.strict_decode64(payload['iv'])
166
+ encrypted_data = Base64.strict_decode64(payload['data'])
169
167
 
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)
168
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
169
+ cipher.decrypt
173
170
 
174
- server_str = create_cf_string(SERVICE_NAME)
175
- account_str = create_cf_string(account_name)
176
- data_obj = create_cf_data(content)
171
+ key = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, 10_000, cipher.key_len, 'sha256')
172
+ cipher.key = key
173
+ cipher.iv = iv
177
174
 
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)
175
+ cipher.update(encrypted_data) + cipher.final
176
+ end
183
177
 
184
- status = SecurityFramework.SecItemAdd(query, nil)
178
+ # Append entry to JSONL
179
+ def append_entry(namespace, key, value, operation: 'set')
180
+ ensure_secrets_file
185
181
 
186
- # Cleanup
187
- CoreFoundation.CFRelease(server_str)
188
- CoreFoundation.CFRelease(account_str)
189
- CoreFoundation.CFRelease(data_obj)
190
- CoreFoundation.CFRelease(query)
182
+ entry = {
183
+ ts: Time.now.utc.iso8601(3),
184
+ op: operation,
185
+ ns: namespace,
186
+ key: key
187
+ }
191
188
 
192
- unless status == SecurityFramework::ErrSecSuccess
193
- raise Error, "Failed to save to keychain (status: #{status})"
189
+ entry[:val] = encrypt(value) if operation == 'set'
190
+
191
+ File.open(SECRETS_FILE, 'a') do |f|
192
+ f.puts(entry.to_json)
194
193
  end
195
194
  end
196
195
 
197
- def load(account_name)
198
- # Create query dictionary for Internet Password
199
- query = CoreFoundation.CFDictionaryCreateMutable(nil, 0, nil, nil)
196
+ # Detect and merge conflicted copies
197
+ def detect_and_merge_conflicts
198
+ return unless File.exist?(SECRETS_FILE)
200
199
 
201
- server_str = create_cf_string(SERVICE_NAME)
202
- account_str = create_cf_string(account_name)
200
+ dir = File.dirname(SECRETS_FILE)
201
+ base = File.basename(SECRETS_FILE, '.jsonl')
203
202
 
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))
203
+ # Find all conflicted copies
204
+ conflicted = Dir.glob(File.join(dir, "#{base} (*conflicted copy*).jsonl"))
205
+ return if conflicted.empty?
209
206
 
210
- result_ptr = FFI::MemoryPointer.new(:pointer)
211
- status = SecurityFramework.SecItemCopyMatching(query, result_ptr)
207
+ # Read all files
208
+ all_entries = []
212
209
 
213
- # Cleanup query
214
- CoreFoundation.CFRelease(server_str)
215
- CoreFoundation.CFRelease(account_str)
216
- CoreFoundation.CFRelease(query)
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?
217
215
 
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})"
216
+ begin
217
+ all_entries << JSON.parse(line, symbolize_names: true)
218
+ rescue JSON::ParserError
219
+ # Skip malformed lines
220
+ end
223
221
  end
224
222
  end
225
223
 
226
- # Extract data
227
- result = result_ptr.read_pointer
228
- data = cf_data_to_ruby(result)
229
- CoreFoundation.CFRelease(result)
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
230
245
 
231
- data
246
+ # Delete conflicted copies
247
+ conflicted.each { |file| File.delete(file) }
232
248
  end
233
249
 
234
- def delete(account_name)
235
- # Create query dictionary for Internet Password
236
- query = CoreFoundation.CFDictionaryCreateMutable(nil, 0, nil, nil)
250
+ # Read all entries from JSONL
251
+ def read_entries
252
+ # First, detect and merge any conflicts
253
+ detect_and_merge_conflicts
237
254
 
238
- server_str = create_cf_string(SERVICE_NAME)
239
- account_str = create_cf_string(account_name)
255
+ return [] unless File.exist?(SECRETS_FILE)
240
256
 
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)
257
+ entries = []
258
+ File.readlines(SECRETS_FILE).each do |line|
259
+ line = line.strip
260
+ next if line.empty?
245
261
 
246
- status = SecurityFramework.SecItemDelete(query)
262
+ begin
263
+ entries << JSON.parse(line, symbolize_names: true)
264
+ rescue JSON::ParserError
265
+ # Skip malformed lines
266
+ end
267
+ end
247
268
 
248
- # Cleanup
249
- CoreFoundation.CFRelease(server_str)
250
- CoreFoundation.CFRelease(account_str)
251
- CoreFoundation.CFRelease(query)
269
+ entries
270
+ end
252
271
 
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})"
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)
258
289
  end
259
290
  end
291
+
292
+ state
260
293
  end
261
294
 
262
- def list(prefix = nil)
263
- # Create query dictionary for Internet Password
264
- query = CoreFoundation.CFDictionaryCreateMutable(nil, 0, nil, nil)
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
265
300
 
266
- server_str = create_cf_string(SERVICE_NAME)
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}"
267
306
 
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))
307
+ entry = state[ns_key]
308
+ raise Error, "Entry '#{account_name}' not found" unless entry
273
309
 
274
- result_ptr = FFI::MemoryPointer.new(:pointer)
275
- status = SecurityFramework.SecItemCopyMatching(query, result_ptr)
310
+ decrypt(entry[:encrypted_value])
311
+ end
276
312
 
277
- # Cleanup query
278
- CoreFoundation.CFRelease(server_str)
279
- CoreFoundation.CFRelease(query)
313
+ # Delete a secret
314
+ def delete(account_name)
315
+ namespace, key = parse_account_name(account_name)
280
316
 
281
- if status == SecurityFramework::ErrSecItemNotFound
282
- return []
283
- end
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]
284
321
 
285
- unless status == SecurityFramework::ErrSecSuccess
286
- raise Error, "Failed to list keychain items (status: #{status})"
287
- end
322
+ append_entry(namespace, key, nil, operation: 'del')
323
+ end
288
324
 
289
- # Parse results
290
- result = result_ptr.read_pointer
291
- count = CoreFoundation.CFArrayGetCount(result)
292
- accounts = []
325
+ # List secrets
326
+ def list(prefix = nil)
327
+ state = current_state
328
+ keys = state.keys
293
329
 
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)
330
+ keys = keys.select { |k| k.start_with?(prefix) } if prefix
298
331
 
299
- if account_name && (prefix.nil? || account_name.start_with?(prefix))
300
- accounts << account_name
301
- end
302
- end
332
+ keys.sort
333
+ end
334
+
335
+ private
303
336
 
304
- CoreFoundation.CFRelease(result)
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
305
346
 
306
- accounts.sort.uniq
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)
307
350
  end
308
351
  end
309
352
  end
data/lib/kc/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kc
2
- VERSION = '0.2.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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AILERON
@@ -65,10 +65,10 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '3.0'
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.
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.
72
72
  email:
73
73
  - masa@aileron.cc
74
74
  executables:
@@ -115,5 +115,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
115
115
  requirements: []
116
116
  rubygems_version: 3.6.9
117
117
  specification_version: 4
118
- summary: Manage secrets in macOS Keychain with iCloud sync and namespace support
118
+ summary: Secure secrets manager with iCloud Drive sync and AES-256 encryption
119
119
  test_files: []