localvault 1.5.2 → 1.6.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/sync.rb +255 -117
- data/lib/localvault/sync_state.rb +71 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c3cc5ee20ea5b0017e6ce6b7879234c36d2c30f6a507ca8dd119c9b24a32fa9
|
|
4
|
+
data.tar.gz: 28c32bd08489c90748782c8320bca666e95a1cf71cf5deb22bcb443bf776e406
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: efd141b468fe4f8c0ec3b1b180ef492c3521f074574bbb600ec0d2cc871927df73d7db4c06093eb25444816ae0fa7b92452470f41b65ad7b959a1f6e8a1511b8
|
|
7
|
+
data.tar.gz: c385bc4e21bec7ca233633e452496589a626f6706672270efc3365b4036f1da1c0274809a96d4d4880f6ba519bf319c7f860afe644f742d01d657e3b3d8327e1
|
data/lib/localvault/cli/sync.rb
CHANGED
|
@@ -1,95 +1,234 @@
|
|
|
1
1
|
require "thor"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "digest"
|
|
3
4
|
|
|
4
5
|
module LocalVault
|
|
5
6
|
class CLI
|
|
6
7
|
class Sync < Thor
|
|
7
|
-
desc "
|
|
8
|
-
|
|
8
|
+
desc "all", "Sync all vaults bidirectionally (push local changes, pull remote changes)"
|
|
9
|
+
method_option :dry_run, type: :boolean, default: false, desc: "Show what would happen without making changes"
|
|
10
|
+
# Smart bidirectional sync for all vaults.
|
|
11
|
+
#
|
|
12
|
+
# Uses per-vault .sync_state files (written by push/pull) to track
|
|
13
|
+
# the last-synced checksum and detect what changed on each side:
|
|
9
14
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
15
|
+
# - Local-only vault → push
|
|
16
|
+
# - Remote-only vault → pull
|
|
17
|
+
# - Both exist, only local changed → push
|
|
18
|
+
# - Both exist, only remote changed → pull
|
|
19
|
+
# - Both exist, neither changed → skip
|
|
20
|
+
# - Both exist, both changed → CONFLICT (manual resolution)
|
|
21
|
+
# - Shared vault (not owned by you) → pull-only
|
|
22
|
+
def all
|
|
23
|
+
return unless logged_in?
|
|
24
|
+
|
|
25
|
+
client = ApiClient.new(token: Config.token)
|
|
26
|
+
my_handle = Config.inventlist_handle
|
|
27
|
+
result = client.list_vaults
|
|
28
|
+
remote_map = (result["vaults"] || []).each_with_object({}) { |v, h| h[v["name"]] = v }
|
|
29
|
+
local_set = Store.list_vaults.to_set
|
|
30
|
+
all_names = (remote_map.keys + local_set.to_a).uniq.sort
|
|
31
|
+
|
|
32
|
+
if all_names.empty?
|
|
33
|
+
$stdout.puts "No vaults to sync."
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
plan = all_names.map { |name| classify_vault(name, local_set, remote_map, my_handle) }
|
|
38
|
+
|
|
39
|
+
# Print plan
|
|
40
|
+
max_name = (["Vault"] + plan.map { |p| p[:name] }).map(&:length).max
|
|
41
|
+
max_action = (["Action"] + plan.map { |p| p[:action].to_s }).map(&:length).max
|
|
42
|
+
|
|
43
|
+
$stdout.puts
|
|
44
|
+
$stdout.puts " #{"Vault".ljust(max_name)} #{"Action".ljust(max_action)} Reason"
|
|
45
|
+
$stdout.puts " #{"─" * max_name} #{"─" * max_action} ──────"
|
|
46
|
+
plan.each do |p|
|
|
47
|
+
label = p[:action] == :conflict ? "CONFLICT" : p[:action].to_s
|
|
48
|
+
$stdout.puts " #{p[:name].ljust(max_name)} #{label.ljust(max_action)} #{p[:reason]}"
|
|
49
|
+
end
|
|
50
|
+
$stdout.puts
|
|
51
|
+
|
|
52
|
+
if options[:dry_run]
|
|
53
|
+
$stdout.puts "Dry run — no changes made."
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Execute
|
|
58
|
+
pushed = pulled = skipped = conflicts = errors = 0
|
|
59
|
+
plan.each do |entry|
|
|
60
|
+
case entry[:action]
|
|
61
|
+
when :push
|
|
62
|
+
if perform_push(entry[:name], client)
|
|
63
|
+
pushed += 1
|
|
64
|
+
else
|
|
65
|
+
errors += 1
|
|
66
|
+
end
|
|
67
|
+
when :pull
|
|
68
|
+
if perform_pull(entry[:name], client, force: true)
|
|
69
|
+
pulled += 1
|
|
70
|
+
else
|
|
71
|
+
errors += 1
|
|
72
|
+
end
|
|
73
|
+
when :skip
|
|
74
|
+
skipped += 1
|
|
75
|
+
when :conflict
|
|
76
|
+
conflicts += 1
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Summary
|
|
81
|
+
parts = []
|
|
82
|
+
parts << "#{pushed} pushed" if pushed > 0
|
|
83
|
+
parts << "#{pulled} pulled" if pulled > 0
|
|
84
|
+
parts << "#{skipped} up to date" if skipped > 0
|
|
85
|
+
parts << "#{errors} failed" if errors > 0
|
|
86
|
+
parts << "#{conflicts} conflict#{conflicts == 1 ? "" : "s"}" if conflicts > 0
|
|
87
|
+
$stdout.puts "Summary: #{parts.join(", ")}"
|
|
88
|
+
|
|
89
|
+
# Conflict guidance
|
|
90
|
+
if conflicts > 0
|
|
91
|
+
$stdout.puts
|
|
92
|
+
plan.select { |p| p[:action] == :conflict }.each do |p|
|
|
93
|
+
$stderr.puts " #{p[:name]} — #{p[:reason]}"
|
|
94
|
+
$stderr.puts " Resolve with:"
|
|
95
|
+
$stderr.puts " localvault sync push #{p[:name]} (keep local, overwrite remote)"
|
|
96
|
+
$stderr.puts " localvault sync pull #{p[:name]} --force (keep remote, overwrite local)"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
rescue ApiClient::ApiError => e
|
|
100
|
+
$stderr.puts "Error: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
default_task :all
|
|
104
|
+
|
|
105
|
+
desc "push [NAME]", "Push a vault to InventList cloud sync"
|
|
14
106
|
def push(vault_name = nil)
|
|
15
107
|
return unless logged_in?
|
|
108
|
+
vault_name ||= Config.default_vault
|
|
109
|
+
client = ApiClient.new(token: Config.token)
|
|
110
|
+
perform_push(vault_name, client)
|
|
111
|
+
end
|
|
16
112
|
|
|
113
|
+
desc "pull [NAME]", "Pull a vault from InventList cloud sync"
|
|
114
|
+
method_option :force, type: :boolean, default: false, desc: "Overwrite existing local vault"
|
|
115
|
+
def pull(vault_name = nil)
|
|
116
|
+
return unless logged_in?
|
|
17
117
|
vault_name ||= Config.default_vault
|
|
18
|
-
|
|
118
|
+
client = ApiClient.new(token: Config.token)
|
|
119
|
+
perform_pull(vault_name, client, force: options[:force])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
desc "status", "Show sync status for all vaults"
|
|
123
|
+
def status
|
|
124
|
+
return unless logged_in?
|
|
125
|
+
|
|
126
|
+
client = ApiClient.new(token: Config.token)
|
|
127
|
+
result = client.list_vaults
|
|
128
|
+
remote = (result["vaults"] || []).each_with_object({}) { |v, h| h[v["name"]] = v }
|
|
129
|
+
local_set = Store.list_vaults.to_set
|
|
130
|
+
all_names = (remote.keys + local_set.to_a).uniq.sort
|
|
131
|
+
|
|
132
|
+
if all_names.empty?
|
|
133
|
+
$stdout.puts "No vaults found locally or in cloud."
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
rows = all_names.map do |name|
|
|
138
|
+
r = remote[name]
|
|
139
|
+
l_exists = local_set.include?(name)
|
|
140
|
+
row_status = if r && l_exists then "synced"
|
|
141
|
+
elsif r then "remote only"
|
|
142
|
+
else "local only"
|
|
143
|
+
end
|
|
144
|
+
synced_at = r ? (r["synced_at"]&.slice(0, 10) || "—") : "—"
|
|
145
|
+
[name, row_status, synced_at]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
max_name = (["Vault"] + rows.map { |r| r[0] }).map(&:length).max
|
|
149
|
+
max_status = (["Status"] + rows.map { |r| r[1] }).map(&:length).max
|
|
150
|
+
|
|
151
|
+
$stdout.puts "#{"Vault".ljust(max_name)} #{"Status".ljust(max_status)} Synced At"
|
|
152
|
+
$stdout.puts "#{"─" * max_name} #{"─" * max_status} ─────────"
|
|
153
|
+
rows.each do |name, row_status, synced_at|
|
|
154
|
+
$stdout.puts "#{name.ljust(max_name)} #{row_status.ljust(max_status)} #{synced_at}"
|
|
155
|
+
end
|
|
156
|
+
rescue ApiClient::ApiError => e
|
|
157
|
+
$stderr.puts "Error: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.exit_on_failure?
|
|
161
|
+
true
|
|
162
|
+
end
|
|
19
163
|
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# ── Core push logic ──────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def perform_push(vault_name, client)
|
|
169
|
+
store = Store.new(vault_name)
|
|
20
170
|
unless store.exists?
|
|
21
171
|
$stderr.puts "Error: Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
|
|
22
|
-
return
|
|
172
|
+
return false
|
|
23
173
|
end
|
|
24
174
|
|
|
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
175
|
remote_data, load_error = load_remote_bundle_data(vault_name)
|
|
30
176
|
if load_error
|
|
31
177
|
$stderr.puts "Error: #{load_error}"
|
|
32
|
-
$stderr.puts "Refusing to push — cannot verify vault mode.
|
|
33
|
-
return
|
|
178
|
+
$stderr.puts "Refusing to push — cannot verify vault mode."
|
|
179
|
+
return false
|
|
34
180
|
end
|
|
181
|
+
|
|
35
182
|
handle = Config.inventlist_handle
|
|
36
183
|
|
|
37
184
|
if remote_data && remote_data[:owner]
|
|
38
|
-
|
|
39
|
-
owner = remote_data[:owner]
|
|
185
|
+
owner = remote_data[:owner]
|
|
40
186
|
key_slots = remote_data[:key_slots] || {}
|
|
41
187
|
has_scoped = key_slots.values.any? { |s| s.is_a?(Hash) && s["scopes"].is_a?(Array) }
|
|
42
|
-
my_slot
|
|
43
|
-
am_scoped
|
|
188
|
+
my_slot = key_slots[handle]
|
|
189
|
+
am_scoped = my_slot.is_a?(Hash) && my_slot["scopes"].is_a?(Array)
|
|
44
190
|
|
|
45
191
|
if am_scoped
|
|
46
192
|
$stderr.puts "Error: You have scoped access to vault '#{vault_name}'. Only the owner (@#{owner}) can push."
|
|
47
|
-
return
|
|
193
|
+
return false
|
|
48
194
|
end
|
|
49
|
-
|
|
50
195
|
if has_scoped && owner != handle
|
|
51
196
|
$stderr.puts "Error: Vault '#{vault_name}' has scoped members. Only the owner (@#{owner}) can push."
|
|
52
|
-
return
|
|
197
|
+
return false
|
|
53
198
|
end
|
|
54
199
|
|
|
55
|
-
# Authorized — push as v3, preserve key_slots
|
|
56
200
|
key_slots = bootstrap_owner_slot(key_slots, store)
|
|
57
201
|
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
58
202
|
else
|
|
59
|
-
# Personal vault — push as v1
|
|
60
203
|
blob = SyncBundle.pack(store)
|
|
61
204
|
end
|
|
62
205
|
|
|
63
|
-
client = ApiClient.new(token: Config.token)
|
|
64
206
|
client.push_vault(vault_name, blob)
|
|
65
207
|
|
|
66
|
-
|
|
208
|
+
# Record sync state
|
|
209
|
+
SyncState.new(vault_name).write!(
|
|
210
|
+
checksum: SyncState.local_checksum(store),
|
|
211
|
+
direction: "push"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
$stdout.puts " pushed #{vault_name} (#{blob.bytesize} bytes)"
|
|
215
|
+
true
|
|
67
216
|
rescue ApiClient::ApiError => e
|
|
68
|
-
$stderr.puts "Error: #{e.message}"
|
|
217
|
+
$stderr.puts "Error pushing '#{vault_name}': #{e.message}"
|
|
218
|
+
false
|
|
69
219
|
end
|
|
70
220
|
|
|
71
|
-
|
|
72
|
-
method_option :force, type: :boolean, default: false, desc: "Overwrite existing local vault"
|
|
73
|
-
# Pull a vault from InventList cloud sync to the local filesystem.
|
|
74
|
-
#
|
|
75
|
-
# Downloads the SyncBundle, writes meta.yml and secrets.enc locally, and
|
|
76
|
-
# attempts automatic unlock via key slot. Refuses to overwrite an existing
|
|
77
|
-
# local vault unless +--force+ is passed. Defaults to the configured default
|
|
78
|
-
# vault if no name is given.
|
|
79
|
-
def pull(vault_name = nil)
|
|
80
|
-
return unless logged_in?
|
|
81
|
-
|
|
82
|
-
vault_name ||= Config.default_vault
|
|
83
|
-
store = Store.new(vault_name)
|
|
221
|
+
# ── Core pull logic ──────────────────────────────────────────
|
|
84
222
|
|
|
85
|
-
|
|
223
|
+
def perform_pull(vault_name, client, force: false)
|
|
224
|
+
store = Store.new(vault_name)
|
|
225
|
+
if store.exists? && !force
|
|
86
226
|
$stderr.puts "Error: Vault '#{vault_name}' already exists locally. Use --force to overwrite."
|
|
87
|
-
return
|
|
227
|
+
return false
|
|
88
228
|
end
|
|
89
229
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
data = SyncBundle.unpack(blob, expected_name: vault_name)
|
|
230
|
+
blob = client.pull_vault(vault_name)
|
|
231
|
+
data = SyncBundle.unpack(blob, expected_name: vault_name)
|
|
93
232
|
|
|
94
233
|
FileUtils.mkdir_p(store.vault_path, mode: 0o700)
|
|
95
234
|
File.write(store.meta_path, data[:meta])
|
|
@@ -100,78 +239,100 @@ module LocalVault
|
|
|
100
239
|
store.write_encrypted(data[:secrets])
|
|
101
240
|
end
|
|
102
241
|
|
|
103
|
-
|
|
242
|
+
# Record sync state
|
|
243
|
+
SyncState.new(vault_name).write!(
|
|
244
|
+
checksum: SyncState.local_checksum(store),
|
|
245
|
+
direction: "pull"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
$stdout.puts " pulled #{vault_name}"
|
|
104
249
|
|
|
105
250
|
if try_unlock_via_key_slot(vault_name, data[:key_slots])
|
|
106
|
-
$stdout.puts "
|
|
251
|
+
$stdout.puts " unlocked via identity key"
|
|
107
252
|
else
|
|
108
|
-
$stdout.puts "
|
|
253
|
+
$stdout.puts " unlock it with: localvault unlock #{vault_name}"
|
|
109
254
|
end
|
|
255
|
+
true
|
|
110
256
|
rescue SyncBundle::UnpackError => e
|
|
111
|
-
$stderr.puts "Error: #{e.message}"
|
|
257
|
+
$stderr.puts "Error pulling '#{vault_name}': #{e.message}"
|
|
258
|
+
false
|
|
112
259
|
rescue ApiClient::ApiError => e
|
|
113
260
|
if e.status == 404
|
|
114
261
|
$stderr.puts "Error: Vault '#{vault_name}' not found in cloud."
|
|
115
262
|
else
|
|
116
|
-
$stderr.puts "Error: #{e.message}"
|
|
263
|
+
$stderr.puts "Error pulling '#{vault_name}': #{e.message}"
|
|
117
264
|
end
|
|
265
|
+
false
|
|
118
266
|
end
|
|
119
267
|
|
|
120
|
-
|
|
121
|
-
# Display sync status for all local and remote vaults.
|
|
122
|
-
#
|
|
123
|
-
# Shows a table with vault name, status (synced / remote only / local only),
|
|
124
|
-
# and last sync timestamp. Compares local vaults against the cloud inventory.
|
|
125
|
-
def status
|
|
126
|
-
return unless logged_in?
|
|
268
|
+
# ── Classification ───────────────────────────────────────────
|
|
127
269
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
all_names = (remote.keys + local_set.to_a).uniq.sort
|
|
270
|
+
def classify_vault(name, local_set, remote_map, my_handle)
|
|
271
|
+
l_exists = local_set.include?(name)
|
|
272
|
+
r_info = remote_map[name]
|
|
273
|
+
r_exists = !r_info.nil?
|
|
133
274
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
275
|
+
store = l_exists ? Store.new(name) : nil
|
|
276
|
+
ss = SyncState.new(name)
|
|
277
|
+
s_exists = ss.exists?
|
|
278
|
+
baseline = ss.last_synced_checksum
|
|
138
279
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
280
|
+
local_checksum = l_exists && store ? SyncState.local_checksum(store) : nil
|
|
281
|
+
remote_checksum = r_info&.dig("checksum")
|
|
282
|
+
|
|
283
|
+
# Ownership
|
|
284
|
+
owner_handle = r_info&.dig("owner_handle")
|
|
285
|
+
is_shared = r_info&.dig("shared") == true
|
|
286
|
+
is_read_only = is_shared || (owner_handle && owner_handle != my_handle)
|
|
287
|
+
|
|
288
|
+
action, reason = determine_action(
|
|
289
|
+
l_exists, r_exists, s_exists,
|
|
290
|
+
local_checksum, remote_checksum, baseline,
|
|
291
|
+
is_read_only
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
{ name: name, action: action, reason: reason }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def determine_action(l_exists, r_exists, s_exists,
|
|
298
|
+
local_cs, remote_cs, baseline, is_read_only)
|
|
299
|
+
# Only local
|
|
300
|
+
if l_exists && !r_exists
|
|
301
|
+
return is_read_only ? [:skip, "shared vault, local copy only"] : [:push, "local only"]
|
|
148
302
|
end
|
|
149
303
|
|
|
150
|
-
|
|
151
|
-
|
|
304
|
+
# Only remote
|
|
305
|
+
return [:pull, "remote only"] if !l_exists && r_exists
|
|
152
306
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
307
|
+
# Neither (shouldn't happen since we iterate union)
|
|
308
|
+
return [:skip, "no data"] unless l_exists && r_exists
|
|
309
|
+
|
|
310
|
+
# Both exist — no baseline (first sync for this vault)
|
|
311
|
+
unless s_exists
|
|
312
|
+
if local_cs == remote_cs || (local_cs.nil? && remote_cs.nil?)
|
|
313
|
+
return [:skip, "already in sync (first check)"]
|
|
314
|
+
else
|
|
315
|
+
return [:conflict, "both exist, no sync baseline — cannot determine which side changed"]
|
|
316
|
+
end
|
|
157
317
|
end
|
|
158
|
-
rescue ApiClient::ApiError => e
|
|
159
|
-
$stderr.puts "Error: #{e.message}"
|
|
160
|
-
end
|
|
161
318
|
|
|
162
|
-
|
|
163
|
-
|
|
319
|
+
# Both exist, have baseline
|
|
320
|
+
local_changed = local_cs != baseline
|
|
321
|
+
remote_changed = remote_cs != baseline
|
|
322
|
+
|
|
323
|
+
if !local_changed && !remote_changed
|
|
324
|
+
[:skip, "up to date"]
|
|
325
|
+
elsif local_changed && !remote_changed
|
|
326
|
+
is_read_only ? [:skip, "shared vault (local edits, pull-only)"] : [:push, "local changes"]
|
|
327
|
+
elsif !local_changed && remote_changed
|
|
328
|
+
[:pull, "remote changes"]
|
|
329
|
+
else
|
|
330
|
+
[:conflict, "both local and remote changed since last sync"]
|
|
331
|
+
end
|
|
164
332
|
end
|
|
165
333
|
|
|
166
|
-
|
|
334
|
+
# ── Helpers ──────────────────────────────────────────────────
|
|
167
335
|
|
|
168
|
-
# Try to decrypt via key slot matching the current identity.
|
|
169
|
-
#
|
|
170
|
-
# For full-access members (scopes: nil): decrypts enc_key to get the master key.
|
|
171
|
-
# For scoped members (scopes: [...]): decrypts enc_key to get the per-member key,
|
|
172
|
-
# then decrypts the per-member blob and writes it as the local vault's secrets.
|
|
173
|
-
#
|
|
174
|
-
# On success, caches the key in SessionCache. Returns true/false.
|
|
175
336
|
def try_unlock_via_key_slot(vault_name, key_slots)
|
|
176
337
|
return false unless key_slots.is_a?(Hash) && !key_slots.empty?
|
|
177
338
|
return false unless Identity.exists?
|
|
@@ -185,22 +346,16 @@ module LocalVault
|
|
|
185
346
|
decrypted_key = KeySlot.decrypt(slot["enc_key"], Identity.private_key_bytes)
|
|
186
347
|
|
|
187
348
|
if slot["scopes"].is_a?(Array) && slot["blob"].is_a?(String)
|
|
188
|
-
# Scoped member: decrypt per-member blob and write as local vault
|
|
189
349
|
blob_encrypted = Base64.strict_decode64(slot["blob"])
|
|
190
350
|
filtered_json = Crypto.decrypt(blob_encrypted, decrypted_key)
|
|
191
|
-
# Verify it's valid JSON
|
|
192
351
|
JSON.parse(filtered_json)
|
|
193
352
|
|
|
194
|
-
# Re-encrypt the filtered secrets with the member key as local "master key"
|
|
195
353
|
store = Store.new(vault_name)
|
|
196
354
|
store.write_encrypted(Crypto.encrypt(filtered_json, decrypted_key))
|
|
197
|
-
|
|
198
355
|
SessionCache.set(vault_name, decrypted_key)
|
|
199
356
|
else
|
|
200
|
-
# Full-access member: decrypted_key IS the master key
|
|
201
357
|
vault = Vault.new(name: vault_name, master_key: decrypted_key)
|
|
202
|
-
vault.all
|
|
203
|
-
|
|
358
|
+
vault.all
|
|
204
359
|
SessionCache.set(vault_name, decrypted_key)
|
|
205
360
|
end
|
|
206
361
|
true
|
|
@@ -221,16 +376,6 @@ module LocalVault
|
|
|
221
376
|
false
|
|
222
377
|
end
|
|
223
378
|
|
|
224
|
-
# Load the full unpacked remote bundle data (owner, key_slots, etc).
|
|
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.
|
|
234
379
|
def load_remote_bundle_data(vault_name)
|
|
235
380
|
client = ApiClient.new(token: Config.token)
|
|
236
381
|
blob = client.pull_vault(vault_name)
|
|
@@ -243,8 +388,6 @@ module LocalVault
|
|
|
243
388
|
[nil, "Could not parse remote bundle for '#{vault_name}': #{e.message}"]
|
|
244
389
|
end
|
|
245
390
|
|
|
246
|
-
# Load key_slots from the last pushed blob (if any).
|
|
247
|
-
# Returns {} if no remote blob or if it's a v1 bundle.
|
|
248
391
|
def load_existing_key_slots(vault_name)
|
|
249
392
|
client = ApiClient.new(token: Config.token)
|
|
250
393
|
blob = client.pull_vault(vault_name)
|
|
@@ -255,17 +398,12 @@ module LocalVault
|
|
|
255
398
|
{}
|
|
256
399
|
end
|
|
257
400
|
|
|
258
|
-
# Add the owner's key slot if identity exists and no slot is present.
|
|
259
|
-
# Requires the vault to be unlockable (needs master key for encryption).
|
|
260
401
|
def bootstrap_owner_slot(key_slots, store)
|
|
261
402
|
return key_slots unless Identity.exists?
|
|
262
403
|
handle = Config.inventlist_handle
|
|
263
404
|
return key_slots unless handle
|
|
264
|
-
|
|
265
|
-
# Already has owner slot — don't churn
|
|
266
405
|
return key_slots if key_slots.key?(handle)
|
|
267
406
|
|
|
268
|
-
# Need the master key to create the slot — try SessionCache
|
|
269
407
|
master_key = SessionCache.get(store.vault_name)
|
|
270
408
|
return key_slots unless master_key
|
|
271
409
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module LocalVault
|
|
6
|
+
# Per-vault sync state tracking. Stores the checksum of secrets.enc at the
|
|
7
|
+
# time of the last successful push or pull, so the next `localvault sync`
|
|
8
|
+
# can determine whether local, remote, or both sides have changed.
|
|
9
|
+
#
|
|
10
|
+
# Stored as `~/.localvault/vaults/<name>/.sync_state` (YAML, mode 0600).
|
|
11
|
+
# Separate from meta.yml because meta.yml is part of the SyncBundle and
|
|
12
|
+
# including sync bookkeeping there would create a checksum feedback loop.
|
|
13
|
+
class SyncState
|
|
14
|
+
FILENAME = ".sync_state"
|
|
15
|
+
|
|
16
|
+
attr_reader :vault_name
|
|
17
|
+
|
|
18
|
+
def initialize(vault_name)
|
|
19
|
+
@vault_name = vault_name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def path
|
|
23
|
+
File.join(Config.vaults_path, vault_name, FILENAME)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exists?
|
|
27
|
+
File.exist?(path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Hash, nil] parsed YAML data or nil
|
|
31
|
+
def read
|
|
32
|
+
return nil unless exists?
|
|
33
|
+
YAML.safe_load_file(path)
|
|
34
|
+
rescue Psych::SyntaxError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def last_synced_checksum
|
|
39
|
+
read&.dig("last_synced_checksum")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def last_synced_at
|
|
43
|
+
read&.dig("last_synced_at")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Record a successful sync operation.
|
|
47
|
+
#
|
|
48
|
+
# @param checksum [String] SHA256 hex of the local secrets.enc
|
|
49
|
+
# @param direction [String] "push" or "pull"
|
|
50
|
+
def write!(checksum:, direction:)
|
|
51
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
52
|
+
data = {
|
|
53
|
+
"last_synced_checksum" => checksum,
|
|
54
|
+
"last_synced_at" => Time.now.utc.iso8601,
|
|
55
|
+
"direction" => direction
|
|
56
|
+
}
|
|
57
|
+
File.write(path, YAML.dump(data))
|
|
58
|
+
File.chmod(0o600, path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Compute the SHA256 hex digest of a vault's local secrets.enc.
|
|
62
|
+
#
|
|
63
|
+
# @param store [Store] vault store
|
|
64
|
+
# @return [String, nil] hex digest or nil if no secrets file
|
|
65
|
+
def self.local_checksum(store)
|
|
66
|
+
bytes = store.read_encrypted
|
|
67
|
+
return nil if bytes.nil? || bytes.empty?
|
|
68
|
+
Digest::SHA256.hexdigest(bytes)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/localvault/version.rb
CHANGED
data/lib/localvault.rb
CHANGED
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.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nauman Tariq
|
|
@@ -122,6 +122,7 @@ files:
|
|
|
122
122
|
- lib/localvault/share_crypto.rb
|
|
123
123
|
- lib/localvault/store.rb
|
|
124
124
|
- lib/localvault/sync_bundle.rb
|
|
125
|
+
- lib/localvault/sync_state.rb
|
|
125
126
|
- lib/localvault/vault.rb
|
|
126
127
|
- lib/localvault/version.rb
|
|
127
128
|
homepage: https://github.com/inventlist/localvault
|