Class: LocalVault::Vault

Inherits:
Object
  • Object
show all
Defined in:
lib/localvault/vault.rb

Overview

Encrypted key-value store backed by a single JSON blob.

Each vault has a name, a master key (derived from passphrase + salt), and a Store that handles file I/O. Secrets are stored as a flat or nested JSON hash, encrypted with XSalsa20-Poly1305.

Supports dot-notation for nested keys: “project.SECRET_KEY” groups secrets under a project namespace.

Examples:

Basic usage

vault = Vault.create!(name: "default", master_key: key, salt: salt)
vault.set("API_KEY", "sk-...")
vault.get("API_KEY")    # => "sk-..."
vault.list              # => ["API_KEY"]
vault.export_env        # => "export API_KEY=sk-..."

Nested keys

vault.set("myapp.DB_URL", "postgres://...")
vault.get("myapp.DB_URL")  # => "postgres://..."
vault.env_hash(project: "myapp")  # => {"DB_URL" => "postgres://..."}

Defined Under Namespace

Classes: InvalidKeyName

Constant Summary collapse

KEY_SEGMENT_PATTERN =

Shell-safe pattern: letters, digits, underscores. Must start with letter or underscore.

/\A[A-Za-z_][A-Za-z0-9_]*\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, master_key:) ⇒ Vault

Initialize a vault instance for reading and writing secrets.

Parameters:

  • name (String)

    the vault name

  • master_key (String)

    32-byte derived master key



38
39
40
41
42
# File 'lib/localvault/vault.rb', line 38

def initialize(name:, master_key:)
  @name = name
  @master_key = master_key
  @store = Store.new(name)
end

Instance Attribute Details

#master_keyObject (readonly)

Returns the value of attribute master_key.



32
33
34
# File 'lib/localvault/vault.rb', line 32

def master_key
  @master_key
end

#nameObject (readonly)

Returns the value of attribute name.



32
33
34
# File 'lib/localvault/vault.rb', line 32

def name
  @name
end

#storeObject (readonly)

Returns the value of attribute store.



32
33
34
# File 'lib/localvault/vault.rb', line 32

def store
  @store
end

Class Method Details

.create!(name:, master_key:, salt:) ⇒ Vault

Create a new vault with an empty secrets store.

Parameters:

  • name (String)

    the vault name

  • master_key (String)

    32-byte derived master key

  • salt (String)

    the salt used for key derivation (stored in metadata)

Returns:

  • (Vault)

    the newly created vault instance

Raises:

  • (RuntimeError)

    when a vault with the same name already exists



242
243
244
245
246
247
248
249
250
251
# File 'lib/localvault/vault.rb', line 242

def self.create!(name:, master_key:, salt:)
  store = Store.new(name)
  store.create!(salt: salt)

  empty_json = JSON.generate({})
  encrypted = Crypto.encrypt(empty_json, master_key)
  store.write_encrypted(encrypted)

  new(name: name, master_key: master_key)
end

.open(name:, passphrase:) ⇒ Vault

Open an existing vault by deriving the master key from a passphrase.

Parameters:

  • name (String)

    the vault name

  • passphrase (String)

    the passphrase to derive the master key

Returns:

  • (Vault)

    the opened vault instance

Raises:

  • (RuntimeError)

    when the vault does not exist or has no salt



277
278
279
280
281
282
283
284
285
286
# File 'lib/localvault/vault.rb', line 277

def self.open(name:, passphrase:)
  store = Store.new(name)
  raise "Vault '#{name}' does not exist" unless store.exists?

  salt = store.salt
  raise "Vault '#{name}' has no salt in metadata" unless salt

  master_key = Crypto.derive_master_key(passphrase, salt)
  new(name: name, master_key: master_key)
end

Instance Method Details

#allHash

Decrypt and return all secrets as a hash.

Returns:

  • (Hash)

    the decrypted secrets hash (may contain nested hashes for groups)

Raises:



126
127
128
129
130
131
132
# File 'lib/localvault/vault.rb', line 126

def all
  encrypted = store.read_encrypted
  return {} unless encrypted && !encrypted.empty?

  json = Crypto.decrypt(encrypted, master_key)
  JSON.parse(json)
end

#delete(key) ⇒ String?

Delete a secret by key. Supports dot-notation for nested keys.

Parameters:

  • key (String)

    the secret key to delete

Returns:

  • (String, nil)

    the deleted value, or nil if not found



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/localvault/vault.rb', line 97

def delete(key)
  secrets = all
  if key.include?(".")
    group, subkey = key.split(".", 2)
    return nil unless secrets[group].is_a?(Hash)
    deleted = secrets[group].delete(subkey)
    secrets.delete(group) if secrets[group].empty?
    write_secrets(secrets) if deleted
    deleted
  else
    deleted = secrets.delete(key)
    write_secrets(secrets) if deleted
    deleted
  end
