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 +4 -4
- data/lib/localvault/cli/team.rb +20 -20
- data/lib/localvault/cli/team_helpers.rb +104 -27
- data/lib/localvault/cli.rb +7 -2
- data/lib/localvault/sync_bundle.rb +27 -4
- data/lib/localvault/vault.rb +9 -3
- 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: 6ec85a7f67eb8705f2078a55eeeb1a24997f61e965a76284ef9a9eb314eef032
|
|
4
|
+
data.tar.gz: d799cbfabdced0595eb91132e35daa2930f19a705cc189357cb9a5c3b794cc05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74123bc8d783ace6773c11223486974d7210870903d5901d0f1c3410266d7d9c29d2ffe9f0bead2137629b3132495854c490c1a771becf06355a26b527afa435
|
|
7
|
+
data.tar.gz: b95d606abec4fe145d23b7028a5091f4ec1fab3a9b5d6df3a8ae201b238b9a334a8c07033892a6d3ae36d73f1e116cc3f0c72dcde955f1109f09d58405025021
|
data/lib/localvault/cli/team.rb
CHANGED
|
@@ -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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
197
|
-
client.push_vault(vault_name,
|
|
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)
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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(
|
|
56
|
-
"secrets" => Base64.strict_encode64(
|
|
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
|
data/lib/localvault/vault.rb
CHANGED
|
@@ -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
|
-
|
|
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]
|
data/lib/localvault/version.rb
CHANGED