localvault 1.0.5 → 1.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb92b726fe45e83ae0f84124af3d6f2c452c3bafabce5a910a526a9a872651df
4
- data.tar.gz: 109d0432aeca53aa65db977ecf8bf5e0fdd9650866997c849ae34aad4a65e884
3
+ metadata.gz: c7a7a9c4bee5e2c661610323979b91de29205e4316213bde33496d7785f09d8e
4
+ data.tar.gz: aa0a9d9c4e1387cecc27f7f5de8b67ac7d843f603d3940bd3b5d726f95d2f433
5
5
  SHA512:
6
- metadata.gz: 5857befd8cc78b20dc0ebfd3dc2995b6ed0887e7084d8b843504d51089f9be977ebf79ac7ed323123a482b9d8a6a20b18caf825743c3ba5bdbea9c28bc18b99d
7
- data.tar.gz: 4e01a56bf7e7e253dcf655feabffb03c9f4c1e8ca6ca5c70e3d0eed99fef80fe6079d06871d5a53a5d748f1aefbc1c6a55e889149ed9d6d6b0d8e93e13a77f59
6
+ metadata.gz: c39e166b61c0021798884390112dddd051b23c5569ab0d41ec0dc420d0d0e472bff487e8fda5f9dd6bb47c7c1b2de99644fd3463cf9d334634b78dfc4f0f95f3
7
+ data.tar.gz: e3f4423241183217c1a08ea2ee5eae7aeccb167f698b94aedfbe730551b02a1567ed864f0406a84ea425cfc8233dde59221a4380f63d318512e2c1449f294c9d
@@ -16,7 +16,10 @@ module LocalVault
16
16
  return
17
17
  end
18
18
 
19
- blob = SyncBundle.pack(store)
19
+ key_slots = load_existing_key_slots(vault_name)
20
+ key_slots = bootstrap_owner_slot(key_slots, store)
21
+
22
+ blob = SyncBundle.pack(store, key_slots: key_slots)
20
23
  client = ApiClient.new(token: Config.token)
21
24
  client.push_vault(vault_name, blob)
22
25
 
@@ -52,7 +55,12 @@ module LocalVault
52
55
  end
53
56
 
54
57
  $stdout.puts "Pulled vault '#{vault_name}'."
55
- $stdout.puts "Unlock it with: localvault unlock -v #{vault_name}"
58
+
59
+ if try_unlock_via_key_slot(vault_name, data[:key_slots])
60
+ $stdout.puts "Unlocked via your identity key."
61
+ else
62
+ $stdout.puts "Unlock it with: localvault unlock -v #{vault_name}"
63
+ end
56
64
  rescue SyncBundle::UnpackError => e
57
65
  $stderr.puts "Error: #{e.message}"
58
66
  rescue ApiClient::ApiError => e
@@ -107,12 +115,68 @@ module LocalVault
107
115
 
108
116
  private
109
117
 
118
+ # Try to decrypt the master key from a key slot matching the current identity.
119
+ # On success, caches the master key in SessionCache. Returns true/false.
120
+ def try_unlock_via_key_slot(vault_name, key_slots)
121
+ return false unless key_slots.is_a?(Hash) && !key_slots.empty?
122
+ return false unless Identity.exists?
123
+
124
+ handle = Config.inventlist_handle
125
+ return false unless handle
126
+
127
+ slot = key_slots[handle]
128
+ return false unless slot.is_a?(Hash) && slot["enc_key"].is_a?(String)
129
+
130
+ master_key = KeySlot.decrypt(slot["enc_key"], Identity.private_key_bytes)
131
+
132
+ # Verify the key actually works by trying to decrypt
133
+ vault = Vault.new(name: vault_name, master_key: master_key)
134
+ vault.all
135
+
136
+ SessionCache.set(vault_name, master_key)
137
+ true
138
+ rescue KeySlot::DecryptionError, Crypto::DecryptionError
139
+ false
140
+ end
141
+
110
142
  def logged_in?
111
143
  return true if Config.token
112
144
 
113
145
  $stderr.puts "Error: Not logged in. Run: localvault login TOKEN"
