From 0b18d76cfda669aebef950c166596d4fe7e0fd3d Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 10 Sep 2026 23:27:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=A6=E4=BD=9C=20vault=20=E6=8C=87?= =?UTF-8?q?=E4=BB=A4=E7=BE=A4=20status/unlock/lock/list/get/create/edit/de?= =?UTF-8?q?lete/restore/purge/folders/sync/password/rescue=EF=BC=88issue?= =?UTF-8?q?=20#17=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 對接 alterminal/bear#41 的 /api/v1/vault JSON API(PAT Bearer) - 客戶端端到端加密,格式與 Web Vault(P2)完全一致: - 加密字串 2...(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 --- README.md | 10 +- docs/commands.md | 98 ++ lib/bear_cli/api.ex | 81 ++ lib/bear_cli/cli.ex | 43 +- lib/bear_cli/vault.ex | 1269 ++++++++++++++++++++++++++ lib/bear_cli/vault/bip39.ex | 139 +++ lib/bear_cli/vault/bip39_wordlist.ex | 20 + lib/bear_cli/vault/crypto.ex | 118 +++ test/bear_cli/vault_test.exs | 712 +++++++++++++++ 9 files changed, 2488 insertions(+), 2 deletions(-) create mode 100644 lib/bear_cli/vault.ex create mode 100644 lib/bear_cli/vault/bip39.ex create mode 100644 lib/bear_cli/vault/bip39_wordlist.ex create mode 100644 lib/bear_cli/vault/crypto.ex create mode 100644 test/bear_cli/vault_test.exs diff --git a/README.md b/README.md index 713e9cf..8788d7f 100644 --- a/README.md +++ b/README.md @@ -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 `、`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 `、`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 | ### 建置與執行 diff --git a/docs/commands.md b/docs/commands.md index e557706..7e48c38 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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..."`: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=` 匯入後,同一 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 [--json] +bear vault create --type login|secure_note|card|identity [--folder UUID] [--json] +bear vault edit [--json] +bear vault delete [--json] +bear vault restore [--json] +bear vault purge [--json] +bear vault folders list|create|rename |delete [--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": "", "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 ` + +`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 ` + +`GET` 現值 → 解密 → 逐欄提示(**直接 Enter=保留現值**;密碼欄不回顧)→ 僅重加密有變更的欄位 → `PUT /api/v1/vault/ciphers/{uuid}`。需要解鎖。 + +#### 3.11.8 `bear vault delete/restore/purge ` + +`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 |delete ` + +資料夾 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. 退出碼總表 diff --git a/lib/bear_cli/api.ex b/lib/bear_cli/api.ex index e9db0f1..ed68d72 100644 --- a/lib/bear_cli/api.ex +++ b/lib/bear_cli/api.ex @@ -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 + # -- 共用請求輔助 -- # 回傳: diff --git a/lib/bear_cli/cli.ex b/lib/bear_cli/cli.ex index c324227..dcfcc29 100644 --- a/lib/bear_cli/cli.ex +++ b/lib/bear_cli/cli.ex @@ -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 顯示單一項目(解鎖後解密欄位) + vault create --type login|secure_note|card|identity [--folder UUID] + vault edit 互動編輯(Enter 保留現值) + vault delete 丟入回收桶 + vault restore 從回收桶還原 + vault purge 永久刪除(需確認) + vault folders list|create|rename |delete + vault sync 完整同步(伺服器為準) + vault password change 變更 vault 主密碼 + vault rescue 助記詞救援(重設主密碼) 全域選項: --issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com) diff --git a/lib/bear_cli/vault.ex b/lib/bear_cli/vault.ex new file mode 100644 index 0000000..5f30298 --- /dev/null +++ b/lib/bear_cli/vault.ex @@ -0,0 +1,1269 @@ +defmodule BearCli.Vault do + @moduledoc """ + `bear vault` 指令群(issue #17、docs/commands.md §3.11)。 + + 對接 alterminal/bear 的 `/api/v1/vault` JSON API(PAT Bearer, + alterminal/bear#41)。端到端加密全在本模組與 `BearCli.Vault.Crypto`/ + `BearCli.Vault.BIP39` 完成,格式與 Web Vault(P2)完全一致: + + - 加密字串 `"2..."`(AES-256-CBC+HMAC-SHA-256、 + encrypt-then-MAC) + - 主金鑰 PBKDF2-SHA512(迭代數以 `GET /api/v1/vault/config` 公告與 + profile 記錄為準,不寫死;salt=帳號 email 小寫) + - 助記詞 BIP39(12 字、128-bit 熵、英文詞表) + + **K_user 僅存記憶體**(不寫憑證檔/任何磁碟位置):`vault unlock` 解開 + K_user 後以 session 環境變數 `BEAR_VAULT_SESSION`(base64)交給後續 + 指令(同 Bitwarden CLI 的 `BW_SESSION` 慣例;環境變數只存在於 shell + 記憶體,不落盤)。`vault lock` 指示丟棄(unset)。 + + - 主密碼/助記詞以互動提示讀取(不回顧、不進 shell history、不接受 + 命令列明文參數);非 TTY 環境 → 退出碼 2。 + - 助記詞只在 Web Vault 設定時顯示一次;CLI 不提供重新顯示,僅提供 + `vault rescue`(助記詞救援重設主密碼)。 + - 退出碼:0 成功;1 一般錯誤(404/422、主密碼/助記詞錯誤);2 用法 + 錯誤(含非 TTY);3 未登入或 401;6 網路/伺服器錯誤;8 權限不足。 + """ + + alias BearCli.{Api, Config, Credentials} + alias BearCli.Vault.{BIP39, Crypto} + + @session_env "BEAR_VAULT_SESSION" + + @verbs ~w(status unlock lock list get create edit delete restore purge folders sync password rescue) + @folder_verbs ~w(list create rename delete) + @cipher_types ~w(login secure_note card identity) + + @folder_switches [folder: :string] + @create_switches [type: :string, folder: :string, email: :string] + + # -- 解析(由 BearCli.CLI 轉入)-- + + @doc """ + 解析 `vault` 子指令樹,回 `{:ok, {BearCli.Vault, verb}, opts}`、 + `{:ok, {:vault_folders, verb}, opts}` 或 `{:error, message, 2}`。 + """ + def parse(["vault" | args], global) do + global_switches = [ + issuer: :string, + config: :string, + json: :boolean, + verbose: :boolean, + help: :boolean, + version: :boolean, + email: :string + ] + + case args do + ["folders", verb | rest] when verb in @folder_verbs -> + case OptionParser.parse(rest, strict: global_switches) do + {opts, positionals, []} -> + validate_folders(verb, positionals, merge(global, opts)) + + {_opts, _extra, [{opt, _} | _]} -> + {:error, "無法解析選項:#{opt}", 2} + end + + ["password", "change" | rest] -> + case OptionParser.parse(rest, strict: global_switches) do + {opts, [], []} -> + {:ok, {__MODULE__, :password_change}, merge(global, opts)} + + {_opts, extra, []} -> + {:error, "vault password change 不接受參數:#{Enum.join(extra, " ")}", 2} + + {_opts, _extra, [{opt, _} | _]} -> + {:error, "無法解析選項:#{opt}", 2} + end + + ["--help"] -> + {:help, :vault} + + ["help"] -> + {:help, :vault} + + [verb | rest] when verb in @verbs -> + switches = verb_switches(verb) + + case OptionParser.parse(rest, strict: global_switches ++ switches) do + {opts, positionals, []} -> + validate(verb, positionals, merge(global, opts)) + + {_opts, _extra, [{opt, _} | _]} -> + {:error, "無法解析選項:#{opt}", 2} + end + + [] -> + {:error, "vault 缺少子指令(可用:#{Enum.join(@verbs, " ")})", 2} + + [unknown | _] -> + {:error, "未知的 vault 子指令:#{unknown}(可用:#{Enum.join(@verbs, " ")})", 2} + end + end + + defp verb_switches("list"), do: @folder_switches + defp verb_switches("create"), do: @create_switches + defp verb_switches(_), do: [] + + defp merge(global, opts), do: Map.merge(Map.new(global), Map.new(opts)) + + # -- 各動詞的位置參數驗證(用法錯誤 → 2)-- + + defp validate("status", [], opts), do: {:ok, {__MODULE__, :status}, opts} + defp validate("status", extra, _), do: extra_error("vault status", extra) + + defp validate("unlock", [], opts), do: {:ok, {__MODULE__, :unlock}, opts} + defp validate("unlock", extra, _), do: extra_error("vault unlock", extra) + + defp validate("lock", [], opts), do: {:ok, {__MODULE__, :lock}, opts} + defp validate("lock", extra, _), do: extra_error("vault lock", extra) + + defp validate("list", [], opts), do: {:ok, {__MODULE__, :list}, opts} + defp validate("list", extra, _), do: extra_error("vault list", extra) + + defp validate("get", [uuid], opts), + do: {:ok, {__MODULE__, :get}, Map.put(opts, :uuid_arg, uuid)} + + defp validate("get", [], _), do: {:error, "vault get 缺少 ", 2} + defp validate("get", extra, _), do: single_error("vault get", extra) + + defp validate("create", [], opts) do + type = opts[:type] + + cond do + blank?(type) -> + {:error, "vault create 缺少 --type #{Enum.join(@cipher_types, "|")}", 2} + + type not in @cipher_types -> + {:error, "--type 不接受的值:#{type}(可用:#{Enum.join(@cipher_types, "、")})", 2} + + true -> + {:ok, {__MODULE__, :create}, opts} + end + end + + defp validate("create", extra, _), do: extra_error("vault create", extra) + + defp validate("edit", [uuid], opts), + do: {:ok, {__MODULE__, :edit}, Map.put(opts, :uuid_arg, uuid)} + + defp validate("edit", [], _), do: {:error, "vault edit 缺少 ", 2} + defp validate("edit", extra, _), do: single_error("vault edit", extra) + + defp validate("delete", [uuid], opts), + do: {:ok, {__MODULE__, :delete}, Map.put(opts, :uuid_arg, uuid)} + + defp validate("delete", [], _), do: {:error, "vault delete 缺少 ", 2} + defp validate("delete", extra, _), do: single_error("vault delete", extra) + + defp validate("restore", [uuid], opts), + do: {:ok, {__MODULE__, :restore}, Map.put(opts, :uuid_arg, uuid)} + + defp validate("restore", [], _), do: {:error, "vault restore 缺少 ", 2} + defp validate("restore", extra, _), do: single_error("vault restore", extra) + + defp validate("purge", [uuid], opts), + do: {:ok, {__MODULE__, :purge}, Map.put(opts, :uuid_arg, uuid)} + + defp validate("purge", [], _), do: {:error, "vault purge 缺少 ", 2} + defp validate("purge", extra, _), do: single_error("vault purge", extra) + + defp validate("sync", [], opts), do: {:ok, {__MODULE__, :sync}, opts} + defp validate("sync", extra, _), do: extra_error("vault sync", extra) + + defp validate("rescue", [], opts), do: {:ok, {__MODULE__, :rescue}, opts} + defp validate("rescue", extra, _), do: extra_error("vault rescue", extra) + + defp validate_folders("list", [], opts), do: {:ok, {__MODULE__, :folders_list}, opts} + defp validate_folders("list", extra, _), do: extra_error("vault folders list", extra) + + defp validate_folders("create", [], opts), do: {:ok, {__MODULE__, :folders_create}, opts} + defp validate_folders("create", extra, _), do: extra_error("vault folders create", extra) + + defp validate_folders("rename", [uuid], opts), + do: {:ok, {__MODULE__, :folders_rename}, Map.put(opts, :uuid_arg, uuid)} + + defp validate_folders("rename", [], _), do: {:error, "vault folders rename 缺少 ", 2} + defp validate_folders("rename", extra, _), do: single_error("vault folders rename", extra) + + defp validate_folders("delete", [uuid], opts), + do: {:ok, {__MODULE__, :folders_delete}, Map.put(opts, :uuid_arg, uuid)} + + defp validate_folders("delete", [], _), do: {:error, "vault folders delete 缺少 ", 2} + defp validate_folders("delete", extra, _), do: single_error("vault folders delete", extra) + + defp extra_error(cmd, extra), + do: {:error, "#{cmd} 不接受位置參數:#{Enum.join(extra, " ")}", 2} + + defp single_error(cmd, extra), + do: {:error, "#{cmd} 只接受一個 (多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2} + + # -- 執行入口 -- + + @doc """ + 執行 vault 指令,回傳退出碼。`opts[:vault_api]` 可注入假 API 模組、 + `opts[:io]` 注入互動輸入(`prompt_secret/1` 與 `prompt/2`)、 + `opts[:tty?]` 覆寫 TTY 判定、`opts[:session]` 注入 session 金鑰供測試。 + """ + def run(:help, _opts) do + IO.puts(""" + bear vault — 密碼管理指令群(docs/commands.md §3.11) + + 用法:bear vault <子指令> [選項] + + 子指令: + status 初始化狀態、KDF 參數、解鎖狀態 + unlock 主密碼解鎖 → 匯出 BEAR_VAULT_SESSION(K_user 僅記憶體) + lock 丟棄 session(unset BEAR_VAULT_SESSION) + list [--folder UUID] 列出項目(解鎖後顯示明文名稱) + get 顯示單一項目(解鎖後解密欄位) + create --type login|secure_note|card|identity [--folder UUID] + edit 互動編輯(Enter 保留現值) + delete 丟入回收桶 + restore 從回收桶還原 + purge 永久刪除(需確認) + folders list|create|rename |delete + sync 完整同步(伺服器為準) + password change 變更 vault 主密碼 + rescue 助記詞救援(重設主密碼) + """) + + 0 + end + + def run(verb, opts) do + api = opts[:vault_api] || Api + + with {:ok, ctx} <- context(opts, api) do + execute(verb, ctx, opts, api) + else + {:error, message, code} -> fail(opts, message, code) + end + end + + # -- 各指令實作 -- + + defp execute(:status, ctx, opts, api) do + # 先看 profile(404=未初始化);config 取不到時以 profile 的 KDF 為準。 + with {:ok, profile} <- fetch_profile(ctx, opts, api), + {:ok, config} <- fetch_config(ctx, opts, api) do + kdf = profile["kdf"] || %{} + iterations = kdf["iterations"] + + if opts[:json] do + IO.puts( + Jason.encode!(%{ + ok: true, + initialized: true, + enabled: profile["enabled"], + kdf: kdf, + iterations: iterations, + encryption_types: config["encryption_types"], + security_stamp: profile["security_stamp"], + last_synced_at: profile["last_synced_at"], + unlocked: session_key(opts) != nil + }) + ) + else + IO.puts("Vault 狀態:已初始化#{if profile["enabled"] == false, do: "(已停用)", else: ""}") + IO.puts("KDF:#{kdf["hash"] || "?"}/#{iterations || "?"} 迭代(salt=帳號 email 小寫)") + IO.puts("上次同步:#{profile["last_synced_at"] || "(從未)"}") + + IO.puts( + "解鎖狀態:#{if session_key(opts), do: "已解鎖(本 shell)", else: "未解鎖(bear vault unlock)"}" + ) + end + + 0 + else + {:error, :not_initialized} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, initialized: false, unlocked: false})) + else + IO.puts("Vault 狀態:尚未初始化(請至 Web Vault /vault 頁面設定主密碼與助記詞)") + end + + 0 + + {:error, message, code} -> + fail(opts, message, code) + end + end + + defp execute(:unlock, ctx, opts, api) do + with {:ok, profile} <- fetch_profile(ctx, opts, api), + {:ok, k_user} <- unlock_with_password(ctx, profile, opts, api) do + session = Base.encode64(k_user) + + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, session: session, env: @session_env})) + else + IO.puts("已解鎖(K_user 僅存本 shell 記憶體;以下指令匯入 session):") + IO.puts(" export #{@session_env}=#{session}") + IO.puts("或一次性使用:#{@session_env}=<…> bear vault list") + IO.puts("鎖定:bear vault lock(unset #{@session_env})") + end + + 0 + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:lock, _ctx, opts, _api) do + cond do + opts[:session] || System.get_env(@session_env) -> + IO.puts("請執行:unset #{@session_env}") + IO.puts("(K_user 只存在 shell 環境變數,unset 即丟棄)") + 0 + + true -> + IO.puts("目前 shell 未持有 session(#{@session_env} 未設定)。") + 0 + end + end + + defp execute(:list, ctx, opts, api) do + with {:ok, body} <- wrap_api(api.vault_ciphers_list(ctx.issuer, ctx.token), ctx, opts), + {:ok, folders_body} <- wrap_api(api.vault_folders_list(ctx.issuer, ctx.token), ctx, opts) do + ciphers = body["data"] || [] + folders = folders_body["data"] || [] + k_user = session_key(opts) + + folder_names = + if k_user do + Map.new(folders, fn f -> + {f["folder_uuid"], decrypt_to(f["name"], k_user, "(無法解密)")} + end) + else + %{} + end + + ciphers + |> Enum.filter(fn c -> + case opts[:folder] do + nil -> true + folder_uuid -> (c["data"] || %{})["folder_uuid"] == folder_uuid + end + end) + |> Enum.map(fn c -> cipher_row(c, k_user, folder_names) end) + |> render_rows(["UUID", "NAME", "TYPE", "FOLDER", "UPDATED", "STATE"]) + + if opts[:json] do + IO.puts( + Jason.encode!(%{ + ok: true, + decrypted: k_user != nil, + ciphers: Enum.map(ciphers, &cipher_json(&1, k_user, folder_names)) + }) + ) + end + + if ciphers == [] and not json?(opts) do + IO.puts("(無資料)") + end + + if k_user == nil and ciphers != [] and not json?(opts) do + IO.puts("(名稱未解密;先 bear vault unlock 匯入 session)") + end + + 0 + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:get, ctx, opts, api) do + uuid = opts[:uuid_arg] + + with {:ok, body} <- wrap_api(api.vault_ciphers_get(ctx.issuer, ctx.token, uuid), ctx, opts) do + cipher = body["data"] || %{} + k_user = session_key(opts) + + if opts[:json] do + IO.puts( + Jason.encode!(%{ + ok: true, + decrypted: k_user != nil, + cipher: cipher_json(cipher, k_key_or_map(cipher, k_user)) + }) + ) + else + print_cipher(cipher, k_key_or_map(cipher, k_user)) + if k_user == nil, do: IO.puts("(欄位未解密;先 bear vault unlock 匯入 session)") + end + + 0 + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:create, ctx, opts, api) do + with {:ok, k_user} <- require_session(opts), + {:ok, attrs} <- prompt_cipher_fields(opts, opts[:type]) do + data = + case opts[:folder] do + nil -> %{} + folder_uuid -> %{"folder_uuid" => folder_uuid} + end + + attrs = + Map.merge(attrs, %{ + "cipher_uuid" => new_uuid(), + "cipher_type" => opts[:type], + "data" => data, + "favorite" => false, + "reprompt" => 0 + }) + |> encrypt_attrs(k_user) + + case api.vault_ciphers_upsert(ctx.issuer, ctx.token, attrs) do + {:ok, body} -> + cipher = body["data"] || %{} + + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, cipher: cipher})) + else + IO.puts("已建立:#{cipher["cipher_uuid"]}(#{opts[:type]})") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:edit, ctx, opts, api) do + uuid = opts[:uuid_arg] + + with {:ok, k_user} <- require_session(opts), + {:ok, body} <- wrap_api(api.vault_ciphers_get(ctx.issuer, ctx.token, uuid), ctx, opts), + {:ok, attrs} <- prompt_edit_fields(opts, body["data"] || %{}, k_user) do + case api.vault_ciphers_update(ctx.issuer, ctx.token, uuid, encrypt_attrs(attrs, k_user)) do + {:ok, _body} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, updated: uuid})) + else + IO.puts("已更新:#{uuid}") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:delete, ctx, opts, api) do + uuid = opts[:uuid_arg] + + case api.vault_ciphers_delete(ctx.issuer, ctx.token, uuid) do + {:ok, _body} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, trashed: uuid})) + else + IO.puts("已丟入回收桶:#{uuid}(還原:bear vault restore #{uuid};永久刪除:bear vault purge #{uuid})") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + end + + defp execute(:restore, ctx, opts, api) do + uuid = opts[:uuid_arg] + + case api.vault_ciphers_restore(ctx.issuer, ctx.token, uuid) do + {:ok, _body} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, restored: uuid})) + else + IO.puts("已從回收桶還原:#{uuid}") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + end + + defp execute(:purge, ctx, opts, api) do + uuid = opts[:uuid_arg] + + with :ok <- confirm_purge(opts, uuid) do + case api.vault_ciphers_purge(ctx.issuer, ctx.token, uuid) do + {:ok, _body} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, purged: uuid})) + else + IO.puts("已永久刪除:#{uuid}") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:folders_list, ctx, opts, api) do + with {:ok, body} <- wrap_api(api.vault_folders_list(ctx.issuer, ctx.token), ctx, opts) do + folders = body["data"] || [] + k_user = session_key(opts) + + rows = + Enum.map(folders, fn f -> + name = + if k_user, + do: decrypt_to(f["name"], k_key(f["name"], k_user), "(無法解密)"), + else: "(未解密)" + + [f["folder_uuid"], name, f["revision_date"] || ""] + end) + + render_rows(rows, ["UUID", "NAME", "REVISED"]) + + if opts[:json] do + IO.puts( + Jason.encode!(%{ + ok: true, + decrypted: k_key_any(folders, k_user) != nil, + folders: + Enum.map(folders, fn f -> + %{ + "uuid" => f["folder_uuid"], + "name" => if(k_user, do: decrypt_to(f["name"], k_user, nil), else: nil) + } + end) + }) + ) + end + + if folders == [] and not opts[:json], do: IO.puts("(無資料)") + + if k_user == nil and folders != [] and not opts[:json], + do: IO.puts("(名稱未解密;先 bear vault unlock)") + + 0 + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:folders_create, ctx, opts, api) do + with {:ok, k_user} <- require_session(opts), + {:ok, name} <- prompt(opts, "資料夾名稱"), + :ok <- require_nonempty(name) do + attrs = %{"folder_uuid" => new_uuid(), "name" => Crypto.encrypt(name, k_user)} + + case api.vault_folders_upsert(ctx.issuer, ctx.token, attrs) do + {:ok, body} -> + folder = body["data"] || %{} + + if opts[:json] do + IO.puts( + Jason.encode!(%{ + ok: true, + folder: %{"uuid" => folder["folder_uuid"], "name" => name} + }) + ) + else + IO.puts("已建立資料夾:#{folder["folder_uuid"]}") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:folders_rename, ctx, opts, api) do + uuid = opts[:uuid_arg] + + with {:ok, k_user} <- require_session(opts), + {:ok, name} <- prompt(opts, "新的資料夾名稱"), + :ok <- require_nonempty(name) do + case api.vault_folders_rename(ctx.issuer, ctx.token, uuid, %{ + "name" => Crypto.encrypt(name, k_user) + }) do + {:ok, _body} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, renamed: uuid})) + else + IO.puts("已重新命名:#{uuid}") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:folders_delete, ctx, opts, api) do + uuid = opts[:uuid_arg] + + case api.vault_folders_delete(ctx.issuer, ctx.token, uuid) do + {:ok, _body} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, deleted: uuid})) + else + IO.puts("已刪除資料夾:#{uuid}(所屬項目不受影響,僅失去分類)") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + end + + defp execute(:sync, ctx, opts, api) do + with {:ok, body} <- wrap_api(api.vault_sync(ctx.issuer, ctx.token), ctx, opts) do + data = body["data"] || %{} + profile = data["profile"] || %{} + folders = data["folders"] || [] + ciphers = data["data"] |> List.wrap() |> Enum.map(&(Map.get(&1, "data") || &1)) + cipher_list = data["ciphers"] || [] + + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true, sync: data})) + else + IO.puts("同步完成(伺服器為準):") + IO.puts(" security_stamp:#{profile["security_stamp"] || "(未知)"}") + IO.puts(" 資料夾:#{length(folders)} 個") + IO.puts(" 項目:#{length(cipher_list)} 個(含回收桶)") + IO.puts(" last_synced_at:#{profile["last_synced_at"] || "(首次)"}") + end + + _ = ciphers + 0 + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:password_change, ctx, opts, api) do + with {:ok, profile} <- fetch_profile(ctx, opts, api), + {:ok, k_user} <- unlock_with_password(ctx, profile, opts, api), + {:ok, new_password} <- read_new_password(opts), + true <- byte_size(new_password) >= 8 || {:error, "新主密碼至少需要 8 個字元", 2} do + iterations = iterations_for(profile, ctx, opts, api) + + case iterations do + {:ok, iterations} -> + new_master = Crypto.derive_master_key(new_password, ctx.email, iterations) + wrapped = Crypto.wrap_user_key(k_user, new_master) + + case api.vault_profile_put(ctx.issuer, ctx.token, %{"wrapped_user_password" => wrapped}) do + {:ok, _body} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true})) + else + IO.puts("主密碼已更新;既有資料無需重新加密(K_user 未變)。") + IO.puts("提醒:新的 session 匯入請重新 bear vault unlock。") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + + {:error, message, code} -> + fail(opts, message, code) + end + else + {:error, message, code} -> fail(opts, message, code) + end + end + + defp execute(:rescue, ctx, opts, api) do + with {:ok, profile} <- fetch_profile(ctx, opts, api), + {:ok, mnemonic} <- prompt_secret(opts, "助記詞(12 字,空格分隔)"), + true <- BIP39.valid?(mnemonic) || {:error, "助記詞無效(字數/詞表/校驗和檢查未過)", 1}, + {:ok, new_password} <- read_new_password(opts), + true <- byte_size(new_password) >= 8 || {:error, "新主密碼至少需要 8 個字元", 2}, + {:ok, iterations} <- iterations_for(profile, ctx, opts, api), + wrapped_mnemonic when is_binary(wrapped_mnemonic) <- + profile["wrapped_user_mnemonic"] || {:error, "此 vault 未綁定助記詞(無法救援)", 1}, + seed = BIP39.mnemonic_to_seed(mnemonic), + rescue_key = BIP39.rescue_key_from_seed(seed, ctx.email, iterations), + {:ok, k_user} <- Crypto.unwrap_user_key(wrapped_mnemonic, rescue_key) do + new_master = Crypto.derive_master_key(new_password, ctx.email, iterations) + wrapped = Crypto.wrap_user_key(k_user, new_master) + + case api.vault_profile_put(ctx.issuer, ctx.token, %{"wrapped_user_password" => wrapped}) do + {:ok, _body} -> + if opts[:json] do + IO.puts(Jason.encode!(%{ok: true})) + else + IO.puts("已透過助記詞重設主密碼;既有資料無需重新加密。") + end + + 0 + + {:error, _kind, _detail} = error -> + fail_api(opts, ctx, error) + end + else + {:error, :invalid} -> + fail(opts, "助記詞不正確(解不開 wrapped_user_mnemonic)", 1) + + {:error, message, code} -> + fail(opts, message, code) + end + end + + # -- 背景(token/issuer/email)與金鑰 -- + + defp context(opts, api) do + token_source = + cond do + token = Config.env_token() -> {:env, token} + true -> :file + end + + case token_source do + {:env, token} -> + with {:ok, email} <- resolve_email(opts, api, token, nil) do + {:ok, %{token: token, issuer: resolve_issuer(opts, nil), email: email}} + end + + :file -> + case Credentials.load() do + {:ok, creds} -> + token = creds["access_token"] + + if blank?(token) do + {:error, "憑證檔缺少 access_token,請重新 bear login", 7} + else + with {:ok, email} <- resolve_email(opts, api, token, creds["email"]) do + {:ok, + %{token: token, issuer: resolve_issuer(opts, creds["issuer"]), email: email}} + end + end + + :error -> + {:error, "未登入(請先執行 bear login)", 3} + end + end + end + + # salt=帳號 email 小寫(與 Web Vault 一致);來源優先序: + # --email > 憑證檔 > 伺服器 profile。 + defp resolve_email(opts, _api, _token, creds_email) do + email = opts[:email] || creds_email + + cond do + is_binary(email) and email != "" -> {:ok, String.trim(email)} + true -> {:error, "無法得知帳號 email(KDF salt 需要);請以 --email EMAIL 提供", 2} + end + end + + defp resolve_issuer(opts, stored_issuer) do + opts[:issuer] || stored_issuer || Config.resolve_issuer(nil, opts[:config]) + end + + defp fetch_config(ctx, _opts, api) do + case api.vault_config(ctx.issuer, ctx.token) do + {:ok, body} -> {:ok, body["data"] || %{}} + {:error, _kind, _detail} = error -> api_error(ctx, error) + end + end + + # 404 → 未初始化(不以錯誤處理) + defp fetch_profile(ctx, _opts, api) do + case api.vault_profile_get(ctx.issuer, ctx.token) do + {:ok, body} -> {:ok, body["data"] || %{}} + {:error, :not_found, _} -> {:error, :not_initialized} + {:error, _kind, _detail} = error -> api_error(ctx, error) + end + end + + defp iterations_for(profile, ctx, opts, api) do + from_profile = (profile["kdf"] || %{})["iterations"] + + cond do + is_integer(from_profile) and from_profile > 0 -> + {:ok, from_profile} + + true -> + case fetch_config(ctx, opts, api) do + {:ok, %{"kdf" => %{"iterations" => iters}}} when is_integer(iters) -> + {:ok, iters} + + _ -> + {:error, "無法取得 KDF 迭代數(profile 與 /vault/config 皆未提供)", 6} + end + end + end + + defp unlock_with_password(ctx, profile, opts, api) do + wrapped = profile["wrapped_user_password"] + + cond do + blank?(wrapped) -> + {:error, "此 vault 未設定主密碼包裝金鑰(可能僅綁定助記詞);請用 bear vault rescue", 1} + + true -> + with {:ok, password} <- prompt_secret(opts, "主密碼"), + {:ok, iterations} <- iterations_for(profile, ctx, opts, api) do + master = Crypto.derive_master_key(password, ctx.email, iterations) + + case Crypto.unwrap_user_key(wrapped, master) do + {:ok, k_user} -> {:ok, k_user} + {:error, :invalid} -> {:error, "主密碼不正確", 1} + end + end + end + end + + defp read_new_password(opts) do + with {:ok, new} <- prompt_secret(opts, "新主密碼"), + {:ok, confirm} <- prompt_secret(opts, "確認新主密碼") do + if new == confirm do + {:ok, new} + else + {:error, "兩次輸入的新主密碼不一致", 2} + end + end + end + + # -- Session(K_user 傳遞;僅環境變數,不落盤)-- + + defp session_key(opts) do + raw = opts[:session] || System.get_env(@session_env) + + with value when is_binary(value) <- raw, + {:ok, key} <- Base.decode64(value), + true <- byte_size(key) == 64 do + key + else + _ when raw in [nil, ""] -> nil + _ -> :invalid + end + end + + defp require_session(opts) do + case session_key(opts) do + nil -> {:error, "此指令需要先解鎖(bear vault unlock 後匯入 #{@session_env})", 2} + :invalid -> {:error, "#{@session_env} 內容無效(需 vault unlock 產生的 base64 金鑰)", 2} + key -> {:ok, key} + end + end + + # -- 互動輸入 -- + + # 敏感輸入(主密碼、助記詞、項目密碼):不回顧;非 TTY → 2。 + defp prompt_secret(opts, label) do + tty? = Map.get(opts, :tty?, true) + io = opts[:io] + + cond do + not tty? -> + {:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2} + + io -> + case io.prompt_secret(label) do + :eof -> {:error, "未讀到輸入(#{label})", 2} + {:ok, value} -> {:ok, value} + end + + true -> + if tty_stdin?() do + case IO.gets("#{label}:") do + :eof -> {:error, "未讀到輸入(#{label})", 2} + line -> {:ok, String.trim(line)} + end + else + {:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2} + end + end + end + + # 一般輸入(名稱、備註等非敏感欄位):可回顯;fake io 亦走 prompt/2。 + defp prompt(opts, label, default \\ nil) do + tty? = Map.get(opts, :tty?, true) + io = opts[:io] + hint = if default && default != "", do: "(直接 Enter 保留 #{default})", else: "" + + cond do + not tty? -> + {:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2} + + io -> + case io.prompt("#{label}#{hint}") do + :eof -> {:error, "未讀到輸入(#{label})", 2} + {:ok, ""} -> {:ok, default || ""} + {:ok, value} -> {:ok, value} + end + + true -> + if tty_stdin?() do + case IO.gets("#{label}#{hint}:") do + :eof -> {:error, "未讀到輸入(#{label})", 2} + line -> {:ok, String.trim(line)} + end + else + {:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2} + end + end + end + + defp tty_stdin?, do: match?({:ok, _}, :io.rows()) + + defp require_nonempty(""), do: {:error, "不可空白", 2} + defp require_nonempty(value) when is_binary(value), do: :ok + + # -- 建立/編輯的欄位提示 -- + + # 回 plaintext attrs(name 必填;其餘可空 → nil) + defp prompt_cipher_fields(opts, type) do + with {:ok, name} <- prompt(opts, "名稱"), + :ok <- require_nonempty(name) do + case type do + "login" -> + with {:ok, username} <- prompt(opts, "帳號(username)"), + {:ok, password} <- prompt_secret(opts, "密碼"), + {:ok, uri} <- prompt(opts, "URI(多個以逗號分隔,可省略)"), + {:ok, notes} <- prompt(opts, "備註(可省略)") do + {:ok, + %{ + "name" => name, + "username" => empty_to_nil(username), + "password" => empty_to_nil(password), + "uris" => split_uris(uri), + "notes" => empty_to_nil(notes) + }} + end + + "secure_note" -> + with {:ok, notes} <- prompt(opts, "筆記內容") do + {:ok, %{"name" => name, "notes" => empty_to_nil(notes)}} + end + + extra_type when extra_type in ~w(card identity) -> + with {:ok, extra} <- prompt(opts, "額外資訊(卡號/身分資料;會加密存放)") do + {:ok, %{"name" => name, "extra" => empty_to_nil(extra)}} + end + end + end + end + + # 編輯:解密現值 → 逐欄提示(Enter 保留現值)→ 只有變更的欄位重加密。 + defp prompt_edit_fields(opts, cipher, k_user) do + current = decrypt_fields(cipher, k_user) + + with {:ok, name} <- prompt(opts, "名稱", current["name"]), + :ok <- require_nonempty(name) do + type = cipher["cipher_type"] + + attrs = + case type do + "login" -> + with {:ok, username} <- prompt(opts, "帳號(username)", current["username"]), + {:ok, password} <- prompt_edit_secret(opts, "密碼"), + {:ok, uri} <- prompt(opts, "URI(逗號分隔)", Enum.join(current["uris"] || [], ",")), + {:ok, notes} <- prompt(opts, "備註", current["notes"]) do + %{ + "name" => name, + "username" => empty_to_nil(username), + # Enter 保留現值:以解密後的現值重加密(新 IV,同一明文) + "password" => if(password == "", do: current["password"], else: password), + "uris" => split_uris(uri), + "notes" => empty_to_nil(notes) + } + end + + "secure_note" -> + with {:ok, notes} <- prompt(opts, "筆記內容", current["notes"]) do + %{"name" => name, "notes" => empty_to_nil(notes)} + end + + _ -> + with {:ok, extra} <- prompt(opts, "額外資訊", current["extra"]) do + %{"name" => name, "extra" => empty_to_nil(extra)} + end + end + + case attrs do + {:error, _m, _c} = err -> err + map -> {:ok, map} + end + end + end + + # 編輯時的密碼欄:不回顯;直接 Enter=保留現值(回 "")。 + defp prompt_edit_secret(opts, label) do + tty? = Map.get(opts, :tty?, true) + io = opts[:io] + + cond do + not tty? -> {:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2} + io -> io.prompt_secret("#{label}(直接 Enter 保留現值)") + true -> prompt_secret(opts, "#{label}(直接 Enter 保留現值)") + end + end + + defp split_uris(""), do: [] + defp split_uris(nil), do: [] + + defp split_uris(uri_string) do + uri_string + |> String.split(~r/[,;\s]+/, trim: true) + |> Enum.reject(&(&1 == "")) + end + + defp empty_to_nil(""), do: nil + defp empty_to_nil(value), do: value + + # plaintext attrs → 加密 attrs(多餘鍵留在 data;敏感欄位逐欄加密)。 + defp encrypt_attrs(attrs, k_user) do + Enum.reduce(attrs, %{}, fn {key, value}, acc -> + case key do + "extra" -> + data = Map.get(acc, "data") || %{} + + Map.put(acc, "data", Map.put(data, "extra", value && Crypto.encrypt(value, k_user))) + + key when key in ~w(name username password notes) -> + Map.put(acc, key, value && Crypto.encrypt(value, k_user)) + + "uris" -> + Map.put(acc, "uris", Enum.map(value || [], &Crypto.encrypt(&1, k_user))) + + _ -> + Map.put(acc, key, value) + end + end) + end + + defp decrypt_fields(cipher, k_user) do + %{ + "name" => decrypt_to(cipher["name"], k_user, ""), + "username" => decrypt_to(cipher["username"], k_user, ""), + "password" => decrypt_to(cipher["password"], k_user, ""), + "notes" => decrypt_to(cipher["notes"], k_user, ""), + "extra" => decrypt_to((cipher["data"] || %{})["extra"], k_user, ""), + "uris" => + Enum.map(cipher["uris"] || [], &decrypt_to(&1, k_user, "")) + |> Enum.reject(&(&1 == "")) + } + end + + defp decrypt_to(nil, _k_user, default), do: default + defp decrypt_to(_value, nil, _default), do: nil + + defp decrypt_to(value, k_user, default) when is_binary(value) do + case Crypto.decrypt(value, k_user) do + {:ok, plain} -> plain + {:error, :invalid} -> default + end + end + + # -- 輸出 -- + + defp cipher_row(cipher, k_user, folder_names) do + name = + cond do + k_user == nil -> "(未解密)" + true -> decrypt_to(cipher["name"], k_user, "(無法解密)") + end + + folder_uuid = (cipher["data"] || %{})["folder_uuid"] + + [ + cipher["cipher_uuid"], + name, + cipher["cipher_type"], + folder_names[folder_uuid] || "", + cipher["revision_date"] || cipher["updated_at"] || "", + if(cipher["deleted_at"], do: "回收桶", else: "有效") + ] + end + + defp cipher_json(cipher, k_user, folder_names \\ %{}) + + defp cipher_json(cipher, nil, _folder_names), do: cipher + + defp cipher_json(cipher, k_user, folder_names) do + folder_uuid = (cipher["data"] || %{})["folder_uuid"] + + Map.merge(cipher, %{ + "plain" => + %{ + "name" => decrypt_to(cipher["name"], k_user, nil), + "username" => decrypt_to(cipher["username"], k_user, nil), + "password" => decrypt_to(cipher["password"], k_user, nil), + "notes" => decrypt_to(cipher["notes"], k_user, nil), + "uris" => Enum.map(cipher["uris"] || [], &decrypt_to(&1, k_user, nil)) + } + |> Map.put("folder_name", folder_names[folder_uuid]) + }) + end + + # get 指令的輔助(k_user 或 nil;資料夾名不存在) + defp k_key_or_map(_cipher, k_user), do: k_user + defp k_key(_value, k_user), do: k_user + defp k_key_any([], _k_user), do: nil + defp k_key_any(_folders, k_user), do: k_user + + defp print_cipher(cipher, k_user) do + IO.puts("#{String.pad_trailing("UUID", 12)}: #{cipher["cipher_uuid"]}") + IO.puts("#{String.pad_trailing("類型", 12)}: #{cipher["cipher_type"]}") + IO.puts("#{String.pad_trailing("狀態", 12)}: #{if cipher["deleted_at"], do: "回收桶", else: "有效"}") + IO.puts("#{String.pad_trailing("最愛", 12)}: #{cipher["favorite"]}") + + if k_user do + fields = decrypt_fields(cipher, k_user) + + IO.puts("#{String.pad_trailing("名稱", 12)}: #{fields["name"]}") + IO.puts("#{String.pad_trailing("帳號", 12)}: #{fields["username"] || "(無)"}") + + IO.puts( + "#{String.pad_trailing("密碼", 12)}: #{fields["password"] || decrypt_to(cipher["password"], nil, "(無)")}" + ) + + uris = fields["uris"] + + IO.puts( + "#{String.pad_trailing("URI", 12)}: #{if uris == [], do: "(無)", else: Enum.join(uris, "、")}" + ) + + IO.puts("#{String.pad_trailing("備註", 12)}: #{fields["notes"] || "(無)"}") + + extra = fields["extra"] + if extra, do: IO.puts("#{String.pad_trailing("額外資訊", 12)}: #{extra}") + else + Enum.each(~w(name username password notes), fn field -> + IO.puts("#{String.pad_trailing(field, 12)}: #{cipher[field] || "(未提供)"}") + end) + end + + IO.puts( + "#{String.pad_trailing("修訂時間", 12)}: #{cipher["revision_date"] || cipher["updated_at"] || ""}" + ) + end + + defp render_rows(rows, headers) do + widths = + Enum.with_index(headers, fn _h, i -> + Enum.max([ + String.length(Enum.at(headers, i)) + | Enum.map(rows, &String.length(Enum.at(&1, i) || "")) + ]) + end) + + render = fn cells -> + cells + |> Enum.with_index() + |> Enum.map(fn {cell, i} -> + String.pad_trailing(to_string(cell || ""), Enum.at(widths, i)) + end) + |> Enum.join(" ") + |> String.trim_trailing() + end + + IO.puts(render.(headers)) + Enum.each(rows, &IO.puts(render.(&1))) + end + + defp confirm_purge(opts, uuid) do + with {:ok, answer} <- prompt(opts, "永久刪除 #{uuid}?(y/N)") do + if answer in ~w(y Y yes YES) do + :ok + else + {:error, "已取消", 1} + end + end + end + + defp new_uuid do + Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false) + end + + # -- API 錯誤處理(退出碼對應 §3.11)-- + + defp wrap_api(result, ctx, _opts) do + case result do + {:ok, body} -> + {:ok, body} + + {:error, _kind, _detail} = error -> + api_error(ctx, error) + end + end + + defp api_error(_ctx, {:error, :unauthorized, desc}), + do: {:error, "token 無效或已過期(#{desc}),請重新 bear login", 3} + + defp api_error(_ctx, {:error, :forbidden, desc}), do: {:error, "權限不足(HTTP 403:#{desc})", 8} + defp api_error(_ctx, {:error, :not_found, _desc}), do: {:error, "找不到目標(HTTP 404)", 1} + + defp api_error(_ctx, {:error, :unprocessable_entity, %{"errors" => errors} = body}) + when map_size(body) <= 2 do + details = + errors + |> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(List.wrap(messages), "、")}" end) + |> Enum.join(";") + + {:error, "驗證失敗(HTTP 422):#{details}", 1} + end + + defp api_error(_ctx, {:error, :unprocessable_entity, body}) when is_map(body) do + desc = body["error_description"] || body["error"] || "" + {:error, "驗證失敼(HTTP 422)#{if desc != "", do: ":#{desc}"}", 1} + end + + defp api_error(_ctx, {:error, :too_many_requests, _body}), + do: {:error, "請求過於頻繁(HTTP 429),請稍後再試", 1} + + defp api_error(_ctx, {:error, :server_error, status}), do: {:error, "伺服器回應 #{status}", 6} + + defp api_error(ctx, {:error, :network, reason}), + do: {:error, "無法連線到 #{ctx.issuer}:#{reason}", 6} + + defp fail_api(opts, ctx, error) do + case api_error(ctx, error) do + {:error, message, code} -> fail(opts, message, code) + end + end + + defp fail(opts, message, code) do + if opts[:json] do + IO.puts(Jason.encode!(%{ok: false, error: message, code: code})) + else + IO.puts(:stderr, "錯誤:#{message}") + end + + code + end + + defp json?(opts), do: opts[:json] == true + + defp blank?(nil), do: true + defp blank?(""), do: true + defp blank?(_), do: false +end diff --git a/lib/bear_cli/vault/bip39.ex b/lib/bear_cli/vault/bip39.ex new file mode 100644 index 0000000..3bdf515 --- /dev/null +++ b/lib/bear_cli/vault/bip39.ex @@ -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(<>, 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 diff --git a/lib/bear_cli/vault/bip39_wordlist.ex b/lib/bear_cli/vault/bip39_wordlist.ex new file mode 100644 index 0000000..63ac7d2 --- /dev/null +++ b/lib/bear_cli/vault/bip39_wordlist.ex @@ -0,0 +1,20 @@ +defmodule BearCli.Vault.BIP39.Wordlist do + @moduledoc """ + BIP39 英文詞表(2048 字),與 bear 主倉庫 assets/js/vault/wordlist.js + 完全一致(來源:bitcoin/bips bip-0039 english.txt;勿手改)。 + """ + + @words String.split( + "abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artefact artist artwork ask aspect assault asset assist assume asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp can canal cancel candy cannon canoe canvas canyon capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current curtain curve cushion custom cute cycle dad damage damp dance danger daring dash daughter dawn day deal debate debris decade december decide decline decorate decrease deer defense define defy degree delay deliver demand demise denial dentist deny depart depend deposit depth deputy derive describe desert design desk despair destroy detail detect develop device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance divert divide divorce dizzy doctor document dog doll dolphin domain donate donkey donor door dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop drum dry duck dumb dune during dust dutch duty dwarf dynamic eager eagle early earn earth easily east easy echo ecology economy edge edit educate effort egg eight either elbow elder electric elegant element elephant elevator elite else embark embody embrace emerge emotion employ empower empty enable enact end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence evil evoke evolve exact example excess exchange excite exclude excuse execute exercise exhaust exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye eyebrow fabric face faculty fade faint faith fall false fame family famous fan fancy fantasy farm fashion fat fatal father fatigue fault favorite feature february federal fee feed feel female fence festival fetch fever few fiber fiction field figure file film filter final find fine finger finish fire firm first fiscal fish fit fitness fix flag flame flash flat flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold follow food foot force forest forget fork fortune forum forward fossil foster found fox fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny furnace fury future gadget gain galaxy gallery game gap garage garbage garden garlic garment gas gasp gate gather gauge gaze general genius genre gentle genuine gesture ghost giant gift giggle ginger giraffe girl give glad glance glare glass glide glimpse globe gloom glory glove glow glue goat goddess gold good goose gorilla gospel gossip govern gown grab grace grain grant grape grass gravity great green grid grief grit grocery group grow grunt guard guess guide guilt guitar gun gym habit hair half hammer hamster hand happy harbor hard harsh harvest hat have hawk hazard head health heart heavy hedgehog height hello helmet help hen hero hidden high hill hint hip hire history hobby hockey hold hole holiday hollow home honey hood hope horn horror horse hospital host hotel hour hover hub huge human humble humor hundred hungry hunt hurdle hurry hurt husband hybrid ice icon idea identify idle ignore ill illegal illness image imitate immense immune impact impose improve impulse inch include income increase index indicate indoor industry infant inflict inform inhale inherit initial inject injury inmate inner innocent input inquiry insane insect inside inspire install intact interest into invest invite involve iron island isolate issue item ivory jacket jaguar jar jazz jealous jeans jelly jewel job join joke journey joy judge juice jump jungle junior junk just kangaroo keen keep ketchup key kick kid kidney kind kingdom kiss kit kitchen kite kitten kiwi knee knife knock know lab label labor ladder lady lake lamp language laptop large later latin laugh laundry lava law lawn lawsuit layer lazy leader leaf learn leave lecture left leg legal legend leisure lemon lend length lens leopard lesson letter level liar liberty library license life lift light like limb limit link lion liquid list little live lizard load loan lobster local lock logic lonely long loop lottery loud lounge love loyal lucky luggage lumber lunar lunch luxury lyrics machine mad magic magnet maid mail main major make mammal man manage mandate mango mansion manual maple marble march margin marine market marriage mask mass master match material math matrix matter maximum maze meadow mean measure meat mechanic medal media melody melt member memory mention menu mercy merge merit merry mesh message metal method middle midnight milk million mimic mind minimum minor minute miracle mirror misery miss mistake mix mixed mixture mobile model modify mom moment monitor monkey monster month moon moral more morning mosquito mother motion motor mountain mouse move movie much muffin mule multiply muscle museum mushroom music must mutual myself mystery myth naive name napkin narrow nasty nation nature near neck need negative neglect neither nephew nerve nest net network neutral never news next nice night noble noise nominee noodle normal north nose notable note nothing notice novel now nuclear number nurse nut oak obey object oblige obscure observe obtain obvious occur ocean october odor off offer office often oil okay old olive olympic omit once one onion online only open opera opinion oppose option orange orbit orchard order ordinary organ orient original orphan ostrich other outdoor outer output outside oval oven over own owner oxygen oyster ozone pact paddle page pair palace palm panda panel panic panther paper parade parent park parrot party pass patch path patient patrol pattern pause pave payment peace peanut pear peasant pelican pen penalty pencil people pepper perfect permit person pet phone photo phrase physical piano picnic picture piece pig pigeon pill pilot pink pioneer pipe pistol pitch pizza place planet plastic plate play please pledge pluck plug plunge poem poet point polar pole police pond pony pool popular portion position possible post potato pottery poverty powder power practice praise predict prefer prepare present pretty prevent price pride primary print priority prison private prize problem process produce profit program project promote proof property prosper protect proud provide public pudding pull pulp pulse pumpkin punch pupil puppy purchase purity purpose purse push put puzzle pyramid quality quantum quarter question quick quit quiz quote rabbit raccoon race rack radar radio rail rain raise rally ramp ranch random range rapid rare rate rather raven raw razor ready real reason rebel rebuild recall receive recipe record recycle reduce reflect reform refuse region regret regular reject relax release relief rely remain remember remind remove render renew rent reopen repair repeat replace report require rescue resemble resist resource response result retire retreat return reunion reveal review reward rhythm rib ribbon rice rich ride ridge rifle right rigid ring riot ripple risk ritual rival river road roast robot robust rocket romance roof rookie room rose rotate rough round route royal rubber rude rug rule run runway rural sad saddle sadness safe sail salad salmon salon salt salute same sample sand satisfy satoshi sauce sausage save say scale scan scare scatter scene scheme school science scissors scorpion scout scrap screen script scrub sea search season seat second secret section security seed seek segment select sell seminar senior sense sentence series service session settle setup seven shadow shaft shallow share shed shell sheriff shield shift shine ship shiver shock shoe shoot shop short shoulder shove shrimp shrug shuffle shy sibling sick side siege sight sign silent silk silly silver similar simple since sing siren sister situate six size skate sketch ski skill skin skirt skull slab slam sleep slender slice slide slight slim slogan slot slow slush small smart smile smoke smooth snack snake snap sniff snow soap soccer social sock soda soft solar soldier solid solution solve someone song soon sorry sort soul sound soup source south space spare spatial spawn speak special speed spell spend sphere spice spider spike spin spirit split spoil sponsor spoon sport spot spray spread spring spy square squeeze squirrel stable stadium staff stage stairs stamp stand start state stay steak steel stem step stereo stick still sting stock stomach stone stool story stove strategy street strike strong struggle student stuff stumble style subject submit subway success such sudden suffer sugar suggest suit summer sun sunny sunset super supply supreme sure surface surge surprise surround survey suspect sustain swallow swamp swap swarm swear sweet swift swim swing switch sword symbol symptom syrup system table tackle tag tail talent talk tank tape target task taste tattoo taxi teach team tell ten tenant tennis tent term test text thank that theme then theory there they thing this thought three thrive throw thumb thunder ticket tide tiger tilt timber time tiny tip tired tissue title toast tobacco today toddler toe together toilet token tomato tomorrow tone tongue tonight tool tooth top topic topple torch tornado tortoise toss total tourist toward tower town toy track trade traffic tragic train transfer trap trash travel tray treat tree trend trial tribe trick trigger trim trip trophy trouble truck true truly trumpet trust truth try tube tuition tumble tuna tunnel turkey turn turtle twelve twenty twice twin twist two type typical ugly umbrella unable unaware uncle uncover under undo unfair unfold unhappy uniform unique unit universe unknown unlock until unusual unveil update upgrade uphold upon upper upset urban urge usage use used useful useless usual utility vacant vacuum vague valid valley valve van vanish vapor various vast vault vehicle velvet vendor venture venue verb verify version very vessel veteran viable vibrant vicious victory video view village vintage violin virtual virus visa visit visual vital vivid vocal voice void volcano volume vote voyage wage wagon wait walk wall walnut want warfare warm warrior wash wasp waste water wave way wealth weapon wear weasel weather web wedding weekend weird welcome west wet whale what wheat wheel when where whip whisper wide width wife wild will win window wine wing wink winner winter wire wisdom wise wish witness wolf woman wonder wood wool word work world worry worth wrap wreck wrestle wrist write wrong yard year yellow you young youth zebra zero zone zoo", + " ", + trim: true + ) + + @doc "完整詞表(2048 字,index 即 BIP39 編號)。" + def words, do: @words + + @doc "單字 → 索引(0..2047);不在詞表回 nil。" + def index(word) when is_binary(word) do + Enum.find_index(@words, &(&1 === word)) + end +end diff --git a/lib/bear_cli/vault/crypto.ex b/lib/bear_cli/vault/crypto.ex new file mode 100644 index 0000000..427d2a7 --- /dev/null +++ b/lib/bear_cli/vault/crypto.ex @@ -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..."`(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 使用者金鑰(`<>`)。" + def generate_user_key do + :crypto.strong_rand_bytes(64) + end + + # -- 加密字串(type 2)-- + + @doc """ + 以 64-byte `user_key` 加密明文,回傳加密字串 `"2..."` + (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 + <> = 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 + <> = 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) + 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) do + binary_part(data, 0, byte_size(data) - pad_len) + else + :error + end + end +end diff --git a/test/bear_cli/vault_test.exs b/test/bear_cli/vault_test.exs new file mode 100644 index 0000000..7448f4a --- /dev/null +++ b/test/bear_cli/vault_test.exs @@ -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 =~ "缺少 " + 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