feat: 實作 vault 指令群(issue #17;對接 bear vault P3) #18

Merged
ceo merged 1 commits from feat/vault-commands into main 2026-09-10 23:36:27 +08:00
9 changed files with 2488 additions and 2 deletions
+9 -1
View File
@@ -2,7 +2,7 @@
**Bear CLI** 是 [澳特科技](https://alterminal.com)(Alter Technology)單點登入系統 [Bear](https://gitea.alterminal.com/alterminal/bear) 的**終端機(CLI)客戶端**:讓使用者在 Linux 終端機以 Bear 帳號登入(Device Flow 或個人存取權杖)、查詢身分、取得 Token,作為 SSO 的終端入口。
> **狀態:已實作 PAT 模式 MVP(Elixir + Req,issue #6)+ App 管理(#10)+ 個人自助指令群(#11)**。`login --token <PAT>`、`whoami`、`token`、`logout`、`status`、`apps`、`profile`/`password`/`email`/`sessions`/`tokens`/`mfa` 已可用;Device Authorization Grant(RFC 8628)登入待伺服器端 P1(bear 倉庫)完成後再接。
> **狀態:已實作 PAT 模式 MVP(Elixir + Req,issue #6)+ App 管理(#10)+ 個人自助指令群(#11)+ 管理端指令群(#12)+ Vault 密碼管理(#17)**。`login --token <PAT>`、`whoami`、`token`、`logout`、`status`、`apps`、`profile`/`password`/`email`/`sessions`/`tokens`/`mfa`、`jwks`/`accounts`/`audit-logs`、`vault` 已可用;Device Authorization Grant(RFC 8628)登入待伺服器端 P1(bear 倉庫)完成後再接。
> 伺服器端採用的 OIDC **Device Authorization Grant(RFC 8628)** 方向,詳見 bear 倉庫的設計文件 `docs/cli-feature-plan.md`(issue #17)。
> Bear 已支援**個人存取權杖(Personal Access Token,PAT)**;CLI 規劃以其作為完全免瀏覽器的替代登入方式(見下文)。
@@ -33,6 +33,13 @@ Bear 目前所有流程都依賴「瀏覽器」:登入 `/login`、儀表板 La
| `bear sessions list/revoke/revoke-others` | 管理網頁 session | P3(✅ 已實作) |
| `bear tokens list/create/revoke` | 管理 PAT(create 一次性顯示明文) | P3(✅ 已實作) |
| `bear mfa status/setup/disable/recovery-codes` | TOTP 兩因子管理 | P3(✅ 已實作) |
| `bear vault status/unlock/lock` | Vault 解鎖狀態與 K_user session(僅記憶體) | P3(✅ 已實作) |
| `bear vault list/get/create/edit` | 項目 CRUD(客戶端端到端加密,與 Web Vault 互操作) | P3(✅ 已實作) |
| `bear vault delete/restore/purge` | 回收桶(soft delete/還原/永久刪除) | P3(✅ 已實作) |
| `bear vault folders list/create/rename/delete` | 資料夾管理(名稱客戶端加密) | P3(✅ 已實作) |
| `bear vault sync` | 完整同步(伺服器為準) | P3(✅ 已實作) |
| `bear vault password change` | 變更 vault 主密碼(重新包裝 K_user,不動 cipher) | P3(✅ 已實作) |
| `bear vault rescue` | 助記詞救援(BIP39,重設主密碼) | P3(✅ 已實作) |
完整指令規格(選項、行為、輸出、退出碼)見 [`docs/commands.md`](docs/commands.md)。
@@ -54,6 +61,7 @@ Bear 目前所有流程都依賴「瀏覽器」:登入 `/login`、儀表板 La
| `bear accounts` | ✅ 已實作(`list`/`show`/`create`/`update`/`set-password`/`delete`;issue #12,對接 alterminal/bear#46 的 `/api/v1/accounts` JSON API;密碼不進命令列,初始/新密碼一次性輸出) |
| `bear audit-logs` | ✅ 已實作(`list` 含 `--category` 過濾與 emails 對照;issue #12,對接 alterminal/bear#46 的 `/api/v1/audit-logs` JSON API) |
| `bear profile`/`password`/`email`/`sessions`/`tokens`/`mfa` | ✅ 已實作(issue #11,對接 alterminal/bear#38 的 `/api/v1/profile*` JSON API,任何有效 PAT 皆可):`profile show/set`、`password change`、`email change --new`、`sessions list/revoke/revoke-others`、`tokens list/create/revoke`、`mfa status/setup/disable/recovery-codes`。規格見 `docs/commands.md` §3.7 |
| `bear vault` | ✅ 已實作(issue #17,對接 alterminal/bear#41 的 `/api/v1/vault` JSON API,任何有效 PAT 皆可):`status`/`unlock`/`lock`/`list`/`get`/`create`/`edit`/`delete`/`restore`/`purge`/`folders`/`sync`/`password change`/`rescue`。端到端加密全在客戶端(AES-256-CBC+HMAC-SHA-256 加密字串、PBKDF2-SHA512、BIP39 助記詞),格式與 Web Vault(P2)完全一致;K_user 僅存 shell 環境變數記憶體(`BEAR_VAULT_SESSION`),不落盤。規格見 `docs/commands.md` §3.11 |
### 建置與執行
+98
View File
@@ -666,6 +666,104 @@ TIME CATEGORY EVENT ACTOR ACCOUN
**`--json` 輸出範例**:`{"ok": true, "page": 1, "per_page": 50, "total": 1, "total_pages": 1, "emails": {…}, "entries": […]}`
### 3.11 `bear vault`:密碼管理指令群(P3,已實作)
> 依賴:bear 伺服器端 **vault JSON API**(alterminal/bear#41,`/api/v1/vault`、PAT Bearer、任何有效 PAT 帳號)與 Web Vault(P2,alterminal/bear#48/#49)。兩者皆已上線,本節指令群已實作(issue #17)。
**端到端加密(與 Web Vault 完全一致,同一 vault 兩端互相可解)**:
- 加密字串 `"2.<iv_b64>.<ct_b64>.<mac_b64>"`:AES-256-CBC+PKCS#7、encrypt-then-MAC(HMAC-SHA-256 over `iv <> ct`)、先驗 MAC 再解密。
- K_user 為隨機 64 byte(前 32 enc_key/後 32 mac_key);K_user 的包裝(wrapped key)加密的是 **K_user 的 base64**。
- 主金鑰:PBKDF2-SHA512(salt=帳號 email 小寫;迭代數以 `GET /api/v1/vault/config` 公告與 profile 記錄為準,**不寫死**)。
- 助記詞:BIP39(12 字、128-bit 熵、英文詞表);救援金鑰=種子 hex → PBKDF2-SHA512(salt=email 小寫)。
- 所有加解密全在客戶端(本 CLI)完成;伺服器零解密。
**K_user 僅存記憶體(不落盤)**:`vault unlock` 解開 K_user 後輸出 session 環境變數設定指令,使用者 `export BEAR_VAULT_SESSION=<base64>` 匯入後,同一 shell 的後續 vault 指令即可解密(同 Bitwarden CLI 的 `BW_SESSION` 慣例;環境變數只存在 shell 記憶體,不寫入任何檔案)。`vault lock` 提示 `unset BEAR_VAULT_SESSION` 即丟棄。
```
bear vault status [--json]
bear vault unlock [--json]
bear vault lock
bear vault list [--folder UUID] [--json]
bear vault get <uuid> [--json]
bear vault create --type login|secure_note|card|identity [--folder UUID] [--json]
bear vault edit <uuid> [--json]
bear vault delete <uuid> [--json]
bear vault restore <uuid> [--json]
bear vault purge <uuid> [--json]
bear vault folders list|create|rename <uuid>|delete <uuid> [--json]
bear vault sync [--json]
bear vault password change [--json]
bear vault rescue [--json]
```
**認證與授權(群組共通)**:
- 使用既有 PAT 憑證(`bear login --token` 或 `BEAR_TOKEN`)作為 Bearer;任何有效 PAT 帳號皆可。
- **KDF salt 需要帳號 email**:來源優先序為 `--email EMAIL` 選項>憑證檔記錄的 email>(無法得知 → 用法錯誤 `2`,提示以 `--email` 提供)。一般 `bear login` 後憑證檔即有 email,無需手動給。
- `401` → `3`;`403` → `8`;`404`/`422` → `1`;網路/伺服器錯誤 → `6`(沿用 §4 總表)。
- **主密碼、助記詞、項目密碼等敏感輸入一律互動提示讀取且不回顧**,不接受命令列明文參數;非 TTY → `2`。名稱、URI、備註等非敏感欄位互動提示可回顯(直接 Enter=保留現值/略過)。
- vault 尚未初始化(`GET /profile` 404)→ `vault status` 顯示「尚未初始化」並提示至 Web Vault 設定(初始化產生助記詞的流程在 Web Vault P2,CLI 不重作);其餘需要 profile 的指令 → 退出碼 `1`。
#### 3.11.1 `bear vault status`
顯示初始化狀態、KDF 參數、上次同步與本 shell 解鎖狀態(`BEAR_VAULT_SESSION` 是否設定)。`GET /api/v1/vault/config`+`GET /api/v1/vault/profile`。
**`--json`**:`{"ok": true, "initialized": true, "kdf": {…}, "unlocked": false, …}`;未初始化 → `{"ok": true, "initialized": false, "unlocked": false}`。
#### 3.11.2 `bear vault unlock`
互動輸入主密碼(不回顧)→ PBKDF2 推導 K_master → 解開 `wrapped_user_password` 得 K_user → 輸出 `export BEAR_VAULT_SESSION=…`。主密碼錯誤(MAC 驗證失敗)→ 退出碼 `1`(訊息「主密碼不正確」)。
**`--json`**:`{"ok": true, "session": "<base64>", "env": "BEAR_VAULT_SESSION"}`。
#### 3.11.3 `bear vault lock`
K_user 只存在環境變數,本指令提示 `unset BEAR_VAULT_SESSION`(無法代跨 shell unset)。
#### 3.11.4 `bear vault list [--folder UUID]`
`GET /api/v1/vault/ciphers`。未解鎖 → 只顯示 UUID/類型(名稱為密文狀態提示);已解鎖 → 一併解密名稱與所屬資料夾名。`--folder` 以 `data.folder_uuid` 過濾(資料夾關聯存在 cipher 的 `data` 欄位,與 Web Vault 一致)。
```
UUID NAME TYPE FOLDER UPDATED STATE
c9J2… GitHub login 工作 2026-09-10T05:00:00Z 有效
```
**`--json`**:`{"ok": true, "decrypted": true, "ciphers": […含 "plain" 明文欄位(已解鎖時)…]}`。
#### 3.11.5 `bear vault get <uuid>`
`GET /api/v1/vault/ciphers/{uuid}`。已解鎖 → 解密全部欄位(名稱/帳號/密碼/URI/備註/額外資訊);未解鎖 → 顯示密文與提示。密碼欄位只在 stdout 呈現(供腳本擷取),不提供 `--clip`(本版)。
#### 3.11.6 `bear vault create --type login|secure_note|card|identity [--folder UUID]`
互動輸入欄位(名稱必填;`login`:帳號/密碼(不回顧)/URI(逗號分隔可多個)/備註;`secure_note`:筆記內容;`card`/`identity`:額外資訊,加密存於 `data.extra`——與 Web Vault 相同的欄位配置)。客戶端加密後 `POST /api/v1/vault/ciphers`(客戶端產生 UUID)。需要解鎖。
#### 3.11.7 `bear vault edit <uuid>`
`GET` 現值 → 解密 → 逐欄提示(**直接 Enter=保留現值**;密碼欄不回顧)→ 僅重加密有變更的欄位 → `PUT /api/v1/vault/ciphers/{uuid}`。需要解鎖。
#### 3.11.8 `bear vault delete/restore/purge <uuid>`
`delete` → `POST …/{uuid}/delete`(回收桶);`restore` → `POST …/{uuid}/restore`;`purge` → `DELETE …/{uuid}/purge`(**永久刪除,互動確認 `y` 才執行**,其他輸入取消 → 退出碼 `1`)。已回收桶再 delete → `409` → `1`。
#### 3.11.9 `bear vault folders list|create|rename <uuid>|delete <uuid>`
資料夾 CRUD(name 客戶端加密):`GET`/`POST`(互動輸入名稱,需解鎖)/`PUT …/{uuid}`(互動輸入新名稱,需解鎖)/`DELETE …/{uuid}`。刪除資料夾不影響所屬項目(僅失去分類)。
#### 3.11.10 `bear vault sync`
`GET /api/v1/vault/sync`(伺服器回 profile+folders+ciphers 並標記 `last_synced_at`)。**離線衝突以伺服器為準**(CLI 為無狀態客戶端,不做雙向合併;本機無快取可衝突)。顯示數量統計;`--json` 回完整 sync payload。
#### 3.11.11 `bear vault password change`
互動輸入:目前主密碼 → 驗證(解開 wrapped_user_password)→ 新主密碼+確認(≥8 字元,兩次一致)→ 以新 K_master 重新包裝 K_user → `PUT /api/v1/vault/profile`(只動 `wrapped_user_password`,**ciphers 不需重新加密**)。
#### 3.11.12 `bear vault rescue`
忘記主密碼的救援路徑:互動輸入助記詞(12 字,會做 BIP39 完整驗證:字數/詞表/校驗和)→ 解開 `wrapped_user_mnemonic` 得 K_user → 設新主密碼(≥8 字元,兩次一致)→ 重新包裝上傳。助記詞錯誤 → 退出碼 `1`。CLI 不提供助記詞顯示(僅 Web Vault 設定時顯示一次)。
---
## 4. 退出碼總表
+81
View File
@@ -223,6 +223,87 @@ defmodule BearCli.Api do
api_request(issuer, token, :post, "/api/v1/profile/mfa/recovery-codes", json: %{code: code})
end
# -- Vault API(alterminal/bear#41;PAT Bearer、任何有效帳號)--
@doc "`GET /api/v1/vault/config`:公告的 KDF 參數與支援的加密字串型別。"
def vault_config(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/config")
end
@doc "`GET /api/v1/vault/profile`:vault profile(404=尚未初始化)。"
def vault_profile_get(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/profile")
end
@doc "`PUT /api/v1/vault/profile`:建立或更新 profile(上傳重新包裝的金鑰)。"
def vault_profile_put(issuer, token, attrs) do
api_request(issuer, token, :put, "/api/v1/vault/profile", json: attrs)
end
@doc "`GET /api/v1/vault/sync`:完整同步(profile+folders+ciphers)。"
def vault_sync(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/sync")
end
@doc "`GET /api/v1/vault/ciphers`:列出 ciphers(含回收桶)。"
def vault_ciphers_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/ciphers")
end
@doc "`POST /api/v1/vault/ciphers`:以客戶端 UUID upsert(衝突時整列覆蓋)。"
def vault_ciphers_upsert(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/vault/ciphers", json: attrs)
end
@doc "`GET /api/v1/vault/ciphers/{uuid}`:單一 cipher。"
def vault_ciphers_get(issuer, token, uuid) do
api_request(issuer, token, :get, "/api/v1/vault/ciphers/" <> URI.encode(uuid))
end
@doc "`PUT /api/v1/vault/ciphers/{uuid}`:部分更新(遞增 revision_date)。"
def vault_ciphers_update(issuer, token, uuid, attrs) do
api_request(issuer, token, :put, "/api/v1/vault/ciphers/" <> URI.encode(uuid), json: attrs)
end
@doc "`POST /api/v1/vault/ciphers/{uuid}/delete`:丟進回收桶(soft delete)。"
def vault_ciphers_delete(issuer, token, uuid) do
api_request(issuer, token, :post, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/delete",
json: %{}
)
end
@doc "`POST /api/v1/vault/ciphers/{uuid}/restore`:從回收桶還原。"
def vault_ciphers_restore(issuer, token, uuid) do
api_request(issuer, token, :post, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/restore",
json: %{}
)
end
@doc "`DELETE /api/v1/vault/ciphers/{uuid}/purge`:永久刪除(hard delete)。"
def vault_ciphers_purge(issuer, token, uuid) do
api_request(issuer, token, :delete, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/purge")
end
@doc "`GET /api/v1/vault/folders`:列出資料夾。"
def vault_folders_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/vault/folders")
end
@doc "`POST /api/v1/vault/folders`:以客戶端 UUID upsert 資料夾。"
def vault_folders_upsert(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/vault/folders", json: attrs)
end
@doc "`PUT /api/v1/vault/folders/{uuid}`:重新命名(新的加密名稱)。"
def vault_folders_rename(issuer, token, uuid, attrs) do
api_request(issuer, token, :put, "/api/v1/vault/folders/" <> URI.encode(uuid), json: attrs)
end
@doc "`DELETE /api/v1/vault/folders/{uuid}`:刪除資料夾。"
def vault_folders_delete(issuer, token, uuid) do
api_request(issuer, token, :delete, "/api/v1/vault/folders/" <> URI.encode(uuid))
end
# -- 共用請求輔助 --
# 回傳:
+42 -1
View File
@@ -8,7 +8,18 @@ defmodule BearCli.CLI do
優先使用該值,不讀寫本機憑證檔。
"""
alias BearCli.{Accounts, Admin, Api, Apps, AuditLogs, Config, Credentials, Jwks, SelfService}
alias BearCli.{
Accounts,
Admin,
Api,
Apps,
AuditLogs,
Config,
Credentials,
Jwks,
SelfService,
Vault
}
@global_switches [
issuer: :string,
@@ -327,6 +338,11 @@ defmodule BearCli.CLI do
end
end
# vault 指令群(issue #17;解析委由 BearCli.Vault)
defp parse_command("vault", args, global) do
Vault.parse(["vault" | args], global)
end
defp parse_command(unknown, _args, _global) do
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
end
@@ -584,6 +600,11 @@ defmodule BearCli.CLI do
IO.puts(BearCli.version())
0
{:help, :vault} ->
# vault 群組專屬說明
_ = Vault.run(:help, %{})
0
{:help, _} ->
print_help()
0
@@ -616,6 +637,11 @@ defmodule BearCli.CLI do
audit_logs_api = cmd_opts[:audit_logs_api] || opts[:audit_logs_api] || Api
AuditLogs.run(verb, Map.put(cmd_opts, :audit_logs_api, audit_logs_api))
# vault 指令群({:ok, {BearCli.Vault, verb}, opts} 形狀)
{:ok, {BearCli.Vault, verb}, cmd_opts} when is_atom(verb) ->
vault_opts = opts |> Map.new() |> Map.take([:vault_api, :io, :tty?, :session, :email])
Vault.run(verb, Map.merge(cmd_opts, vault_opts))
{:ok, command, cmd_opts} ->
cond do
cmd_opts[:version] ->
@@ -969,6 +995,21 @@ defmodule BearCli.CLI do
mfa setup
mfa disable
mfa recovery-codes
vault 指令群(任何有效 PAT 皆可;詳見 bear vault --help 與 docs/commands.md §3.11):
vault status 初始化狀態、KDF 參數、解鎖狀態
vault unlock 主密碼解鎖 → 匯出 BEAR_VAULT_SESSION
vault lock 丟棄 session(unset 環境變數)
vault list [--folder UUID] 列出項目(解鎖後顯示明文名稱)
vault get <uuid> 顯示單一項目(解鎖後解密欄位)
vault create --type login|secure_note|card|identity [--folder UUID]
vault edit <uuid> 互動編輯(Enter 保留現值)
vault delete <uuid> 丟入回收桶
vault restore <uuid> 從回收桶還原
vault purge <uuid> 永久刪除(需確認)
vault folders list|create|rename <uuid>|delete <uuid>
vault sync 完整同步(伺服器為準)
vault password change 變更 vault 主密碼
vault rescue 助記詞救援(重設主密碼)
全域選項:
--issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com)
File diff suppressed because it is too large Load Diff
+139
View File
@@ -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
+118
View File
@@ -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
+712
View File
@@ -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