Class: LocalVault::Vault
- Inherits:
-
Object
- Object
- LocalVault::Vault
- 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.
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
-
#master_key ⇒ Object
readonly
Returns the value of attribute master_key.
-
#name ⇒ Object
readonly
Returns the value of attribute name.
-
#store ⇒ Object
readonly
Returns the value of attribute store.
Class Method Summary collapse
-
.create!(name:, master_key:, salt:) ⇒ Vault
Create a new vault with an empty secrets store.
-
.open(name:, passphrase:) ⇒ Vault
Open an existing vault by deriving the master key from a passphrase.
Instance Method Summary collapse
-
#all ⇒ Hash
Decrypt and return all secrets as a hash.
-
#delete(key) ⇒ String?
Delete a secret by key.
-
#env_hash(project: nil, on_skip: nil) ⇒ Hash{String => String}
Returns a flat hash suitable for env injection.
-
#export_env(project: nil, on_skip: nil) ⇒ String
Export secrets as shell variable assignments (export KEY=value).
-
#filter(scopes, from: nil) ⇒ Hash
Return a subset of secrets matching the given scopes.
-
#get(key) ⇒ String?
Retrieve a secret by key.
-
#initialize(name:, master_key:) ⇒ Vault
constructor
Initialize a vault instance for reading and writing secrets.
-
#list ⇒ Array<String>
Returns a sorted flat list of all keys.
-
#merge(hash) ⇒ void
Bulk-set key-value pairs in a single decrypt/encrypt cycle.
-
#rekey(new_passphrase, new_salt: Crypto.generate_salt) ⇒ Vault
Re-encrypt the vault with a new passphrase and salt.
-
#set(key, value) ⇒ String
Store a secret.
Constructor Details
Instance Attribute Details
#master_key ⇒ Object (readonly)
Returns the value of attribute master_key.
32 33 34 |
# File 'lib/localvault/vault.rb', line 32 def master_key @master_key end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
32 33 34 |
# File 'lib/localvault/vault.rb', line 32 def name @name end |
#store ⇒ Object (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.
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.
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
#all ⇒ Hash
Decrypt and return all secrets as a hash.
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.
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.
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.
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.
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.
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 |
#list ⇒ Array<String>
Returns a sorted flat list of all keys. Nested keys use dot-notation.
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.
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.
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.(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.
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 |