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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +227 -0
- data/bin/localvault +12 -0
- data/lib/localvault/api_client.rb +125 -0
- data/lib/localvault/cli/keys.rb +56 -0
- data/lib/localvault/cli/team.rb +37 -0
- data/lib/localvault/cli.rb +1073 -0
- data/lib/localvault/config.rb +80 -0
- data/lib/localvault/crypto.rb +63 -0
- data/lib/localvault/identity.rb +44 -0
- data/lib/localvault/mcp/server.rb +158 -0
- data/lib/localvault/mcp/tools.rb +115 -0
- data/lib/localvault/session_cache.rb +107 -0
- data/lib/localvault/share_crypto.rb +48 -0
- data/lib/localvault/store.rb +109 -0
- data/lib/localvault/vault.rb +154 -0
- data/lib/localvault/version.rb +3 -0
- data/lib/localvault.rb +11 -0
- metadata +144 -0
|
@@ -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
|
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: []
|