localvault 1.1.1 → 1.2.1
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/LICENSE +15 -17
- data/README.md +64 -2
- data/lib/localvault/api_client.rb +86 -14
- data/lib/localvault/cli/keys.rb +14 -0
- data/lib/localvault/cli/sync.rb +90 -12
- data/lib/localvault/cli/team.rb +388 -46
- data/lib/localvault/cli.rb +66 -4
- data/lib/localvault/config.rb +61 -0
- data/lib/localvault/crypto.rb +46 -0
- data/lib/localvault/identity.rb +41 -0
- data/lib/localvault/key_slot.rb +23 -3
- data/lib/localvault/mcp/server.rb +17 -0
- data/lib/localvault/mcp/tools.rb +13 -1
- data/lib/localvault/session_cache.rb +35 -7
- data/lib/localvault/share_crypto.rb +30 -5
- data/lib/localvault/store.rb +66 -0
- data/lib/localvault/sync_bundle.rb +50 -11
- data/lib/localvault/vault.rb +122 -12
- data/lib/localvault/version.rb +1 -1
- metadata +1 -1
data/lib/localvault/config.rb
CHANGED
|
@@ -2,76 +2,137 @@ require "yaml"
|
|
|
2
2
|
require "fileutils"
|
|
3
3
|
|
|
4
4
|
module LocalVault
|
|
5
|
+
# Global configuration — paths, default vault, API credentials.
|
|
6
|
+
#
|
|
7
|
+
# Reads/writes +~/.localvault/config.yml+ (mode 0600).
|
|
8
|
+
# All directories are created with mode 0700.
|
|
9
|
+
# Override the root path with +LOCALVAULT_HOME+ env var.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# Config.root_path # => "~/.localvault"
|
|
13
|
+
# Config.default_vault # => "default"
|
|
14
|
+
# Config.token = "tok-..."
|
|
15
|
+
# Config.inventlist_handle = "nauman"
|
|
5
16
|
module Config
|
|
6
17
|
CONFIG_FILE = "config.yml"
|
|
7
18
|
|
|
19
|
+
# Root directory for all LocalVault data. Honors +LOCALVAULT_HOME+ env var.
|
|
20
|
+
#
|
|
21
|
+
# @return [String] absolute path, defaults to +~/.localvault+
|
|
8
22
|
def self.root_path
|
|
9
23
|
ENV.fetch("LOCALVAULT_HOME") { File.join(Dir.home, ".localvault") }
|
|
10
24
|
end
|
|
11
25
|
|
|
26
|
+
# Path to the global config file.
|
|
27
|
+
#
|
|
28
|
+
# @return [String] absolute path to +config.yml+
|
|
12
29
|
def self.config_path
|
|
13
30
|
File.join(root_path, CONFIG_FILE)
|
|
14
31
|
end
|
|
15
32
|
|
|
33
|
+
# Path to the directory containing all vaults.
|
|
34
|
+
#
|
|
35
|
+
# @return [String] absolute path to +vaults/+ directory
|
|
16
36
|
def self.vaults_path
|
|
17
37
|
File.join(root_path, "vaults")
|
|
18
38
|
end
|
|
19
39
|
|
|
40
|
+
# Path to the directory containing identity keys.
|
|
41
|
+
#
|
|
42
|
+
# @return [String] absolute path to +keys/+ directory
|
|
20
43
|
def self.keys_path
|
|
21
44
|
File.join(root_path, "keys")
|
|
22
45
|
end
|
|
23
46
|
|
|
47
|
+
# Load the config file as a hash.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash] parsed config, or empty hash if file is missing
|
|
24
50
|
def self.load
|
|
25
51
|
return {} unless File.exist?(config_path)
|
|
26
52
|
YAML.safe_load_file(config_path, permitted_classes: [Symbol]) || {}
|
|
27
53
|
end
|
|
28
54
|
|
|
55
|
+
# Write config data to disk (mode 0600).
|
|
56
|
+
#
|
|
57
|
+
# @param data [Hash] the config hash to persist
|
|
58
|
+
# @return [void]
|
|
29
59
|
def self.save(data)
|
|
30
60
|
FileUtils.mkdir_p(root_path, mode: 0o700)
|
|
31
61
|
File.write(config_path, YAML.dump(data))
|
|
32
62
|
File.chmod(0o600, config_path)
|
|
33
63
|
end
|
|
34
64
|
|
|
65
|
+
# Name of the default vault.
|
|
66
|
+
#
|
|
67
|
+
# @return [String] vault name, defaults to "default"
|
|
35
68
|
def self.default_vault
|
|
36
69
|
load.fetch("default_vault", "default")
|
|
37
70
|
end
|
|
38
71
|
|
|
72
|
+
# Set the default vault name.
|
|
73
|
+
#
|
|
74
|
+
# @param name [String] the vault name to use as default
|
|
75
|
+
# @return [void]
|
|
39
76
|
def self.default_vault=(name)
|
|
40
77
|
data = load
|
|
41
78
|
data["default_vault"] = name
|
|
42
79
|
save(data)
|
|
43
80
|
end
|
|
44
81
|
|
|
82
|
+
# Create the root, vaults, and keys directories if they don't exist (mode 0700).
|
|
83
|
+
#
|
|
84
|
+
# @return [void]
|
|
45
85
|
def self.ensure_directories!
|
|
46
86
|
FileUtils.mkdir_p(root_path, mode: 0o700)
|
|
47
87
|
FileUtils.mkdir_p(vaults_path, mode: 0o700)
|
|
48
88
|
FileUtils.mkdir_p(keys_path, mode: 0o700)
|
|
49
89
|
end
|
|
50
90
|
|
|
91
|
+
# Read the API authentication token.
|
|
92
|
+
#
|
|
93
|
+
# @return [String, nil] the stored token, or nil
|
|
51
94
|
def self.token
|
|
52
95
|
load["token"]
|
|
53
96
|
end
|
|
54
97
|
|
|
98
|
+
# Set the API authentication token.
|
|
99
|
+
#
|
|
100
|
+
# @param t [String] the token to store
|
|
101
|
+
# @return [void]
|
|
55
102
|
def self.token=(t)
|
|
56
103
|
data = load
|
|
57
104
|
data["token"] = t
|
|
58
105
|
save(data)
|
|
59
106
|
end
|
|
60
107
|
|
|
108
|
+
# Read the InventList user handle.
|
|
109
|
+
#
|
|
110
|
+
# @return [String, nil] the stored handle, or nil
|
|
61
111
|
def self.inventlist_handle
|
|
62
112
|
load["inventlist_handle"]
|
|
63
113
|
end
|
|
64
114
|
|
|
115
|
+
# Set the InventList user handle.
|
|
116
|
+
#
|
|
117
|
+
# @param h [String] the handle to store
|
|
118
|
+
# @return [void]
|
|
65
119
|
def self.inventlist_handle=(h)
|
|
66
120
|
data = load
|
|
67
121
|
data["inventlist_handle"] = h
|
|
68
122
|
save(data)
|
|
69
123
|
end
|
|
70
124
|
|
|
125
|
+
# Read the InventList API base URL.
|
|
126
|
+
#
|
|
127
|
+
# @return [String] the API URL, defaults to "https://inventlist.com"
|
|
71
128
|
def self.api_url
|
|
72
129
|
load.fetch("api_url", "https://inventlist.com")
|
|
73
130
|
end
|
|
74
131
|
|
|
132
|
+
# Set the InventList API base URL.
|
|
133
|
+
#
|
|
134
|
+
# @param url [String] the API URL to store
|
|
135
|
+
# @return [void]
|
|
75
136
|
def self.api_url=(url)
|
|
76
137
|
data = load
|
|
77
138
|
data["api_url"] = url
|
data/lib/localvault/crypto.rb
CHANGED
|
@@ -22,7 +22,20 @@ rescue LoadError => e
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
module LocalVault
|
|
25
|
+
# Cryptographic primitives for vault encryption and key derivation.
|
|
26
|
+
#
|
|
27
|
+
# Uses libsodium (via RbNaCl) exclusively:
|
|
28
|
+
# - Argon2id for passphrase → master key derivation (memory-hard KDF)
|
|
29
|
+
# - XSalsa20-Poly1305 for authenticated symmetric encryption
|
|
30
|
+
# - X25519 for asymmetric keypair generation (used by Identity + KeySlot)
|
|
31
|
+
#
|
|
32
|
+
# @example Derive a master key and encrypt
|
|
33
|
+
# salt = Crypto.generate_salt
|
|
34
|
+
# key = Crypto.derive_master_key("my passphrase", salt)
|
|
35
|
+
# ct = Crypto.encrypt("secret data", key)
|
|
36
|
+
# Crypto.decrypt(ct, key) # => "secret data"
|
|
25
37
|
module Crypto
|
|
38
|
+
# Raised when decryption fails (wrong key, tampered data, or corrupt ciphertext).
|
|
26
39
|
class DecryptionError < StandardError; end
|
|
27
40
|
|
|
28
41
|
SALT_BYTES = 16
|
|
@@ -33,10 +46,18 @@ module LocalVault
|
|
|
33
46
|
ARGON2_OPSLIMIT = 2
|
|
34
47
|
ARGON2_MEMLIMIT = 67_108_864 # 64 MB
|
|
35
48
|
|
|
49
|
+
# Generate a random salt for key derivation.
|
|
50
|
+
#
|
|
51
|
+
# @return [String] 16 random bytes
|
|
36
52
|
def self.generate_salt
|
|
37
53
|
RbNaCl::Random.random_bytes(SALT_BYTES)
|
|
38
54
|
end
|
|
39
55
|
|
|
56
|
+
# Derive a 32-byte master key from a passphrase using Argon2id.
|
|
57
|
+
#
|
|
58
|
+
# @param passphrase [String] the user's passphrase
|
|
59
|
+
# @param salt [String] 16-byte salt
|
|
60
|
+
# @return [String] 32-byte derived key
|
|
40
61
|
def self.derive_master_key(passphrase, salt)
|
|
41
62
|
RbNaCl::PasswordHash.argon2id(
|
|
42
63
|
passphrase,
|
|
@@ -47,6 +68,11 @@ module LocalVault
|
|
|
47
68
|
)
|
|
48
69
|
end
|
|
49
70
|
|
|
71
|
+
# Encrypt plaintext with XSalsa20-Poly1305. Prepends a random nonce.
|
|
72
|
+
#
|
|
73
|
+
# @param plaintext [String] data to encrypt
|
|
74
|
+
# @param key [String] 32-byte symmetric key
|
|
75
|
+
# @return [String] nonce (24 bytes) + ciphertext
|
|
50
76
|
def self.encrypt(plaintext, key)
|
|
51
77
|
box = RbNaCl::SecretBox.new(key)
|
|
52
78
|
nonce = RbNaCl::Random.random_bytes(NONCE_BYTES)
|
|
@@ -54,6 +80,12 @@ module LocalVault
|
|
|
54
80
|
nonce + ciphertext
|
|
55
81
|
end
|
|
56
82
|
|
|
83
|
+
# Decrypt ciphertext produced by +encrypt+. Expects nonce prepended.
|
|
84
|
+
#
|
|
85
|
+
# @param ciphertext_with_nonce [String] nonce (24 bytes) + ciphertext
|
|
86
|
+
# @param key [String] 32-byte symmetric key
|
|
87
|
+
# @return [String] decrypted plaintext
|
|
88
|
+
# @raise [DecryptionError] when the key is wrong or data is tampered
|
|
57
89
|
def self.decrypt(ciphertext_with_nonce, key)
|
|
58
90
|
box = RbNaCl::SecretBox.new(key)
|
|
59
91
|
nonce = ciphertext_with_nonce[0, NONCE_BYTES]
|
|
@@ -63,6 +95,9 @@ module LocalVault
|
|
|
63
95
|
raise DecryptionError, "Decryption failed: #{e.message}"
|
|
64
96
|
end
|
|
65
97
|
|
|
98
|
+
# Generate an X25519 keypair for asymmetric encryption.
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash{Symbol => String}] +:public_key+ and +:private_key+ as raw bytes
|
|
66
101
|
def self.generate_keypair
|
|
67
102
|
sk = RbNaCl::PrivateKey.generate
|
|
68
103
|
{
|
|
@@ -71,10 +106,21 @@ module LocalVault
|
|
|
71
106
|
}
|
|
72
107
|
end
|
|
73
108
|
|
|
109
|
+
# Encrypt a private key with a master key (convenience wrapper around +encrypt+).
|
|
110
|
+
#
|
|
111
|
+
# @param private_key_bytes [String] raw private key bytes
|
|
112
|
+
# @param master_key [String] 32-byte symmetric key
|
|
113
|
+
# @return [String] nonce + ciphertext
|
|
74
114
|
def self.encrypt_private_key(private_key_bytes, master_key)
|
|
75
115
|
encrypt(private_key_bytes, master_key)
|
|
76
116
|
end
|
|
77
117
|
|
|
118
|
+
# Decrypt a private key with a master key (convenience wrapper around +decrypt+).
|
|
119
|
+
#
|
|
120
|
+
# @param encrypted_bytes [String] nonce + ciphertext from +encrypt_private_key+
|
|
121
|
+
# @param master_key [String] 32-byte symmetric key
|
|
122
|
+
# @return [String] raw private key bytes
|
|
123
|
+
# @raise [DecryptionError] when the key is wrong or data is tampered
|
|
78
124
|
def self.decrypt_private_key(encrypted_bytes, master_key)
|
|
79
125
|
decrypt(encrypted_bytes, master_key)
|
|
80
126
|
end
|
data/lib/localvault/identity.rb
CHANGED
|
@@ -2,14 +2,43 @@ require "base64"
|
|
|
2
2
|
require "fileutils"
|
|
3
3
|
|
|
4
4
|
module LocalVault
|
|
5
|
+
# Manages the user's X25519 identity keypair for vault sharing and sync.
|
|
6
|
+
#
|
|
7
|
+
# The keypair is stored in +~/.localvault/keys/+:
|
|
8
|
+
# - +identity.priv+ (mode 0600) — base64-encoded private key
|
|
9
|
+
# - +identity.pub+ (mode 0644) — base64-encoded public key
|
|
10
|
+
#
|
|
11
|
+
# The public key is published to InventList so others can encrypt
|
|
12
|
+
# key slots for you. The private key never leaves the local machine.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# Identity.generate!
|
|
16
|
+
# Identity.public_key # => "base64..."
|
|
17
|
+
# Identity.private_key_bytes # => 32 raw bytes
|
|
18
|
+
# Identity.setup? # => true (if keypair + token exist)
|
|
5
19
|
module Identity
|
|
20
|
+
# Path to the private key file.
|
|
21
|
+
#
|
|
22
|
+
# @return [String] absolute path to +identity.priv+
|
|
6
23
|
def self.priv_key_path = File.join(Config.keys_path, "identity.priv")
|
|
24
|
+
|
|
25
|
+
# Path to the public key file.
|
|
26
|
+
#
|
|
27
|
+
# @return [String] absolute path to +identity.pub+
|
|
7
28
|
def self.pub_key_path = File.join(Config.keys_path, "identity.pub")
|
|
8
29
|
|
|
30
|
+
# Check whether both key files exist on disk.
|
|
31
|
+
#
|
|
32
|
+
# @return [Boolean] true if both +identity.priv+ and +identity.pub+ exist
|
|
9
33
|
def self.exists?
|
|
10
34
|
File.exist?(priv_key_path) && File.exist?(pub_key_path)
|
|
11
35
|
end
|
|
12
36
|
|
|
37
|
+
# Generate a new X25519 identity keypair and write to disk.
|
|
38
|
+
#
|
|
39
|
+
# @param force [Boolean] overwrite an existing keypair if true
|
|
40
|
+
# @return [Hash{Symbol => String}] +:public_key+ and +:private_key+ as raw bytes
|
|
41
|
+
# @raise [RuntimeError] when keypair exists and +force+ is false
|
|
13
42
|
def self.generate!(force: false)
|
|
14
43
|
raise "Keypair already exists. Use --force to overwrite." if exists? && !force
|
|
15
44
|
|
|
@@ -23,21 +52,33 @@ module LocalVault
|
|
|
23
52
|
kp
|
|
24
53
|
end
|
|
25
54
|
|
|
55
|
+
# Read the public key as a base64-encoded string.
|
|
56
|
+
#
|
|
57
|
+
# @return [String, nil] base64 public key, or nil if not generated
|
|
26
58
|
def self.public_key
|
|
27
59
|
return nil unless File.exist?(pub_key_path)
|
|
28
60
|
File.read(pub_key_path).strip
|
|
29
61
|
end
|
|
30
62
|
|
|
63
|
+
# Read the private key as a base64-encoded string.
|
|
64
|
+
#
|
|
65
|
+
# @return [String, nil] base64 private key, or nil if not generated
|
|
31
66
|
def self.private_key_b64
|
|
32
67
|
return nil unless File.exist?(priv_key_path)
|
|
33
68
|
File.read(priv_key_path).strip
|
|
34
69
|
end
|
|
35
70
|
|
|
71
|
+
# Read the private key as raw bytes (decoded from base64).
|
|
72
|
+
#
|
|
73
|
+
# @return [String, nil] 32 raw bytes, or nil if not generated
|
|
36
74
|
def self.private_key_bytes
|
|
37
75
|
b64 = private_key_b64
|
|
38
76
|
b64 ? Base64.strict_decode64(b64) : nil
|
|
39
77
|
end
|
|
40
78
|
|
|
79
|
+
# Check whether identity is fully configured (keypair exists and token is set).
|
|
80
|
+
#
|
|
81
|
+
# @return [Boolean] true if keypair exists and an API token is configured
|
|
41
82
|
def self.setup?
|
|
42
83
|
exists? && !Config.token.nil? && !Config.token.empty?
|
|
43
84
|
end
|
data/lib/localvault/key_slot.rb
CHANGED
|
@@ -2,12 +2,28 @@ require "rbnacl"
|
|
|
2
2
|
require "base64"
|
|
3
3
|
|
|
4
4
|
module LocalVault
|
|
5
|
+
# Encrypts/decrypts a vault's master key for a specific user's X25519 public key.
|
|
6
|
+
#
|
|
7
|
+
# Key slots enable multi-user vault access via sync. Each authorized user
|
|
8
|
+
# has a slot containing the vault's master key encrypted to their public key.
|
|
9
|
+
# Uses an ephemeral sender keypair (X25519 Box) — same construction as ShareCrypto.
|
|
10
|
+
#
|
|
11
|
+
# @example Create and decrypt a key slot
|
|
12
|
+
# slot = KeySlot.create(master_key, recipient_pub_b64)
|
|
13
|
+
# recovered = KeySlot.decrypt(slot, recipient_priv_bytes)
|
|
14
|
+
# recovered == master_key # => true
|
|
5
15
|
module KeySlot
|
|
16
|
+
# Raised when decryption fails (wrong key, tampered data, or invalid format).
|
|
6
17
|
class DecryptionError < StandardError; end
|
|
7
18
|
|
|
8
19
|
# Encrypt a master key for a recipient's X25519 public key.
|
|
9
|
-
#
|
|
10
|
-
# Uses an ephemeral sender keypair
|
|
20
|
+
#
|
|
21
|
+
# Uses an ephemeral sender keypair so the recipient can decrypt
|
|
22
|
+
# without knowing who sent it.
|
|
23
|
+
#
|
|
24
|
+
# @param master_key [String] raw 32-byte master key to encrypt
|
|
25
|
+
# @param recipient_pub_key_b64 [String] base64-encoded X25519 public key
|
|
26
|
+
# @return [String] base64-encoded JSON payload containing the encrypted key slot
|
|
11
27
|
def self.create(master_key, recipient_pub_key_b64)
|
|
12
28
|
recipient_pub = RbNaCl::PublicKey.new(Base64.strict_decode64(recipient_pub_key_b64))
|
|
13
29
|
ephemeral_sk = RbNaCl::PrivateKey.generate
|
|
@@ -25,7 +41,11 @@ module LocalVault
|
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
# Decrypt a key slot using the recipient's private key.
|
|
28
|
-
#
|
|
44
|
+
#
|
|
45
|
+
# @param slot_b64 [String] base64-encoded key slot from +create+
|
|
46
|
+
# @param my_private_key_bytes [String] raw 32-byte X25519 private key
|
|
47
|
+
# @return [String] raw master key bytes
|
|
48
|
+
# @raise [DecryptionError] when the key is wrong, data is tampered, or format is invalid
|
|
29
49
|
def self.decrypt(slot_b64, my_private_key_bytes)
|
|
30
50
|
raw = Base64.strict_decode64(slot_b64)
|
|
31
51
|
payload = JSON.parse(raw)
|
|
@@ -11,6 +11,10 @@ require_relative "tools"
|
|
|
11
11
|
module LocalVault
|
|
12
12
|
module MCP
|
|
13
13
|
class Server
|
|
14
|
+
# Create an MCP server reading JSON-RPC from input, writing responses to output.
|
|
15
|
+
#
|
|
16
|
+
# @param input [IO] input stream for JSON-RPC messages (default: $stdin)
|
|
17
|
+
# @param output [IO] output stream for JSON-RPC responses (default: $stdout)
|
|
14
18
|
def initialize(input: $stdin, output: $stdout)
|
|
15
19
|
@input = input
|
|
16
20
|
@output = output
|
|
@@ -18,6 +22,12 @@ module LocalVault
|
|
|
18
22
|
@session_vault = load_session_vault # LOCALVAULT_SESSION fast-path
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
# Start the MCP server loop, reading JSON-RPC messages line-by-line.
|
|
26
|
+
#
|
|
27
|
+
# Logs available unlocked vaults to stderr on startup. Blocks until
|
|
28
|
+
# input is exhausted or interrupted (Ctrl-C).
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
21
31
|
def start
|
|
22
32
|
unlocked = unlocked_vault_names
|
|
23
33
|
label = unlocked.empty? ? "no unlocked vaults (run: localvault show)" : "vaults=#{unlocked.join(', ')}"
|
|
@@ -41,6 +51,13 @@ module LocalVault
|
|
|
41
51
|
$stderr.flush
|
|
42
52
|
end
|
|
43
53
|
|
|
54
|
+
# Parse and dispatch a single JSON-RPC message.
|
|
55
|
+
#
|
|
56
|
+
# Handles +initialize+, +tools/list+, and +tools/call+ methods.
|
|
57
|
+
# Notifications (no "id" field) return nil.
|
|
58
|
+
#
|
|
59
|
+
# @param json_string [String] raw JSON-RPC message
|
|
60
|
+
# @return [Hash, nil] JSON-RPC response hash, or nil for notifications
|
|
44
61
|
def handle_message(json_string)
|
|
45
62
|
message = JSON.parse(json_string)
|
|
46
63
|
|
data/lib/localvault/mcp/tools.rb
CHANGED
|
@@ -10,6 +10,8 @@ module LocalVault
|
|
|
10
10
|
}
|
|
11
11
|
}.freeze
|
|
12
12
|
|
|
13
|
+
# MCP tool definitions conforming to the MCP tools/list schema.
|
|
14
|
+
# Each entry specifies a tool name, description, and JSON Schema for input.
|
|
13
15
|
DEFINITIONS = [
|
|
14
16
|
{
|
|
15
17
|
"name" => "get_secret",
|
|
@@ -59,7 +61,17 @@ module LocalVault
|
|
|
59
61
|
}
|
|
60
62
|
].freeze
|
|
61
63
|
|
|
62
|
-
#
|
|
64
|
+
# Dispatch an MCP tool call by name.
|
|
65
|
+
#
|
|
66
|
+
# Resolves the target vault via the provided callable, then executes the
|
|
67
|
+
# requested tool (get_secret, list_secrets, set_secret, or delete_secret).
|
|
68
|
+
#
|
|
69
|
+
# @param name [String] tool name (must match a DEFINITIONS entry)
|
|
70
|
+
# @param arguments [Hash] tool arguments (e.g. {"key" => "API_KEY", "vault" => "prod"})
|
|
71
|
+
# @param vault_resolver [#call] callable that accepts a vault name (String or nil)
|
|
72
|
+
# and returns a Vault instance or nil
|
|
73
|
+
# @return [Hash] MCP content result with "content" array and optional "isError"
|
|
74
|
+
# @raise [ArgumentError] if the tool name is unknown
|
|
63
75
|
def self.call(name, arguments, vault_resolver)
|
|
64
76
|
unless DEFINITIONS.any? { |t| t["name"] == name }
|
|
65
77
|
raise ArgumentError, "Unknown tool: #{name}"
|
|
@@ -3,16 +3,32 @@ require "fileutils"
|
|
|
3
3
|
require "shellwords"
|
|
4
4
|
|
|
5
5
|
module LocalVault
|
|
6
|
-
# Caches derived master keys
|
|
7
|
-
# Avoids re-prompting passphrase on every command.
|
|
6
|
+
# Caches derived master keys to avoid re-prompting passphrase on every command.
|
|
8
7
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# On macOS, uses the system Keychain (service "localvault", account = vault name).
|
|
9
|
+
# Falls back to file-based cache at +~/.localvault/.sessions/+ (mode 0600)
|
|
10
|
+
# when Keychain is unavailable (CI, sandboxed, Linux).
|
|
11
|
+
#
|
|
12
|
+
# Entries expire after +DEFAULT_TTL_HOURS+ (8 hours). Expired entries are
|
|
13
|
+
# cleaned up on read.
|
|
14
|
+
#
|
|
15
|
+
# Stored payload format: +"<base64_key>|<expiry_unix_ts>"+
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# SessionCache.set("production", master_key)
|
|
19
|
+
# SessionCache.get("production") # => master_key bytes (or nil if expired)
|
|
20
|
+
# SessionCache.clear("production")
|
|
12
21
|
module SessionCache
|
|
13
22
|
DEFAULT_TTL_HOURS = 8
|
|
14
23
|
KEYCHAIN_SERVICE = "localvault"
|
|
15
24
|
|
|
25
|
+
# Retrieve a cached master key for the given vault.
|
|
26
|
+
#
|
|
27
|
+
# Returns nil if no entry exists or the entry has expired. Expired entries
|
|
28
|
+
# are automatically cleaned up.
|
|
29
|
+
#
|
|
30
|
+
# @param vault_name [String] the vault name to look up
|
|
31
|
+
# @return [String, nil] raw master key bytes, or nil if not cached/expired
|
|
16
32
|
def self.get(vault_name)
|
|
17
33
|
payload = keychain_get(vault_name)
|
|
18
34
|
return nil unless payload
|
|
@@ -31,16 +47,29 @@ module LocalVault
|
|
|
31
47
|
nil
|
|
32
48
|
end
|
|
33
49
|
|
|
50
|
+
# Cache a master key for the given vault with a time-to-live.
|
|
51
|
+
#
|
|
52
|
+
# @param vault_name [String] the vault name to cache
|
|
53
|
+
# @param master_key [String] raw master key bytes to store
|
|
54
|
+
# @param ttl_hours [Integer] hours until expiry (default: 8)
|
|
55
|
+
# @return [void]
|
|
34
56
|
def self.set(vault_name, master_key, ttl_hours: DEFAULT_TTL_HOURS)
|
|
35
57
|
expiry = Time.now.to_i + (ttl_hours * 3600).to_i
|
|
36
58
|
payload = "#{Base64.strict_encode64(master_key)}|#{expiry}"
|
|
37
59
|
keychain_set(vault_name, payload)
|
|
38
60
|
end
|
|
39
61
|
|
|
62
|
+
# Remove the cached master key for a single vault.
|
|
63
|
+
#
|
|
64
|
+
# @param vault_name [String] the vault name to clear
|
|
65
|
+
# @return [void]
|
|
40
66
|
def self.clear(vault_name)
|
|
41
67
|
keychain_delete(vault_name)
|
|
42
68
|
end
|
|
43
69
|
|
|
70
|
+
# Remove cached master keys for all known vaults.
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
44
73
|
def self.clear_all
|
|
45
74
|
Store.list_vaults.each { |name| clear(name) }
|
|
46
75
|
end
|
|
@@ -87,8 +116,7 @@ module LocalVault
|
|
|
87
116
|
# Fall back to file store if Keychain fails (e.g., in CI or sandboxed env)
|
|
88
117
|
unless success
|
|
89
118
|
file = session_file(vault_name)
|
|
90
|
-
File.write(file, payload)
|
|
91
|
-
File.chmod(0o600, file)
|
|
119
|
+
File.write(file, payload, perm: 0o600)
|
|
92
120
|
end
|
|
93
121
|
else
|
|
94
122
|
keychain_delete(vault_name)
|
|
@@ -3,13 +3,31 @@ require "base64"
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module LocalVault
|
|
6
|
+
# Asymmetric encryption for one-time vault sharing (direct share model).
|
|
7
|
+
#
|
|
8
|
+
# Encrypts a secrets hash for a recipient using their X25519 public key.
|
|
9
|
+
# Uses an ephemeral sender keypair so the sender's identity key is never
|
|
10
|
+
# transmitted. The recipient decrypts with their private key.
|
|
11
|
+
#
|
|
12
|
+
# This is used for the +localvault share --with @handle+ flow (one-time
|
|
13
|
+
# handoff). For ongoing team access, see KeySlot.
|
|
14
|
+
#
|
|
15
|
+
# @example Encrypt and decrypt a share
|
|
16
|
+
# payload = ShareCrypto.encrypt_for({"KEY" => "val"}, recipient_pub_b64)
|
|
17
|
+
# secrets = ShareCrypto.decrypt_from(payload, recipient_priv_bytes)
|
|
6
18
|
module ShareCrypto
|
|
19
|
+
# Raised when decryption fails (wrong key, tampered payload, or invalid format).
|
|
7
20
|
class DecryptionError < StandardError; end
|
|
8
21
|
|
|
9
22
|
# Encrypt a secrets hash for a recipient using their X25519 public key.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
23
|
+
#
|
|
24
|
+
# Uses an ephemeral sender keypair (NaCl Box construction) so the sender's
|
|
25
|
+
# identity private key is never transmitted. The returned blob contains the
|
|
26
|
+
# ephemeral public key, nonce, and ciphertext.
|
|
27
|
+
#
|
|
28
|
+
# @param secrets [Hash] key-value pairs to encrypt (e.g. {"API_KEY" => "sk-..."})
|
|
29
|
+
# @param recipient_pub_key_b64 [String] recipient's X25519 public key, base64-encoded
|
|
30
|
+
# @return [String] base64-encoded JSON payload containing sender_pub, nonce, and ciphertext
|
|
13
31
|
def self.encrypt_for(secrets, recipient_pub_key_b64)
|
|
14
32
|
recipient_pub = RbNaCl::PublicKey.new(Base64.strict_decode64(recipient_pub_key_b64))
|
|
15
33
|
ephemeral_sk = RbNaCl::PrivateKey.generate
|
|
@@ -27,8 +45,15 @@ module LocalVault
|
|
|
27
45
|
Base64.strict_encode64(JSON.generate(payload))
|
|
28
46
|
end
|
|
29
47
|
|
|
30
|
-
# Decrypt
|
|
31
|
-
#
|
|
48
|
+
# Decrypt a shared payload using the recipient's private key.
|
|
49
|
+
#
|
|
50
|
+
# Reverses the envelope produced by {.encrypt_for}, extracting the ephemeral
|
|
51
|
+
# sender public key and using NaCl Box to decrypt.
|
|
52
|
+
#
|
|
53
|
+
# @param encrypted_payload_b64 [String] base64-encoded payload from {.encrypt_for}
|
|
54
|
+
# @param my_private_key_bytes [String] recipient's raw X25519 private key bytes
|
|
55
|
+
# @return [Hash] decrypted secrets (e.g. {"API_KEY" => "sk-..."})
|
|
56
|
+
# @raise [DecryptionError] when the key is wrong, payload is tampered, or format is invalid
|
|
32
57
|
def self.decrypt_from(encrypted_payload_b64, my_private_key_bytes)
|
|
33
58
|
raw = Base64.strict_decode64(encrypted_payload_b64)
|
|
34
59
|
payload = JSON.parse(raw)
|