localvault 1.0.5 → 1.2.0

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: b181487cbbb186078cda14748801f5272f55349217358a9d9d93e47e079fb0c6
4
+ data.tar.gz: 66ffd265de195800930cd7da63da0359d9df62e0a69c8f6b9e519d8d643b62e7
5
5
  SHA512:
6
- metadata.gz: 5857befd8cc78b20dc0ebfd3dc2995b6ed0887e7084d8b843504d51089f9be977ebf79ac7ed323123a482b9d8a6a20b18caf825743c3ba5bdbea9c28bc18b99d
7
- data.tar.gz: 4e01a56bf7e7e253dcf655feabffb03c9f4c1e8ca6ca5c70e3d0eed99fef80fe6079d06871d5a53a5d748f1aefbc1c6a55e889149ed9d6d6b0d8e93e13a77f59
6
+ metadata.gz: e360a9858c992f4f257393ee6a0dad05c4f993624a7b59545048f85f7050c1ba7a9ec0cc2b22c8ad48a704f4a70ef66b1e749075aa83c88e145717f649a67c4b
7
+ data.tar.gz: a84fb0edbf9fddb7b7bd4324354798003ae0caa95dfed899a75ca00fbb019db7441de5301ac135dccd11944ea2ad07efec1844456f849cc6e7bee50779067f9d
data/LICENSE CHANGED
@@ -1,21 +1,19 @@
1
- MIT License
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
2
4
 
3
- Copyright (c) 2026 Nauman Tariq
5
+ Copyright 2026 Nauman Tariq / InventList (inventlist.com)
4
6
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
11
10
 
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
11
+ http://www.apache.org/licenses/LICENSE-2.0
14
12
 
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
18
+
19
+ See http://www.apache.org/licenses/LICENSE-2.0 for the full license text.
data/README.md CHANGED
@@ -75,6 +75,15 @@ localvault exec -- node app.js
75
75
  | `vaults` | List all vaults |
76
76
  | `unlock` | Output a session token for passphrase-free access |
77
77
  | `reset [NAME]` | Destroy all secrets and reinitialize with a new passphrase |
78
+ | `login TOKEN` | Log in to InventList (auto-keygen + publish public key) |
79
+ | `sync push` | Push vault to InventList cloud |
80
+ | `sync pull` | Pull vault from InventList cloud |
81
+ | `sync status` | Show sync state for all vaults |
82
+ | `team init` | Initialize a vault as a team vault (sets you as owner) |
83
+ | `team add @handle` | Add a teammate (with optional `--scope`) |
84
+ | `team remove @handle` | Remove a teammate (with optional `--rotate` or `--scope`) |
85
+ | `team list` | Show who has access to a synced vault |
86
+ | `team rotate` | Re-encrypt vault with new passphrase (no member changes) |
78
87
  | `version` | Print version |
79
88
 
80
89
  All vault commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. Defaults to `default`.
@@ -147,6 +156,59 @@ localvault reset
147
156
 
148
157
  Works on named vaults too: `localvault reset production`. All secrets are gone — there is no recovery.
149
158
 
159
+ ## Cloud Sync
160
+
161
+ Sync vaults across devices via [InventList](https://inventlist.com). Your secrets stay encrypted — the server never sees plaintext.
162
+
163
+ ```bash
164
+ # Log in (auto-generates keypair + publishes public key)
165
+ localvault login YOUR_TOKEN
166
+
167
+ # Push a vault to the cloud
168
+ localvault sync push
169
+
170
+ # Pull on another device
171
+ localvault sync pull
172
+
173
+ # Check sync status
174
+ localvault sync status
175
+ ```
176
+
177
+ ## Team Sharing
178
+
179
+ Share vault access with teammates using X25519 key slots. Three modes: personal sync (just you), direct share (one-time handoff), and team sync (ongoing shared access with scoping).
180
+
181
+ ```bash
182
+ # Initialize a vault as a team vault (you become the owner)
183
+ localvault team init -v production
184
+
185
+ # Add a teammate with full access
186
+ localvault team add @bob -v production
187
+
188
+ # Add a teammate with scoped access (only specific keys)
189
+ localvault team add @carol -v production --scope platepose DATABASE_URL
190
+
191
+ # See who has access
192
+ localvault team list -v production
193
+ # => @alice (owner)
194
+ # => @bob (full vault)
195
+ # => @carol (scopes: platepose, DATABASE_URL)
196
+
197
+ # Remove access
198
+ localvault team remove @bob -v production
199
+
200
+ # Remove a specific scope (keeps other scopes)
201
+ localvault team remove @carol -v production --scope DATABASE_URL
202
+
203
+ # Remove + re-encrypt with new passphrase (full revocation)
204
+ localvault team remove @bob -v production --rotate
205
+
206
+ # Re-key without removing anyone (periodic rotation)
207
+ localvault team rotate -v production
208
+ ```
209
+
210
+ When a teammate pulls a vault they have a key slot for, it auto-unlocks via their identity key. Scoped members see only their authorized keys — they don't know other keys exist.
211
+
150
212
  ## MCP Server (AI Agents)
151
213
 
152
214
  LocalVault includes an MCP server so AI coding agents can read and manage secrets via the Model Context Protocol — without ever seeing your passphrase.
@@ -186,7 +248,7 @@ See [MCP for AI Agents](https://inventlist.com/sites/localvault/series/localvaul
186
248
  |-------|-----------|---------|
187
249
  | Key derivation | **Argon2id** (64 MB, 2 iterations) | Passphrase to master key |
188
250
  | Encryption | **XSalsa20-Poly1305** | Authenticated encryption of secrets |
189
- | Key exchange | **X25519** | Future: shared vaults |
251
+ | Key exchange | **X25519** | Team key slots + vault sharing |
190
252
 
191
253
  - Every encryption uses a random 24-byte nonce
192
254
  - Authentication tag prevents tampering (Poly1305)
@@ -205,7 +267,7 @@ See [MCP for AI Agents](https://inventlist.com/sites/localvault/series/localvaul
205
267
  │ └── production/
206
268
  │ ├── meta.yml
207
269
  │ └── secrets.enc
208
- └── keys/ # Future: shared vault keys
270
+ └── keys/ # X25519 identity keypair for sync + team access
209
271
  ```
210
272
 
211
273
  - Secrets are stored as a single encrypted JSON blob per vault
@@ -3,7 +3,19 @@ require "uri"
3
3
  require "json"
4
4
 
5
5
  module LocalVault
6
+ # HTTP client for the InventList API (vault sync, sharing, public keys).
7
+ #
8
+ # All requests use Bearer token auth. Timeouts: 10s open, 30s read/write.
9
+ # Network and timeout errors are wrapped as ApiError so callers get a
10
+ # consistent exception type.
11
+ #
12
+ # @example
13
+ # client = ApiClient.new(token: "tok-...", base_url: "https://inventlist.com")
14
+ # client.me # => {"user" => {"handle" => "nauman"}}
15
+ # client.push_vault("prod", blob) # => {"name" => "prod", ...}
16
+ # client.pull_vault("prod") # => raw binary blob
6
17
  class ApiClient
18
+ # Raised on HTTP errors, network failures, and timeouts.
7
19
  class ApiError < StandardError
8
20
  attr_reader :status
9
21
 
@@ -15,39 +27,67 @@ module LocalVault
15
27
 
16
28
  BASE_PATH = "/api/v1"
17
29
 
30
+ # Create a new API client.
31
+ #
32
+ # @param token [String] Bearer token for authentication
33
+ # @param base_url [String, nil] API base URL (defaults to Config.api_url)
18
34
  def initialize(token:, base_url: nil)
19
35
  @token = token
20
36
  @base_url = base_url || Config.api_url
21
37
  end
22
38
 
23
- # GET /api/v1/me
39
+ # Fetch the authenticated user's profile.
40
+ #
41
+ # @return [Hash] user data (e.g. {"user" => {"handle" => "nauman"}})
42
+ # @raise [ApiError] on HTTP or network failure
24
43
  def me
25
44
  get("/me")
26
45
  end
27
46
 
28
- # GET /api/v1/users/:handle/public_key
47
+ # Fetch a user's X25519 public key by handle.
48
+ #
49
+ # @param handle [String] the user's InventList handle
50
+ # @return [Hash] key data (e.g. {"public_key" => "base64..."})
51
+ # @raise [ApiError] on HTTP or network failure
29
52
  def get_public_key(handle)
30
53
  get("/users/#{URI.encode_uri_component(handle)}/public_key")
31
54
  end
32
55
 
33
- # PUT /api/v1/profile/public_key
56
+ # Upload the current user's X25519 public key to InventList.
57
+ #
58
+ # @param public_key_b64 [String] base64-encoded X25519 public key
59
+ # @return [Hash] confirmation response
60
+ # @raise [ApiError] on HTTP or network failure
34
61
  def publish_public_key(public_key_b64)
35
62
  put("/profile/public_key", { public_key: public_key_b64 })
36
63
  end
37
64
 
38
- # GET /api/v1/vault_shares/pending
65
+ # List vault shares pending acceptance by the current user.
66
+ #
67
+ # @return [Hash] shares data (e.g. {"shares" => [...]})
68
+ # @raise [ApiError] on HTTP or network failure
39
69
  def pending_shares
40
70
  get("/vault_shares/pending")
41
71
  end
42
72
 
43
- # GET /api/v1/vault_shares/sent?vault_name=NAME
73
+ # List vault shares sent by the current user, optionally filtered by vault.
74
+ #
75
+ # @param vault_name [String, nil] filter by vault name (all shares if nil)
76
+ # @return [Hash] shares data (e.g. {"shares" => [...]})
77
+ # @raise [ApiError] on HTTP or network failure
44
78
  def sent_shares(vault_name: nil)
45
79
  path = "/vault_shares/sent"
46
80
  path += "?vault_name=#{URI.encode_uri_component(vault_name)}" if vault_name
47
81
  get(path)
48
82
  end
49
83
 
50
- # POST /api/v1/vault_shares
84
+ # Create a new vault share for a recipient.
85
+ #
86
+ # @param vault_name [String] name of the vault being shared
87
+ # @param recipient_handle [String] InventList handle of the recipient
88
+ # @param encrypted_payload [String] base64-encoded encrypted secrets from ShareCrypto
89
+ # @return [Hash] created share data
90
+ # @raise [ApiError] on HTTP or network failure
51
91
  def create_share(vault_name:, recipient_handle:, encrypted_payload:)
52
92
  post("/vault_shares", {
53
93
  vault_name: vault_name,
@@ -56,42 +96,74 @@ module LocalVault
56
96
  })
57
97
  end
58
98
 
59
- # PATCH /api/v1/vault_shares/:id/accept
99
+ # Accept a pending vault share.
100
+ #
101
+ # @param id [Integer, String] the share ID to accept
102
+ # @return [Hash] updated share data
103
+ # @raise [ApiError] on HTTP or network failure
60
104
  def accept_share(id)
61
105
  patch("/vault_shares/#{URI.encode_uri_component(id.to_s)}/accept", {})
62
106
  end
63
107
 
64
- # DELETE /api/v1/vault_shares/:id
108
+ # Revoke (delete) a vault share.
109
+ #
110
+ # @param id [Integer, String] the share ID to revoke
111
+ # @return [Hash] confirmation response
112
+ # @raise [ApiError] on HTTP or network failure
65
113
  def revoke_share(id)
66
114
  delete("/vault_shares/#{URI.encode_uri_component(id.to_s)}")
67
115
  end
68
116
 
69
- # GET /api/v1/teams/:handle/members/public_keys
117
+ # Fetch public keys for all members of a team.
118
+ #
119
+ # @param team_handle [String] the team's InventList handle
120
+ # @return [Hash] member keys (e.g. {"members" => [{"handle" => "...", "public_key" => "..."}]})
121
+ # @raise [ApiError] on HTTP or network failure
70
122
  def team_public_keys(team_handle)
71
123
  get("/teams/#{URI.encode_uri_component(team_handle)}/members/public_keys")
72
124
  end
73
125
 
74
- # GET /api/v1/sites/:slug/crew/public_keys
126
+ # Fetch public keys for all crew members of a site.
127
+ #
128
+ # @param site_slug [String] the site's slug
129
+ # @return [Hash] crew keys (e.g. {"members" => [{"handle" => "...", "public_key" => "..."}]})
130
+ # @raise [ApiError] on HTTP or network failure
75
131
  def crew_public_keys(site_slug)
76
132
  get("/sites/#{URI.encode_uri_component(site_slug)}/crew/public_keys")
77
133
  end
78
134
 
79
- # GET /api/v1/vaults
135
+ # List all vaults stored in the cloud for the authenticated user.
136
+ #
137
+ # @return [Hash] vaults data (e.g. {"vaults" => [{"name" => "prod", ...}]})
138
+ # @raise [ApiError] on HTTP or network failure
80
139
  def list_vaults
81
140
  get("/vaults")
82
141
  end
83
142
 
84
- # PUT /api/v1/vaults/:name sends raw binary blob, returns JSON
143
+ # Upload a vault bundle to the cloud (raw binary).
144
+ #
145
+ # @param name [String] vault name
146
+ # @param blob [String] packed SyncBundle binary blob
147
+ # @return [Hash] confirmation with vault metadata
148
+ # @raise [ApiError] on HTTP or network failure
85
149
  def push_vault(name, blob)
86
150
  request_binary(:put, "/vaults/#{URI.encode_uri_component(name)}", blob)
87
151
  end
88
152
 
89
- # GET /api/v1/vaults/:name returns raw binary blob
153
+ # Download a vault bundle from the cloud (raw binary).
154
+ #
155
+ # @param name [String] vault name
156
+ # @return [String] raw binary blob for SyncBundle.unpack
157
+ # @raise [ApiError] on HTTP or network failure (404 if vault not found)
90
158
  def pull_vault(name)
91
159
  request_raw(:get, "/vaults/#{URI.encode_uri_component(name)}")
92
160
  end
93
161
 
94
- # DELETE /api/v1/vaults/:name
162
+ # Delete a vault from the cloud.
163
+ #
164
+ # @param name [String] vault name to delete
165
+ # @return [Hash] confirmation response
166
+ # @raise [ApiError] on HTTP or network failure
95
167
  def delete_vault(name)
96
168
  delete("/vaults/#{URI.encode_uri_component(name)}")
97
169
  end
@@ -5,6 +5,11 @@ module LocalVault
5
5
  class Keys < Thor
6
6
  desc "generate", "Generate an X25519 keypair for vault sharing"
7
7
  method_option :force, type: :boolean, default: false, desc: "Overwrite existing keypair"
8
+ # Generate a new X25519 keypair for vault sharing.
9
+ #
10
+ # Creates a private/public key pair in the LocalVault config directory.
11
+ # Refuses to overwrite an existing keypair unless +--force+ is passed.
12
+ # After generating, run +localvault keys publish+ to upload the public key.
8
13
  def generate
9
14
  if Identity.exists? && !options[:force]
10
15
  $stdout.puts "Keypair already exists at #{Config.keys_path}"
@@ -24,6 +29,11 @@ module LocalVault
24
29
  end
25
30
 
26
31
  desc "publish", "Upload your public key to InventList"
32
+ # Publish your X25519 public key to InventList.
33
+ #
34
+ # Requires a keypair (run +localvault keys generate+ first) and an active
35
+ # connection (run +localvault connect+ first). Once published, other users
36
+ # can share vaults with you.
27
37
  def publish
28
38
  unless Identity.exists?
29
39
  $stderr.puts "Error: No keypair found. Run: localvault keys generate"
@@ -44,6 +54,10 @@ module LocalVault
44
54
  end
45
55
 
46
56
  desc "show", "Display your public key"
57
+ # Print your base64-encoded X25519 public key to stdout.
58
+ #
59
+ # Useful for manual key exchange or verification. Requires a keypair
60
+ # to exist (run +localvault keys generate+ first).
47
61
  def show
48
62
  unless Identity.exists?
49
63
  $stderr.puts "Error: No keypair found. Run: localvault keys generate"
@@ -5,6 +5,12 @@ module LocalVault
5
5
  class CLI
6
6
  class Sync < Thor
7
7
  desc "push [NAME]", "Push a vault to InventList cloud sync"
8
+ # Push a local vault to InventList cloud sync.
9
+ #
10
+ # Packs the vault's meta and encrypted secrets into a SyncBundle and uploads
11
+ # it. Preserves existing key slots from the remote and bootstraps an owner
12
+ # slot if the current identity has no slot yet. Defaults to the configured
13
+ # default vault if no name is given.
8
14
  def push(vault_name = nil)
9
15
  return unless logged_in?
10
16
 
@@ -16,7 +22,36 @@ module LocalVault
16
22
  return
17
23
  end
18
24
 
19
- blob = SyncBundle.pack(store)
25
+ # Load remote state to determine vault mode
26
+ remote_data = load_remote_bundle_data(vault_name)
27
+ handle = Config.inventlist_handle
28
+
29
+ if remote_data && remote_data[:owner]
30
+ # Team vault — check push authorization
31
+ owner = remote_data[:owner]
32
+ key_slots = remote_data[:key_slots] || {}
33
+ has_scoped = key_slots.values.any? { |s| s.is_a?(Hash) && s["scopes"].is_a?(Array) }
34
+ my_slot = key_slots[handle]
35
+ am_scoped = my_slot.is_a?(Hash) && my_slot["scopes"].is_a?(Array)
36
+
37
+ if am_scoped
38
+ $stderr.puts "Error: You have scoped access to vault '#{vault_name}'. Only the owner (@#{owner}) can push."
39
+ return
40
+ end
41
+
42
+ if has_scoped && owner != handle
43
+ $stderr.puts "Error: Vault '#{vault_name}' has scoped members. Only the owner (@#{owner}) can push."
44
+ return
45
+ end
46
+
47
+ # Authorized — push as v3, preserve key_slots
48
+ key_slots = bootstrap_owner_slot(key_slots, store)
49
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
50
+ else
51
+ # Personal vault — push as v1
52
+ blob = SyncBundle.pack(store)
53
+ end
54
+
20
55
  client = ApiClient.new(token: Config.token)
21
56
  client.push_vault(vault_name, blob)
22
57
 
@@ -27,6 +62,12 @@ module LocalVault
27
62
 
28
63
  desc "pull [NAME]", "Pull a vault from InventList cloud sync"
29
64
  method_option :force, type: :boolean, default: false, desc: "Overwrite existing local vault"
65
+ # Pull a vault from InventList cloud sync to the local filesystem.
66
+ #
67
+ # Downloads the SyncBundle, writes meta.yml and secrets.enc locally, and
68
+ # attempts automatic unlock via key slot. Refuses to overwrite an existing
69
+ # local vault unless +--force+ is passed. Defaults to the configured default
70
+ # vault if no name is given.
30
71
  def pull(vault_name = nil)
31
72
  return unless logged_in?
32
73
 
@@ -52,7 +93,12 @@ module LocalVault
52
93
  end
53
94
 
54
95
  $stdout.puts "Pulled vault '#{vault_name}'."
55
- $stdout.puts "Unlock it with: localvault unlock -v #{vault_name}"
96
+
97
+ if try_unlock_via_key_slot(vault_name, data[:key_slots])
98
+ $stdout.puts "Unlocked via your identity key."
99
+ else
100
+ $stdout.puts "Unlock it with: localvault unlock -v #{vault_name}"
101
+ end
56
102
  rescue SyncBundle::UnpackError => e
57
103
  $stderr.puts "Error: #{e.message}"
58
104
  rescue ApiClient::ApiError => e
@@ -64,6 +110,10 @@ module LocalVault
64
110
  end
65
111
 
66
112
  desc "status", "Show sync status for all vaults"
113
+ # Display sync status for all local and remote vaults.
114
+ #
115
+ # Shows a table with vault name, status (synced / remote only / local only),
116
+ # and last sync timestamp. Compares local vaults against the cloud inventory.
67
117
  def status
68
118
  return unless logged_in?
69
119
 
@@ -107,12 +157,104 @@ module LocalVault
107
157
 
108
158
  private
109
159
 
160
+ # Try to decrypt via key slot matching the current identity.
161
+ #
162
+ # For full-access members (scopes: nil): decrypts enc_key to get the master key.
163
+ # For scoped members (scopes: [...]): decrypts enc_key to get the per-member key,
164
+ # then decrypts the per-member blob and writes it as the local vault's secrets.
165
+ #
166
+ # On success, caches the key in SessionCache. Returns true/false.
167
+ def try_unlock_via_key_slot(vault_name, key_slots)
168
+ return false unless key_slots.is_a?(Hash) && !key_slots.empty?
169
+ return false unless Identity.exists?
170
+
171
+ handle = Config.inventlist_handle
172
+ return false unless handle
173
+
174
+ slot = key_slots[handle]
175
+ return false unless slot.is_a?(Hash) && slot["enc_key"].is_a?(String)
176
+
177
+ decrypted_key = KeySlot.decrypt(slot["enc_key"], Identity.private_key_bytes)
178
+
179
+ if slot["scopes"].is_a?(Array) && slot["blob"].is_a?(String)
180
+ # Scoped member: decrypt per-member blob and write as local vault
181
+ blob_encrypted = Base64.strict_decode64(slot["blob"])
182
+ filtered_json = Crypto.decrypt(blob_encrypted, decrypted_key)
183
+ # Verify it's valid JSON
184
+ JSON.parse(filtered_json)
185
+
186
+ # Re-encrypt the filtered secrets with the member key as local "master key"
187
+ store = Store.new(vault_name)
188
+ store.write_encrypted(Crypto.encrypt(filtered_json, decrypted_key))
189
+
190
+ SessionCache.set(vault_name, decrypted_key)
191
+ else
192
+ # Full-access member: decrypted_key IS the master key
193
+ vault = Vault.new(name: vault_name, master_key: decrypted_key)
194
+ vault.all # verify
195
+
196
+ SessionCache.set(vault_name, decrypted_key)
197
+ end
198
+ true
199
+ rescue KeySlot::DecryptionError, Crypto::DecryptionError, ArgumentError, JSON::ParserError
200
+ false
201
+ end
202
+
110
203
  def logged_in?
111
204
  return true if Config.token
112
205
 
113
- $stderr.puts "Error: Not logged in. Run: localvault login TOKEN"
206
+ $stderr.puts "Error: Not logged in."
207
+ $stderr.puts
208
+ $stderr.puts " localvault login YOUR_TOKEN"
209
+ $stderr.puts
210
+ $stderr.puts "Get your token at: https://inventlist.com/settings"
211
+ $stderr.puts "New to InventList? Sign up free at https://inventlist.com"
212
+ $stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
114
213
  false
115
214
  end
215
+
216
+ # Load the full unpacked remote bundle data (owner, key_slots, etc).
217
+ # Returns nil if no remote blob exists.
218
+ def load_remote_bundle_data(vault_name)
219
+ client = ApiClient.new(token: Config.token)
220
+ blob = client.pull_vault(vault_name)
221
+ return nil unless blob.is_a?(String) && !blob.empty?
222
+ SyncBundle.unpack(blob)
223
+ rescue ApiClient::ApiError, SyncBundle::UnpackError
224
+ nil
225
+ end
226
+
227
+ # Load key_slots from the last pushed blob (if any).
228
+ # Returns {} if no remote blob or if it's a v1 bundle.
229
+ def load_existing_key_slots(vault_name)
230
+ client = ApiClient.new(token: Config.token)
231
+ blob = client.pull_vault(vault_name)
232
+ return {} unless blob.is_a?(String) && !blob.empty?
233
+ data = SyncBundle.unpack(blob)
234
+ data[:key_slots] || {}
235
+ rescue ApiClient::ApiError, SyncBundle::UnpackError
236
+ {}
237
+ end
238
+
239
+ # Add the owner's key slot if identity exists and no slot is present.
240
+ # Requires the vault to be unlockable (needs master key for encryption).
241
+ def bootstrap_owner_slot(key_slots, store)
242
+ return key_slots unless Identity.exists?
243
+ handle = Config.inventlist_handle
244
+ return key_slots unless handle
245
+
246
+ # Already has owner slot — don't churn
247
+ return key_slots if key_slots.key?(handle)
248
+
249
+ # Need the master key to create the slot — try SessionCache
250
+ master_key = SessionCache.get(store.vault_name)
251
+ return key_slots unless master_key
252
+
253
+ pub_b64 = Identity.public_key
254
+ enc_key = KeySlot.create(master_key, pub_b64)
255
+ key_slots[handle] = { "pub" => pub_b64, "enc_key" => enc_key }
256
+ key_slots
257
+ end
116
258
  end
117
259
  end
118
260
  end