Compare commits
10
Commits
9784e18c24
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c301551f | ||
|
|
a29b976f13
|
||
|
|
a2edb03a0e | ||
|
|
0b18d76cfd
|
||
|
|
a880bcea4d | ||
|
|
34c549e77a | ||
|
|
4d353ef5f4 | ||
|
|
d70aa9e541 | ||
|
|
e23be309f8 | ||
|
|
62fd6b730e |
@@ -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 的終端入口。
|
**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)。
|
> 伺服器端採用的 OIDC **Device Authorization Grant(RFC 8628)** 方向,詳見 bear 倉庫的設計文件 `docs/cli-feature-plan.md`(issue #17)。
|
||||||
> Bear 已支援**個人存取權杖(Personal Access Token,PAT)**;CLI 規劃以其作為完全免瀏覽器的替代登入方式(見下文)。
|
> Bear 已支援**個人存取權杖(Personal Access Token,PAT)**;CLI 規劃以其作為完全免瀏覽器的替代登入方式(見下文)。
|
||||||
|
|
||||||
@@ -33,6 +33,13 @@ Bear 目前所有流程都依賴「瀏覽器」:登入 `/login`、儀表板 La
|
|||||||
| `bear sessions list/revoke/revoke-others` | 管理網頁 session | P3(✅ 已實作) |
|
| `bear sessions list/revoke/revoke-others` | 管理網頁 session | P3(✅ 已實作) |
|
||||||
| `bear tokens list/create/revoke` | 管理 PAT(create 一次性顯示明文) | P3(✅ 已實作) |
|
| `bear tokens list/create/revoke` | 管理 PAT(create 一次性顯示明文) | P3(✅ 已實作) |
|
||||||
| `bear mfa status/setup/disable/recovery-codes` | TOTP 兩因子管理 | 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)。
|
完整指令規格(選項、行為、輸出、退出碼)見 [`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 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 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 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": […]}`
|
**`--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. 退出碼總表
|
## 4. 退出碼總表
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ defmodule BearCli.Accounts do
|
|||||||
|
|
||||||
import BearCli.Admin, only: [context: 1, fail: 3, format_api_error: 2]
|
import BearCli.Admin, only: [context: 1, fail: 3, format_api_error: 2]
|
||||||
alias BearCli.Api
|
alias BearCli.Api
|
||||||
|
alias BearCli.TTY
|
||||||
|
|
||||||
@list_per_page_fetch 100
|
@list_per_page_fetch 100
|
||||||
@max_fetch_pages 50
|
@max_fetch_pages 50
|
||||||
@@ -251,8 +252,10 @@ defmodule BearCli.Accounts do
|
|||||||
|
|
||||||
defp weak_password?(_), do: true
|
defp weak_password?(_), do: true
|
||||||
|
|
||||||
|
# stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.columns/0
|
||||||
|
# 一律 enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。
|
||||||
defp tty? do
|
defp tty? do
|
||||||
:io.columns() != {:error, :enotsup}
|
TTY.stdin_tty?()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp prompt_password(prompt) do
|
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})
|
api_request(issuer, token, :post, "/api/v1/profile/mfa/recovery-codes", json: %{code: code})
|
||||||
end
|
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 [
|
@global_switches [
|
||||||
issuer: :string,
|
issuer: :string,
|
||||||
@@ -327,6 +338,11 @@ defmodule BearCli.CLI do
|
|||||||
end
|
end
|
||||||
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
|
defp parse_command(unknown, _args, _global) do
|
||||||
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
|
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
|
||||||
end
|
end
|
||||||
@@ -584,6 +600,11 @@ defmodule BearCli.CLI do
|
|||||||
IO.puts(BearCli.version())
|
IO.puts(BearCli.version())
|
||||||
0
|
0
|
||||||
|
|
||||||
|
{:help, :vault} ->
|
||||||
|
# vault 群組專屬說明
|
||||||
|
_ = Vault.run(:help, %{})
|
||||||
|
0
|
||||||
|
|
||||||
{:help, _} ->
|
{:help, _} ->
|
||||||
print_help()
|
print_help()
|
||||||
0
|
0
|
||||||
@@ -616,6 +637,11 @@ defmodule BearCli.CLI do
|
|||||||
audit_logs_api = cmd_opts[:audit_logs_api] || opts[:audit_logs_api] || Api
|
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))
|
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} ->
|
{:ok, command, cmd_opts} ->
|
||||||
cond do
|
cond do
|
||||||
cmd_opts[:version] ->
|
cmd_opts[:version] ->
|
||||||
@@ -969,6 +995,21 @@ defmodule BearCli.CLI do
|
|||||||
mfa setup
|
mfa setup
|
||||||
mfa disable
|
mfa disable
|
||||||
mfa recovery-codes
|
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)
|
--issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ defmodule BearCli.SelfService do
|
|||||||
3 未登入或 401;6 網路/伺服器錯誤;8 權限不足(403)。
|
3 未登入或 401;6 網路/伺服器錯誤;8 權限不足(403)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias BearCli.{Api, Config, Credentials}
|
alias BearCli.{Api, Config, Credentials, TTY}
|
||||||
|
|
||||||
@genders ~w(male female other)
|
@genders ~w(male female other)
|
||||||
|
|
||||||
@@ -551,9 +551,10 @@ defmodule BearCli.SelfService do
|
|||||||
end
|
end
|
||||||
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
|
defp tty_stdin? do
|
||||||
match?({:ok, _}, :io.rows())
|
TTY.stdin_tty?()
|
||||||
end
|
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