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