- 對接 alterminal/bear#41 的 /api/v1/vault JSON API(PAT Bearer) - 客戶端端到端加密,格式與 Web Vault(P2)完全一致: - 加密字串 2.<iv>.<ct>.<mac>(AES-256-CBC+HMAC-SHA-256、PKCS#7、encrypt-then-MAC) - 主金鑰 PBKDF2-SHA512(迭代數取自 /vault/config 與 profile,不寫死;salt=email 小寫) - BIP39 助記詞(12 字、128-bit、英文詞表)+救援路徑 - K_user 僅存記憶體:vault unlock 匯出 BEAR_VAULT_SESSION(base64), 同 Bitwarden CLI BW_SESSION 慣例;lock 提示 unset;不寫入任何檔案 - docs/commands.md 補 §3.11 規格;README 同步 - 測試:fake API 注入+Bear.Vault.Crypto 密文樣本交叉驗證(雙向); BIP39 官方向量;46 個新測試,全套 196 passed
1270 lines
42 KiB
Elixir
1270 lines
42 KiB
Elixir
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}
|
||
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
|
||
|
||
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
|