localvault 1.1.1 → 1.2.1
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/LICENSE +15 -17
- data/README.md +64 -2
- data/lib/localvault/api_client.rb +86 -14
- data/lib/localvault/cli/keys.rb +14 -0
- data/lib/localvault/cli/sync.rb +90 -12
- data/lib/localvault/cli/team.rb +388 -46
- data/lib/localvault/cli.rb +66 -4
- data/lib/localvault/config.rb +61 -0
- data/lib/localvault/crypto.rb +46 -0
- data/lib/localvault/identity.rb +41 -0
- data/lib/localvault/key_slot.rb +23 -3
- data/lib/localvault/mcp/server.rb +17 -0
- data/lib/localvault/mcp/tools.rb +13 -1
- data/lib/localvault/session_cache.rb +35 -7
- data/lib/localvault/share_crypto.rb +30 -5
- data/lib/localvault/store.rb +66 -0
- data/lib/localvault/sync_bundle.rb +50 -11
- data/lib/localvault/vault.rb +122 -12
- data/lib/localvault/version.rb +1 -1
- metadata +1 -1
data/lib/localvault/cli/team.rb
CHANGED
|
@@ -1,13 +1,99 @@
|
|
|
1
1
|
require "thor"
|
|
2
|
+
require "securerandom"
|
|
2
3
|
|
|
3
4
|
module LocalVault
|
|
4
5
|
class CLI
|
|
5
6
|
class Team < Thor
|
|
7
|
+
desc "init", "Initialize a vault as a team vault (sets you as owner)"
|
|
8
|
+
method_option :vault, type: :string, aliases: "-v"
|
|
9
|
+
# Initialize a vault as a team vault with you as the owner.
|
|
10
|
+
#
|
|
11
|
+
# This is the explicit transition from personal sync to team-shared sync.
|
|
12
|
+
# Creates the owner's key slot and bumps the bundle to v3.
|
|
13
|
+
def init
|
|
14
|
+
unless Config.token
|
|
15
|
+
$stderr.puts "Error: Not logged in."
|
|
16
|
+
$stderr.puts "\n localvault login YOUR_TOKEN\n"
|
|
17
|
+
$stderr.puts "Get your token at: https://inventlist.com/settings"
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
unless Identity.exists?
|
|
22
|
+
$stderr.puts "Error: No keypair found. Run: localvault keygen"
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
vault_name = options[:vault] || Config.default_vault
|
|
27
|
+
handle = Config.inventlist_handle
|
|
28
|
+
|
|
29
|
+
master_key = SessionCache.get(vault_name)
|
|
30
|
+
unless master_key
|
|
31
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
client = ApiClient.new(token: Config.token)
|
|
36
|
+
begin
|
|
37
|
+
blob = client.pull_vault(vault_name)
|
|
38
|
+
unless blob.is_a?(String) && !blob.empty?
|
|
39
|
+
$stderr.puts "Error: Vault '#{vault_name}' has not been synced. Run: localvault sync push -v #{vault_name}"
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
data = SyncBundle.unpack(blob)
|
|
43
|
+
if data[:owner]
|
|
44
|
+
$stderr.puts "Error: Vault '#{vault_name}' is already a team vault. Owner: @#{data[:owner]}"
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
rescue ApiClient::ApiError => e
|
|
48
|
+
if e.status == 404
|
|
49
|
+
$stderr.puts "Error: Vault '#{vault_name}' has not been synced. Run: localvault sync push -v #{vault_name}"
|
|
50
|
+
else
|
|
51
|
+
$stderr.puts "Error: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Create owner key slot
|
|
57
|
+
pub_b64 = Identity.public_key
|
|
58
|
+
enc_key = KeySlot.create(master_key, pub_b64)
|
|
59
|
+
key_slots = {
|
|
60
|
+
handle => { "pub" => pub_b64, "enc_key" => enc_key, "scopes" => nil, "blob" => nil }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Preserve existing key slots from v2 (upgrade path)
|
|
64
|
+
data[:key_slots].each do |h, slot|
|
|
65
|
+
next if h == handle
|
|
66
|
+
next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
|
|
67
|
+
key_slots[h] = slot.merge("scopes" => nil, "blob" => nil)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
store = Store.new(vault_name)
|
|
71
|
+
new_blob = SyncBundle.pack_v3(store, owner: handle, key_slots: key_slots)
|
|
72
|
+
client.push_vault(vault_name, new_blob)
|
|
73
|
+
|
|
74
|
+
$stdout.puts "Vault '#{vault_name}' is now a team vault."
|
|
75
|
+
$stdout.puts "Owner: @#{handle}"
|
|
76
|
+
$stdout.puts "\nNext: localvault team add @handle -v #{vault_name}"
|
|
77
|
+
rescue SyncBundle::UnpackError => e
|
|
78
|
+
$stderr.puts "Error: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
6
81
|
desc "list [VAULT]", "Show who has access to a vault"
|
|
7
82
|
method_option :vault, type: :string, aliases: "-v"
|
|
83
|
+
# List all users who have access to a vault.
|
|
84
|
+
#
|
|
85
|
+
# Checks sync-based key slots first; falls back to direct shares if no
|
|
86
|
+
# key slots exist. Displays member handles (key slots) or a share table
|
|
87
|
+
# with ID, recipient, status, and date.
|
|
8
88
|
def list(vault_name = nil)
|
|
9
89
|
unless Config.token
|
|
10
|
-
$stderr.puts "Error: Not
|
|
90
|
+
$stderr.puts "Error: Not logged in."
|
|
91
|
+
$stderr.puts
|
|
92
|
+
$stderr.puts " localvault login YOUR_TOKEN"
|
|
93
|
+
$stderr.puts
|
|
94
|
+
$stderr.puts "Get your token at: https://inventlist.com/settings"
|
|
95
|
+
$stderr.puts "New to InventList? Sign up free at https://inventlist.com"
|
|
96
|
+
$stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
|
|
11
97
|
return
|
|
12
98
|
end
|
|
13
99
|
|
|
@@ -43,11 +129,54 @@ module LocalVault
|
|
|
43
129
|
$stderr.puts "Error: #{e.message}"
|
|
44
130
|
end
|
|
45
131
|
|
|
132
|
+
desc "verify HANDLE", "Check if a user exists and has a public key for sharing"
|
|
133
|
+
# Verify a user's handle and public key status before adding them.
|
|
134
|
+
#
|
|
135
|
+
# Checks InventList for the handle and whether they have a published
|
|
136
|
+
# X25519 public key. Does not modify anything.
|
|
137
|
+
def verify(handle)
|
|
138
|
+
unless Config.token
|
|
139
|
+
$stderr.puts "Error: Not logged in."
|
|
140
|
+
$stderr.puts "\n localvault login YOUR_TOKEN\n"
|
|
141
|
+
$stderr.puts "Get your token at: https://inventlist.com/settings"
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
handle = handle.delete_prefix("@")
|
|
146
|
+
client = ApiClient.new(token: Config.token)
|
|
147
|
+
result = client.get_public_key(handle)
|
|
148
|
+
pub_key = result["public_key"]
|
|
149
|
+
|
|
150
|
+
if pub_key && !pub_key.empty?
|
|
151
|
+
fingerprint = pub_key.length > 12 ? "#{pub_key[0..7]}...#{pub_key[-4..]}" : pub_key
|
|
152
|
+
$stdout.puts "@#{handle} — public key published"
|
|
153
|
+
$stdout.puts " Fingerprint: #{fingerprint}"
|
|
154
|
+
$stdout.puts " Ready for: localvault team add @#{handle} -v VAULT"
|
|
155
|
+
else
|
|
156
|
+
$stderr.puts "@#{handle} exists but has no public key published."
|
|
157
|
+
$stderr.puts "They need to run: localvault login TOKEN"
|
|
158
|
+
end
|
|
159
|
+
rescue ApiClient::ApiError => e
|
|
160
|
+
if e.status == 404
|
|
161
|
+
$stderr.puts "Error: @#{handle} not found on InventList."
|
|
162
|
+
else
|
|
163
|
+
$stderr.puts "Error: #{e.message}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
46
167
|
desc "add HANDLE", "Add a teammate to a synced vault via key slot"
|
|
47
168
|
method_option :vault, type: :string, aliases: "-v"
|
|
169
|
+
method_option :scope, type: :array, desc: "Groups or keys to share (omit for full access)"
|
|
170
|
+
# Grant a user access to a synced vault by creating a key slot.
|
|
171
|
+
#
|
|
172
|
+
# With --scope, creates a per-member encrypted blob containing only the
|
|
173
|
+
# specified keys. Without --scope, grants full vault access.
|
|
174
|
+
# Requires the vault to be a team vault (run team init first).
|
|
48
175
|
def add(handle)
|
|
49
176
|
unless Config.token
|
|
50
|
-
$stderr.puts "Error: Not logged in.
|
|
177
|
+
$stderr.puts "Error: Not logged in."
|
|
178
|
+
$stderr.puts "\n localvault login YOUR_TOKEN\n"
|
|
179
|
+
$stderr.puts "Get your token at: https://inventlist.com/settings"
|
|
51
180
|
return
|
|
52
181
|
end
|
|
53
182
|
|
|
@@ -58,55 +187,99 @@ module LocalVault
|
|
|
58
187
|
|
|
59
188
|
handle = handle.delete_prefix("@")
|
|
60
189
|
vault_name = options[:vault] || Config.default_vault
|
|
190
|
+
scope_list = options[:scope]
|
|
61
191
|
|
|
62
|
-
# Need master key from session
|
|
63
192
|
master_key = SessionCache.get(vault_name)
|
|
64
193
|
unless master_key
|
|
65
194
|
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
66
195
|
return
|
|
67
196
|
end
|
|
68
197
|
|
|
69
|
-
# Fetch recipient's public key
|
|
70
198
|
client = ApiClient.new(token: Config.token)
|
|
199
|
+
|
|
200
|
+
# Load existing bundle — must be a team vault (v3)
|
|
201
|
+
existing_blob = client.pull_vault(vault_name) rescue nil
|
|
202
|
+
unless existing_blob.is_a?(String) && !existing_blob.empty?
|
|
203
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
data = SyncBundle.unpack(existing_blob)
|
|
208
|
+
unless data[:owner]
|
|
209
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
|
|
210
|
+
return
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Only owner can add members
|
|
214
|
+
unless data[:owner] == Config.inventlist_handle
|
|
215
|
+
$stderr.puts "Error: Only the vault owner (@#{data[:owner]}) can manage team access."
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
key_slots = data[:key_slots].is_a?(Hash) ? data[:key_slots] : {}
|
|
220
|
+
|
|
221
|
+
# Check if member already has full access
|
|
222
|
+
if key_slots.key?(handle) && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].nil?
|
|
223
|
+
if scope_list
|
|
224
|
+
$stdout.puts "@#{handle} already has full vault access."
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Fetch recipient's public key
|
|
71
230
|
result = client.get_public_key(handle)
|
|
72
231
|
pub_key = result["public_key"]
|
|
73
|
-
|
|
74
232
|
unless pub_key && !pub_key.empty?
|
|
75
233
|
$stderr.puts "Error: @#{handle} has no public key published."
|
|
76
234
|
return
|
|
77
235
|
end
|
|
78
236
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
237
|
+
if scope_list
|
|
238
|
+
# Accumulate scopes if member already has some
|
|
239
|
+
existing_scopes = key_slots.dig(handle, "scopes") || []
|
|
240
|
+
merged_scopes = (existing_scopes + scope_list).uniq
|
|
241
|
+
|
|
242
|
+
# Create per-member blob with filtered secrets
|
|
243
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
244
|
+
filtered = vault.filter(merged_scopes)
|
|
245
|
+
|
|
246
|
+
member_key = RbNaCl::Random.random_bytes(32)
|
|
247
|
+
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
248
|
+
|
|
249
|
+
begin
|
|
250
|
+
enc_key = KeySlot.create(member_key, pub_key)
|
|
251
|
+
rescue ArgumentError, KeySlot::DecryptionError => e
|
|
252
|
+
$stderr.puts "Error: @#{handle}'s public key is invalid: #{e.message}"
|
|
253
|
+
return
|
|
254
|
+
end
|
|
96
255
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
256
|
+
key_slots[handle] = {
|
|
257
|
+
"pub" => pub_key,
|
|
258
|
+
"enc_key" => enc_key,
|
|
259
|
+
"scopes" => merged_scopes,
|
|
260
|
+
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
# Full vault access — encrypt master key directly
|
|
264
|
+
begin
|
|
265
|
+
enc_key = KeySlot.create(master_key, pub_key)
|
|
266
|
+
rescue ArgumentError, KeySlot::DecryptionError => e
|
|
267
|
+
$stderr.puts "Error: @#{handle}'s public key is invalid: #{e.message}"
|
|
268
|
+
return
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
key_slots[handle] = { "pub" => pub_key, "enc_key" => enc_key, "scopes" => nil, "blob" => nil }
|
|
102
272
|
end
|
|
103
273
|
|
|
104
|
-
# Pack and push
|
|
105
274
|
store = Store.new(vault_name)
|
|
106
|
-
blob = SyncBundle.
|
|
275
|
+
blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
|
|
107
276
|
client.push_vault(vault_name, blob)
|
|
108
277
|
|
|
109
|
-
|
|
278
|
+
if scope_list
|
|
279
|
+
$stdout.puts "Added @#{handle} to vault '#{vault_name}' (scopes: #{key_slots[handle]["scopes"].join(", ")})."
|
|
280
|
+
else
|
|
281
|
+
$stdout.puts "Added @#{handle} to vault '#{vault_name}'."
|
|
282
|
+
end
|
|
110
283
|
rescue ApiClient::ApiError => e
|
|
111
284
|
if e.status == 404
|
|
112
285
|
$stderr.puts "Error: @#{handle} not found or has no public key."
|
|
@@ -120,9 +293,22 @@ module LocalVault
|
|
|
120
293
|
desc "remove HANDLE", "Remove a person's access to a vault"
|
|
121
294
|
method_option :vault, type: :string, aliases: "-v"
|
|
122
295
|
method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
|
|
296
|
+
method_option :scope, type: :array, desc: "Remove specific scopes only (keeps other scopes)"
|
|
297
|
+
# Remove a user's access to a vault.
|
|
298
|
+
#
|
|
299
|
+
# Removes the user's key slot and pushes the updated bundle. With +--rotate+,
|
|
300
|
+
# re-encrypts the vault with a new master key and recreates all remaining
|
|
301
|
+
# key slots for full cryptographic revocation. Falls back to revoking a
|
|
302
|
+
# direct share if no key slots exist.
|
|
123
303
|
def remove(handle)
|
|
124
304
|
unless Config.token
|
|
125
|
-
$stderr.puts "Error: Not
|
|
305
|
+
$stderr.puts "Error: Not logged in."
|
|
306
|
+
$stderr.puts
|
|
307
|
+
$stderr.puts " localvault login YOUR_TOKEN"
|
|
308
|
+
$stderr.puts
|
|
309
|
+
$stderr.puts "Get your token at: https://inventlist.com/settings"
|
|
310
|
+
$stderr.puts "New to InventList? Sign up free at https://inventlist.com"
|
|
311
|
+
$stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
|
|
126
312
|
return
|
|
127
313
|
end
|
|
128
314
|
|
|
@@ -131,9 +317,20 @@ module LocalVault
|
|
|
131
317
|
client = ApiClient.new(token: Config.token)
|
|
132
318
|
|
|
133
319
|
# Try sync-based key slot removal first
|
|
134
|
-
|
|
135
|
-
if key_slots && !key_slots.empty?
|
|
136
|
-
|
|
320
|
+
team_data = load_team_data(client, vault_name)
|
|
321
|
+
if team_data && team_data[:key_slots] && !team_data[:key_slots].empty?
|
|
322
|
+
# Must be a v3 team vault with owner
|
|
323
|
+
unless team_data[:owner]
|
|
324
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
|
|
325
|
+
return
|
|
326
|
+
end
|
|
327
|
+
unless team_data[:owner] == Config.inventlist_handle
|
|
328
|
+
$stderr.puts "Error: Only the vault owner (@#{team_data[:owner]}) can manage team access."
|
|
329
|
+
return
|
|
330
|
+
end
|
|
331
|
+
remove_key_slot(handle, vault_name, team_data[:key_slots], client,
|
|
332
|
+
rotate: options[:rotate], remove_scopes: options[:scope],
|
|
333
|
+
owner: team_data[:owner])
|
|
137
334
|
return
|
|
138
335
|
end
|
|
139
336
|
|
|
@@ -154,25 +351,154 @@ module LocalVault
|
|
|
154
351
|
$stderr.puts "Error: #{e.message}"
|
|
155
352
|
end
|
|
156
353
|
|
|
354
|
+
desc "rotate", "Re-encrypt a team vault with a new master key (no member changes)"
|
|
355
|
+
method_option :vault, type: :string, aliases: "-v"
|
|
356
|
+
# Re-key a team vault without adding or removing members.
|
|
357
|
+
#
|
|
358
|
+
# Prompts for a new passphrase, re-encrypts all secrets, and rebuilds
|
|
359
|
+
# all key slots. Useful for periodic key rotation.
|
|
360
|
+
def rotate
|
|
361
|
+
unless Config.token
|
|
362
|
+
$stderr.puts "Error: Not logged in."
|
|
363
|
+
return
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
vault_name = options[:vault] || Config.default_vault
|
|
367
|
+
client = ApiClient.new(token: Config.token)
|
|
368
|
+
|
|
369
|
+
team_data = load_team_data(client, vault_name)
|
|
370
|
+
unless team_data && team_data[:key_slots] && !team_data[:key_slots].empty?
|
|
371
|
+
$stderr.puts "Error: Vault '#{vault_name}' has no team access. Nothing to rotate."
|
|
372
|
+
return
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
unless team_data[:owner]
|
|
376
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
|
|
377
|
+
return
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
unless team_data[:owner] == Config.inventlist_handle
|
|
381
|
+
$stderr.puts "Error: Only the vault owner (@#{team_data[:owner]}) can rotate keys."
|
|
382
|
+
return
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
key_slots = team_data[:key_slots]
|
|
386
|
+
vault_owner = team_data[:owner]
|
|
387
|
+
|
|
388
|
+
master_key = SessionCache.get(vault_name)
|
|
389
|
+
unless master_key
|
|
390
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked."
|
|
391
|
+
return
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
|
|
395
|
+
if passphrase.nil? || passphrase.empty?
|
|
396
|
+
$stderr.puts "Error: Passphrase cannot be empty."
|
|
397
|
+
return
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
401
|
+
secrets = vault.all
|
|
402
|
+
store = Store.new(vault_name)
|
|
403
|
+
|
|
404
|
+
new_salt = Crypto.generate_salt
|
|
405
|
+
new_master_key = Crypto.derive_master_key(passphrase, new_salt)
|
|
406
|
+
|
|
407
|
+
store.write_encrypted(Crypto.encrypt(JSON.generate(secrets), new_master_key))
|
|
408
|
+
store.create_meta!(salt: new_salt)
|
|
409
|
+
|
|
410
|
+
new_slots = {}
|
|
411
|
+
key_slots.each do |h, slot|
|
|
412
|
+
next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
|
|
413
|
+
if slot["scopes"].is_a?(Array)
|
|
414
|
+
filtered = vault.filter(slot["scopes"])
|
|
415
|
+
member_key = RbNaCl::Random.random_bytes(32)
|
|
416
|
+
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
417
|
+
new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(member_key, slot["pub"]), "scopes" => slot["scopes"], "blob" => Base64.strict_encode64(encrypted_blob) }
|
|
418
|
+
else
|
|
419
|
+
new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
blob = SyncBundle.pack_v3(store, owner: vault_owner, key_slots: new_slots)
|
|
424
|
+
client.push_vault(vault_name, blob)
|
|
425
|
+
SessionCache.set(vault_name, new_master_key)
|
|
426
|
+
|
|
427
|
+
$stdout.puts "Vault '#{vault_name}' re-encrypted with new master key."
|
|
428
|
+
$stdout.puts "#{new_slots.size} member(s) updated."
|
|
429
|
+
rescue ApiClient::ApiError => e
|
|
430
|
+
$stderr.puts "Error: #{e.message}"
|
|
431
|
+
end
|
|
432
|
+
|
|
157
433
|
private
|
|
158
434
|
|
|
435
|
+
def prompt_passphrase(msg = "Passphrase: ")
|
|
436
|
+
IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
|
|
437
|
+
rescue Interrupt
|
|
438
|
+
$stderr.puts
|
|
439
|
+
""
|
|
440
|
+
end
|
|
441
|
+
|
|
159
442
|
def load_key_slots(client, vault_name)
|
|
443
|
+
data = load_team_data(client, vault_name)
|
|
444
|
+
data ? data[:key_slots] : nil
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Load full bundle data including owner. Returns nil if no remote or not a team vault.
|
|
448
|
+
def load_team_data(client, vault_name)
|
|
160
449
|
return nil unless client.respond_to?(:pull_vault)
|
|
161
450
|
blob = client.pull_vault(vault_name)
|
|
162
451
|
return nil unless blob.is_a?(String) && !blob.empty?
|
|
163
452
|
data = SyncBundle.unpack(blob)
|
|
164
|
-
|
|
165
|
-
|
|
453
|
+
return nil unless data[:key_slots].is_a?(Hash)
|
|
454
|
+
data
|
|
166
455
|
rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
|
|
167
456
|
nil
|
|
168
457
|
end
|
|
169
458
|
|
|
170
|
-
|
|
459
|
+
# Remove a member's key slot, optionally rotating the vault master key.
|
|
460
|
+
# Supports partial scope removal via remove_scopes.
|
|
461
|
+
def remove_key_slot(handle, vault_name, key_slots, client, rotate: false, remove_scopes: nil, owner: nil)
|
|
462
|
+
owner ||= Config.inventlist_handle
|
|
171
463
|
unless key_slots.key?(handle)
|
|
172
464
|
$stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
|
|
173
465
|
return
|
|
174
466
|
end
|
|
175
467
|
|
|
468
|
+
store = Store.new(vault_name)
|
|
469
|
+
|
|
470
|
+
# Partial scope removal
|
|
471
|
+
if remove_scopes && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].is_a?(Array)
|
|
472
|
+
remaining = key_slots[handle]["scopes"] - remove_scopes
|
|
473
|
+
if remaining.empty?
|
|
474
|
+
# Last scope removed — remove member entirely
|
|
475
|
+
key_slots.delete(handle)
|
|
476
|
+
$stdout.puts "Removed @#{handle} from vault '#{vault_name}' (last scope removed)."
|
|
477
|
+
else
|
|
478
|
+
# Rebuild blob with remaining scopes
|
|
479
|
+
master_key = SessionCache.get(vault_name)
|
|
480
|
+
if master_key
|
|
481
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
482
|
+
filtered = vault.filter(remaining)
|
|
483
|
+
member_key = RbNaCl::Random.random_bytes(32)
|
|
484
|
+
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
485
|
+
enc_key = KeySlot.create(member_key, key_slots[handle]["pub"])
|
|
486
|
+
key_slots[handle] = {
|
|
487
|
+
"pub" => key_slots[handle]["pub"],
|
|
488
|
+
"enc_key" => enc_key,
|
|
489
|
+
"scopes" => remaining,
|
|
490
|
+
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
491
|
+
}
|
|
492
|
+
end
|
|
493
|
+
$stdout.puts "Removed scope(s) #{remove_scopes.join(", ")} from @#{handle}. Remaining: #{remaining.join(", ")}"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
497
|
+
client.push_vault(vault_name, blob)
|
|
498
|
+
return
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Full member removal
|
|
176
502
|
valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
|
|
177
503
|
if handle == Config.inventlist_handle && valid_slots.size <= 1
|
|
178
504
|
$stderr.puts "Error: Cannot remove yourself — you are the only member."
|
|
@@ -180,7 +506,6 @@ module LocalVault
|
|
|
180
506
|
end
|
|
181
507
|
|
|
182
508
|
key_slots.delete(handle)
|
|
183
|
-
store = Store.new(vault_name)
|
|
184
509
|
|
|
185
510
|
if rotate
|
|
186
511
|
master_key = SessionCache.get(vault_name)
|
|
@@ -189,30 +514,47 @@ module LocalVault
|
|
|
189
514
|
return
|
|
190
515
|
end
|
|
191
516
|
|
|
192
|
-
#
|
|
517
|
+
# Prompt for new passphrase
|
|
518
|
+
passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
|
|
519
|
+
if passphrase.nil? || passphrase.empty?
|
|
520
|
+
$stderr.puts "Error: Passphrase cannot be empty."
|
|
521
|
+
return
|
|
522
|
+
end
|
|
523
|
+
|
|
193
524
|
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
194
525
|
secrets = vault.all
|
|
195
526
|
|
|
196
527
|
new_salt = Crypto.generate_salt
|
|
197
|
-
new_master_key = Crypto.derive_master_key(
|
|
528
|
+
new_master_key = Crypto.derive_master_key(passphrase, new_salt)
|
|
198
529
|
|
|
199
|
-
# Re-encrypt secrets with new master key
|
|
200
530
|
new_json = JSON.generate(secrets)
|
|
201
531
|
new_encrypted = Crypto.encrypt(new_json, new_master_key)
|
|
202
532
|
store.write_encrypted(new_encrypted)
|
|
203
533
|
store.create_meta!(salt: new_salt)
|
|
204
534
|
|
|
205
|
-
# Re-create key slots for remaining members with new master key
|
|
206
535
|
new_slots = {}
|
|
207
536
|
key_slots.each do |h, slot|
|
|
208
537
|
next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
|
|
209
|
-
|
|
538
|
+
if slot["scopes"].is_a?(Array)
|
|
539
|
+
# Scoped member — rebuild per-member blob
|
|
540
|
+
filtered = vault.filter(slot["scopes"])
|
|
541
|
+
member_key = RbNaCl::Random.random_bytes(32)
|
|
542
|
+
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
543
|
+
new_slots[h] = {
|
|
544
|
+
"pub" => slot["pub"],
|
|
545
|
+
"enc_key" => KeySlot.create(member_key, slot["pub"]),
|
|
546
|
+
"scopes" => slot["scopes"],
|
|
547
|
+
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
548
|
+
}
|
|
549
|
+
else
|
|
550
|
+
# Full-access member
|
|
551
|
+
new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
|
|
552
|
+
end
|
|
210
553
|
end
|
|
211
554
|
|
|
212
|
-
blob = SyncBundle.
|
|
555
|
+
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
|
|
213
556
|
client.push_vault(vault_name, blob)
|
|
214
557
|
|
|
215
|
-
# Only cache new master key if the caller is still a member
|
|
216
558
|
if new_slots.key?(Config.inventlist_handle)
|
|
217
559
|
SessionCache.set(vault_name, new_master_key)
|
|
218
560
|
else
|
|
@@ -222,7 +564,7 @@ module LocalVault
|
|
|
222
564
|
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
223
565
|
$stdout.puts "Vault re-encrypted with new master key (rotated)."
|
|
224
566
|
else
|
|
225
|
-
blob = SyncBundle.
|
|
567
|
+
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
226
568
|
client.push_vault(vault_name, blob)
|
|
227
569
|
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
228
570
|
end
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -8,6 +8,61 @@ module LocalVault
|
|
|
8
8
|
class CLI < Thor
|
|
9
9
|
class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
|
|
10
10
|
|
|
11
|
+
def self.help(shell, subcommand = false)
|
|
12
|
+
shell.say ""
|
|
13
|
+
shell.say "LocalVault — encrypted local secrets vault with MCP support for AI agents"
|
|
14
|
+
shell.say " https://inventlist.com/tools/localvault"
|
|
15
|
+
shell.say ""
|
|
16
|
+
shell.say "GETTING STARTED"
|
|
17
|
+
shell.say " localvault login [TOKEN] Log in to InventList (enables sync + team features)"
|
|
18
|
+
shell.say " localvault init [NAME] Create a new encrypted vault"
|
|
19
|
+
shell.say " localvault demo Create a demo vault to explore commands"
|
|
20
|
+
shell.say ""
|
|
21
|
+
shell.say "SECRETS"
|
|
22
|
+
shell.say " localvault set KEY VALUE Store a secret"
|
|
23
|
+
shell.say " localvault get KEY Retrieve a secret"
|
|
24
|
+
shell.say " localvault show Display all secrets (masked by default)"
|
|
25
|
+
shell.say " localvault list List secret key names"
|
|
26
|
+
shell.say " localvault delete KEY Remove a secret"
|
|
27
|
+
shell.say " localvault import FILE Bulk-import from .env / .json / .yml"
|
|
28
|
+
shell.say " localvault env Export as shell variable assignments"
|
|
29
|
+
shell.say " localvault exec -- CMD Run a command with secrets injected"
|
|
30
|
+
shell.say ""
|
|
31
|
+
shell.say "VAULT MANAGEMENT"
|
|
32
|
+
shell.say " localvault vaults List all vaults"
|
|
33
|
+
shell.say " localvault switch [VAULT] Switch default vault"
|
|
34
|
+
shell.say " localvault rekey [NAME] Change vault passphrase"
|
|
35
|
+
shell.say " localvault unlock Cache passphrase for session"
|
|
36
|
+
shell.say " localvault lock [NAME] Clear cached passphrase"
|
|
37
|
+
shell.say " localvault reset [NAME] Destroy and reinitialize a vault"
|
|
38
|
+
shell.say " localvault rename OLD NEW Rename a secret key"
|
|
39
|
+
shell.say " localvault copy KEY --to V Copy a secret to another vault"
|
|
40
|
+
shell.say ""
|
|
41
|
+
shell.say "TEAM & SYNC (requires localvault login)"
|
|
42
|
+
shell.say " localvault sync push [NAME] Push vault to cloud"
|
|
43
|
+
shell.say " localvault sync pull [NAME] Pull vault from cloud"
|
|
44
|
+
shell.say " localvault sync status Show sync status"
|
|
45
|
+
shell.say " localvault team init Convert vault to team vault (required before team add)"
|
|
46
|
+
shell.say " localvault team add HANDLE Add teammate (use --scope KEY... for partial access)"
|
|
47
|
+
shell.say " localvault team remove HANDLE Remove teammate (--scope KEY to strip one key, --rotate to re-key)"
|
|
48
|
+
shell.say " localvault team list List vault members and their access"
|
|
49
|
+
shell.say " localvault team rotate Re-key vault, keep all members"
|
|
50
|
+
shell.say " localvault keys generate Generate X25519 identity keypair"
|
|
51
|
+
shell.say " localvault keys publish Publish public key so others can share vaults with you"
|
|
52
|
+
shell.say " localvault keys show Display your current public key"
|
|
53
|
+
shell.say ""
|
|
54
|
+
shell.say "AI / MCP"
|
|
55
|
+
shell.say " localvault install-mcp Configure MCP server in your AI tool"
|
|
56
|
+
shell.say " localvault mcp Start MCP server (stdio)"
|
|
57
|
+
shell.say ""
|
|
58
|
+
shell.say "OTHER"
|
|
59
|
+
shell.say " localvault login --status Show current login status"
|
|
60
|
+
shell.say " localvault logout Log out"
|
|
61
|
+
shell.say " localvault version Print version"
|
|
62
|
+
shell.say " localvault help [COMMAND] Full help for any command"
|
|
63
|
+
shell.say ""
|
|
64
|
+
end
|
|
65
|
+
|
|
11
66
|
desc "init [NAME]", "Create a new vault"
|
|
12
67
|
def init(name = nil)
|
|
13
68
|
vault_name = name || Config.default_vault
|
|
@@ -487,8 +542,15 @@ module LocalVault
|
|
|
487
542
|
end
|
|
488
543
|
|
|
489
544
|
unless token
|
|
490
|
-
$stdout.puts "Usage: localvault login
|
|
545
|
+
$stdout.puts "Usage: localvault login YOUR_TOKEN"
|
|
546
|
+
$stdout.puts
|
|
491
547
|
$stdout.puts "Get your token at: https://inventlist.com/settings"
|
|
548
|
+
$stdout.puts "New to InventList? Sign up free at https://inventlist.com"
|
|
549
|
+
$stdout.puts
|
|
550
|
+
$stdout.puts "LocalVault sync and team features require a free InventList account."
|
|
551
|
+
$stdout.puts "Local vault encryption works without an account."
|
|
552
|
+
$stdout.puts
|
|
553
|
+
$stdout.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
|
|
492
554
|
return
|
|
493
555
|
end
|
|
494
556
|
|
|
@@ -547,7 +609,7 @@ module LocalVault
|
|
|
547
609
|
desc: "Recipient: @handle, team:HANDLE, or crew:SLUG"
|
|
548
610
|
def share(vault_name = nil)
|
|
549
611
|
unless Config.token
|
|
550
|
-
abort_with "Not
|
|
612
|
+
abort_with "Not logged in. Run: localvault login YOUR_TOKEN\n Get your token at: https://inventlist.com/settings"
|
|
551
613
|
return
|
|
552
614
|
end
|
|
553
615
|
|
|
@@ -590,7 +652,7 @@ module LocalVault
|
|
|
590
652
|
desc "receive", "Fetch and import vaults shared with you"
|
|
591
653
|
def receive
|
|
592
654
|
unless Config.token
|
|
593
|
-
abort_with "Not
|
|
655
|
+
abort_with "Not logged in. Run: localvault login YOUR_TOKEN\n Get your token at: https://inventlist.com/settings"
|
|
594
656
|
return
|
|
595
657
|
end
|
|
596
658
|
|
|
@@ -658,7 +720,7 @@ module LocalVault
|
|
|
658
720
|
desc "revoke SHARE_ID", "Revoke a vault share (stops future access)"
|
|
659
721
|
def revoke(share_id)
|
|
660
722
|
unless Config.token
|
|
661
|
-
abort_with "Not
|
|
723
|
+
abort_with "Not logged in. Run: localvault login YOUR_TOKEN\n Get your token at: https://inventlist.com/settings"
|
|
662
724
|
return
|
|
663
725
|
end
|
|
664
726
|
|