localvault 1.3.2 → 1.3.4
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/lib/localvault/cli/keys.rb +2 -2
- data/lib/localvault/cli/sync.rb +26 -7
- data/lib/localvault/cli/team.rb +4 -10
- data/lib/localvault/cli/team_helpers.rb +64 -22
- data/lib/localvault/cli.rb +13 -20
- data/lib/localvault/vault.rb +20 -2
- data/lib/localvault/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e57b23276f02c28222edddf4d555066764b38b091b32e5c107c033c447218e2
|
|
4
|
+
data.tar.gz: c5b00e5cb5935555e093ab9df9fa0d1f740beb0986c5935818a4ee163d1c3777
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f28817a5b6c310436977e07f4a51b8ce4373212d9b65b2560576819d43229b5d137053b0aee00efcfe5f8eaa1016372d1ceb7a113d9d372208da8fef803a5d49
|
|
7
|
+
data.tar.gz: 6a2326ed0fde101e65b5433ac2e2a211a8c8067f2aeb8726d6fe640b5ce14956e45b2cf135526e33e8595f8bf1667b06f88483e7e0ff2dac2a950db36f124370
|
data/lib/localvault/cli/keys.rb
CHANGED
|
@@ -32,7 +32,7 @@ module LocalVault
|
|
|
32
32
|
# Publish your X25519 public key to InventList.
|
|
33
33
|
#
|
|
34
34
|
# Requires a keypair (run +localvault keys generate+ first) and an active
|
|
35
|
-
#
|
|
35
|
+
# login session (run +localvault login+ first). Once published, other users
|
|
36
36
|
# can share vaults with you.
|
|
37
37
|
def publish
|
|
38
38
|
unless Identity.exists?
|
|
@@ -41,7 +41,7 @@ module LocalVault
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
unless Config.token
|
|
44
|
-
$stderr.puts "Error: Not
|
|
44
|
+
$stderr.puts "Error: Not logged in. Run: localvault login"
|
|
45
45
|
return
|
|
46
46
|
end
|
|
47
47
|
|
data/lib/localvault/cli/sync.rb
CHANGED
|
@@ -22,8 +22,16 @@ module LocalVault
|
|
|
22
22
|
return
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
# Load remote state to determine vault mode
|
|
26
|
-
|
|
25
|
+
# Load remote state to determine vault mode. MUST distinguish a
|
|
26
|
+
# genuinely-absent remote (404) from a transient API failure: treating
|
|
27
|
+
# a 5xx as "no remote" would silently downgrade a team vault to a
|
|
28
|
+
# personal v1 bundle on the next push.
|
|
29
|
+
remote_data, load_error = load_remote_bundle_data(vault_name)
|
|
30
|
+
if load_error
|
|
31
|
+
$stderr.puts "Error: #{load_error}"
|
|
32
|
+
$stderr.puts "Refusing to push — cannot verify vault mode. Retry when the server is reachable."
|
|
33
|
+
return
|
|
34
|
+
end
|
|
27
35
|
handle = Config.inventlist_handle
|
|
28
36
|
|
|
29
37
|
if remote_data && remote_data[:owner]
|
|
@@ -214,14 +222,25 @@ module LocalVault
|
|
|
214
222
|
end
|
|
215
223
|
|
|
216
224
|
# Load the full unpacked remote bundle data (owner, key_slots, etc).
|
|
217
|
-
# Returns
|
|
225
|
+
# Returns [data, error_message]:
|
|
226
|
+
# - [hash, nil] on success
|
|
227
|
+
# - [nil, nil] when there genuinely is no remote bundle (404 / empty)
|
|
228
|
+
# - [nil, msg] on any other failure (transient network, 5xx, bad bundle)
|
|
229
|
+
#
|
|
230
|
+
# The caller MUST distinguish these cases — treating a transient error
|
|
231
|
+
# as "no remote" is a data-corruption bug: sync push would then re-upload
|
|
232
|
+
# the vault as a v1 personal bundle, silently downgrading a team vault
|
|
233
|
+
# and wiping its owner + key_slots.
|
|
218
234
|
def load_remote_bundle_data(vault_name)
|
|
219
235
|
client = ApiClient.new(token: Config.token)
|
|
220
236
|
blob = client.pull_vault(vault_name)
|
|
221
|
-
return nil unless blob.is_a?(String) && !blob.empty?
|
|
222
|
-
SyncBundle.unpack(blob)
|
|
223
|
-
rescue ApiClient::ApiError
|
|
224
|
-
nil
|
|
237
|
+
return [nil, nil] unless blob.is_a?(String) && !blob.empty?
|
|
238
|
+
[SyncBundle.unpack(blob), nil]
|
|
239
|
+
rescue ApiClient::ApiError => e
|
|
240
|
+
return [nil, nil] if e.status == 404
|
|
241
|
+
[nil, "Could not load remote bundle for '#{vault_name}': #{e.message}"]
|
|
242
|
+
rescue SyncBundle::UnpackError => e
|
|
243
|
+
[nil, "Could not parse remote bundle for '#{vault_name}': #{e.message}"]
|
|
225
244
|
end
|
|
226
245
|
|
|
227
246
|
# Load key_slots from the last pushed blob (if any).
|
data/lib/localvault/cli/team.rb
CHANGED
|
@@ -31,11 +31,8 @@ module LocalVault
|
|
|
31
31
|
vault_name ||= options[:vault] || Config.default_vault
|
|
32
32
|
handle = Config.inventlist_handle
|
|
33
33
|
|
|
34
|
-
master_key =
|
|
35
|
-
unless master_key
|
|
36
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault unlock -v #{vault_name}"
|
|
37
|
-
return
|
|
38
|
-
end
|
|
34
|
+
master_key = ensure_master_key(vault_name)
|
|
35
|
+
return unless master_key
|
|
39
36
|
|
|
40
37
|
client = ApiClient.new(token: Config.token)
|
|
41
38
|
begin
|
|
@@ -171,11 +168,8 @@ module LocalVault
|
|
|
171
168
|
key_slots = team_data[:key_slots]
|
|
172
169
|
vault_owner = team_data[:owner]
|
|
173
170
|
|
|
174
|
-
master_key =
|
|
175
|
-
unless master_key
|
|
176
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked."
|
|
177
|
-
return
|
|
178
|
-
end
|
|
171
|
+
master_key = ensure_master_key(vault_name)
|
|
172
|
+
return unless master_key
|
|
179
173
|
|
|
180
174
|
passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
|
|
181
175
|
if passphrase.nil? || passphrase.empty?
|
|
@@ -10,6 +10,37 @@ module LocalVault
|
|
|
10
10
|
module TeamHelpers
|
|
11
11
|
private
|
|
12
12
|
|
|
13
|
+
# Return the master key for +vault_name+, prompting for the passphrase
|
|
14
|
+
# if the vault isn't already cached in the session. Returns +nil+ and
|
|
15
|
+
# emits an error if the vault doesn't exist or the passphrase is wrong.
|
|
16
|
+
#
|
|
17
|
+
# This is what lets team init / rotate / add / remove "just work"
|
|
18
|
+
# without a separate `localvault unlock` step. Delegates to
|
|
19
|
+
# +Vault.open+ (the canonical passphrase-to-vault constructor) and
|
|
20
|
+
# verifies the passphrase by calling +vault.all+ — +Vault.open+ alone
|
|
21
|
+
# doesn't verify, it just derives the key.
|
|
22
|
+
def ensure_master_key(vault_name)
|
|
23
|
+
if (cached = SessionCache.get(vault_name))
|
|
24
|
+
return cached
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless Store.new(vault_name).exists?
|
|
28
|
+
$stderr.puts "Error: Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
passphrase = prompt_passphrase("Passphrase for '#{vault_name}': ")
|
|
33
|
+
return nil if passphrase.nil? || passphrase.empty?
|
|
34
|
+
|
|
35
|
+
vault = Vault.open(name: vault_name, passphrase: passphrase)
|
|
36
|
+
vault.all # raises Crypto::DecryptionError on wrong passphrase
|
|
37
|
+
SessionCache.set(vault_name, vault.master_key)
|
|
38
|
+
vault.master_key
|
|
39
|
+
rescue Crypto::DecryptionError
|
|
40
|
+
$stderr.puts "Error: Wrong passphrase for vault '#{vault_name}'."
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
13
44
|
def load_key_slots(client, vault_name)
|
|
14
45
|
data = load_team_data(client, vault_name)
|
|
15
46
|
data ? data[:key_slots] : nil
|
|
@@ -67,28 +98,42 @@ module LocalVault
|
|
|
67
98
|
store = Store.new(vault_name)
|
|
68
99
|
|
|
69
100
|
# Partial scope removal
|
|
70
|
-
if remove_scopes
|
|
71
|
-
|
|
101
|
+
if remove_scopes
|
|
102
|
+
slot = key_slots[handle]
|
|
103
|
+
slot_scopes = slot.is_a?(Hash) ? slot["scopes"] : nil
|
|
104
|
+
|
|
105
|
+
unless slot_scopes.is_a?(Array)
|
|
106
|
+
# Member has full access (nil scopes) — --scope is a user error.
|
|
107
|
+
# Falling through to full removal here used to silently delete
|
|
108
|
+
# the member, which is surprising and destructive.
|
|
109
|
+
$stderr.puts "Error: @#{handle} has full access to '#{vault_name}', not scoped. " \
|
|
110
|
+
"Use `localvault remove @#{handle}` without --scope to revoke access."
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
remaining = slot_scopes - remove_scopes
|
|
72
115
|
if remaining.empty?
|
|
73
116
|
# Last scope removed — remove member entirely
|
|
74
117
|
key_slots.delete(handle)
|
|
75
118
|
$stdout.puts "Removed @#{handle} from vault '#{vault_name}' (last scope removed)."
|
|
76
119
|
else
|
|
77
|
-
# Rebuild blob with remaining scopes
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
120
|
+
# Rebuild blob with remaining scopes. Requires the vault to be
|
|
121
|
+
# unlocked — previously this would silently no-op (print success,
|
|
122
|
+
# push unchanged slot) when the session cache was empty.
|
|
123
|
+
master_key = ensure_master_key(vault_name)
|
|
124
|
+
return unless master_key
|
|
125
|
+
|
|
126
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
127
|
+
filtered = vault.filter(remaining)
|
|
128
|
+
member_key = RbNaCl::Random.random_bytes(32)
|
|
129
|
+
encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
|
|
130
|
+
enc_key = KeySlot.create(member_key, slot["pub"])
|
|
131
|
+
key_slots[handle] = {
|
|
132
|
+
"pub" => slot["pub"],
|
|
133
|
+
"enc_key" => enc_key,
|
|
134
|
+
"scopes" => remaining,
|
|
135
|
+
"blob" => Base64.strict_encode64(encrypted_blob)
|
|
136
|
+
}
|
|
92
137
|
$stdout.puts "Removed scope(s) #{remove_scopes.join(", ")} from @#{handle}. Remaining: #{remaining.join(", ")}"
|
|
93
138
|
end
|
|
94
139
|
|
|
@@ -107,11 +152,8 @@ module LocalVault
|
|
|
107
152
|
key_slots.delete(handle)
|
|
108
153
|
|
|
109
154
|
if rotate
|
|
110
|
-
master_key =
|
|
111
|
-
unless master_key
|
|
112
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
113
|
-
return
|
|
114
|
-
end
|
|
155
|
+
master_key = ensure_master_key(vault_name)
|
|
156
|
+
return unless master_key
|
|
115
157
|
|
|
116
158
|
# Prompt for new passphrase
|
|
117
159
|
passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -59,6 +59,12 @@ module LocalVault
|
|
|
59
59
|
shell.say " localvault install-mcp Configure MCP server in your AI tool"
|
|
60
60
|
shell.say " localvault mcp Start MCP server (stdio)"
|
|
61
61
|
shell.say ""
|
|
62
|
+
shell.say "LEGACY SHARING (pre-v1.2 direct share, still works as fallback)"
|
|
63
|
+
shell.say " localvault keygen Generate X25519 keypair (same as `keys generate`)"
|
|
64
|
+
shell.say " localvault share [VAULT] Share a vault with a user, team, or crew (one-shot copy)"
|
|
65
|
+
shell.say " localvault receive Fetch and import vaults shared with you"
|
|
66
|
+
shell.say " localvault revoke SHARE_ID Revoke a direct vault share"
|
|
67
|
+
shell.say ""
|
|
62
68
|
shell.say "OTHER"
|
|
63
69
|
shell.say " localvault login --status Show current login status"
|
|
64
70
|
shell.say " localvault logout Log out"
|
|
@@ -399,8 +405,9 @@ module LocalVault
|
|
|
399
405
|
return
|
|
400
406
|
end
|
|
401
407
|
|
|
402
|
-
|
|
403
|
-
|
|
408
|
+
# Gather + validate the new passphrase BEFORE destroying the existing
|
|
409
|
+
# vault. If the user enters empty / mismatched / interrupts, we abort
|
|
410
|
+
# without touching anything on disk.
|
|
404
411
|
passphrase = prompt_passphrase("New passphrase: ")
|
|
405
412
|
if passphrase.empty?
|
|
406
413
|
abort_with "Passphrase cannot be empty"
|
|
@@ -413,6 +420,8 @@ module LocalVault
|
|
|
413
420
|
return
|
|
414
421
|
end
|
|
415
422
|
|
|
423
|
+
# All inputs validated — safe to destroy + recreate.
|
|
424
|
+
store.destroy!
|
|
416
425
|
salt = Crypto.generate_salt
|
|
417
426
|
master_key = Crypto.derive_master_key(passphrase, salt)
|
|
418
427
|
Vault.create!(name: vault_name, master_key: master_key, salt: salt)
|
|
@@ -598,19 +607,6 @@ module LocalVault
|
|
|
598
607
|
$stdout.puts "Logged out#{" @#{handle}" if handle}."
|
|
599
608
|
end
|
|
600
609
|
|
|
601
|
-
desc "connect", "Connect to InventList for vault sharing"
|
|
602
|
-
method_option :token, required: true, type: :string, desc: "InventList API token"
|
|
603
|
-
method_option :handle, required: true, type: :string, desc: "Your InventList handle"
|
|
604
|
-
def connect
|
|
605
|
-
Config.token = options[:token]
|
|
606
|
-
Config.inventlist_handle = options[:handle]
|
|
607
|
-
$stdout.puts "Connected as @#{options[:handle]}"
|
|
608
|
-
$stdout.puts
|
|
609
|
-
$stdout.puts "Next steps:"
|
|
610
|
-
$stdout.puts " localvault keys generate # generate your X25519 keypair"
|
|
611
|
-
$stdout.puts " localvault keys publish # upload your public key to InventList"
|
|
612
|
-
end
|
|
613
|
-
|
|
614
610
|
desc "share [VAULT]", "Share a vault with an InventList user, team, or crew"
|
|
615
611
|
method_option :with, required: true, type: :string,
|
|
616
612
|
desc: "Recipient: @handle, team:HANDLE, or crew:SLUG"
|
|
@@ -803,11 +799,8 @@ module LocalVault
|
|
|
803
799
|
vault_name = options[:vault] || Config.default_vault
|
|
804
800
|
scope_list = options[:scope]
|
|
805
801
|
|
|
806
|
-
master_key =
|
|
807
|
-
unless master_key
|
|
808
|
-
$stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
|
|
809
|
-
return
|
|
810
|
-
end
|
|
802
|
+
master_key = ensure_master_key(vault_name)
|
|
803
|
+
return unless master_key
|
|
811
804
|
|
|
812
805
|
client = ApiClient.new(token: Config.token)
|
|
813
806
|
|
data/lib/localvault/vault.rb
CHANGED
|
@@ -64,7 +64,8 @@ module LocalVault
|
|
|
64
64
|
# @param value [String] the secret value
|
|
65
65
|
# @return [String] the stored value
|
|
66
66
|
# @raise [InvalidKeyName] when key contains invalid characters
|
|
67
|
-
# @raise [RuntimeError] when a scalar key is used as a group
|
|
67
|
+
# @raise [RuntimeError] when a scalar key is used as a group, or when a
|
|
68
|
+
# scalar is being assigned to an existing group name
|
|
68
69
|
def set(key, value)
|
|
69
70
|
validate_key!(key)
|
|
70
71
|
secrets = all
|
|
@@ -74,6 +75,15 @@ module LocalVault
|
|
|
74
75
|
raise "#{group} is a scalar value, not a group" unless secrets[group].is_a?(Hash)
|
|
75
76
|
secrets[group][subkey] = value
|
|
76
77
|
else
|
|
78
|
+
# Refuse to silently clobber an existing group with a scalar. This
|
|
79
|
+
# used to succeed: set("app", "oops") on a vault containing
|
|
80
|
+
# {"app" => {"DB" => ...}} would replace the whole group and lose
|
|
81
|
+
# every nested secret under it.
|
|
82
|
+
if secrets[key].is_a?(Hash)
|
|
83
|
+
raise "'#{key}' is a group containing #{secrets[key].size} secret(s), " \
|
|
84
|
+
"not a scalar. Use `localvault delete #{key}` first if you " \
|
|
85
|
+
"really want to replace the whole group."
|
|
86
|
+
end
|
|
77
87
|
secrets[key] = value
|
|
78
88
|
end
|
|
79
89
|
write_secrets(secrets)
|
|
@@ -283,7 +293,8 @@ module LocalVault
|
|
|
283
293
|
# @param hash [Hash] key-value pairs to merge into the vault
|
|
284
294
|
# @return [void]
|
|
285
295
|
# @raise [InvalidKeyName] when any key contains invalid characters
|
|
286
|
-
# @raise [RuntimeError] when a scalar key is used as a group
|
|
296
|
+
# @raise [RuntimeError] when a scalar key is used as a group, or when a
|
|
297
|
+
# scalar is being assigned to an existing group name
|
|
287
298
|
def merge(hash)
|
|
288
299
|
secrets = all
|
|
289
300
|
hash.each do |k, v|
|
|
@@ -303,6 +314,13 @@ module LocalVault
|
|
|
303
314
|
raise "#{group} is a scalar value, not a group" unless secrets[group].is_a?(Hash)
|
|
304
315
|
secrets[group][subkey] = v.to_s
|
|
305
316
|
else
|
|
317
|
+
# Same guard as Vault#set: don't silently clobber a group with
|
|
318
|
+
# a scalar. This protects bulk `import` and `receive` flows.
|
|
319
|
+
if secrets[k].is_a?(Hash)
|
|
320
|
+
raise "'#{k}' is a group containing #{secrets[k].size} secret(s), " \
|
|
321
|
+
"not a scalar. Delete the group first if you really want " \
|
|
322
|
+
"to replace it with a scalar."
|
|
323
|
+
end
|
|
306
324
|
secrets[k] = v.to_s
|
|
307
325
|
end
|
|
308
326
|
end
|
data/lib/localvault/version.rb
CHANGED