114
146
  false
115
147
  end
148
+
149
+ # Load key_slots from the last pushed blob (if any).
150
+ # Returns {} if no remote blob or if it's a v1 bundle.
151
+ def load_existing_key_slots(vault_name)
152
+ client = ApiClient.new(token: Config.token)
153
+ blob = client.pull_vault(vault_name)
154
+ return {} unless blob.is_a?(String) && !blob.empty?
155
+ data = SyncBundle.unpack(blob)
156
+ data[:key_slots] || {}
157
+ rescue ApiClient::ApiError, SyncBundle::UnpackError
158
+ {}
159
+ end
160
+
161
+ # Add the owner's key slot if identity exists and no slot is present.
162
+ # Requires the vault to be unlockable (needs master key for encryption).
163
+ def bootstrap_owner_slot(key_slots, store)
164
+ return key_slots unless Identity.exists?
165
+ handle = Config.inventlist_handle
166
+ return key_slots unless handle
167
+
168
+ # Already has owner slot — don't churn
169
+ return key_slots if key_slots.key?(handle)
170
+
171
+ # Need the master key to create the slot — try SessionCache
172
+ master_key = SessionCache.get(store.vault_name)
173
+ return key_slots unless master_key
174
+
175
+ pub_b64 = Identity.public_key
176
+ enc_key = KeySlot.create(master_key, pub_b64)
177
+ key_slots[handle] = { "pub" => pub_b64, "enc_key" => enc_key }
178
+ key_slots
179
+ end
116
180
  end
117
181
  end
118
182
  end
@@ -13,6 +13,15 @@ module LocalVault
13
13
 
14
14
  vault_name ||= options[:vault] || Config.default_vault
15
15
  client = ApiClient.new(token: Config.token)
16
+
17
+ # Try sync-based key slots first
18
+ key_slots = load_key_slots(client, vault_name)
19
+ if key_slots && !key_slots.empty?
20
+ list_key_slots(vault_name, key_slots)
21
+ return
22
+ end
23
+
24
+ # Fall back to direct shares
16
25
  result = client.sent_shares(vault_name: vault_name)
17
26
  shares = (result["shares"] || []).reject { |s| s["status"] == "revoked" }
18
27
 
@@ -34,8 +43,83 @@ module LocalVault
34
43
  $stderr.puts "Error: #{e.message}"
35
44
  end
36
45
 
46
+ desc "add HANDLE", "Add a teammate to a synced vault via key slot"
47
+ method_option :vault, type: :string, aliases: "-v"
48
+ def add(handle)
49
+ unless Config.token
50
+ $stderr.puts "Error: Not logged in. Run: localvault login TOKEN"
51
+ return
52
+ end
53
+
54
+ unless Identity.exists?
55
+ $stderr.puts "Error: No keypair found. Run: localvault keygen"
56
+ return
57
+ end
58
+
59
+ handle = handle.delete_prefix("@")
60
+ vault_name = options[:vault] || Config.default_vault
61
+
62
+ # Need master key from session
63
+ master_key = SessionCache.get(vault_name)
64
+ unless master_key
65
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
66
+ return
67
+ end
68
+
69
+ # Fetch recipient's public key
70
+ client = ApiClient.new(token: Config.token)
71
+ result = client.get_public_key(handle)
72
+ pub_key = result["public_key"]
73
+
74
+ unless pub_key && !pub_key.empty?
75
+ $stderr.puts "Error: @#{handle} has no public key published."
76
+ return
77
+ end
78
+
79
+ # Load existing key slots from remote
80
+ existing_blob = client.pull_vault(vault_name) rescue nil
81
+ key_slots = if existing_blob.is_a?(String) && !existing_blob.empty?
82
+ data = SyncBundle.unpack(existing_blob)
83
+ data[:key_slots].is_a?(Hash) ? data[:key_slots] : {}
84
+ else
85
+ {}
86
+ end
87
+
88
+ # Create key slot for recipient
89
+ begin
90
+ enc_key = KeySlot.create(master_key, pub_key)
91
+ rescue ArgumentError, KeySlot::DecryptionError => e
92
+ $stderr.puts "Error: @#{handle}'s public key is invalid: #{e.message}"
93
+ return
94
+ end
95
+ key_slots[handle] = { "pub" => pub_key, "enc_key" => enc_key }
96
+
97
+ # Ensure owner slot exists too
98
+ owner_handle = Config.inventlist_handle
99
+ unless key_slots.key?(owner_handle)
100
+ owner_pub = Identity.public_key
101
+ key_slots[owner_handle] = { "pub" => owner_pub, "enc_key" => KeySlot.create(master_key, owner_pub) }
102
+ end
103
+
104
+ # Pack and push
105
+ store = Store.new(vault_name)
106
+ blob = SyncBundle.pack(store, key_slots: key_slots)
107
+ client.push_vault(vault_name, blob)
108
+
109
+ $stdout.puts "Added @#{handle} to vault '#{vault_name}'."
110
+ rescue ApiClient::ApiError => e
111
+ if e.status == 404
112
+ $stderr.puts "Error: @#{handle} not found or has no public key."
113
+ else
114
+ $stderr.puts "Error: #{e.message}"
115
+ end
116
+ rescue SyncBundle::UnpackError => e
117
+ $stderr.puts "Error: #{e.message}"
118
+ end
119
+
37
120
  desc "remove HANDLE", "Remove a person's access to a vault"
