localvault 0.9.8 → 1.0.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: fb41c4ac2d8dd408f4e1add9e9d79a7ec8f3f9fc139a8c8575008d0c0a4dd53d
4
- data.tar.gz: d530b0a5e4924f270fe7386a6efb46feaf8ebd27f4b6474b288a6567fc31a9a5
3
+ metadata.gz: 188e3a7249114892858d65724529b3d537a2c058250fb06a2bbfd63acc4cc387
4
+ data.tar.gz: 222bf0d8f5a377d6633f95ae066769c2db16333913c50c158e430b93b89faf7a
5
5
  SHA512:
6
- metadata.gz: 48c14b4bca2e1de6e48e35f8fba1a44bfb63d2291d86e6a60b4c07baa9b07d77180b8e511c9ad1a93c80681f4ec4c17c885707bb6c50b24545f973c4d770adb2
7
- data.tar.gz: 56c1b8aa333146e7d2553e5c8a81dbaa8077f4185f5b76208086e72a886f2db253e8334232076ca7b6c16ee16bebad391713cbe472f1db75978bfbb7a6107370
6
+ metadata.gz: 18cf7cdc7323b02abed37741d870dd20dbc5e81415f3d4f802ffd004286b3171c228cfe22e8b2d9d7a927bf9f276990013014c91d5ddb6ae008abc1c199f557d
7
+ data.tar.gz: 28dcf1e3d8c3c9626bd328d021e9870d9da0c1804c6b2bf226c20e8411d14a421ec711646d1a6cbd05add7e513bd2a1989c6a45ce891374d9a252c06bec02fae
@@ -27,7 +27,7 @@ module LocalVault
27
27
 
28
28
  # GET /api/v1/users/:handle/public_key
29
29
  def get_public_key(handle)
30
- get("/users/#{handle}/public_key")
30
+ get("/users/#{URI.encode_uri_component(handle)}/public_key")
31
31
  end
32
32
 
33
33
  # PUT /api/v1/profile/public_key
@@ -58,22 +58,22 @@ module LocalVault
58
58
 
59
59
  # PATCH /api/v1/vault_shares/:id/accept
60
60
  def accept_share(id)
61
- patch("/vault_shares/#{id}/accept", {})
61
+ patch("/vault_shares/#{URI.encode_uri_component(id.to_s)}/accept", {})
62
62
  end
63
63
 
64
64
  # DELETE /api/v1/vault_shares/:id
65
65
  def revoke_share(id)
66
- delete("/vault_shares/#{id}")
66
+ delete("/vault_shares/#{URI.encode_uri_component(id.to_s)}")
67
67
  end
68
68
 
69
69
  # GET /api/v1/teams/:handle/members/public_keys
70
70
  def team_public_keys(team_handle)
71
- get("/teams/#{team_handle}/members/public_keys")
71
+ get("/teams/#{URI.encode_uri_component(team_handle)}/members/public_keys")
72
72
  end
73
73
 
74
74
  # GET /api/v1/sites/:slug/crew/public_keys
75
75
  def crew_public_keys(site_slug)
76
- get("/sites/#{site_slug}/crew/public_keys")
76
+ get("/sites/#{URI.encode_uri_component(site_slug)}/crew/public_keys")
77
77
  end
78
78
 
79
79
  # GET /api/v1/vaults
@@ -122,6 +122,8 @@ module LocalVault
122
122
  uri = URI("#{@base_url}#{BASE_PATH}#{path}")
123
123
  http = Net::HTTP.new(uri.host, uri.port)
124
124
  http.use_ssl = uri.scheme == "https"
125
+ http.open_timeout = 10
126
+ http.read_timeout = 30
125
127
 
126
128
  req_class = {
127
129
  get: Net::HTTP::Get,
@@ -152,6 +154,8 @@ module LocalVault
152
154
  uri = URI("#{@base_url}#{BASE_PATH}#{path}")
153
155
  http = Net::HTTP.new(uri.host, uri.port)
154
156
  http.use_ssl = uri.scheme == "https"
157
+ http.open_timeout = 10
158
+ http.read_timeout = 30
155
159
 
156
160
  req_class = { put: Net::HTTP::Put }.fetch(method)
157
161
  req = req_class.new(uri.request_uri)
@@ -175,6 +179,8 @@ module LocalVault
175
179
  uri = URI("#{@base_url}#{BASE_PATH}#{path}")
176
180
  http = Net::HTTP.new(uri.host, uri.port)
177
181
  http.use_ssl = uri.scheme == "https"
182
+ http.open_timeout = 10
183
+ http.read_timeout = 30
178
184
 
179
185
  req_class = { get: Net::HTTP::Get }.fetch(method)
180
186
  req = req_class.new(uri.request_uri)
@@ -42,9 +42,14 @@ module LocalVault
42
42
  blob = client.pull_vault(vault_name)
43
43
  data = SyncBundle.unpack(blob)
44
44
 
45
- FileUtils.mkdir_p(store.vault_path)
45
+ FileUtils.mkdir_p(store.vault_path, mode: 0o700)
46
46
  File.write(store.meta_path, data[:meta])
47
- store.write_encrypted(data[:secrets]) unless data[:secrets].empty?
47
+ File.chmod(0o600, store.meta_path)
48
+ if data[:secrets].empty?
49
+ FileUtils.rm_f(store.secrets_path)
50
+ else
51
+ store.write_encrypted(data[:secrets])
52
+ end
48
53
 
49
54
  $stdout.puts "Pulled vault '#{vault_name}'."
50
55
  $stdout.puts "Unlock it with: localvault unlock -v #{vault_name}"
@@ -72,10 +72,26 @@ module LocalVault
72
72
  def get(key)
73
73
  vault = open_vault!
74
74
  value = vault.get(key)
75
+
75
76
  if value.nil?
76
- abort_with "Key '#{key}' not found in vault '#{vault.name}'"
77
- return
77
+ # Fall back to case-insensitive substring match
78
+ all_keys = vault.list
79
+ matches = all_keys.select { |k| k.downcase.include?(key.downcase) }
80
+
81
+ if matches.size == 1
82
+ value = vault.get(matches.first)
83
+ $stdout.puts value
84
+ return
85
+ elsif matches.size > 1
86
+ $stderr.puts "Error: Multiple keys match '#{key}'. Be more specific:"
87
+ matches.sort.each { |k| $stderr.puts " #{k}" }
88
+ return
89
+ else
90
+ abort_with "Key '#{key}' not found in vault '#{vault.name}'"
91
+ return
92
+ end
78
93
  end
94
+
79
95
  $stdout.puts value
80
96
  end
81
97
 
@@ -406,7 +422,7 @@ module LocalVault
406
422
  salt = Crypto.generate_salt
407
423
  master_key = Crypto.derive_master_key("demo", salt)
408
424
  vault = Vault.create!(name: vault_name, master_key: master_key, salt: salt)
409
- secrets.each { |k, v| vault.set(k, v) }
425
+ vault.merge(secrets)
410
426
  $stdout.puts " created vault '#{vault_name}' (#{secrets.size} secrets)"
411
427
  end
412
428
 
@@ -595,7 +611,7 @@ module LocalVault
595
611
 
596
612
  imported = 0
597
613
  shares.each do |share|
598
- vault_name = "#{share["vault_name"]}-from-#{share["sender_handle"]}"
614
+ vault_name = sanitize_receive_vault_name(share["vault_name"], share["sender_handle"])
599
615
  $stdout.puts " [#{share["id"]}] vault '#{share["vault_name"]}' from @#{share["sender_handle"]}"
600
616
 
601
617
  begin
@@ -619,10 +635,15 @@ module LocalVault
619
635
  salt = Crypto.generate_salt
620
636
  master_key = Crypto.derive_master_key(passphrase, salt)
621
637
  vault = Vault.create!(name: vault_name, master_key: master_key, salt: salt)
622
- secrets.each { |k, v| vault.set(k, v.to_s) }
638
+ vault.merge(secrets)
623
639
 
624
- $stdout.puts " Imported #{secrets.size} secret(s) vault '#{vault_name}'"
625
- client.accept_share(share["id"]) rescue nil
640
+ count = secrets.sum { |_, v| v.is_a?(Hash) ? v.size : 1 }
641
+ $stdout.puts " Imported #{count} secret(s) vault '#{vault_name}'"
642
+ begin
643
+ client.accept_share(share["id"])
644
+ rescue ApiClient::ApiError => e
645
+ $stderr.puts " Warning: could not mark share as accepted: #{e.message}"
646
+ end
626
647
  imported += 1
627
648
  end
628
649
 
@@ -684,21 +705,23 @@ module LocalVault
684
705
 
685
706
  vault = open_vault!
686
707
  project = options[:project]
687
- count = 0
688
708
 
709
+ # Restructure data for bulk merge
710
+ to_merge = {}
689
711
  data.each do |key, value|
690
712
  if value.is_a?(Hash)
691
- value.each do |subkey, subval|
692
- vault.set("#{key}.#{subkey}", subval.to_s)
693
- count += 1
694
- end
713
+ to_merge[key] = value
714
+ elsif project
715
+ to_merge[project] ||= {}
716
+ to_merge[project][key] = value.to_s
695
717
  else
696
- dest_key = project ? "#{project}.#{key}" : key
697
- vault.set(dest_key, value.to_s)
698
- count += 1
718
+ to_merge[key] = value.to_s
699
719
  end
700
720
  end
701
721
 
722
+ vault.merge(to_merge)
723
+ count = to_merge.sum { |_, v| v.is_a?(Hash) ? v.size : 1 }
724
+
702
725
  $stdout.puts "Imported #{count} secret(s) into vault '#{vault.name}'" \
703
726
  "#{project ? " / #{project}" : ""}."
704
727
  rescue RuntimeError => e
@@ -979,7 +1002,8 @@ module LocalVault
979
1002
  end
980
1003
  end
981
1004
 
982
- passphrase = prompt_passphrase("Passphrase for '#{vault_name}': ")
1005
+ prompt_msg = vault_name == resolve_vault_name ? "Passphrase: " : "Passphrase for '#{vault_name}': "
1006
+ passphrase = prompt_passphrase(prompt_msg)
983
1007
  vault = Vault.open(name: vault_name, passphrase: passphrase)
984
1008
  vault.all
985
1009
  SessionCache.set(vault_name, vault.master_key)
@@ -990,58 +1014,27 @@ module LocalVault
990
1014
  end
991
1015
 
992
1016
  def resolve_recipients(client, target)
993
- if target.start_with?("team:")
994
- handle = target.delete_prefix("team:")
995
- result = client.team_public_keys(handle)
996
- (result["members"] || []).map { |m| [m["handle"], m["public_key"]] }
997
- elsif target.start_with?("crew:")
998
- slug = target.delete_prefix("crew:")
999
- result = client.crew_public_keys(slug)
1000
- (result["members"] || []).map { |m| [m["handle"], m["public_key"]] }
1001
- else
1002
- handle = target.delete_prefix("@")
1003
- result = client.get_public_key(handle)
1004
- [[result["handle"], result["public_key"]]]
1005
- end
1017
+ raw = if target.start_with?("team:")
1018
+ handle = target.delete_prefix("team:")
1019
+ result = client.team_public_keys(handle)
1020
+ (result["members"] || []).map { |m| [m["handle"], m["public_key"]] }
1021
+ elsif target.start_with?("crew:")
1022
+ slug = target.delete_prefix("crew:")
1023
+ result = client.crew_public_keys(slug)
1024
+ (result["members"] || []).map { |m| [m["handle"], m["public_key"]] }
1025
+ else
1026
+ handle = target.delete_prefix("@")
1027
+ result = client.get_public_key(handle)
1028
+ [[result["handle"], result["public_key"]]]
1029
+ end
1030
+ raw.select { |h, pk| h && pk && !h.empty? && !pk.empty? }
1006
1031
  rescue ApiClient::ApiError => e
1007
1032
  $stderr.puts "Warning: #{e.message}"
1008
1033
  []
1009
1034
  end
1010
1035
 
1011
1036
  def open_vault!
1012
- vault_name = resolve_vault_name
1013
-
1014
- # 1. Try LOCALVAULT_SESSION env var
1015
- if (vault = vault_from_session(vault_name))
1016
- return vault
1017
- end
1018
-
1019
- store = Store.new(vault_name)
1020
- unless store.exists?
1021
- abort_with "Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
1022
- raise SystemExit.new(1)
1023
- end
1024
-
1025
- # 2. Try Keychain session cache
1026
- if (master_key = SessionCache.get(vault_name))
1027
- begin
1028
- vault = Vault.new(name: vault_name, master_key: master_key)
1029
- vault.all # verify key still valid
1030
- return vault
1031
- rescue Crypto::DecryptionError
1032
- SessionCache.clear(vault_name) # stale cache — clear and fall through
1033
- end
1034
- end
1035
-
1036
- # 3. Prompt passphrase and cache the result
1037
- passphrase = prompt_passphrase("Passphrase: ")
1038
- vault = Vault.open(name: vault_name, passphrase: passphrase)
1039
- vault.all # eager verification
1040
- SessionCache.set(vault_name, vault.master_key)
1041
- vault
1042
- rescue Crypto::DecryptionError
1043
- abort_with "Wrong passphrase for vault '#{vault_name}'"
1044
- raise SystemExit.new(1)
1037
+ open_vault_by_name!(resolve_vault_name)
1045
1038
  end
1046
1039
 
1047
1040
  def vault_from_session(vault_name)
@@ -1060,6 +1053,15 @@ module LocalVault
1060
1053
  nil
1061
1054
  end
1062
1055
 
1056
+ def sanitize_receive_vault_name(vault_name, sender_handle)
1057
+ safe_vault = vault_name.to_s.gsub(/[^a-zA-Z0-9_\-]/, "-")
1058
+ safe_handle = sender_handle.to_s.gsub(/[^a-zA-Z0-9_\-]/, "-")
1059
+ # Ensure it starts with alphanumeric (Store validation requirement)
1060
+ safe_vault = "v-#{safe_vault}" unless safe_vault.match?(/\A[a-zA-Z0-9]/)
1061
+ safe_handle = "u-#{safe_handle}" unless safe_handle.match?(/\A[a-zA-Z0-9]/)
1062
+ "#{safe_vault}-from-#{safe_handle}"[0, 64]
1063
+ end
1064
+
1063
1065
  def abort_with(message)
1064
1066
  $stderr.puts "Error: #{message}"
1065
1067
  end
@@ -29,6 +29,7 @@ module LocalVault
29
29
  def self.save(data)
30
30
  FileUtils.mkdir_p(root_path)
31
31
  File.write(config_path, YAML.dump(data))
32
+ File.chmod(0o600, config_path)
32
33
  end
33
34
 
34
35
  def self.default_vault
@@ -42,9 +43,9 @@ module LocalVault
42
43
  end
43
44
 
44
45
  def self.ensure_directories!
45
- FileUtils.mkdir_p(root_path)
46
- FileUtils.mkdir_p(vaults_path)
47
- FileUtils.mkdir_p(keys_path)
46
+ FileUtils.mkdir_p(root_path, mode: 0o700)
47
+ FileUtils.mkdir_p(vaults_path, mode: 0o700)
48
+ FileUtils.mkdir_p(keys_path, mode: 0o700)
48
49
  end
49
50
 
50
51
  def self.token
@@ -43,8 +43,6 @@ module LocalVault
43
43
  end
44
44
 
45
45
  def self.generate_keypair
46
- private_key = RbNaCl::GroupElements::Curve25519.base.mult(RbNaCl::Random.random_bytes(32))
47
- # Use X25519 for key exchange
48
46
  sk = RbNaCl::PrivateKey.generate
49
47
  {
50
48
  public_key: sk.public_key.to_bytes,
@@ -19,6 +19,7 @@ module LocalVault
19
19
  File.write(priv_key_path, Base64.strict_encode64(kp[:private_key]))
20
20
  File.chmod(0o600, priv_key_path)
21
21
  File.write(pub_key_path, Base64.strict_encode64(kp[:public_key]))
22
+ File.chmod(0o644, pub_key_path)
22
23
  kp
23
24
  end
24
25
 
@@ -94,6 +94,8 @@ module LocalVault
94
94
  def self.set_secret(key, value, vault)
95
95
  vault.set(key, value)
96
96
  text_result("Stored #{key}")
97
+ rescue Vault::InvalidKeyName => e
98
+ error_result("Invalid key name: #{e.message}")
97
99
  end
98
100
 
99
101
  def self.delete_secret(key, vault)
@@ -64,24 +64,29 @@ module LocalVault
64
64
  def self.keychain_get(vault_name)
65
65
  if macos?
66
66
  out = `security find-generic-password -a #{Shellwords.escape(vault_name)} -s #{Shellwords.escape(KEYCHAIN_SERVICE)} -w 2>/dev/null`.chomp
67
- $?.success? && !out.empty? ? out : nil
68
- else
69
- file = session_file(vault_name)
70
- File.exist?(file) ? File.read(file).strip : nil
67
+ return out if $?.success? && !out.empty?
71
68
  end
69
+ # File fallback (Linux, or macOS when Keychain unavailable)
70
+ file = session_file(vault_name)
71
+ File.exist?(file) ? File.read(file).strip : nil
72
72
  end
73
73
 
74
74
  def self.keychain_set(vault_name, payload)
75
75
  if macos?
76
76
  keychain_delete(vault_name)
77
- system(
77
+ success = system(
78
78
  "security", "add-generic-password",
79
79
  "-a", vault_name,
80
80
  "-s", KEYCHAIN_SERVICE,
81
81
  "-w", payload,
82
- "-A",
83
82
  out: File::NULL, err: File::NULL
84
83
  )
84
+ # Fall back to file store if Keychain fails (e.g., in CI or sandboxed env)
85
+ unless success
86
+ file = session_file(vault_name)
87
+ File.write(file, payload)
88
+ File.chmod(0o600, file)
89
+ end
85
90
  else
86
91
  keychain_delete(vault_name)
87
92
  file = session_file(vault_name)
@@ -98,9 +103,9 @@ module LocalVault
98
103
  "-s", KEYCHAIN_SERVICE,
99
104
  out: File::NULL, err: File::NULL
100
105
  )
101
- else
102
- FileUtils.rm_f(session_file(vault_name))
103
106
  end
107
+ # Always clean file fallback
108
+ FileUtils.rm_f(session_file(vault_name))
104
109
  end
105
110
 
106
111
  end
@@ -43,6 +43,8 @@ module LocalVault
43
43
  raise DecryptionError, "Failed to decrypt share: #{e.message}"
44
44
  rescue JSON::ParserError, KeyError => e
45
45
  raise DecryptionError, "Invalid payload format: #{e.message}"
46
+ rescue ArgumentError => e
47
+ raise DecryptionError, "Invalid payload encoding: #{e.message}"
46
48
  end
47
49
  end
48
50
  end
@@ -5,9 +5,15 @@ require "base64"
5
5
 
6
6
  module LocalVault
7
7
  class Store
8
+ class InvalidVaultName < StandardError; end
9
+
10
+ # Letters, digits, underscore, dash. Must start with alphanumeric.
11
+ VAULT_NAME_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9_\-]*\z/
12
+
8
13
  attr_reader :vault_name
9
14
 
10
15
  def initialize(vault_name)
16
+ validate_vault_name!(vault_name)
11
17
  @vault_name = vault_name
12
18
  end
13
19
 
@@ -30,15 +36,15 @@ module LocalVault
30
36
  def create!(salt:)
31
37
  raise "Vault '#{vault_name}' already exists" if exists?
32
38
 
33
- FileUtils.mkdir_p(vault_path)
34
- meta = {
39
+ FileUtils.mkdir_p(vault_path, mode: 0o700)
40
+ new_meta = {
35
41
  "name" => vault_name,
36
42
  "created_at" => Time.now.utc.iso8601,
37
43
  "version" => 1,
38
44
  "salt" => Base64.strict_encode64(salt),
39
45
  "count" => 0
40
46
  }
41
- File.write(meta_path, YAML.dump(meta))
47
+ write_meta(new_meta)
42
48
  end
43
49
 
44
50
  def meta
@@ -60,7 +66,7 @@ module LocalVault
60
66
  m = meta
61
67
  return unless m
62
68
  m["count"] = n
63
- File.write(meta_path, YAML.dump(m))
69
+ write_meta(m)
64
70
  end
65
71
 
66
72
  def read_encrypted
@@ -69,7 +75,7 @@ module LocalVault
69
75
  end
70
76
 
71
77
  def write_encrypted(bytes)
72
- FileUtils.mkdir_p(vault_path)
78
+ FileUtils.mkdir_p(vault_path, mode: 0o700)
73
79
 
74
80
  # Atomic write: write to temp file, then rename
75
81
  tmp = Tempfile.new("localvault", vault_path)
@@ -77,6 +83,7 @@ module LocalVault
77
83
  tmp.write(bytes)
78
84
  tmp.close
79
85
  File.rename(tmp.path, secrets_path)
86
+ File.chmod(0o600, secrets_path)
80
87
  rescue StandardError
81
88
  tmp&.close
82
89
  tmp&.unlink
@@ -84,19 +91,36 @@ module LocalVault
84
91
  end
85
92
 
86
93
  def create_meta!(salt:)
87
- meta = {
94
+ existing = meta
95
+ new_meta = {
88
96
  "name" => vault_name,
89
- "created_at" => meta&.dig("created_at") || Time.now.utc.iso8601,
97
+ "created_at" => existing&.dig("created_at") || Time.now.utc.iso8601,
90
98
  "version" => 1,
91
99
  "salt" => Base64.strict_encode64(salt)
92
100
  }
93
- File.write(meta_path, YAML.dump(meta))
101
+ write_meta(new_meta)
94
102
  end
95
103
 
96
104
  def destroy!
97
105
  FileUtils.rm_rf(vault_path)
98
106
  end
99
107
 
108
+ private
109
+
110
+ def write_meta(data)
111
+ File.write(meta_path, YAML.dump(data))
112
+ File.chmod(0o600, meta_path)
113
+ end
114
+
115
+ def validate_vault_name!(name)
116
+ raise InvalidVaultName, "Vault name cannot be empty" if name.nil? || name.to_s.empty?
117
+ name = name.to_s
118
+ raise InvalidVaultName, "Vault name '#{name}' contains invalid characters (allowed: a-z, 0-9, dash, underscore)" unless name.match?(VAULT_NAME_PATTERN)
119
+ raise InvalidVaultName, "Vault name '#{name}' is too long (max 64)" if name.length > 64
120
+ end
121
+
122
+ public
123
+
100
124
  def self.list_vaults
101
125
  vaults_dir = Config.vaults_path
102
126
  return [] unless File.directory?(vaults_dir)
@@ -1,9 +1,10 @@
1
1
  require "json"
2
2
  require "base64"
3
- require "digest"
4
3
 
5
4
  module LocalVault
6
5
  module SyncBundle
6
+ class UnpackError < StandardError; end
7
+
7
8
  VERSION = 1
8
9
 
9
10
  # Pack a vault's meta.yml + secrets.enc into a single JSON blob.
@@ -21,10 +22,18 @@ module LocalVault
21
22
  # Unpack a blob back into {meta:, secrets:} strings.
22
23
  def self.unpack(blob)
23
24
  data = JSON.parse(blob)
25
+ version = data["version"]
26
+ raise UnpackError, "Unsupported bundle version: #{version}" if version && version != VERSION
24
27
  {
25
28
  meta: Base64.strict_decode64(data.fetch("meta")),
26
29
  secrets: Base64.strict_decode64(data.fetch("secrets"))
27
30
  }
31
+ rescue JSON::ParserError => e
32
+ raise UnpackError, "Invalid sync bundle format: #{e.message}"
33
+ rescue KeyError => e
34
+ raise UnpackError, "Sync bundle missing required field: #{e.message}"
35
+ rescue ArgumentError => e
36
+ raise UnpackError, "Sync bundle has invalid encoding: #{e.message}"
28
37
  end
29
38
  end
30
39
  end
@@ -3,6 +3,11 @@ require "shellwords"
3
3
 
4
4
  module LocalVault
5
5
  class Vault
6
+ class InvalidKeyName < StandardError; end
7
+
8
+ # Shell-safe: letters, digits, underscores. Must start with letter or underscore.
9
+ KEY_SEGMENT_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
10
+
6
11
  attr_reader :name, :master_key, :store
7
12
 
8
13
  def initialize(name:, master_key:)
@@ -17,11 +22,13 @@ module LocalVault
17
22
  value = all[group]
18
23
  value.is_a?(Hash) ? value[subkey] : nil
19
24
  else
20
- all[key]
25
+ value = all[key]
26
+ value.is_a?(Hash) ? nil : value
21
27
  end
22
28
  end
23
29
 
24
30
  def set(key, value)
31
+ validate_key!(key)
25
32
  secrets = all
26
33
  if key.include?(".")
27
34
  group, subkey = key.split(".", 2)
@@ -69,18 +76,20 @@ module LocalVault
69
76
  # Export as shell variable assignments.
70
77
  # - With project: exports only that group's keys (no prefix).
71
78
  # - Without project: flat keys as-is, nested keys as GROUP__KEY.
79
+ # Keys that aren't valid shell identifiers are silently skipped.
72
80
  def export_env(project: nil)
73
81
  secrets = all
74
82
  if project
75
83
  group = secrets[project]
76
84
  return "" unless group.is_a?(Hash)
77
- group.map { |k, v| "export #{k}=#{Shellwords.escape(v.to_s)}" }.join("\n")
85
+ group.filter_map { |k, v| "export #{k}=#{Shellwords.escape(v.to_s)}" if shell_safe_key?(k) }.join("\n")
78
86
  else
79
87
  secrets.flat_map do |k, v|
80
88
  if v.is_a?(Hash)
81
- v.map { |sk, sv| "export #{k.upcase}__#{sk}=#{Shellwords.escape(sv.to_s)}" }
89
+ next [] unless shell_safe_key?(k)
90
+ v.filter_map { |sk, sv| "export #{k.upcase}__#{sk}=#{Shellwords.escape(sv.to_s)}" if shell_safe_key?(sk) }
82
91
  else
83
- ["export #{k}=#{Shellwords.escape(v.to_s)}"]
92
+ shell_safe_key?(k) ? ["export #{k}=#{Shellwords.escape(v.to_s)}"] : []
84
93
  end
85
94
  end.join("\n")
86
95
  end
@@ -89,20 +98,22 @@ module LocalVault
89
98
  # Returns a flat hash suitable for env injection.
90
99
  # - With project: only that group's key-value pairs.
91
100
  # - Without project: flat keys + nested keys as GROUP__KEY.
101
+ # Keys that aren't valid shell identifiers are silently skipped.
92
102
  def env_hash(project: nil)
93
103
  secrets = all
94
104
  if project
95
105
  group = secrets[project]
96
106
  return {} unless group.is_a?(Hash)
97
- group.transform_values(&:to_s)
107
+ group.each_with_object({}) { |(k, v), h| h[k] = v.to_s if shell_safe_key?(k) }
98
108
  else
99
- secrets.flat_map do |k, v|
109
+ secrets.each_with_object({}) do |(k, v), h|
100
110
  if v.is_a?(Hash)
101
- v.map { |sk, sv| ["#{k.upcase}__#{sk}", sv.to_s] }
111
+ next unless shell_safe_key?(k)
112
+ v.each { |sk, sv| h["#{k.upcase}__#{sk}"] = sv.to_s if shell_safe_key?(sk) }
102
113
  else
103
- [[k, v.to_s]]
114
+ h[k] = v.to_s if shell_safe_key?(k)
104
115
  end
105
- end.to_h
116
+ end
106
117
  end
107
118
  end
108
119
 
@@ -138,8 +149,57 @@ module LocalVault
138
149
  new(name: name, master_key: master_key)
139
150
  end
140
151
 
152
+ # Bulk-set: merges all key-value pairs in a single decrypt/encrypt cycle.
153
+ # Supports nested hashes: { "app" => { "DB" => "..." } } merges into group "app".
154
+ def merge(hash)
155
+ secrets = all
156
+ hash.each do |k, v|
157
+ if v.is_a?(Hash)
158
+ validate_key_segment!(k)
159
+ secrets[k] ||= {}
160
+ raise "#{k} is a scalar value, not a group" unless secrets[k].is_a?(Hash)
161
+ v.each do |sk, sv|
162
+ validate_key_segment!(sk)
163
+ secrets[k][sk] = sv.to_s
164
+ end
165
+ else
166
+ validate_key!(k)
167
+ if k.include?(".")
168
+ group, subkey = k.split(".", 2)
169
+ secrets[group] ||= {}
170
+ raise "#{group} is a scalar value, not a group" unless secrets[group].is_a?(Hash)
171
+ secrets[group][subkey] = v.to_s
172
+ else
173
+ secrets[k] = v.to_s
174
+ end
175
+ end
176
+ end
177
+ write_secrets(secrets)
178
+ end
179
+
141
180
  private
142
181
 
182
+ def shell_safe_key?(key)
183
+ key.is_a?(String) && key.match?(KEY_SEGMENT_PATTERN)
184
+ end
185
+
186
+ def validate_key!(key)
187
+ raise InvalidKeyName, "Key name cannot be empty" if key.nil? || key.empty?
188
+ if key.include?(".")
189
+ group, subkey = key.split(".", 2)
190
+ validate_key_segment!(group)
191
+ validate_key_segment!(subkey)
192
+ else
193
+ validate_key_segment!(key)
194
+ end
195
+ end
196
+
197
+ def validate_key_segment!(segment)
198
+ raise InvalidKeyName, "Key segment cannot be empty" if segment.nil? || segment.empty?
199
+ raise InvalidKeyName, "Key '#{segment}' contains invalid characters (allowed: A-Z, a-z, 0-9, underscore)" unless segment.match?(KEY_SEGMENT_PATTERN)
200
+ raise InvalidKeyName, "Key '#{segment}' is too long (max 128)" if segment.length > 128
201
+ end
202
+
143
203
  def write_secrets(secrets)
144
204
  json = JSON.generate(secrets)
145
205
  encrypted = Crypto.encrypt(json, master_key)
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "0.9.8"
2
+ VERSION = "1.0.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: 0.9.8
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq