localvault 1.2.4 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 27e49afd2aae0e73dc4e78e00d81765f77f0c7870a643db8c3549a99b777f27c
4
- data.tar.gz: 17cba7049d299d9581dceab6dd9c4a10a55214fafa7c08a51d793b05a39282e1
3
+ metadata.gz: 0b105245bc108c66fb889a043714a464b0d6c17c31b3546842c1b2e887c83898
4
+ data.tar.gz: c2d881d51515e62c29879bbc6dbee27c14bb7e19bb003c399ea5edd801931add
5
5
  SHA512:
6
- metadata.gz: 8b38a8ac1fc8774c0f505c65365e91db5a7ea2b7557a6bf437f43c807969be0c4521619f2eccdbc55c402c34e4f097342014c638d3691b5c5c265a2f6a1e0788
7
- data.tar.gz: 5d35c1ad8fa8573dcec3f9d6a267dead9024cf72cf92faaba4700ac15238d1c72b08d4c746499d1abe23dab92fd19c17cc6c69fb0667e4e94fc75afd770953ba
6
+ metadata.gz: da9d072b2bfd8633f687838590636a9982056d086bf8e95e0d085c8974e24e7b6edb8fca2ddca79612bfa3eabbd9fa3edeaadc5f86f96d9b31364cb36410f1bb
7
+ data.tar.gz: 66cc50037f18fd47f84d4e6f387904d066eddb0914ba438894c16c5974dd937fa67a729be59bee6df2bd002876c71c17e0a3a8bc7c96d09dfb05dab2f59fbf1f
data/README.md CHANGED
@@ -105,19 +105,25 @@ localvault exec -- rails server
105
105
  | `sync status` | Show sync state for all vaults |
106
106
  | `config set server URL` | Point at a custom server (default: inventlist.com) |
107
107
 
108
- ### Team Sharing (v1.2.0)
108
+ ### Team Sharing (v1.3.0)
109
+
110
+ Vault-level operations live under `team`. Person operations (the `@handle`
111
+ already signals a person) are top-level.
109
112
 
110
113
  | Command | Description |
111
114
  |---------|-------------|
112
115
  | `team init` | Convert vault to team vault (sets you as owner, SyncBundle v3) |
113
- | `team verify @handle` | Check if a user has a published public key (dry-run) |
114
- | `team add @handle` | Add teammate with full vault access |
115
- | `team add @handle --scope KEY...` | Add teammate with access to specific keys only |
116
- | `team remove @handle` | Remove teammate's access |
117
- | `team remove @handle --scope KEY` | Remove one scoped key (keeps other scopes) |
118
- | `team remove @handle --rotate` | Full revocation + re-encrypt with new passphrase |
119
116
  | `team list` | List vault members |
120
117
  | `team rotate` | Re-key vault with new passphrase, keep all members |
118
+ | `verify @handle` | Check if a user has a published public key (dry-run) |
119
+ | `add @handle` | Add teammate with full vault access |
120
+ | `add @handle --scope KEY...` | Add teammate with access to specific keys only |
121
+ | `remove @handle` | Remove teammate's access |
122
+ | `remove @handle --scope KEY` | Remove one scoped key (keeps other scopes) |
123
+ | `remove @handle --rotate` | Full revocation + re-encrypt with new passphrase |
124
+
125
+ The `team add`, `team remove`, and `team verify` aliases still work for
126
+ backward compatibility but the top-level forms are preferred.
121
127
 
122
128
  ### Keys
123
129
 
@@ -168,13 +174,13 @@ Share vault access with teammates using X25519 asymmetric encryption. The server
168
174
  localvault team init -v production
169
175
 
170
176
  # 2. Verify teammate has a published key
171
- localvault team verify @alice
177
+ localvault verify @alice
172
178
 
173
179
  # 3. Add with full access
174
- localvault team add @alice -v production
180
+ localvault add @alice -v production
175
181
 
176
182
  # 4. Or scoped — they only see specific keys
177
- localvault team add @bob -v production --scope STRIPE_KEY WEBHOOK_SECRET
183
+ localvault add @bob -v production --scope STRIPE_KEY WEBHOOK_SECRET
178
184
 
179
185
  # 5. When Alice pulls, auto-unlocks via her identity key
180
186
  # (on Alice's machine)
@@ -190,7 +196,7 @@ localvault sync push production
190
196
  localvault team rotate -v production
191
197
 
192
198
  # 8. Full revocation + re-key