38
121
  method_option :vault, type: :string, aliases: "-v"
122
+ method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
39
123
  def remove(handle)
40
124
  unless Config.token
41
125
  $stderr.puts "Error: Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
@@ -46,6 +130,14 @@ module LocalVault
46
130
  vault_name = options[:vault] || Config.default_vault
47
131
  client = ApiClient.new(token: Config.token)
48
132
 
133
+ # Try sync-based key slot removal first
134
+ key_slots = load_key_slots(client, vault_name)
135
+ if key_slots && !key_slots.empty?
136
+ remove_key_slot(handle, vault_name, key_slots, client, rotate: options[:rotate])
137
+ return
138
+ end
139
+
140
+ # Fall back to direct share revocation
49
141
  result = client.sent_shares(vault_name: vault_name)
50
142
  share = (result["shares"] || []).find do |s|
51
143
  s["recipient_handle"] == handle && s["status"] != "revoked"
@@ -61,6 +153,97 @@ module LocalVault
61
153
  rescue ApiClient::ApiError => e
62
154
  $stderr.puts "Error: #{e.message}"
63
155
  end
156
+
157
+ private
158
+
159
+ def load_key_slots(client, vault_name)
160
+ return nil unless client.respond_to?(:pull_vault)
161
+ blob = client.pull_vault(vault_name)
162
+ return nil unless blob.is_a?(String) && !blob.empty?
163
+ data = SyncBundle.unpack(blob)
164
+ slots = data[:key_slots]
165
+ slots.is_a?(Hash) ? slots : nil
166
+ rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
167
+ nil
168
+ end
169
+
170
+ def remove_key_slot(handle, vault_name, key_slots, client, rotate: false)
171
+ unless key_slots.key?(handle)
172
+ $stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
173
+ return
174
+ end
175
+
176
+ valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
177
+ if handle == Config.inventlist_handle && valid_slots.size <= 1
178
+ $stderr.puts "Error: Cannot remove yourself — you are the only member."
179
+ return
180
+ end
181
+
182
+ key_slots.delete(handle)
183
+ store = Store.new(vault_name)
184
+
185
+ if rotate
186
+ master_key = SessionCache.get(vault_name)
187
+ unless master_key
188
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
189
+ return
190
+ end
191
+
192
+ # Decrypt current secrets, generate new master key, re-encrypt
193
+ vault = Vault.new(name: vault_name, master_key: master_key)
194
+ secrets = vault.all
195
+
196
+ new_salt = Crypto.generate_salt
197
+ new_master_key = Crypto.derive_master_key(SecureRandom.hex(32), new_salt)
198
+
199
+ # Re-encrypt secrets with new master key
200
+ new_json = JSON.generate(secrets)
201
+ new_encrypted = Crypto.encrypt(new_json, new_master_key)
202
+ store.write_encrypted(new_encrypted)
203
+ store.create_meta!(salt: new_salt)
204
+
205
+ # Re-create key slots for remaining members with new master key
206
+ new_slots = {}
207
+ key_slots.each do |h, slot|
208
+ next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
209
+ new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]) }
210
+ end
211
+
212
+ blob = SyncBundle.pack(store, key_slots: new_slots)
213
+ client.push_vault(vault_name, blob)
214
+
215
+ # Only cache new master key if the caller is still a member
216
+ if new_slots.key?(Config.inventlist_handle)
217
+ SessionCache.set(vault_name, new_master_key)
218
+ else
219
+ SessionCache.clear(vault_name)
220
+ end
221
+
222
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
223
+ $stdout.puts "Vault re-encrypted with new master key (rotated)."
224
+ else
225
+ blob = SyncBundle.pack(store, key_slots: key_slots)
226
+ client.push_vault(vault_name, blob)
227
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
228
+ end
229
+ end
230
+
231
+ def list_key_slots(vault_name, key_slots)
232
+ my_handle = Config.inventlist_handle
233
+ valid = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
234
+
235
+ if valid.empty?
236
+ $stdout.puts "No key slots for vault '#{vault_name}'."
237
+ return
238
+ end
239
+
240
+ $stdout.puts "Vault: #{vault_name} — #{valid.size} member(s)"
241
+ $stdout.puts
242
+ valid.sort.each do |handle, slot|
243
+ marker = handle == my_handle ? " (you)" : ""
244
+ $stdout.puts " @#{handle}#{marker}"
245
+ end
246
+ end
64
247
  end
