localvault 0.9.6

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.
@@ -0,0 +1,109 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+ require "tempfile"
4
+ require "base64"
5
+
6
+ module LocalVault
7
+ class Store
8
+ attr_reader :vault_name
9
+
10
+ def initialize(vault_name)
11
+ @vault_name = vault_name
12
+ end
13
+
14
+ def vault_path
15
+ File.join(Config.vaults_path, vault_name)
16
+ end
17
+
18
+ def secrets_path
19
+ File.join(vault_path, "secrets.enc")
20
+ end
21
+
22
+ def meta_path
23
+ File.join(vault_path, "meta.yml")
24
+ end
25
+
26
+ def exists?
27
+ File.directory?(vault_path) && File.exist?(meta_path)
28
+ end
29
+
30
+ def create!(salt:)
31
+ raise "Vault '#{vault_name}' already exists" if exists?
32
+
33
+ FileUtils.mkdir_p(vault_path)
34
+ meta = {
35
+ "name" => vault_name,
36
+ "created_at" => Time.now.utc.iso8601,
37
+ "version" => 1,
38
+ "salt" => Base64.strict_encode64(salt),
39
+ "count" => 0
40
+ }
41
+ File.write(meta_path, YAML.dump(meta))
42
+ end
43
+
44
+ def meta
45
+ return nil unless File.exist?(meta_path)
46
+ YAML.safe_load_file(meta_path)
47
+ end
48
+
49
+ def salt
50
+ m = meta
51
+ return nil unless m && m["salt"]
52
+ Base64.strict_decode64(m["salt"])
53
+ end
54
+
55
+ def count
56
+ meta&.dig("count") || 0
57
+ end
58
+
59
+ def update_count!(n)
60
+ m = meta
61
+ return unless m
62
+ m["count"] = n
63
+ File.write(meta_path, YAML.dump(m))
64
+ end
65
+
66
+ def read_encrypted
67
+ return nil unless File.exist?(secrets_path)
68
+ File.binread(secrets_path)
69
+ end
70
+
71
+ def write_encrypted(bytes)
72
+ FileUtils.mkdir_p(vault_path)
73
+
74
+ # Atomic write: write to temp file, then rename
75
+ tmp = Tempfile.new("localvault", vault_path)
76
+ tmp.binmode
77
+ tmp.write(bytes)
78
+ tmp.close
79
+ File.rename(tmp.path, secrets_path)
80
+ rescue StandardError
81
+ tmp&.close
82
+ tmp&.unlink
83
+ raise
84
+ end
85
+
86
+ def create_meta!(salt:)
87
+ meta = {
88
+ "name" => vault_name,
89
+ "created_at" => meta&.dig("created_at") || Time.now.utc.iso8601,
90
+ "version" => 1,
91
+ "salt" => Base64.strict_encode64(salt)
92
+ }
93
+ File.write(meta_path, YAML.dump(meta))
94
+ end
95
+
96
+ def destroy!
97
+ FileUtils.rm_rf(vault_path)
98
+ end
99
+
100
+ def self.list_vaults
101
+ vaults_dir = Config.vaults_path
102
+ return [] unless File.directory?(vaults_dir)
103
+
104
+ Dir.children(vaults_dir)
105
+ .select { |name| File.directory?(File.join(vaults_dir, name)) }
106
+ .sort
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,154 @@
1
+ require "json"
2
+ require "shellwords"
3
+
4
+ module LocalVault
5
+ class Vault
6
+ attr_reader :name, :master_key, :store
7
+
8
+ def initialize(name:, master_key:)
9
+ @name = name
10
+ @master_key = master_key
11
+ @store = Store.new(name)
12
+ end
13
+
14
+ def get(key)
15
+ if key.include?(".")
16
+ group, subkey = key.split(".", 2)
17
+ value = all[group]
18
+ value.is_a?(Hash) ? value[subkey] : nil
19
+ else
20
+ all[key]
21
+ end
22
+ end
23
+
24
+ def set(key, value)
25
+ secrets = all
26
+ if key.include?(".")
27
+ group, subkey = key.split(".", 2)
28
+ secrets[group] ||= {}
29
+ raise "#{group} is a scalar value, not a group" unless secrets[group].is_a?(Hash)
30
+ secrets[group][subkey] = value
31
+ else
32
+ secrets[key] = value
33
+ end
34
+ write_secrets(secrets)
35
+ value
36
+ end
37
+
38
+ def delete(key)
39
+ secrets = all
40
+ if key.include?(".")
41
+ group, subkey = key.split(".", 2)
42
+ return nil unless secrets[group].is_a?(Hash)
43
+ deleted = secrets[group].delete(subkey)
44
+ secrets.delete(group) if secrets[group].empty?
45
+ write_secrets(secrets) if deleted
46
+ deleted
47
+ else
48
+ deleted = secrets.delete(key)
49
+ write_secrets(secrets) if deleted
50
+ deleted
51
+ end
52
+ end
53
+
54
+ # Returns a flat list of all keys — nested keys use dot-notation.
55
+ def list
56
+ all.flat_map do |k, v|
57
+ v.is_a?(Hash) ? v.keys.map { |sk| "#{k}.#{sk}" } : [k]
58
+ end.sort
59
+ end
60
+
61
+ def all
62
+ encrypted = store.read_encrypted
63
+ return {} unless encrypted && !encrypted.empty?
64
+
65
+ json = Crypto.decrypt(encrypted, master_key)
66
+ JSON.parse(json)
67
+ end
68
+
69
+ # Export as shell variable assignments.
70
+ # - With project: exports only that group's keys (no prefix).
71
+ # - Without project: flat keys as-is, nested keys as GROUP__KEY.
72
+ def export_env(project: nil)
73
+ secrets = all
74
+ if project
75
+ group = secrets[project]
76
+ return "" unless group.is_a?(Hash)
77
+ group.map { |k, v| "export #{k}=#{Shellwords.escape(v.to_s)}" }.join("\n")
78
+ else
79
+ secrets.flat_map do |k, v|
80
+ if v.is_a?(Hash)
81
+ v.map { |sk, sv| "export #{k.upcase}__#{sk}=#{Shellwords.escape(sv.to_s)}" }
82
+ else
83
+ ["export #{k}=#{Shellwords.escape(v.to_s)}"]
84
+ end
85
+ end.join("\n")
86
+ end
87
+ end
88
+
89
+ # Returns a flat hash suitable for env injection.
90
+ # - With project: only that group's key-value pairs.
91
+ # - Without project: flat keys + nested keys as GROUP__KEY.
92
+ def env_hash(project: nil)
93
+ secrets = all
94
+ if project
95
+ group = secrets[project]
96
+ return {} unless group.is_a?(Hash)
97
+ group.transform_values(&:to_s)
98
+ else
99
+ secrets.flat_map do |k, v|
100
+ if v.is_a?(Hash)
101
+ v.map { |sk, sv| ["#{k.upcase}__#{sk}", sv.to_s] }
102
+ else
103
+ [[k, v.to_s]]
104
+ end
105
+ end.to_h
106
+ end
107
+ end
108
+
109
+ def self.create!(name:, master_key:, salt:)
110
+ store = Store.new(name)
111
+ store.create!(salt: salt)
112
+
113
+ empty_json = JSON.generate({})
114
+ encrypted = Crypto.encrypt(empty_json, master_key)
115
+ store.write_encrypted(encrypted)
116
+
117
+ new(name: name, master_key: master_key)
118
+ end
119
+
120
+ def rekey(new_passphrase, new_salt: Crypto.generate_salt)
121
+ secrets = all
122
+ new_master_key = Crypto.derive_master_key(new_passphrase, new_salt)
123
+
124
+ store.create_meta!(salt: new_salt)
125
+ new_vault = self.class.new(name: name, master_key: new_master_key)
126
+ new_vault.send(:write_secrets, secrets)
127
+ new_vault
128
+ end
129
+
130
+ def self.open(name:, passphrase:)
131
+ store = Store.new(name)
132
+ raise "Vault '#{name}' does not exist" unless store.exists?
133
+
134
+ salt = store.salt
135
+ raise "Vault '#{name}' has no salt in metadata" unless salt
136
+
137
+ master_key = Crypto.derive_master_key(passphrase, salt)
138
+ new(name: name, master_key: master_key)
139
+ end
140
+
141
+ private
142
+
143
+ def write_secrets(secrets)
144
+ json = JSON.generate(secrets)
145
+ encrypted = Crypto.encrypt(json, master_key)
146
+ store.write_encrypted(encrypted)
147
+ store.update_count!(count_leaves(secrets))
148
+ end
149
+
150
+ def count_leaves(hash)
151
+ hash.sum { |_, v| v.is_a?(Hash) ? count_leaves(v) : 1 }
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,3 @@
1
+ module LocalVault
2
+ VERSION = "0.9.6"
3
+ end
data/lib/localvault.rb ADDED
@@ -0,0 +1,11 @@
1
+ require_relative "localvault/version"
2
+ require_relative "localvault/crypto"
3
+ require_relative "localvault/config"
4
+ require_relative "localvault/store"
5
+ require_relative "localvault/vault"
6
+ require_relative "localvault/identity"
7
+ require_relative "localvault/share_crypto"
8
+ require_relative "localvault/api_client"
9
+
10
+ module LocalVault
11
+ end
metadata ADDED
@@ -0,0 +1,144 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: localvault
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.6
5
+ platform: ruby
6
+ authors:
7
+ - Nauman Tariq
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rbnacl
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: base64
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: lipgloss
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.2'
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '5.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '5.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '13.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '13.0'
96
+ description: Encrypted local vault for secrets with MCP server for AI agents. No cloud
97
+ required.
98
+ email:
99
+ - nauman@intellecta.co
100
+ executables:
101
+ - localvault
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - LICENSE
106
+ - README.md
107
+ - bin/localvault
108
+ - lib/localvault.rb
109
+ - lib/localvault/api_client.rb
110
+ - lib/localvault/cli.rb
111
+ - lib/localvault/cli/keys.rb
112
+ - lib/localvault/cli/team.rb
113
+ - lib/localvault/config.rb
114
+ - lib/localvault/crypto.rb
115
+ - lib/localvault/identity.rb
116
+ - lib/localvault/mcp/server.rb
117
+ - lib/localvault/mcp/tools.rb
118
+ - lib/localvault/session_cache.rb
119
+ - lib/localvault/share_crypto.rb
120
+ - lib/localvault/store.rb
121
+ - lib/localvault/vault.rb
122
+ - lib/localvault/version.rb
123
+ homepage: https://github.com/inventlist/localvault
124
+ licenses:
125
+ - MIT
126
+ metadata: {}
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: 3.2.0
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubygems_version: 3.6.9
142
+ specification_version: 4
143
+ summary: Zero-infrastructure secrets manager
144
+ test_files: []