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 +4 -4
- data/README.md +83 -20
- data/kc.gemspec +20 -21
- data/lib/kc/cli.rb +66 -28
- data/lib/kc/keychain.rb +290 -165
- data/lib/kc/version.rb +1 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 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
|
-
- 🔐
|
|
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
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
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
|
|
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,10 +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
|
|
121
150
|
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
185
|
-
-
|
|
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/
|
|
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/
|
|
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
|
|
3
|
+
require 'kc/version'
|
|
5
4
|
|
|
6
5
|
Gem::Specification.new do |spec|
|
|
7
|
-
spec.name =
|
|
6
|
+
spec.name = 'kc'
|
|
8
7
|
spec.version = Kc::VERSION
|
|
9
|
-
spec.authors = [
|
|
10
|
-
spec.email = [
|
|
8
|
+
spec.authors = ['AILERON']
|
|
9
|
+
spec.email = ['masa@aileron.cc']
|
|
11
10
|
|
|
12
|
-
spec.summary =
|
|
13
|
-
spec.description =
|
|
14
|
-
spec.homepage =
|
|
15
|
-
spec.license =
|
|
11
|
+
spec.summary = '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[
|
|
21
|
-
spec.metadata[
|
|
19
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
20
|
+
spec.metadata['source_code_uri'] = 'https://github.com/aileron/kc'
|
|
22
21
|
else
|
|
23
|
-
raise
|
|
24
|
-
|
|
22
|
+
raise 'RubyGems 2.0 or newer is required to protect against ' \
|
|
23
|
+
'public gem pushes.'
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
# Specify which files should be added to the gem when it is released.
|
|
28
27
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
29
|
-
spec.files
|
|
28
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
30
29
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
31
30
|
end
|
|
32
|
-
spec.bindir =
|
|
31
|
+
spec.bindir = 'exe'
|
|
33
32
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
34
|
-
spec.require_paths = [
|
|
33
|
+
spec.require_paths = ['lib']
|
|
35
34
|
|
|
36
|
-
spec.add_dependency
|
|
35
|
+
spec.add_dependency 'ffi', '~> 1.15'
|
|
37
36
|
|
|
38
|
-
spec.add_development_dependency
|
|
39
|
-
spec.add_development_dependency
|
|
40
|
-
spec.add_development_dependency
|
|
37
|
+
spec.add_development_dependency 'bundler'
|
|
38
|
+
spec.add_development_dependency 'rake'
|
|
39
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
41
40
|
end
|
data/lib/kc/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,227 +1,352 @@
|
|
|
1
|
-
require
|
|
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
|
-
|
|
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
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
54
|
-
attach_function :
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
#
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
37
|
+
ErrSecSuccess = 0
|
|
38
|
+
ErrSecItemNotFound = -25_300
|
|
121
39
|
end
|
|
122
40
|
|
|
123
41
|
class << self
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
#
|
|
51
|
+
# Save new master password to local keychain
|
|
129
52
|
status = SecurityFramework.SecKeychainAddGenericPassword(
|
|
130
|
-
nil,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
FFI::MemoryPointer.from_string(
|
|
137
|
-
nil
|
|
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
|
|
141
|
-
raise Error, "Failed to save
|
|
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
|
-
|
|
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,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
password_length,
|
|
156
|
-
password_data,
|
|
157
|
-
nil
|
|
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
|
-
|
|
161
|
-
raise Error, "
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
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,8 +65,10 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '3.0'
|
|
68
|
-
description: A CLI tool to
|
|
69
|
-
|
|
68
|
+
description: A CLI tool to securely store and retrieve secrets 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:
|
|
118
|
+
summary: Secure secrets manager with iCloud Drive sync and AES-256 encryption
|
|
117
119
|
test_files: []
|