localvault 1.0.5 → 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 +145 -3
- data/lib/localvault/cli/team.rb +492 -2
- 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 +66 -0
- 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 +67 -0
- data/lib/localvault/sync_bundle.rb +52 -8
- data/lib/localvault/vault.rb +122 -12
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +1 -0
- metadata +2 -1
data/lib/localvault/cli/team.rb
CHANGED
|
@@ -1,18 +1,113 @@
|
|
|
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
|
|
|
14
100
|
vault_name ||= options[:vault] || Config.default_vault
|
|
15
101
|
client = ApiClient.new(token: Config.token)
|
|
102
|
+
|
|
103
|
+
# Try sync-based key slots first
|
|
104
|
+
key_slots = load_key_slots(client, vault_name)
|
|
105
|
+
if key_slots && !key_slots.empty?
|
|
106
|
+
list_key_slots(vault_name, key_slots)
|
|
107
|
+
return
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Fall back to direct shares
|
|
16
111
|
result = client.sent_shares(vault_name: vault_name)
|
|
17
112
|
shares = (result["shares"] || []).reject { |s| s["status"] == "revoked" }
|
|
18
113
|
|
|
@@ -34,11 +129,151 @@ module LocalVault
|
|
|
34
129
|
$stderr.puts "Error: #{e.message}"
|
|
35
130
|
end
|
|
36
131
|
|
|
132
|
+
desc "add HANDLE", "Add a teammate to a synced vault via key slot"
|
|
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).
|
|
140
|
+
def add(handle)
|
|
141
|
+
unless Config.token
|
|
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"
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
unless Identity.exists?
|
|
149
|
+
$stderr.puts "Error: No keypair found. Run: localvault keygen"
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
handle = handle.delete_prefix("@")
|
|
154
|
+
vault_name = options[:vault] || Config.default_vault
|
|
155
|
+
scope_list = options[:scope]
|
|
156
|
+
|
|
157
|
+
master_key = SessionCache.get(vault_name)
|
|
158
|
+
unless master_key
|
|
159
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
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
|
|
195
|
+
result = client.get_public_key(handle)
|
|
196
|
+
pub_key = result["public_key"]
|
|
197
|
+
unless pub_key && !pub_key.empty?
|
|
198
|
+
$stderr.puts "Error: @#{handle} has no public key published."
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
|
|
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
|
|
206
|
+
|
|
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 }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
store = Store.new(vault_name)
|
|
240
|
+
blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
|
|
241
|
+
client.push_vault(vault_name, blob)
|
|
242
|
+
|
|
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
|
|
248
|
+
rescue ApiClient::ApiError => e
|
|
249
|
+
if e.status == 404
|
|
250
|
+
$stderr.puts "Error: @#{handle} not found or has no public key."
|
|
251
|
+
else
|
|
252
|
+
$stderr.puts "Error: #{e.message}"
|
|
253
|
+
end
|
|
254
|
+
rescue SyncBundle::UnpackError => e
|
|
255
|
+
$stderr.puts "Error: #{e.message}"
|
|
256
|
+
end
|
|
257
|
+
|
|
37
258
|
desc "remove HANDLE", "Remove a person's access to a vault"
|
|
38
259
|
method_option :vault, type: :string, aliases: "-v"
|
|
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.
|
|
39
268
|
def remove(handle)
|
|
40
269
|
unless Config.token
|
|
41
|
-
$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"
|
|
42
277
|
return
|
|
43
278
|
end
|
|
44
279
|
|
|
@@ -46,6 +281,25 @@ module LocalVault
|
|
|
46
281
|
vault_name = options[:vault] || Config.default_vault
|
|
47
282
|
client = ApiClient.new(token: Config.token)
|
|
48
283
|
|
|
284
|
+
# Try sync-based key slot removal first
|
|
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])
|
|
299
|
+
return
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Fall back to direct share revocation
|
|
49
303
|
result = client.sent_shares(vault_name: vault_name)
|
|
50
304
|
share = (result["shares"] || []).find do |s|
|
|
51
305
|
s["recipient_handle"] == handle && s["status"] != "revoked"
|
|
@@ -61,6 +315,242 @@ module LocalVault
|
|
|
61
315
|
rescue ApiClient::ApiError => e
|
|
62
316
|
$stderr.puts "Error: #{e.message}"
|
|
63
317
|
end
|
|
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
|
+
|
|
398
|
+
private
|
|
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
|
+
|
|
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)
|
|
414
|
+
return nil unless client.respond_to?(:pull_vault)
|
|
415
|
+
blob = client.pull_vault(vault_name)
|
|
416
|
+
return nil unless blob.is_a?(String) && !blob.empty?
|
|
417
|
+
data = SyncBundle.unpack(blob)
|
|
418
|
+
return nil unless data[:key_slots].is_a?(Hash)
|
|
419
|
+
data
|
|
420
|
+
rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
|
|
421
|
+
nil
|
|
422
|
+
end
|
|
423
|
+
|
|
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
|
|
428
|
+
unless key_slots.key?(handle)
|
|
429
|
+
$stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
|
|
430
|
+
return
|
|
431
|
+
end
|
|
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
|
|
467
|
+
valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
|
|
468
|
+
if handle == Config.inventlist_handle && valid_slots.size <= 1
|
|
469
|
+
$stderr.puts "Error: Cannot remove yourself — you are the only member."
|
|
470
|
+
return
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
key_slots.delete(handle)
|
|
474
|
+
|
|
475
|
+
if rotate
|
|
476
|
+
master_key = SessionCache.get(vault_name)
|
|
477
|
+
unless master_key
|
|
478
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
479
|
+
return
|
|
480
|
+
end
|
|
481
|
+
|
|
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
|
+
|
|
489
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
490
|
+
secrets = vault.all
|
|
491
|
+
|
|
492
|
+
new_salt = Crypto.generate_salt
|
|
493
|
+
new_master_key = Crypto.derive_master_key(passphrase, new_salt)
|
|
494
|
+
|
|
495
|
+
new_json = JSON.generate(secrets)
|
|
496
|
+
new_encrypted = Crypto.encrypt(new_json, new_master_key)
|
|
497
|
+
store.write_encrypted(new_encrypted)
|
|
498
|
+
store.create_meta!(salt: new_salt)
|
|
499
|
+
|
|
500
|
+
new_slots = {}
|
|
501
|
+
key_slots.each do |h, slot|
|
|
502
|
+
next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
|
|
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
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
|
|
521
|
+
client.push_vault(vault_name, blob)
|
|
522
|
+
|
|
523
|
+
if new_slots.key?(Config.inventlist_handle)
|
|
524
|
+
SessionCache.set(vault_name, new_master_key)
|
|
525
|
+
else
|
|
526
|
+
SessionCache.clear(vault_name)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
530
|
+
$stdout.puts "Vault re-encrypted with new master key (rotated)."
|
|
531
|
+
else
|
|
532
|
+
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
533
|
+
client.push_vault(vault_name, blob)
|
|
534
|
+
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def list_key_slots(vault_name, key_slots)
|
|
539
|
+
my_handle = Config.inventlist_handle
|
|
540
|
+
valid = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
|
|
541
|
+
|
|
542
|
+
if valid.empty?
|
|
543
|
+
$stdout.puts "No key slots for vault '#{vault_name}'."
|
|
544
|
+
return
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
$stdout.puts "Vault: #{vault_name} — #{valid.size} member(s)"
|
|
548
|
+
$stdout.puts
|
|
549
|
+
valid.sort.each do |handle, slot|
|
|
550
|
+
marker = handle == my_handle ? " (you)" : ""
|
|
551
|
+
$stdout.puts " @#{handle}#{marker}"
|
|
552
|
+
end
|
|
553
|
+
end
|
|
64
554
|
end
|
|
65
555
|
end
|
|
66
556
|
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
|
|