Files
bear-cli/lib/bear_cli/vault/bip39.ex
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

140 lines
3.8 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.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