Files
bear-cli/lib/bear_cli/vault.ex
T
alex a29b976f13 fix: stdin TTY 偵測改以 fd 0 實際裝置判定,escript 真終端機不再誤判(issue #19)
- 新增 BearCli.TTY:readlink /proc/self/fd/0 判定 pts/tty/console/ptmx;
  無 /proc 平台退回 :io.rows/0
- vault/self_service 的 tty_stdin?、accounts 的 tty? 改用 TTY.stdin_tty?
- 根因::io.rows/0/:io.columns/0 走 io 協定問 group leader,escript
  (noshell standard_io)不支援行列查詢,一律回 enotsup——真終端機被
  誤判成非 TTY,互動指令(vault unlock 等)無法讀 stdin 密碼
  (bear #52 驗收建議 2)
- 非 TTY(pipe/redirect)行為不變:明確提示+退出碼 2
- 新增 tty_test(4 測試);mix precommit 全綠(200 passed)
2026-09-14 19:26:23 +08:00

1272 lines
42 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule BearCli.Vault do
@moduledoc """
`bear vault` 指令群(issue #17、docs/commands.md §3.11)。
對接 alterminal/bear 的 `/api/v1/vault` JSON API(PAT Bearer,
alterminal/bear#41)。端到端加密全在本模組與 `BearCli.Vault.Crypto`/
`BearCli.Vault.BIP39` 完成,格式與 Web Vault(P2)完全一致:
- 加密字串 `"2.<iv>.<ct>.<mac>"`(AES-256-CBC+HMAC-SHA-256、
encrypt-then-MAC)
- 主金鑰 PBKDF2-SHA512(迭代數以 `GET /api/v1/vault/config` 公告與
profile 記錄為準,不寫死;salt=帳號 email 小寫)
- 助記詞 BIP39(12 字、128-bit 熵、英文詞表)
**K_user 僅存記憶體**(不寫憑證檔/任何磁碟位置):`vault unlock` 解開
K_user 後以 session 環境變數 `BEAR_VAULT_SESSION`(base64)交給後續
指令(同 Bitwarden CLI 的 `BW_SESSION` 慣例;環境變數只存在於 shell
記憶體,不落盤)。`vault lock` 指示丟棄(unset)。
- 主密碼/助記詞以互動提示讀取(不回顧、不進 shell history、不接受
命令列明文參數);非 TTY 環境 → 退出碼 2。
- 助記詞只在 Web Vault 設定時顯示一次;CLI 不提供重新顯示,僅提供
`vault rescue`(助記詞救援重設主密碼)。
- 退出碼:0 成功;1 一般錯誤(404/422、主密碼/助記詞錯誤);2 用法
錯誤(含非 TTY);3 未登入或 401;6 網路/伺服器錯誤;8 權限不足。
"""
alias BearCli.{Api, Config, Credentials, TTY}
alias BearCli.Vault.{BIP39, Crypto}
@session_env "BEAR_VAULT_SESSION"
@verbs ~w(status unlock lock list get create edit delete restore purge folders sync password rescue)
@folder_verbs ~w(list create rename delete)
@cipher_types ~w(login secure_note card identity)
@folder_switches [folder: :string]
@create_switches [type: :string, folder: :string, email: :string]
# -- 解析(由 BearCli.CLI 轉入)--
@doc """
解析 `vault` 子指令樹,回 `{:ok, {BearCli.Vault, verb}, opts}`、
`{:ok, {:vault_folders, verb}, opts}` 或 `{:error, message, 2}`。
"""
def parse(["vault" | args], global) do
global_switches = [
issuer: :string,
config: :string,
json: :boolean,
verbose: :boolean,
help: :boolean,
version: :boolean,
email: :string
]
case args do
["folders", verb | rest] when verb in @folder_verbs ->
case OptionParser.parse(rest, strict: global_switches) do
{opts, positionals, []} ->
validate_folders(verb, positionals, merge(global, opts))
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
["password", "change" | rest] ->
case OptionParser.parse(rest, strict: global_switches) do
{opts, [], []} ->
{:ok, {__MODULE__, :password_change}, merge(global, opts)}
{_opts, extra, []} ->
{:error, "vault password change 不接受參數:#{Enum.join(extra, " ")}", 2}
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
["--help"] ->
{:help, :vault}
["help"] ->
{:help, :vault}
[verb | rest] when verb in @verbs ->
switches = verb_switches(verb)
case OptionParser.parse(rest, strict: global_switches ++ switches) do
{opts, positionals, []} ->
validate(verb, positionals, merge(global, opts))
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
[] ->
{:error, "vault 缺少子指令(可用:#{Enum.join(@verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 vault 子指令:#{unknown}(可用:#{Enum.join(@verbs, " ")})", 2}
end
end
defp verb_switches("list"), do: @folder_switches
defp verb_switches("create"), do: @create_switches
defp verb_switches(_), do: []
defp merge(global, opts), do: Map.merge(Map.new(global), Map.new(opts))
# -- 各動詞的位置參數驗證(用法錯誤 → 2)--
defp validate("status", [], opts), do: {:ok, {__MODULE__, :status}, opts}
defp validate("status", extra, _), do: extra_error("vault status", extra)
defp validate("unlock", [], opts), do: {:ok, {__MODULE__, :unlock}, opts}
defp validate("unlock", extra, _), do: extra_error("vault unlock", extra)
defp validate("lock", [], opts), do: {:ok, {__MODULE__, :lock}, opts}
defp validate("lock", extra, _), do: extra_error("vault lock", extra)
defp validate("list", [], opts), do: {:ok, {__MODULE__, :list}, opts}
defp validate("list", extra, _), do: extra_error("vault list", extra)
defp validate("get", [uuid], opts),
do: {:ok, {__MODULE__, :get}, Map.put(opts, :uuid_arg, uuid)}
defp validate("get", [], _), do: {:error, "vault get 缺少 <uuid>", 2}
defp validate("get", extra, _), do: single_error("vault get", extra)
defp validate("create", [], opts) do
type = opts[:type]
cond do
blank?(type) ->
{:error, "vault create 缺少 --type #{Enum.join(@cipher_types, "|")}", 2}
type not in @cipher_types ->
{:error, "--type 不接受的值:#{type}(可用:#{Enum.join(@cipher_types, "、")})", 2}
true ->
{:ok, {__MODULE__, :create}, opts}
end
end
defp validate("create", extra, _), do: extra_error("vault create", extra)
defp validate("edit", [uuid], opts),
do: {:ok, {__MODULE__, :edit}, Map.put(opts, :uuid_arg, uuid)}
defp validate("edit", [], _), do: {:error, "vault edit 缺少 <uuid>", 2}
defp validate("edit", extra, _), do: single_error("vault edit", extra)
defp validate("delete", [uuid], opts),
do: {:ok, {__MODULE__, :delete}, Map.put(opts, :uuid_arg, uuid)}
defp validate("delete", [], _), do: {:error, "vault delete 缺少 <uuid>", 2}
defp validate("delete", extra, _), do: single_error("vault delete", extra)
defp validate("restore", [uuid], opts),
do: {:ok, {__MODULE__, :restore}, Map.put(opts, :uuid_arg, uuid)}
defp validate("restore", [], _), do: {:error, "vault restore 缺少 <uuid>", 2}
defp validate("restore", extra, _), do: single_error("vault restore", extra)
defp validate("purge", [uuid], opts),
do: {:ok, {__MODULE__, :purge}, Map.put(opts, :uuid_arg, uuid)}
defp validate("purge", [], _), do: {:error, "vault purge 缺少 <uuid>", 2}
defp validate("purge", extra, _), do: single_error("vault purge", extra)
defp validate("sync", [], opts), do: {:ok, {__MODULE__, :sync}, opts}
defp validate("sync", extra, _), do: extra_error("vault sync", extra)
defp validate("rescue", [], opts), do: {:ok, {__MODULE__, :rescue}, opts}
defp validate("rescue", extra, _), do: extra_error("vault rescue", extra)
defp validate_folders("list", [], opts), do: {:ok, {__MODULE__, :folders_list}, opts}
defp validate_folders("list", extra, _), do: extra_error("vault folders list", extra)
defp validate_folders("create", [], opts), do: {:ok, {__MODULE__, :folders_create}, opts}
defp validate_folders("create", extra, _), do: extra_error("vault folders create", extra)
defp validate_folders("rename", [uuid], opts),
do: {:ok, {__MODULE__, :folders_rename}, Map.put(opts, :uuid_arg, uuid)}
defp validate_folders("rename", [], _), do: {:error, "vault folders rename 缺少 <uuid>", 2}
defp validate_folders("rename", extra, _), do: single_error("vault folders rename", extra)
defp validate_folders("delete", [uuid], opts),
do: {:ok, {__MODULE__, :folders_delete}, Map.put(opts, :uuid_arg, uuid)}
defp validate_folders("delete", [], _), do: {:error, "vault folders delete 缺少 <uuid>", 2}
defp validate_folders("delete", extra, _), do: single_error("vault folders delete", extra)
defp extra_error(cmd, extra),
do: {:error, "#{cmd} 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp single_error(cmd, extra),
do: {:error, "#{cmd} 只接受一個 <uuid>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2}
# -- 執行入口 --
@doc """
執行 vault 指令,回傳退出碼。`opts[:vault_api]` 可注入假 API 模組、
`opts[:io]` 注入互動輸入(`prompt_secret/1` 與 `prompt/2`)、
`opts[:tty?]` 覆寫 TTY 判定、`opts[:session]` 注入 session 金鑰供測試。
"""
def run(:help, _opts) do
IO.puts("""
bear vault — 密碼管理指令群(docs/commands.md §3.11)
用法:bear vault <子指令> [選項]
子指令:
status 初始化狀態、KDF 參數、解鎖狀態
unlock 主密碼解鎖 → 匯出 BEAR_VAULT_SESSION(K_user 僅記憶體)
lock 丟棄 session(unset BEAR_VAULT_SESSION)
list [--folder UUID] 列出項目(解鎖後顯示明文名稱)
get <uuid> 顯示單一項目(解鎖後解密欄位)
create --type login|secure_note|card|identity [--folder UUID]
edit <uuid> 互動編輯(Enter 保留現值)
delete <uuid> 丟入回收桶
restore <uuid> 從回收桶還原
purge <uuid> 永久刪除(需確認)
folders list|create|rename <uuid>|delete <uuid>
sync 完整同步(伺服器為準)
password change 變更 vault 主密碼
rescue 助記詞救援(重設主密碼)
""")
0
end
def run(verb, opts) do
api = opts[:vault_api] || Api
with {:ok, ctx} <- context(opts, api) do
execute(verb, ctx, opts, api)
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- 各指令實作 --
defp execute(:status, ctx, opts, api) do
# 先看 profile(404=未初始化);config 取不到時以 profile 的 KDF 為準。
with {:ok, profile} <- fetch_profile(ctx, opts, api),
{:ok, config} <- fetch_config(ctx, opts, api) do
kdf = profile["kdf"] || %{}
iterations = kdf["iterations"]
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
initialized: true,
enabled: profile["enabled"],
kdf: kdf,
iterations: iterations,
encryption_types: config["encryption_types"],
security_stamp: profile["security_stamp"],
last_synced_at: profile["last_synced_at"],
unlocked: session_key(opts) != nil
})
)
else
IO.puts("Vault 狀態:已初始化#{if profile["enabled"] == false, do: "(已停用)", else: ""}")
IO.puts("KDF:#{kdf["hash"] || "?"}/#{iterations || "?"} 迭代(salt=帳號 email 小寫)")
IO.puts("上次同步:#{profile["last_synced_at"] || "(從未)"}")
IO.puts(
"解鎖狀態:#{if session_key(opts), do: "已解鎖(本 shell)", else: "未解鎖(bear vault unlock)"}"
)
end
0
else
{:error, :not_initialized} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, initialized: false, unlocked: false}))
else
IO.puts("Vault 狀態:尚未初始化(請至 Web Vault /vault 頁面設定主密碼與助記詞)")
end
0
{:error, message, code} ->
fail(opts, message, code)
end
end
defp execute(:unlock, ctx, opts, api) do
with {:ok, profile} <- fetch_profile(ctx, opts, api),
{:ok, k_user} <- unlock_with_password(ctx, profile, opts, api) do
session = Base.encode64(k_user)
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, session: session, env: @session_env}))
else
IO.puts("已解鎖(K_user 僅存本 shell 記憶體;以下指令匯入 session):")
IO.puts(" export #{@session_env}=#{session}")
IO.puts("或一次性使用:#{@session_env}=<…> bear vault list")
IO.puts("鎖定:bear vault lock(unset #{@session_env})")
end
0
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:lock, _ctx, opts, _api) do
cond do
opts[:session] || System.get_env(@session_env) ->
IO.puts("請執行:unset #{@session_env}")
IO.puts("(K_user 只存在 shell 環境變數,unset 即丟棄)")
0
true ->
IO.puts("目前 shell 未持有 session(#{@session_env} 未設定)。")
0
end
end
defp execute(:list, ctx, opts, api) do
with {:ok, body} <- wrap_api(api.vault_ciphers_list(ctx.issuer, ctx.token), ctx, opts),
{:ok, folders_body} <- wrap_api(api.vault_folders_list(ctx.issuer, ctx.token), ctx, opts) do
ciphers = body["data"] || []
folders = folders_body["data"] || []
k_user = session_key(opts)
folder_names =
if k_user do
Map.new(folders, fn f ->
{f["folder_uuid"], decrypt_to(f["name"], k_user, "(無法解密)")}
end)
else
%{}
end
ciphers
|> Enum.filter(fn c ->
case opts[:folder] do
nil -> true
folder_uuid -> (c["data"] || %{})["folder_uuid"] == folder_uuid
end
end)
|> Enum.map(fn c -> cipher_row(c, k_user, folder_names) end)
|> render_rows(["UUID", "NAME", "TYPE", "FOLDER", "UPDATED", "STATE"])
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
decrypted: k_user != nil,
ciphers: Enum.map(ciphers, &cipher_json(&1, k_user, folder_names))
})
)
end
if ciphers == [] and not json?(opts) do
IO.puts("(無資料)")
end
if k_user == nil and ciphers != [] and not json?(opts) do
IO.puts("(名稱未解密;先 bear vault unlock 匯入 session)")
end
0
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:get, ctx, opts, api) do
uuid = opts[:uuid_arg]
with {:ok, body} <- wrap_api(api.vault_ciphers_get(ctx.issuer, ctx.token, uuid), ctx, opts) do
cipher = body["data"] || %{}
k_user = session_key(opts)
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
decrypted: k_user != nil,
cipher: cipher_json(cipher, k_key_or_map(cipher, k_user))
})
)
else
print_cipher(cipher, k_key_or_map(cipher, k_user))
if k_user == nil, do: IO.puts("(欄位未解密;先 bear vault unlock 匯入 session)")
end
0
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:create, ctx, opts, api) do
with {:ok, k_user} <- require_session(opts),
{:ok, attrs} <- prompt_cipher_fields(opts, opts[:type]) do
data =
case opts[:folder] do
nil -> %{}
folder_uuid -> %{"folder_uuid" => folder_uuid}
end
attrs =
Map.merge(attrs, %{
"cipher_uuid" => new_uuid(),
"cipher_type" => opts[:type],
"data" => data,
"favorite" => false,
"reprompt" => 0
})
|> encrypt_attrs(k_user)
case api.vault_ciphers_upsert(ctx.issuer, ctx.token, attrs) do
{:ok, body} ->
cipher = body["data"] || %{}
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, cipher: cipher}))
else
IO.puts("已建立:#{cipher["cipher_uuid"]}(#{opts[:type]})")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:edit, ctx, opts, api) do
uuid = opts[:uuid_arg]
with {:ok, k_user} <- require_session(opts),
{:ok, body} <- wrap_api(api.vault_ciphers_get(ctx.issuer, ctx.token, uuid), ctx, opts),
{:ok, attrs} <- prompt_edit_fields(opts, body["data"] || %{}, k_user) do
case api.vault_ciphers_update(ctx.issuer, ctx.token, uuid, encrypt_attrs(attrs, k_user)) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, updated: uuid}))
else
IO.puts("已更新:#{uuid}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:delete, ctx, opts, api) do
uuid = opts[:uuid_arg]
case api.vault_ciphers_delete(ctx.issuer, ctx.token, uuid) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, trashed: uuid}))
else
IO.puts("已丟入回收桶:#{uuid}(還原:bear vault restore #{uuid};永久刪除:bear vault purge #{uuid})")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:restore, ctx, opts, api) do
uuid = opts[:uuid_arg]
case api.vault_ciphers_restore(ctx.issuer, ctx.token, uuid) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, restored: uuid}))
else
IO.puts("已從回收桶還原:#{uuid}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:purge, ctx, opts, api) do
uuid = opts[:uuid_arg]
with :ok <- confirm_purge(opts, uuid) do
case api.vault_ciphers_purge(ctx.issuer, ctx.token, uuid) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, purged: uuid}))
else
IO.puts("已永久刪除:#{uuid}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:folders_list, ctx, opts, api) do
with {:ok, body} <- wrap_api(api.vault_folders_list(ctx.issuer, ctx.token), ctx, opts) do
folders = body["data"] || []
k_user = session_key(opts)
rows =
Enum.map(folders, fn f ->
name =
if k_user,
do: decrypt_to(f["name"], k_key(f["name"], k_user), "(無法解密)"),
else: "(未解密)"
[f["folder_uuid"], name, f["revision_date"] || ""]
end)
render_rows(rows, ["UUID", "NAME", "REVISED"])
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
decrypted: k_key_any(folders, k_user) != nil,
folders:
Enum.map(folders, fn f ->
%{
"uuid" => f["folder_uuid"],
"name" => if(k_user, do: decrypt_to(f["name"], k_user, nil), else: nil)
}
end)
})
)
end
if folders == [] and not opts[:json], do: IO.puts("(無資料)")
if k_user == nil and folders != [] and not opts[:json],
do: IO.puts("(名稱未解密;先 bear vault unlock)")
0
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:folders_create, ctx, opts, api) do
with {:ok, k_user} <- require_session(opts),
{:ok, name} <- prompt(opts, "資料夾名稱"),
:ok <- require_nonempty(name) do
attrs = %{"folder_uuid" => new_uuid(), "name" => Crypto.encrypt(name, k_user)}
case api.vault_folders_upsert(ctx.issuer, ctx.token, attrs) do
{:ok, body} ->
folder = body["data"] || %{}
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
folder: %{"uuid" => folder["folder_uuid"], "name" => name}
})
)
else
IO.puts("已建立資料夾:#{folder["folder_uuid"]}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:folders_rename, ctx, opts, api) do
uuid = opts[:uuid_arg]
with {:ok, k_user} <- require_session(opts),
{:ok, name} <- prompt(opts, "新的資料夾名稱"),
:ok <- require_nonempty(name) do
case api.vault_folders_rename(ctx.issuer, ctx.token, uuid, %{
"name" => Crypto.encrypt(name, k_user)
}) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, renamed: uuid}))
else
IO.puts("已重新命名:#{uuid}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:folders_delete, ctx, opts, api) do
uuid = opts[:uuid_arg]
case api.vault_folders_delete(ctx.issuer, ctx.token, uuid) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, deleted: uuid}))
else
IO.puts("已刪除資料夾:#{uuid}(所屬項目不受影響,僅失去分類)")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:sync, ctx, opts, api) do
with {:ok, body} <- wrap_api(api.vault_sync(ctx.issuer, ctx.token), ctx, opts) do
data = body["data"] || %{}
profile = data["profile"] || %{}
folders = data["folders"] || []
ciphers = data["data"] |> List.wrap() |> Enum.map(&(Map.get(&1, "data") || &1))
cipher_list = data["ciphers"] || []
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, sync: data}))
else
IO.puts("同步完成(伺服器為準):")
IO.puts(" security_stamp:#{profile["security_stamp"] || "(未知)"}")
IO.puts(" 資料夾:#{length(folders)} 個")
IO.puts(" 項目:#{length(cipher_list)} 個(含回收桶)")
IO.puts(" last_synced_at:#{profile["last_synced_at"] || "(首次)"}")
end
_ = ciphers
0
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:password_change, ctx, opts, api) do
with {:ok, profile} <- fetch_profile(ctx, opts, api),
{:ok, k_user} <- unlock_with_password(ctx, profile, opts, api),
{:ok, new_password} <- read_new_password(opts),
true <- byte_size(new_password) >= 8 || {:error, "新主密碼至少需要 8 個字元", 2} do
iterations = iterations_for(profile, ctx, opts, api)
case iterations do
{:ok, iterations} ->
new_master = Crypto.derive_master_key(new_password, ctx.email, iterations)
wrapped = Crypto.wrap_user_key(k_user, new_master)
case api.vault_profile_put(ctx.issuer, ctx.token, %{"wrapped_user_password" => wrapped}) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true}))
else
IO.puts("主密碼已更新;既有資料無需重新加密(K_user 未變)。")
IO.puts("提醒:新的 session 匯入請重新 bear vault unlock。")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
{:error, message, code} ->
fail(opts, message, code)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:rescue, ctx, opts, api) do
with {:ok, profile} <- fetch_profile(ctx, opts, api),
{:ok, mnemonic} <- prompt_secret(opts, "助記詞(12 字,空格分隔)"),
true <- BIP39.valid?(mnemonic) || {:error, "助記詞無效(字數/詞表/校驗和檢查未過)", 1},
{:ok, new_password} <- read_new_password(opts),
true <- byte_size(new_password) >= 8 || {:error, "新主密碼至少需要 8 個字元", 2},
{:ok, iterations} <- iterations_for(profile, ctx, opts, api),
wrapped_mnemonic when is_binary(wrapped_mnemonic) <-
profile["wrapped_user_mnemonic"] || {:error, "此 vault 未綁定助記詞(無法救援)", 1},
seed = BIP39.mnemonic_to_seed(mnemonic),
rescue_key = BIP39.rescue_key_from_seed(seed, ctx.email, iterations),
{:ok, k_user} <- Crypto.unwrap_user_key(wrapped_mnemonic, rescue_key) do
new_master = Crypto.derive_master_key(new_password, ctx.email, iterations)
wrapped = Crypto.wrap_user_key(k_user, new_master)
case api.vault_profile_put(ctx.issuer, ctx.token, %{"wrapped_user_password" => wrapped}) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true}))
else
IO.puts("已透過助記詞重設主密碼;既有資料無需重新加密。")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, :invalid} ->
fail(opts, "助記詞不正確(解不開 wrapped_user_mnemonic)", 1)
{:error, message, code} ->
fail(opts, message, code)
end
end
# -- 背景(token/issuer/email)與金鑰 --
defp context(opts, api) do
token_source =
cond do
token = Config.env_token() -> {:env, token}
true -> :file
end
case token_source do
{:env, token} ->
with {:ok, email} <- resolve_email(opts, api, token, nil) do
{:ok, %{token: token, issuer: resolve_issuer(opts, nil), email: email}}
end
:file ->
case Credentials.load() do
{:ok, creds} ->
token = creds["access_token"]
if blank?(token) do
{:error, "憑證檔缺少 access_token,請重新 bear login", 7}
else
with {:ok, email} <- resolve_email(opts, api, token, creds["email"]) do
{:ok,
%{token: token, issuer: resolve_issuer(opts, creds["issuer"]), email: email}}
end
end
:error ->
{:error, "未登入(請先執行 bear login)", 3}
end
end
end
# salt=帳號 email 小寫(與 Web Vault 一致);來源優先序:
# --email > 憑證檔 > 伺服器 profile。
defp resolve_email(opts, _api, _token, creds_email) do
email = opts[:email] || creds_email
cond do
is_binary(email) and email != "" -> {:ok, String.trim(email)}
true -> {:error, "無法得知帳號 email(KDF salt 需要);請以 --email EMAIL 提供", 2}
end
end
defp resolve_issuer(opts, stored_issuer) do
opts[:issuer] || stored_issuer || Config.resolve_issuer(nil, opts[:config])
end
defp fetch_config(ctx, _opts, api) do
case api.vault_config(ctx.issuer, ctx.token) do
{:ok, body} -> {:ok, body["data"] || %{}}
{:error, _kind, _detail} = error -> api_error(ctx, error)
end
end
# 404 → 未初始化(不以錯誤處理)
defp fetch_profile(ctx, _opts, api) do
case api.vault_profile_get(ctx.issuer, ctx.token) do
{:ok, body} -> {:ok, body["data"] || %{}}
{:error, :not_found, _} -> {:error, :not_initialized}
{:error, _kind, _detail} = error -> api_error(ctx, error)
end
end
defp iterations_for(profile, ctx, opts, api) do
from_profile = (profile["kdf"] || %{})["iterations"]
cond do
is_integer(from_profile) and from_profile > 0 ->
{:ok, from_profile}
true ->
case fetch_config(ctx, opts, api) do
{:ok, %{"kdf" => %{"iterations" => iters}}} when is_integer(iters) ->
{:ok, iters}
_ ->
{:error, "無法取得 KDF 迭代數(profile 與 /vault/config 皆未提供)", 6}
end
end
end
defp unlock_with_password(ctx, profile, opts, api) do
wrapped = profile["wrapped_user_password"]
cond do
blank?(wrapped) ->
{:error, "此 vault 未設定主密碼包裝金鑰(可能僅綁定助記詞);請用 bear vault rescue", 1}
true ->
with {:ok, password} <- prompt_secret(opts, "主密碼"),
{:ok, iterations} <- iterations_for(profile, ctx, opts, api) do
master = Crypto.derive_master_key(password, ctx.email, iterations)
case Crypto.unwrap_user_key(wrapped, master) do
{:ok, k_user} -> {:ok, k_user}
{:error, :invalid} -> {:error, "主密碼不正確", 1}
end
end
end
end
defp read_new_password(opts) do
with {:ok, new} <- prompt_secret(opts, "新主密碼"),
{:ok, confirm} <- prompt_secret(opts, "確認新主密碼") do
if new == confirm do
{:ok, new}
else
{:error, "兩次輸入的新主密碼不一致", 2}
end
end
end
# -- Session(K_user 傳遞;僅環境變數,不落盤)--
defp session_key(opts) do
raw = opts[:session] || System.get_env(@session_env)
with value when is_binary(value) <- raw,
{:ok, key} <- Base.decode64(value),
true <- byte_size(key) == 64 do
key
else
_ when raw in [nil, ""] -> nil
_ -> :invalid
end
end
defp require_session(opts) do
case session_key(opts) do
nil -> {:error, "此指令需要先解鎖(bear vault unlock 後匯入 #{@session_env})", 2}
:invalid -> {:error, "#{@session_env} 內容無效(需 vault unlock 產生的 base64 金鑰)", 2}
key -> {:ok, key}
end
end
# -- 互動輸入 --
# 敏感輸入(主密碼、助記詞、項目密碼):不回顧;非 TTY → 2。
defp prompt_secret(opts, label) do
tty? = Map.get(opts, :tty?, true)
io = opts[:io]
cond do
not tty? ->
{:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2}
io ->
case io.prompt_secret(label) do
:eof -> {:error, "未讀到輸入(#{label})", 2}
{:ok, value} -> {:ok, value}
end
true ->
if tty_stdin?() do
case IO.gets("#{label}:") do
:eof -> {:error, "未讀到輸入(#{label})", 2}
line -> {:ok, String.trim(line)}
end
else
{:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2}
end
end
end
# 一般輸入(名稱、備註等非敏感欄位):可回顯;fake io 亦走 prompt/2。
defp prompt(opts, label, default \\ nil) do
tty? = Map.get(opts, :tty?, true)
io = opts[:io]
hint = if default && default != "", do: "(直接 Enter 保留 #{default})", else: ""
cond do
not tty? ->
{:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2}
io ->
case io.prompt("#{label}#{hint}") do
:eof -> {:error, "未讀到輸入(#{label})", 2}
{:ok, ""} -> {:ok, default || ""}
{:ok, value} -> {:ok, value}
end
true ->
if tty_stdin?() do
case IO.gets("#{label}#{hint}:") do
:eof -> {:error, "未讀到輸入(#{label})", 2}
line -> {:ok, String.trim(line)}
end
else
{:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2}
end
end
end
# stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.rows/0 一律
# enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。
defp tty_stdin?, do: TTY.stdin_tty?()
defp require_nonempty(""), do: {:error, "不可空白", 2}
defp require_nonempty(value) when is_binary(value), do: :ok
# -- 建立/編輯的欄位提示 --
# 回 plaintext attrs(name 必填;其餘可空 → nil)
defp prompt_cipher_fields(opts, type) do
with {:ok, name} <- prompt(opts, "名稱"),
:ok <- require_nonempty(name) do
case type do
"login" ->
with {:ok, username} <- prompt(opts, "帳號(username)"),
{:ok, password} <- prompt_secret(opts, "密碼"),
{:ok, uri} <- prompt(opts, "URI(多個以逗號分隔,可省略)"),
{:ok, notes} <- prompt(opts, "備註(可省略)") do
{:ok,
%{
"name" => name,
"username" => empty_to_nil(username),
"password" => empty_to_nil(password),
"uris" => split_uris(uri),
"notes" => empty_to_nil(notes)
}}
end
"secure_note" ->
with {:ok, notes} <- prompt(opts, "筆記內容") do
{:ok, %{"name" => name, "notes" => empty_to_nil(notes)}}
end
extra_type when extra_type in ~w(card identity) ->
with {:ok, extra} <- prompt(opts, "額外資訊(卡號/身分資料;會加密存放)") do
{:ok, %{"name" => name, "extra" => empty_to_nil(extra)}}
end
end
end
end
# 編輯:解密現值 → 逐欄提示(Enter 保留現值)→ 只有變更的欄位重加密。
defp prompt_edit_fields(opts, cipher, k_user) do
current = decrypt_fields(cipher, k_user)
with {:ok, name} <- prompt(opts, "名稱", current["name"]),
:ok <- require_nonempty(name) do
type = cipher["cipher_type"]
attrs =
case type do
"login" ->
with {:ok, username} <- prompt(opts, "帳號(username)", current["username"]),
{:ok, password} <- prompt_edit_secret(opts, "密碼"),
{:ok, uri} <- prompt(opts, "URI(逗號分隔)", Enum.join(current["uris"] || [], ",")),
{:ok, notes} <- prompt(opts, "備註", current["notes"]) do
%{
"name" => name,
"username" => empty_to_nil(username),
# Enter 保留現值:以解密後的現值重加密(新 IV,同一明文)
"password" => if(password == "", do: current["password"], else: password),
"uris" => split_uris(uri),
"notes" => empty_to_nil(notes)
}
end
"secure_note" ->
with {:ok, notes} <- prompt(opts, "筆記內容", current["notes"]) do
%{"name" => name, "notes" => empty_to_nil(notes)}
end
_ ->
with {:ok, extra} <- prompt(opts, "額外資訊", current["extra"]) do
%{"name" => name, "extra" => empty_to_nil(extra)}
end
end
case attrs do
{:error, _m, _c} = err -> err
map -> {:ok, map}
end
end
end
# 編輯時的密碼欄:不回顯;直接 Enter=保留現值(回 "")。
defp prompt_edit_secret(opts, label) do
tty? = Map.get(opts, :tty?, true)
io = opts[:io]
cond do
not tty? -> {:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2}
io -> io.prompt_secret("#{label}(直接 Enter 保留現值)")
true -> prompt_secret(opts, "#{label}(直接 Enter 保留現值)")
end
end
defp split_uris(""), do: []
defp split_uris(nil), do: []
defp split_uris(uri_string) do
uri_string
|> String.split(~r/[,;\s]+/, trim: true)
|> Enum.reject(&(&1 == ""))
end
defp empty_to_nil(""), do: nil
defp empty_to_nil(value), do: value
# plaintext attrs → 加密 attrs(多餘鍵留在 data;敏感欄位逐欄加密)。
defp encrypt_attrs(attrs, k_user) do
Enum.reduce(attrs, %{}, fn {key, value}, acc ->
case key do
"extra" ->
data = Map.get(acc, "data") || %{}
Map.put(acc, "data", Map.put(data, "extra", value && Crypto.encrypt(value, k_user)))
key when key in ~w(name username password notes) ->
Map.put(acc, key, value && Crypto.encrypt(value, k_user))
"uris" ->
Map.put(acc, "uris", Enum.map(value || [], &Crypto.encrypt(&1, k_user)))
_ ->
Map.put(acc, key, value)
end
end)
end
defp decrypt_fields(cipher, k_user) do
%{
"name" => decrypt_to(cipher["name"], k_user, ""),
"username" => decrypt_to(cipher["username"], k_user, ""),
"password" => decrypt_to(cipher["password"], k_user, ""),
"notes" => decrypt_to(cipher["notes"], k_user, ""),
"extra" => decrypt_to((cipher["data"] || %{})["extra"], k_user, ""),
"uris" =>
Enum.map(cipher["uris"] || [], &decrypt_to(&1, k_user, ""))
|> Enum.reject(&(&1 == ""))
}
end
defp decrypt_to(nil, _k_user, default), do: default
defp decrypt_to(_value, nil, _default), do: nil
defp decrypt_to(value, k_user, default) when is_binary(value) do
case Crypto.decrypt(value, k_user) do
{:ok, plain} -> plain
{:error, :invalid} -> default
end
end
# -- 輸出 --
defp cipher_row(cipher, k_user, folder_names) do
name =
cond do
k_user == nil -> "(未解密)"
true -> decrypt_to(cipher["name"], k_user, "(無法解密)")
end
folder_uuid = (cipher["data"] || %{})["folder_uuid"]
[
cipher["cipher_uuid"],
name,
cipher["cipher_type"],
folder_names[folder_uuid] || "",
cipher["revision_date"] || cipher["updated_at"] || "",
if(cipher["deleted_at"], do: "回收桶", else: "有效")
]
end
defp cipher_json(cipher, k_user, folder_names \\ %{})
defp cipher_json(cipher, nil, _folder_names), do: cipher
defp cipher_json(cipher, k_user, folder_names) do
folder_uuid = (cipher["data"] || %{})["folder_uuid"]
Map.merge(cipher, %{
"plain" =>
%{
"name" => decrypt_to(cipher["name"], k_user, nil),
"username" => decrypt_to(cipher["username"], k_user, nil),
"password" => decrypt_to(cipher["password"], k_user, nil),
"notes" => decrypt_to(cipher["notes"], k_user, nil),
"uris" => Enum.map(cipher["uris"] || [], &decrypt_to(&1, k_user, nil))
}
|> Map.put("folder_name", folder_names[folder_uuid])
})
end
# get 指令的輔助(k_user 或 nil;資料夾名不存在)
defp k_key_or_map(_cipher, k_user), do: k_user
defp k_key(_value, k_user), do: k_user
defp k_key_any([], _k_user), do: nil
defp k_key_any(_folders, k_user), do: k_user
defp print_cipher(cipher, k_user) do
IO.puts("#{String.pad_trailing("UUID", 12)}: #{cipher["cipher_uuid"]}")
IO.puts("#{String.pad_trailing("類型", 12)}: #{cipher["cipher_type"]}")
IO.puts("#{String.pad_trailing("狀態", 12)}: #{if cipher["deleted_at"], do: "回收桶", else: "有效"}")
IO.puts("#{String.pad_trailing("最愛", 12)}: #{cipher["favorite"]}")
if k_user do
fields = decrypt_fields(cipher, k_user)
IO.puts("#{String.pad_trailing("名稱", 12)}: #{fields["name"]}")
IO.puts("#{String.pad_trailing("帳號", 12)}: #{fields["username"] || "(無)"}")
IO.puts(
"#{String.pad_trailing("密碼", 12)}: #{fields["password"] || decrypt_to(cipher["password"], nil, "(無)")}"
)
uris = fields["uris"]
IO.puts(
"#{String.pad_trailing("URI", 12)}: #{if uris == [], do: "(無)", else: Enum.join(uris, "、")}"
)
IO.puts("#{String.pad_trailing("備註", 12)}: #{fields["notes"] || "(無)"}")
extra = fields["extra"]
if extra, do: IO.puts("#{String.pad_trailing("額外資訊", 12)}: #{extra}")
else
Enum.each(~w(name username password notes), fn field ->
IO.puts("#{String.pad_trailing(field, 12)}: #{cipher[field] || "(未提供)"}")
end)
end
IO.puts(
"#{String.pad_trailing("修訂時間", 12)}: #{cipher["revision_date"] || cipher["updated_at"] || ""}"
)
end
defp render_rows(rows, headers) do
widths =
Enum.with_index(headers, fn _h, i ->
Enum.max([
String.length(Enum.at(headers, i))
| Enum.map(rows, &String.length(Enum.at(&1, i) || ""))
])
end)
render = fn cells ->
cells
|> Enum.with_index()
|> Enum.map(fn {cell, i} ->
String.pad_trailing(to_string(cell || ""), Enum.at(widths, i))
end)
|> Enum.join(" ")
|> String.trim_trailing()
end
IO.puts(render.(headers))
Enum.each(rows, &IO.puts(render.(&1)))
end
defp confirm_purge(opts, uuid) do
with {:ok, answer} <- prompt(opts, "永久刪除 #{uuid}?(y/N)") do
if answer in ~w(y Y yes YES) do
:ok
else
{:error, "已取消", 1}
end
end
end
defp new_uuid do
Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false)
end
# -- API 錯誤處理(退出碼對應 §3.11)--
defp wrap_api(result, ctx, _opts) do
case result do
{:ok, body} ->
{:ok, body}
{:error, _kind, _detail} = error ->
api_error(ctx, error)
end
end
defp api_error(_ctx, {:error, :unauthorized, desc}),
do: {:error, "token 無效或已過期(#{desc}),請重新 bear login", 3}
defp api_error(_ctx, {:error, :forbidden, desc}), do: {:error, "權限不足(HTTP 403:#{desc})", 8}
defp api_error(_ctx, {:error, :not_found, _desc}), do: {:error, "找不到目標(HTTP 404)", 1}
defp api_error(_ctx, {:error, :unprocessable_entity, %{"errors" => errors} = body})
when map_size(body) <= 2 do
details =
errors
|> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(List.wrap(messages), "、")}" end)
|> Enum.join(";")
{:error, "驗證失敗(HTTP 422):#{details}", 1}
end
defp api_error(_ctx, {:error, :unprocessable_entity, body}) when is_map(body) do
desc = body["error_description"] || body["error"] || ""
{:error, "驗證失敼(HTTP 422)#{if desc != "", do: ":#{desc}"}", 1}
end
defp api_error(_ctx, {:error, :too_many_requests, _body}),
do: {:error, "請求過於頻繁(HTTP 429),請稍後再試", 1}
defp api_error(_ctx, {:error, :server_error, status}), do: {:error, "伺服器回應 #{status}", 6}
defp api_error(ctx, {:error, :network, reason}),
do: {:error, "無法連線到 #{ctx.issuer}:#{reason}", 6}
defp fail_api(opts, ctx, error) do
case api_error(ctx, error) do
{:error, message, code} -> fail(opts, message, code)
end
end
defp fail(opts, message, code) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: false, error: message, code: code}))
else
IO.puts(:stderr, "錯誤:#{message}")
end
code
end
defp json?(opts), do: opts[:json] == true
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false
end