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.
@@ -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