localvault 1.0.5 → 1.2.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/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 +145 -3
- data/lib/localvault/cli/team.rb +492 -2
- data/lib/localvault/cli.rb +63 -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 +66 -0
- 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 +67 -0
- data/lib/localvault/sync_bundle.rb +52 -8
- data/lib/localvault/vault.rb +122 -12
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +1 -0
- metadata +2 -1
data/lib/localvault/store.rb
CHANGED
|
@@ -2,9 +2,25 @@ require "yaml"
|
|
|
2
2
|
require "fileutils"
|
|
3
3
|
require "tempfile"
|
|
4
4
|
require "base64"
|
|
5
|
+
require "time"
|
|
5
6
|
|
|
6
7
|
module LocalVault
|
|
8
|
+
# File-system storage for a single vault's encrypted data and metadata.
|
|
9
|
+
#
|
|
10
|
+
# Each vault lives at +~/.localvault/vaults/<name>/+ with two files:
|
|
11
|
+
# - +meta.yml+ — salt, creation date, version, secret count
|
|
12
|
+
# - +secrets.enc+ — encrypted JSON blob (XSalsa20-Poly1305)
|
|
13
|
+
#
|
|
14
|
+
# Uses atomic writes (tempfile + rename) to prevent corruption.
|
|
15
|
+
# All directories are created with mode 0700, all files with mode 0600.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# store = Store.new("production")
|
|
19
|
+
# store.create!(salt: Crypto.generate_salt)
|
|
20
|
+
# store.write_encrypted(ciphertext)
|
|
21
|
+
# store.read_encrypted # => ciphertext bytes
|
|
7
22
|
class Store
|
|
23
|
+
# Raised when a vault name contains invalid characters or path traversal.
|
|
8
24
|
class InvalidVaultName < StandardError; end
|
|
9
25
|
|
|
10
26
|
# Letters, digits, underscore, dash. Must start with alphanumeric.
|
|
@@ -12,27 +28,48 @@ module LocalVault
|
|
|
12
28
|
|
|
13
29
|
attr_reader :vault_name
|
|
14
30
|
|
|
31
|
+
# Initialize a store for the given vault name.
|
|
32
|
+
#
|
|
33
|
+
# @param vault_name [String] the vault name (alphanumeric, dash, underscore)
|
|
34
|
+
# @raise [InvalidVaultName] when name is empty, too long, or has invalid characters
|
|
15
35
|
def initialize(vault_name)
|
|
16
36
|
validate_vault_name!(vault_name)
|
|
17
37
|
@vault_name = vault_name
|
|
18
38
|
end
|
|
19
39
|
|
|
40
|
+
# Absolute path to this vault's directory.
|
|
41
|
+
#
|
|
42
|
+
# @return [String] path to +~/.localvault/vaults/<name>/+
|
|
20
43
|
def vault_path
|
|
21
44
|
File.join(Config.vaults_path, vault_name)
|
|
22
45
|
end
|
|
23
46
|
|
|
47
|
+
# Absolute path to the encrypted secrets file.
|
|
48
|
+
#
|
|
49
|
+
# @return [String] path to +secrets.enc+
|
|
24
50
|
def secrets_path
|
|
25
51
|
File.join(vault_path, "secrets.enc")
|
|
26
52
|
end
|
|
27
53
|
|
|
54
|
+
# Absolute path to the metadata file.
|
|
55
|
+
#
|
|
56
|
+
# @return [String] path to +meta.yml+
|
|
28
57
|
def meta_path
|
|
29
58
|
File.join(vault_path, "meta.yml")
|
|
30
59
|
end
|
|
31
60
|
|
|
61
|
+
# Check whether this vault exists on disk.
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean] true if the vault directory and meta file exist
|
|
32
64
|
def exists?
|
|
33
65
|
File.directory?(vault_path) && File.exist?(meta_path)
|
|
34
66
|
end
|
|
35
67
|
|
|
68
|
+
# Create a new vault on disk with initial metadata.
|
|
69
|
+
#
|
|
70
|
+
# @param salt [String] raw salt bytes for key derivation
|
|
71
|
+
# @return [void]
|
|
72
|
+
# @raise [RuntimeError] when the vault already exists
|
|
36
73
|
def create!(salt:)
|
|
37
74
|
raise "Vault '#{vault_name}' already exists" if exists?
|
|
38
75
|
|
|
@@ -47,21 +84,34 @@ module LocalVault
|
|
|
47
84
|
write_meta(new_meta)
|
|
48
85
|
end
|
|
49
86
|
|
|
87
|
+
# Read and parse the vault's metadata.
|
|
88
|
+
#
|
|
89
|
+
# @return [Hash, nil] the parsed meta.yml contents, or nil if file is missing
|
|
50
90
|
def meta
|
|
51
91
|
return nil unless File.exist?(meta_path)
|
|
52
92
|
YAML.safe_load_file(meta_path)
|
|
53
93
|
end
|
|
54
94
|
|
|
95
|
+
# Read the raw salt bytes from metadata.
|
|
96
|
+
#
|
|
97
|
+
# @return [String, nil] decoded salt bytes, or nil if not available
|
|
55
98
|
def salt
|
|
56
99
|
m = meta
|
|
57
100
|
return nil unless m && m["salt"]
|
|
58
101
|
Base64.strict_decode64(m["salt"])
|
|
59
102
|
end
|
|
60
103
|
|
|
104
|
+
# Number of secrets stored in this vault.
|
|
105
|
+
#
|
|
106
|
+
# @return [Integer] the secret count from metadata, defaults to 0
|
|
61
107
|
def count
|
|
62
108
|
meta&.dig("count") || 0
|
|
63
109
|
end
|
|
64
110
|
|
|
111
|
+
# Update the secret count in metadata.
|
|
112
|
+
#
|
|
113
|
+
# @param n [Integer] the new count
|
|
114
|
+
# @return [void]
|
|
65
115
|
def update_count!(n)
|
|
66
116
|
m = meta
|
|
67
117
|
return unless m
|
|
@@ -69,11 +119,18 @@ module LocalVault
|
|
|
69
119
|
write_meta(m)
|
|
70
120
|
end
|
|
71
121
|
|
|
122
|
+
# Read the encrypted secrets blob from disk.
|
|
123
|
+
#
|
|
124
|
+
# @return [String, nil] raw ciphertext bytes, or nil if file is missing
|
|
72
125
|
def read_encrypted
|
|
73
126
|
return nil unless File.exist?(secrets_path)
|
|
74
127
|
File.binread(secrets_path)
|
|
75
128
|
end
|
|
76
129
|
|
|
130
|
+
# Atomically write encrypted bytes to disk using tempfile + rename.
|
|
131
|
+
#
|
|
132
|
+
# @param bytes [String] raw ciphertext bytes to write
|
|
133
|
+
# @return [void]
|
|
77
134
|
def write_encrypted(bytes)
|
|
78
135
|
FileUtils.mkdir_p(vault_path, mode: 0o700)
|
|
79
136
|
|
|
@@ -90,6 +147,10 @@ module LocalVault
|
|
|
90
147
|
raise
|
|
91
148
|
end
|
|
92
149
|
|
|
150
|
+
# Create or overwrite metadata with a new salt, preserving created_at if present.
|
|
151
|
+
#
|
|
152
|
+
# @param salt [String] raw salt bytes for key derivation
|
|
153
|
+
# @return [void]
|
|
93
154
|
def create_meta!(salt:)
|
|
94
155
|
existing = meta
|
|
95
156
|
new_meta = {
|
|
@@ -101,6 +162,9 @@ module LocalVault
|
|
|
101
162
|
write_meta(new_meta)
|
|
102
163
|
end
|
|
103
164
|
|
|
165
|
+
# Permanently delete this vault's directory and all its contents.
|
|
166
|
+
#
|
|
167
|
+
# @return [void]
|
|
104
168
|
def destroy!
|
|
105
169
|
FileUtils.rm_rf(vault_path)
|
|
106
170
|
end
|
|
@@ -121,6 +185,9 @@ module LocalVault
|
|
|
121
185
|
|
|
122
186
|
public
|
|
123
187
|
|
|
188
|
+
# List all vault names found on disk.
|
|
189
|
+
#
|
|
190
|
+
# @return [Array<String>] sorted vault names
|
|
124
191
|
def self.list_vaults
|
|
125
192
|
vaults_dir = Config.vaults_path
|
|
126
193
|
return [] unless File.directory?(vaults_dir)
|
|
@@ -3,32 +3,76 @@ require "base64"
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
5
|
module LocalVault
|
|
6
|
+
# Packs and unpacks vault data for cloud sync via InventList + R2.
|
|
7
|
+
#
|
|
8
|
+
# Bundle versions:
|
|
9
|
+
# - v1: personal sync — +{ version, meta, secrets }+
|
|
10
|
+
# - v2: legacy team — +{ version, meta, secrets, key_slots }+
|
|
11
|
+
# - v3: team with ownership — +{ version, owner, meta, secrets, key_slots }+
|
|
12
|
+
#
|
|
13
|
+
# The server never sees plaintext — the bundle is opaque ciphertext.
|
|
14
|
+
#
|
|
15
|
+
# @example Personal sync (v1)
|
|
16
|
+
# blob = SyncBundle.pack(store)
|
|
17
|
+
#
|
|
18
|
+
# @example Team sync (v3)
|
|
19
|
+
# blob = SyncBundle.pack_v3(store, owner: "alice", key_slots: slots)
|
|
20
|
+
# data = SyncBundle.unpack(blob)
|
|
21
|
+
# data[:owner] # => "alice"
|
|
22
|
+
# data[:key_slots] # => {"alice" => {...}, "bob" => {...}}
|
|
6
23
|
module SyncBundle
|
|
24
|
+
# Raised when unpacking fails (bad JSON, missing fields, wrong version, invalid encoding).
|
|
7
25
|
class UnpackError < StandardError; end
|
|
8
26
|
|
|
9
|
-
|
|
27
|
+
SUPPORTED_VERSIONS = [1, 2, 3].freeze
|
|
10
28
|
|
|
11
|
-
# Pack a vault
|
|
12
|
-
#
|
|
29
|
+
# Pack a personal vault — v1 format, no key_slots, no owner.
|
|
30
|
+
#
|
|
31
|
+
# @param store [Store] the vault store to pack
|
|
32
|
+
# @return [String] JSON string ready for upload
|
|
13
33
|
def self.pack(store)
|
|
14
34
|
meta_content = File.read(store.meta_path)
|
|
15
35
|
secrets_content = store.read_encrypted || ""
|
|
16
36
|
JSON.generate(
|
|
17
|
-
"version" =>
|
|
37
|
+
"version" => 1,
|
|
18
38
|
"meta" => Base64.strict_encode64(meta_content),
|
|
19
39
|
"secrets" => Base64.strict_encode64(secrets_content)
|
|
20
40
|
)
|
|
21
41
|
end
|
|
22
42
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
43
|
+
# Pack a team vault — v3 format with owner, key_slots, and per-member blobs.
|
|
44
|
+
#
|
|
45
|
+
# @param store [Store] the vault store to pack
|
|
46
|
+
# @param owner [String] the owner's InventList handle
|
|
47
|
+
# @param key_slots [Hash] per-user key slot data
|
|
48
|
+
# @return [String] JSON string ready for upload
|
|
49
|
+
def self.pack_v3(store, owner:, key_slots: {})
|
|
50
|
+
meta_content = File.read(store.meta_path)
|
|
51
|
+
secrets_content = store.read_encrypted || ""
|
|
52
|
+
JSON.generate(
|
|
53
|
+
"version" => 3,
|
|
54
|
+
"owner" => owner,
|
|
55
|
+
"meta" => Base64.strict_encode64(meta_content),
|
|
56
|
+
"secrets" => Base64.strict_encode64(secrets_content),
|
|
57
|
+
"key_slots" => key_slots
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Unpack any version bundle into its component parts.
|
|
62
|
+
#
|
|
63
|
+
# @param blob [String] JSON string from ApiClient#pull_vault
|
|
64
|
+
# @param expected_name [String, nil] if set, validates the meta.yml vault name matches
|
|
65
|
+
# @return [Hash] +{meta:, secrets:, key_slots:, owner:}+
|
|
66
|
+
# @raise [UnpackError] on invalid format, unsupported version, or name mismatch
|
|
25
67
|
def self.unpack(blob, expected_name: nil)
|
|
26
68
|
data = JSON.parse(blob)
|
|
27
69
|
version = data["version"]
|
|
28
|
-
raise UnpackError, "Unsupported bundle version: #{version}" if version && version
|
|
70
|
+
raise UnpackError, "Unsupported bundle version: #{version}" if version && !SUPPORTED_VERSIONS.include?(version)
|
|
29
71
|
|
|
30
72
|
meta_raw = Base64.strict_decode64(data.fetch("meta"))
|
|
31
73
|
secrets_raw = Base64.strict_decode64(data.fetch("secrets"))
|
|
74
|
+
key_slots = data["key_slots"].is_a?(Hash) ? data["key_slots"] : {}
|
|
75
|
+
owner = data["owner"]
|
|
32
76
|
|
|
33
77
|
if expected_name
|
|
34
78
|
meta_parsed = YAML.safe_load(meta_raw)
|
|
@@ -38,7 +82,7 @@ module LocalVault
|
|
|
38
82
|
end
|
|
39
83
|
end
|
|
40
84
|
|
|
41
|
-
{ meta: meta_raw, secrets: secrets_raw }
|
|
85
|
+
{ meta: meta_raw, secrets: secrets_raw, key_slots: key_slots, owner: owner }
|
|
42
86
|
rescue JSON::ParserError => e
|
|
43
87
|
raise UnpackError, "Invalid sync bundle format: #{e.message}"
|
|
44
88
|
rescue KeyError => e
|
data/lib/localvault/vault.rb
CHANGED
|
@@ -2,20 +2,51 @@ require "json"
|
|
|
2
2
|
require "shellwords"
|
|
3
3
|
|
|
4
4
|
module LocalVault
|
|
5
|
+
# Encrypted key-value store backed by a single JSON blob.
|
|
6
|
+
#
|
|
7
|
+
# Each vault has a name, a master key (derived from passphrase + salt),
|
|
8
|
+
# and a Store that handles file I/O. Secrets are stored as a flat or
|
|
9
|
+
# nested JSON hash, encrypted with XSalsa20-Poly1305.
|
|
10
|
+
#
|
|
11
|
+
# Supports dot-notation for nested keys: +"project.SECRET_KEY"+
|
|
12
|
+
# groups secrets under a project namespace.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# vault = Vault.create!(name: "default", master_key: key, salt: salt)
|
|
16
|
+
# vault.set("API_KEY", "sk-...")
|
|
17
|
+
# vault.get("API_KEY") # => "sk-..."
|
|
18
|
+
# vault.list # => ["API_KEY"]
|
|
19
|
+
# vault.export_env # => "export API_KEY=sk-..."
|
|
20
|
+
#
|
|
21
|
+
# @example Nested keys
|
|
22
|
+
# vault.set("myapp.DB_URL", "postgres://...")
|
|
23
|
+
# vault.get("myapp.DB_URL") # => "postgres://..."
|
|
24
|
+
# vault.env_hash(project: "myapp") # => {"DB_URL" => "postgres://..."}
|
|
5
25
|
class Vault
|
|
26
|
+
# Raised when a key name contains invalid characters.
|
|
6
27
|
class InvalidKeyName < StandardError; end
|
|
7
28
|
|
|
8
|
-
# Shell-safe: letters, digits, underscores. Must start with letter or underscore.
|
|
29
|
+
# Shell-safe pattern: letters, digits, underscores. Must start with letter or underscore.
|
|
9
30
|
KEY_SEGMENT_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
10
31
|
|
|
11
32
|
attr_reader :name, :master_key, :store
|
|
12
33
|
|
|
34
|
+
# Initialize a vault instance for reading and writing secrets.
|
|
35
|
+
#
|
|
36
|
+
# @param name [String] the vault name
|
|
37
|
+
# @param master_key [String] 32-byte derived master key
|
|
13
38
|
def initialize(name:, master_key:)
|
|
14
39
|
@name = name
|
|
15
40
|
@master_key = master_key
|
|
16
41
|
@store = Store.new(name)
|
|
17
42
|
end
|
|
18
43
|
|
|
44
|
+
# Retrieve a secret by key. Supports dot-notation for nested keys.
|
|
45
|
+
#
|
|
46
|
+
# @param key [String] the secret key, e.g. "API_KEY" or "myapp.DB_URL"
|
|
47
|
+
# @return [String, nil] the secret value, or nil if not found
|
|
48
|
+
# @example
|
|
49
|
+
# vault.get("myapp.DB_URL") # => "postgres://..."
|
|
19
50
|
def get(key)
|
|
20
51
|
if key.include?(".")
|
|
21
52
|
group, subkey = key.split(".", 2)
|
|
@@ -27,6 +58,13 @@ module LocalVault
|
|
|
27
58
|
end
|
|
28
59
|
end
|
|
29
60
|
|
|
61
|
+
# Store a secret. Supports dot-notation for nested keys.
|
|
62
|
+
#
|
|
63
|
+
# @param key [String] the secret key, e.g. "API_KEY" or "myapp.DB_URL"
|
|
64
|
+
# @param value [String] the secret value
|
|
65
|
+
# @return [String] the stored value
|
|
66
|
+
# @raise [InvalidKeyName] when key contains invalid characters
|
|
67
|
+
# @raise [RuntimeError] when a scalar key is used as a group
|
|
30
68
|
def set(key, value)
|
|
31
69
|
validate_key!(key)
|
|
32
70
|
secrets = all
|
|
@@ -42,6 +80,10 @@ module LocalVault
|
|
|
42
80
|
value
|
|
43
81
|
end
|
|
44
82
|
|
|
83
|
+
# Delete a secret by key. Supports dot-notation for nested keys.
|
|
84
|
+
#
|
|
85
|
+
# @param key [String] the secret key to delete
|
|
86
|
+
# @return [String, nil] the deleted value, or nil if not found
|
|
45
87
|
def delete(key)
|
|
46
88
|
secrets = all
|
|
47
89
|
if key.include?(".")
|
|
@@ -58,13 +100,19 @@ module LocalVault
|
|
|
58
100
|
end
|
|
59
101
|
end
|
|
60
102
|
|
|
61
|
-
# Returns a flat list of all keys
|
|
103
|
+
# Returns a sorted flat list of all keys. Nested keys use dot-notation.
|
|
104
|
+
#
|
|
105
|
+
# @return [Array<String>] sorted key names, e.g. ["API_KEY", "myapp.DB_URL"]
|
|
62
106
|
def list
|
|
63
107
|
all.flat_map do |k, v|
|
|
64
108
|
v.is_a?(Hash) ? v.keys.map { |sk| "#{k}.#{sk}" } : [k]
|
|
65
109
|
end.sort
|
|
66
110
|
end
|
|
67
111
|
|
|
112
|
+
# Decrypt and return all secrets as a hash.
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash] the decrypted secrets hash (may contain nested hashes for groups)
|
|
115
|
+
# @raise [Crypto::DecryptionError] when master key is wrong or data is corrupt
|
|
68
116
|
def all
|
|
69
117
|
encrypted = store.read_encrypted
|
|
70
118
|
return {} unless encrypted && !encrypted.empty?
|
|
@@ -73,11 +121,18 @@ module LocalVault
|
|
|
73
121
|
JSON.parse(json)
|
|
74
122
|
end
|
|
75
123
|
|
|
76
|
-
# Export as shell variable assignments.
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
124
|
+
# Export secrets as shell variable assignments (export KEY=value).
|
|
125
|
+
#
|
|
126
|
+
# With +project+, exports only that group's keys without prefix.
|
|
127
|
+
# Without +project+, flat keys export as-is, nested keys as GROUP__KEY.
|
|
128
|
+
# Keys that are not valid shell identifiers are skipped.
|
|
129
|
+
#
|
|
130
|
+
# @param project [String, nil] optional group name to scope the export
|
|
131
|
+
# @param on_skip [#call, nil] called with key name when a key is skipped
|
|
132
|
+
# @return [String] newline-separated export statements
|
|
133
|
+
# @example
|
|
134
|
+
# vault.export_env(project: "myapp")
|
|
135
|
+
# # => "export DB_URL=postgres%3A//..."
|
|
81
136
|
def export_env(project: nil, on_skip: nil)
|
|
82
137
|
secrets = all
|
|
83
138
|
if project
|
|
@@ -119,10 +174,17 @@ module LocalVault
|
|
|
119
174
|
end
|
|
120
175
|
|
|
121
176
|
# Returns a flat hash suitable for env injection.
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
177
|
+
#
|
|
178
|
+
# With +project+, returns only that group's key-value pairs.
|
|
179
|
+
# Without +project+, flat keys are kept as-is, nested keys become GROUP__KEY.
|
|
180
|
+
# Keys that are not valid shell identifiers are skipped.
|
|
181
|
+
#
|
|
182
|
+
# @param project [String, nil] optional group name to scope the output
|
|
183
|
+
# @param on_skip [#call, nil] called with key name when a key is skipped
|
|
184
|
+
# @return [Hash{String => String}] flat hash of env variable names to values
|
|
185
|
+
# @example
|
|
186
|
+
# vault.env_hash(project: "myapp")
|
|
187
|
+
# # => {"DB_URL" => "postgres://...", "SECRET" => "abc"}
|
|
126
188
|
def env_hash(project: nil, on_skip: nil)
|
|
127
189
|
secrets = all
|
|
128
190
|
if project
|
|
@@ -160,6 +222,13 @@ module LocalVault
|
|
|
160
222
|
end
|
|
161
223
|
end
|
|
162
224
|
|
|
225
|
+
# Create a new vault with an empty secrets store.
|
|
226
|
+
#
|
|
227
|
+
# @param name [String] the vault name
|
|
228
|
+
# @param master_key [String] 32-byte derived master key
|
|
229
|
+
# @param salt [String] the salt used for key derivation (stored in metadata)
|
|
230
|
+
# @return [Vault] the newly created vault instance
|
|
231
|
+
# @raise [RuntimeError] when a vault with the same name already exists
|
|
163
232
|
def self.create!(name:, master_key:, salt:)
|
|
164
233
|
store = Store.new(name)
|
|
165
234
|
store.create!(salt: salt)
|
|
@@ -171,6 +240,14 @@ module LocalVault
|
|
|
171
240
|
new(name: name, master_key: master_key)
|
|
172
241
|
end
|
|
173
242
|
|
|
243
|
+
# Re-encrypt the vault with a new passphrase and salt.
|
|
244
|
+
#
|
|
245
|
+
# Decrypts all secrets with the current key, derives a new master key,
|
|
246
|
+
# and re-encrypts everything. Returns a new Vault instance with the new key.
|
|
247
|
+
#
|
|
248
|
+
# @param new_passphrase [String] the new passphrase
|
|
249
|
+
# @param new_salt [String] optional salt (generated if omitted)
|
|
250
|
+
# @return [Vault] a new vault instance with the updated master key
|
|
174
251
|
def rekey(new_passphrase, new_salt: Crypto.generate_salt)
|
|
175
252
|
secrets = all
|
|
176
253
|
new_master_key = Crypto.derive_master_key(new_passphrase, new_salt)
|
|
@@ -181,6 +258,12 @@ module LocalVault
|
|
|
181
258
|
new_vault
|
|
182
259
|
end
|
|
183
260
|
|
|
261
|
+
# Open an existing vault by deriving the master key from a passphrase.
|
|
262
|
+
#
|
|
263
|
+
# @param name [String] the vault name
|
|
264
|
+
# @param passphrase [String] the passphrase to derive the master key
|
|
265
|
+
# @return [Vault] the opened vault instance
|
|
266
|
+
# @raise [RuntimeError] when the vault does not exist or has no salt
|
|
184
267
|
def self.open(name:, passphrase:)
|
|
185
268
|
store = Store.new(name)
|
|
186
269
|
raise "Vault '#{name}' does not exist" unless store.exists?
|
|
@@ -192,8 +275,15 @@ module LocalVault
|
|
|
192
275
|
new(name: name, master_key: master_key)
|
|
193
276
|
end
|
|
194
277
|
|
|
195
|
-
# Bulk-set
|
|
278
|
+
# Bulk-set key-value pairs in a single decrypt/encrypt cycle.
|
|
279
|
+
#
|
|
196
280
|
# Supports nested hashes: { "app" => { "DB" => "..." } } merges into group "app".
|
|
281
|
+
# Dot-notation keys are also supported in the top-level hash.
|
|
282
|
+
#
|
|
283
|
+
# @param hash [Hash] key-value pairs to merge into the vault
|
|
284
|
+
# @return [void]
|
|
285
|
+
# @raise [InvalidKeyName] when any key contains invalid characters
|
|
286
|
+
# @raise [RuntimeError] when a scalar key is used as a group
|
|
197
287
|
def merge(hash)
|
|
198
288
|
secrets = all
|
|
199
289
|
hash.each do |k, v|
|
|
@@ -220,6 +310,26 @@ module LocalVault
|
|
|
220
310
|
write_secrets(secrets)
|
|
221
311
|
end
|
|
222
312
|
|
|
313
|
+
# Return a subset of secrets matching the given scopes.
|
|
314
|
+
#
|
|
315
|
+
# Scopes can be group names (returns entire nested hash) or flat key names.
|
|
316
|
+
# +nil+ means full access (returns all). Empty array means nothing.
|
|
317
|
+
#
|
|
318
|
+
# @param scopes [Array<String>, nil] list of group/key names, or nil for all
|
|
319
|
+
# @return [Hash] filtered secrets
|
|
320
|
+
def filter(scopes)
|
|
321
|
+
return all if scopes.nil?
|
|
322
|
+
return {} if scopes.empty?
|
|
323
|
+
|
|
324
|
+
secrets = all
|
|
325
|
+
result = {}
|
|
326
|
+
scopes.each do |scope|
|
|
327
|
+
value = secrets[scope]
|
|
328
|
+
result[scope] = value if value
|
|
329
|
+
end
|
|
330
|
+
result
|
|
331
|
+
end
|
|
332
|
+
|
|
223
333
|
private
|
|
224
334
|
|
|
225
335
|
def shell_safe_key?(key)
|
data/lib/localvault/version.rb
CHANGED
data/lib/localvault.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "localvault/store"
|
|
|
5
5
|
require_relative "localvault/vault"
|
|
6
6
|
require_relative "localvault/identity"
|
|
7
7
|
require_relative "localvault/share_crypto"
|
|
8
|
+
require_relative "localvault/key_slot"
|
|
8
9
|
require_relative "localvault/api_client"
|
|
9
10
|
require_relative "localvault/sync_bundle"
|
|
10
11
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: localvault
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nauman Tariq
|
|
@@ -114,6 +114,7 @@ files:
|
|
|
114
114
|
- lib/localvault/config.rb
|
|
115
115
|
- lib/localvault/crypto.rb
|
|
116
116
|
- lib/localvault/identity.rb
|
|
117
|
+
- lib/localvault/key_slot.rb
|
|
117
118
|
- lib/localvault/mcp/server.rb
|
|
118
119
|
- lib/localvault/mcp/tools.rb
|
|
119
120
|
- lib/localvault/session_cache.rb
|