localvault 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e30526cd10f0e0e29efe3db5b5c73d8de353cad6ad797d02fe1ac7f626ef7bc
4
- data.tar.gz: e6f53442c299ba9836eddd53de5715a3f14577c2213080bfe2b7eab9f4646f41
3
+ metadata.gz: 2fb8eb382004083b9fcd03144139013da7eda568be14370e08a66a1adb7bd332
4
+ data.tar.gz: 5e03b4a0b7a7c5ff7e560dd79ef2ada9a4413f8414b275a54478044c90bd267c
5
5
  SHA512:
6
- metadata.gz: 9d707292c02dace77c7595a61885273145f524e917a1b79a04670b428e5812cbc59564f74cacad4bd73f9a1d0843d91ad45588b33c5b1cf2fae58840df3e484f
7
- data.tar.gz: 761ac32c6161f3c52d6c51ef7f254b8bd8689739cd7a692fc3ae33f03e014de311514f7753d2470068aca7ef005045f672ea87d386cc06daa0909e136521f325
6
+ metadata.gz: d241d6f3fa8ddcd176c78bdc8b8d58d66d511f9527d139c3896fdfd84b92462c7f72f705851f05ec8c9be26a14419613093bfda1265cde860f861f6bbfddebcd
7
+ data.tar.gz: fc9ec7322791d8eedcdfbb252c11cdd995dc67008d7de6fd7c34b208a9fd0bb913e2a13550e0336104f36f0f3eeaf410d56b2035e699a1a495cd75c67acb310a
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,6 +4,8 @@ require "securerandom"
4
4
  module LocalVault
5
5
  class CLI
6
6
  class Team < Thor
7
+ include LocalVault::CLI::TeamHelpers
8
+
7
9
  desc "init", "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.
@@ -73,7 +75,7 @@ module LocalVault
73
75
 
74
76
  $stdout.puts "Vault '#{vault_name}' is now a team vault."
75
77
  $stdout.puts "Owner: @#{handle}"
76
- $stdout.puts "\nNext: localvault team add @handle -v #{vault_name}"
78
+ $stdout.puts "\nNext: localvault add @handle -v #{vault_name}"
77
79
  rescue SyncBundle::UnpackError => e
78
80
  $stderr.puts "Error: #{e.message}"
79
81
  end
@@ -129,228 +131,6 @@ module LocalVault
129
131
  $stderr.puts "Error: #{e.message}"
130
132
  end
131
133
 
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
- handle = handle.delete_prefix("@")
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
- # 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
230
- result = client.get_public_key(handle)
231
- pub_key = result["public_key"]
232
- unless pub_key && !pub_key.empty?
233
- $stderr.puts "Error: @#{handle} has no public key published."
234
- return
235
- end
236
-
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
255
-
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 }
272
- end
273
-
274
- store = Store.new(vault_name)
275
- blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
276
- client.push_vault(vault_name, blob)
277
-
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
283
- rescue ApiClient::ApiError => e
284
- if e.status == 404
285
- $stderr.puts "Error: @#{handle} not found or has no public key."
286
- else
287
- $stderr.puts "Error: #{e.message}"
288
- end
289
- rescue SyncBundle::UnpackError => e
290
- $stderr.puts "Error: #{e.message}"
291
- end
292
-
293
- desc "remove HANDLE", "Remove a person's access to a vault"
294
- method_option :vault, type: :string, aliases: "-v"
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.
303
- def remove(handle)
304
- unless Config.token
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/@YOUR_HANDLE/edit#developer"
310
- $stderr.puts "New to InventList? Sign up free at https://inventlist.com"
311
- $stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
312
- return
313
- end
314
-
315
- handle = handle.delete_prefix("@")
316
- vault_name = options[:vault] || Config.default_vault
317
- client = ApiClient.new(token: Config.token)
318
-
319
- # Try sync-based key slot removal first
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])
334
- return
335
- end
336
-
337
- # Fall back to direct share revocation
338
- result = client.sent_shares(vault_name: vault_name)
339
- share = (result["shares"] || []).find do |s|
340
- s["recipient_handle"] == handle && s["status"] != "revoked"
341
- end
342
-
343
- unless share
344
- $stderr.puts "Error: No active share found for @#{handle}."
345
- return
346
- end
347
-
348
- client.revoke_share(share["id"])
349
- $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
350
- rescue ApiClient::ApiError => e
351
- $stderr.puts "Error: #{e.message}"
352
- end
353
-
354
134
  desc "rotate", "Re-encrypt a team vault with a new master key (no member changes)"
355
135
  method_option :vault, type: :string, aliases: "-v"
356
136
  # Re-key a team vault without adding or removing members.
@@ -430,161 +210,40 @@ module LocalVault
430
210
  $stderr.puts "Error: #{e.message}"
431
211
  end
432
212
 
433
- private
434
-
435
- def prompt_passphrase(msg = "Passphrase: ")
436
- IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
437
- rescue Interrupt
438
- $stderr.puts
439
- ""
440
- end
213
+ # ── Backward-compat delegates ─────────────────────────────────
214
+ # `add`, `remove`, and `verify` moved to the top-level CLI in v1.3.0
215
+ # (the leading @ in the handle already signals a person operation).
216
+ # The `team add/remove/verify` aliases keep existing scripts and muscle
217
+ # memory working — they forward to the top-level commands with the same
218
+ # options.
441
219
 
442
- def load_key_slots(client, vault_name)
443
- data = load_team_data(client, vault_name)
444
- data ? data[:key_slots] : nil
220
+ desc "add HANDLE", "(alias) Add a teammate — prefer `localvault add @HANDLE`"
221
+ method_option :vault, type: :string, aliases: "-v"
222
+ method_option :scope, type: :array, desc: "Groups or keys to share (omit for full access)"
223
+ def add(handle)
224
+ LocalVault::CLI.new([], options, {}).add(handle)
445
225
  end
446
226
 
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)
449
- return nil unless client.respond_to?(:pull_vault)
450
- blob = client.pull_vault(vault_name)
451
- return nil unless blob.is_a?(String) && !blob.empty?
452
- data = SyncBundle.unpack(blob)
453
- return nil unless data[:key_slots].is_a?(Hash)
454
- data
455
- rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
456
- nil
227
+ desc "remove HANDLE", "(alias) Remove a teammate prefer `localvault remove @HANDLE`"
228
+ method_option :vault, type: :string, aliases: "-v"
229
+ method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
230
+ method_option :scope, type: :array, desc: "Remove specific scopes only (keeps other scopes)"
231
+ def remove(handle)
232
+ LocalVault::CLI.new([], options, {}).remove(handle)
457
233
  end
458
234
 
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
463
- unless key_slots.key?(handle)
464
- $stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
465
- return
466
- end
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
502
- valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
503
- if handle == Config.inventlist_handle && valid_slots.size <= 1
504
- $stderr.puts "Error: Cannot remove yourself — you are the only member."
505
- return
506
- end
507
-
508
- key_slots.delete(handle)
509
-
510
- if rotate
511
- master_key = SessionCache.get(vault_name)
512
- unless master_key
513
- $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
514
- return
515
- end
516
-
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
-
524
- vault = Vault.new(name: vault_name, master_key: master_key)
525
- secrets = vault.all
526
-
527
- new_salt = Crypto.generate_salt
528
- new_master_key = Crypto.derive_master_key(passphrase, new_salt)
529
-
530
- new_json = JSON.generate(secrets)
531
- new_encrypted = Crypto.encrypt(new_json, new_master_key)
532
- store.write_encrypted(new_encrypted)
533
- store.create_meta!(salt: new_salt)
534
-
535
- new_slots = {}
536
- key_slots.each do |h, slot|
537
- next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
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
553
- end
554
-
555
- blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
556
- client.push_vault(vault_name, blob)
557
-
558
- if new_slots.key?(Config.inventlist_handle)
559
- SessionCache.set(vault_name, new_master_key)
560
- else
561
- SessionCache.clear(vault_name)
562
- end
563
-
564
- $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
565
- $stdout.puts "Vault re-encrypted with new master key (rotated)."
566
- else
567
- blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
568
- client.push_vault(vault_name, blob)
569
- $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
570
- end
235
+ desc "verify HANDLE", "(alias) Verify a public key prefer `localvault verify @HANDLE`"
236
+ def verify(handle)
237
+ LocalVault::CLI.new([], options, {}).verify(handle)
571
238
  end
572
239
 
573
- def list_key_slots(vault_name, key_slots)
574
- my_handle = Config.inventlist_handle
575
- valid = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
576
-
577
- if valid.empty?
578
- $stdout.puts "No key slots for vault '#{vault_name}'."
579
- return
580
- end
240
+ private
581
241
 
582
- $stdout.puts "Vault: #{vault_name} — #{valid.size} member(s)"
583
- $stdout.puts
584
- valid.sort.each do |handle, slot|
585
- marker = handle == my_handle ? " (you)" : ""
586
- $stdout.puts " @#{handle}#{marker}"
587
- end
242
+ def prompt_passphrase(msg = "Passphrase: ")
243
+ IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
244
+ rescue Interrupt
245
+ $stderr.puts
246
+ ""
588
247
  end
589
248
  end
590
249
  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.3"
2
+ VERSION = "1.3.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: localvault
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-04-06 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: thor
@@ -112,6 +111,7 @@ files:
112
111
  - lib/localvault/cli/keys.rb
113
112
  - lib/localvault/cli/sync.rb
114
113
  - lib/localvault/cli/team.rb
114
+ - lib/localvault/cli/team_helpers.rb
115
115
  - lib/localvault/config.rb
116
116
  - lib/localvault/crypto.rb
117
117
  - lib/localvault/identity.rb
@@ -131,7 +131,6 @@ metadata:
131
131
  homepage_uri: https://inventlist.com/tools/localvault
132
132
  source_code_uri: https://github.com/inventlist/localvault
133
133
  funding_uri: https://inventlist.com
134
- post_install_message:
135
134
  rdoc_options: []
136
135
  require_paths:
137
136
  - lib
@@ -146,8 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
146
145
  - !ruby/object:Gem::Version
147
146
  version: '0'
148
147
  requirements: []
149
- rubygems_version: 3.0.3.1
150
- signing_key:
148
+ rubygems_version: 3.6.9
151
149
  specification_version: 4
152
150
  summary: Zero-infrastructure secrets manager
153
151
  test_files: []