localvault 0.9.9 → 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: 3e609590f2d3491aeb43afb3bf63209d46ba2e2a69823dbbe23a189f742ad0a7
4
- data.tar.gz: 17124f6838c5187862e9a3b6faa266a16dc8bf21c9ddabe2beec85d5396fefc5
3
+ metadata.gz: 188e3a7249114892858d65724529b3d537a2c058250fb06a2bbfd63acc4cc387
4
+ data.tar.gz: 222bf0d8f5a377d6633f95ae066769c2db16333913c50c158e430b93b89faf7a
5
5
  SHA512:
6
- metadata.gz: 2d992f96f563d6f7e4d86083cc25d26ebcfb2d226270bfcaeb6d0ad77b193149082552d88e263d8b58d6b419efdff27701ad2f697d47ebd51bf2cdf825c50ab4
7
- data.tar.gz: 5ea129e738d979e86714dca84878f5dc333039f8ba4e1a49423909ec024c4308e1fa4b839dedd6243723b9614284909b9c1dc8e810125ba3bd44d86d53c9a34e
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}"
@@ -422,7 +422,7 @@ module LocalVault
422
422
  salt = Crypto.generate_salt
423
423
  master_key = Crypto.derive_master_key("demo", salt)
424
424
  vault = Vault.create!(name: vault_name, master_key: master_key, salt: salt)
425
- secrets.each { |k, v| vault.set(k, v) }
425
+ vault.merge(secrets)
426
426
  $stdout.puts " created vault '#{vault_name}' (#{secrets.size} secrets)"
427
427
  end
428
428
 
@@ -611,7 +611,7 @@ module LocalVault
611
611
 
612
612
  imported = 0
613
613
  shares.each do |share|
614
- vault_name = "#{share["vault_name"]}-from-#{share["sender_handle"]}"
614
+ vault_name = sanitize_receive_vault_name(share["vault_name"], share["sender_handle"])
615
615
  $stdout.puts " [#{share["id"]}] vault '#{share["vault_name"]}' from @#{share["sender_handle"]}"
616
616
 
617
617
  begin
@@ -635,10 +635,15 @@ module LocalVault
635
635
  salt = Crypto.generate_salt
636
636
  master_key = Crypto.derive_master_key(passphrase, salt)
637
637
  vault = Vault.create!(name: vault_name, master_key: master_key, salt: salt)
638
- secrets.each { |k, v| vault.set(k, v.to_s) }
638
+ vault.merge(secrets)
639
639
 
640
- $stdout.puts " Imported #{secrets.size} secret(s) vault '#{vault_name}'"
641
- 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
642
647
  imported += 1
643
648
  end
644
649
 
@@ -700,21 +705,23 @@ module LocalVault
700
705
 
701
706
  vault = open_vault!
702
707
  project = options[:project]
703
- count = 0
704
708
 
709
+ # Restructure data for bulk merge
710
+ to_merge = {}
705
711
  data.each do |key, value|
706
712
  if value.is_a?(Hash)
707
- value.each do |subkey, subval|
708
- vault.set("#{key}.#{subkey}", subval.to_s)
709
- count += 1
710
- end
713
+ to_merge[key] = value
714
+ elsif project
715
+ to_merge[project] ||= {}
716
+ to_merge[project][key] = value.to_s
711
717
  else
712
- dest_key = project ? "#{project}.#{key}" : key
713
- vault.set(dest_key, value.to_s)
714
- count += 1
718
+ to_merge[key] = value.to_s
715
719
  end
716
720
  end
717
721
 
722
+ vault.merge(to_merge)
723
+ count = to_merge.sum { |_, v| v.is_a?(Hash) ? v.size : 1 }
724
+
718
725
  $stdout.puts "Imported #{count} secret(s) into vault '#{vault.name}'" \
719
726
  "#{project ? " / #{project}" : ""}."
720
727
  rescue RuntimeError => e
@@ -995,7 +1002,8 @@ module LocalVault
995
1002
  end
996
1003
  end
997
1004
 
998
- 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)
999
1007
  vault = Vault.open(name: vault_name, passphrase: passphrase)
1000
1008
  vault.all
1001
1009
  SessionCache.set(vault_name, vault.master_key)
@@ -1006,58 +1014,27 @@ module LocalVault
1006
1014
  end
1007
1015
 
1008
1016
  def resolve_recipients(client, target)
1009
- if target.start_with?("team:")
1010
- handle = target.delete_prefix("team:")
1011
- result = client.team_public_keys(handle)
1012
- (result["members"] || []).map { |m| [m["handle"], m["public_key"]] }
1013
- elsif target.start_with?("crew:")
1014
- slug = target.delete_prefix("crew:")
1015
- result = client.crew_public_keys(slug)
1016
- (result["members"] || []).map { |m| [m["handle"], m["public_key"]] }
1017
- else
1018
- handle = target.delete_prefix("@")
1019
- result = client.get_public_key(handle)
1020
- [[result["handle"], result["public_key"]]]
1021
- 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? }
1022
1031
  rescue ApiClient::ApiError => e
1023
1032
  $stderr.puts "Warning: #{e.message}"
1024
1033
  []
1025
1034
  end
1026
1035
 
1027
1036
  def open_vault!
1028
- vault_name = resolve_vault_name
1029
-
1030
- # 1. Try LOCALVAULT_SESSION env var
1031
- if (vault = vault_from_session(vault_name))
1032
- return vault
1033
- end
1034
-
1035
- store = Store.new(vault_name)
1036
- unless store.exists?
1037
- abort_with "Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
1038
- raise SystemExit.new(1)
1039
- end
1040
-
1041
- # 2. Try Keychain session cache
1042
- if (master_key = SessionCache.get(vault_name))
1043
- begin
1044
- vault = Vault.new(name: vault_name, master_key: master_key)
1045
- vault.all # verify key still valid
1046
- return vault
1047
- rescue Crypto::DecryptionError
1048
- SessionCache.clear(vault_name) # stale cache — clear and fall through
1049
- end
1050
- end
1051
-
1052
- # 3. Prompt passphrase and cache the result
1053
- passphrase = prompt_passphrase("Passphrase: ")
1054
- vault = Vault.open(name: vault_name, passphrase: passphrase)
1055
- vault.all # eager verification
1056
- SessionCache.set(vault_name, vault.master_key)
1057
- vault
1058
- rescue Crypto::DecryptionError
1059
- abort_with "Wrong passphrase for vault '#{vault_name}'"
1060
- raise SystemExit.new(1)
1037
+ open_vault_by_name!(resolve_vault_name)
1061
1038
  end
1062
1039
 
1063
1040
  def vault_from_session(vault_name)
@@ -1076,6 +1053,15 @@ module LocalVault
1076
1053
  nil
1077
1054
  end
1078
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
+
1079
1065
  def abort_with(message)
1080
1066
  $stderr.puts "Error: #{message}"
1081
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.9"
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.9
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq