Files
alex 0b18d76cfd 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
2026-09-10 23:27:47 +08:00

119 lines
4.2 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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