localvault 0.9.6
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +227 -0
- data/bin/localvault +12 -0
- data/lib/localvault/api_client.rb +125 -0
- data/lib/localvault/cli/keys.rb +56 -0
- data/lib/localvault/cli/team.rb +37 -0
- data/lib/localvault/cli.rb +1073 -0
- data/lib/localvault/config.rb +80 -0
- data/lib/localvault/crypto.rb +63 -0
- data/lib/localvault/identity.rb +44 -0
- data/lib/localvault/mcp/server.rb +158 -0
- data/lib/localvault/mcp/tools.rb +115 -0
- data/lib/localvault/session_cache.rb +107 -0
- data/lib/localvault/share_crypto.rb +48 -0
- data/lib/localvault/store.rb +109 -0
- data/lib/localvault/vault.rb +154 -0
- data/lib/localvault/version.rb +3 -0
- data/lib/localvault.rb +11 -0
- metadata +144 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module LocalVault
|
|
5
|
+
module Config
|
|
6
|
+
CONFIG_FILE = "config.yml"
|
|
7
|
+
|
|
8
|
+
def self.root_path
|
|
9
|
+
ENV.fetch("LOCALVAULT_HOME") { File.join(Dir.home, ".localvault") }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.config_path
|
|
13
|
+
File.join(root_path, CONFIG_FILE)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.vaults_path
|
|
17
|
+
File.join(root_path, "vaults")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.keys_path
|
|
21
|
+
File.join(root_path, "keys")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.load
|
|
25
|
+
return {} unless File.exist?(config_path)
|
|
26
|
+
YAML.safe_load_file(config_path, permitted_classes: [Symbol]) || {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.save(data)
|
|
30
|
+
FileUtils.mkdir_p(root_path)
|
|
31
|
+
File.write(config_path, YAML.dump(data))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.default_vault
|
|
35
|
+
load.fetch("default_vault", "default")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.default_vault=(name)
|
|
39
|
+
data = load
|
|
40
|
+
data["default_vault"] = name
|
|
41
|
+
save(data)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.ensure_directories!
|
|
45
|
+
FileUtils.mkdir_p(root_path)
|
|
46
|
+
FileUtils.mkdir_p(vaults_path)
|
|
47
|
+
FileUtils.mkdir_p(keys_path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.token
|
|
51
|
+
load["token"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.token=(t)
|
|
55
|
+
data = load
|
|
56
|
+
data["token"] = t
|
|
57
|
+
save(data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.inventlist_handle
|
|
61
|
+
load["inventlist_handle"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.inventlist_handle=(h)
|
|
65
|
+
data = load
|
|
66
|
+
data["inventlist_handle"] = h
|
|
67
|
+
save(data)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.api_url
|
|
71
|
+
load.fetch("api_url", "https://inventlist.com")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.api_url=(url)
|
|
75
|
+
data = load
|
|
76
|
+
data["api_url"] = url
|
|
77
|
+
save(data)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "rbnacl"
|
|
2
|
+
|
|
3
|
+
module LocalVault
|
|
4
|
+
module Crypto
|
|
5
|
+
class DecryptionError < StandardError; end
|
|
6
|
+
|
|
7
|
+
SALT_BYTES = 16
|
|
8
|
+
NONCE_BYTES = RbNaCl::SecretBoxes::XSalsa20Poly1305.nonce_bytes # 24
|
|
9
|
+
KEY_BYTES = RbNaCl::SecretBoxes::XSalsa20Poly1305.key_bytes # 32
|
|
10
|
+
|
|
11
|
+
# Argon2id parameters (moderate — fast enough for CLI, strong enough for secrets)
|
|
12
|
+
ARGON2_OPSLIMIT = 2
|
|
13
|
+
ARGON2_MEMLIMIT = 67_108_864 # 64 MB
|
|
14
|
+
|
|
15
|
+
def self.generate_salt
|
|
16
|
+
RbNaCl::Random.random_bytes(SALT_BYTES)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.derive_master_key(passphrase, salt)
|
|
20
|
+
RbNaCl::PasswordHash.argon2id(
|
|
21
|
+
passphrase,
|
|
22
|
+
salt,
|
|
23
|
+
ARGON2_OPSLIMIT,
|
|
24
|
+
ARGON2_MEMLIMIT,
|
|
25
|
+
KEY_BYTES
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.encrypt(plaintext, key)
|
|
30
|
+
box = RbNaCl::SecretBox.new(key)
|
|
31
|
+
nonce = RbNaCl::Random.random_bytes(NONCE_BYTES)
|
|
32
|
+
ciphertext = box.encrypt(nonce, plaintext)
|
|
33
|
+
nonce + ciphertext
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.decrypt(ciphertext_with_nonce, key)
|
|
37
|
+
box = RbNaCl::SecretBox.new(key)
|
|
38
|
+
nonce = ciphertext_with_nonce[0, NONCE_BYTES]
|
|
39
|
+
ciphertext = ciphertext_with_nonce[NONCE_BYTES..]
|
|
40
|
+
box.decrypt(nonce, ciphertext)
|
|
41
|
+
rescue RbNaCl::CryptoError => e
|
|
42
|
+
raise DecryptionError, "Decryption failed: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
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
|
+
sk = RbNaCl::PrivateKey.generate
|
|
49
|
+
{
|
|
50
|
+
public_key: sk.public_key.to_bytes,
|
|
51
|
+
private_key: sk.to_bytes
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.encrypt_private_key(private_key_bytes, master_key)
|
|
56
|
+
encrypt(private_key_bytes, master_key)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.decrypt_private_key(encrypted_bytes, master_key)
|
|
60
|
+
decrypt(encrypted_bytes, master_key)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module LocalVault
|
|
5
|
+
module Identity
|
|
6
|
+
def self.priv_key_path = File.join(Config.keys_path, "identity.priv")
|
|
7
|
+
def self.pub_key_path = File.join(Config.keys_path, "identity.pub")
|
|
8
|
+
|
|
9
|
+
def self.exists?
|
|
10
|
+
File.exist?(priv_key_path) && File.exist?(pub_key_path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.generate!(force: false)
|
|
14
|
+
raise "Keypair already exists. Use --force to overwrite." if exists? && !force
|
|
15
|
+
|
|
16
|
+
Config.ensure_directories!
|
|
17
|
+
kp = Crypto.generate_keypair
|
|
18
|
+
|
|
19
|
+
File.write(priv_key_path, Base64.strict_encode64(kp[:private_key]))
|
|
20
|
+
File.chmod(0o600, priv_key_path)
|
|
21
|
+
File.write(pub_key_path, Base64.strict_encode64(kp[:public_key]))
|
|
22
|
+
kp
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.public_key
|
|
26
|
+
return nil unless File.exist?(pub_key_path)
|
|
27
|
+
File.read(pub_key_path).strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.private_key_b64
|
|
31
|
+
return nil unless File.exist?(priv_key_path)
|
|
32
|
+
File.read(priv_key_path).strip
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.private_key_bytes
|
|
36
|
+
b64 = private_key_b64
|
|
37
|
+
b64 ? Base64.strict_decode64(b64) : nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.setup?
|
|
41
|
+
exists? && !Config.token.nil? && !Config.token.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "base64"
|
|
3
|
+
require_relative "../version"
|
|
4
|
+
require_relative "../crypto"
|
|
5
|
+
require_relative "../config"
|
|
6
|
+
require_relative "../store"
|
|
7
|
+
require_relative "../vault"
|
|
8
|
+
require_relative "../session_cache"
|
|
9
|
+
require_relative "tools"
|
|
10
|
+
|
|
11
|
+
module LocalVault
|
|
12
|
+
module MCP
|
|
13
|
+
class Server
|
|
14
|
+
def initialize(input: $stdin, output: $stdout)
|
|
15
|
+
@input = input
|
|
16
|
+
@output = output
|
|
17
|
+
@vault_cache = {} # name => Vault — lazily populated per-call
|
|
18
|
+
@session_vault = load_session_vault # LOCALVAULT_SESSION fast-path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start
|
|
22
|
+
unlocked = unlocked_vault_names
|
|
23
|
+
label = unlocked.empty? ? "no unlocked vaults (run: localvault show)" : "vaults=#{unlocked.join(', ')}"
|
|
24
|
+
$stderr.puts "[localvault-mcp] started v#{LocalVault::VERSION} #{label}"
|
|
25
|
+
$stderr.flush
|
|
26
|
+
|
|
27
|
+
@input.each_line do |line|
|
|
28
|
+
line = line.strip
|
|
29
|
+
next if line.empty?
|
|
30
|
+
|
|
31
|
+
response = handle_message(line)
|
|
32
|
+
if response
|
|
33
|
+
@output.puts(JSON.generate(response))
|
|
34
|
+
@output.flush
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
$stderr.puts "[localvault-mcp] stopped"
|
|
39
|
+
$stderr.flush
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handle_message(json_string)
|
|
43
|
+
message = JSON.parse(json_string)
|
|
44
|
+
|
|
45
|
+
# Notifications have no id — no response
|
|
46
|
+
return nil unless message.key?("id")
|
|
47
|
+
|
|
48
|
+
id = message["id"]
|
|
49
|
+
method = message["method"]
|
|
50
|
+
params = message["params"] || {}
|
|
51
|
+
|
|
52
|
+
case method
|
|
53
|
+
when "initialize"
|
|
54
|
+
success_response(id, {
|
|
55
|
+
"protocolVersion" => "2025-11-25",
|
|
56
|
+
"capabilities" => { "tools" => {} },
|
|
57
|
+
"serverInfo" => { "name" => "localvault", "version" => LocalVault::VERSION }
|
|
58
|
+
})
|
|
59
|
+
when "tools/list"
|
|
60
|
+
success_response(id, { "tools" => Tools::DEFINITIONS })
|
|
61
|
+
when "tools/call"
|
|
62
|
+
tool_name = params["name"]
|
|
63
|
+
arguments = params["arguments"] || {}
|
|
64
|
+
|
|
65
|
+
unless Tools::DEFINITIONS.any? { |t| t["name"] == tool_name }
|
|
66
|
+
return error_response(id, -32602, "Unknown tool: #{tool_name}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result = Tools.call(tool_name, arguments, method(:vault_for))
|
|
70
|
+
success_response(id, result)
|
|
71
|
+
else
|
|
72
|
+
error_response(id, -32601, "Method not found: #{method}")
|
|
73
|
+
end
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
error_response(nil, -32700, "Parse error")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Resolve vault by name, lazily — tries session token, then Keychain.
|
|
81
|
+
# Returns nil if vault is not unlocked.
|
|
82
|
+
def vault_for(name = nil)
|
|
83
|
+
# No specific vault requested: session vault takes priority over default
|
|
84
|
+
if name.nil? && @session_vault
|
|
85
|
+
@vault_cache[@session_vault.name] ||= @session_vault
|
|
86
|
+
return @session_vault
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
vault_name = name || default_vault_name
|
|
90
|
+
|
|
91
|
+
return @vault_cache[vault_name] if @vault_cache.key?(vault_name)
|
|
92
|
+
|
|
93
|
+
# Fast-path: LOCALVAULT_SESSION matches by name
|
|
94
|
+
if @session_vault && @session_vault.name == vault_name
|
|
95
|
+
@vault_cache[vault_name] = @session_vault
|
|
96
|
+
return @session_vault
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Keychain lookup
|
|
100
|
+
if (master_key = SessionCache.get(vault_name))
|
|
101
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
102
|
+
vault.all # verify decryption
|
|
103
|
+
@vault_cache[vault_name] = vault
|
|
104
|
+
return vault
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
nil
|
|
108
|
+
rescue Crypto::DecryptionError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def default_vault_name
|
|
113
|
+
ENV["LOCALVAULT_VAULT"] || Config.default_vault
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Parse LOCALVAULT_SESSION on startup (single-vault legacy path).
|
|
117
|
+
def load_session_vault
|
|
118
|
+
token = ENV["LOCALVAULT_SESSION"]
|
|
119
|
+
return nil unless token
|
|
120
|
+
|
|
121
|
+
decoded = Base64.strict_decode64(token)
|
|
122
|
+
vault_name, key_b64 = decoded.split(":", 2)
|
|
123
|
+
return nil unless vault_name && key_b64
|
|
124
|
+
|
|
125
|
+
master_key = Base64.strict_decode64(key_b64)
|
|
126
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
127
|
+
vault.all # verify decryption
|
|
128
|
+
vault
|
|
129
|
+
rescue ArgumentError, Crypto::DecryptionError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# List vault names that are currently unlocked (for the startup log).
|
|
134
|
+
def unlocked_vault_names
|
|
135
|
+
names = []
|
|
136
|
+
|
|
137
|
+
# From LOCALVAULT_SESSION
|
|
138
|
+
names << @session_vault.name if @session_vault
|
|
139
|
+
|
|
140
|
+
# From Keychain — check all known vaults
|
|
141
|
+
Store.list_vaults.each do |n|
|
|
142
|
+
next if names.include?(n)
|
|
143
|
+
names << n if SessionCache.get(n)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
names
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def success_response(id, result)
|
|
150
|
+
{ "jsonrpc" => "2.0", "id" => id, "result" => result }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def error_response(id, code, message)
|
|
154
|
+
{ "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module LocalVault
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
VAULT_PARAM = {
|
|
7
|
+
"vault" => {
|
|
8
|
+
"type" => "string",
|
|
9
|
+
"description" => "Vault name to use (uses default vault if omitted)"
|
|
10
|
+
}
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
DEFINITIONS = [
|
|
14
|
+
{
|
|
15
|
+
"name" => "get_secret",
|
|
16
|
+
"description" => "Retrieve a secret value by key from a localvault vault",
|
|
17
|
+
"inputSchema" => {
|
|
18
|
+
"type" => "object",
|
|
19
|
+
"properties" => {
|
|
20
|
+
"key" => { "type" => "string", "description" => "The secret key to retrieve" },
|
|
21
|
+
**VAULT_PARAM
|
|
22
|
+
},
|
|
23
|
+
"required" => ["key"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name" => "list_secrets",
|
|
28
|
+
"description" => "List all secret keys in a localvault vault",
|
|
29
|
+
"inputSchema" => {
|
|
30
|
+
"type" => "object",
|
|
31
|
+
"properties" => { **VAULT_PARAM },
|
|
32
|
+
"required" => []
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name" => "set_secret",
|
|
37
|
+
"description" => "Store a secret key-value pair in a localvault vault. Use dot-notation (project.KEY) for namespaced secrets.",
|
|
38
|
+
"inputSchema" => {
|
|
39
|
+
"type" => "object",
|
|
40
|
+
"properties" => {
|
|
41
|
+
"key" => { "type" => "string", "description" => "The secret key (supports dot-notation: project.KEY)" },
|
|
42
|
+
"value" => { "type" => "string", "description" => "The secret value" },
|
|
43
|
+
**VAULT_PARAM
|
|
44
|
+
},
|
|
45
|
+
"required" => ["key", "value"]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name" => "delete_secret",
|
|
50
|
+
"description" => "Delete a secret by key from a localvault vault",
|
|
51
|
+
"inputSchema" => {
|
|
52
|
+
"type" => "object",
|
|
53
|
+
"properties" => {
|
|
54
|
+
"key" => { "type" => "string", "description" => "The secret key to delete" },
|
|
55
|
+
**VAULT_PARAM
|
|
56
|
+
},
|
|
57
|
+
"required" => ["key"]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# vault_resolver: callable that takes a vault name (String or nil) and returns a Vault or nil
|
|
63
|
+
def self.call(name, arguments, vault_resolver)
|
|
64
|
+
unless DEFINITIONS.any? { |t| t["name"] == name }
|
|
65
|
+
raise ArgumentError, "Unknown tool: #{name}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
vault_name = arguments["vault"]
|
|
69
|
+
vault = vault_resolver.call(vault_name)
|
|
70
|
+
|
|
71
|
+
unless vault
|
|
72
|
+
hint = vault_name ? "localvault show -v #{vault_name}" : "localvault show"
|
|
73
|
+
return error_result("No unlocked vault session. Run: #{hint}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
case name
|
|
77
|
+
when "get_secret" then get_secret(arguments["key"], vault)
|
|
78
|
+
when "list_secrets" then list_secrets(vault)
|
|
79
|
+
when "set_secret" then set_secret(arguments["key"], arguments["value"], vault)
|
|
80
|
+
when "delete_secret" then delete_secret(arguments["key"], vault)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.get_secret(key, vault)
|
|
85
|
+
value = vault.get(key)
|
|
86
|
+
value.nil? ? error_result("Key '#{key}' not found") : text_result(value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.list_secrets(vault)
|
|
90
|
+
keys = vault.list
|
|
91
|
+
keys.empty? ? text_result("No secrets stored") : text_result(keys.join("\n"))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.set_secret(key, value, vault)
|
|
95
|
+
vault.set(key, value)
|
|
96
|
+
text_result("Stored #{key}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.delete_secret(key, vault)
|
|
100
|
+
deleted = vault.delete(key)
|
|
101
|
+
deleted.nil? ? error_result("Key '#{key}' not found") : text_result("Deleted #{key}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.text_result(text)
|
|
105
|
+
{ "content" => [{ "type" => "text", "text" => text }] }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.error_result(text)
|
|
109
|
+
{ "content" => [{ "type" => "text", "text" => text }], "isError" => true }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private_class_method :get_secret, :list_secrets, :set_secret, :delete_secret, :text_result, :error_result
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module LocalVault
|
|
6
|
+
# Caches derived master keys in macOS Keychain (or a fallback file store).
|
|
7
|
+
# Avoids re-prompting passphrase on every command.
|
|
8
|
+
#
|
|
9
|
+
# Stored payload: "<base64_key>|<expiry_unix_ts>"
|
|
10
|
+
# macOS: Keychain service "localvault", account: vault name
|
|
11
|
+
# Linux/other: ~/.localvault/.sessions/<vault_name> (mode 0600)
|
|
12
|
+
module SessionCache
|
|
13
|
+
DEFAULT_TTL_HOURS = 8
|
|
14
|
+
KEYCHAIN_SERVICE = "localvault"
|
|
15
|
+
|
|
16
|
+
def self.get(vault_name)
|
|
17
|
+
payload = keychain_get(vault_name)
|
|
18
|
+
return nil unless payload
|
|
19
|
+
|
|
20
|
+
key_b64, expiry_str = payload.split("|", 2)
|
|
21
|
+
return nil unless key_b64 && expiry_str
|
|
22
|
+
|
|
23
|
+
expiry = expiry_str.to_i
|
|
24
|
+
return nil if Time.now.to_i >= expiry
|
|
25
|
+
|
|
26
|
+
Base64.strict_decode64(key_b64)
|
|
27
|
+
rescue ArgumentError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.set(vault_name, master_key, ttl_hours: DEFAULT_TTL_HOURS)
|
|
32
|
+
expiry = Time.now.to_i + (ttl_hours * 3600).to_i
|
|
33
|
+
payload = "#{Base64.strict_encode64(master_key)}|#{expiry}"
|
|
34
|
+
keychain_set(vault_name, payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.clear(vault_name)
|
|
38
|
+
keychain_delete(vault_name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.clear_all
|
|
42
|
+
Store.list_vaults.each { |name| clear(name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def self.macos?
|
|
48
|
+
RUBY_PLATFORM.include?("darwin")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.sessions_dir
|
|
52
|
+
dir = File.join(
|
|
53
|
+
ENV.fetch("LOCALVAULT_HOME", File.expand_path("~/.localvault")),
|
|
54
|
+
".sessions"
|
|
55
|
+
)
|
|
56
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
57
|
+
dir
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.session_file(vault_name)
|
|
61
|
+
File.join(sessions_dir, vault_name.gsub(/[^a-zA-Z0-9_\-]/, "_"))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.keychain_get(vault_name)
|
|
65
|
+
if macos?
|
|
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
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.keychain_set(vault_name, payload)
|
|
75
|
+
if macos?
|
|
76
|
+
keychain_delete(vault_name)
|
|
77
|
+
system(
|
|
78
|
+
"security", "add-generic-password",
|
|
79
|
+
"-a", vault_name,
|
|
80
|
+
"-s", KEYCHAIN_SERVICE,
|
|
81
|
+
"-w", payload,
|
|
82
|
+
"-A",
|
|
83
|
+
out: File::NULL, err: File::NULL
|
|
84
|
+
)
|
|
85
|
+
else
|
|
86
|
+
keychain_delete(vault_name)
|
|
87
|
+
file = session_file(vault_name)
|
|
88
|
+
File.write(file, payload)
|
|
89
|
+
File.chmod(0o600, file)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.keychain_delete(vault_name)
|
|
94
|
+
if macos?
|
|
95
|
+
system(
|
|
96
|
+
"security", "delete-generic-password",
|
|
97
|
+
"-a", vault_name,
|
|
98
|
+
"-s", KEYCHAIN_SERVICE,
|
|
99
|
+
out: File::NULL, err: File::NULL
|
|
100
|
+
)
|
|
101
|
+
else
|
|
102
|
+
FileUtils.rm_f(session_file(vault_name))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "rbnacl"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LocalVault
|
|
6
|
+
module ShareCrypto
|
|
7
|
+
class DecryptionError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Encrypt a secrets hash for a recipient using their X25519 public key.
|
|
10
|
+
# Uses an ephemeral sender keypair (Box construction) so the sender's
|
|
11
|
+
# identity private key is never transmitted.
|
|
12
|
+
# Returns a base64-encoded JSON blob.
|
|
13
|
+
def self.encrypt_for(secrets, recipient_pub_key_b64)
|
|
14
|
+
recipient_pub = RbNaCl::PublicKey.new(Base64.strict_decode64(recipient_pub_key_b64))
|
|
15
|
+
ephemeral_sk = RbNaCl::PrivateKey.generate
|
|
16
|
+
box = RbNaCl::Box.new(recipient_pub, ephemeral_sk)
|
|
17
|
+
nonce = RbNaCl::Random.random_bytes(RbNaCl::Box.nonce_bytes)
|
|
18
|
+
plaintext = JSON.generate(secrets)
|
|
19
|
+
ciphertext = box.box(nonce, plaintext)
|
|
20
|
+
|
|
21
|
+
payload = {
|
|
22
|
+
"v" => 1,
|
|
23
|
+
"sender_pub" => Base64.strict_encode64(ephemeral_sk.public_key.to_bytes),
|
|
24
|
+
"nonce" => Base64.strict_encode64(nonce),
|
|
25
|
+
"ciphertext" => Base64.strict_encode64(ciphertext)
|
|
26
|
+
}
|
|
27
|
+
Base64.strict_encode64(JSON.generate(payload))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Decrypt an encrypted_payload using the recipient's private key.
|
|
31
|
+
# Returns the decrypted secrets hash { "KEY" => "value", ... }.
|
|
32
|
+
def self.decrypt_from(encrypted_payload_b64, my_private_key_bytes)
|
|
33
|
+
raw = Base64.strict_decode64(encrypted_payload_b64)
|
|
34
|
+
payload = JSON.parse(raw)
|
|
35
|
+
sender_pub = RbNaCl::PublicKey.new(Base64.strict_decode64(payload.fetch("sender_pub")))
|
|
36
|
+
my_sk = RbNaCl::PrivateKey.new(my_private_key_bytes)
|
|
37
|
+
box = RbNaCl::Box.new(sender_pub, my_sk)
|
|
38
|
+
nonce = Base64.strict_decode64(payload.fetch("nonce"))
|
|
39
|
+
ciphertext = Base64.strict_decode64(payload.fetch("ciphertext"))
|
|
40
|
+
plaintext = box.open(nonce, ciphertext)
|
|
41
|
+
JSON.parse(plaintext)
|
|
42
|
+
rescue RbNaCl::CryptoError => e
|
|
43
|
+
raise DecryptionError, "Failed to decrypt share: #{e.message}"
|
|
44
|
+
rescue JSON::ParserError, KeyError => e
|
|
45
|
+
raise DecryptionError, "Invalid payload format: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|