localvault 1.13.0 → 1.15.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: aa374ffc862a0591ab2936ea836ea4f04e96d3d86acd3591fb1b57ed91ea1253
4
- data.tar.gz: f8ac6c6bd7e329d395801741f1c9e61f121339d5f05d013623faa6e2f7d255e0
3
+ metadata.gz: 0cfbfdee976a274c577ccb8e782e21f3d64bd1b71ba30e37a5bb583fc1ca54c4
4
+ data.tar.gz: 9dd3b6663bd7e0eb15d2a6e00fd4d04afb6c0de3962ed76f219418fc4c01207a
5
5
  SHA512:
6
- metadata.gz: 3cfb2aaccbfb4c0fc8882d6115dc3ca7ab8802f060601f3083ec9d1c36f76b1688ea2649eea70edaaf18d463f87e35605311a7a5492094f1e22d70326771b1ae
7
- data.tar.gz: 874a4be548a5f7498d9fa520be60485f762df2f4ebe2e4612816b16617bf072cb1c735f73375ace33638c73d309d0a86c294095ea1c777ccfc57a3b9666b1eca
6
+ metadata.gz: c2849e50bf55078b747403fdc65bde8ae1cabb4c304fd22a422c92f4dd12e2a482ddab6d731b6c18d9cba077e86b93eefdb54dffd2724d28ae087a543efcadbc
7
+ data.tar.gz: 7d42cfe5bc6c237e6d9b4d9aeefef4e223ff965471c6b417fbf15e7841e1d05a74edfed09221b89b300902ed88ec67afe2e565bd03c46729a2cc5f90debb573c
data/README.md CHANGED
@@ -125,6 +125,8 @@ localvault exec -- rails server
125
125
  | `sync --dry-run` | Preview what sync would do without making changes |
126
126
  | `sync push [NAME]` | Push one vault to cloud |
127
127
  | `sync pull [NAME]` | Pull one vault from cloud (auto-unlocks if you have a key slot) |
128
+ | `sync diff [NAME]` | Show which keys differ between local and cloud (names only, never values) |
129
+ | `sync merge [NAME]` | Three-way merge local and cloud key by key, then push (`--prefer local\|remote`, `--local KEY`, `--remote KEY`) |
128
130
  | `sync status` | Show sync state for all vaults |
129
131
 
130
132
  ### Team Sharing (v1.3.0)
@@ -201,6 +203,30 @@ localvault sync --dry-run
201
203
  # staging pull remote changes