193
- localvault team remove @alice -v production --rotate
199
+ localvault remove @alice -v production --rotate
194
200
  ```
195
201
 
196
202
  **Prerequisites:** Teammates must have a published public key. `localvault login` does this automatically, or: `localvault keys generate && localvault keys publish`.
@@ -4,13 +4,18 @@ require "securerandom"
4
4
  module LocalVault
5
5
  class CLI
6
6
  class Team < Thor
7
- desc "init", "Initialize a vault as a team vault (sets you as owner)"
7
+ include LocalVault::CLI::TeamHelpers
8
+
9
+ desc "init [VAULT]", "Initialize a vault as a team vault (sets you as owner)"
8
10
  method_option :vault, type: :string, aliases: "-v"
9
11
  # Initialize a vault as a team vault with you as the owner.
10
12
  #
11
13
  # This is the explicit transition from personal sync to team-shared sync.
12
14
  # Creates the owner's key slot and bumps the bundle to v3.
13
- def init
15
+ #
16
+ # Accepts the vault name as either a positional argument
17
+ # (`team init intellectaco`) or via --vault/-v.
18
+ def init(vault_name = nil)
14
19
  unless Config.token
15
20
  $stderr.puts "Error: Not logged in."
16
21
  $stderr.puts "\n localvault login YOUR_TOKEN\n"
@@ -23,12 +28,12 @@ module LocalVault
23
28
  return
24
29
  end
25
30
 
26
- vault_name = options[:vault] || Config.default_vault
31
+ vault_name ||= options[:vault] || Config.default_vault
27
32
  handle = Config.inventlist_handle
28
33
 
29
34
  master_key = SessionCache.get(vault_name)
30
35
  unless master_key
31
- $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
36
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault unlock -v #{vault_name}"
32
37
  return
33
38
  end
34
39
 
@@ -73,7 +78,7 @@ module LocalVault
73
78
 
74
79
  $stdout.puts "Vault '#{vault_name}' is now a team vault."
75
80
  $stdout.puts "Owner: @#{handle}"
76
- $stdout.puts "\nNext: localvault team add @handle -v #{vault_name}"
81
+ $stdout.puts "\nNext: localvault add @handle -v #{vault_name}"
77
82
  rescue SyncBundle::UnpackError => e
78
83
  $stderr.puts "Error: #{e.message}"
79
84
  end
@@ -129,249 +134,22 @@ module LocalVault
129
134
  $stderr.puts "Error: #{e.message}"
130
135
  end
131
136
 
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/@YOUR_HANDLE/edit#developer"
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
-
167
- desc "add HANDLE", "Add a teammate to a synced vault via key slot"
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).
175
- def add(handle)
176
- unless Config.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/@YOUR_HANDLE/edit#developer"
180
- return
181
- end
182
-
183
- unless Identity.exists?
184
- $stderr.puts "Error: No keypair found. Run: localvault keygen"
185
- return
186
- end
187
-
188
- target = handle
189
- vault_name = options[:vault] || Config.default_vault
190
- scope_list = options[:scope]
191
-
192
- master_key = SessionCache.get(vault_name)
193
- unless master_key
194
- $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
195
- return
196
- end
197
-
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
- unless data[:owner] == Config.inventlist_handle
214
- $stderr.puts "Error: Only the vault owner (@#{data[:owner]}) can manage team access."
215
- return
216
- end
217
-
218
- key_slots = data[:key_slots].is_a?(Hash) ? data[:key_slots] : {}
219
-
220
- # Resolve recipients — single @handle, team:HANDLE, or crew:SLUG
221
- recipients = resolve_add_recipients(client, target)
222
- if recipients.empty?
223
- $stderr.puts "Error: No recipients with public keys found for '#{target}'"
224
- return
225
- end
226
-
227
- added = 0
228
- recipients.each do |member_handle, pub_key|
229
- next if member_handle == Config.inventlist_handle # skip self
230
-
231
- # Skip if already has full access
232
- if key_slots.key?(member_handle) && key_slots[member_handle].is_a?(Hash) && key_slots[member_handle]["scopes"].nil?
233
- $stdout.puts "@#{member_handle} already has full vault access." if scope_list
234
- next
235
- end
236
-
237
- if scope_list
238
- existing_scopes = key_slots.dig(member_handle, "scopes") || []
239
- merged_scopes = (existing_scopes + scope_list).uniq
240
-
241
- vault = Vault.new(name: vault_name, master_key: master_key)
242
- filtered = vault.filter(merged_scopes)
243
-
244
- member_key = RbNaCl::Random.random_bytes(32)
245
- encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
246
-
247
- begin
248
- enc_key = KeySlot.create(member_key, pub_key)
249
- rescue ArgumentError, KeySlot::DecryptionError => e
250
- $stderr.puts "Error: @#{member_handle}'s public key is invalid: #{e.message}"
251
- next
252
- end
253
-
254
- key_slots[member_handle] = {
255
- "pub" => pub_key, "enc_key" => enc_key,
256
- "scopes" => merged_scopes,
257
- "blob" => Base64.strict_encode64(encrypted_blob)
258
- }
259
- else
260
- begin
261
- enc_key = KeySlot.create(master_key, pub_key)
262
- rescue ArgumentError, KeySlot::DecryptionError => e
263
- $stderr.puts "Error: @#{member_handle}'s public key is invalid: #{e.message}"
264
- next
265
- end
266
-
267
- key_slots[member_handle] = { "pub" => pub_key, "enc_key" => enc_key, "scopes" => nil, "blob" => nil }
268
- end
269
- added += 1
270
- end
271
-
272
- if added == 0
273
- $stdout.puts "No new members added."
274
- return
275
- end
276
-
277
- store = Store.new(vault_name)
278
- blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
279
- client.push_vault(vault_name, blob)
280
-
281
- if recipients.size == 1
282
- h = recipients.first[0]
283
- if scope_list
284
- $stdout.puts "Added @#{h} to vault '#{vault_name}' (scopes: #{key_slots[h]["scopes"].join(", ")})."
285
- else
286
- $stdout.puts "Added @#{h} to vault '#{vault_name}'."
287
- end
288
- else
289
- $stdout.puts "Added #{added} member(s) to vault '#{vault_name}'."
290
- end
291
- rescue ApiClient::ApiError => e
292
- if e.status == 404
293
- $stderr.puts "Error: @#{handle} not found or has no public key."
294
- else
295
- $stderr.puts "Error: #{e.message}"
296
- end
297
- rescue SyncBundle::UnpackError => e
298
- $stderr.puts "Error: #{e.message}"
299
- end
300
-
301
- desc "remove HANDLE", "Remove a person's access to a vault"
302
- method_option :vault, type: :string, aliases: "-v"
303
- method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
304
- method_option :scope, type: :array, desc: "Remove specific scopes only (keeps other scopes)"
305
- # Remove a user's access to a vault.
306
- #
307
- # Removes the user's key slot and pushes the updated bundle. With +--rotate+,
308
- # re-encrypts the vault with a new master key and recreates all remaining
309
- # key slots for full cryptographic revocation. Falls back to revoking a
310
- # direct share if no key slots exist.
311
- def remove(handle)
312
- unless Config.token
313
- $stderr.puts "Error: Not logged in."
314
- $stderr.puts
315
- $stderr.puts " localvault login YOUR_TOKEN"
316
- $stderr.puts
317
- $stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
318
- $stderr.puts "New to InventList? Sign up free at https://inventlist.com"
319
- $stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
320
- return
321
- end
322
-
323
- handle = handle.delete_prefix("@")
324
- vault_name = options[:vault] || Config.default_vault
325
- client = ApiClient.new(token: Config.token)
326
-
327
- # Try sync-based key slot removal first
328
- team_data = load_team_data(client, vault_name)
329
- if team_data && team_data[:key_slots] && !team_data[:key_slots].empty?
330
- # Must be a v3 team vault with owner
331
- unless team_data[:owner]
332
- $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
333
- return
334
- end
335
- unless team_data[:owner] == Config.inventlist_handle
336
- $stderr.puts "Error: Only the vault owner (@#{team_data[:owner]}) can manage team access."
337
- return
338
- end
339
- remove_key_slot(handle, vault_name, team_data[:key_slots], client,
340
- rotate: options[:rotate], remove_scopes: options[:scope],
341
- owner: team_data[:owner])
342
- return
343
- end
344
-
345
- # Fall back to direct share revocation
346
- result = client.sent_shares(vault_name: vault_name)
347
- share = (result["shares"] || []).find do |s|
348
- s["recipient_handle"] == handle && s["status"] != "revoked"
349
- end
350
-
351
- unless share
352
- $stderr.puts "Error: No active share found for @#{handle}."
353
- return
354
- end
355
-
356
- client.revoke_share(share["id"])
357
- $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
358
- rescue ApiClient::ApiError => e
359
- $stderr.puts "Error: #{e.message}"
360
- end
361
-
362
- desc "rotate", "Re-encrypt a team vault with a new master key (no member changes)"
137
+ desc "rotate [VAULT]", "Re-encrypt a team vault with a new master key (no member changes)"
363
138
  method_option :vault, type: :string, aliases: "-v"
364
139
  # Re-key a team vault without adding or removing members.
365
140
  #
366
141
  # Prompts for a new passphrase, re-encrypts all secrets, and rebuilds
367
142
  # all key slots. Useful for periodic key rotation.
368
- def rotate
143
+ #
144
+ # Accepts the vault name as either a positional argument
145
+ # (`team rotate intellectaco`) or via --vault/-v.
146
+ def rotate(vault_name = nil)
369
147
  unless Config.token
370
148
  $stderr.puts "Error: Not logged in."
371
149
  return
372
150
  end
373
151
 
374
- vault_name = options[:vault] || Config.default_vault
152
+ vault_name ||= options[:vault] || Config.default_vault
375
153
  client = ApiClient.new(token: Config.token)
376
154
 
377
155
  team_data = load_team_data(client, vault_name)
@@ -438,188 +216,40 @@ module LocalVault
438
216
  $stderr.puts "Error: #{e.message}"
439
217
  end
440
218
 
441
- private
442
-
443
- def prompt_passphrase(msg = "Passphrase: ")
444
- IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
445
- rescue Interrupt
446
- $stderr.puts
447
- ""
448
- end
449
-
450
- def load_key_slots(client, vault_name)
451
- data = load_team_data(client, vault_name)
452
- data ? data[:key_slots] : nil
453
- end
219
+ # ── Backward-compat delegates ─────────────────────────────────
220
+ # `add`, `remove`, and `verify` moved to the top-level CLI in v1.3.0
221
+ # (the leading @ in the handle already signals a person operation).
222
+ # The `team add/remove/verify` aliases keep existing scripts and muscle
223
+ # memory working — they forward to the top-level commands with the same
224
+ # options.
454
225
 
455
- # Load full bundle data including owner. Returns nil if no remote or not a team vault.
456
- def load_team_data(client, vault_name)
457
- return nil unless client.respond_to?(:pull_vault)
458
- blob = client.pull_vault(vault_name)
459
- return nil unless blob.is_a?(String) && !blob.empty?
460
- data = SyncBundle.unpack(blob)
461
- return nil unless data[:key_slots].is_a?(Hash)
462
- data
463
- rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
464
- nil
226
+ desc "add HANDLE", "(alias) Add a teammate prefer `localvault add @HANDLE`"
227
+ method_option :vault, type: :string, aliases: "-v"
228
+ method_option :scope, type: :array, desc: "Groups or keys to share (omit for full access)"
229
+ def add(handle)
230
+ LocalVault::CLI.new([], options, {}).add(handle)
465
231
  end
466
232
 
467
- # Resolve target into list of [handle, public_key] pairs.
468
- # Supports @handle, team:HANDLE, and crew:SLUG.
469
- def resolve_add_recipients(client, target)
470
- if target.start_with?("team:")
471
- team_handle = target.delete_prefix("team:")
472
- result = client.team_public_keys(team_handle)
473
- (result["members"] || [])
474
- .select { |m| m["handle"] && m["public_key"] && !m["public_key"].empty? }
475
- .map { |m| [m["handle"], m["public_key"]] }
476
- elsif target.start_with?("crew:")
477
- slug = target.delete_prefix("crew:")
478
- result = client.crew_public_keys(slug)
479
- (result["members"] || [])
480
- .select { |m| m["handle"] && m["public_key"] && !m["public_key"].empty? }
481
- .map { |m| [m["handle"], m["public_key"]] }
482
- else
483
- handle = target.delete_prefix("@")
484
- result = client.get_public_key(handle)
485
- pub_key = result["public_key"]
486
- return [] unless pub_key && !pub_key.empty?
487
- [[handle, pub_key]]
488
- end
489
- rescue ApiClient::ApiError => e
490
- $stderr.puts "Warning: #{e.message}"
491
- []
233
+ desc "remove HANDLE", "(alias) Remove a teammate prefer `localvault remove @HANDLE`"
234
+ method_option :vault, type: :string, aliases: "-v"
235
+ method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
236
+ method_option :scope, type: :array, desc: "Remove specific scopes only (keeps other scopes)"
237
+ def remove(handle)
238
+ LocalVault::CLI.new([], options, {}).remove(handle)
492
239
  end
493
240
 
494
- # Remove a member's key slot, optionally rotating the vault master key.
495
- # Supports partial scope removal via remove_scopes.
496
- def remove_key_slot(handle, vault_name, key_slots, client, rotate: false, remove_scopes: nil, owner: nil)
497
- owner ||= Config.inventlist_handle
498
- unless key_slots.key?(handle)
499
- $stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
500
- return
501
- end
502
-
503
- store = Store.new(vault_name)
504
-
505
- # Partial scope removal
506
- if remove_scopes && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].is_a?(Array)
507
- remaining = key_slots[handle]["scopes"] - remove_scopes
508
- if remaining.empty?
509
- # Last scope removed — remove member entirely
510
- key_slots.delete(handle)
511
- $stdout.puts "Removed @#{handle} from vault '#{vault_name}' (last scope removed)."
512
- else
513
- # Rebuild blob with remaining scopes
514
- master_key = SessionCache.get(vault_name)
515
- if master_key
516
- vault = Vault.new(name: vault_name, master_key: master_key)
517
- filtered = vault.filter(remaining)
518
- member_key = RbNaCl::Random.random_bytes(32)
519
- encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
520
- enc_key = KeySlot.create(member_key, key_slots[handle]["pub"])
521
- key_slots[handle] = {
522
- "pub" => key_slots[handle]["pub"],
523
- "enc_key" => enc_key,
524
- "scopes" => remaining,
525
- "blob" => Base64.strict_encode64(encrypted_blob)
526
- }
527
- end
528
- $stdout.puts "Removed scope(s) #{remove_scopes.join(", ")} from @#{handle}. Remaining: #{remaining.join(", ")}"
529
- end
530
-
531
- blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
532
- client.push_vault(vault_name, blob)
533
- return
534
- end
535
-
536
- # Full member removal
537
- valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
538
- if handle == Config.inventlist_handle && valid_slots.size <= 1
539
- $stderr.puts "Error: Cannot remove yourself — you are the only member."
540
- return
541
- end
542
-
543
- key_slots.delete(handle)
544
-
545
- if rotate
546
- master_key = SessionCache.get(vault_name)
547
- unless master_key
548
- $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
549
- return
550
- end
551
-
552
- # Prompt for new passphrase
553
- passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
554
- if passphrase.nil? || passphrase.empty?
555
- $stderr.puts "Error: Passphrase cannot be empty."
556
- return
557
- end
558
-
559
- vault = Vault.new(name: vault_name, master_key: master_key)
560
- secrets = vault.all
561
-
562
- new_salt = Crypto.generate_salt
563
- new_master_key = Crypto.derive_master_key(passphrase, new_salt)
564
-
565
- new_json = JSON.generate(secrets)
566
- new_encrypted = Crypto.encrypt(new_json, new_master_key)
567
- store.write_encrypted(new_encrypted)
568
- store.create_meta!(salt: new_salt)
569
-
570
- new_slots = {}
571
- key_slots.each do |h, slot|
572
- next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
573
- if slot["scopes"].is_a?(Array)
574
- # Scoped member — rebuild per-member blob
575
- filtered = vault.filter(slot["scopes"])
576
- member_key = RbNaCl::Random.random_bytes(32)
577
- encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
578
- new_slots[h] = {
579
- "pub" => slot["pub"],
580
- "enc_key" => KeySlot.create(member_key, slot["pub"]),
581
- "scopes" => slot["scopes"],
582
- "blob" => Base64.strict_encode64(encrypted_blob)
583
- }
584
- else
585
- # Full-access member
586
- new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
587
- end
588
- end
589
-
590
- blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
591
- client.push_vault(vault_name, blob)
592
-
593
- if new_slots.key?(Config.inventlist_handle)
594
- SessionCache.set(vault_name, new_master_key)
595
- else
596
- SessionCache.clear(vault_name)
597
- end
598
-
599
- $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
600
- $stdout.puts "Vault re-encrypted with new master key (rotated)."
601
- else
602
- blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
603
- client.push_vault(vault_name, blob)
604
- $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
605
- end
241
+ desc "verify HANDLE", "(alias) Verify a public key prefer `localvault verify @HANDLE`"
242
+ def verify(handle)
243
+ LocalVault::CLI.new([], options, {}).verify(handle)
606
244
  end
607
245
 
608
- def list_key_slots(vault_name, key_slots)
609
- my_handle = Config.inventlist_handle
610
- valid = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
611
-
612
- if valid.empty?
613
- $stdout.puts "No key slots for vault '#{vault_name}'."
614
- return
615
- end
246
+ private
616
247
 
617
- $stdout.puts "Vault: #{vault_name} — #{valid.size} member(s)"
618
- $stdout.puts
619
- valid.sort.each do |handle, slot|
620
- marker = handle == my_handle ? " (you)" : ""
621
- $stdout.puts " @#{handle}#{marker}"
622
- end
248
+ def prompt_passphrase(msg = "Passphrase: ")
249
+ IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
250
+ rescue Interrupt
251
+ $stderr.puts
252
+ ""
623
253
  end
624
254
  end
625
255
  end
@@ -0,0 +1,190 @@
1
+ require "json"
2
+ require "base64"
3
+ require "securerandom"
4
+
5
+ module LocalVault
6
+ class CLI < Thor
7
+ # Shared helpers for team-vault commands. Included by both CLI (for the
8
+ # top-level `add`/`remove`/`verify` commands) and CLI::Team (for `init`/
9
+ # `list`/`rotate` and the backward-compat delegators).
10
+ module TeamHelpers
11
+ private
12
+
13
+ def load_key_slots(client, vault_name)
14
+ data = load_team_data(client, vault_name)
15
+ data ? data[:key_slots] : nil
16
+ end
17
+
18
+ # Load full bundle data including owner. Returns nil if no remote or
19
+ # not a team vault.
20
+ def load_team_data(client, vault_name)
21
+ return nil unless client.respond_to?(:pull_vault)
22
+ blob = client.pull_vault(vault_name)
23
+ return nil unless blob.is_a?(String) && !blob.empty?
24
+ data = SyncBundle.unpack(blob)
25
+ return nil unless data[:key_slots].is_a?(Hash)
26
+ data
27
+ rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
28
+ nil
29
+ end
30
+
31
+ # Resolve target into list of [handle, public_key] pairs.
32
+ # Supports @handle, team:HANDLE, and crew:SLUG.
33
+ def resolve_add_recipients(client, target)
34
+ if target.start_with?("team:")
35
+ team_handle = target.delete_prefix("team:")
36
+ result = client.team_public_keys(team_handle)
37
+ (result["members"] || [])
38
+ .select { |m| m["handle"] && m["public_key"] && !m["public_key"].empty? }
39
+ .map { |m| [m["handle"], m["public_key"]] }
40
+ elsif target.start_with?("crew:")
41
+ slug = target.delete_prefix("crew:")
42
+ result = client.crew_public_keys(slug)
43
+ (result["members"] || [])
44
+ .select { |m| m["handle"] && m["public_key"] && !m["public_key"].empty? }
45
+ .map { |m| [m["handle"], m["public_key"]] }
46
+ else
47
+ handle = target.delete_prefix("@")
48
+ result = client.get_public_key(handle)
49
+ pub_key = result["public_key"]
50
+ return [] unless pub_key && !pub_key.empty?
51
+ [[handle, pub_key]]
52
+ end
53
+ rescue ApiClient::ApiError => e
54
+ $stderr.puts "Warning: #{e.message}"
55
+ []
56
+ end
57
+
58
+ # Remove a member's key slot, optionally rotating the vault master key.
59
+ # Supports partial scope removal via remove_scopes.
60
+ def remove_key_slot(handle, vault_name, key_slots, client, rotate: false, remove_scopes: nil, owner: nil)
61
+ owner ||= Config.inventlist_handle
62
+ unless key_slots.key?(handle)
63
+ $stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
64
+ return
65
+ end
66
+
67
+ store = Store.new(vault_name)
68
+
69
+ # Partial scope removal
70
+ if remove_scopes && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].is_a?(Array)
71
+ remaining = key_slots[handle]["scopes"] - remove_scopes
72
+ if remaining.empty?
73
+ # Last scope removed — remove member entirely
74
+ key_slots.delete(handle)
75
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}' (last scope removed)."
76
+ else
77
+ # Rebuild blob with remaining scopes
78
+ master_key = SessionCache.get(vault_name)
79
+ if master_key
80
+ vault = Vault.new(name: vault_name, master_key: master_key)
81
+ filtered = vault.filter(remaining)
82
+ member_key = RbNaCl::Random.random_bytes(32)
83
+ encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
84
+ enc_key = KeySlot.create(member_key, key_slots[handle]["pub"])
85
+ key_slots[handle] = {
86
+ "pub" => key_slots[handle]["pub"],
87
+ "enc_key" => enc_key,
88
+ "scopes" => remaining,
89
+ "blob" => Base64.strict_encode64(encrypted_blob)
90
+ }
91
+ end
92
+ $stdout.puts "Removed scope(s) #{remove_scopes.join(", ")} from @#{handle}. Remaining: #{remaining.join(", ")}"
93
+ end
94
+
95
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
96
+ client.push_vault(vault_name, blob)
97
+ return
98
+ end
99
+
100
+ # Full member removal
101
+ valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
102
+ if handle == Config.inventlist_handle && valid_slots.size <= 1
103
+ $stderr.puts "Error: Cannot remove yourself — you are the only member."
104
+ return
105
+ end
106
+
107
+ key_slots.delete(handle)
108
+
109
+ if rotate
110
+ master_key = SessionCache.get(vault_name)
111
+ unless master_key
112
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
113
+ return
114
+ end
115
+
116
+ # Prompt for new passphrase
117
+ passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
118
+ if passphrase.nil? || passphrase.empty?
119
+ $stderr.puts "Error: Passphrase cannot be empty."
120
+ return
121
+ end
122
+
123
+ vault = Vault.new(name: vault_name, master_key: master_key)
124
+ secrets = vault.all
125
+
126
+ new_salt = Crypto.generate_salt
127
+ new_master_key = Crypto.derive_master_key(passphrase, new_salt)
128
+
129
+ new_json = JSON.generate(secrets)
130
+ new_encrypted = Crypto.encrypt(new_json, new_master_key)
131
+ store.write_encrypted(new_encrypted)
132
+ store.create_meta!(salt: new_salt)
133
+
134
+ new_slots = {}
135
+ key_slots.each do |h, slot|
136
+ next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
137
+ if slot["scopes"].is_a?(Array)
138
+ # Scoped member — rebuild per-member blob
139
+ filtered = vault.filter(slot["scopes"])
140
+ member_key = RbNaCl::Random.random_bytes(32)
141
+ encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
142
+ new_slots[h] = {
143
+ "pub" => slot["pub"],
144
+ "enc_key" => KeySlot.create(member_key, slot["pub"]),
145
+ "scopes" => slot["scopes"],
146
+ "blob" => Base64.strict_encode64(encrypted_blob)
147
+ }
148
+ else
149
+ # Full-access member
150
+ new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
151
+ end
152
+ end
153
+
154
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
155
+ client.push_vault(vault_name, blob)
156
+
157
+ if new_slots.key?(Config.inventlist_handle)
158
+ SessionCache.set(vault_name, new_master_key)
159
+ else
160
+ SessionCache.clear(vault_name)
161
+ end
162
+
163
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
164
+ $stdout.puts "Vault re-encrypted with new master key (rotated)."
165
+ else
166
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
167
+ client.push_vault(vault_name, blob)
168
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
169
+ end
170
+ end
171
+
172
+ def list_key_slots(vault_name, key_slots)
173
+ my_handle = Config.inventlist_handle
174
+ valid = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
175
+
176
+ if valid.empty?
177
+ $stdout.puts "No key slots for vault '#{vault_name}'."
178
+ return
179
+ end
180
+
181
+ $stdout.puts "Vault: #{vault_name} — #{valid.size} member(s)"
182
+ $stdout.puts
183
+ valid.sort.each do |handle, slot|
184
+ marker = handle == my_handle ? " (you)" : ""
185
+ $stdout.puts " @#{handle}#{marker}"
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -45,9 +45,10 @@ module LocalVault
45
45
  shell.say " localvault sync push [NAME] Push vault to cloud"
46
46
  shell.say " localvault sync pull [NAME] Pull vault from cloud"
47
47
  shell.say " localvault sync status Show sync status"
48
- shell.say " localvault team init Convert vault to team vault (required before team add)"
49
- shell.say " localvault team add HANDLE Add teammate (use --scope KEY... for partial access)"
50
- shell.say " localvault team remove HANDLE Remove teammate (--scope KEY to strip one key, --rotate to re-key)"
48
+ shell.say " localvault team init Convert vault to team vault (required before add)"
49
+ shell.say " localvault verify @HANDLE Check if a person has a published public key"
50
+ shell.say " localvault add @HANDLE Add teammate (use --scope KEY... for partial access)"
51
+ shell.say " localvault remove @HANDLE Remove teammate (--scope KEY to strip one key, --rotate to re-key)"
51
52
  shell.say " localvault team list List vault members and their access"
52
53
  shell.say " localvault team rotate Re-key vault, keep all members"
53
54
  shell.say " localvault keys generate Generate X25519 identity keypair"
@@ -499,6 +500,9 @@ module LocalVault
499
500
 
500
501
  # ── Teams / sharing / sync ────────────────────────────────────
501
502
 
503
+ require_relative "cli/team_helpers"
504
+ include TeamHelpers
505
+
502
506
  require_relative "cli/keys"
503
507
  require_relative "cli/team"
504
508
  require_relative "cli/sync"
@@ -735,6 +739,240 @@ module LocalVault
735
739
  abort_with e.message
736
740
  end
737
741
 
742
+ # ── Person operations: add / remove / verify ──────────────────
743
+ # The leading `@` in the handle already signals these act on a person.
744
+ # Vault-level team operations (init/list/rotate) live under `localvault team`.
745
+
746
+ desc "verify HANDLE", "Check if a user has a published public key (for sharing)"
747
+ # Verify a user's handle and public key status before adding them.
748
+ #
749
+ # Checks InventList for the handle and whether they have a published
750
+ # X25519 public key. Does not modify anything.
751
+ def verify(handle)
752
+ unless Config.token
753
+ $stderr.puts "Error: Not logged in."
754
+ $stderr.puts "\n localvault login YOUR_TOKEN\n"
755
+ $stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
756
+ return
757
+ end
758
+
759
+ handle = handle.delete_prefix("@")
760
+ client = ApiClient.new(token: Config.token)
761
+ result = client.get_public_key(handle)
762
+ pub_key = result["public_key"]
763
+
764
+ if pub_key && !pub_key.empty?
765
+ fingerprint = pub_key.length > 12 ? "#{pub_key[0..7]}...#{pub_key[-4..]}" : pub_key
766
+ $stdout.puts "@#{handle} — public key published"
767
+ $stdout.puts " Fingerprint: #{fingerprint}"
768
+ $stdout.puts " Ready for: localvault add @#{handle} -v VAULT"
769
+ else
770
+ $stderr.puts "@#{handle} exists but has no public key published."
771
+ $stderr.puts "They need to run: localvault login TOKEN"
772
+ end
773
+ rescue ApiClient::ApiError => e
774
+ if e.status == 404
775
+ $stderr.puts "Error: @#{handle} not found on InventList."
776
+ else
777
+ $stderr.puts "Error: #{e.message}"
778
+ end
779
+ end
780
+
781
+ desc "add HANDLE", "Add a teammate to a synced team vault via key slot"
782
+ method_option :vault, type: :string, aliases: "-v"
783
+ method_option :scope, type: :array, desc: "Groups or keys to share (omit for full access)"
784
+ # Grant a user access to a synced vault by creating a key slot.
785
+ #
786
+ # With --scope, creates a per-member encrypted blob containing only the
787
+ # specified keys. Without --scope, grants full vault access.
788
+ # Requires the vault to be a team vault (run `localvault team init` first).
789
+ def add(handle)
790
+ unless Config.token
791
+ $stderr.puts "Error: Not logged in."
792
+ $stderr.puts "\n localvault login YOUR_TOKEN\n"
793
+ $stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
794
+ return
795
+ end
796
+
797
+ unless Identity.exists?
798
+ $stderr.puts "Error: No keypair found. Run: localvault keygen"
799
+ return
800
+ end
801
+
802
+ target = handle
803
+ vault_name = options[:vault] || Config.default_vault
804
+ scope_list = options[:scope]
805
+
806
+ master_key = SessionCache.get(vault_name)
807
+ unless master_key
808
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
809
+ return
810
+ end
811
+
812
+ client = ApiClient.new(token: Config.token)
813
+
814
+ # Load existing bundle — must be a team vault (v3)
815
+ existing_blob = client.pull_vault(vault_name) rescue nil
816
+ unless existing_blob.is_a?(String) && !existing_blob.empty?
817
+ $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
818
+ return
819
+ end
820
+
821
+ data = SyncBundle.unpack(existing_blob)
822
+ unless data[:owner]
823
+ $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
824
+ return
825
+ end
826
+
827
+ unless data[:owner] == Config.inventlist_handle
828
+ $stderr.puts "Error: Only the vault owner (@#{data[:owner]}) can manage team access."
829
+ return
830
+ end
831
+
832
+ key_slots = data[:key_slots].is_a?(Hash) ? data[:key_slots] : {}
833
+
834
+ # Resolve recipients — single @handle, team:HANDLE, or crew:SLUG
835
+ recipients = resolve_add_recipients(client, target)
836
+ if recipients.empty?
837
+ $stderr.puts "Error: No recipients with public keys found for '#{target}'"
838
+ return
839
+ end
840
+
841
+ added = 0
842
+ recipients.each do |member_handle, pub_key|
843
+ next if member_handle == Config.inventlist_handle # skip self
844
+
845
+ # Skip if already has full access
846
+ if key_slots.key?(member_handle) && key_slots[member_handle].is_a?(Hash) && key_slots[member_handle]["scopes"].nil?
847
+ $stdout.puts "@#{member_handle} already has full vault access." if scope_list
848
+ next
849
+ end
850
+
851
+ if scope_list
852
+ existing_scopes = key_slots.dig(member_handle, "scopes") || []
853
+ merged_scopes = (existing_scopes + scope_list).uniq
854
+
855
+ vault = Vault.new(name: vault_name, master_key: master_key)
856
+ filtered = vault.filter(merged_scopes)
857
+
858
+ member_key = RbNaCl::Random.random_bytes(32)
859
+ encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
860
+
861
+ begin
862
+ enc_key = KeySlot.create(member_key, pub_key)
863
+ rescue ArgumentError, KeySlot::DecryptionError => e
864
+ $stderr.puts "Error: @#{member_handle}'s public key is invalid: #{e.message}"
865
+ next
866
+ end
867
+
868
+ key_slots[member_handle] = {
869
+ "pub" => pub_key, "enc_key" => enc_key,
870
+ "scopes" => merged_scopes,
871
+ "blob" => Base64.strict_encode64(encrypted_blob)
872
+ }
873
+ else
874
+ begin
875
+ enc_key = KeySlot.create(master_key, pub_key)
876
+ rescue ArgumentError, KeySlot::DecryptionError => e
877
+ $stderr.puts "Error: @#{member_handle}'s public key is invalid: #{e.message}"
878
+ next
879
+ end
880
+
881
+ key_slots[member_handle] = { "pub" => pub_key, "enc_key" => enc_key, "scopes" => nil, "blob" => nil }
882
+ end
883
+ added += 1
884
+ end
885
+
886
+ if added == 0
887
+ $stdout.puts "No new members added."
888
+ return
889
+ end
890
+
891
+ store = Store.new(vault_name)
892
+ blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
893
+ client.push_vault(vault_name, blob)
894
+
895
+ if recipients.size == 1
896
+ h = recipients.first[0]
897
+ if scope_list
898
+ $stdout.puts "Added @#{h} to vault '#{vault_name}' (scopes: #{key_slots[h]["scopes"].join(", ")})."
899
+ else
900
+ $stdout.puts "Added @#{h} to vault '#{vault_name}'."
901
+ end
902
+ else
903
+ $stdout.puts "Added #{added} member(s) to vault '#{vault_name}'."
904
+ end
905
+ rescue ApiClient::ApiError => e
906
+ if e.status == 404
907
+ $stderr.puts "Error: @#{handle} not found or has no public key."
908
+ else
909
+ $stderr.puts "Error: #{e.message}"
910
+ end
911
+ rescue SyncBundle::UnpackError => e
912
+ $stderr.puts "Error: #{e.message}"
913
+ end
914
+
915
+ desc "remove HANDLE", "Remove a person's access to a vault"
916
+ method_option :vault, type: :string, aliases: "-v"
917
+ method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
918
+ method_option :scope, type: :array, desc: "Remove specific scopes only (keeps other scopes)"
919
+ # Remove a user's access to a vault.
920
+ #
921
+ # Removes the user's key slot and pushes the updated bundle. With +--rotate+,
922
+ # re-encrypts the vault with a new master key and recreates all remaining
923
+ # key slots for full cryptographic revocation. Falls back to revoking a
924
+ # direct share if no key slots exist.
925
+ def remove(handle)
926
+ unless Config.token
927
+ $stderr.puts "Error: Not logged in."
928
+ $stderr.puts
929
+ $stderr.puts " localvault login YOUR_TOKEN"
930
+ $stderr.puts
931
+ $stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
932
+ $stderr.puts "New to InventList? Sign up free at https://inventlist.com"
933
+ $stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
934
+ return
935
+ end
936
+
937
+ handle = handle.delete_prefix("@")
938
+ vault_name = options[:vault] || Config.default_vault
939
+ client = ApiClient.new(token: Config.token)
940
+
941
+ # Try sync-based key slot removal first
942
+ team_data = load_team_data(client, vault_name)
943
+ if team_data && team_data[:key_slots] && !team_data[:key_slots].empty?
944
+ # Must be a v3 team vault with owner
945
+ unless team_data[:owner]
946
+ $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
947
+ return
948
+ end
949
+ unless team_data[:owner] == Config.inventlist_handle
950
+ $stderr.puts "Error: Only the vault owner (@#{team_data[:owner]}) can manage team access."
951
+ return
952
+ end
953
+ remove_key_slot(handle, vault_name, team_data[:key_slots], client,
954
+ rotate: options[:rotate], remove_scopes: options[:scope],
955
+ owner: team_data[:owner])
956
+ return
957
+ end
958
+
959
+ # Fall back to direct share revocation
960
+ result = client.sent_shares(vault_name: vault_name)
961
+ share = (result["shares"] || []).find do |s|
962
+ s["recipient_handle"] == handle && s["status"] != "revoked"
963
+ end
964
+
965
+ unless share
966
+ $stderr.puts "Error: No active share found for @#{handle}."
967
+ return
968
+ end
969
+
970
+ client.revoke_share(share["id"])
971
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
972
+ rescue ApiClient::ApiError => e
973
+ $stderr.puts "Error: #{e.message}"
974
+ end
975
+
738
976
  desc "import FILE", "Bulk-import secrets from a .env, .json, or .yml file"
739
977
  long_desc <<~DESC
740
978
  Import all secrets from a file into a vault. Supports .env, .json, and .yml.
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.2.4"
2
+ VERSION = "1.3.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: localvault
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.4
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -111,6 +111,7 @@ files:
111
111
  - lib/localvault/cli/keys.rb
112
112
  - lib/localvault/cli/sync.rb
113
113
  - lib/localvault/cli/team.rb
114
+ - lib/localvault/cli/team_helpers.rb
114
115
  - lib/localvault/config.rb
115
116
  - lib/localvault/crypto.rb
116
117
  - lib/localvault/identity.rb