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 +4 -4
- data/README.md +76 -27
- data/kc.gemspec +2 -2
- data/lib/kc/cli.rb +66 -28
- data/lib/kc/keychain.rb +271 -228
- data/lib/kc/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a784b3ff753a30bc3f560d8cd417b6597fe77996da2cc95a96d58f431aa9bff1
|
|
4
|
+
data.tar.gz: d3c844cf4e13e797ee98cde972fc52706bdeb08724e19b94de3c2cebe5b1c928
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 60b2e62994b43f2d2396c930a046e0bac7a4834cb60d3be101e8615da7c5e0e651a7031aaa5f947ed2cd27c34918aa5d8dc49682379489ebe698d1956e746d29
|
|
7
|
+
data.tar.gz: 5db7687b87fdcab2b7b295b3c7a183394142291230561f673a49ce7969c8557fda062da156f26c95f4f1f3404ce34bc872c85ab47e4c3e6cb7e1e9ab89a56447
|
data/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
# kc -
|
|
1
|
+
# kc - Secure Secrets Manager with iCloud Sync
|
|
2
2
|
|
|
3
|
-
A CLI tool to securely store and retrieve secrets
|
|
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
|
-
- 🔐
|
|
8
|
-
- ☁️ **iCloud
|
|
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
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- 📋
|
|
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
|
|
49
|
+
All commands use the format `<namespace>:<name>`. Namespaces help organize different types of secrets.
|
|
29
50
|
|
|
30
|
-
### Save
|
|
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
|
|
100
|
-
- `kc
|
|
101
|
-
- `kc
|
|
102
|
-
- `kc
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
### Security
|
|
129
167
|
|
|
130
|
-
-
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
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
|
-
|
|
175
|
+
### Conflict Resolution
|
|
136
176
|
|
|
137
|
-
|
|
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
|
|
199
|
-
-
|
|
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 = '
|
|
12
|
-
spec.description = 'A CLI tool to securely store and retrieve secrets
|
|
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
|
|
16
|
+
when 'init'
|
|
17
|
+
handle_init
|
|
18
|
+
when 'save'
|
|
17
19
|
handle_save(name)
|
|
18
|
-
when
|
|
20
|
+
when 'load'
|
|
19
21
|
handle_load(name)
|
|
20
|
-
when
|
|
22
|
+
when 'delete'
|
|
21
23
|
handle_delete(name)
|
|
22
|
-
when
|
|
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
|
|
35
|
-
puts
|
|
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(
|
|
40
|
-
|
|
75
|
+
namespace, key = name.split(':', 2)
|
|
76
|
+
|
|
41
77
|
if namespace.empty? || key.empty?
|
|
42
|
-
puts
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
134
|
-
kc
|
|
135
|
-
kc
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
#
|
|
68
|
-
attach_function :
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
#
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
114
|
-
|
|
94
|
+
unless status == SecurityFramework::ErrSecSuccess
|
|
95
|
+
raise Error, "Failed to load master password (status: #{status})"
|
|
96
|
+
end
|
|
115
97
|
|
|
116
|
-
length =
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
def kSecClass
|
|
123
|
-
@kSecClass ||= SecurityFramework.get_constant('kSecClass')
|
|
124
|
-
end
|
|
102
|
+
SecurityFramework.SecKeychainItemFreeContent(nil, data_ptr)
|
|
125
103
|
|
|
126
|
-
|
|
127
|
-
@kSecClassInternetPassword ||= SecurityFramework.get_constant('kSecClassInternetPassword')
|
|
104
|
+
password
|
|
128
105
|
end
|
|
129
106
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
107
|
+
# Delete master password
|
|
108
|
+
def delete_master_password
|
|
109
|
+
item_ref = FFI::MemoryPointer.new(:pointer)
|
|
133
110
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
@kSecAttrProtocol ||= SecurityFramework.get_constant('kSecAttrProtocol')
|
|
140
|
-
end
|
|
122
|
+
return if status == SecurityFramework::ErrSecItemNotFound
|
|
141
123
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
124
|
+
unless status == SecurityFramework::ErrSecSuccess
|
|
125
|
+
raise Error, "Failed to find master password (status: #{status})"
|
|
126
|
+
end
|
|
145
127
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
end
|
|
128
|
+
delete_status = SecurityFramework.SecKeychainItemDelete(item_ref.read_pointer)
|
|
129
|
+
return if delete_status == SecurityFramework::ErrSecSuccess
|
|
149
130
|
|
|
150
|
-
|
|
151
|
-
@kSecReturnData ||= SecurityFramework.get_constant('kSecReturnData')
|
|
131
|
+
raise Error, "Failed to delete master password (status: #{delete_status})"
|
|
152
132
|
end
|
|
153
133
|
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
# Decrypt data with master password
|
|
158
|
+
def decrypt(encrypted_str)
|
|
159
|
+
password = master_password
|
|
161
160
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
end
|
|
161
|
+
# Decode outer base64
|
|
162
|
+
payload = JSON.parse(Base64.strict_decode64(encrypted_str))
|
|
165
163
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
query = CoreFoundation.CFDictionaryCreateMutable(nil, 0, nil, nil)
|
|
168
|
+
cipher = OpenSSL::Cipher.new('AES-256-CBC')
|
|
169
|
+
cipher.decrypt
|
|
173
170
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
178
|
+
# Append entry to JSONL
|
|
179
|
+
def append_entry(namespace, key, value, operation: 'set')
|
|
180
|
+
ensure_secrets_file
|
|
185
181
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
182
|
+
entry = {
|
|
183
|
+
ts: Time.now.utc.iso8601(3),
|
|
184
|
+
op: operation,
|
|
185
|
+
ns: namespace,
|
|
186
|
+
key: key
|
|
187
|
+
}
|
|
191
188
|
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
# Detect and merge conflicted copies
|
|
197
|
+
def detect_and_merge_conflicts
|
|
198
|
+
return unless File.exist?(SECRETS_FILE)
|
|
200
199
|
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
dir = File.dirname(SECRETS_FILE)
|
|
201
|
+
base = File.basename(SECRETS_FILE, '.jsonl')
|
|
203
202
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
211
|
-
|
|
207
|
+
# Read all files
|
|
208
|
+
all_entries = []
|
|
212
209
|
|
|
213
|
-
#
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
246
|
+
# Delete conflicted copies
|
|
247
|
+
conflicted.each { |file| File.delete(file) }
|
|
232
248
|
end
|
|
233
249
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
account_str = create_cf_string(account_name)
|
|
255
|
+
return [] unless File.exist?(SECRETS_FILE)
|
|
240
256
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
257
|
+
entries = []
|
|
258
|
+
File.readlines(SECRETS_FILE).each do |line|
|
|
259
|
+
line = line.strip
|
|
260
|
+
next if line.empty?
|
|
245
261
|
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
CoreFoundation.CFRelease(account_str)
|
|
251
|
-
CoreFoundation.CFRelease(query)
|
|
269
|
+
entries
|
|
270
|
+
end
|
|
252
271
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
310
|
+
decrypt(entry[:encrypted_value])
|
|
311
|
+
end
|
|
276
312
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
313
|
+
# Delete a secret
|
|
314
|
+
def delete(account_name)
|
|
315
|
+
namespace, key = parse_account_name(account_name)
|
|
280
316
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
end
|
|
322
|
+
append_entry(namespace, key, nil, operation: 'del')
|
|
323
|
+
end
|
|
288
324
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
325
|
+
# List secrets
|
|
326
|
+
def list(prefix = nil)
|
|
327
|
+
state = current_state
|
|
328
|
+
keys = state.keys
|
|
293
329
|
|
|
294
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
332
|
+
keys.sort
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private
|
|
303
336
|
|
|
304
|
-
|
|
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
|
-
|
|
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
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.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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
118
|
+
summary: Secure secrets manager with iCloud Drive sync and AES-256 encryption
|
|
119
119
|
test_files: []
|