localvault 1.3.5 → 1.4.0

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: e44a0545063601cd7e83f453e89e1fca4c952651ef43e576465de057e68914c3
4
- data.tar.gz: 5b541ed9dbf57090d92a45c36887edf1e5e0c6ded7c680487d95a8519e4c79c9
3
+ metadata.gz: 6ec85a7f67eb8705f2078a55eeeb1a24997f61e965a76284ef9a9eb314eef032
4
+ data.tar.gz: d799cbfabdced0595eb91132e35daa2930f19a705cc189357cb9a5c3b794cc05
5
5
  SHA512:
6
- metadata.gz: d7706066c8dc5ed32c513ffccc1fdcb242c54c83289abf559eeb651b01d948d6eb00606ce213438fb390ab6fc9d05a1c7d96a3fdcdc59289c8fcdcffd11b94ec
7
- data.tar.gz: 4b882262f40a9cbe7c60487c1ec1b5ebf61e07c005261676ef4b2eb6cfdb17ec328ef41731ef7b570bbae201dceba2f22b6f4eaa8ea44a1a9de36f1b649b2b91
6
+ metadata.gz: 74123bc8d783ace6773c11223486974d7210870903d5901d0f1c3410266d7d9c29d2ffe9f0bead2137629b3132495854c490c1a771becf06355a26b527afa435
7
+ data.tar.gz: b95d606abec4fe145d23b7028a5091f4ec1fab3a9b5d6df3a8ae201b238b9a334a8c07033892a6d3ae36d73f1e116cc3f0c72dcde955f1109f09d58405025021
@@ -177,35 +177,35 @@ module LocalVault
177
177
  return
178
178
  end
179
179
 
180
+ # Decrypt existing secrets with the CURRENT master key. After this
181
+ # point we must not touch the store until the push has succeeded —
182
+ # see finding #5 (transactional rotate).
180
183
  vault = Vault.new(name: vault_name, master_key: master_key)
181
184
  secrets = vault.all
182
- store = Store.new(vault_name)
183
185
 
184
186
  new_salt = Crypto.generate_salt
185
187
  new_master_key = Crypto.derive_master_key(passphrase, new_salt)
186
188
 
187
- store.write_encrypted(Crypto.encrypt(JSON.generate(secrets), new_master_key))
188
- store.create_meta!(salt: new_salt)
189
-
190
- new_slots = {}
191
- key_slots.each do |h, slot|
192
- next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
193
- if slot["scopes"].is_a?(Array)
194
- filtered = vault.filter(slot["scopes"])
195
- member_key = RbNaCl::Random.random_bytes(32)
196
- encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
197
- new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(member_key, slot["pub"]), "scopes" => slot["scopes"], "blob" => Base64.strict_encode64(encrypted_blob) }
198
- else
199
- new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
200
- end
201
- end
202
-
203
- blob = SyncBundle.pack_v3(store, owner: vault_owner, key_slots: new_slots)
204
- client.push_vault(vault_name, blob)
189
+ bundle = build_rotated_bundle(
190
+ secrets: secrets,
191
+ key_slots: key_slots,
192
+ new_master_key: new_master_key,
193
+ new_salt: new_salt,
194
+ owner: vault_owner,
195
+ vault_name: vault_name,
196
+ vault: vault
197
+ )
198
+
199
+ # Push first. If this raises, nothing on local disk has changed, so
200
+ # the user can safely retry with the same passphrase.
201
+ client.push_vault(vault_name, bundle[:bundle_json])
202
+
203
+ # Push succeeded — commit locally and update the session cache.
204
+ commit_rotated_bundle_locally(vault_name, bundle[:new_secrets_bytes], bundle[:new_meta_bytes])
205
205
  SessionCache.set(vault_name, new_master_key)
206
206
 
207
207
  $stdout.puts "Vault '#{vault_name}' re-encrypted with new master key."
208
- $stdout.puts "#{new_slots.size} member(s) updated."
208
+ $stdout.puts "#{bundle[:new_slots].size} member(s) updated."
209
209
  rescue ApiClient::ApiError => e
