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



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

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.



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

def master_key
  @master_key
end

#nameObject (readonly)

Returns the value of attribute name.



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

def name
  @name
end

#storeObject (readonly)

Returns the value of attribute store.



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

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



190
191
192
193
194
195
196
197
198
199
# File 'lib/localvault/vault.rb', line 190

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



225
226
227
228
229
230
231
232
233
234
# File 'lib/localvault/vault.rb', line 225

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:



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

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



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

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, only: nil, except: nil, map: nil, profile: 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



170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/localvault/vault.rb', line 170

def env_hash(project: nil, on_skip: nil, only: nil, except: nil, map: nil, profile: nil)
  EnvProjection.entries(all,
    project: project,
    only: only,
    except: except,
    map: map,
    profile: profile,
    on_skip: on_skip
  ).each_with_object({}) do |entry, hash|
    hash[entry.env_name] = entry.value
  end
end

#export_env(project: nil, on_skip: nil, only: nil, except: nil, map: nil, profile: 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



147
148
149
150
151
152
153
154
155
156
# File 'lib/localvault/vault.rb', line 147

def export_env(project: nil, on_skip: nil, only: nil, except: nil, map: nil, profile: nil)
  EnvProjection.entries(all,
    project: project,
    only: only,
    except: except,
    map: map,
    profile: profile,
    on_skip: on_skip
  ).map { |entry| "export #{entry.env_name}=#{Shellwords.escape(entry.value)}" }.join("\n")
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



292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/localvault/vault.rb', line 292

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



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

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"]



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

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



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/localvault/vault.rb', line 246

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



209
210
211
212
213
214
215
216
217
# File 'lib/localvault/vault.rb', line 209

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



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

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