defmodule BearCli.Vault.Crypto do @moduledoc """ 客戶端 vault 加密原語(issue #17,對應 bear 倉庫 Bear.Vault.Crypto 與 Web Vault 的 assets/js/vault/crypto.js)。 格式必須與 Web Vault **完全一致**(同一 vault 兩端互相可解): - 加密字串 `"2..."`(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 使用者金鑰(`<>`)。" def generate_user_key do :crypto.strong_rand_bytes(64) end # -- 加密字串(type 2)-- @doc """ 以 64-byte `user_key` 加密明文,回傳加密字串 `"2..."` (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 <> = 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 <> = 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) 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) do binary_part(data, 0, byte_size(data) - pad_len) else :error end end end