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..."`(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} 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 缺少 ", 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 缺少 ", 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 缺少 ", 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 缺少 ", 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 缺少 ", 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 缺少 ", 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 缺少 ", 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} 只接受一個 (多餘:#{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 顯示單一項目(解鎖後解密欄位) create --type login|secure_note|card|identity [--folder UUID] edit 互動編輯(Enter 保留現值) delete 丟入回收桶 restore 從回收桶還原 purge 永久刪除(需確認) folders list|create|rename |delete 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 defp tty_stdin?, do: match?({:ok, _}, :io.rows()) 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