210
210
  $stderr.puts "Error: #{e.message}"
211
211
  end
@@ -1,6 +1,8 @@
1
1
  require "json"
2
2
  require "base64"
3
3
  require "securerandom"
4
+ require "yaml"
5
+ require "time"
4
6
 
5
7
  module LocalVault
6
8
  class CLI < Thor
@@ -86,6 +88,90 @@ module LocalVault
86
88
  []
87
89
  end
88
90
 
91
+ # Build everything a rotate needs in memory, without touching local disk.
92
+ #
93
+ # Returns a hash of in-memory bytes and derived state that the caller
94
+ # can either push to the remote before committing locally
95
+ # (+pack+ + +client.push_vault+), or discard without side effects if
96
+ # the user interrupts or the push fails.
97
+ #
98
+ # This is the centerpiece of finding #5 (transactional rotate): by
99
+ # producing everything in memory first, the caller can push + commit
100
+ # atomically — no half-rotated local state if the network dies.
101
+ #
102
+ # It also fixes finding #6 (scoped-sharing inefficiency): the plaintext
103
+ # secrets are decrypted exactly once and passed into +Vault#filter+
104
+ # for each scoped member instead of being re-decrypted per member.
105
+ #
106
+ # @param secrets [Hash] plaintext secrets hash (from +vault.all+)
107
+ # @param key_slots [Hash] existing key slots from the remote bundle
108
+ # @param new_master_key [String] the rotation target master key
109
+ # @param new_salt [String] raw salt bytes for the new meta.yml
110
+ # @param owner [String] the owner's InventList handle
111
+ # @param vault_name [String]
112
+ # @param vault [Vault] a Vault instance (used only for its +filter+ helper)
113
+ # @return [Hash] +{ new_slots:, new_secrets_bytes:, new_meta_bytes:, bundle_json: }+
114
+ def build_rotated_bundle(secrets:, key_slots:, new_master_key:, new_salt:,
115
+ owner:, vault_name:, vault:)
116
+ new_secrets_bytes = Crypto.encrypt(JSON.generate(secrets), new_master_key)
117
+ new_meta_bytes = YAML.dump(
118
+ "name" => vault_name,
119
+ "created_at" => Store.new(vault_name).meta&.dig("created_at") || Time.now.utc.iso8601,
120
+ "version" => 1,
121
+ "salt" => Base64.strict_encode64(new_salt)
122
+ )
123
+
124
+ new_slots = {}
125
+ key_slots.each do |h, slot|
126
+ next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
127
+ if slot["scopes"].is_a?(Array)
128
+ # Scoped member — rebuild per-member blob. Pass the already-loaded
129
+ # plaintext `secrets` into filter so we don't re-decrypt the whole
130
+ # vault for every scoped member.
131
+ filtered = vault.filter(slot["scopes"], from: secrets)
132
+ member_key = RbNaCl::Random.random_bytes(32)
133
+ encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
134
+ new_slots[h] = {
135
+ "pub" => slot["pub"],
136
+ "enc_key" => KeySlot.create(member_key, slot["pub"]),
137
+ "scopes" => slot["scopes"],
138
+ "blob" => Base64.strict_encode64(encrypted_blob)
139
+ }
140
+ else
141
+ # Full-access member — enc_key wraps the new master key.
142
+ new_slots[h] = {
143
+ "pub" => slot["pub"],
144
+ "enc_key" => KeySlot.create(new_master_key, slot["pub"]),
145
+ "scopes" => nil,
146
+ "blob" => nil
147
+ }
148
+ end
149
+ end
150
+
151
+ bundle_json = SyncBundle.pack_v3_bytes(
152
+ meta_bytes: new_meta_bytes,
153
+ secrets_bytes: new_secrets_bytes,
154
+ owner: owner,
155
+ key_slots: new_slots
156
+ )
157
+
158
+ {
159
+ new_slots: new_slots,
160
+ new_secrets_bytes: new_secrets_bytes,
161
+ new_meta_bytes: new_meta_bytes,
162
+ bundle_json: bundle_json
163
+ }
164
+ end
165
+
166
+ # Commit a rotated bundle to local disk AFTER the remote push has
167
+ # succeeded. Writes the new ciphertext + meta.yml atomically.
168
+ def commit_rotated_bundle_locally(vault_name, new_secrets_bytes, new_meta_bytes)
169
+ store = Store.new(vault_name)
170
+ store.write_encrypted(new_secrets_bytes)
171
+ File.write(store.meta_path, new_meta_bytes)
172
+ File.chmod(0o600, store.meta_path)
173
+ end
174
+
89
175
  # Remove a member's key slot, optionally rotating the vault master key.
