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, only: nil, except: nil, map: nil, profile: nil) ⇒ Hash{String => String}
Returns a flat hash suitable for env injection.
-
#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).
-
#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.
33 34 35 |
# File 'lib/localvault/vault.rb', line 33 def master_key @master_key end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
33 34 35 |
# File 'lib/localvault/vault.rb', line 33 def name @name end |
#store ⇒ Object (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.
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.
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
#all ⇒ Hash
Decrypt and return all secrets as a hash.
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.
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.
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.
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.
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.
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 |
#list ⇒ Array<String>
Returns a sorted flat list of all keys. Nested keys use dot-notation.
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.
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.
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.(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.
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 |