localvault 1.3.3 → 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: ca1ee7041877b10f5964e4fc74e7edf9365f4a7e34d283c624c15bd45ab32c98
4
- data.tar.gz: 59a54ac2d1591cbc6d5910f9222c3c89039edc4d89e45f50dc3fd09aa7720a29
3
+ metadata.gz: 2e57b23276f02c28222edddf4d555066764b38b091b32e5c107c033c447218e2
4
+ data.tar.gz: c5b00e5cb5935555e093ab9df9fa0d1f740beb0986c5935818a4ee163d1c3777
5
5
  SHA512:
6
- metadata.gz: db6ebd7b3e0371faa96ea772f28f8fb6447e03fe7184a596bccccdc8251d32ab9ee7a777ae1d772d985f9ed68f991fb6c2eaba988aded97fc710f2994f6aab7f
7
- data.tar.gz: 93286e65a351b4995a438870782c147ae86c227bbe2f307080b76c39eeaa96dfa852f133821dfdc28466329529a2e540d1a0c8738ef26f421b22e1e156361797
6
+ metadata.gz: f28817a5b6c310436977e07f4a51b8ce4373212d9b65b2560576819d43229b5d137053b0aee00efcfe5f8eaa1016372d1ceb7a113d9d372208da8fef803a5d49
7
+ data.tar.gz: 6a2326ed0fde101e65b5433ac2e2a211a8c8067f2aeb8726d6fe640b5ce14956e45b2cf135526e33e8595f8bf1667b06f88483e7e0ff2dac2a950db36f124370
@@ -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).
@@ -98,28 +98,42 @@ module LocalVault
98
98
  store = Store.new(vault_name)
99
99
 
100
100
  # Partial scope removal
101
- if remove_scopes && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].is_a?(Array)
102
- 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
103
115
  if remaining.empty?
104
116
  # Last scope removed — remove member entirely
105
117
  key_slots.delete(handle)
106
118
  $stdout.puts "Removed @#{handle} from vault '#{vault_name}' (last scope removed)."
107
119
  else
108
- # Rebuild blob with remaining scopes
109
- master_key = SessionCache.get(vault_name)
110
- if master_key
111
- vault = Vault.new(name: vault_name, master_key: master_key)
112
- filtered = vault.filter(remaining)
113
- member_key = RbNaCl::Random.random_bytes(32)
114
- encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
115
- enc_key = KeySlot.create(member_key, key_slots[handle]["pub"])
116
- key_slots[handle] = {
117
- "pub" => key_slots[handle]["pub"],
118
- "enc_key" => enc_key,
119
- "scopes" => remaining,
120
- "blob" => Base64.strict_encode64(encrypted_blob)
121
- }
122
- 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
+ }
123
137
  $stdout.puts "Removed scope(s) #{remove_scopes.join(", ")} from @#{handle}. Remaining: #{remaining.join(", ")}"
124
138
  end
125
139
 
@@ -405,8 +405,9 @@ module LocalVault
405
405
  return
406
406
  end
407
407
 
408
- store.destroy!
409
-
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.
410
411
  passphrase = prompt_passphrase("New passphrase: ")
411
412
  if passphrase.empty?
412
413
  abort_with "Passphrase cannot be empty"
@@ -419,6 +420,8 @@ module LocalVault
419
420
  return
420
421
  end
421
422
 
423
+ # All inputs validated — safe to destroy + recreate.
424
+ store.destroy!
422
425
  salt = Crypto.generate_salt
423
426
  master_key = Crypto.derive_master_key(passphrase, salt)
424
427
  Vault.create!(name: vault_name, master_key: master_key, salt: salt)
@@ -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.3"
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.3
4
+ version: 1.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq