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:
@@ -223,6 +223,87 @@ defmodule BearCli.Api do
|
||||
api_request(issuer, token, :post, "/api/v1/profile/mfa/recovery-codes", json: %{code: code})
|
||||
end
|
||||
|
||||
# -- Vault API(alterminal/bear#41;PAT Bearer、任何有效帳號)--
|
||||
|
||||
@doc "`GET /api/v1/vault/config`:公告的 KDF 參數與支援的加密字串型別。"
|
||||
def vault_config(issuer, token) do
|
||||
api_request(issuer, token, :get, "/api/v1/vault/config")
|
||||
end
|
||||
|
||||
@doc "`GET /api/v1/vault/profile`:vault profile(404=尚未初始化)。"
|
||||
def vault_profile_get(issuer, token) do
|
||||
api_request(issuer, token, :get, "/api/v1/vault/profile")
|
||||
end
|
||||
|
||||
@doc "`PUT /api/v1/vault/profile`:建立或更新 profile(上傳重新包裝的金鑰)。"
|
||||
def vault_profile_put(issuer, token, attrs) do
|
||||
api_request(issuer, token, :put, "/api/v1/vault/profile", json: attrs)
|
||||
end
|
||||
|
||||
@doc "`GET /api/v1/vault/sync`:完整同步(profile+folders+ciphers)。"
|
||||
def vault_sync(issuer, token) do
|
||||
api_request(issuer, token, :get, "/api/v1/vault/sync")
|
||||
end
|
||||
|
||||
@doc "`GET /api/v1/vault/ciphers`:列出 ciphers(含回收桶)。"
|
||||
def vault_ciphers_list(issuer, token) do
|
||||
api_request(issuer, token, :get, "/api/v1/vault/ciphers")
|
||||
end
|
||||
|
||||
@doc "`POST /api/v1/vault/ciphers`:以客戶端 UUID upsert(衝突時整列覆蓋)。"
|
||||
def vault_ciphers_upsert(issuer, token, attrs) do
|
||||
api_request(issuer, token, :post, "/api/v1/vault/ciphers", json: attrs)
|
||||
end
|
||||
|
||||
@doc "`GET /api/v1/vault/ciphers/{uuid}`:單一 cipher。"
|
||||
def vault_ciphers_get(issuer, token, uuid) do
|
||||
api_request(issuer, token, :get, "/api/v1/vault/ciphers/" <> URI.encode(uuid))
|
||||
end
|
||||
|
||||
@doc "`PUT /api/v1/vault/ciphers/{uuid}`:部分更新(遞增 revision_date)。"
|
||||
def vault_ciphers_update(issuer, token, uuid, attrs) do
|
||||
api_request(issuer, token, :put, "/api/v1/vault/ciphers/" <> URI.encode(uuid), json: attrs)
|
||||
end
|
||||
|
||||
@doc "`POST /api/v1/vault/ciphers/{uuid}/delete`:丟進回收桶(soft delete)。"
|
||||
def vault_ciphers_delete(issuer, token, uuid) do
|
||||
api_request(issuer, token, :post, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/delete",
|
||||
json: %{}
|
||||
)
|
||||
end
|
||||
|
||||
@doc "`POST /api/v1/vault/ciphers/{uuid}/restore`:從回收桶還原。"
|
||||
def vault_ciphers_restore(issuer, token, uuid) do
|
||||
api_request(issuer, token, :post, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/restore",
|
||||
json: %{}
|
||||
)
|
||||
end
|
||||
|
||||
@doc "`DELETE /api/v1/vault/ciphers/{uuid}/purge`:永久刪除(hard delete)。"
|
||||
def vault_ciphers_purge(issuer, token, uuid) do
|
||||
api_request(issuer, token, :delete, "/api/v1/vault/ciphers/" <> URI.encode(uuid) <> "/purge")
|
||||
end
|
||||
|
||||
@doc "`GET /api/v1/vault/folders`:列出資料夾。"
|
||||
def vault_folders_list(issuer, token) do
|
||||
api_request(issuer, token, :get, "/api/v1/vault/folders")
|
||||
end
|
||||
|
||||
@doc "`POST /api/v1/vault/folders`:以客戶端 UUID upsert 資料夾。"
|
||||
def vault_folders_upsert(issuer, token, attrs) do
|
||||
api_request(issuer, token, :post, "/api/v1/vault/folders", json: attrs)
|
||||
end
|
||||
|
||||
@doc "`PUT /api/v1/vault/folders/{uuid}`:重新命名(新的加密名稱)。"
|
||||
def vault_folders_rename(issuer, token, uuid, attrs) do
|
||||
api_request(issuer, token, :put, "/api/v1/vault/folders/" <> URI.encode(uuid), json: attrs)
|
||||
end
|
||||
|
||||
@doc "`DELETE /api/v1/vault/folders/{uuid}`:刪除資料夾。"
|
||||
def vault_folders_delete(issuer, token, uuid) do
|
||||
api_request(issuer, token, :delete, "/api/v1/vault/folders/" <> URI.encode(uuid))
|
||||
end
|
||||
|
||||
# -- 共用請求輔助 --
|
||||
|
||||
# 回傳:
|
||||
|
||||
+42
-1
@@ -8,7 +8,18 @@ defmodule BearCli.CLI do
|
||||
優先使用該值,不讀寫本機憑證檔。
|
||||
"""
|
||||
|
||||
alias BearCli.{Accounts, Admin, Api, Apps, AuditLogs, Config, Credentials, Jwks, SelfService}
|
||||
alias BearCli.{
|
||||
Accounts,
|
||||
Admin,
|
||||
Api,
|
||||
Apps,
|
||||
AuditLogs,
|
||||
Config,
|
||||
Credentials,
|
||||
Jwks,
|
||||
SelfService,
|
||||
Vault
|
||||
}
|
||||
|
||||
@global_switches [
|
||||
issuer: :string,
|
||||
@@ -327,6 +338,11 @@ defmodule BearCli.CLI do
|
||||
end
|
||||
end
|
||||
|
||||
# vault 指令群(issue #17;解析委由 BearCli.Vault)
|
||||
defp parse_command("vault", args, global) do
|
||||
Vault.parse(["vault" | args], global)
|
||||
end
|
||||
|
||||
defp parse_command(unknown, _args, _global) do
|
||||
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
|
||||
end
|
||||
@@ -584,6 +600,11 @@ defmodule BearCli.CLI do
|
||||
IO.puts(BearCli.version())
|
||||
0
|
||||
|
||||
{:help, :vault} ->
|
||||
# vault 群組專屬說明
|
||||
_ = Vault.run(:help, %{})
|
||||
0
|
||||
|
||||
{:help, _} ->
|
||||
print_help()
|
||||
0
|
||||
@@ -616,6 +637,11 @@ defmodule BearCli.CLI do
|
||||
audit_logs_api = cmd_opts[:audit_logs_api] || opts[:audit_logs_api] || Api
|
||||
AuditLogs.run(verb, Map.put(cmd_opts, :audit_logs_api, audit_logs_api))
|
||||
|
||||
# vault 指令群({:ok, {BearCli.Vault, verb}, opts} 形狀)
|
||||
{:ok, {BearCli.Vault, verb}, cmd_opts} when is_atom(verb) ->
|
||||
vault_opts = opts |> Map.new() |> Map.take([:vault_api, :io, :tty?, :session, :email])
|
||||
Vault.run(verb, Map.merge(cmd_opts, vault_opts))
|
||||
|
||||
{:ok, command, cmd_opts} ->
|
||||
cond do
|
||||
cmd_opts[:version] ->
|
||||
@@ -969,6 +995,21 @@ defmodule BearCli.CLI do
|
||||
mfa setup
|
||||
mfa disable
|
||||
mfa recovery-codes
|
||||
vault 指令群(任何有效 PAT 皆可;詳見 bear vault --help 與 docs/commands.md §3.11):
|
||||
vault status 初始化狀態、KDF 參數、解鎖狀態
|
||||
vault unlock 主密碼解鎖 → 匯出 BEAR_VAULT_SESSION
|
||||
vault lock 丟棄 session(unset 環境變數)
|
||||
vault list [--folder UUID] 列出項目(解鎖後顯示明文名稱)
|
||||
vault get <uuid> 顯示單一項目(解鎖後解密欄位)
|
||||
vault create --type login|secure_note|card|identity [--folder UUID]
|
||||
vault edit <uuid> 互動編輯(Enter 保留現值)
|
||||
vault delete <uuid> 丟入回收桶
|
||||
vault restore <uuid> 從回收桶還原
|
||||
vault purge <uuid> 永久刪除(需確認)
|
||||
vault folders list|create|rename <uuid>|delete <uuid>
|
||||
vault sync 完整同步(伺服器為準)
|
||||
vault password change 變更 vault 主密碼
|
||||
vault rescue 助記詞救援(重設主密碼)
|
||||
|
||||
全域選項:
|
||||
--issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
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
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
Reference in New Issue
Block a user