end

#env_hash(project: nil, on_skip: nil) ⇒ Hash{String => String}

Returns a flat hash suitable for env injection.

With project, returns only that group’s key-value pairs. Without project, flat keys are kept as-is, nested keys become GROUP__KEY. Keys that are not valid shell identifiers are skipped.

Examples:

vault.env_hash(project: "myapp")
# => {"DB_URL" => "postgres://...", "SECRET" => "abc"}

Parameters:

  • project (String, nil) (defaults to: nil)

    optional group name to scope the output

  • on_skip (#call, nil) (defaults to: nil)

    called with key name when a key is skipped

Returns:

  • (Hash{String => String})

    flat hash of env variable names to values



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/localvault/vault.rb', line 198

def env_hash(project: nil, on_skip: nil)
  secrets = all
  if project
    group = secrets[project]
    return {} unless group.is_a?(Hash)
    group.each_with_object({}) do |(k, v), h|
      if shell_safe_key?(k)
        h[k] = v.to_s
      else
        on_skip&.call(k)
      end
    end
  else
    secrets.each_with_object({}) do |(k, v), h|
      if v.is_a?(Hash)
        unless shell_safe_key?(k)
          on_skip&.call(k)
          next
        end
        v.each do |sk, sv|
          if shell_safe_key?(sk)
            h["#{k.upcase}__#{sk}"] = sv.to_s
          else
            on_skip&.call("#{k}.#{sk}")
          end
        end
      else
        if shell_safe_key?(k)
          h[k] = v.to_s
        else
          on_skip&.call(k)
        end
      end
    end
  end
end

#export_env(project: nil, on_skip: nil) ⇒ String

Export secrets as shell variable assignments (export KEY=value).

With project, exports only that group’s keys without prefix. Without project, flat keys export as-is, nested keys as GROUP__KEY. Keys that are not valid shell identifiers are skipped.

Examples:

vault.export_env(project: "myapp")
# => "export DB_URL=postgres%3A//..."

Parameters:

  • project (String, nil) (defaults to: nil)

    optional group name to scope the export

  • on_skip (#call, nil) (defaults to: nil)

    called with key name when a key is skipped

Returns:

  • (String)

    newline-separated export statements



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/localvault/vault.rb', line 146

def export_env(project: nil, on_skip: nil)
  secrets = all
  if project
    group = secrets[project]
    return "" unless group.is_a?(Hash)
    group.filter_map do |k, v|
      if shell_safe_key?(k)
        "export #{k}=#{Shellwords.escape(v.to_s)}"
      else
        on_skip&.call(k)
        nil
      end
    end.join("\n")
  else
    secrets.flat_map do |k, v|
      if v.is_a?(Hash)
        unless shell_safe_key?(k)
          on_skip&.call(k)
          next []
        end
        v.filter_map do |sk, sv|
          if shell_safe_key?(sk)
            "export #{k.upcase}__#{sk}=#{Shellwords.escape(sv.to_s)}"
          else
            on_skip&.call("#{k}.#{sk}")
            nil
          end
        end
      else
        if shell_safe_key?(k)
          ["export #{k}=#{Shellwords.escape(v.to_s)}"]
        else
          on_skip&.call(k)
          []
        end
      end
    end.join("\n")
  end
end

#filter(scopes, from: nil) ⇒ Hash

Return a subset of secrets matching the given scopes.

Scopes can be group names (returns entire nested hash) or flat key names. nil means full access (returns all). Empty array means nothing.

Pass from: to avoid re-decrypting when you already have the plaintext secrets hash (e.g. inside a rotate loop that filters once per member). Without from:, this method calls all on every invocation, which decrypts the whole vault — expensive when called in a loop.

Parameters:

  • scopes (Array<String>, nil)

    list of group/key names, or nil for all

  • from (Hash, nil) (defaults to: nil)

    pre-loaded secrets hash (avoids a re-decrypt)

Returns:

  • (Hash)

    filtered secrets



344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/localvault/vault.rb', line 344

def filter(scopes, from: nil)
  secrets = from || all
  return secrets if scopes.nil?
  return {} if scopes.empty?

  result = {}
  scopes.each do |scope|
    value = secrets[scope]
    result[scope] = value if value
  end
  result
end

#get(key) ⇒ String?

Retrieve a secret by key. Supports dot-notation for nested keys.

Examples:

vault.get("myapp.DB_URL")  # => "postgres://..."

Parameters:

  • key (String)

    the secret key, e.g. “API_KEY” or “myapp.DB_URL”

Returns:

  • (String, nil)

    the secret value, or nil if not found



50
51
52
53
54
55
56
57
58
59
# File 'lib/localvault/vault.rb', line 50

def get(key)
  if key.include?(".")
    group, subkey = key.split(".", 2)
    value = all[group]
    value.is_a?(Hash) ? value[subkey] : nil
  else
    value = all[key]
    value.is_a?(Hash) ? nil : value
  end
end

#listArray<String>

Returns a sorted flat list of all keys. Nested keys use dot-notation.

Returns:

  • (Array<String>)

    sorted key names, e.g. [“API_KEY”, “myapp.DB_URL”]



116
117
118
119
120
# File 'lib/localvault/vault.rb', line 116

def list
  all.flat_map do |k, v|
    v.is_a?(Hash) ? v.keys.map { |sk| "#{k}.#{sk}" } : [k]
  end.sort
end

#merge(hash) ⇒ void

This method returns an undefined value.

Bulk-set key-value pairs in a single decrypt/encrypt cycle.

Supports nested hashes: { “app” => { “DB” => “…” } } merges into group “app”. Dot-notation keys are also supported in the top-level hash.

Parameters:

  • hash (Hash)

    key-value pairs to merge into the vault

Raises:

  • (InvalidKeyName)

    when any key contains invalid characters

  • (RuntimeError)

    when a scalar key is used as a group, or when a scalar is being assigned to an existing group name



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/localvault/vault.rb', line 298

def merge(hash)
  secrets = all
  hash.each do |k, v|
    if v.is_a?(Hash)
      validate_key_segment!(k)
      secrets[k] ||= {}
      raise "#{k} is a scalar value, not a group" unless secrets[k].is_a?(Hash)
      v.each do |sk, sv|
        validate_key_segment!(sk)
        secrets[k][sk] = sv.to_s
      end
    else
      validate_key!(k)
      if k.include?(".")
        group, subkey = k.split(".", 2)
        secrets[group] ||= {}
        raise "#{group} is a scalar value, not a group" unless secrets[group].is_a?(Hash)
        secrets[group][subkey] = v.to_s
      else
        # Same guard as Vault#set: don't silently clobber a group with
        # a scalar. This protects bulk `import` and `receive` flows.
        if secrets[k].is_a?(Hash)
          raise "'#{k}' is a group containing #{secrets[k].size} secret(s), " \
                "not a scalar. Delete the group first if you really want " \
                "to replace it with a scalar."
        end
        secrets[k] = v.to_s
      end
    end
  end
  write_secrets(secrets)
end

#rekey(new_passphrase, new_salt: Crypto.generate_salt) ⇒ Vault

Re-encrypt the vault with a new passphrase and salt.

Decrypts all secrets with the current key, derives a new master key, and re-encrypts everything. Returns a new Vault instance with the new key.

Parameters:

  • new_passphrase (String)

    the new passphrase

  • new_salt (String) (defaults to: Crypto.generate_salt)

    optional salt (generated if omitted)

Returns:

  • (Vault)

    a new vault instance with the updated master key



261
262
263
264
265
266
267
268
269
# File 'lib/localvault/vault.rb', line 261

def rekey(new_passphrase, new_salt: Crypto.generate_salt)
  secrets = all
  new_master_key = Crypto.derive_master_key(new_passphrase, new_salt)

  store.create_meta!(salt: new_salt)
  new_vault = self.class.new(name: name, master_key: new_master_key)
  new_vault.send(:write_secrets, secrets)
  new_vault
end

#set(key, value) ⇒ String

Store a secret. Supports dot-notation for nested keys.

Parameters:

  • key (String)

    the secret key, e.g. “API_KEY” or “myapp.DB_URL”

  • value (String)

    the secret value

Returns:

  • (String)

    the stored value

Raises:

  • (InvalidKeyName)

    when key contains invalid characters

  • (RuntimeError)

    when a scalar key is used as a group, or when a scalar is being assigned to an existing group name



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/localvault/vault.rb', line 69

def set(key, value)
  validate_key!(key)
  secrets = all
  if key.include?(".")
    group, subkey = key.split(".", 2)
    secrets[group] ||= {}
    raise "#{group} is a scalar value, not a group" unless secrets[group].is_a?(Hash)
    secrets[group][subkey] = value
  else
    # Refuse to silently clobber an existing group with a scalar. This
    # used to succeed: set("app", "oops") on a vault containing
    # {"app" => {"DB" => ...}} would replace the whole group and lose
    # every nested secret under it.
    if secrets[key].is_a?(Hash)
      raise "'#{key}' is a group containing #{secrets[key].size} secret(s), " \
            "not a scalar. Use `localvault delete #{key}` first if you " \
            "really want to replace the whole group."
    end
    secrets[key] = value
  end
  write_secrets(secrets)
  value
end