90
176
  # Supports partial scope removal via remove_scopes.
91
177
  def remove_key_slot(handle, vault_name, key_slots, client, rotate: false, remove_scopes: nil, owner: nil)
@@ -162,41 +248,32 @@ module LocalVault
162
248
  return
163
249
  end
164
250
 
251
+ # Decrypt with the CURRENT master key. After this point we must
252
+ # not touch the store until the push has succeeded — see
253
+ # finding #5 (transactional rotate).
165
254
  vault = Vault.new(name: vault_name, master_key: master_key)
166
255
  secrets = vault.all
167
256
 
168
257
  new_salt = Crypto.generate_salt
169
258
  new_master_key = Crypto.derive_master_key(passphrase, new_salt)
170
259
 
171
- new_json = JSON.generate(secrets)
172
- new_encrypted = Crypto.encrypt(new_json, new_master_key)
173
- store.write_encrypted(new_encrypted)
174
- store.create_meta!(salt: new_salt)
175
-
176
- new_slots = {}
177
- key_slots.each do |h, slot|
178
- next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
179
- if slot["scopes"].is_a?(Array)
180
- # Scoped member — rebuild per-member blob
181
- filtered = vault.filter(slot["scopes"])
182
- member_key = RbNaCl::Random.random_bytes(32)
183
- encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
184
- new_slots[h] = {
185
- "pub" => slot["pub"],
186
- "enc_key" => KeySlot.create(member_key, slot["pub"]),
187
- "scopes" => slot["scopes"],
188
- "blob" => Base64.strict_encode64(encrypted_blob)
189
- }
190
- else
191
- # Full-access member
192
- new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
193
- end
194
- end
260
+ bundle = build_rotated_bundle(
261
+ secrets: secrets,
262
+ key_slots: key_slots, # already has `handle` removed above
263
+ new_master_key: new_master_key,
264
+ new_salt: new_salt,
265
+ owner: owner,
266
+ vault_name: vault_name,
267
+ vault: vault
268
+ )
195
269
 
196
- blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
197
- client.push_vault(vault_name, blob)
270
+ # Push first. If this raises, nothing on local disk has changed.
271
+ client.push_vault(vault_name, bundle[:bundle_json])
272
+
273
+ # Push succeeded — commit locally.
274
+ commit_rotated_bundle_locally(vault_name, bundle[:new_secrets_bytes], bundle[:new_meta_bytes])
198
275
 
199
- if new_slots.key?(Config.inventlist_handle)
276
+ if bundle[:new_slots].key?(Config.inventlist_handle)
200
277
  SessionCache.set(vault_name, new_master_key)
201
278
  else
202
279
  SessionCache.clear(vault_name)
@@ -839,6 +839,12 @@ module LocalVault
839
839
  return
840
840
  end
841
841
 
842
+ # Decrypt the vault ONCE if we're going to need filtered blobs for
843
+ # scoped members. Without this, a `team add team:HANDLE --scope KEY`
844
+ # call against an N-member team re-decrypts the whole vault N times.
845
+ vault = scope_list ? Vault.new(name: vault_name, master_key: master_key) : nil
846
+ all_secrets = scope_list ? vault.all : nil
847
+
842
848
  added = 0
843
849
  recipients.each do |member_handle, pub_key|
844
850
  next if member_handle == Config.inventlist_handle # skip self
@@ -853,8 +859,7 @@ module LocalVault
853
859
  existing_scopes = key_slots.dig(member_handle, "scopes") || []
