Class: LocalVault::CLI::Team

Inherits:
Thor
  • Object
show all
Includes:
TeamHelpers
Defined in:
lib/localvault/cli/team.rb

Instance Method Summary collapse

Instance Method Details

#add(handle) ⇒ Object



223
224
225
# File 'lib/localvault/cli/team.rb', line 223

def add(handle)
  LocalVault::CLI.new([], options, {}).add(handle)
end

#init(vault_name = nil) ⇒ Object

Initialize a vault as a team vault with you as the owner.

This is the explicit transition from personal sync to team-shared sync. Creates the owner’s key slot and bumps the bundle to v3.

Accepts the vault name as either a positional argument (‘team init intellectaco`) or via –vault/-v.



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/localvault/cli/team.rb', line 18

def init(vault_name = nil)
  unless Config.token
    $stderr.puts "Error: Not logged in."
    $stderr.puts "\n  localvault login YOUR_TOKEN\n"
    $stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
    return
  end

  unless Identity.exists?
    $stderr.puts "Error: No keypair found. Run: localvault keygen"
    return
  end

  vault_name ||= options[:vault] || Config.default_vault
  handle = Config.inventlist_handle

  master_key = ensure_master_key(vault_name)
  return unless master_key

  client = ApiClient.new(token: Config.token)
  begin
    blob = client.pull_vault(vault_name)
    unless blob.is_a?(String) && !blob.empty?
      $stderr.puts "Error: Vault '#{vault_name}' has not been synced. Run: localvault sync push -v #{vault_name}"
      return
    end
    data = SyncBundle.unpack(blob)
    if data[:owner]
      $stderr.puts "Error: Vault '#{vault_name}' is already a team vault. Owner: @#{data[:owner]}"
      return
    end
  rescue ApiClient::ApiError => e
    if e.status == 404
      $stderr.puts "Error: Vault '#{vault_name}' has not been synced. Run: localvault sync push -v #{vault_name}"
    else
      $stderr.puts "Error: #{e.message}"
    end
    return
  end

  # Create owner key slot
  pub_b64 = Identity.public_key
  enc_key = KeySlot.create(master_key, pub_b64)
  key_slots = {
    handle => { "pub" => pub_b64, "enc_key" => enc_key, "scopes" => nil, "blob" => nil }
  }

  # Preserve existing key slots from v2 (upgrade path)
  data[:key_slots].each do |h, slot|
    next if h == handle
    next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
    key_slots[h] = slot.merge("scopes" => nil, "blob" => nil)
  end

  store = Store.new(vault_name)
  new_blob = SyncBundle.pack_v3(store, owner: handle, key_slots: key_slots)
  client.push_vault(vault_name, new_blob)

  $stdout.puts "Vault '#{vault_name}' is now a team vault."
  $stdout.puts "Owner: @#{handle}"
  $stdout.puts "\nNext: localvault add @handle -v #{vault_name}"
rescue SyncBundle::UnpackError => e
  $stderr.puts "Error: #{e.message}"
end

#list(vault_name = nil) ⇒ Object

List all users who have access to a vault.

Checks sync-based key slots first; falls back to direct shares if no key slots exist. Displays member handles (key slots) or a share table with ID, recipient, status, and date.



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/localvault/cli/team.rb', line 90

def list(vault_name = nil)
  unless Config.token
    $stderr.puts "Error: Not logged in."
    $stderr.puts
    $stderr.puts "  localvault login YOUR_TOKEN"
    $stderr.puts
    $stderr.puts "Get your token at: https://inventlist.com/@YOUR_HANDLE/edit#developer"
    $stderr.puts "New to InventList? Sign up free at https://inventlist.com"
    $stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
    return
  end

  vault_name ||= options[:vault] || Config.default_vault
  client = ApiClient.new(token: Config.token)

  # Try sync-based key slots first
  key_slots = load_key_slots(client, vault_name)
  if key_slots && !key_slots.empty?
    list_key_slots(vault_name, key_slots)
    return
  end

  # Fall back to direct shares
  result = client.sent_shares(vault_name: vault_name)
  shares = (result["shares"] || []).reject { |s| s["status"] == "revoked" }

  if shares.empty?
    $stdout.puts "No active shares for vault '#{vault_name}'."
    return
  end

  $stdout.puts "Vault: #{vault_name}#{shares.size} share(s)"
  $stdout.puts
  $stdout.printf("%-8s  %-20s  %-10s  %-12s\n", "ID", "Recipient", "Status", "Shared")
  $stdout.puts("-" * 56)
  shares.each do |s|
    date = s["created_at"]&.slice(0, 10) || ""
    $stdout.printf("%-8s  %-20s  %-10s  %-12s\n",
      s["id"].to_s, "@#{s["recipient_handle"]}", s["status"], date)
  end
rescue ApiClient::ApiError => e
  $stderr.puts "Error: #{e.message}"
end

#remove(handle) ⇒ Object



231
232
233
# File 'lib/localvault/cli/team.rb', line 231

def remove(handle)
  LocalVault::CLI.new([], options, {}).remove(handle)
end

#rotate(vault_name = nil) ⇒ Object

Re-key a team vault without adding or removing members.

Prompts for a new passphrase, re-encrypts all secrets, and rebuilds all key slots. Useful for periodic key rotation.

Accepts the vault name as either a positional argument (‘team rotate intellectaco`) or via –vault/-v.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/localvault/cli/team.rb', line 143

def rotate(vault_name = nil)
  unless Config.token
    $stderr.puts "Error: Not logged in."
    return
  end

  vault_name ||= options[:vault] || Config.default_vault
  client = ApiClient.new(token: Config.token)

  team_data = load_team_data(client, vault_name)
  unless team_data && team_data[:key_slots] && !team_data[:key_slots].empty?
    $stderr.puts "Error: Vault '#{vault_name}' has no team access. Nothing to rotate."
    return
  end

  unless team_data[:owner]
    $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
    return
  end

  unless team_data[:owner] == Config.inventlist_handle
    $stderr.puts "Error: Only the vault owner (@#{team_data[:owner]}) can rotate keys."
    return
  end

  key_slots = team_data[:key_slots]
  vault_owner = team_data[:owner]

  master_key = ensure_master_key(vault_name)
  return unless master_key

  passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
  if passphrase.nil? || passphrase.empty?
    $stderr.puts "Error: Passphrase cannot be empty."
    return
  end

  # Decrypt existing secrets with the CURRENT master key. After this
  # point we must not touch the store until the push has succeeded —
  # see finding #5 (transactional rotate).
  vault = Vault.new(name: vault_name, master_key: master_key)
  secrets = vault.all

  new_salt = Crypto.generate_salt
  new_master_key = Crypto.derive_master_key(passphrase, new_salt)

  bundle = build_rotated_bundle(
    secrets:        secrets,
    key_slots:      key_slots,
    new_master_key: new_master_key,
    new_salt:       new_salt,
    owner:          vault_owner,
    vault_name:     vault_name,
    vault:          vault
  )

  # Push first. If this raises, nothing on local disk has changed, so
  # the user can safely retry with the same passphrase.
  client.push_vault(vault_name, bundle[:bundle_json])

  # Push succeeded — commit locally and update the session cache.
  commit_rotated_bundle_locally(vault_name, bundle[:new_secrets_bytes], bundle[:new_meta_bytes])
  SessionCache.set(vault_name, new_master_key)

  $stdout.puts "Vault '#{vault_name}' re-encrypted with new master key."
  $stdout.puts "#{bundle[:new_slots].size} member(s) updated."
rescue ApiClient::ApiError => e
  $stderr.puts "Error: #{e.message}"
end

#verify(handle) ⇒ Object



236
237
238
# File 'lib/localvault/cli/team.rb', line 236

def verify(handle)
  LocalVault::CLI.new([], options, {}).verify(handle)
end