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.
@@ -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 connected. Run: localvault connect --token TOKEN --handle HANDLE"
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. Run: localvault login TOKEN"
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
- # Load existing key slots from remote
80
- existing_blob = client.pull_vault(vault_name) rescue nil
81
- key_slots = if existing_blob.is_a?(String) && !existing_blob.empty?
82
- data = SyncBundle.unpack(existing_blob)
83
- data[:key_slots].is_a?(Hash) ? data[:key_slots] : {}
84
- else
85
- {}
86
- end
87
-
88
- # Create key slot for recipient
89
- begin
90
- enc_key = KeySlot.create(master_key, pub_key)
91
- rescue ArgumentError, KeySlot::DecryptionError => e
92
- $stderr.puts "Error: @#{handle}'s public key is invalid: #{e.message}"
93
- return
94
- end
95
- key_slots[handle] = { "pub" => pub_key, "enc_key" => enc_key }
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
- # Ensure owner slot exists too
98
- owner_handle = Config.inventlist_handle
99
- unless key_slots.key?(owner_handle)
100
- owner_pub = Identity.public_key
101
- key_slots[owner_handle] = { "pub" => owner_pub, "enc_key" => KeySlot.create(master_key, owner_pub) }
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.pack(store, key_slots: key_slots)
275
+ blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
107
276
  client.push_vault(vault_name, blob)
108
277
 
109
- $stdout.puts "Added @#{handle} to vault '#{vault_name}'."
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 connected. Run: localvault connect --token TOKEN --handle HANDLE"
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
- key_slots = load_key_slots(client, vault_name)
135
- if key_slots && !key_slots.empty?
136
- remove_key_slot(handle, vault_name, key_slots, client, rotate: options[:rotate])
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
- slots = data[:key_slots]
165
- slots.is_a?(Hash) ? slots : nil
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
- def remove_key_slot(handle, vault_name, key_slots, client, rotate: false)
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
- # Decrypt current secrets, generate new master key, re-encrypt
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(SecureRandom.hex(32), new_salt)
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
- new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]) }
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.pack(store, key_slots: new_slots)
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.pack(store, key_slots: key_slots)
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
@@ -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 TOKEN"
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 connected. Run: localvault connect --token TOKEN --handle HANDLE"
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 connected. Run: localvault connect --token TOKEN --handle HANDLE"
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 connected. Run: localvault connect --token TOKEN --handle HANDLE"
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