65
248
  end
66
249
  end
@@ -0,0 +1,46 @@
1
+ require "rbnacl"
2
+ require "base64"
3
+
4
+ module LocalVault
5
+ module KeySlot
6
+ class DecryptionError < StandardError; end
7
+
8
+ # Encrypt a master key for a recipient's X25519 public key.
9
+ # Returns a base64-encoded ciphertext string.
10
+ # Uses an ephemeral sender keypair (same Box construction as ShareCrypto).
11
+ def self.create(master_key, recipient_pub_key_b64)
12
+ recipient_pub = RbNaCl::PublicKey.new(Base64.strict_decode64(recipient_pub_key_b64))
13
+ ephemeral_sk = RbNaCl::PrivateKey.generate
14
+ box = RbNaCl::Box.new(recipient_pub, ephemeral_sk)
15
+ nonce = RbNaCl::Random.random_bytes(RbNaCl::Box.nonce_bytes)
16
+ ciphertext = box.box(nonce, master_key)
17
+
18
+ payload = {
19
+ "v" => 1,
20
+ "sender_pub" => Base64.strict_encode64(ephemeral_sk.public_key.to_bytes),
21
+ "nonce" => Base64.strict_encode64(nonce),
22
+ "ciphertext" => Base64.strict_encode64(ciphertext)
23
+ }
24
+ Base64.strict_encode64(JSON.generate(payload))
25
+ end
26
+
27
+ # Decrypt a key slot using the recipient's private key.
28
+ # Returns the raw master key bytes.
29
+ def self.decrypt(slot_b64, my_private_key_bytes)
30
+ raw = Base64.strict_decode64(slot_b64)
31
+ payload = JSON.parse(raw)
32
+ sender_pub = RbNaCl::PublicKey.new(Base64.strict_decode64(payload.fetch("sender_pub")))
33
+ my_sk = RbNaCl::PrivateKey.new(my_private_key_bytes)
34
+ box = RbNaCl::Box.new(sender_pub, my_sk)
35
+ nonce = Base64.strict_decode64(payload.fetch("nonce"))
36
+ ciphertext = Base64.strict_decode64(payload.fetch("ciphertext"))
37
+ box.open(nonce, ciphertext)
38
+ rescue RbNaCl::CryptoError => e
39
+ raise DecryptionError, "Failed to decrypt key slot: #{e.message}"
40
+ rescue JSON::ParserError, KeyError => e
41
+ raise DecryptionError, "Invalid key slot format: #{e.message}"
42
+ rescue ArgumentError => e
43
+ raise DecryptionError, "Invalid key slot encoding: #{e.message}"
44
+ end
45
+ end
46
+ end
@@ -2,6 +2,7 @@ require "yaml"
2
2
  require "fileutils"
3
3
  require "tempfile"
4
4
  require "base64"
5
+ require "time"
5
6
 
6
7
  module LocalVault
7
8
  class Store
@@ -6,29 +6,34 @@ module LocalVault
6
6
  module SyncBundle
7
7
  class UnpackError < StandardError; end
8
8
 
9
- VERSION = 1
9
+ VERSION = 2
10
+ SUPPORTED_VERSIONS = [1, 2].freeze
10
11
 
11
12
  # Pack a vault's meta.yml + secrets.enc into a single JSON blob.
12
13
  # The secrets.enc is already encrypted — this bundle is opaque to the server.
13
- def self.pack(store)
14
+ # key_slots: optional hash of { "handle" => { "pub" => b64, "enc_key" => b64 }, ... }
15
+ def self.pack(store, key_slots: {})
14
16
  meta_content = File.read(store.meta_path)
15
17
  secrets_content = store.read_encrypted || ""
16
18
  JSON.generate(
17
- "version" => VERSION,
18
- "meta" => Base64.strict_encode64(meta_content),
19
- "secrets" => Base64.strict_encode64(secrets_content)
19
+ "version" => VERSION,
20
+ "meta" => Base64.strict_encode64(meta_content),
21
+ "secrets" => Base64.strict_encode64(secrets_content),
22
+ "key_slots" => key_slots
20
23
  )
21
24
  end
22
25
 
23
- # Unpack a blob back into {meta:, secrets:} strings.
26
+ # Unpack a blob back into {meta:, secrets:, key_slots:}.
24
27
  # Pass expected_name: to validate the meta.yml name matches the vault being pulled.
28
+ # v1 bundles return empty key_slots for backward compatibility.
25
29
  def self.unpack(blob, expected_name: nil)
26
30
  data = JSON.parse(blob)
27
31
  version = data["version"]
28
- raise UnpackError, "Unsupported bundle version: #{version}" if version && version != VERSION
32
+ raise UnpackError, "Unsupported bundle version: #{version}" if version && !SUPPORTED_VERSIONS.include?(version)
29
33
 
30
34
  meta_raw = Base64.strict_decode64(data.fetch("meta"))
31
35
  secrets_raw = Base64.strict_decode64(data.fetch("secrets"))
36
+ key_slots = data["key_slots"].is_a?(Hash) ? data["key_slots"] : {}
32
37
 
33
38
  if expected_name
34
39
  meta_parsed = YAML.safe_load(meta_raw)
@@ -38,7 +43,7 @@ module LocalVault
38
43
  end
39
44
  end
40
45
 
41
- { meta: meta_raw, secrets: secrets_raw }
46
+ { meta: meta_raw, secrets: secrets_raw, key_slots: key_slots }
42
47
  rescue JSON::ParserError => e
43
48
  raise UnpackError, "Invalid sync bundle format: #{e.message}"
44
49
  rescue KeyError => e
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.0.5"
2
+ VERSION = "1.1.1"
3
3
  end
data/lib/localvault.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "localvault/store"
5
5
  require_relative "localvault/vault"
6
6
  require_relative "localvault/identity"
7
7
  require_relative "localvault/share_crypto"
8
+ require_relative "localvault/key_slot"
8
9
  require_relative "localvault/api_client"
9
10
  require_relative "localvault/sync_bundle"
10
11
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: localvault
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.5
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -114,6 +114,7 @@ files:
114
114
  - lib/localvault/config.rb
115
115
  - lib/localvault/crypto.rb
116
116
  - lib/localvault/identity.rb
117
+ - lib/localvault/key_slot.rb
117
118
  - lib/localvault/mcp/server.rb
118
119
  - lib/localvault/mcp/tools.rb
119
120
  - lib/localvault/session_cache.rb