localvault 1.2.4 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +17 -11
- data/lib/localvault/cli/team.rb +42 -412
- data/lib/localvault/cli/team_helpers.rb +190 -0
- data/lib/localvault/cli.rb +241 -3
- data/lib/localvault/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b105245bc108c66fb889a043714a464b0d6c17c31b3546842c1b2e887c83898
|
|
4
|
+
data.tar.gz: c2d881d51515e62c29879bbc6dbee27c14bb7e19bb003c399ea5edd801931add
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da9d072b2bfd8633f687838590636a9982056d086bf8e95e0d085c8974e24e7b6edb8fca2ddca79612bfa3eabbd9fa3edeaadc5f86f96d9b31364cb36410f1bb
|
|
7
|
+
data.tar.gz: 66cc50037f18fd47f84d4e6f387904d066eddb0914ba438894c16c5974dd937fa67a729be59bee6df2bd002876c71c17e0a3a8bc7c96d09dfb05dab2f59fbf1f
|
data/README.md
CHANGED
|
@@ -105,19 +105,25 @@ localvault exec -- rails server
|
|
|
105
105
|
| `sync status` | Show sync state for all vaults |
|
|
106
106
|
| `config set server URL` | Point at a custom server (default: inventlist.com) |
|
|
107
107
|
|
|
108
|
-
### Team Sharing (v1.
|
|
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
|
|
177
|
+
localvault verify @alice
|
|
172
178
|
|
|
173
179
|
# 3. Add with full access
|
|
174
|
-
localvault
|
|
180
|
+
localvault add @alice -v production
|
|
175
181
|
|
|
176
182
|
# 4. Or scoped — they only see specific keys
|
|
177
|
-
localvault
|
|
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
|
|
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`.
|
data/lib/localvault/cli/team.rb
CHANGED
|
@@ -4,13 +4,18 @@ require "securerandom"
|
|
|
4
4
|
module LocalVault
|
|
5
5
|
class CLI
|
|
6
6
|
class Team < Thor
|
|
7
|
-
|
|
7
|
+
include LocalVault::CLI::TeamHelpers
|
|
8
|
+
|
|
9
|
+
desc "init [VAULT]", "Initialize a vault as a team vault (sets you as owner)"
|
|
8
10
|
method_option :vault, type: :string, aliases: "-v"
|
|
9
11
|
# Initialize a vault as a team vault with you as the owner.
|
|
10
12
|
#
|
|
11
13
|
# This is the explicit transition from personal sync to team-shared sync.
|
|
12
14
|
# Creates the owner's key slot and bumps the bundle to v3.
|
|
13
|
-
|
|
15
|
+
#
|
|
16
|
+
# Accepts the vault name as either a positional argument
|
|
17
|
+
# (`team init intellectaco`) or via --vault/-v.
|
|
18
|
+
def init(vault_name = nil)
|
|
14
19
|
unless Config.token
|
|
15
20
|
$stderr.puts "Error: Not logged in."
|
|
16
21
|
$stderr.puts "\n localvault login YOUR_TOKEN\n"
|
|
@@ -23,12 +28,12 @@ module LocalVault
|
|
|
23
28
|
return
|
|
24
29
|
end
|
|
25
30
|
|
|
26
|
-
vault_name
|
|
31
|
+
vault_name ||= options[:vault] || Config.default_vault
|
|
27
32
|
handle = Config.inventlist_handle
|
|
28
33
|
|
|
29
34
|
master_key = SessionCache.get(vault_name)
|
|
30
35
|
unless master_key
|
|
31
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault
|
|
36
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault unlock -v #{vault_name}"
|
|
32
37
|
return
|
|
33
38
|
end
|
|
34
39
|
|
|
@@ -73,7 +78,7 @@ module LocalVault
|
|
|
73
78
|
|
|
74
79
|
$stdout.puts "Vault '#{vault_name}' is now a team vault."
|
|
75
80
|
$stdout.puts "Owner: @#{handle}"
|
|
76
|
-
$stdout.puts "\nNext: localvault
|
|
81
|
+
$stdout.puts "\nNext: localvault add @handle -v #{vault_name}"
|
|
77
82
|
rescue SyncBundle::UnpackError => e
|
|
78
83
|
$stderr.puts "Error: #{e.message}"
|
|
79
84
|
end
|
|
@@ -129,249 +134,22 @@ module LocalVault
|
|
|
129
134
|
$stderr.puts "Error: #{e.message}"
|
|
130
135
|
end
|
|
131
136
|
|
|
132
|
-
desc "
|
|
133
|
-
# Verify a user's handle and public key status before adding them.
|
|
134
|
-
#
|
|
135
|
-
# Checks InventList for the handle and whether they have a published
|
|
136
|
-
# X25519 public key. Does not modify anything.
|
|
137
|
-
def verify(handle)
|
|
138
|
-
unless Config.token
|
|
139
|
-
$stderr.puts "Error: Not logged in."
|
|
140
|
-
$stderr.puts "\n localvault login YOUR_TOKEN\n"
|
|
141
|
-
$stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
|
|
142
|
-
return
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
handle = handle.delete_prefix("@")
|
|
146
|
-
client = ApiClient.new(token: Config.token)
|
|
147
|
-
result = client.get_public_key(handle)
|
|
148
|
-
pub_key = result["public_key"]
|
|
149
|
-
|
|
150
|
-
if pub_key && !pub_key.empty?
|
|
151
|
-
fingerprint = pub_key.length > 12 ? "#{pub_key[0..7]}...#{pub_key[-4..]}" : pub_key
|
|
152
|
-
$stdout.puts "@#{handle} — public key published"
|
|
153
|
-
$stdout.puts " Fingerprint: #{fingerprint}"
|
|
154
|
-
$stdout.puts " Ready for: localvault team add @#{handle} -v VAULT"
|
|
155
|
-
else
|
|
156
|
-
$stderr.puts "@#{handle} exists but has no public key published."
|
|
157
|
-
$stderr.puts "They need to run: localvault login TOKEN"
|
|
158
|
-
end
|
|
159
|
-
rescue ApiClient::ApiError => e
|
|
160
|
-
if e.status == 404
|
|
161
|
-
$stderr.puts "Error: @#{handle} not found on InventList."
|
|
162
|
-
else
|
|
163
|
-
$stderr.puts "Error: #{e.message}"
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
desc "add HANDLE", "Add a teammate to a synced vault via key slot"
|
|
168
|
-
method_option :vault, type: :string, aliases: "-v"
|
|
169
|
-
method_option :scope, type: :array, desc: "Groups or keys to share (omit for full access)"
|
|
170
|
-
# Grant a user access to a synced vault by creating a key slot.
|
|
171
|
-
#
|
|
172
|
-
# With --scope, creates a per-member encrypted blob containing only the
|
|
173
|
-
# specified keys. Without --scope, grants full vault access.
|
|
174
|
-
# Requires the vault to be a team vault (run team init first).
|
|
175
|
-
def add(handle)
|
|
176
|
-
unless Config.token
|
|
177
|
-
$stderr.puts "Error: Not logged in."
|
|
178
|
-
$stderr.puts "\n localvault login YOUR_TOKEN\n"
|
|
179
|
-
$stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
|
|
180
|
-
return
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
unless Identity.exists?
|
|
184
|
-
$stderr.puts "Error: No keypair found. Run: localvault keygen"
|
|
185
|
-
return
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
target = handle
|
|
189
|
-
vault_name = options[:vault] || Config.default_vault
|
|
190
|
-
scope_list = options[:scope]
|
|
191
|
-
|
|
192
|
-
master_key = SessionCache.get(vault_name)
|
|
193
|
-
unless master_key
|
|
194
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
195
|
-
return
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
client = ApiClient.new(token: Config.token)
|
|
199
|
-
|
|
200
|
-
# Load existing bundle — must be a team vault (v3)
|
|
201
|
-
existing_blob = client.pull_vault(vault_name) rescue nil
|
|
202
|
-
unless existing_blob.is_a?(String) && !existing_blob.empty?
|
|
203
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
|
|
204
|
-
return
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
data = SyncBundle.unpack(existing_blob)
|
|
208
|
-
unless data[:owner]
|
|
209
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
|
|
210
|
-
return
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
unless data[:owner] == Config.inventlist_handle
|
|
214
|
-
$stderr.puts "Error: Only the vault owner (@#{data[:owner]}) can manage team access."
|
|
215
|
-
return
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
key_slots = data[:key_slots].is_a?(Hash) ? data[:key_slots] : {}
|
|
219
|
-
|
|
220
|
-
# Resolve recipients — single @handle, team:HANDLE, or crew:SLUG
|
|
221
|
-
recipients = resolve_add_recipients(client, target)
|
|
222
|
-
if recipients.empty?
|
|
223
|
-
$stderr.puts "Error: No recipients with public keys found for '#{target}'"
|
|
224
|
-
return
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
added = 0
|
|
228
|
-
recipients.each do |member_handle, pub_key|
|
|
229
|
-
next if member_handle == Config.inventlist_handle # skip self
|
|
230
|
-
|
|
231
|
-
# Skip if already has full access
|
|
232
|
-
if key_slots.key?(member_handle) && key_slots[member_handle].is_a?(Hash) && key_slots[member_handle]["scopes"].nil?
|
|
233
|
-
$stdout.puts "@#{member_handle} already has full vault access." if scope_list
|
|
234
|
-
next
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
if scope_list
|
|
238
|
-
existing_scopes = key_slots.dig(member_handle, "scopes") || []
|
|
239
|
-
merged_scopes = (existing_scopes + scope_list).uniq
|
|
240
|
-
|
|
241
|
-
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
242
|
-
filtered = vault.filter(merged_scopes)
|
|
243
|
-
|
|
244
|
-
member_key = RbNaCl::Random.random_bytes(32)
|
|
245
|
-
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
246
|
-
|
|
247
|
-
begin
|
|
248
|
-
enc_key = KeySlot.create(member_key, pub_key)
|
|
249
|
-
rescue ArgumentError, KeySlot::DecryptionError => e
|
|
250
|
-
$stderr.puts "Error: @#{member_handle}'s public key is invalid: #{e.message}"
|
|
251
|
-
next
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
key_slots[member_handle] = {
|
|
255
|
-
"pub" => pub_key, "enc_key" => enc_key,
|
|
256
|
-
"scopes" => merged_scopes,
|
|
257
|
-
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
258
|
-
}
|
|
259
|
-
else
|
|
260
|
-
begin
|
|
261
|
-
enc_key = KeySlot.create(master_key, pub_key)
|
|
262
|
-
rescue ArgumentError, KeySlot::DecryptionError => e
|
|
263
|
-
$stderr.puts "Error: @#{member_handle}'s public key is invalid: #{e.message}"
|
|
264
|
-
next
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
key_slots[member_handle] = { "pub" => pub_key, "enc_key" => enc_key, "scopes" => nil, "blob" => nil }
|
|
268
|
-
end
|
|
269
|
-
added += 1
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
if added == 0
|
|
273
|
-
$stdout.puts "No new members added."
|
|
274
|
-
return
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
store = Store.new(vault_name)
|
|
278
|
-
blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
|
|
279
|
-
client.push_vault(vault_name, blob)
|
|
280
|
-
|
|
281
|
-
if recipients.size == 1
|
|
282
|
-
h = recipients.first[0]
|
|
283
|
-
if scope_list
|
|
284
|
-
$stdout.puts "Added @#{h} to vault '#{vault_name}' (scopes: #{key_slots[h]["scopes"].join(", ")})."
|
|
285
|
-
else
|
|
286
|
-
$stdout.puts "Added @#{h} to vault '#{vault_name}'."
|
|
287
|
-
end
|
|
288
|
-
else
|
|
289
|
-
$stdout.puts "Added #{added} member(s) to vault '#{vault_name}'."
|
|
290
|
-
end
|
|
291
|
-
rescue ApiClient::ApiError => e
|
|
292
|
-
if e.status == 404
|
|
293
|
-
$stderr.puts "Error: @#{handle} not found or has no public key."
|
|
294
|
-
else
|
|
295
|
-
$stderr.puts "Error: #{e.message}"
|
|
296
|
-
end
|
|
297
|
-
rescue SyncBundle::UnpackError => e
|
|
298
|
-
$stderr.puts "Error: #{e.message}"
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
desc "remove HANDLE", "Remove a person's access to a vault"
|
|
302
|
-
method_option :vault, type: :string, aliases: "-v"
|
|
303
|
-
method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
|
|
304
|
-
method_option :scope, type: :array, desc: "Remove specific scopes only (keeps other scopes)"
|
|
305
|
-
# Remove a user's access to a vault.
|
|
306
|
-
#
|
|
307
|
-
# Removes the user's key slot and pushes the updated bundle. With +--rotate+,
|
|
308
|
-
# re-encrypts the vault with a new master key and recreates all remaining
|
|
309
|
-
# key slots for full cryptographic revocation. Falls back to revoking a
|
|
310
|
-
# direct share if no key slots exist.
|
|
311
|
-
def remove(handle)
|
|
312
|
-
unless Config.token
|
|
313
|
-
$stderr.puts "Error: Not logged in."
|
|
314
|
-
$stderr.puts
|
|
315
|
-
$stderr.puts " localvault login YOUR_TOKEN"
|
|
316
|
-
$stderr.puts
|
|
317
|
-
$stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
|
|
318
|
-
$stderr.puts "New to InventList? Sign up free at https://inventlist.com"
|
|
319
|
-
$stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
|
|
320
|
-
return
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
handle = handle.delete_prefix("@")
|
|
324
|
-
vault_name = options[:vault] || Config.default_vault
|
|
325
|
-
client = ApiClient.new(token: Config.token)
|
|
326
|
-
|
|
327
|
-
# Try sync-based key slot removal first
|
|
328
|
-
team_data = load_team_data(client, vault_name)
|
|
329
|
-
if team_data && team_data[:key_slots] && !team_data[:key_slots].empty?
|
|
330
|
-
# Must be a v3 team vault with owner
|
|
331
|
-
unless team_data[:owner]
|
|
332
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
|
|
333
|
-
return
|
|
334
|
-
end
|
|
335
|
-
unless team_data[:owner] == Config.inventlist_handle
|
|
336
|
-
$stderr.puts "Error: Only the vault owner (@#{team_data[:owner]}) can manage team access."
|
|
337
|
-
return
|
|
338
|
-
end
|
|
339
|
-
remove_key_slot(handle, vault_name, team_data[:key_slots], client,
|
|
340
|
-
rotate: options[:rotate], remove_scopes: options[:scope],
|
|
341
|
-
owner: team_data[:owner])
|
|
342
|
-
return
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
# Fall back to direct share revocation
|
|
346
|
-
result = client.sent_shares(vault_name: vault_name)
|
|
347
|
-
share = (result["shares"] || []).find do |s|
|
|
348
|
-
s["recipient_handle"] == handle && s["status"] != "revoked"
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
unless share
|
|
352
|
-
$stderr.puts "Error: No active share found for @#{handle}."
|
|
353
|
-
return
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
client.revoke_share(share["id"])
|
|
357
|
-
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
358
|
-
rescue ApiClient::ApiError => e
|
|
359
|
-
$stderr.puts "Error: #{e.message}"
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
desc "rotate", "Re-encrypt a team vault with a new master key (no member changes)"
|
|
137
|
+
desc "rotate [VAULT]", "Re-encrypt a team vault with a new master key (no member changes)"
|
|
363
138
|
method_option :vault, type: :string, aliases: "-v"
|
|
364
139
|
# Re-key a team vault without adding or removing members.
|
|
365
140
|
#
|
|
366
141
|
# Prompts for a new passphrase, re-encrypts all secrets, and rebuilds
|
|
367
142
|
# all key slots. Useful for periodic key rotation.
|
|
368
|
-
|
|
143
|
+
#
|
|
144
|
+
# Accepts the vault name as either a positional argument
|
|
145
|
+
# (`team rotate intellectaco`) or via --vault/-v.
|
|
146
|
+
def rotate(vault_name = nil)
|
|
369
147
|
unless Config.token
|
|
370
148
|
$stderr.puts "Error: Not logged in."
|
|
371
149
|
return
|
|
372
150
|
end
|
|
373
151
|
|
|
374
|
-
vault_name
|
|
152
|
+
vault_name ||= options[:vault] || Config.default_vault
|
|
375
153
|
client = ApiClient.new(token: Config.token)
|
|
376
154
|
|
|
377
155
|
team_data = load_team_data(client, vault_name)
|
|
@@ -438,188 +216,40 @@ module LocalVault
|
|
|
438
216
|
$stderr.puts "Error: #{e.message}"
|
|
439
217
|
end
|
|
440
218
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
""
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
def load_key_slots(client, vault_name)
|
|
451
|
-
data = load_team_data(client, vault_name)
|
|
452
|
-
data ? data[:key_slots] : nil
|
|
453
|
-
end
|
|
219
|
+
# ── Backward-compat delegates ─────────────────────────────────
|
|
220
|
+
# `add`, `remove`, and `verify` moved to the top-level CLI in v1.3.0
|
|
221
|
+
# (the leading @ in the handle already signals a person operation).
|
|
222
|
+
# The `team add/remove/verify` aliases keep existing scripts and muscle
|
|
223
|
+
# memory working — they forward to the top-level commands with the same
|
|
224
|
+
# options.
|
|
454
225
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
data = SyncBundle.unpack(blob)
|
|
461
|
-
return nil unless data[:key_slots].is_a?(Hash)
|
|
462
|
-
data
|
|
463
|
-
rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
|
|
464
|
-
nil
|
|
226
|
+
desc "add HANDLE", "(alias) Add a teammate — prefer `localvault add @HANDLE`"
|
|
227
|
+
method_option :vault, type: :string, aliases: "-v"
|
|
228
|
+
method_option :scope, type: :array, desc: "Groups or keys to share (omit for full access)"
|
|
229
|
+
def add(handle)
|
|
230
|
+
LocalVault::CLI.new([], options, {}).add(handle)
|
|
465
231
|
end
|
|
466
232
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
(result["members"] || [])
|
|
474
|
-
.select { |m| m["handle"] && m["public_key"] && !m["public_key"].empty? }
|
|
475
|
-
.map { |m| [m["handle"], m["public_key"]] }
|
|
476
|
-
elsif target.start_with?("crew:")
|
|
477
|
-
slug = target.delete_prefix("crew:")
|
|
478
|
-
result = client.crew_public_keys(slug)
|
|
479
|
-
(result["members"] || [])
|
|
480
|
-
.select { |m| m["handle"] && m["public_key"] && !m["public_key"].empty? }
|
|
481
|
-
.map { |m| [m["handle"], m["public_key"]] }
|
|
482
|
-
else
|
|
483
|
-
handle = target.delete_prefix("@")
|
|
484
|
-
result = client.get_public_key(handle)
|
|
485
|
-
pub_key = result["public_key"]
|
|
486
|
-
return [] unless pub_key && !pub_key.empty?
|
|
487
|
-
[[handle, pub_key]]
|
|
488
|
-
end
|
|
489
|
-
rescue ApiClient::ApiError => e
|
|
490
|
-
$stderr.puts "Warning: #{e.message}"
|
|
491
|
-
[]
|
|
233
|
+
desc "remove HANDLE", "(alias) Remove a teammate — prefer `localvault remove @HANDLE`"
|
|
234
|
+
method_option :vault, type: :string, aliases: "-v"
|
|
235
|
+
method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
|
|
236
|
+
method_option :scope, type: :array, desc: "Remove specific scopes only (keeps other scopes)"
|
|
237
|
+
def remove(handle)
|
|
238
|
+
LocalVault::CLI.new([], options, {}).remove(handle)
|
|
492
239
|
end
|
|
493
240
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
owner ||= Config.inventlist_handle
|
|
498
|
-
unless key_slots.key?(handle)
|
|
499
|
-
$stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
|
|
500
|
-
return
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
store = Store.new(vault_name)
|
|
504
|
-
|
|
505
|
-
# Partial scope removal
|
|
506
|
-
if remove_scopes && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].is_a?(Array)
|
|
507
|
-
remaining = key_slots[handle]["scopes"] - remove_scopes
|
|
508
|
-
if remaining.empty?
|
|
509
|
-
# Last scope removed — remove member entirely
|
|
510
|
-
key_slots.delete(handle)
|
|
511
|
-
$stdout.puts "Removed @#{handle} from vault '#{vault_name}' (last scope removed)."
|
|
512
|
-
else
|
|
513
|
-
# Rebuild blob with remaining scopes
|
|
514
|
-
master_key = SessionCache.get(vault_name)
|
|
515
|
-
if master_key
|
|
516
|
-
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
517
|
-
filtered = vault.filter(remaining)
|
|
518
|
-
member_key = RbNaCl::Random.random_bytes(32)
|
|
519
|
-
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
520
|
-
enc_key = KeySlot.create(member_key, key_slots[handle]["pub"])
|
|
521
|
-
key_slots[handle] = {
|
|
522
|
-
"pub" => key_slots[handle]["pub"],
|
|
523
|
-
"enc_key" => enc_key,
|
|
524
|
-
"scopes" => remaining,
|
|
525
|
-
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
526
|
-
}
|
|
527
|
-
end
|
|
528
|
-
$stdout.puts "Removed scope(s) #{remove_scopes.join(", ")} from @#{handle}. Remaining: #{remaining.join(", ")}"
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
532
|
-
client.push_vault(vault_name, blob)
|
|
533
|
-
return
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
# Full member removal
|
|
537
|
-
valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
|
|
538
|
-
if handle == Config.inventlist_handle && valid_slots.size <= 1
|
|
539
|
-
$stderr.puts "Error: Cannot remove yourself — you are the only member."
|
|
540
|
-
return
|
|
541
|
-
end
|
|
542
|
-
|
|
543
|
-
key_slots.delete(handle)
|
|
544
|
-
|
|
545
|
-
if rotate
|
|
546
|
-
master_key = SessionCache.get(vault_name)
|
|
547
|
-
unless master_key
|
|
548
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
549
|
-
return
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
# Prompt for new passphrase
|
|
553
|
-
passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
|
|
554
|
-
if passphrase.nil? || passphrase.empty?
|
|
555
|
-
$stderr.puts "Error: Passphrase cannot be empty."
|
|
556
|
-
return
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
560
|
-
secrets = vault.all
|
|
561
|
-
|
|
562
|
-
new_salt = Crypto.generate_salt
|
|
563
|
-
new_master_key = Crypto.derive_master_key(passphrase, new_salt)
|
|
564
|
-
|
|
565
|
-
new_json = JSON.generate(secrets)
|
|
566
|
-
new_encrypted = Crypto.encrypt(new_json, new_master_key)
|
|
567
|
-
store.write_encrypted(new_encrypted)
|
|
568
|
-
store.create_meta!(salt: new_salt)
|
|
569
|
-
|
|
570
|
-
new_slots = {}
|
|
571
|
-
key_slots.each do |h, slot|
|
|
572
|
-
next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
|
|
573
|
-
if slot["scopes"].is_a?(Array)
|
|
574
|
-
# Scoped member — rebuild per-member blob
|
|
575
|
-
filtered = vault.filter(slot["scopes"])
|
|
576
|
-
member_key = RbNaCl::Random.random_bytes(32)
|
|
577
|
-
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
578
|
-
new_slots[h] = {
|
|
579
|
-
"pub" => slot["pub"],
|
|
580
|
-
"enc_key" => KeySlot.create(member_key, slot["pub"]),
|
|
581
|
-
"scopes" => slot["scopes"],
|
|
582
|
-
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
583
|
-
}
|
|
584
|
-
else
|
|
585
|
-
# Full-access member
|
|
586
|
-
new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
|
|
587
|
-
end
|
|
588
|
-
end
|
|
589
|
-
|
|
590
|
-
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
|
|
591
|
-
client.push_vault(vault_name, blob)
|
|
592
|
-
|
|
593
|
-
if new_slots.key?(Config.inventlist_handle)
|
|
594
|
-
SessionCache.set(vault_name, new_master_key)
|
|
595
|
-
else
|
|
596
|
-
SessionCache.clear(vault_name)
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
600
|
-
$stdout.puts "Vault re-encrypted with new master key (rotated)."
|
|
601
|
-
else
|
|
602
|
-
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
603
|
-
client.push_vault(vault_name, blob)
|
|
604
|
-
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
605
|
-
end
|
|
241
|
+
desc "verify HANDLE", "(alias) Verify a public key — prefer `localvault verify @HANDLE`"
|
|
242
|
+
def verify(handle)
|
|
243
|
+
LocalVault::CLI.new([], options, {}).verify(handle)
|
|
606
244
|
end
|
|
607
245
|
|
|
608
|
-
|
|
609
|
-
my_handle = Config.inventlist_handle
|
|
610
|
-
valid = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
|
|
611
|
-
|
|
612
|
-
if valid.empty?
|
|
613
|
-
$stdout.puts "No key slots for vault '#{vault_name}'."
|
|
614
|
-
return
|
|
615
|
-
end
|
|
246
|
+
private
|
|
616
247
|
|
|
617
|
-
|
|
618
|
-
$
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
end
|
|
248
|
+
def prompt_passphrase(msg = "Passphrase: ")
|
|
249
|
+
IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
|
|
250
|
+
rescue Interrupt
|
|
251
|
+
$stderr.puts
|
|
252
|
+
""
|
|
623
253
|
end
|
|
624
254
|
end
|
|
625
255
|
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module LocalVault
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
# Shared helpers for team-vault commands. Included by both CLI (for the
|
|
8
|
+
# top-level `add`/`remove`/`verify` commands) and CLI::Team (for `init`/
|
|
9
|
+
# `list`/`rotate` and the backward-compat delegators).
|
|
10
|
+
module TeamHelpers
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def load_key_slots(client, vault_name)
|
|
14
|
+
data = load_team_data(client, vault_name)
|
|
15
|
+
data ? data[:key_slots] : nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Load full bundle data including owner. Returns nil if no remote or
|
|
19
|
+
# not a team vault.
|
|
20
|
+
def load_team_data(client, vault_name)
|
|
21
|
+
return nil unless client.respond_to?(:pull_vault)
|
|
22
|
+
blob = client.pull_vault(vault_name)
|
|
23
|
+
return nil unless blob.is_a?(String) && !blob.empty?
|
|
24
|
+
data = SyncBundle.unpack(blob)
|
|
25
|
+
return nil unless data[:key_slots].is_a?(Hash)
|
|
26
|
+
data
|
|
27
|
+
rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Resolve target into list of [handle, public_key] pairs.
|
|
32
|
+
# Supports @handle, team:HANDLE, and crew:SLUG.
|
|
33
|
+
def resolve_add_recipients(client, target)
|
|
34
|
+
if target.start_with?("team:")
|
|
35
|
+
team_handle = target.delete_prefix("team:")
|
|
36
|
+
result = client.team_public_keys(team_handle)
|
|
37
|
+
(result["members"] || [])
|
|
38
|
+
.select { |m| m["handle"] && m["public_key"] && !m["public_key"].empty? }
|
|
39
|
+
.map { |m| [m["handle"], m["public_key"]] }
|
|
40
|
+
elsif target.start_with?("crew:")
|
|
41
|
+
slug = target.delete_prefix("crew:")
|
|
42
|
+
result = client.crew_public_keys(slug)
|
|
43
|
+
(result["members"] || [])
|
|
44
|
+
.select { |m| m["handle"] && m["public_key"] && !m["public_key"].empty? }
|
|
45
|
+
.map { |m| [m["handle"], m["public_key"]] }
|
|
46
|
+
else
|
|
47
|
+
handle = target.delete_prefix("@")
|
|
48
|
+
result = client.get_public_key(handle)
|
|
49
|
+
pub_key = result["public_key"]
|
|
50
|
+
return [] unless pub_key && !pub_key.empty?
|
|
51
|
+
[[handle, pub_key]]
|
|
52
|
+
end
|
|
53
|
+
rescue ApiClient::ApiError => e
|
|
54
|
+
$stderr.puts "Warning: #{e.message}"
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Remove a member's key slot, optionally rotating the vault master key.
|
|
59
|
+
# Supports partial scope removal via remove_scopes.
|
|
60
|
+
def remove_key_slot(handle, vault_name, key_slots, client, rotate: false, remove_scopes: nil, owner: nil)
|
|
61
|
+
owner ||= Config.inventlist_handle
|
|
62
|
+
unless key_slots.key?(handle)
|
|
63
|
+
$stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
store = Store.new(vault_name)
|
|
68
|
+
|
|
69
|
+
# Partial scope removal
|
|
70
|
+
if remove_scopes && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].is_a?(Array)
|
|
71
|
+
remaining = key_slots[handle]["scopes"] - remove_scopes
|
|
72
|
+
if remaining.empty?
|
|
73
|
+
# Last scope removed — remove member entirely
|
|
74
|
+
key_slots.delete(handle)
|
|
75
|
+
$stdout.puts "Removed @#{handle} from vault '#{vault_name}' (last scope removed)."
|
|
76
|
+
else
|
|
77
|
+
# Rebuild blob with remaining scopes
|
|
78
|
+
master_key = SessionCache.get(vault_name)
|
|
79
|
+
if master_key
|
|
80
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
81
|
+
filtered = vault.filter(remaining)
|
|
82
|
+
member_key = RbNaCl::Random.random_bytes(32)
|
|
83
|
+
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
84
|
+
enc_key = KeySlot.create(member_key, key_slots[handle]["pub"])
|
|
85
|
+
key_slots[handle] = {
|
|
86
|
+
"pub" => key_slots[handle]["pub"],
|
|
87
|
+
"enc_key" => enc_key,
|
|
88
|
+
"scopes" => remaining,
|
|
89
|
+
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
$stdout.puts "Removed scope(s) #{remove_scopes.join(", ")} from @#{handle}. Remaining: #{remaining.join(", ")}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
96
|
+
client.push_vault(vault_name, blob)
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Full member removal
|
|
101
|
+
valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
|
|
102
|
+
if handle == Config.inventlist_handle && valid_slots.size <= 1
|
|
103
|
+
$stderr.puts "Error: Cannot remove yourself — you are the only member."
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
key_slots.delete(handle)
|
|
108
|
+
|
|
109
|
+
if rotate
|
|
110
|
+
master_key = SessionCache.get(vault_name)
|
|
111
|
+
unless master_key
|
|
112
|
+
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Prompt for new passphrase
|
|
117
|
+
passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
|
|
118
|
+
if passphrase.nil? || passphrase.empty?
|
|
119
|
+
$stderr.puts "Error: Passphrase cannot be empty."
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
124
|
+
secrets = vault.all
|
|
125
|
+
|
|
126
|
+
new_salt = Crypto.generate_salt
|
|
127
|
+
new_master_key = Crypto.derive_master_key(passphrase, new_salt)
|
|
128
|
+
|
|
129
|
+
new_json = JSON.generate(secrets)
|
|
130
|
+
new_encrypted = Crypto.encrypt(new_json, new_master_key)
|
|
131
|
+
store.write_encrypted(new_encrypted)
|
|
132
|
+
store.create_meta!(salt: new_salt)
|
|
133
|
+
|
|
134
|
+
new_slots = {}
|
|
135
|
+
key_slots.each do |h, slot|
|
|
136
|
+
next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
|
|
137
|
+
if slot["scopes"].is_a?(Array)
|
|
138
|
+
# Scoped member — rebuild per-member blob
|
|
139
|
+
filtered = vault.filter(slot["scopes"])
|
|
140
|
+
member_key = RbNaCl::Random.random_bytes(32)
|
|
141
|
+
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
142
|
+
new_slots[h] = {
|
|
143
|
+
"pub" => slot["pub"],
|
|
144
|
+
"enc_key" => KeySlot.create(member_key, slot["pub"]),
|
|
145
|
+
"scopes" => slot["scopes"],
|
|
146
|
+
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
# Full-access member
|
|
150
|
+
new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
|
|
155
|
+
client.push_vault(vault_name, blob)
|
|
156
|
+
|
|
157
|
+
if new_slots.key?(Config.inventlist_handle)
|
|
158
|
+
SessionCache.set(vault_name, new_master_key)
|
|
159
|
+
else
|
|
160
|
+
SessionCache.clear(vault_name)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
164
|
+
$stdout.puts "Vault re-encrypted with new master key (rotated)."
|
|
165
|
+
else
|
|
166
|
+
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
167
|
+
client.push_vault(vault_name, blob)
|
|
168
|
+
$stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def list_key_slots(vault_name, key_slots)
|
|
173
|
+
my_handle = Config.inventlist_handle
|
|
174
|
+
valid = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
|
|
175
|
+
|
|
176
|
+
if valid.empty?
|
|
177
|
+
$stdout.puts "No key slots for vault '#{vault_name}'."
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
$stdout.puts "Vault: #{vault_name} — #{valid.size} member(s)"
|
|
182
|
+
$stdout.puts
|
|
183
|
+
valid.sort.each do |handle, slot|
|
|
184
|
+
marker = handle == my_handle ? " (you)" : ""
|
|
185
|
+
$stdout.puts " @#{handle}#{marker}"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -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
|
|
49
|
-
shell.say " localvault
|
|
50
|
-
shell.say " localvault
|
|
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.
|
data/lib/localvault/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: localvault
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nauman Tariq
|
|
@@ -111,6 +111,7 @@ files:
|
|
|
111
111
|
- lib/localvault/cli/keys.rb
|
|
112
112
|
- lib/localvault/cli/sync.rb
|
|
113
113
|
- lib/localvault/cli/team.rb
|
|
114
|
+
- lib/localvault/cli/team_helpers.rb
|
|
114
115
|
- lib/localvault/config.rb
|
|
115
116
|
- lib/localvault/crypto.rb
|
|
116
117
|
- lib/localvault/identity.rb
|