854
860
  merged_scopes = (existing_scopes + scope_list).uniq
855
861
 
856
- vault = Vault.new(name: vault_name, master_key: master_key)
857
- filtered = vault.filter(merged_scopes)
862
+ filtered = vault.filter(merged_scopes, from: all_secrets)
858
863
 
859
864
  member_key = RbNaCl::Random.random_bytes(32)
860
865
  encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
@@ -42,18 +42,41 @@ module LocalVault
42
42
 
43
43
  # Pack a team vault — v3 format with owner, key_slots, and per-member blobs.
44
44
  #
45
+ # Reads +store.meta_path+ and +store.read_encrypted+ off disk. For flows
46
+ # that need to build a bundle from in-memory bytes without touching disk
47
+ # first (e.g. transactional rotate), use +pack_v3_bytes+.
48
+ #
45
49
  # @param store [Store] the vault store to pack
46
50
  # @param owner [String] the owner's InventList handle
47
51
  # @param key_slots [Hash] per-user key slot data
48
52
  # @return [String] JSON string ready for upload
49
53
  def self.pack_v3(store, owner:, key_slots: {})
50
- meta_content = File.read(store.meta_path)
51
- secrets_content = store.read_encrypted || ""
54
+ pack_v3_bytes(
55
+ meta_bytes: File.read(store.meta_path),
56
+ secrets_bytes: store.read_encrypted || "",
57
+ owner: owner,
58
+ key_slots: key_slots
59
+ )
60
+ end
61
+
62
+ # Pack a v3 team bundle from raw in-memory bytes without touching disk.
63
+ #
64
+ # Used by transactional rotate flows that need to push the new bundle to
65
+ # the server BEFORE committing the rotated secrets + meta to local disk —
66
+ # if the push fails, nothing on disk changes, so the user can retry
67
+ # without being left with a locally-rotated but remotely-stale vault.
68
+ #
69
+ # @param meta_bytes [String] raw meta YAML bytes (same shape as +store.meta_path+ contents)
70
+ # @param secrets_bytes [String] raw encrypted secrets bytes
71
+ # @param owner [String] the owner's InventList handle
72
+ # @param key_slots [Hash] per-user key slot data
73
+ # @return [String] JSON string ready for upload
74
+ def self.pack_v3_bytes(meta_bytes:, secrets_bytes:, owner:, key_slots: {})
52
75
  JSON.generate(
53
76
  "version" => 3,
54
77
  "owner" => owner,
55
- "meta" => Base64.strict_encode64(meta_content),
56
- "secrets" => Base64.strict_encode64(secrets_content),
78
+ "meta" => Base64.strict_encode64(meta_bytes),
79
+ "secrets" => Base64.strict_encode64(secrets_bytes),
57
80
  "key_slots" => key_slots
58
81
  )
59
82
  end
@@ -333,13 +333,19 @@ module LocalVault
333
333
  # Scopes can be group names (returns entire nested hash) or flat key names.
334
334
  # +nil+ means full access (returns all). Empty array means nothing.
335
335
  #
336
+ # Pass +from:+ to avoid re-decrypting when you already have the plaintext
337
+ # secrets hash (e.g. inside a rotate loop that filters once per member).
338
+ # Without +from:+, this method calls +all+ on every invocation, which
339
+ # decrypts the whole vault — expensive when called in a loop.
340
+ #
336
341
  # @param scopes [Array<String>, nil] list of group/key names, or nil for all
342
+ # @param from [Hash, nil] pre-loaded secrets hash (avoids a re-decrypt)
337
343
  # @return [Hash] filtered secrets
338
- def filter(scopes)
339
- return all if scopes.nil?
344
+ def filter(scopes, from: nil)
345
+ secrets = from || all
346
+ return secrets if scopes.nil?
340
347
  return {} if scopes.empty?
341
348
 
342
- secrets = all
343
349
  result = {}
344
350
  scopes.each do |scope|
345
351
  value = secrets[scope]
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.3.5"
2
+ VERSION = "1.4.0"
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.5
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq