feat: 實作 vault 指令群 status/unlock/lock/list/get/create/edit/delete/restore/purge/folders/sync/password/rescue(issue #17)

- 對接 alterminal/bear#41 的 /api/v1/vault JSON API(PAT Bearer)
- 客戶端端到端加密,格式與 Web Vault(P2)完全一致:
  - 加密字串 2.<iv>.<ct>.<mac>(AES-256-CBC+HMAC-SHA-256、PKCS#7、encrypt-then-MAC)
  - 主金鑰 PBKDF2-SHA512(迭代數取自 /vault/config 與 profile,不寫死;salt=email 小寫)
  - BIP39 助記詞(12 字、128-bit、英文詞表)+救援路徑
- K_user 僅存記憶體:vault unlock 匯出 BEAR_VAULT_SESSION(base64),
  同 Bitwarden CLI BW_SESSION 慣例;lock 提示 unset;不寫入任何檔案
- docs/commands.md 補 §3.11 規格;README 同步
- 測試:fake API 注入+Bear.Vault.Crypto 密文樣本交叉驗證(雙向);
  BIP39 官方向量;46 個新測試,全套 196 passed
This commit is contained in:
2026-09-10 23:27:47 +08:00
parent a880bcea4d
commit 0b18d76cfd
9 changed files with 2488 additions and 2 deletions
+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