localvault 1.3.3 → 1.3.5

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: e44a0545063601cd7e83f453e89e1fca4c952651ef43e576465de057e68914c3
4
+ data.tar.gz: 5b541ed9dbf57090d92a45c36887edf1e5e0c6ded7c680487d95a8519e4c79c9
5
5
  SHA512:
6
- metadata.gz: db6ebd7b3e0371faa96ea772f28f8fb6447e03fe7184a596bccccdc8251d32ab9ee7a777ae1d772d985f9ed68f991fb6c2eaba988aded97fc710f2994f6aab7f
7
- data.tar.gz: 93286e65a351b4995a438870782c147ae86c227bbe2f307080b76c39eeaa96dfa852f133821dfdc28466329529a2e540d1a0c8738ef26f421b22e1e156361797
6
+ metadata.gz: d7706066c8dc5ed32c513ffccc1fdcb242c54c83289abf559eeb651b01d948d6eb00606ce213438fb390ab6fc9d05a1c7d96a3fdcdc59289c8fcdcffd11b94ec
7
+ data.tar.gz: 4b882262f40a9cbe7c60487c1ec1b5ebf61e07c005261676ef4b2eb6cfdb17ec328ef41731ef7b570bbae201dceba2f22b6f4eaa8ea44a1a9de36f1b649b2b91
@@ -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
 
@@ -41,19 +41,27 @@ module LocalVault
41
41
  shell.say " localvault rename OLD NEW Rename a secret key"
42
42
  shell.say " localvault copy KEY --to V Copy a secret to another vault"
43
43
  shell.say ""
44
- shell.say "TEAM & SYNC (requires localvault login)"
44
+ shell.say "SYNC (requires localvault login)"
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 add)"
48
+ shell.say " localvault sync SUBCOMMAND See `localvault help sync` for full sync reference"
49
+ shell.say ""
50
+ shell.say "TEAM SHARING (requires localvault login)"
49
51
  shell.say " localvault verify @HANDLE Check if a person has a published public key"
50
52
  shell.say " localvault add @HANDLE Add teammate (use --scope KEY... for partial access)"
51
53
  shell.say " localvault remove @HANDLE Remove teammate (--scope KEY to strip one key, --rotate to re-key)"
52
- shell.say " localvault team list List vault members and their access"
53
- shell.say " localvault team rotate Re-key vault, keep all members"
54
+ shell.say " localvault team init [VAULT] Convert vault to team vault (required before add)"
55
+ shell.say " localvault team list [VAULT] List vault members and their access"
56
+ shell.say " localvault team rotate [VAULT] Re-key vault, keep all members"
57
+ shell.say " localvault team SUBCOMMAND See `localvault help team` for the full team namespace"
58
+ shell.say " (also accepts `team add/remove/verify` aliases for the top-level commands)"
59
+ shell.say ""
60
+ shell.say "KEYS (X25519 identity for vault sharing)"
54
61
  shell.say " localvault keys generate Generate X25519 identity keypair"
55
62
  shell.say " localvault keys publish Publish public key so others can share vaults with you"
56
63
  shell.say " localvault keys show Display your current public key"
64
+ shell.say " localvault keys SUBCOMMAND See `localvault help keys` for the full keys namespace"
57
65
  shell.say ""
58
66
  shell.say "AI / MCP"
59
67
  shell.say " localvault install-mcp Configure MCP server in your AI tool"
@@ -405,8 +413,9 @@ module LocalVault
405
413
  return
406
414
  end
407
415
 
408
- store.destroy!
409
-
416
+ # Gather + validate the new passphrase BEFORE destroying the existing
417
+ # vault. If the user enters empty / mismatched / interrupts, we abort
418
+ # without touching anything on disk.
410
419
  passphrase = prompt_passphrase("New passphrase: ")
411
420
  if passphrase.empty?
412
421
  abort_with "Passphrase cannot be empty"
@@ -419,6 +428,8 @@ module LocalVault
419
428
  return
420
429
  end
421
430
 
431
+ # All inputs validated — safe to destroy + recreate.
432
+ store.destroy!
422
433
  salt = Crypto.generate_salt
423
434
  master_key = Crypto.derive_master_key(passphrase, salt)
424
435
  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.5"
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.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq