Compare commits
5
Commits
34c549e77a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c301551f | ||
|
|
a29b976f13
|
||
|
|
a2edb03a0e | ||
|
|
0b18d76cfd
|
||
|
|
a880bcea4d |
@@ -2,7 +2,7 @@
|
||||
|
||||
**Bear CLI** 是 [澳特科技](https://alterminal.com)(Alter Technology)單點登入系統 [Bear](https://gitea.alterminal.com/alterminal/bear) 的**終端機(CLI)客戶端**:讓使用者在 Linux 終端機以 Bear 帳號登入(Device Flow 或個人存取權杖)、查詢身分、取得 Token,作為 SSO 的終端入口。
|
||||
|
||||
> **狀態:已實作 PAT 模式 MVP(Elixir + Req,issue #6)+ App 管理(#10)+ 個人自助指令群(#11)**。`login --token <PAT>`、`whoami`、`token`、`logout`、`status`、`apps`、`profile`/`password`/`email`/`sessions`/`tokens`/`mfa` 已可用;Device Authorization Grant(RFC 8628)登入待伺服器端 P1(bear 倉庫)完成後再接。
|
||||
> **狀態:已實作 PAT 模式 MVP(Elixir + Req,issue #6)+ App 管理(#10)+ 個人自助指令群(#11)+ 管理端指令群(#12)+ Vault 密碼管理(#17)**。`login --token <PAT>`、`whoami`、`token`、`logout`、`status`、`apps`、`profile`/`password`/`email`/`sessions`/`tokens`/`mfa`、`jwks`/`accounts`/`audit-logs`、`vault` 已可用;Device Authorization Grant(RFC 8628)登入待伺服器端 P1(bear 倉庫)完成後再接。
|
||||
> 伺服器端採用的 OIDC **Device Authorization Grant(RFC 8628)** 方向,詳見 bear 倉庫的設計文件 `docs/cli-feature-plan.md`(issue #17)。
|
||||
> Bear 已支援**個人存取權杖(Personal Access Token,PAT)**;CLI 規劃以其作為完全免瀏覽器的替代登入方式(見下文)。
|
||||
|
||||
@@ -33,6 +33,13 @@ Bear 目前所有流程都依賴「瀏覽器」:登入 `/login`、儀表板 La
|
||||
| `bear sessions list/revoke/revoke-others` | 管理網頁 session | P3(✅ 已實作) |
|
||||
| `bear tokens list/create/revoke` | 管理 PAT(create 一次性顯示明文) | P3(✅ 已實作) |
|
||||
| `bear mfa status/setup/disable/recovery-codes` | TOTP 兩因子管理 | P3(✅ 已實作) |
|
||||
| `bear vault status/unlock/lock` | Vault 解鎖狀態與 K_user session(僅記憶體) | P3(✅ 已實作) |
|
||||
| `bear vault list/get/create/edit` | 項目 CRUD(客戶端端到端加密,與 Web Vault 互操作) | P3(✅ 已實作) |
|
||||
| `bear vault delete/restore/purge` | 回收桶(soft delete/還原/永久刪除) | P3(✅ 已實作) |
|
||||
| `bear vault folders list/create/rename/delete` | 資料夾管理(名稱客戶端加密) | P3(✅ 已實作) |
|
||||
| `bear vault sync` | 完整同步(伺服器為準) | P3(✅ 已實作) |
|
||||
| `bear vault password change` | 變更 vault 主密碼(重新包裝 K_user,不動 cipher) | P3(✅ 已實作) |
|
||||
| `bear vault rescue` | 助記詞救援(BIP39,重設主密碼) | P3(✅ 已實作) |
|
||||
|
||||
完整指令規格(選項、行為、輸出、退出碼)見 [`docs/commands.md`](docs/commands.md)。
|
||||
|
||||
@@ -54,6 +61,7 @@ Bear 目前所有流程都依賴「瀏覽器」:登入 `/login`、儀表板 La
|
||||
| `bear accounts` | ✅ 已實作(`list`/`show`/`create`/`update`/`set-password`/`delete`;issue #12,對接 alterminal/bear#46 的 `/api/v1/accounts` JSON API;密碼不進命令列,初始/新密碼一次性輸出) |
|
||||
| `bear audit-logs` | ✅ 已實作(`list` 含 `--category` 過濾與 emails 對照;issue #12,對接 alterminal/bear#46 的 `/api/v1/audit-logs` JSON API) |
|
||||
| `bear profile`/`password`/`email`/`sessions`/`tokens`/`mfa` | ✅ 已實作(issue #11,對接 alterminal/bear#38 的 `/api/v1/profile*` JSON API,任何有效 PAT 皆可):`profile show/set`、`password change`、`email change --new`、`sessions list/revoke/revoke-others`、`tokens list/create/revoke`、`mfa status/setup/disable/recovery-codes`。規格見 `docs/commands.md` §3.7 |
|
||||
| `bear vault` | ✅ 已實作(issue #17,對接 alterminal/bear#41 的 `/api/v1/vault` JSON API,任何有效 PAT 皆可):`status`/`unlock`/`lock`/`list`/`get`/`create`/`edit`/`delete`/`restore`/`purge`/`folders`/`sync`/`password change`/`rescue`。端到端加密全在客戶端(AES-256-CBC+HMAC-SHA-256 加密字串、PBKDF2-SHA512、BIP39 助記詞),格式與 Web Vault(P2)完全一致;K_user 僅存 shell 環境變數記憶體(`BEAR_VAULT_SESSION`),不落盤。規格見 `docs/commands.md` §3.11 |
|
||||
|
||||
### 建置與執行
|
||||
|
||||
|
||||
@@ -666,6 +666,104 @@ TIME CATEGORY EVENT ACTOR ACCOUN
|
||||
|
||||
**`--json` 輸出範例**:`{"ok": true, "page": 1, "per_page": 50, "total": 1, "total_pages": 1, "emails": {…}, "entries": […]}`
|
||||
|
||||
### 3.11 `bear vault`:密碼管理指令群(P3,已實作)
|
||||
|
||||
> 依賴:bear 伺服器端 **vault JSON API**(alterminal/bear#41,`/api/v1/vault`、PAT Bearer、任何有效 PAT 帳號)與 Web Vault(P2,alterminal/bear#48/#49)。兩者皆已上線,本節指令群已實作(issue #17)。
|
||||
|
||||
**端到端加密(與 Web Vault 完全一致,同一 vault 兩端互相可解)**:
|
||||
|
||||
- 加密字串 `"2.<iv_b64>.<ct_b64>.<mac_b64>"`:AES-256-CBC+PKCS#7、encrypt-then-MAC(HMAC-SHA-256 over `iv <> ct`)、先驗 MAC 再解密。
|
||||
- K_user 為隨機 64 byte(前 32 enc_key/後 32 mac_key);K_user 的包裝(wrapped key)加密的是 **K_user 的 base64**。
|
||||
- 主金鑰:PBKDF2-SHA512(salt=帳號 email 小寫;迭代數以 `GET /api/v1/vault/config` 公告與 profile 記錄為準,**不寫死**)。
|
||||
- 助記詞:BIP39(12 字、128-bit 熵、英文詞表);救援金鑰=種子 hex → PBKDF2-SHA512(salt=email 小寫)。
|
||||
- 所有加解密全在客戶端(本 CLI)完成;伺服器零解密。
|
||||
|
||||
**K_user 僅存記憶體(不落盤)**:`vault unlock` 解開 K_user 後輸出 session 環境變數設定指令,使用者 `export BEAR_VAULT_SESSION=<base64>` 匯入後,同一 shell 的後續 vault 指令即可解密(同 Bitwarden CLI 的 `BW_SESSION` 慣例;環境變數只存在 shell 記憶體,不寫入任何檔案)。`vault lock` 提示 `unset BEAR_VAULT_SESSION` 即丟棄。
|
||||
|
||||
```
|
||||
bear vault status [--json]
|
||||
bear vault unlock [--json]
|
||||
bear vault lock
|
||||
bear vault list [--folder UUID] [--json]
|
||||
bear vault get <uuid> [--json]
|
||||
bear vault create --type login|secure_note|card|identity [--folder UUID] [--json]
|
||||
bear vault edit <uuid> [--json]
|
||||
bear vault delete <uuid> [--json]
|
||||
bear vault restore <uuid> [--json]
|
||||
bear vault purge <uuid> [--json]
|
||||
bear vault folders list|create|rename <uuid>|delete <uuid> [--json]
|
||||
bear vault sync [--json]
|
||||
bear vault password change [--json]
|
||||
bear vault rescue [--json]
|
||||
```
|
||||
|
||||
**認證與授權(群組共通)**:
|
||||
|
||||
- 使用既有 PAT 憑證(`bear login --token` 或 `BEAR_TOKEN`)作為 Bearer;任何有效 PAT 帳號皆可。
|
||||
- **KDF salt 需要帳號 email**:來源優先序為 `--email EMAIL` 選項>憑證檔記錄的 email>(無法得知 → 用法錯誤 `2`,提示以 `--email` 提供)。一般 `bear login` 後憑證檔即有 email,無需手動給。
|
||||
- `401` → `3`;`403` → `8`;`404`/`422` → `1`;網路/伺服器錯誤 → `6`(沿用 §4 總表)。
|
||||
- **主密碼、助記詞、項目密碼等敏感輸入一律互動提示讀取且不回顧**,不接受命令列明文參數;非 TTY → `2`。名稱、URI、備註等非敏感欄位互動提示可回顯(直接 Enter=保留現值/略過)。
|
||||
- vault 尚未初始化(`GET /profile` 404)→ `vault status` 顯示「尚未初始化」並提示至 Web Vault 設定(初始化產生助記詞的流程在 Web Vault P2,CLI 不重作);其餘需要 profile 的指令 → 退出碼 `1`。
|
||||
|
||||
#### 3.11.1 `bear vault status`
|
||||
|
||||
顯示初始化狀態、KDF 參數、上次同步與本 shell 解鎖狀態(`BEAR_VAULT_SESSION` 是否設定)。`GET /api/v1/vault/config`+`GET /api/v1/vault/profile`。
|
||||
|
||||
**`--json`**:`{"ok": true, "initialized": true, "kdf": {…}, "unlocked": false, …}`;未初始化 → `{"ok": true, "initialized": false, "unlocked": false}`。
|
||||
|
||||
#### 3.11.2 `bear vault unlock`
|
||||
|
||||
互動輸入主密碼(不回顧)→ PBKDF2 推導 K_master → 解開 `wrapped_user_password` 得 K_user → 輸出 `export BEAR_VAULT_SESSION=…`。主密碼錯誤(MAC 驗證失敗)→ 退出碼 `1`(訊息「主密碼不正確」)。
|
||||
|
||||
**`--json`**:`{"ok": true, "session": "<base64>", "env": "BEAR_VAULT_SESSION"}`。
|
||||
|
||||
#### 3.11.3 `bear vault lock`
|
||||
|
||||
K_user 只存在環境變數,本指令提示 `unset BEAR_VAULT_SESSION`(無法代跨 shell unset)。
|
||||
|
||||
#### 3.11.4 `bear vault list [--folder UUID]`
|
||||
|
||||
`GET /api/v1/vault/ciphers`。未解鎖 → 只顯示 UUID/類型(名稱為密文狀態提示);已解鎖 → 一併解密名稱與所屬資料夾名。`--folder` 以 `data.folder_uuid` 過濾(資料夾關聯存在 cipher 的 `data` 欄位,與 Web Vault 一致)。
|
||||
|
||||
```
|
||||
UUID NAME TYPE FOLDER UPDATED STATE
|
||||
c9J2… GitHub login 工作 2026-09-10T05:00:00Z 有效
|
||||
```
|
||||
|
||||
**`--json`**:`{"ok": true, "decrypted": true, "ciphers": […含 "plain" 明文欄位(已解鎖時)…]}`。
|
||||
|
||||
#### 3.11.5 `bear vault get <uuid>`
|
||||
|
||||
`GET /api/v1/vault/ciphers/{uuid}`。已解鎖 → 解密全部欄位(名稱/帳號/密碼/URI/備註/額外資訊);未解鎖 → 顯示密文與提示。密碼欄位只在 stdout 呈現(供腳本擷取),不提供 `--clip`(本版)。
|
||||
|
||||
#### 3.11.6 `bear vault create --type login|secure_note|card|identity [--folder UUID]`
|
||||
|
||||
互動輸入欄位(名稱必填;`login`:帳號/密碼(不回顧)/URI(逗號分隔可多個)/備註;`secure_note`:筆記內容;`card`/`identity`:額外資訊,加密存於 `data.extra`——與 Web Vault 相同的欄位配置)。客戶端加密後 `POST /api/v1/vault/ciphers`(客戶端產生 UUID)。需要解鎖。
|
||||
|
||||
#### 3.11.7 `bear vault edit <uuid>`
|
||||
|
||||
`GET` 現值 → 解密 → 逐欄提示(**直接 Enter=保留現值**;密碼欄不回顧)→ 僅重加密有變更的欄位 → `PUT /api/v1/vault/ciphers/{uuid}`。需要解鎖。
|
||||
|
||||
#### 3.11.8 `bear vault delete/restore/purge <uuid>`
|
||||
|
||||
`delete` → `POST …/{uuid}/delete`(回收桶);`restore` → `POST …/{uuid}/restore`;`purge` → `DELETE …/{uuid}/purge`(**永久刪除,互動確認 `y` 才執行**,其他輸入取消 → 退出碼 `1`)。已回收桶再 delete → `409` → `1`。
|
||||
|
||||
#### 3.11.9 `bear vault folders list|create|rename <uuid>|delete <uuid>`
|
||||
|
||||
資料夾 CRUD(name 客戶端加密):`GET`/`POST`(互動輸入名稱,需解鎖)/`PUT …/{uuid}`(互動輸入新名稱,需解鎖)/`DELETE …/{uuid}`。刪除資料夾不影響所屬項目(僅失去分類)。
|
||||
|
||||
#### 3.11.10 `bear vault sync`
|
||||
|
||||
`GET /api/v1/vault/sync`(伺服器回 profile+folders+ciphers 並標記 `last_synced_at`)。**離線衝突以伺服器為準**(CLI 為無狀態客戶端,不做雙向合併;本機無快取可衝突)。顯示數量統計;`--json` 回完整 sync payload。
|
||||
|
||||
#### 3.11.11 `bear vault password change`
|
||||
|
||||
互動輸入:目前主密碼 → 驗證(解開 wrapped_user_password)→ 新主密碼+確認(≥8 字元,兩次一致)→ 以新 K_master 重新包裝 K_user → `PUT /api/v1/vault/profile`(只動 `wrapped_user_password`,**ciphers 不需重新加密**)。
|
||||
|
||||
#### 3.11.12 `bear vault rescue`
|
||||
|
||||
忘記主密碼的救援路徑:互動輸入助記詞(12 字,會做 BIP39 完整驗證:字數/詞表/校驗和)→ 解開 `wrapped_user_mnemonic` 得 K_user → 設新主密碼(≥8 字元,兩次一致)→ 重新包裝上傳。助記詞錯誤 → 退出碼 `1`。CLI 不提供助記詞顯示(僅 Web Vault 設定時顯示一次)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 退出碼總表
|
||||
|
||||
@@ -21,6 +21,7 @@ defmodule BearCli.Accounts do
|
||||
|
||||
import BearCli.Admin, only: [context: 1, fail: 3, format_api_error: 2]
|
||||
alias BearCli.Api
|
||||
alias BearCli.TTY
|
||||
|
||||
@list_per_page_fetch 100
|
||||
@max_fetch_pages 50
|
||||
@@ -251,8 +252,10 @@ defmodule BearCli.Accounts do
|
||||
|
||||
defp weak_password?(_), do: true
|
||||
|
||||
# stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.columns/0
|
||||
# 一律 enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。
|
||||
defp tty? do
|
||||
:io.columns() != {:error, :enotsup}
|
||||
TTY.stdin_tty?()
|
||||
end
|
||||
|
||||
defp prompt_password(prompt) do
|
||||
|
||||
@@ -223,6 +223,87 @@ defmodule BearCli.Api 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
|
||||
|
||||
# -- 共用請求輔助 --
|
||||
|
||||
# 回傳:
|
||||
|
||||
+42
-1
@@ -8,7 +8,18 @@ defmodule BearCli.CLI do
|
||||
優先使用該值,不讀寫本機憑證檔。
|
||||
"""
|
||||
|
||||
alias BearCli.{Accounts, Admin, Api, Apps, AuditLogs, Config, Credentials, Jwks, SelfService}
|
||||
alias BearCli.{
|
||||
Accounts,
|
||||
Admin,
|
||||
Api,
|
||||
Apps,
|
||||
AuditLogs,
|
||||
Config,
|
||||
Credentials,
|
||||
Jwks,
|
||||
SelfService,
|
||||
Vault
|
||||
}
|
||||
|
||||
@global_switches [
|
||||
issuer: :string,
|
||||
@@ -327,6 +338,11 @@ defmodule BearCli.CLI do
|
||||
end
|
||||
end
|
||||
|
||||
# vault 指令群(issue #17;解析委由 BearCli.Vault)
|
||||
defp parse_command("vault", args, global) do
|
||||
Vault.parse(["vault" | args], global)
|
||||
end
|
||||
|
||||
defp parse_command(unknown, _args, _global) do
|
||||
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
|
||||
end
|
||||
@@ -584,6 +600,11 @@ defmodule BearCli.CLI do
|
||||
IO.puts(BearCli.version())
|
||||
0
|
||||
|
||||
{:help, :vault} ->
|
||||
# vault 群組專屬說明
|
||||
_ = Vault.run(:help, %{})
|
||||
0
|
||||
|
||||
{:help, _} ->
|
||||
print_help()
|
||||
0
|
||||
@@ -616,6 +637,11 @@ defmodule BearCli.CLI do
|
||||
audit_logs_api = cmd_opts[:audit_logs_api] || opts[:audit_logs_api] || Api
|
||||
AuditLogs.run(verb, Map.put(cmd_opts, :audit_logs_api, audit_logs_api))
|
||||
|
||||
# vault 指令群({:ok, {BearCli.Vault, verb}, opts} 形狀)
|
||||
{:ok, {BearCli.Vault, verb}, cmd_opts} when is_atom(verb) ->
|
||||
vault_opts = opts |> Map.new() |> Map.take([:vault_api, :io, :tty?, :session, :email])
|
||||
Vault.run(verb, Map.merge(cmd_opts, vault_opts))
|
||||
|
||||
{:ok, command, cmd_opts} ->
|
||||
cond do
|
||||
cmd_opts[:version] ->
|
||||
@@ -969,6 +995,21 @@ defmodule BearCli.CLI do
|
||||
mfa setup
|
||||
mfa disable
|
||||
mfa recovery-codes
|
||||
vault 指令群(任何有效 PAT 皆可;詳見 bear vault --help 與 docs/commands.md §3.11):
|
||||
vault status 初始化狀態、KDF 參數、解鎖狀態
|
||||
vault unlock 主密碼解鎖 → 匯出 BEAR_VAULT_SESSION
|
||||
vault lock 丟棄 session(unset 環境變數)
|
||||
vault list [--folder UUID] 列出項目(解鎖後顯示明文名稱)
|
||||
vault get <uuid> 顯示單一項目(解鎖後解密欄位)
|
||||
vault create --type login|secure_note|card|identity [--folder UUID]
|
||||
vault edit <uuid> 互動編輯(Enter 保留現值)
|
||||
vault delete <uuid> 丟入回收桶
|
||||
vault restore <uuid> 從回收桶還原
|
||||
vault purge <uuid> 永久刪除(需確認)
|
||||
vault folders list|create|rename <uuid>|delete <uuid>
|
||||
vault sync 完整同步(伺服器為準)
|
||||
vault password change 變更 vault 主密碼
|
||||
vault rescue 助記詞救援(重設主密碼)
|
||||
|
||||
全域選項:
|
||||
--issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com)
|
||||
|
||||
@@ -15,7 +15,7 @@ defmodule BearCli.SelfService do
|
||||
3 未登入或 401;6 網路/伺服器錯誤;8 權限不足(403)。
|
||||
"""
|
||||
|
||||
alias BearCli.{Api, Config, Credentials}
|
||||
alias BearCli.{Api, Config, Credentials, TTY}
|
||||
|
||||
@genders ~w(male female other)
|
||||
|
||||
@@ -551,9 +551,10 @@ defmodule BearCli.SelfService do
|
||||
end
|
||||
end
|
||||
|
||||
# :io.rows/0 僅在終端機裝置成功;pipe/檔案重導回 {:error, :enotsup}。
|
||||
# stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.rows/0 一律
|
||||
# enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。
|
||||
defp tty_stdin? do
|
||||
match?({:ok, _}, :io.rows())
|
||||
TTY.stdin_tty?()
|
||||
end
|
||||
|
||||
# -- 輸出 --
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
defmodule BearCli.TTY do
|
||||
@moduledoc """
|
||||
stdin 是否為終端機(TTY)的偵測(issue #19;來源 bear #52 驗收建議 2)。
|
||||
|
||||
不再只依賴 `:io.rows/0`/`:io.columns/0`:兩者走 io 協定詢問 group
|
||||
leader,escript(`erl -noshell` 的 `standard_io`)不支援行列查詢,
|
||||
即使 fd 0 是真終端機也回 `{:error, :enotsup}`,互動環境會被誤判成
|
||||
非 TTY(無法讀 stdin 密碼,只能靠測試注入介面繞過)。
|
||||
|
||||
本模組改為直接判定 fd 0 指向的裝置:
|
||||
|
||||
- Linux:readlink `/proc/self/fd/0`,指向 `/dev/pts/*`、`/dev/tty*`、
|
||||
`/dev/console`、`/dev/ptmx` 視為 TTY;pipe/socket/一般檔案(含
|
||||
`/dev/null`)視為非 TTY。
|
||||
- 無 `/proc` 的平台(如 macOS、Windows):退回 `:io.rows/0`(僅在
|
||||
終端機裝置成功)。
|
||||
"""
|
||||
|
||||
@tty_prefixes ["/dev/pts/", "/dev/tty", "/dev/console", "/dev/ptmx"]
|
||||
|
||||
@doc "stdin(fd 0)是否為終端機。"
|
||||
@spec stdin_tty?() :: boolean()
|
||||
def stdin_tty? do
|
||||
case fd0_path() do
|
||||
{:ok, path} -> tty_path?(path)
|
||||
:error -> rows_tty?()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
裝置路徑是否為終端機(公開供測試與診斷)。
|
||||
|
||||
iex> BearCli.TTY.tty_path?("/dev/pts/0")
|
||||
true
|
||||
iex> BearCli.TTY.tty_path?("pipe:[42]")
|
||||
false
|
||||
"""
|
||||
@spec tty_path?(String.t()) :: boolean()
|
||||
def tty_path?(path) when is_binary(path) do
|
||||
String.starts_with?(path, @tty_prefixes)
|
||||
end
|
||||
|
||||
@doc """
|
||||
舊式偵測(`:io.rows/0`):終端機 io 裝置回 `{:ok, rows}`;escript 的
|
||||
`standard_io` 一律回 `{:error, :enotsup}`。僅作為無 `/proc` 平台的退路。
|
||||
"""
|
||||
@spec rows_tty?() :: boolean()
|
||||
def rows_tty? do
|
||||
match?({:ok, _}, :io.rows())
|
||||
end
|
||||
|
||||
# fd 0 實際指向的路徑(readlink /proc/self/fd/0);無 /proc 或讀取失敗 → :error
|
||||
defp fd0_path do
|
||||
case :file.read_link(~c"/proc/self/fd/0") do
|
||||
{:ok, path} -> {:ok, to_string(path)}
|
||||
{:error, _} -> :error
|
||||
end
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
defmodule BearCli.Vault.BIP39 do
|
||||
@moduledoc """
|
||||
BIP39 助記詞(12 字、128-bit 熵、英文詞表),與 Web Vault 的
|
||||
assets/js/vault/bip39.js 完全一致:
|
||||
|
||||
- 產生:16 bytes 熵 → 熵+SHA-256 前 4 bit 校驗 → 12 × 11-bit 索引
|
||||
- 種子:PBKDF2-HMAC-SHA512(password=正規化助記詞、salt=
|
||||
`"mnemonic"`(Bear 不用 BIP39 passphrase)、2048 迭代、64 bytes)
|
||||
- 救援金鑰:種子 hex 字串 → PBKDF2-SHA512(salt=帳號 email 小寫、
|
||||
迭代數同主金鑰),與主金鑰同形
|
||||
|
||||
正規化:小寫、去首尾空白、內部連續空白收斂為單一空格。
|
||||
"""
|
||||
|
||||
import BearCli.Vault.BIP39.Wordlist, only: [words: 0, index: 1]
|
||||
import Bitwise
|
||||
|
||||
@strength_bits 128
|
||||
@word_count 12
|
||||
@seed_iterations 2048
|
||||
@seed_keylen 64
|
||||
|
||||
# -- 正規化 --
|
||||
|
||||
@doc "正規化助記詞(小寫、trim、內部空白收斂);回傳單字清單。"
|
||||
def normalize(mnemonic) do
|
||||
mnemonic
|
||||
|> to_string()
|
||||
|> String.downcase()
|
||||
|> String.trim()
|
||||
|> String.split(~r/\s+/, trim: true)
|
||||
end
|
||||
|
||||
@doc "正規化後以單一空格連接的助記詞字串。"
|
||||
def normalize_joined(mnemonic) do
|
||||
normalize(mnemonic) |> Enum.join(" ")
|
||||
end
|
||||
|
||||
# -- 產生 --
|
||||
|
||||
@doc "以 CSPRNG 產生 12 字助記詞(128-bit 熵)。"
|
||||
def generate do
|
||||
mnemonic_from_entropy(:crypto.strong_rand_bytes(div(@strength_bits, 8)))
|
||||
end
|
||||
|
||||
@doc "16 bytes 熵 → 12 字助記詞(熵+SHA-256 前 4 bit 校驗)。"
|
||||
def mnemonic_from_entropy(entropy) when byte_size(entropy) == 16 do
|
||||
checksum = :crypto.hash(:sha256, entropy)
|
||||
|
||||
bits =
|
||||
(entropy <> checksum)
|
||||
|> bytes_to_bits()
|
||||
|> Enum.take(12 * 11)
|
||||
|
||||
bits
|
||||
|> Enum.chunk_every(11)
|
||||
|> Enum.map(fn eleven ->
|
||||
Enum.reduce(eleven, 0, fn bit, acc -> acc * 2 + bit end)
|
||||
|> then(&Enum.at(words(), &1))
|
||||
end)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
# -- 驗證 --
|
||||
|
||||
@doc "驗證助記詞:12 字、全部在詞表、校驗和正確。回 true/false。"
|
||||
def valid?(mnemonic) do
|
||||
list = normalize(mnemonic)
|
||||
|
||||
if length(list) != @word_count do
|
||||
false
|
||||
else
|
||||
indices = Enum.map(list, &index/1)
|
||||
|
||||
if Enum.any?(indices, &is_nil/1) do
|
||||
false
|
||||
else
|
||||
bits = Enum.flat_map(indices, &int_to_bits(&1, 11))
|
||||
entropy_bits = Enum.take(bits, 128)
|
||||
checksum_bits = Enum.drop(bits, 128)
|
||||
|
||||
entropy = bits_to_bytes(entropy_bits)
|
||||
checksum = :crypto.hash(:sha256, entropy)
|
||||
|
||||
expected =
|
||||
checksum
|
||||
|> bytes_to_bits()
|
||||
|> Enum.take(4)
|
||||
|
||||
checksum_bits == expected
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# -- 種子與救援金鑰 --
|
||||
|
||||
@doc """
|
||||
BIP39 種子:PBKDF2-HMAC-SHA512(2048 迭代、64 bytes、salt=`"mnemonic"`)。
|
||||
password=正規化助記詞(與 bip39.js `mnemonicToSeed` 相同)。
|
||||
"""
|
||||
def mnemonic_to_seed(mnemonic) do
|
||||
:crypto.pbkdf2_hmac(
|
||||
:sha512,
|
||||
normalize_joined(mnemonic),
|
||||
"mnemonic",
|
||||
@seed_iterations,
|
||||
@seed_keylen
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
救援金鑰:種子 hex 字串 → PBKDF2-SHA512(salt=帳號 email 小寫、迭代數
|
||||
同主金鑰)。與 bip39.js `rescueKeyFromSeed` 相同構造。
|
||||
"""
|
||||
def rescue_key_from_seed(seed, email, iterations) do
|
||||
seed_hex = Base.encode16(seed, case: :lower)
|
||||
:crypto.pbkdf2_hmac(:sha512, seed_hex, String.downcase(email), iterations, 64)
|
||||
end
|
||||
|
||||
# -- Private --
|
||||
|
||||
defp bytes_to_bits(binary) do
|
||||
for(<<byte <- binary>>, do: for(i <- 7..0//-1, do: Bitwise.bsr(byte, i) &&& 1))
|
||||
|> List.flatten()
|
||||
end
|
||||
|
||||
defp int_to_bits(value, n) do
|
||||
for(i <- (n - 1)..0//-1, do: Bitwise.bsr(value, i) &&& 1)
|
||||
end
|
||||
|
||||
defp bits_to_bytes(bits) do
|
||||
bits
|
||||
|> Enum.chunk_every(8)
|
||||
|> Enum.map(fn eight ->
|
||||
Enum.reduce(eight, 0, fn bit, acc -> acc * 2 + bit end)
|
||||
end)
|
||||
|> :binary.list_to_bin()
|
||||
end
|
||||
end
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,118 @@
|
||||
defmodule BearCli.Vault.Crypto do
|
||||
@moduledoc """
|
||||
客戶端 vault 加密原語(issue #17,對應 bear 倉庫 Bear.Vault.Crypto 與
|
||||
Web Vault 的 assets/js/vault/crypto.js)。
|
||||
|
||||
格式必須與 Web Vault **完全一致**(同一 vault 兩端互相可解):
|
||||
|
||||
- 加密字串 `"2.<iv_b64>.<ct_b64>.<mac_b64>"`(AES-256-CBC+HMAC-SHA-256、
|
||||
PKCS#7、encrypt-then-MAC、先驗 MAC 再解密)
|
||||
- K_user 為隨機 64 byte(前 32 enc_key/後 32 mac_key)
|
||||
- 主金鑰:PBKDF2-SHA512(迭代數以 `GET /api/v1/vault/config` 公告為準,
|
||||
不寫死),salt=帳號 email 小寫
|
||||
- K_user 的包裝(wrap):加密 **K_user 的 base64**(隨機 bytes 非 UTF-8,
|
||||
與 Web Vault `wrapUserKey` 相同構造)
|
||||
- 助記詞救援:BIP39 種子 → hex 字串 → PBKDF2-SHA512(salt=email 小寫)
|
||||
|
||||
全部在客戶端完成,K_user 僅存記憶體(不落盤、不進 log/commit)。
|
||||
"""
|
||||
|
||||
@type_prefix "2"
|
||||
@block_size 16
|
||||
|
||||
# -- 金鑰推導 --
|
||||
|
||||
@doc """
|
||||
以主密碼推導 64-byte 主金鑰(PBKDF2-SHA512;salt=帳號 email 小寫)。
|
||||
`iterations` 以 `/api/v1/vault/config` 公告值為準,不寫死。
|
||||
"""
|
||||
def derive_master_key(password, email, iterations)
|
||||
when is_binary(password) and is_binary(email) and is_integer(iterations) do
|
||||
:crypto.pbkdf2_hmac(:sha512, password, String.downcase(email), iterations, 64)
|
||||
end
|
||||
|
||||
@doc "產生隨機 64-byte 使用者金鑰(`<<enc_key::32, mac_key::32>>`)。"
|
||||
def generate_user_key do
|
||||
:crypto.strong_rand_bytes(64)
|
||||
end
|
||||
|
||||
# -- 加密字串(type 2)--
|
||||
|
||||
@doc """
|
||||
以 64-byte `user_key` 加密明文,回傳加密字串 `"2.<iv>.<ct>.<mac>"`
|
||||
(AES-256-CBC+PKCS#7;encrypt-then-MAC,HMAC-SHA-256 over `iv <> ct`)。
|
||||
"""
|
||||
def encrypt(plaintext, user_key)
|
||||
when is_binary(plaintext) and byte_size(user_key) == 64 do
|
||||
<<enc_key::binary-size(32), mac_key::binary-size(32)>> = user_key
|
||||
iv = :crypto.strong_rand_bytes(@block_size)
|
||||
|
||||
ciphertext =
|
||||
:crypto.crypto_one_time(:aes_256_cbc, enc_key, iv, pkcs7_pad(plaintext), true)
|
||||
|
||||
mac = :crypto.mac(:hmac, :sha256, mac_key, iv <> ciphertext)
|
||||
|
||||
[@type_prefix, Base.encode64(iv), Base.encode64(ciphertext), Base.encode64(mac)]
|
||||
|> Enum.join(".")
|
||||
end
|
||||
|
||||
@doc """
|
||||
先驗 MAC(encrypt-then-MAC)再解密加密字串。回 `{:ok, plaintext}` 或
|
||||
`{:error, :invalid}`(失敗形態一致,不洩漏原因)。
|
||||
"""
|
||||
def decrypt(encrypted_string, user_key)
|
||||
when is_binary(encrypted_string) and byte_size(user_key) == 64 do
|
||||
<<enc_key::binary-size(32), mac_key::binary-size(32)>> = user_key
|
||||
|
||||
with [@type_prefix, iv_b64, ct_b64, mac_b64] <- String.split(encrypted_string, "."),
|
||||
{:ok, iv} <- Base.decode64(iv_b64),
|
||||
{:ok, ct} <- Base.decode64(ct_b64),
|
||||
{:ok, mac} <- Base.decode64(mac_b64),
|
||||
true <- byte_size(iv) == @block_size,
|
||||
true <- mac == :crypto.mac(:hmac, :sha256, mac_key, iv <> ct),
|
||||
plaintext <- :crypto.crypto_one_time(:aes_256_cbc, enc_key, iv, ct, false),
|
||||
plaintext <- pkcs7_unpad(plaintext) do
|
||||
{:ok, plaintext}
|
||||
else
|
||||
_ -> {:error, :invalid}
|
||||
end
|
||||
end
|
||||
|
||||
# -- 包裝金鑰(wrap/unwrap;與 Web Vault wrapUserKey 相同構造)--
|
||||
|
||||
@doc "包裝 K_user:加密 K_user 的 base64 字串,回傳加密字串。"
|
||||
def wrap_user_key(user_key, wrapping_key) do
|
||||
encrypt(Base.encode64(user_key), wrapping_key)
|
||||
end
|
||||
|
||||
@doc """
|
||||
解開包裝的 K_user。回 `{:ok, 64-byte key}` 或 `{:error, :invalid}`。
|
||||
"""
|
||||
def unwrap_user_key(wrapped, wrapping_key) do
|
||||
with {:ok, inner} <- decrypt(wrapped, wrapping_key),
|
||||
{:ok, key} <- Base.decode64(inner),
|
||||
true <- byte_size(key) == 64 do
|
||||
{:ok, key}
|
||||
else
|
||||
_ -> {:error, :invalid}
|
||||
end
|
||||
end
|
||||
|
||||
# -- Private --
|
||||
|
||||
defp pkcs7_pad(data) do
|
||||
pad_len = @block_size - rem(byte_size(data), @block_size)
|
||||
data <> :binary.copy(<<pad_len>>, pad_len)
|
||||
end
|
||||
|
||||
defp pkcs7_unpad(data) do
|
||||
pad_len = :binary.last(data)
|
||||
|
||||
if pad_len in 1..@block_size//1 and byte_size(data) >= pad_len and
|
||||
binary_part(data, byte_size(data), -pad_len) == :binary.copy(<<pad_len>>, pad_len) do
|
||||
binary_part(data, 0, byte_size(data) - pad_len)
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
defmodule BearCli.TTYTest do
|
||||
@moduledoc """
|
||||
`BearCli.TTY` 單元測試(issue #19):
|
||||
|
||||
- `tty_path?/1`:裝置路徑分類(pts/tty/console=TTY;pipe/檔案/
|
||||
socket//dev/null=非 TTY)。
|
||||
- `stdin_tty?/0`:在本測試環境(ExUnit 捕獲 IO,stdin 非 TTY)應為
|
||||
`false`;`rows_tty?/0` 在 escript/noshell 環境回 `enotsup` → `false`。
|
||||
- fd 0 偵測走 `/proc/self/fd/0`,Linux 上必可用。
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias BearCli.TTY
|
||||
|
||||
describe "tty_path?/1" do
|
||||
test "終端機裝置路徑 → true" do
|
||||
assert TTY.tty_path?("/dev/pts/0")
|
||||
assert TTY.tty_path?("/dev/pts/17")
|
||||
assert TTY.tty_path?("/dev/tty1")
|
||||
assert TTY.tty_path?("/dev/tty")
|
||||
assert TTY.tty_path?("/dev/ttyS0")
|
||||
assert TTY.tty_path?("/dev/console")
|
||||
assert TTY.tty_path?("/dev/ptmx")
|
||||
end
|
||||
|
||||
test "pipe/檔案/socket/null → false" do
|
||||
refute TTY.tty_path?("pipe:[12345]")
|
||||
refute TTY.tty_path?("socket:[12345]")
|
||||
refute TTY.tty_path?("/dev/null")
|
||||
refute TTY.tty_path?("/home/user/secret.txt")
|
||||
refute TTY.tty_path?("/proc/self/fd/0")
|
||||
refute TTY.tty_path?("")
|
||||
end
|
||||
end
|
||||
|
||||
describe "stdin_tty?/0" do
|
||||
test "測試環境(stdin 非 TTY)→ false" do
|
||||
# ExUnit 的 group leader 為擷取裝置、測試程序的 stdin 非終端機。
|
||||
refute TTY.stdin_tty?()
|
||||
end
|
||||
end
|
||||
|
||||
describe "fd0 實體判定(Linux /proc)" do
|
||||
test "/proc/self/fd/0 可解析且與路徑分類一致" do
|
||||
# 本測試在 Linux 跑:readlink 一定有結果;stdin 非 TTY → 分類為非 TTY。
|
||||
assert {:ok, path} = call_fd0_path()
|
||||
assert TTY.stdin_tty?() == TTY.tty_path?(path)
|
||||
end
|
||||
end
|
||||
|
||||
defp call_fd0_path do
|
||||
case :file.read_link('/proc/self/fd/0') do
|
||||
{:ok, p} -> {:ok, to_string(p)}
|
||||
{:error, e} -> {:error, e}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,712 @@
|
||||
defmodule BearCli.VaultTest do
|
||||
@moduledoc """
|
||||
`bear vault` 指令群單元測試(issue #17):注入 fake API 與 fake IO,
|
||||
涵蓋解析、退出碼、session 傳遞、輸出,以及以 bear 倉庫
|
||||
Bear.Vault.Crypto 產生的密文樣本做交叉驗證(解密方向)。
|
||||
"""
|
||||
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BearCli.{CLI, Vault}
|
||||
alias BearCli.Vault.{BIP39, Crypto}
|
||||
|
||||
# -- 測試向量(bear 倉庫 Bear.Vault.Crypto 產生;低迭代數 1000)--
|
||||
|
||||
@email "alice@example.com"
|
||||
@master_password "hunter2-master"
|
||||
@iterations 1000
|
||||
|
||||
@mnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
@k_user_b64 "K/O8bAn/PY6dn/eDgrk+nygzsJqefb8X6dXJbsObc52vgwE59gJneAoisgLUVH27ZJzo0ZmZ9YHINm77GDBhvQ=="
|
||||
|
||||
# Bear.Vault.Crypto 產生的密文樣本(Bear 加密 → CLI 必須能解)
|
||||
@bear_fields %{
|
||||
"name" =>
|
||||
"2.d6+BTOM7a9cfjkwHuVwMiQ==.HjD0PvHOAvF3paiwPVYhEw==.PP1LIagMuk+K1abbVsacUuZ75D6encFDxqoPlpYdxX0=",
|
||||
"username" =>
|
||||
"2.vC7LtEADotrjHGCsjW5Uig==.IaNd0TABb1ho8A4G0pqA/2n0Na6cxQ0ZwoN4bKt2R6A=.x8QsmI0yJCcLEZV4gsCKAFHQeG1knlr/djc18oAGBJA=",
|
||||
"password" =>
|
||||
"2.nxg73aQIDN4xvpEMBk3dSQ==.Y3+Tb5FwJkG/MmdtcTu1K82yiyQHFFqZeqR/IhJaOq0=.8jixeUyAfq+pw/wHuywg63VCIOCqGx3MJDnPV33NozU=",
|
||||
"notes" =>
|
||||
"2.v5pif7yQpe2u3XAgH06Srw==.oDT6EqIuzuVcZca2oI4j2AJWQk70XmLWn0C9GY/NkMs=.ntfUvhrh8W879tdRM/VVBaRKJ9fwTvZBOKybZXK+1Ww=",
|
||||
"uri" =>
|
||||
"2.Dip98+nZdQa73RVoYxRXmg==.9IVFM9cxG6qLfdcvpPNNPnukpasXXVC3qnYaPQeBp+U=.5AdqSZcYL3vIxAOOkQPCszIwtgGbmTwMuk5rraJuCD4="
|
||||
}
|
||||
|
||||
@wrapped_password "2.7QpF/MDK1QxjxvHuW+InIw==.Ner6k/lJdta6eBfFICfBXpSeUTuOoABGKUp8dG9joah1dWrjazOJqW/9YPGgiAgYS25wif2WgtnQ5grXHY2xrjSvRclb0rdH1mSA/ublaVfVxKMFGZPnkeoQgB+FmjcP.iRDkb7+J67pVh/ywWiWP+bkZut2k4o7JrJfJG6izZzg="
|
||||
@wrapped_mnemonic "2.i73ZfZChxC+x8qIWtXRQ5w==.5gOWELJjoYm78kP6cXvUG1yR4X6T44c7sTHMp0om8RhljFa9dfGLCXaxTULLBHlJ/9EbrRj3Q3kUaG3/e6grAm37L4zEJZsXcETQS9J4tvHSh87lqGH+3lvMAsBlRpVp.ePbxk+weahIIaZdQlCRqHQ0KTnSinUOleuoUIMghlqE="
|
||||
|
||||
@folder_name_ct "2.uREiwRhNrOwjocwcGJrSXA==.vc5kZtTN/SfDJB0/3Qe5yg==.fVSBlhTtmpP0Lm8rbHT+n1ftomf3chV8pWwpK0Rwgig="
|
||||
@extra_ct "2.W1q2N0MclfjZvF0puu+g0A==.K3s0aX2La3XwsUu9cIwluQ==./ZrMME1LJoHjtev/xWXRwcPmIEiyN/Nq4QcauPiRWSw="
|
||||
|
||||
@profile %{
|
||||
"id" => "0193aaaa-0000-7000-8000-0000000000aa",
|
||||
"security_stamp" => "stamp-1",
|
||||
"kdf" => %{
|
||||
"iterations" => @iterations,
|
||||
"hash" => "SHA-512",
|
||||
"salt" => "account_email_lowercase"
|
||||
},
|
||||
"wrapped_user_password" => @wrapped_password,
|
||||
"wrapped_user_mnemonic" => @wrapped_mnemonic,
|
||||
"enabled" => true,
|
||||
"last_synced_at" => "2026-09-10T00:00:00Z"
|
||||
}
|
||||
|
||||
@folder %{
|
||||
"id" => "0193bbbb-0000-7000-8000-0000000000bb",
|
||||
"folder_uuid" => "folder-uuid-1",
|
||||
"name" => @folder_name_ct,
|
||||
"revision_date" => "2026-09-10T01:00:00Z"
|
||||
}
|
||||
|
||||
@cipher %{
|
||||
"id" => "0193cccc-0000-7000-8000-0000000000cc",
|
||||
"cipher_uuid" => "cipher-uuid-1",
|
||||
"cipher_type" => "login",
|
||||
"name" => @bear_fields["name"],
|
||||
"username" => @bear_fields["username"],
|
||||
"password" => @bear_fields["password"],
|
||||
"uris" => [@bear_fields["uri"]],
|
||||
"notes" => @bear_fields["notes"],
|
||||
"data" => %{"folder_uuid" => "folder-uuid-1", "extra" => @extra_ct},
|
||||
"favorite" => false,
|
||||
"reprompt" => 0,
|
||||
"deleted_at" => nil,
|
||||
"revision_date" => "2026-09-10T05:00:00Z"
|
||||
}
|
||||
|
||||
# -- fake API --
|
||||
|
||||
defmodule FakeApi do
|
||||
def error, do: Process.get(:fake_vault_error)
|
||||
def error(result), do: Process.put(:fake_vault_error, result)
|
||||
|
||||
def uploads, do: Process.get(:fake_vault_uploads, [])
|
||||
def push_upload(u), do: Process.put(:fake_vault_uploads, [u | uploads()])
|
||||
|
||||
def vault_config(_issuer, _token) do
|
||||
error() ||
|
||||
{:ok,
|
||||
%{
|
||||
"data" => %{
|
||||
"kdf" => %{
|
||||
"iterations" => 1000,
|
||||
"hash" => "SHA-512",
|
||||
"salt" => "account_email_lowercase"
|
||||
},
|
||||
"encryption_types" => ["2"]
|
||||
}
|
||||
}}
|
||||
end
|
||||
|
||||
def vault_profile_get(_issuer, _token) do
|
||||
error() || {:ok, %{"data" => profile()}}
|
||||
end
|
||||
|
||||
def vault_profile_put(_issuer, _token, attrs) do
|
||||
push_upload({:profile, attrs})
|
||||
{:ok, %{"data" => Map.merge(profile(), attrs)}}
|
||||
end
|
||||
|
||||
def vault_sync(_issuer, _token) do
|
||||
error() ||
|
||||
{:ok,
|
||||
%{
|
||||
"data" => %{
|
||||
"profile" => profile(),
|
||||
"folders" => [folder()],
|
||||
"ciphers" => [cipher()],
|
||||
"domains" => nil
|
||||
}
|
||||
}}
|
||||
end
|
||||
|
||||
def vault_ciphers_list(_issuer, _token) do
|
||||
error() || {:ok, %{"data" => [cipher()]}}
|
||||
end
|
||||
|
||||
def vault_ciphers_upsert(_issuer, _token, attrs) do
|
||||
push_upload({:cipher_upsert, attrs})
|
||||
{:ok, %{"data" => Map.merge(cipher(), attrs)}}
|
||||
end
|
||||
|
||||
def vault_ciphers_get(_issuer, _token, "cipher-uuid-1") do
|
||||
error() || {:ok, %{"data" => cipher()}}
|
||||
end
|
||||
|
||||
def vault_ciphers_get(_issuer, _token, _other) do
|
||||
error() || {:error, :not_found, "not_found"}
|
||||
end
|
||||
|
||||
def vault_ciphers_update(_issuer, _token, uuid, attrs) do
|
||||
push_upload({:cipher_update, uuid, attrs})
|
||||
{:ok, %{"data" => Map.merge(cipher(), attrs)}}
|
||||
end
|
||||
|
||||
def vault_ciphers_delete(_issuer, _token, uuid) do
|
||||
push_upload({:cipher_delete, uuid})
|
||||
{:ok, %{"data" => cipher()}}
|
||||
end
|
||||
|
||||
def vault_ciphers_restore(_issuer, _token, uuid) do
|
||||
push_upload({:cipher_restore, uuid})
|
||||
{:ok, %{"data" => cipher()}}
|
||||
end
|
||||
|
||||
def vault_ciphers_purge(_issuer, _token, uuid) do
|
||||
push_upload({:cipher_purge, uuid})
|
||||
{:ok, %{"data" => %{"deleted" => uuid}}}
|
||||
end
|
||||
|
||||
def vault_folders_list(_issuer, _token) do
|
||||
error() || {:ok, %{"data" => [folder()]}}
|
||||
end
|
||||
|
||||
def vault_folders_upsert(_issuer, _token, attrs) do
|
||||
push_upload({:folder_upsert, attrs})
|
||||
{:ok, %{"data" => Map.merge(folder(), attrs)}}
|
||||
end
|
||||
|
||||
def vault_folders_rename(_issuer, _token, uuid, attrs) do
|
||||
push_upload({:folder_rename, uuid, attrs})
|
||||
{:ok, %{"data" => Map.merge(folder(), attrs)}}
|
||||
end
|
||||
|
||||
def vault_folders_delete(_issuer, _token, uuid) do
|
||||
push_upload({:folder_delete, uuid})
|
||||
{:ok, %{"data" => %{"deleted" => uuid}}}
|
||||
end
|
||||
|
||||
defp profile, do: BearCli.VaultTest.profile_fixture()
|
||||
defp folder, do: BearCli.VaultTest.folder_fixture()
|
||||
defp cipher, do: BearCli.VaultTest.cipher_fixture()
|
||||
end
|
||||
|
||||
# -- fake IO(prompt_secret/1 不回顯;prompt/1 一般輸入)--
|
||||
|
||||
defmodule FakeIO do
|
||||
defp take(queue_key) do
|
||||
case Process.get(queue_key) do
|
||||
[] ->
|
||||
:eof
|
||||
|
||||
[next | rest] ->
|
||||
Process.put(queue_key, rest)
|
||||
{:ok, next}
|
||||
end
|
||||
end
|
||||
|
||||
def prompt_secret(_label), do: take(:fake_io_inputs)
|
||||
def prompt(_label), do: take(:fake_io_inputs)
|
||||
end
|
||||
|
||||
# -- 測試輔助 --
|
||||
|
||||
setup do
|
||||
old = System.get_env("BEAR_TOKEN")
|
||||
old_session = System.get_env("BEAR_VAULT_SESSION")
|
||||
old_creds = System.get_env("BEAR_CREDENTIALS")
|
||||
|
||||
on_exit(fn ->
|
||||
restore(old, "BEAR_TOKEN")
|
||||
restore(old_session, "BEAR_VAULT_SESSION")
|
||||
restore(old_creds, "BEAR_CREDENTIALS")
|
||||
end)
|
||||
|
||||
System.put_env("BEAR_TOKEN", "user-pat")
|
||||
System.put_env("BEAR_CREDENTIALS", "/nonexistent/credentials.json")
|
||||
System.delete_env("BEAR_VAULT_SESSION")
|
||||
:ok
|
||||
end
|
||||
|
||||
def profile_fixture, do: @profile
|
||||
def folder_fixture, do: @folder
|
||||
def cipher_fixture, do: @cipher
|
||||
|
||||
defp run_out(argv, inputs \\ [], session \\ nil) do
|
||||
Process.put(:fake_io_inputs, inputs)
|
||||
Process.put(:fake_vault_uploads, [])
|
||||
FakeApi.error(nil)
|
||||
|
||||
opts = [vault_api: FakeApi, io: FakeIO, tty?: true, email: @email] |> maybe_session(session)
|
||||
|
||||
ExUnit.CaptureIO.with_io(fn ->
|
||||
CLI.dispatch(CLI.parse(argv), opts)
|
||||
end)
|
||||
end
|
||||
|
||||
# 錯誤輸出在 stderr;以 with_io(:stderr) 捕捉。不重設 FakeApi.error
|
||||
# (呼叫者可先設定錯誤情境)。
|
||||
defp run_err(argv, inputs \\ [], session \\ nil) do
|
||||
Process.put(:fake_io_inputs, inputs)
|
||||
Process.put(:fake_vault_uploads, [])
|
||||
|
||||
opts = [vault_api: FakeApi, io: FakeIO, tty?: true, email: @email] |> maybe_session(session)
|
||||
|
||||
ExUnit.CaptureIO.with_io(:stderr, "", fn ->
|
||||
CLI.dispatch(CLI.parse(argv), opts)
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_session(opts, nil), do: opts
|
||||
defp maybe_session(opts, session), do: Keyword.put(opts, :session, session)
|
||||
|
||||
defp uploads, do: Process.get(:fake_vault_uploads, []) |> Enum.reverse()
|
||||
|
||||
defp restore(nil, key), do: System.delete_env(key)
|
||||
defp restore(value, key), do: System.put_env(key, value)
|
||||
|
||||
# -- 加密原語:與 bear(Bear.Vault.Crypto)交叉驗證 --
|
||||
|
||||
describe "crypto interop (Bear.Vault.Crypto samples)" do
|
||||
test "decrypts fields encrypted by Bear.Vault.Crypto" do
|
||||
k_user = Base.decode64!(@k_user_b64)
|
||||
|
||||
assert {:ok, "GitHub"} = Crypto.decrypt(@bear_fields["name"], k_user)
|
||||
assert {:ok, "s3cret-帕米拉"} = Crypto.decrypt(@bear_fields["password"], k_user)
|
||||
end
|
||||
|
||||
test "unwraps K_user encrypted by Bear.Vault.Crypto (password path)" do
|
||||
master = Crypto.derive_master_key(@master_password, @email, @iterations)
|
||||
assert {:ok, key} = Crypto.unwrap_user_key(@wrapped_password, master)
|
||||
assert Base.encode64(key) == @k_user_b64
|
||||
end
|
||||
|
||||
test "rejects wrong master password (MAC failure)" do
|
||||
master = Crypto.derive_master_key("wrong-password", @email, @iterations)
|
||||
assert {:error, :invalid} = Crypto.unwrap_user_key(@wrapped_password, master)
|
||||
end
|
||||
|
||||
test "rejects tampered MAC" do
|
||||
k_user = Base.decode64!(@k_user_b64)
|
||||
ct = Crypto.encrypt("hello", k_user)
|
||||
[pre, iv, c, _mac] = String.split(ct, ".")
|
||||
tampered = Enum.join([pre, iv, c, Base.encode64(:crypto.strong_rand_bytes(32))], ".")
|
||||
assert {:error, :invalid} = Crypto.decrypt(tampered, k_user)
|
||||
end
|
||||
end
|
||||
|
||||
# -- BIP39 --
|
||||
|
||||
describe "BIP39" do
|
||||
test "official vector: zero entropy → abandon…about" do
|
||||
assert BIP39.mnemonic_from_entropy(<<0::128>>) == @mnemonic
|
||||
end
|
||||
|
||||
test "official vector: mnemonic → seed" do
|
||||
seed = BIP39.mnemonic_to_seed(@mnemonic)
|
||||
|
||||
assert Base.encode16(seed, case: :lower) ==
|
||||
"5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
|
||||
end
|
||||
|
||||
test "valid?/1 accepts official vector and rejects bad checksum" do
|
||||
assert BIP39.valid?(@mnemonic)
|
||||
assert BIP39.valid?(String.upcase(" #{@mnemonic} "))
|
||||
# 改一個字 → 校驗和不符
|
||||
refute BIP39.valid?(String.replace(@mnemonic, "about", "abandon"))
|
||||
refute BIP39.valid?("not twelve words")
|
||||
end
|
||||
|
||||
test "rescue key path unwraps wrapped_mnemonic (Bear-produced)" do
|
||||
seed = BIP39.mnemonic_to_seed(@mnemonic)
|
||||
rescue_key = BIP39.rescue_key_from_seed(seed, @email, @iterations)
|
||||
assert {:ok, key} = Crypto.unwrap_user_key(@wrapped_mnemonic, rescue_key)
|
||||
assert Base.encode64(key) == @k_user_b64
|
||||
end
|
||||
end
|
||||
|
||||
# -- 解析與用法錯誤 --
|
||||
|
||||
describe "parsing" do
|
||||
test "unknown verb → 2" do
|
||||
{code, err} = run_err(["vault", "nope"])
|
||||
assert code == 2
|
||||
assert err =~ "未知的 vault 子指令"
|
||||
end
|
||||
|
||||
test "missing uuid → 2" do
|
||||
{code, err} = run_err(["vault", "get"])
|
||||
assert code == 2
|
||||
assert err =~ "缺少 <uuid>"
|
||||
end
|
||||
|
||||
test "create rejects bad --type → 2" do
|
||||
{code, err} = run_err(["vault", "create", "--type", "photo"])
|
||||
assert code == 2
|
||||
assert err =~ "--type"
|
||||
end
|
||||
|
||||
test "vault with no verb → 2" do
|
||||
{code, err} = run_err(["vault"])
|
||||
assert code == 2
|
||||
assert err =~ "缺少子指令"
|
||||
end
|
||||
end
|
||||
|
||||
# -- status / unlock / lock --
|
||||
|
||||
describe "status" do
|
||||
test "shows initialized + KDF + lock state" do
|
||||
{code, out} = run_out(["vault", "status"])
|
||||
assert code == 0
|
||||
assert out =~ "已初始化"
|
||||
assert out =~ "SHA-512"
|
||||
assert out =~ "未解鎖"
|
||||
end
|
||||
|
||||
test "--json reports initialized false on 404" do
|
||||
FakeApi.error({:error, :not_found, "not_found"})
|
||||
|
||||
{code, out} =
|
||||
ExUnit.CaptureIO.with_io(fn ->
|
||||
CLI.dispatch(CLI.parse(["vault", "status", "--json"]),
|
||||
vault_api: FakeApi,
|
||||
io: FakeIO,
|
||||
tty?: true,
|
||||
email: @email
|
||||
)
|
||||
end)
|
||||
|
||||
assert code == 0
|
||||
assert out =~ "\"initialized\":false"
|
||||
end
|
||||
end
|
||||
|
||||
describe "unlock" do
|
||||
test "correct master password → session env output" do
|
||||
{code, out} = run_out(["vault", "unlock"], [@master_password])
|
||||
assert code == 0
|
||||
assert out =~ "BEAR_VAULT_SESSION="
|
||||
assert out =~ @k_user_b64
|
||||
end
|
||||
|
||||
test "wrong master password → 1" do
|
||||
{code, err} = run_err(["vault", "unlock"], ["wrong-password"])
|
||||
assert code == 1
|
||||
assert err =~ "主密碼不正確"
|
||||
end
|
||||
end
|
||||
|
||||
describe "lock" do
|
||||
test "suggests unset when session present" do
|
||||
{code, out} = run_out(["vault", "lock"], [], @k_user_b64)
|
||||
assert code == 0
|
||||
assert out =~ "unset BEAR_VAULT_SESSION"
|
||||
end
|
||||
end
|
||||
|
||||
# -- list / get(解密顯示)--
|
||||
|
||||
describe "list" do
|
||||
test "locked: shows uuid/type without decrypting" do
|
||||
{code, out} = run_out(["vault", "list"])
|
||||
assert code == 0
|
||||
assert out =~ "cipher-uuid-1"
|
||||
refute out =~ "GitHub"
|
||||
end
|
||||
|
||||
test "unlocked: decrypts names and folder (Bear-encrypted samples)" do
|
||||
{code, out} = run_out(["vault", "list"], [], @k_user_b64)
|
||||
assert code == 0
|
||||
assert out =~ "GitHub"
|
||||
assert out =~ "工作"
|
||||
end
|
||||
|
||||
test "--folder filters by data.folder_uuid" do
|
||||
{code, out} = run_out(["vault", "list", "--folder", "folder-uuid-1"], [], @k_user_b64)
|
||||
assert code == 0
|
||||
assert out =~ "GitHub"
|
||||
|
||||
{code, out} = run_out(["vault", "list", "--folder", "no-such"], [], @k_user_b64)
|
||||
assert code == 0
|
||||
refute out =~ "GitHub"
|
||||
end
|
||||
end
|
||||
|
||||
describe "get" do
|
||||
test "unlocked: decrypts all fields (Bear-encrypted samples)" do
|
||||
{code, out} = run_out(["vault", "get", "cipher-uuid-1"], [], @k_user_b64)
|
||||
assert code == 0
|
||||
assert out =~ "GitHub"
|
||||
assert out =~ "alice@example.com"
|
||||
assert out =~ "s3cret-帕米拉"
|
||||
assert out =~ "https://github.com"
|
||||
end
|
||||
|
||||
test "locked: shows ciphertext state hint" do
|
||||
{code, out} = run_out(["vault", "get", "cipher-uuid-1"])
|
||||
assert code == 0
|
||||
assert out =~ "未解密"
|
||||
end
|
||||
|
||||
test "404 → 1" do
|
||||
{code, err} = run_err(["vault", "get", "missing-uuid"])
|
||||
assert code == 1
|
||||
assert err =~ "404"
|
||||
end
|
||||
end
|
||||
|
||||
# -- create(加密上傳)--
|
||||
|
||||
describe "create" do
|
||||
test "requires session → 2" do
|
||||
{code, err} = run_err(["vault", "create", "--type", "login"], [])
|
||||
assert code == 2
|
||||
assert err =~ "BEAR_VAULT_SESSION"
|
||||
end
|
||||
|
||||
test "login: encrypts fields client-side and uploads" do
|
||||
inputs = [
|
||||
"GitHub",
|
||||
"alice@example.com",
|
||||
"new-secret",
|
||||
"https://github.com,https://api.github.com",
|
||||
"備註"
|
||||
]
|
||||
|
||||
{code, out} =
|
||||
run_out(
|
||||
["vault", "create", "--type", "login", "--folder", "folder-uuid-1"],
|
||||
inputs,
|
||||
@k_user_b64
|
||||
)
|
||||
|
||||
assert code == 0
|
||||
assert out =~ "已建立"
|
||||
|
||||
assert [{:cipher_upsert, attrs}] = uploads()
|
||||
|
||||
# 欄位是加密字串(type 2),非明文
|
||||
assert attrs["name"] =~ ~r/^2\./
|
||||
assert attrs["username"] =~ ~r/^2\./
|
||||
assert attrs["password"] =~ ~r/^2\./
|
||||
assert length(attrs["uris"]) == 2
|
||||
assert attrs["data"]["folder_uuid"] == "folder-uuid-1"
|
||||
|
||||
# CLI 加密 → Bear/CLI 可解回(round-trip)
|
||||
assert {:ok, "GitHub"} = Crypto.decrypt(attrs["name"], Base.decode64!(@k_user_b64))
|
||||
assert {:ok, "new-secret"} = Crypto.decrypt(attrs["password"], Base.decode64!(@k_user_b64))
|
||||
end
|
||||
|
||||
test "secure_note: notes encrypted" do
|
||||
inputs = ["筆記標題", "內容一二三"]
|
||||
|
||||
{code, _out} = run_out(["vault", "create", "--type", "secure_note"], inputs, @k_user_b64)
|
||||
|
||||
assert code == 0
|
||||
assert [{:cipher_upsert, attrs}] = uploads()
|
||||
assert {:ok, "筆記標題"} = Crypto.decrypt(attrs["name"], Base.decode64!(@k_user_b64))
|
||||
assert {:ok, "內容一二三"} = Crypto.decrypt(attrs["notes"], Base.decode64!(@k_user_b64))
|
||||
end
|
||||
end
|
||||
|
||||
# -- edit --
|
||||
|
||||
describe "edit" do
|
||||
test "Enter keeps current values; changes re-encrypted" do
|
||||
# 依次:名稱(新值)、帳號(Enter 保留)、密碼(Enter 保留)、URI、備註
|
||||
inputs = ["新名字", "", "", "https://github.com", ""]
|
||||
|
||||
{code, out} = run_out(["vault", "edit", "cipher-uuid-1"], inputs, @k_user_b64)
|
||||
|
||||
assert code == 0
|
||||
assert out =~ "已更新"
|
||||
|
||||
assert [{:cipher_update, "cipher-uuid-1", attrs}] = uploads()
|
||||
assert {:ok, "新名字"} = Crypto.decrypt(attrs["name"], Base.decode64!(@k_user_b64))
|
||||
# 密碼 Enter 保留 → 重加密後解密仍為原明文
|
||||
assert {:ok, "s3cret-帕米拉"} = Crypto.decrypt(attrs["password"], Base.decode64!(@k_user_b64))
|
||||
end
|
||||
end
|
||||
|
||||
# -- delete / restore / purge --
|
||||
|
||||
describe "delete / restore / purge" do
|
||||
test "delete → trash endpoint" do
|
||||
{code, _out} = run_out(["vault", "delete", "cipher-uuid-1"])
|
||||
assert code == 0
|
||||
assert [{:cipher_delete, "cipher-uuid-1"}] = uploads()
|
||||
end
|
||||
|
||||
test "restore → restore endpoint" do
|
||||
{code, _out} = run_out(["vault", "restore", "cipher-uuid-1"])
|
||||
assert code == 0
|
||||
assert [{:cipher_restore, "cipher-uuid-1"}] = uploads()
|
||||
end
|
||||
|
||||
test "purge requires y confirmation" do
|
||||
{code, _out} = run_out(["vault", "purge", "cipher-uuid-1"], ["n"])
|
||||
assert code == 1
|
||||
assert uploads() == []
|
||||
|
||||
{code, _out} = run_out(["vault", "purge", "cipher-uuid-1"], ["y"])
|
||||
assert code == 0
|
||||
assert [{:cipher_purge, "cipher-uuid-1"}] = uploads()
|
||||
end
|
||||
end
|
||||
|
||||
# -- folders --
|
||||
|
||||
describe "folders" do
|
||||
test "list unlocked decrypts names (Bear-encrypted sample)" do
|
||||
{code, out} = run_out(["vault", "folders", "list"], [], @k_user_b64)
|
||||
assert code == 0
|
||||
assert out =~ "folder-uuid-1"
|
||||
assert out =~ "工作"
|
||||
end
|
||||
|
||||
test "create encrypts name" do
|
||||
{code, _out} = run_out(["vault", "folders", "create"], ["新資料夾"], @k_user_b64)
|
||||
assert code == 0
|
||||
assert [{:folder_upsert, attrs}] = uploads()
|
||||
assert {:ok, "新資料夾"} = Crypto.decrypt(attrs["name"], Base.decode64!(@k_user_b64))
|
||||
end
|
||||
|
||||
test "rename encrypts new name" do
|
||||
{code, _out} = run_out(["vault", "folders", "rename", "folder-uuid-1"], ["改名"], @k_user_b64)
|
||||
assert code == 0
|
||||
assert [{:folder_rename, "folder-uuid-1", %{"name" => ct}}] = uploads()
|
||||
assert {:ok, "改名"} = Crypto.decrypt(ct, Base.decode64!(@k_user_b64))
|
||||
end
|
||||
|
||||
test "delete folder" do
|
||||
{code, _out} = run_out(["vault", "folders", "delete", "folder-uuid-1"])
|
||||
assert code == 0
|
||||
assert [{:folder_delete, "folder-uuid-1"}] = uploads()
|
||||
end
|
||||
end
|
||||
|
||||
# -- sync --
|
||||
|
||||
describe "sync" do
|
||||
test "reports counts from server payload" do
|
||||
{code, out} = run_out(["vault", "sync"])
|
||||
assert code == 0
|
||||
assert out =~ "資料夾:1 個"
|
||||
assert out =~ "項目:1 個"
|
||||
end
|
||||
|
||||
test "--json echoes payload" do
|
||||
{code, out} = run_out(["vault", "sync", "--json"])
|
||||
assert code == 0
|
||||
assert out =~ "\"ciphers\""
|
||||
end
|
||||
end
|
||||
|
||||
# -- password change / rescue --
|
||||
|
||||
describe "password change" do
|
||||
test "re-wraps K_user under new master password (ciphers untouched)" do
|
||||
inputs = [@master_password, "new-master-88", "new-master-88"]
|
||||
|
||||
{code, out} = run_out(["vault", "password", "change"], inputs)
|
||||
|
||||
assert code == 0
|
||||
assert out =~ "主密碼已更新"
|
||||
|
||||
assert [{:profile, %{"wrapped_user_password" => wrapped}}] = uploads()
|
||||
|
||||
# 新包裝可用新主密碼解開,且解出同一把 K_user
|
||||
new_master = Crypto.derive_master_key("new-master-88", @email, @iterations)
|
||||
assert {:ok, key} = Crypto.unwrap_user_key(wrapped, new_master)
|
||||
assert Base.encode64(key) == @k_user_b64
|
||||
|
||||
# 只動 wrapped_user_password
|
||||
assert map_size(Process.get(:fake_vault_uploads) |> hd() |> elem(1)) == 1
|
||||
end
|
||||
|
||||
test "mismatched new passwords → 2" do
|
||||
{code, err} =
|
||||
run_err(["vault", "password", "change"], [@master_password, "aaaabbbb", "ccccdddd"])
|
||||
|
||||
assert code == 2
|
||||
assert err =~ "不一致"
|
||||
end
|
||||
|
||||
test "short new password → 2" do
|
||||
{code, err} = run_err(["vault", "password", "change"], [@master_password, "short", "short"])
|
||||
assert code == 2
|
||||
assert err =~ "至少需要"
|
||||
end
|
||||
end
|
||||
|
||||
describe "rescue" do
|
||||
test "unwraps via mnemonic and sets new master password" do
|
||||
inputs = [@mnemonic, "brand-new-99", "brand-new-99"]
|
||||
|
||||
{code, out} = run_out(["vault", "rescue"], inputs)
|
||||
|
||||
assert code == 0
|
||||
assert out =~ "重設主密碼"
|
||||
|
||||
assert [{:profile, %{"wrapped_user_password" => wrapped}}] = uploads()
|
||||
|
||||
new_master = Crypto.derive_master_key("brand-new-99", @email, @iterations)
|
||||
assert {:ok, key} = Crypto.unwrap_user_key(wrapped, new_master)
|
||||
assert Base.encode64(key) == @k_user_b64
|
||||
end
|
||||
|
||||
test "invalid mnemonic checksum → 1" do
|
||||
bad = String.replace(@mnemonic, "about", "abandon")
|
||||
{code, err} = run_err(["vault", "rescue"], [bad])
|
||||
assert code == 1
|
||||
assert err =~ "助記詞"
|
||||
end
|
||||
|
||||
test "wrong-but-valid mnemonic → 1 (unwrap fails)" do
|
||||
# 另一組合法助記詞,但解不開 wrapped_user_mnemonic
|
||||
other = BIP39.generate()
|
||||
{code, err} = run_err(["vault", "rescue"], [other, "brand-new-99", "brand-new-99"])
|
||||
assert code == 1
|
||||
assert err =~ "助記詞"
|
||||
end
|
||||
end
|
||||
|
||||
# -- API 錯誤分流 --
|
||||
|
||||
describe "api errors" do
|
||||
test "401 → 3" do
|
||||
FakeApi.error({:error, :unauthorized, "invalid_token"})
|
||||
{code, err} = run_err(["vault", "list"])
|
||||
assert code == 3
|
||||
assert err =~ "重新 bear login"
|
||||
end
|
||||
|
||||
test "403 → 8" do
|
||||
FakeApi.error({:error, :forbidden, "forbidden"})
|
||||
{code, err} = run_err(["vault", "list"])
|
||||
assert code == 8
|
||||
end
|
||||
|
||||
test "network → 6" do
|
||||
FakeApi.error({:error, :network, "econnrefused"})
|
||||
{code, err} = run_err(["vault", "list"])
|
||||
assert code == 6
|
||||
end
|
||||
end
|
||||
|
||||
# -- 非 TTY --
|
||||
|
||||
describe "non-tty interactive" do
|
||||
test "unlock without tty → 2" do
|
||||
Process.put(:fake_io_inputs, [@master_password])
|
||||
FakeApi.error(nil)
|
||||
|
||||
{code, err} =
|
||||
ExUnit.CaptureIO.with_io(:stderr, "", fn ->
|
||||
CLI.dispatch(CLI.parse(["vault", "unlock"]),
|
||||
vault_api: FakeApi,
|
||||
io: FakeIO,
|
||||
tty?: false,
|
||||
email: @email
|
||||
)
|
||||
end)
|
||||
|
||||
assert code == 2
|
||||
assert err =~ "互動輸入"
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user