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.
@@ -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
- VERSION = 1
27
+ SUPPORTED_VERSIONS = [1, 2, 3].freeze
10
28
 
11
- # Pack a vault's meta.yml + secrets.enc into a single JSON blob.
12
- # The secrets.enc is already encrypted — this bundle is opaque to the server.
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" => 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
- # Unpack a blob back into {meta:, secrets:} strings.
24
- # Pass expected_name: to validate the meta.yml name matches the vault being pulled.
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 != 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
@@ -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 nested keys use dot-notation.
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
- # - With project: exports only that group's keys (no prefix).
78
- # - Without project: flat keys as-is, nested keys as GROUP__KEY.
79
- # Keys that aren't valid shell identifiers are skipped. Pass on_skip: callable
80
- # to be notified (e.g., for warnings).
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
- # - With project: only that group's key-value pairs.
123
- # - Without project: flat keys + nested keys as GROUP__KEY.
124
- # Keys that aren't valid shell identifiers are skipped. Pass on_skip: callable
125
- # to be notified.
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: merges all key-value pairs in a single decrypt/encrypt cycle.
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)
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.0.5"
2
+ VERSION = "1.2.0"
3
3
  end
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.5
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