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 +4 -4
- data/lib/localvault/api_client.rb +11 -5
- data/lib/localvault/cli/sync.rb +7 -2
- data/lib/localvault/cli.rb +46 -60
- 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
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
638
|
+
vault.merge(secrets)
|
|
639
639
|
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
713
|
+
to_merge[key] = value
|
|
714
|
+
elsif project
|
|
715
|
+
to_merge[project] ||= {}
|
|
716
|
+
to_merge[project][key] = value.to_s
|
|
711
717
|
else
|
|
712
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
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
|
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