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 +4 -4
- data/lib/localvault/api_client.rb +11 -5
- data/lib/localvault/cli/sync.rb +7 -2
- data/lib/localvault/cli.rb +64 -62
- data/lib/localvault/config.rb +4 -3
- data/lib/localvault/crypto.rb +0 -2
- data/lib/localvault/identity.rb +1 -0
- data/lib/localvault/mcp/tools.rb +2 -0
- data/lib/localvault/session_cache.rb +13 -8
- data/lib/localvault/share_crypto.rb +2 -0
- data/lib/localvault/store.rb +32 -8
- data/lib/localvault/sync_bundle.rb +10 -1
- data/lib/localvault/vault.rb +69 -9
- data/lib/localvault/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 188e3a7249114892858d65724529b3d537a2c058250fb06a2bbfd63acc4cc387
|
|
4
|
+
data.tar.gz: 222bf0d8f5a377d6633f95ae066769c2db16333913c50c158e430b93b89faf7a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
data/lib/localvault/cli/sync.rb
CHANGED
|
@@ -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
|
-
|
|
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}"
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
638
|
+
vault.merge(secrets)
|
|
623
639
|
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
713
|
+
to_merge[key] = value
|
|
714
|
+
elsif project
|
|
715
|
+
to_merge[project] ||= {}
|
|
716
|
+
to_merge[project][key] = value.to_s
|
|
695
717
|
else
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
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
|
data/lib/localvault/config.rb
CHANGED
|
@@ -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
|
data/lib/localvault/crypto.rb
CHANGED
|
@@ -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,
|
data/lib/localvault/identity.rb
CHANGED
data/lib/localvault/mcp/tools.rb
CHANGED
|
@@ -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?
|
|
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
|
data/lib/localvault/store.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
existing = meta
|
|
95
|
+
new_meta = {
|
|
88
96
|
"name" => vault_name,
|
|
89
|
-
"created_at" =>
|
|
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
|
-
|
|
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
|
data/lib/localvault/vault.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
107
|
+
group.each_with_object({}) { |(k, v), h| h[k] = v.to_s if shell_safe_key?(k) }
|
|
98
108
|
else
|
|
99
|
-
secrets.
|
|
109
|
+
secrets.each_with_object({}) do |(k, v), h|
|
|
100
110
|
if v.is_a?(Hash)
|
|
101
|
-
|
|
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
|
-
[
|
|
114
|
+
h[k] = v.to_s if shell_safe_key?(k)
|
|
104
115
|
end
|
|
105
|
-
end
|
|
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)
|
data/lib/localvault/version.rb
CHANGED