localvault 1.1.1 → 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 +4 -4
- data/LICENSE +15 -17
- data/README.md +64 -2
- data/lib/localvault/api_client.rb +86 -14
- data/lib/localvault/cli/keys.rb +14 -0
- data/lib/localvault/cli/sync.rb +90 -12
- data/lib/localvault/cli/team.rb +353 -46
- data/lib/localvault/cli.rb +63 -4
- data/lib/localvault/config.rb +61 -0
- data/lib/localvault/crypto.rb +46 -0
- data/lib/localvault/identity.rb +41 -0
- data/lib/localvault/key_slot.rb +23 -3
- data/lib/localvault/mcp/server.rb +17 -0
- data/lib/localvault/mcp/tools.rb +13 -1
- data/lib/localvault/session_cache.rb +35 -7
- data/lib/localvault/share_crypto.rb +30 -5
- data/lib/localvault/store.rb +66 -0
- data/lib/localvault/sync_bundle.rb +50 -11
- data/lib/localvault/vault.rb +122 -12
- data/lib/localvault/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b181487cbbb186078cda14748801f5272f55349217358a9d9d93e47e079fb0c6
|
|
4
|
+
data.tar.gz: 66ffd265de195800930cd7da63da0359d9df62e0a69c8f6b9e519d8d643b62e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e360a9858c992f4f257393ee6a0dad05c4f993624a7b59545048f85f7050c1ba7a9ec0cc2b22c8ad48a704f4a70ef66b1e749075aa83c88e145717f649a67c4b
|
|
7
|
+
data.tar.gz: a84fb0edbf9fddb7b7bd4324354798003ae0caa95dfed899a75ca00fbb019db7441de5301ac135dccd11944ea2ad07efec1844456f849cc6e7bee50779067f9d
|
data/LICENSE
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
2
4
|
|
|
3
|
-
Copyright
|
|
5
|
+
Copyright 2026 Nauman Tariq / InventList (inventlist.com)
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
13
|
-
copies or substantial portions of the Software.
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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** |
|
|
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/ #
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
data/lib/localvault/cli/keys.rb
CHANGED
|
@@ -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"
|
data/lib/localvault/cli/sync.rb
CHANGED
|
@@ -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,10 +22,36 @@ module LocalVault
|
|
|
16
22
|
return
|
|
17
23
|
end
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
21
54
|
|
|
22
|
-
blob = SyncBundle.pack(store, key_slots: key_slots)
|
|
23
55
|
client = ApiClient.new(token: Config.token)
|
|
24
56
|
client.push_vault(vault_name, blob)
|
|
25
57
|
|
|
@@ -30,6 +62,12 @@ module LocalVault
|
|
|
30
62
|
|
|
31
63
|
desc "pull [NAME]", "Pull a vault from InventList cloud sync"
|
|
32
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.
|
|
33
71
|
def pull(vault_name = nil)
|
|
34
72
|
return unless logged_in?
|
|
35
73
|
|
|
@@ -72,6 +110,10 @@ module LocalVault
|
|
|
72
110
|
end
|
|
73
111
|
|
|
74
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.
|
|
75
117
|
def status
|
|
76
118
|
return unless logged_in?
|
|
77
119
|
|
|
@@ -115,8 +157,13 @@ module LocalVault
|
|
|
115
157
|
|
|
116
158
|
private
|
|
117
159
|
|
|
118
|
-
# Try to decrypt
|
|
119
|
-
#
|
|
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.
|
|
120
167
|
def try_unlock_via_key_slot(vault_name, key_slots)
|
|
121
168
|
return false unless key_slots.is_a?(Hash) && !key_slots.empty?
|
|
122
169
|
return false unless Identity.exists?
|
|
@@ -127,25 +174,56 @@ module LocalVault
|
|
|
127
174
|
slot = key_slots[handle]
|
|
128
175
|
return false unless slot.is_a?(Hash) && slot["enc_key"].is_a?(String)
|
|
129
176
|
|
|
130
|
-
|
|
177
|
+
decrypted_key = KeySlot.decrypt(slot["enc_key"], Identity.private_key_bytes)
|
|
131
178
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
135
195
|
|
|
136
|
-
|
|
196
|
+
SessionCache.set(vault_name, decrypted_key)
|
|
197
|
+
end
|
|
137
198
|
true
|
|
138
|
-
rescue KeySlot::DecryptionError, Crypto::DecryptionError
|
|
199
|
+
rescue KeySlot::DecryptionError, Crypto::DecryptionError, ArgumentError, JSON::ParserError
|
|
139
200
|
false
|
|
140
201
|
end
|
|
141
202
|
|
|
142
203
|
def logged_in?
|
|
143
204
|
return true if Config.token
|
|
144
205
|
|
|
145
|
-
$stderr.puts "Error: Not logged in.
|
|
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"
|
|
146
213
|
false
|
|
147
214
|
end
|
|
148
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
|
+
|
|
149
227
|
# Load key_slots from the last pushed blob (if any).
|
|
150
228
|
# Returns {} if no remote blob or if it's a v1 bundle.
|
|
151
229
|
def load_existing_key_slots(vault_name)
|