Files
bear-cli/lib/bear_cli/api.ex
alex 0b18d76cfd feat: 實作 vault 指令群 status/unlock/lock/list/get/create/edit/delete/restore/purge/folders/sync/password/rescue(issue #17)
- 對接 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
2026-09-10 23:27:47 +08:00

433 lines
16 KiB
Elixir
Raw Permalink 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.Api do
@moduledoc """
Bear 伺服器 HTTP 介面(一律使用 `Req`)。
- `GET /userinfo`:PAT 模式身分驗證(issue #6)。
- App 管理 API `/api/v1/apps`(alterminal/bear#28;PAT Bearer、admin 限定)。
- 個人自助 API `/api/v1/profile*`(alterminal/bear#35;PAT Bearer、
任何有效帳號):profile/密碼/email/sessions/PAT/MFA。
- 管理端 API `/api/v1/jwks`/`/api/v1/accounts`/`/api/v1/audit-logs`
(alterminal/bear#36; PAT Bearer, admin only).
"""
@finch BearCli.Finch
@doc """
呼叫 `GET {issuer}/userinfo`(Bearer token)。
回傳:
- `{:ok, claims}` — 200,claims 為 JSON map
- `{:error, :unauthorized, description}` — 401
- `{:error, :server_error, status}` — 其他非 2xx
- `{:error, :network, reason}` — 傳輸層錯誤
"""
def userinfo(issuer, token) do
url = String.trim_trailing(issuer, "/") <> "/userinfo"
case Req.get(url,
headers: [authorization: "Bearer " <> token, accept: "application/json"],
retry: false,
finch: [name: @finch]
) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, decode_body(body)}
{:ok, %Req.Response{status: 401, body: body}} ->
{:error, :unauthorized, error_description(body)}
{:ok, %Req.Response{status: status}} ->
{:error, :server_error, status}
{:error, exception} ->
{:error, :network, Exception.message(exception)}
end
end
defp decode_body(%{} = body), do: body
defp decode_body(body) when is_binary(body), do: Jason.decode!(body)
defp decode_body(_), do: %{}
defp error_description(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, %{"error_description" => desc}} when is_binary(desc) -> desc
{:ok, %{"error" => err}} when is_binary(err) -> err
_ -> "invalid_token"
end
end
defp error_description(_), do: "invalid_token"
# -- App 管理 API(alterminal/bear#28;PAT Bearer、admin 限定)--
@doc """
`GET /api/v1/apps`:列出 App(分頁)。
`params` 為 keyword(`page:`/`per_page:`,可加 `visibility:`/`status:` 過濾)。
成功回 `{:ok, %{"data" => [apps], "page" => n, "per_page" => n, "total" => n}}`。
"""
def apps_list(issuer, token, params \\ []) do
api_request(issuer, token, :get, "/api/v1/apps", params: params)
end
@doc "`GET /api/v1/apps/{id}`:單一 App(`client_secret` 永不回傳)。"
def apps_get(issuer, token, id) do
api_request(issuer, token, :get, "/api/v1/apps/" <> URI.encode(id))
end
@doc """
`POST /api/v1/apps`:建立 App。`method=client_secret` 且未給
`client_secret` 時由伺服器產生,成功回應內含一次性明文
(`%{"data" => app, "client_secret" => secret}`)。
"""
def apps_create(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/apps", json: attrs)
end
@doc "`PUT /api/v1/apps/{id}`:部分更新(只送有給的欄位)。"
def apps_update(issuer, token, id, attrs) do
api_request(issuer, token, :put, "/api/v1/apps/" <> URI.encode(id), json: attrs)
end
@doc """
`POST /api/v1/apps/{id}/rotate-secret`:輪轉 client secret,回應內含
一次性明文。PKCE App 回 422 `%{"error" => "pkce_app"}`。
"""
def apps_rotate_secret(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/apps/" <> URI.encode(id) <> "/rotate-secret",
json: %{}
)
end
@doc "`POST /api/v1/apps/{id}/toggle`:切換 active ↔ inactive。"
def apps_toggle(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/apps/" <> URI.encode(id) <> "/toggle", json: %{})
end
# -- 個人自助 API(alterminal/bear#35;PAT Bearer、任何有效帳號)--
@doc "`GET /api/v1/profile`:完整個人資料(含 MFA 狀態)。"
def profile_get(issuer, token) do
api_request(issuer, token, :get, "/api/v1/profile")
end
@doc "`PUT /api/v1/profile`:更新個人資料欄位(只送有給的欄位)。"
def profile_update(issuer, token, attrs) do
api_request(issuer, token, :put, "/api/v1/profile", json: attrs)
end
@doc """
`POST /api/v1/profile/password`:變更密碼(body:`current_password`、
`new_password`、`password_confirmation`;伺服器驗舊密碼,不因 CLI 放寬)。
"""
def profile_change_password(issuer, token, current_password, new_password) do
api_request(issuer, token, :post, "/api/v1/profile/password",
json: %{
current_password: current_password,
new_password: new_password,
password_confirmation: new_password
}
)
end
@doc "`POST /api/v1/profile/email/request-codes`:寄發 email 變更雙向驗證碼。"
def profile_email_request_codes(issuer, token, new_email) do
api_request(issuer, token, :post, "/api/v1/profile/email/request-codes",
json: %{new_email: new_email}
)
end
@doc """
`POST /api/v1/profile/email`:確認變更 email(body:`new_email`、
`current_code`、`new_code`)。
"""
def profile_email_change(issuer, token, new_email, current_code, new_code) do
api_request(issuer, token, :post, "/api/v1/profile/email",
json: %{new_email: new_email, current_code: current_code, new_code: new_code}
)
end
@doc "`GET /api/v1/profile/sessions`:列出作用中 session(新到舊)。"
def sessions_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/profile/sessions")
end
@doc "`POST /api/v1/profile/sessions/{id}/revoke`:撤銷單一 session。"
def sessions_revoke(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/profile/sessions/" <> URI.encode(id) <> "/revoke",
json: %{}
)
end
@doc """
`POST /api/v1/profile/sessions/revoke-others`:撤銷其他 session
(body `keep` 可省略=全部撤銷)。
"""
def sessions_revoke_others(issuer, token, keep \\ nil) do
api_request(issuer, token, :post, "/api/v1/profile/sessions/revoke-others",
json: %{keep: keep} |> Map.reject(fn {_k, v} -> is_nil(v) end)
)
end
@doc "`GET /api/v1/profile/tokens`:列出未撤銷的 PAT(新到舊)。"
def tokens_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/profile/tokens")
end
@doc """
`POST /api/v1/profile/tokens`:建立 PAT;明文 `token` 只在成功回應
出現一次(body:`name`、`expires_in`=天數或 `never`)。
"""
def tokens_create(issuer, token, name, expires_in) do
api_request(issuer, token, :post, "/api/v1/profile/tokens",
json: %{name: name, expires_in: expires_in}
)
end
@doc "`POST /api/v1/profile/tokens/{id}/revoke`:撤銷單一 PAT。"
def tokens_revoke(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/profile/tokens/" <> URI.encode(id) <> "/revoke",
json: %{}
)
end
@doc """
`POST /api/v1/profile/mfa/setup`(無 body):開始 TOTP 設定,回
`%{"data" => %{"secret" => ..., "otpauth_uri" => ...}}`。已啟用 MFA 回
422 `already_enabled`。
"""
def mfa_setup(issuer, token) do
api_request(issuer, token, :post, "/api/v1/profile/mfa/setup", json: %{})
end
@doc """
`POST /api/v1/profile/mfa/setup/confirm`(body:`code`):確認設定並
啟用 MFA,回一次性 recovery codes。
"""
def mfa_setup_confirm(issuer, token, code) do
api_request(issuer, token, :post, "/api/v1/profile/mfa/setup/confirm", json: %{code: code})
end
@doc """
`POST /api/v1/profile/mfa/disable`(body:`code`=目前 TOTP 或
recovery code):停用 MFA。
"""
def mfa_disable(issuer, token, code) do
api_request(issuer, token, :post, "/api/v1/profile/mfa/disable", json: %{code: code})
end
@doc """
`POST /api/v1/profile/mfa/recovery-codes`(body:`code`):重產一組
一次性 recovery codes(舊組全部失效)。
"""
def mfa_recovery_codes(issuer, token, code) do
api_request(issuer, token, :post, "/api/v1/profile/mfa/recovery-codes", json: %{code: code})
end
# -- Vault API(alterminal/bear#41;PAT Bearer、任何有效帳號)--
@doc "`GET /api/v1/vault/config`:公告的 KDF 參數與支援的加密字串型別。"
def vault_config(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/config")
end
@doc "`GET /api/v1/vault/profile`:vault profile(404=尚未初始化)。"
def vault_profile_get(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/profile")
end
@doc "`PUT /api/v1/vault/profile`:建立或更新 profile(上傳重新包裝的金鑰)。"
def vault_profile_put(issuer, token, attrs) do
api_request(issuer, token, :put, "/api/v1/vault/profile", json: attrs)
end
@doc "`GET /api/v1/vault/sync`:完整同步(profile+folders+ciphers)。"
def vault_sync(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/sync")
end
@doc "`GET /api/v1/vault/ciphers`:列出 ciphers(含回收桶)。"
def vault_ciphers_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/ciphers")
end
@doc "`POST /api/v1/vault/ciphers`:以客戶端 UUID upsert(衝突時整列覆蓋)。"
def vault_ciphers_upsert(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/vault/ciphers", json: attrs)
end
@doc "`GET /api/v1/vault/ciphers/{uuid}`:單一 cipher。"
def vault_ciphers_get(issuer, token, uuid) do
api_request(issuer, token, :get, "/api/v1/vault/ciphers/" <> URI.encode(uuid))
end
@doc "`PUT /api/v1/vault/ciphers/{uuid}`:部分更新(遞增 revision_date)。"
def vault_ciphers_update(issuer, token, uuid, attrs) do
api_request(issuer, token, :put, "/api/v1/vault/ciphers/" <> URI.encode(uuid), json: attrs)
end
@doc "`POST /api/v1/vault/ciphers/{uuid}/delete`:丟進回收桶(soft delete)。"
def vault_ciphers_delete(issuer, token, uuid) do
api_request(issuer, token, :post, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/delete",
json: %{}
)
end
@doc "`POST /api/v1/vault/ciphers/{uuid}/restore`:從回收桶還原。"
def vault_ciphers_restore(issuer, token, uuid) do
api_request(issuer, token, :post, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/restore",
json: %{}
)
end
@doc "`DELETE /api/v1/vault/ciphers/{uuid}/purge`:永久刪除(hard delete)。"
def vault_ciphers_purge(issuer, token, uuid) do
api_request(issuer, token, :delete, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/purge")
end
@doc "`GET /api/v1/vault/folders`:列出資料夾。"
def vault_folders_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/folders")
end
@doc "`POST /api/v1/vault/folders`:以客戶端 UUID upsert 資料夾。"
def vault_folders_upsert(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/vault/folders", json: attrs)
end
@doc "`PUT /api/v1/vault/folders/{uuid}`:重新命名(新的加密名稱)。"
def vault_folders_rename(issuer, token, uuid, attrs) do
api_request(issuer, token, :put, "/api/v1/vault/folders/" <> URI.encode(uuid), json: attrs)
end
@doc "`DELETE /api/v1/vault/folders/{uuid}`:刪除資料夾。"
def vault_folders_delete(issuer, token, uuid) do
api_request(issuer, token, :delete, "/api/v1/vault/folders/" <> URI.encode(uuid))
end
# -- 共用請求輔助 --
# 回傳:
# {:ok, body} | {:error, :unauthorized, desc} | {:error, :forbidden, desc}
# | {:error, :not_found, desc} | {:error, :unprocessable_entity, body}
# | {:error, :too_many_requests, body}
# | {:error, :server_error, status} | {:error, :network, reason}
defp api_request(issuer, token, method, path, extra \\ []) do
url = String.trim_trailing(issuer, "/") <> path
opts =
[
headers: [authorization: "Bearer " <> token, accept: "application/json"],
retry: false,
finch: [name: @finch]
]
|> Keyword.merge(extra)
case apply(Req, method, [url, opts]) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, decode_body(body)}
{:ok, %Req.Response{status: 401, body: body}} ->
{:error, :unauthorized, error_description(decode_body(body))}
{:ok, %Req.Response{status: 403, body: body}} ->
{:error, :forbidden, error_description(decode_body(body))}
{:ok, %Req.Response{status: 404, body: body}} ->
{:error, :not_found, error_description(decode_body(body))}
{:ok, %Req.Response{status: 422, body: body}} ->
{:error, :unprocessable_entity, decode_body(body)}
{:ok, %Req.Response{status: 429, body: body}} ->
{:error, :too_many_requests, decode_body(body)}
{:ok, %Req.Response{status: status}} ->
{:error, :server_error, status}
{:error, exception} ->
{:error, :network, Exception.message(exception)}
end
end
@doc "`GET /api/v1/jwks`: list all keys (active and inactive), newest first (no pagination)."
def jwks_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/jwks")
end
@doc """
`POST /api/v1/jwks`: create a new signing key. attrs: `%{"kid" => "...", "alg" => "RS256"}`
(`alg` optional; RS256/384/512, ES256/384/512). Key material is never serialized.
"""
def jwks_create(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/jwks", json: attrs)
end
@doc "`GET /api/v1/jwks/{id}`: single key (public metadata only)."
def jwks_get(issuer, token, id) do
api_request(issuer, token, :get, "/api/v1/jwks/" <> URI.encode(id))
end
@doc "`POST /api/v1/jwks/{id}/toggle`: switch key between active and inactive."
def jwks_toggle(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/jwks/" <> URI.encode(id) <> "/toggle", json: %{})
end
# -- Accounts API (alterminal/bear#46; PAT Bearer, admin only) --
@doc """
`GET /api/v1/accounts`: paginated list (newest first; per_page max 100).
Returns `{:ok, %{"data" => [accounts], "page" => n, "per_page" => n, "total" => n}}`.
"""
def accounts_list(issuer, token, params \\ []) do
api_request(issuer, token, :get, "/api/v1/accounts", params: params)
end
@doc """
`POST /api/v1/accounts`: create an account directly (no email verification).
attrs: `%{"email" => "...", "hash_password" => "<plaintext>", "role" => "user|admin"}`
(`role` optional, default `user`).
"""
def accounts_create(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/accounts", json: attrs)
end
@doc "`GET /api/v1/accounts/{id}`: single account (no credential fields)."
def accounts_get(issuer, token, id) do
api_request(issuer, token, :get, "/api/v1/accounts/" <> URI.encode(id))
end
@doc """
`PUT /api/v1/accounts/{id}`: update role; attrs: `%{"role" => "user|admin"}`.
"""
def accounts_update_role(issuer, token, id, attrs) do
api_request(issuer, token, :put, "/api/v1/accounts/" <> URI.encode(id), json: attrs)
end
@doc """
`PUT /api/v1/accounts/{id}/password`: admin sets a new password (no old password
needed); attrs: `%{"hash_password" => "<plaintext>"}`.
"""
def accounts_set_password(issuer, token, id, attrs) do
api_request(issuer, token, :put, "/api/v1/accounts/" <> URI.encode(id) <> "/password",
json: attrs
)
end
@doc "`DELETE /api/v1/accounts/{id}`: hard-delete an account (admin path). Returns `{:ok, %{}}` (204 empty)."
def accounts_delete(issuer, token, id) do
api_request(issuer, token, :delete, "/api/v1/accounts/" <> URI.encode(id))
end
# -- Audit logs API (alterminal/bear#46; PAT Bearer, admin only) --
@doc """
`GET /api/v1/audit-logs`: paginated list (newest first). params: `page:` /
`per_page:` (max 200) / `category:` filter. Returns `{:ok, %{"data" => [entries],
"page" => n, "per_page" => n, "total" => n, "total_pages" => n,
"emails" => %{id => email}}}`.
"""
def audit_logs_list(issuer, token, params \\ []) do
api_request(issuer, token, :get, "/api/v1/audit-logs", params: params)
end
end