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(<>, 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