202
204
  ```
203
205
 
206
+ ### Resolving a conflict
207
+
208
+ When a vault changed on both machines since the last sync, `sync` stops and
209
+ shows which keys differ. Values are never printed.
210
+
211
+ ```bash
212
+ localvault sync
213
+ # devops CONFLICT both local and remote changed since last sync
214
+ # cloud added REMOTE_ONLY will take cloud
215
+ # local added LOCAL_ONLY will keep local
216
+ # CONFLICT changed on both sides SHARED needs a choice
217
+
218
+ localvault sync merge devops # clean merge: keeps every change from both sides
219
+ localvault sync merge devops --prefer remote # conflicting keys: take cloud
220
+ localvault sync merge devops --local SHARED # or decide per key (--local / --remote, repeatable)
221
+ localvault sync diff devops # just look, change nothing
222
+ ```
223
+
224
+ The merge is three-way: each sync records an encrypted snapshot of the
225
+ last-synced state, so a key edited on only one side applies automatically and
226
+ only keys edited differently on both sides need a choice. Deleting a key on one
227
+ side while the other side edits it is also a conflict. `sync push` and
228
+ `sync pull --force` still take one side wholesale.
229
+
204
230
  ## Team Sharing
205
231
 
206
232
  Share vault access with teammates using X25519 asymmetric encryption. The server never sees plaintext.
@@ -0,0 +1,58 @@
1
+ module LocalVault
2
+ class CLI
3
+ # Finds a Rails app's credentials keys so they can be stored in the vault
4
+ # and injected as RAILS_MASTER_KEY instead of living in a file.
5
+ #
6
+ # Reads only. Removing the key files is the operator's call — this reports
7
+ # what is still on disk and leaves it alone.
8
+ class RailsImport
9
+ Key = Struct.new(:environment, :path, :vault_key, :value, keyword_init: true)
10
+
11
+ MASTER_KEY_PATH = "config/master.key".freeze
12
+ CREDENTIALS_PATH = "config/credentials.yml.enc".freeze
13
+
14
+ def initialize(root = Dir.pwd)
15
+ @root = root
16
+ end
17
+
18
+ def rails_app?
19
+ File.exist?(File.join(@root, CREDENTIALS_PATH)) ||
20
+ File.exist?(File.join(@root, MASTER_KEY_PATH)) ||
21
+ Dir.exist?(File.join(@root, "config/credentials"))
22
+ end
23
+
24
+ # Every key file this app has: the default master key plus one per
25
+ # environment (config/credentials/production.key).
26
+ def keys
27
+ [master_key, *environment_keys].compact
28
+ end
29
+
30
+ private
31
+
32
+ def master_key
33
+ path = File.join(@root, MASTER_KEY_PATH)
34
+ return nil unless File.exist?(path)
35
+
36
+ value = File.read(path).strip
37
+ return nil if value.empty?
38
+
39
+ Key.new(environment: nil, path: MASTER_KEY_PATH, vault_key: "rails.master_key", value: value)
40
+ end
41
+
42
+ def environment_keys
43
+ Dir.glob(File.join(@root, "config/credentials/*.key")).sort.filter_map do |path|
44
+ environment = File.basename(path, ".key")
45
+ value = File.read(path).strip
46
+ next if value.empty?
47
+
48
+ Key.new(
49
+ environment: environment,
50
+ path: "config/credentials/#{environment}.key",
51
+ vault_key: "rails.#{environment}_key",
52
+ value: value
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,10 +1,14 @@
1
1
  require "thor"
2
2
  require "fileutils"
3
3
  require "digest"
4
+ require "io/console"
5
+ require_relative "team_helpers"
4
6
 
5
7
  module LocalVault
6
8
  class CLI
7
9
  class Sync < Thor
10
+ include LocalVault::CLI::TeamHelpers
11
+
8
12
  desc "all", "Sync all vaults bidirectionally (push local changes, pull remote changes)"
9
13
  method_option :dry_run, type: :boolean, default: false, desc: "Show what would happen without making changes"
10
14
  # Smart bidirectional sync for all vaults.
@@ -18,7 +22,7 @@ module LocalVault
18
22
  # - Both exist, only remote changed → pull
19
23
  # - Both exist, neither changed → skip
20
24
  # - Both exist, no baseline but secrets identical → adopt (record baseline)
21
- # - Both exist, both changed → CONFLICT (manual resolution)
25
+ # - Both exist, both changed → CONFLICT (resolve with sync merge / push / pull --force)
22
26
  # - Shared vault (not owned by you) → pull-only
23
27
  def all
24
28
  return unless logged_in?
@@ -94,14 +98,20 @@ module LocalVault
94
98
  parts << "#{conflicts} conflict#{conflicts == 1 ? "" : "s"}" if conflicts > 0
95
99
  $stdout.puts "Summary: #{parts.join(", ")}"
96
100
 
97
- # Conflict guidance
101
+ # Conflict guidance — key names only, never values
98
102
  if conflicts > 0
99
103
  $stdout.puts
100
104
  plan.select { |p| p[:action] == :conflict }.each do |p|
101
105
  $stderr.puts " #{p[:name]} — #{p[:reason]}"
102
- $stderr.puts " Resolve with:"
103
- $stderr.puts " localvault sync push #{p[:name]} (keep local, overwrite remote)"
104
- $stderr.puts " localvault sync pull #{p[:name]} --force (keep remote, overwrite local)"
106
+ result = quiet_merge_preview(p[:name], client)
107
+ if result
108
+ print_merge_report(result, indent: " ", io: $stderr)
109
+ print_resolution_help(p[:name], result, io: $stderr)
110
+ else
111
+ $stderr.puts " See which keys differ (no values shown):"
112
+ $stderr.puts " localvault sync diff #{p[:name]}"
113
+ print_resolution_help(p[:name], nil, io: $stderr)
114
+ end
105
115
  end
106
116
  end
107
117
  rescue ApiClient::ApiError => e
@@ -129,6 +139,98 @@ module LocalVault
129
139
  perform_pull(vault_name, client, force: options[:force])
130
140
  end
131
141
 
142
+ desc "diff [NAME]", "Show which keys differ between local and cloud (names only, no values)"
143
+ method_option :vault, type: :string, aliases: "-v", desc: "Vault name (same as NAME)"
144
+ def diff(vault_name = nil)
145
+ return unless logged_in?
146
+ vault_name ||= options[:vault] || Config.default_vault
147
+ client = ApiClient.new(token: Config.token)
148
+
149
+ result = build_merge(vault_name, client)
150
+ return false unless result
151
+
152
+ if result.local_changes.empty? && result.remote_changes.empty? &&
153
+ result.same_changes.empty? && result.conflicts.empty?
154
+ $stdout.puts "#{vault_name}: local and cloud hold the same secrets."
155
+ return true
156
+ end
157
+
158
+ $stdout.puts "#{vault_name} — three-way diff against last sync (values never shown)"
159
+ print_merge_report(result, indent: " ", io: $stdout)
160
+ $stdout.puts
161
+ print_resolution_help(vault_name, result, io: $stdout)
162
+ true
163
+ rescue ApiClient::ApiError => e
164
+ $stderr.puts "Error: #{e.message}"
165
+ false
166
+ end
167
+
168
+ desc "merge [NAME]", "Three-way merge local and cloud, then push the result"
169
+ method_option :vault, type: :string, aliases: "-v", desc: "Vault name (same as NAME)"
170
+ method_option :prefer, type: :string, enum: %w[local remote], desc: "Resolve every conflicting key from this side"
171
+ method_option :local, type: :array, default: [], desc: "Keys to keep from local (repeatable)"
172
+ method_option :remote, type: :array, default: [], desc: "Keys to take from cloud (repeatable)"
173
+ method_option :dry_run, type: :boolean, default: false, desc: "Show the merge plan without writing or pushing"
174
+ method_option :push, type: :boolean, default: true, desc: "Push after merging (--no-push keeps it local)"
175
+ # Merge cloud changes into the local vault key by key using the
176
+ # ancestor snapshot recorded at the last sync. Non-conflicting changes
177
+ # from both sides apply automatically; a key changed differently on
178
+ # both sides needs +--prefer+ or a per-key +--local+/+--remote+ pick.
179
+ def merge(vault_name = nil)
180
+ return unless logged_in?
181
+ vault_name ||= options[:vault] || Config.default_vault
182
+ client = ApiClient.new(token: Config.token)
183
+
184
+ both = options[:local] & options[:remote]
185
+ unless both.empty?
186
+ $stderr.puts "Error: #{both.join(", ")} given to both --local and --remote. Pick one side per key."
187
+ return false
188
+ end
189
+ picks = {}
190
+ options[:local].each { |k| picks[k] = :local }
191
+ options[:remote].each { |k| picks[k] = :remote }
192
+ prefer = options[:prefer]&.to_sym
193
+
194
+ result = build_merge(vault_name, client, prefer: prefer, picks: picks)
195
+ return false unless result
196
+
197
+ unknown = picks.keys - result.conflict_keys_before_picks
198
+ unless unknown.empty?
199
+ $stderr.puts "Warning: #{unknown.join(", ")} #{unknown.size == 1 ? "is" : "are"} not in conflict — ignored."
200
+ end
201
+
202
+ $stdout.puts "#{vault_name} — merge plan (values never shown)"
203
+ print_merge_report(result, indent: " ", io: $stdout)
204
+
205
+ unless result.clean?
206
+ $stdout.puts
207
+ $stderr.puts "Error: #{result.conflicts.size} key#{result.conflicts.size == 1 ? "" : "s"} changed on both sides. Choose a side:"
208
+ print_resolution_help(vault_name, result, io: $stderr, merge_only: true)
209
+ return false
210
+ end
211
+
212
+ if options[:dry_run]
213
+ $stdout.puts
214
+ $stdout.puts "Dry run — no changes made."
215
+ return true
216
+ end
217
+
218
+ master_key = @merge_master_key
219
+ vault = Vault.new(name: vault_name, master_key: master_key)
220
+ vault.replace(result.merged)
221
+ $stdout.puts " merged #{vault_name} locally"
222
+
223
+ unless options[:push]
224
+ $stdout.puts " not pushed (--no-push). Push later with: localvault sync push #{vault_name}"
225
+ return true
226
+ end
227
+
228
+ perform_push(vault_name, client)
229
+ rescue ApiClient::ApiError => e
230
+ $stderr.puts "Error: #{e.message}"
231
+ false
232
+ end
233
+
132
234
  desc "status", "Show sync status for all vaults"
133
235
  def status
134
236
  return unless logged_in?
@@ -217,6 +319,8 @@ module LocalVault
217
319
  end
218
320
 
219
321
  key_slots = bootstrap_owner_slot(key_slots, store)
322
+ key_slots = refresh_scoped_slots(key_slots, store)
323
+ return false unless key_slots
220
324
  blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
221
325
  else
222
326
  blob = SyncBundle.pack(store)
@@ -224,11 +328,8 @@ module LocalVault
224
328
 
225
329
  client.push_vault(vault_name, blob)
226
330
 
227
- # Record sync state
228
- SyncState.new(vault_name).write!(
229
- checksum: SyncState.local_checksum(store),
230
- direction: "push"
231
- )
331
+ # Record sync state + ancestor snapshot for future merges
332
+ SyncState.new(vault_name).record!(store, direction: "push")
232
333
 
233
334
  $stdout.puts " pushed #{vault_name} (#{blob.bytesize} bytes)"
234
335
  true
@@ -266,11 +367,8 @@ module LocalVault
266
367
  store.write_encrypted(data[:secrets])
267
368
  end
268
369
 
269
- # Record sync state
270
- SyncState.new(vault_name).write!(
271
- checksum: SyncState.local_checksum(store),
272
- direction: "pull"
273
- )
370
+ # Record sync state + ancestor snapshot for future merges
371
+ SyncState.new(vault_name).record!(store, direction: "pull")
274
372
 
275
373
  $stdout.puts " pulled #{vault_name}"
276
374
 
@@ -401,10 +499,7 @@ module LocalVault
401
499
  # @return [Boolean] true on success, false on any error
402
500
  def perform_adopt(vault_name)
403
501
  store = Store.new(vault_name)
404
- SyncState.new(vault_name).write!(
405
- checksum: SyncState.local_checksum(store),
406
- direction: "adopt"
407
- )
502
+ SyncState.new(vault_name).record!(store, direction: "adopt")
408
503
  $stdout.puts " baselined #{vault_name} (already in sync)"
409
504
  true
410
505
  rescue StandardError => e
@@ -462,6 +557,161 @@ module LocalVault
462
557
  false
463
558
  end
464
559
 
560
+ # ── Merge helpers ────────────────────────────────────────────
561
+
562
+ # Decrypt base / local / remote and run the three-way merge. Prompts
563
+ # for the passphrase when the vault isn't unlocked. Returns nil (after
564
+ # printing the reason) when anything needed is missing.
565
+ #
566
+ # @return [SyncMerge::Result, nil]
567
+ def build_merge(vault_name, client, prefer: nil, picks: {})
568
+ store = Store.new(vault_name)
569
+ unless store.exists?
570
+ $stderr.puts "Error: Vault '#{vault_name}' does not exist locally. Use: localvault sync pull #{vault_name}"
571
+ return nil
572
+ end
573
+
574
+ master_key = ensure_master_key(vault_name)
575
+ return nil unless master_key
576
+ @merge_master_key = master_key
577
+
578
+ blob = client.pull_vault(vault_name)
579
+ unless blob.is_a?(String) && !blob.empty?
580
+ $stderr.puts "Error: Vault '#{vault_name}' has no cloud copy. Use: localvault sync push #{vault_name}"
581
+ return nil
582
+ end
583
+ data = SyncBundle.unpack(blob, expected_name: vault_name)
584
+
585
+ # Team vaults: only the owner holds the full plaintext and may push, so
586
+ # only the owner can merge. Members take the cloud copy instead.
587
+ handle = Config.inventlist_handle
588
+ if data[:owner] && data[:owner] != handle
589
+ my_slot = (data[:key_slots] || {})[handle]
590
+ access = my_slot.is_a?(Hash) && my_slot["scopes"].is_a?(Array) ? "scoped" : "member"
591
+ $stderr.puts "Error: '#{vault_name}' is a team vault owned by @#{data[:owner]}; you have #{access} access."
592
+ $stderr.puts "Only the owner can merge or push. Take the cloud copy with:"
593
+ $stderr.puts " localvault sync pull #{vault_name} --force"
594
+ return nil
595
+ end
596
+
597
+ merge_secrets(vault_name, store, master_key, data[:secrets], prefer: prefer, picks: picks)
598
+ rescue SyncBundle::UnpackError => e
599
+ $stderr.puts "Error: Could not parse cloud bundle for '#{vault_name}': #{e.message}"
600
+ nil
601
+ rescue Crypto::DecryptionError
602
+ $stderr.puts "Error: The cloud copy of '#{vault_name}' is encrypted with a different key (rekeyed or rotated elsewhere)."
603
+ $stderr.puts "Merge is not possible. Take one side:"
604
+ print_resolution_help(vault_name, nil, io: $stderr)
605
+ nil
606
+ rescue ApiClient::ApiError => e
607
+ if e.status == 404
608
+ $stderr.puts "Error: Vault '#{vault_name}' not found in cloud. Use: localvault sync push #{vault_name}"
609
+ nil
610
+ else
611
+ raise
612
+ end
613
+ end
614
+
615
+ # Same as +build_merge+ but never prompts and never prints — used by
616
+ # +sync all+ to enrich conflict output when the key is already cached.
617
+ #
618
+ # @return [SyncMerge::Result, nil]
619
+ def quiet_merge_preview(vault_name, client)
620
+ master_key = SessionCache.get(vault_name)
621
+ return nil unless master_key
622
+ store = Store.new(vault_name)
623
+ blob = client.pull_vault(vault_name)
624
+ return nil unless blob.is_a?(String) && !blob.empty?
625
+ remote_bytes = SyncBundle.unpack(blob)[:secrets]
626
+ merge_secrets(vault_name, store, master_key, remote_bytes)
627
+ rescue ApiClient::ApiError, SyncBundle::UnpackError, Crypto::DecryptionError,
628
+ SyncMerge::StructureError, JSON::ParserError
629
+ nil
630
+ end
631
+
632
+ def merge_secrets(vault_name, store, master_key, remote_bytes, prefer: nil, picks: {})
633
+ base_bytes = SyncState.new(vault_name).read_base
634
+ base = base_bytes ? decrypt_secrets(base_bytes, master_key) : nil
635
+ local = decrypt_secrets(store.read_encrypted, master_key)
636
+ remote = decrypt_secrets(remote_bytes, master_key)
637
+ result = SyncMerge.merge(base, local, remote, prefer: prefer, picks: picks)
638
+ # Remember what needed a choice before picks so the CLI can validate them.
639
+ unresolved = (prefer || !picks.empty?) ? SyncMerge.merge(base, local, remote) : result
640
+ result.conflict_keys_before_picks = unresolved.conflicts.map(&:key)
641
+ result
642
+ rescue JSON::ParserError
643
+ # Never echo the parser's excerpt: it would contain decrypted bytes.
644
+ $stderr.puts "Error: decrypted secrets for '#{vault_name}' are not valid JSON (corrupt vault data). Merge aborted."
645
+ nil
646
+ rescue SyncMerge::StructureError => e
647
+ $stderr.puts "Error: cannot merge '#{vault_name}': #{e.message}."
648
+ $stderr.puts "Rename one of them on one side, or take a whole side:"
649
+ print_resolution_help(vault_name, nil, io: $stderr)
650
+ nil
651
+ end
652
+
653
+ def decrypt_secrets(bytes, master_key)
654
+ return {} if bytes.nil? || bytes.empty?
655
+ JSON.parse(Crypto.decrypt(bytes, master_key))
656
+ end
657
+
658
+ # Print the key-level report. Only key names and change kinds appear.
659
+ def print_merge_report(result, indent:, io:)
660
+ lines = []
661
+ result.remote_changes.each { |c| lines << ["cloud", c.kind.to_s, c.key, "will take cloud"] }
662
+ result.local_changes.each { |c| lines << ["local", c.kind.to_s, c.key, "will keep local"] }
663
+ result.same_changes.each { |c| lines << ["both", c.kind.to_s, c.key, "identical, keep"] }
664
+ result.conflicts.each { |c| lines << ["CONFLICT", conflict_label(c.kind), c.key, "needs a choice"] }
665
+
666
+ if lines.empty?
667
+ io.puts "#{indent}no key differences"
668
+ return
669
+ end
670
+
671
+ w0 = lines.map { |l| l[0].length }.max
672
+ w1 = lines.map { |l| l[1].length }.max
673
+ w2 = lines.map { |l| l[2].length }.max
674
+ lines.each do |side, kind, key, note|
675
+ io.puts "#{indent}#{side.ljust(w0)} #{kind.ljust(w1)} #{key.ljust(w2)} #{note}"
676
+ end
677
+ end
678
+
679
+ def conflict_label(kind)
680
+ case kind
681
+ when :local_deleted then "deleted here, changed in cloud"
682
+ when :remote_deleted then "changed here, deleted in cloud"
683
+ when :structure then "secret vs group, differs per side"
684
+ else "changed on both sides"
685
+ end
686
+ end
687
+
688
+ # Print the exact commands that resolve this vault's state.
689
+ def print_resolution_help(vault_name, result, io:, merge_only: false)
690
+ if result.nil? || result.clean?
691
+ io.puts " Merge (keeps every change from both sides):" unless merge_only
692
+ io.puts " localvault sync merge #{vault_name}"
693
+ else
694
+ keys = result.conflicts.map(&:key)
695
+ io.puts " Merge, choosing a side for the conflicting key#{keys.size == 1 ? "" : "s"}:"
696
+ io.puts " localvault sync merge #{vault_name} --prefer local"
697
+ io.puts " localvault sync merge #{vault_name} --prefer remote"
698
+ io.puts " Or pick per key:"
699
+ example = keys.size == 1 ? "--local #{keys.first}" : "--local #{keys.first} --remote #{keys[1]}"
700
+ io.puts " localvault sync merge #{vault_name} #{example}"
701
+ end
702
+ return if merge_only
703
+ io.puts " Or take one side entirely:"
704
+ io.puts " localvault sync push #{vault_name} (keep local, overwrite cloud)"
705
+ io.puts " localvault sync pull #{vault_name} --force (keep cloud, overwrite local)"
706
+ end
707
+
708
+ def prompt_passphrase(msg = "Passphrase: ")
709
+ IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
710
+ rescue Interrupt
711
+ $stderr.puts
712
+ ""
713
+ end
714
+
465
715
  def logged_in?
466
716
  return true if Config.token
467
717
 
@@ -497,6 +747,45 @@ module LocalVault
497
747
  {}
498
748
  end
499
749
 
750
+ # Scoped members read a per-member blob, not the vault ciphertext, so a
751
+ # push must rebuild those blobs from the current plaintext or members
752
+ # keep seeing the pre-push values. Needs the master key; when the vault
753
+ # is locked the push is refused rather than publishing inconsistent
754
+ # views. Returns nil when the push must not proceed.
755
+ #
756
+ # @return [Hash, nil] refreshed key slots, or nil to abort the push
757
+ def refresh_scoped_slots(key_slots, store)
758
+ scoped = key_slots.select { |_, s| s.is_a?(Hash) && s["scopes"].is_a?(Array) && s["pub"].is_a?(String) }
759
+ return key_slots if scoped.empty?
760
+
761
+ master_key = SessionCache.get(store.vault_name)
762
+ unless master_key
763
+ $stderr.puts "Error: '#{store.vault_name}' has scoped members whose copies must be rebuilt on push, but the vault is locked."
764
+ $stderr.puts "Run: localvault unlock #{store.vault_name} && localvault sync push #{store.vault_name}"
765
+ return nil
766
+ end
767
+
768
+ vault = Vault.new(name: store.vault_name, master_key: master_key)
769
+ secrets = vault.all
770
+ scoped.each do |h, slot|
771
+ filtered = vault.filter(slot["scopes"], from: secrets)
772
+ member_key = RbNaCl::Random.random_bytes(32)
773
+ key_slots[h] = slot.merge(
774
+ "enc_key" => KeySlot.create(member_key, slot["pub"]),
775
+ "blob" => Base64.strict_encode64(Crypto.encrypt(JSON.generate(filtered), member_key))
776
+ )
777
+ rescue ArgumentError, KeySlot::DecryptionError
778
+ # A member whose stored public key is unusable could never decrypt
779
+ # anything anyway; keep their old slot rather than block the owner.
780
+ $stderr.puts " warning: @#{h}'s public key is invalid; their scoped copy was not refreshed."
781
+ end
782
+ key_slots
783
+ rescue Crypto::DecryptionError, JSON::ParserError => e
784
+ # Fixed message: a parser error's excerpt would contain decrypted bytes.
785
+ $stderr.puts "Error: could not read '#{store.vault_name}' to rebuild scoped members' copies (#{e.class.name.split("::").last}). Push refused."
786
+ nil
787
+ end
788
+
500
789
  def bootstrap_owner_slot(key_slots, store)
501
790
  return key_slots unless Identity.exists?
502
791
  handle = Config.inventlist_handle
@@ -45,6 +45,10 @@ module LocalVault
45
45
  rescue Crypto::DecryptionError
46
46
  $stderr.puts "Error: Wrong passphrase for vault '#{vault_name}'."
47
47
  nil
48
+ rescue JSON::ParserError
49
+ # Never echo the parser's excerpt: it would contain decrypted bytes.
50
+ $stderr.puts "Error: Vault '#{vault_name}' decrypted but its data is not valid JSON (corrupt vault data)."
51
+ nil
48
52
  end
49
53
 
50
54
  def load_key_slots(client, vault_name)
@@ -116,7 +116,7 @@ module LocalVault
116
116
  # disappearing from help.
117
117
  HELP_SECTIONS = [
118
118
  ["GETTING STARTED", %w[login config init demo]],
119
- ["SECRETS", %w[set get show reveal groups list delete import env exec]],
119
+ ["SECRETS", %w[set get show reveal groups list delete import env exec rails]],
120
120
  ["VAULT MANAGEMENT", %w[vaults switch rekey unlock lock reset rename copy]],
121
121
  ["SYNC (requires localvault login)", %w[sync]],
122
122
  ["TEAM SHARING (requires localvault login)", %w[dashboard verify add remove team]],
@@ -501,6 +501,12 @@ module LocalVault
501
501
  .render
502
502
 
503
503
  $stdout.puts table
504
+
505
+ strays = Store.stray_entries
506
+ unless strays.empty?
507
+ $stderr.puts "Ignored #{strays.size} non-vault entr#{strays.size == 1 ? "y" : "ies"} in #{Config.vaults_path}: #{strays.map { |s| "'#{s}'" }.join(", ")}"
508
+ $stderr.puts "A vault is a folder named [a-z0-9_-] with a meta.yml inside."
509
+ end
504
510
  end
505
511
 
506
512
  desc "unlock [VAULT]", "Cache passphrase for session and output session token"
@@ -807,6 +813,7 @@ module LocalVault
807
813
  require_relative "cli/sync"
808
814
  require_relative "cli/guard"
809
815
  require_relative "cli/identity_cmd"
816
+ require_relative "cli/rails_import"
810
817
 
811
818
  # Thor 1.5 injects a `tree` command into every class. Inside a namespace it
812
819
  # lists under a name that isn't even callable (`identity_command tree`), and
@@ -1649,6 +1656,67 @@ module LocalVault
1649
1656
  end
1650
1657
  end
1651
1658
 
1659
+ desc "rails", "Import a Rails app's credentials keys into the vault"
1660
+ long_desc <<~DESC
1661
+ Store this Rails app's credentials keys in the vault so you can run it
1662
+ with no key file on disk. Rails reads ENV["RAILS_MASTER_KEY"] whenever
1663
+ config/master.key is absent, so injecting that one variable is enough.
1664
+
1665
+ \x05 localvault rails # import config/master.key (and per-env keys)
1666
+ \x05 localvault rails --check # show what would be imported
1667
+
1668
+ Then run your app with the key injected, never written anywhere:
1669
+
1670
+ \x05 localvault exec --profile rails -- bin/rails server
1671
+ \x05 localvault exec --profile rails -- bin/rails credentials:edit
1672
+
1673
+ For a per-environment key (config/credentials/production.key), map it —
1674
+ Rails reads no variable other than RAILS_MASTER_KEY:
1675
+
1676
+ \x05 localvault exec --map rails.production_key=RAILS_MASTER_KEY -- bin/rails console
1677
+
1678
+ Key files are left exactly as they are. Deleting them is your call.
1679
+ DESC
1680
+ method_option :check, type: :boolean, default: false, desc: "List what would be imported without writing"
1681
+ def rails
1682
+ importer = RailsImport.new
1683
+ unless importer.rails_app?
1684
+ abort_with "No Rails app here — expected config/credentials.yml.enc or config/master.key in #{Dir.pwd}"
1685
+ return CommandStatus.error
1686
+ end
1687
+
1688
+ keys = importer.keys
1689
+ if keys.empty?
1690
+ $stdout.puts "No credentials key files found (config/master.key, config/credentials/*.key)."
1691
+ $stdout.puts "Nothing to import — the app may already run from RAILS_MASTER_KEY."
1692
+ return CommandStatus.ok
1693
+ end
1694
+
1695
+ if options[:check]
1696
+ $stdout.puts "Would import into vault '#{options[:vault] || Config.default_vault}':"
1697
+ keys.each { |key| $stdout.puts " #{key.path} -> #{key.vault_key}" }
1698
+ return CommandStatus.ok
1699
+ end
1700
+
1701
+ vault = open_vault!
1702
+ keys.each do |key|
1703
+ vault.set(key.vault_key, key.value)
1704
+ $stdout.puts "Imported #{key.path} -> #{key.vault_key}"
1705
+ end
1706
+
1707
+ $stdout.puts
1708
+ $stdout.puts "Run without a key file on disk:"
1709
+ $stdout.puts " localvault exec --profile rails -- bin/rails server"
1710
+ keys.select(&:environment).each do |key|
1711
+ $stdout.puts " localvault exec --map #{key.vault_key}=RAILS_MASTER_KEY -- bin/rails console # #{key.environment}"
1712
+ end
1713
+
1714
+ $stdout.puts
1715
+ $stdout.puts "Note: the key files are still on disk (#{keys.map(&:path).join(", ")})."
1716
+ $stdout.puts "They keep working until you remove them — that's your call, not ours."
1717
+ CommandStatus.ok
1718
+ end
1719
+
1652
1720
  desc "upgrade", "Upgrade localvault using whichever method installed it"
1653
1721
  long_desc <<~DESC
1654
1722
  Detect how this copy of localvault was installed and run the matching
@@ -17,9 +17,24 @@ module LocalVault
17
17
  "AWS_IAM.secret_access_key" => "AWS_SECRET_ACCESS_KEY",
18
18
  "AWS_IAM.session_token" => "AWS_SESSION_TOKEN"
19
19
  }
20
+ },
21
+ # Rails reads its credentials key from ENV["RAILS_MASTER_KEY"] when
22
+ # config/master.key is absent (railties: encrypted(..., env_key:
23
+ # "RAILS_MASTER_KEY")), so injecting that one variable is enough to run a
24
+ # Rails app with no key file on disk.
25
+ "rails" => {
26
+ only: ["rails.*"],
27
+ map: { "rails.master_key" => "RAILS_MASTER_KEY" }
20
28
  }
21
29
  }.freeze
22
30
 
31
+ # Per-environment credentials (config/credentials/production.key) still have
32
+ # to arrive as RAILS_MASTER_KEY — Rails reads no other variable. This builds
33
+ # the mapping for one environment.
34
+ def self.rails_environment_mapping(environment)
35
+ { "rails.#{environment}_key" => "RAILS_MASTER_KEY" }
36
+ end
37
+
23
38
  def self.entries(secrets, project: nil, only: nil, except: nil, map: nil, profile: nil, on_skip: nil)
24
39
  profile_config = profile_config(profile)
25
40
  selectors = parse_selectors(only) || profile_config[:only]
@@ -4,7 +4,7 @@ module LocalVault
4
4
 
5
5
  SEGMENT_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
6
6
  VAULT_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\z/
7
- PROFILES = %w[aws].freeze
7
+ PROFILES = %w[aws rails].freeze
8
8
 
9
9
  module_function
10
10
 
@@ -188,13 +188,40 @@ module LocalVault
188
188
  # List all vault names found on disk.
189
189
  #
190
190
  # @return [Array<String>] sorted vault names
191
+ # Names of the vaults on disk: directories under +vaults/+ that have a
192
+ # valid vault name and a +meta.yml+. Anything else (a stray file, a
193
+ # hand-made folder with spaces in its name, a half-deleted vault) is
194
+ # skipped so callers can always +Store.new+ what they get back; see
195
+ # +stray_entries+ to surface those.
196
+ #
197
+ # @return [Array<String>] sorted vault names
191
198
  def self.list_vaults
192
199
  vaults_dir = Config.vaults_path
193
200
  return [] unless File.directory?(vaults_dir)
194
201
 
195
202
  Dir.children(vaults_dir)
196
- .select { |name| File.directory?(File.join(vaults_dir, name)) }
203
+ .select { |name| vault_dir?(vaults_dir, name) }
204
+ .sort
205
+ end
206
+
207
+ # Entries under +vaults/+ that +list_vaults+ ignores, so a listing can
208
+ # tell the user why a folder they can see is not a vault.
209
+ #
210
+ # @return [Array<String>] sorted entry names
211
+ def self.stray_entries
212
+ vaults_dir = Config.vaults_path
213
+ return [] unless File.directory?(vaults_dir)
214
+
215
+ Dir.children(vaults_dir)
216
+ .reject { |name| name.start_with?(".") || vault_dir?(vaults_dir, name) }
197
217
  .sort
198
218
  end
219
+
220
+ def self.vault_dir?(vaults_dir, name)
221
+ name.match?(VAULT_NAME_PATTERN) && name.length <= 64 &&
222
+ File.directory?(File.join(vaults_dir, name)) &&
223
+ File.exist?(File.join(vaults_dir, name, "meta.yml"))
224
+ end
225
+ private_class_method :vault_dir?
199
226
  end
200
227
  end
@@ -0,0 +1,204 @@
1
+ require "json"
2
+
3
+ module LocalVault
4
+ # Three-way merge of vault secrets: base (last synced), local, remote.
5
+ #
6
+ # Works on flattened dot-notation keys (+"app.DB_URL"+) so groups and
7
+ # scalars compare uniformly. Values never leave this module in any report —
8
+ # callers get key names plus change kinds, which is all a human needs to
9
+ # decide, and all that is safe to print.
10
+ #
11
+ # Rules per key:
12
+ # - unchanged on both sides → keep
13
+ # - changed on one side only → take that side
14
+ # - changed on both sides to the same value → keep
15
+ # - changed on both sides to different values → conflict
16
+ #
17
+ # "changed" covers add, modify and delete relative to base. With no base
18
+ # (first sync between two pre-existing vaults) nothing can be proven
19
+ # deleted, so the merge is a union: keys on one side only are added, keys
20
+ # on both sides with different values are conflicts.
21
+ module SyncMerge
22
+ # Raised when merged keys cannot coexist in one nested hash, e.g. a scalar
23
+ # +app+ from one side and a group +app.X+ from the other.
24
+ class StructureError < StandardError; end
25
+
26
+ Change = Struct.new(:key, :kind, keyword_init: true)
27
+
28
+ # Result of a merge. +merged+ is the nested secrets hash ready to write;
29
+ # +conflicts+ is a list of keys that still need a human choice.
30
+ Result = Struct.new(:merged, :local_changes, :remote_changes, :conflicts, :same_changes,
31
+ :conflict_keys_before_picks, keyword_init: true) do
32
+ def clean?
33
+ conflicts.empty?
34
+ end
35
+ end
36
+
37
+ # @param base [Hash, nil] nested secrets at last sync, nil if unknown
38
+ # @param local [Hash] nested secrets on disk
39
+ # @param remote [Hash] nested secrets in the cloud bundle
40
+ # @param prefer [Symbol, nil] +:local+ or +:remote+ — resolves every conflict
41
+ # @param picks [Hash{String => Symbol}] per-key resolution, key => :local | :remote
42
+ # @return [Result]
43
+ def self.merge(base, local, remote, prefer: nil, picks: {})
44
+ b = base ? flatten(base) : nil
45
+ l = flatten(local)
46
+ r = flatten(remote)
47
+
48
+ merged = {}
49
+ local_changes = []
50
+ remote_changes = []
51
+ same_changes = []
52
+ conflicts = []
53
+
54
+ structural = structural_conflicts(base, local, remote)
55
+ structural.each do |root|
56
+ side = picks[root] || prefer
57
+ source = side == :remote ? r : l
58
+ source.each { |k, v| merged[k] = v if root_of(k) == root }
59
+ conflicts << Change.new(key: root, kind: :structure) unless %i[local remote].include?(side)
60
+ end
61
+
62
+ (l.keys | r.keys | (b ? b.keys : [])).sort.each do |key|
63
+ next if structural.include?(root_of(key))
64
+ bv = b && b[key]
65
+ lv = l[key]
66
+ rv = r[key]
67
+
68
+ local_changed = b ? lv != bv : false
69
+ remote_changed = b ? rv != bv : false
70
+
71
+ if b.nil?
72
+ # No ancestor: union, conflict only when both present and different.
73
+ if lv && rv && lv != rv
74
+ resolve_conflict(key, lv, rv, prefer, picks, merged, conflicts)
75
+ elsif lv.nil?
76
+ merged[key] = rv
77
+ remote_changes << Change.new(key: key, kind: :added)
78
+ elsif rv.nil?
79
+ merged[key] = lv
80
+ local_changes << Change.new(key: key, kind: :added)
81
+ else
82
+ merged[key] = lv
83
+ end
84
+ next
85
+ end
86
+
87
+ if !local_changed && !remote_changed
88
+ merged[key] = lv unless lv.nil?
89
+ elsif local_changed && !remote_changed
90
+ merged[key] = lv unless lv.nil?
91
+ local_changes << Change.new(key: key, kind: kind_of(bv, lv))
92
+ elsif !local_changed && remote_changed
93
+ merged[key] = rv unless rv.nil?
94
+ remote_changes << Change.new(key: key, kind: kind_of(bv, rv))
95
+ elsif lv == rv
96
+ merged[key] = lv unless lv.nil?
97
+ same_changes << Change.new(key: key, kind: kind_of(bv, lv))
98
+ else
99
+ resolve_conflict(key, lv, rv, prefer, picks, merged, conflicts)
100
+ end
101
+ end
102
+
103
+ Result.new(
104
+ merged: unflatten(merged),
105
+ local_changes: local_changes,
106
+ remote_changes: remote_changes,
107
+ same_changes: same_changes,
108
+ conflicts: conflicts
109
+ )
110
+ end
111
+
112
+ # Flatten one level of grouping into dot keys. Values are stringified so
113
+ # comparison is by content, matching how +Vault#set+ stores them.
114
+ #
115
+ # @param hash [Hash] nested secrets
116
+ # @return [Hash{String => String}]
117
+ def self.flatten(hash)
118
+ out = {}
119
+ hash.each do |k, v|
120
+ if v.is_a?(Hash)
121
+ v.each { |sk, sv| out["#{k}.#{sk}"] = sv.to_s }
122
+ else
123
+ out[k.to_s] = v.to_s
124
+ end
125
+ end
126
+ out
127
+ end
128
+
129
+ # Inverse of +flatten+. Raises +StructureError+ when a scalar and a group
130
+ # share a name.
131
+ #
132
+ # @param flat [Hash{String => String}]
133
+ # @return [Hash]
134
+ def self.unflatten(flat)
135
+ out = {}
136
+ flat.each do |key, value|
137
+ if key.include?(".")
138
+ group, sub = key.split(".", 2)
139
+ out[group] ||= {}
140
+ raise StructureError, "'#{group}' is both a secret and a group" unless out[group].is_a?(Hash)
141
+ out[group][sub] = value
142
+ else
143
+ raise StructureError, "'#{key}' is both a secret and a group" if out[key].is_a?(Hash)
144
+ out[key] = value
145
+ end
146
+ end
147
+ out
148
+ end
149
+
150
+ # Roots whose shape (scalar / group / absent) differs between local and
151
+ # remote, with both sides having moved away from base. With no base, only
152
+ # a present-on-both scalar-vs-group disagreement is structural; a key
153
+ # missing on one side is an ordinary addition.
154
+ def self.structural_conflicts(base, local, remote)
155
+ roots = local.keys | remote.keys | (base ? base.keys : [])
156
+ roots.select do |root|
157
+ lk = shape(local[root])
158
+ rk = shape(remote[root])
159
+ next false if lk == rk
160
+ next(lk != :absent && rk != :absent) if base.nil?
161
+ bk = shape(base[root])
162
+ lk != bk && rk != bk
163
+ end
164
+ end
165
+
166
+ def self.shape(value)
167
+ return :absent if value.nil?
168
+ value.is_a?(Hash) ? :group : :scalar
169
+ end
170
+
171
+ def self.root_of(key)
172
+ key.split(".", 2).first
173
+ end
174
+
175
+ def self.kind_of(before, after)
176
+ return :deleted if after.nil?
177
+ return :added if before.nil?
178
+ :modified
179
+ end
180
+
181
+ def self.resolve_conflict(key, lv, rv, prefer, picks, merged, conflicts)
182
+ side = picks[key] || prefer
183
+ case side
184
+ when :local
185
+ merged[key] = lv unless lv.nil?
186
+ when :remote
187
+ merged[key] = rv unless rv.nil?
188
+ else
189
+ # Leave local in place so an unresolved merge never drops a key.
190
+ merged[key] = lv unless lv.nil?
191
+ conflicts << Change.new(key: key, kind: conflict_kind(lv, rv))
192
+ end
193
+ end
194
+
195
+ # Describe a conflict without values: which side deleted, or both modified.
196
+ def self.conflict_kind(lv, rv)
197
+ return :local_deleted if lv.nil?
198
+ return :remote_deleted if rv.nil?
199
+ :both_modified
200
+ end
201
+
202
+ private_class_method :kind_of, :resolve_conflict, :conflict_kind, :structural_conflicts, :shape, :root_of
203
+ end
204
+ end
@@ -13,6 +13,12 @@ module LocalVault
13
13
  class SyncState
14
14
  FILENAME = ".sync_state"
15
15
 
16
+ # Ciphertext snapshot of secrets.enc as of the last successful sync. This
17
+ # is the common ancestor a three-way merge needs (see +SyncMerge+); the
18
+ # checksum alone only says *that* both sides drifted, not *which keys*.
19
+ # Same bytes and same file mode as secrets.enc, so it leaks nothing new.
20
+ BASE_FILENAME = ".sync_base"
21
+
16
22
  attr_reader :vault_name
17
23
 
18
24
  def initialize(vault_name)
@@ -23,10 +29,22 @@ module LocalVault
23
29
  File.join(Config.vaults_path, vault_name, FILENAME)
24
30
  end
25
31
 
32
+ def base_path
33
+ File.join(Config.vaults_path, vault_name, BASE_FILENAME)
34
+ end
35
+
26
36
  def exists?
27
37
  File.exist?(path)
28
38
  end
29
39
 
40
+ # @return [String, nil] encrypted secrets bytes as of the last sync, or nil
41
+ # when no snapshot was recorded (older clients, or an empty vault).
42
+ def read_base
43
+ return nil unless File.exist?(base_path)
44
+ bytes = File.binread(base_path)
45
+ bytes.empty? ? nil : bytes
46
+ end
47
+
30
48
  # @return [Hash, nil] parsed YAML data or nil
31
49
  def read
32
50
  return nil unless exists?
@@ -46,8 +64,11 @@ module LocalVault
46
64
  # Record a successful sync operation.
47
65
  #
48
66
  # @param checksum [String] SHA256 hex of the local secrets.enc
49
- # @param direction [String] "push" or "pull"
50
- def write!(checksum:, direction:)
67
+ # @param direction [String] "push", "pull", "adopt" or "merge"
68
+ # @param base [String, nil] encrypted secrets bytes to snapshot as the
69
+ # merge ancestor. Pass the bytes that +checksum+ was computed from. nil
70
+ # (or empty) removes any previous snapshot.
71
+ def write!(checksum:, direction:, base: nil)
51
72
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
52
73
  data = {
53
74
  "last_synced_checksum" => checksum,
@@ -56,6 +77,25 @@ module LocalVault
56
77
  }
57
78
  File.write(path, YAML.dump(data))
58
79
  File.chmod(0o600, path)
80
+ write_base!(base)
81
+ end
82
+
83
+ # Record both checksum and ancestor snapshot from a store in one call.
84
+ #
85
+ # @param store [Store] vault store whose current secrets.enc is now synced
86
+ # @param direction [String] see +write!+
87
+ def record!(store, direction:)
88
+ bytes = store.read_encrypted
89
+ write!(checksum: self.class.local_checksum(store), direction: direction, base: bytes)
90
+ end
91
+
92
+ def write_base!(bytes)
93
+ if bytes.nil? || bytes.empty?
94
+ FileUtils.rm_f(base_path)
95
+ return
96
+ end
97
+ File.binwrite(base_path, bytes)
98
+ File.chmod(0o600, base_path)
59
99
  end
60
100
 
61
101
  # Compute the SHA256 hex digest of a vault's local secrets.enc.
@@ -111,6 +111,24 @@ module LocalVault
111
111
  end
112
112
  end
113
113
 
114
+ # Replace the entire secrets hash. Used by sync merge, which computes the
115
+ # full merged result and needs deletions applied as well as additions.
116
+ #
117
+ # @param secrets [Hash] nested secrets hash (groups as nested hashes)
118
+ # @return [void]
119
+ # @raise [InvalidKeyName] when any key contains invalid characters
120
+ def replace(secrets)
121
+ secrets.each do |k, v|
122
+ if v.is_a?(Hash)
123
+ validate_key_segment!(k)
124
+ v.each_key { |sk| validate_key_segment!(sk) }
125
+ else
126
+ validate_key!(k)
127
+ end
128
+ end
129
+ write_secrets(secrets)
130
+ end
131
+
114
132
  # Returns a sorted flat list of all keys. Nested keys use dot-notation.
115
133
  #
116
134
  # @return [Array<String>] sorted key names, e.g. ["API_KEY", "myapp.DB_URL"]
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.13.0"
2
+ VERSION = "1.15.0"
3
3
  end
data/lib/localvault.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "localvault/key_slot"
15
15
  require_relative "localvault/api_client"
16
16
  require_relative "localvault/sync_bundle"
17
17
  require_relative "localvault/sync_state"
18
+ require_relative "localvault/sync_merge"
18
19
 
19
20
  module LocalVault
20
21
  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.13.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -115,6 +115,7 @@ files:
115
115
  - lib/localvault/cli/help_shell.rb
116
116
  - lib/localvault/cli/identity_cmd.rb
117
117
  - lib/localvault/cli/keys.rb
118
+ - lib/localvault/cli/rails_import.rb
118
119
  - lib/localvault/cli/sync.rb
119
120
  - lib/localvault/cli/team.rb
120
121
  - lib/localvault/cli/team_helpers.rb
@@ -136,6 +137,7 @@ files:
136
137
  - lib/localvault/stdin_secret_input.rb
137
138
  - lib/localvault/store.rb
138
139
  - lib/localvault/sync_bundle.rb
140
+ - lib/localvault/sync_merge.rb
139
141
  - lib/localvault/sync_state.rb
140
142
  - lib/localvault/vault.rb
141
143
  - lib/localvault/vault_resolver.rb