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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c854998a9a601c226e9691607d3b7c1b3d9784c6cb2da3945f1eaac9358c453
4
- data.tar.gz: 3fe4e808be8b449ea49934fa43564fb778816d8348e29f3680888d001752baf2
3
+ metadata.gz: 2e57b23276f02c28222edddf4d555066764b38b091b32e5c107c033c447218e2
4
+ data.tar.gz: c5b00e5cb5935555e093ab9df9fa0d1f740beb0986c5935818a4ee163d1c3777
5
5
  SHA512:
6
- metadata.gz: 7522e959e6aad42f76d577fcfe880fff32807d554e01ab44d961b12569d09d9c2bbd12997761fe41631cb9996db9b754c3c555f7cdc6359b508ad1700d2b6490
7
- data.tar.gz: ae605f85a6f5623684fd77a2e6ec4fee66ea1fcabc40a9b0f1c9bb6d5c2352f00ea914673a2f894443bb29344441c173ab91f3980ff23bcf8712274e63bae9b7
6
+ metadata.gz: f28817a5b6c310436977e07f4a51b8ce4373212d9b65b2560576819d43229b5d137053b0aee00efcfe5f8eaa1016372d1ceb7a113d9d372208da8fef803a5d49
7
+ data.tar.gz: 6a2326ed0fde101e65b5433ac2e2a211a8c8067f2aeb8726d6fe640b5ce14956e45b2cf135526e33e8595f8bf1667b06f88483e7e0ff2dac2a950db36f124370
@@ -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
- # connection (run +localvault connect+ first). Once published, other users
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 connected. Run: localvault connect --token TOKEN --handle HANDLE"
44
+ $stderr.puts "Error: Not logged in. Run: localvault login"
45
45
  return
46
46
  end
47
47
 
@@ -22,8 +22,16 @@ module LocalVault
22
22
  return
23
23
  end
24
24
 
25
- # Load remote state to determine vault mode
26
- remote_data = load_remote_bundle_data(vault_name)
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 nil if no remote blob exists.
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, SyncBundle::UnpackError
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).
@@ -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 = SessionCache.get(vault_name)
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 = SessionCache.get(vault_name)
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 && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].is_a?(Array)
71
- remaining = key_slots[handle]["scopes"] - remove_scopes
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
- 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
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 = 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
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}': ")
@@ -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
- store.destroy!
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 = 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
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
 
@@ -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
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.3.2"
2
+ VERSION = "1.3.4"
3
3
  end
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.3.2
4
+ version: 1.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq