軟體架構案例分析 - Bitwarden 會員註冊系統加密設計 軟體架構案例分析 - Bitwarden 會員註冊系統加密設計

Published on Monday, May 15, 2023

Bitwarden 會員註冊系統加密設計

在上一篇文章中我們了解 Bitwarden 的註冊流程,今天來討論它背後是如何進行加密的。

首先我們先看看昨天發送的註冊 POST 請求 http://localhost:33656/accounts/register

{
  "email": "gao333@gmail.com",
  "name": null,
  "masterPasswordHash": "p1zz00J3GLep8IsJL5GTF2US1UyXOvT6yyjREYmHXGk=",
  "key": "2.Dcb1LiBqSXlq4hHFrjSUCg==|135kgwJyzRAnT/3/7bsA3Oz2LSvpANtR04INiTsX/3zrtNQbaV19ZkDLzR32SkkkEzKhGimcMVjzwQpXNLuTuFfSelwT4BuDDr09aGr59xo=|QIDDqRqEcwgt8hmM6kdHm+JoWfydxQ/2oQbCDBpaxzg=",
  "captchaResponse": null,
  "kdf": 0,
  "kdfIterations": 600000,
  "masterPasswordHint": null,
  "keys": {
    "publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl0FJKE24FtBayF+I/+B3OAouHWDXCAgAuH+zIfJej3cgu1C/gyTkmdSn2Y3+nEViaIJ6ICOBM0ynvTew4VzpnGtEsp7vBknO+0LFIG05uoK9Yoge+cN6sboNOkV5ZFa+5y3EjecVFPFVCpH6j0xGFSQZvcE+eQdjj6L6SvReAnMXLEASqudfayweVKtRIt/tGoP4y6+3doueT5OkKBXBME9ObDLxKHixqiId3EuTfw1FYxdX9rDO7WCzSEsy2Og/QZC5TeVur0vtq7e2eR80jYwyB4pBGPWD2U2NDbyALeIrSX//Jhm7ViAzZbDj9yG/mnVm2CRdHbjp9OPp++rNAQIDAQAB",
    "encryptedPrivateKey": "2.08/OkxkS/7kwK+/UYEcUhw==|P1tfsm/U7k+aTrHrz0HWEklsvmn+t70BtU68ixzO4LXhMcQzfMlh+MEwsRUyxGp6UseeGhyILmONwpOrOy+i0NcPZ+VfG6/xpjAXhV68xyfmEbtd3nwEUMiKOeu8r+9PlEAhnpGnpkld9wj1JMzdSyd1v73+BE0r/RzN6Lit8HVIGnipve+8Zmj2nPk01bZKWXnlVFlMJOh1hxPTCLbDD/UP2/DjdnsWtwVp/W9tpJQggRud6HEaJjuKhFY2MymEvTUbvFXppFTCrm1hCEIQqliT4l1Y1XqbyIxZjUaJVgylZw+uzDb4xvrpX2IwC5AORKKIdKy1Hi+Wq7fnXL6L0LzCEEONGr1ekJK8pqXgNL8Ys5iVGVZw6nGEL9QVcT0Gmd5334gPMcuaoyCNB2katXllO2p+/PX6V+GLKTEvkJ0qfcYOTEnftOl2aipzpSw9fw9L6D/AiRzLREWi7Jd5MvbbN522fFzOpX/Eyh4JymgZeGwaj8gGS73oDUgAtU0OU4A/q1+6dLoM6GPo4M0/e3OXXBlcyJbzBS1YCeppUud5DcGaUNeXQ36+Lj/13M3UUyrxj/bZ3srYRFuQERwNVYjAYFcT/OPWYyM8dh9ipQkqkI3UMl+WwXjHTgJvnFune1KduYmKS6+sKbQJ8u6ONbFjQA8BtUEWUfNtiUvCRcACPM8jUxB4Uqoa23LBlLNFYSIvWWP/2v4EvKMSkTH24LzJU6ktpaA8Zg/7WY78W9GDhUvDZaKF/Ke42a0z12ixx0+4OU6fpTtMeE0b1nh7znFSW+tP/eiibN07sXh8NAAfgRjNHQ/OEkSREvQNSUrykEeZ+b80TVATMhtP4NxZXGDm+5jK1HhPGzsrGaq9uVONNBoqG0Bmc50QY8ExQqxPnye00MRnPZVDL1R7Loh2I3joCNLkBR+tt6W1p8i9MD8xiSYIhVgs4+uAUGycNPVSGjzj5/BYYRmJiH9WvQAa7JkljY1pSCMmTGCliXgX5popQErAxknuFv6JrMwexZC2C3nrl3cptjRG0W1k9oa1JhoX+Fv8Y9NUED6+8jDlNyJio7mKYeaMPqBZSRMqjHsuc1myXswpREUQtomlVC7R/CJv4zSuUkWww9TIoEL5NhbxO9EkDL6hCh6TVChMINJu5EyVnFxv1UX4MLKetwJ1hohdUMj3rmTxEpAk09epdNaU1IqlzBGlNxAhmvE0oJtnUALqJ2Dl5+wLCetK0eHGojqGBuW59+iOJG1iLtEQQdjtZCvlVQKXCmxUagmAJfJNxopcqgBBuCmIUsLvFNk/fzJH8/5tyaMCx+yp5/67yDexSNgFqDuhrS0C3nL9VxlieIPvM//gwF6x4EGTi/r0UwusnlVj3aViuJyeOvF+97/sItctT7vupDdzjc4vYAwk+wLf707qSyg7Q6iQ+RTJKBSK4q8V++e54uVWFZ+kCq0CP08HAbOG38vgQiN7pmAsOK8yykjeLhQ+3q725b/dasw4RqMsn4Zw2JPGuhoI5GJeQ7ODfrTCn8iCvXYglN0k+nOldElzfKsUR9Jyt7VYnonF4KwYMYMyQhqROQpbv9+HV0VyKJGvNutPPqHJchfdnJY5uzzw/ygKjjU/NInuDOBLayZvmI+HqwA5dJLh7Do=|t8HEW+pDxMvX/gy6tn4PBlKlqeaGiTKPwDPGRCkAT68="
  }
}

與我們平常常見的直接明碼傳輸帳號密碼的設計不同,在 bitwarden 中反而是優先在 Client 端進行 PBKDF2 運算出 hash 值在傳送到後端, 這樣做的好處是我們最重要的主密碼永遠不會透過網路傳輸並且在計算出 hash 之後主密碼就會盡快從記憶體內清除這兩個作法可以大大增加安全性

我們先看一下 bitwarden 提供的這張架構圖
Bitwarden-Regiser-Encrypt

從最左邊看起當我們按下送出之後第一步驟是計算 MasterKey ,會根據我們的 email 與 master password 做 PBKDF2-SHA256 Iteration 600000 次

byte[] masterKeyByte = PBKDF2.DeriveKey(password, salt, 600000, 256, new HMACSHA256());
string masterKey = Convert.ToBase64String(masterKeyByte);

MasterKey: 7nD/dc7PF1jWAIkISuVbUYEFGIADMEIogaASeUJ+2h0=

接下來第二步驟會是計算出 MasterPasswordHash 使用剛剛計算的 MasterKey 與明碼的 master password 做 PBKDF2-SHA256 運算 1 次

byte[] masterPasswordHashByte = PBKDF2.DeriveKey(masterKeyByte, password, 1, 256, new HMACSHA256());
string masterPasswordHash = Convert.ToBase64String(masterPasswordHashByte);

MasterPasswordHash: p1zz00J3GLep8IsJL5GTF2US1UyXOvT6yyjREYmHXGk=

這個 MasterPasswordHash 可以在 POST 請求中看到這個值之後準備完請求後會一起發送到後端

之後的步驟會與一開始的架構圖有些為差異但還是可以參考

第三步驟需要拿第一步驟計算出的結果 MasterKey 進行 HKDF.Expand 運算獲得 EncKeyMacKey 將兩個 Key 合併在一起得到 Stretched Master Key

byte[] strectchedMasterKeyByte = new byte[64];
byte[] encKeyByte = HKDF.Expand(HashAlgorithmName.SHA256, masterKeyByte, 32, Encoding.UTF8.GetBytes("enc"));
byte[] macKeyByte = HKDF.Expand(HashAlgorithmName.SHA256, masterKeyByte, 32, Encoding.UTF8.GetBytes("mac"));
System.Buffer.BlockCopy(encKeyByte, 0, strectchedMasterKeyByte, 0, 32);
System.Buffer.BlockCopy(macKeyByte, 0, strectchedMasterKeyByte, 32, 32);

string encKey = Convert.ToBase64String(encKeyByte);
string macKey = Convert.ToBase64String(macKeyByte);
string strectchedMasterKey = Convert.ToBase64String(strectchedMasterKeyByte);

EncKey: HHvL6GlIk5M43OerVI8+PuD4Xymst3k0eOPINJs/e0s=
MacKey: WXLNqhg4V80UDMFDFtjJGi4qGRjfaKjpcyPrrbKQ1Fw=
StrectchedMasterKey: HHvL6GlIk5M43OerVI8+PuD4Xymst3k0eOPINJs/e0tZcs2qGDhXzRQMwUMW2MkaLioZGN9oqOlzI+utspDUXA==

在 bitwarden 的設計當中有另一組與主密碼一樣重要的 Key 叫做 Protected Symmetric Key,我們需要先在 Client 端 亂數產生 Symmetric KeyIV 在拿第三步驟的 Stretched Master Key 最後使用 AES-256 加密後產生 Protected Symmetric Key 才能安全的上傳到 Bitwarden 的雲端倉庫內保存避免外洩的問題,之後登入帳號時只要從雲端下載 Protected Symmetric Key 並進行解密 得到當初亂數產生 Symmetric Key,我們以後在金庫寫入的各個網站密碼時就可以用 Symmetric Key 來加解密資料了, 金庫解鎖到我們登出這端時間會把 Symmetric Key 載入到記憶體內之後只我們只需要使用上鎖功能就能快速的把 Symmetric Key 從記憶體內清除。

第四步驟我們需要建立亂數產生器產生一組 Symmetric KeyIV

using RandomNumberGenerator rng = new RNGCryptoServiceProvider();
byte[] symmetricKeyByte = new byte[64];
byte[] iVByte = new byte[16];
rng.GetBytes(symmetricKeyByte);
rng.GetBytes(iVByte);
string symmetricKey = Convert.ToBase64String(symmetricKeyByte);

IV: EUPg0Fo+eISDtkyxwULT7g==
SymmetricKey: QtKDHP65U4b/9N2iva/HyVe2VYJNHJZ3W8EsiHgSRv/Qa/tHdFQPMSa++2bx9l5drECNmCSt6bYDL0OCEnXMdg==

第五步驟將使用第四步驟的 Symmetric KeyIV 與第三步驟的 EncKey 進行 AES 加密, 要注意這裡加密的內文為 Symmetric Key 加密的金鑰為 EncKey

var aesEncryptSymmetricKeyWithEncKeyByte = new byte[] { };

Aes aes = Aes.Create();
aes.Mode = CipherMode.CBC;
aes.Key = encKeyByte;
aes.IV = iVByte;

using (MemoryStream ms = new MemoryStream())
using (CryptoStream cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write))
{
	cs.Write(symmetricKeyByte, 0, symmetricKeyByte.Length);
	cs.FlushFinalBlock();
	aesEncryptSymmetricKeyWithEncKeyByte = ms.ToArray();
}

string aesEncryptSymmetricKeyWithEncKey = Convert.ToBase64String(aesEncryptSymmetricKeyWithEncKeyByte);

AesEncryptSymmetricKeyWithEncKey: n9M7jSQ0kRZWkU0QvsCBFM/ZKqYRJ+v+WYYiKjjBYrLWX50ZOLXyidkmSdqoZ4j/sZsYaqCs111rfTDyaNslgNDpwJf1ovuPYiLrF3SIeXU=

第六步驟合併 IV 與第五步驟的AesEncryptSymmetricKeyWithEncKey 後使用第三步驟 MacKey 運行 HMACSHA256 運算 得到 HashEncryptSymmetricKeyWithMacKey

byte[] encryptSymmetricKeyByte = new byte[iVByte.Length + aesEncryptSymmetricKeyWithEncKeyByte.Length];
byte[] hashEncryptSymmetricKeyWithMacKeyByte = new byte[]{};

System.Buffer.BlockCopy(iVByte, 0, encryptSymmetricKeyByte, 0, iVByte.Length);
System.Buffer.BlockCopy(aesEncryptSymmetricKeyWithEncKeyByte, 0, encryptSymmetricKeyByte, iVByte.Length, aesEncryptSymmetricKeyWithEncKeyByte.Length);
using (var hmacSHA256 = new HMACSHA256(macKeyByte))
{
	hashEncryptSymmetricKeyWithMacKeyByte = hmacSHA256.ComputeHash(encryptSymmetricKeyByte);
}
string hashEncryptSymmetricKeyWithMacKey = Convert.ToBase64String(hashEncryptSymmetricKeyWithMacKeyByte);

HashEncryptSymmetricKeyWithMacKey: rMQJ4ncauPnKyyHtJSDXPpb2iCJOG7Kmf7l2BmG2uMg=

最後第七步驟將 IV 與第五步驟和第六步驟的結果合併成一個字串,這個就是最終的 ProtectedSymmetricKey 將會跟著 API 一起傳送到後端

var protectedSymmetricKey = $"2.{Convert.ToBase64String(iVByte)}|{Convert.ToBase64String(aesEncryptSymmetricKeyWithEncKeyByte)}|{Convert.ToBase64String(hashEncryptSymmetricKeyWithMacKeyByte)}";
	
ProtectedSymmetricKey: 2.EUPg0Fo+eISDtkyxwULT7g==|n9M7jSQ0kRZWkU0QvsCBFM/ZKqYRJ+v+WYYiKjjBYrLWX50ZOLXyidkmSdqoZ4j/sZsYaqCs111rfTDyaNslgNDpwJf1ovuPYiLrF3SIeXU=|rMQJ4ncauPnKyyHtJSDXPpb2iCJOG7Kmf7l2BmG2uMg=

經過以上步驟現在可以清楚了解 POST 請求當中的 masterPasswordHashkey 是如何算出來的了

另外當後端收到請求最保存階段 DbContext 會使用 .net 提供的 AddDataProtection 方法分別對 masterPasswordHashkey 進行加密確保儲存到資料庫的值是有經過再次加密的,最後拼上 bitwarden 加密資料的 prefix p| 並保存到資料庫內

var dataProtector = this.GetService<DP.IDataProtectionProvider>().CreateProtector(
    Constants.DatabaseFieldProtectorPurpose);
var dataProtectionConverter = new DataProtectionConverter(dataProtector);
eUser.Property(c => c.Key).HasConversion(dataProtectionConverter);
eUser.Property(c => c.MasterPassword).HasConversion(dataProtectionConverter);
        
MasterPassword: P|CfDJ8N83xunRSMFEnHYjbQSeE4Tp1NFY3midxCUnWMtKTUuNMJwjgkpaWsYikSh3ODX5tLSoPmGz8itOUw_xZ0-pDPGBhv3cqJ1aCacMEagzwf6TC7gEJ8BRqzFTSrx56PMLCmjwcE_2wdpg97Ux6tP3NRKxVZ5SWrxcz7Ap8oe3qOCSubAoCe_Fl5rUGkA42ce2LPmdQ2l0IjFV4b09A_xahssaxsG8GGIVmjeA9yrXjt73
Key: P|CfDJ8N83xunRSMFEnHYjbQSeE4Qjzc5zanC0jjC5_kOH-DW9PLZeW_fAlZb4nIWyrdb9sgIhxMT-X0ltA3pwsUM9o93w8tg2b8DBQhwcTcWjnKg1aArSqVhsaqzoMSN4c9erB41ui_0KxnOpj7QLa--kkRQKKcWf2UMfQIOqJ_W0yW0Kw-a3WmTqPvIyRPdTZFqq032rRx65yVRDO7FujZujM-cvO8lrNJqJTS6Je8WN83dtk4gZ_dK594y58xF92Hr3ZCPc7_4QlNw8Zs3loUFHwgFqLSLzuejsryJVNoU_PcgBr0ezvaUy5EbWIVvdaBZ3CzpsVhzgR0os3fIFg26yOz3CVUfPCM7QL5eD8nf8Kh0R

另外需要注意在 bitwarden 中自定義一個 AddDataProtection 方法並且在正式環境時會將加密憑證保存到 AzureBlobStorage 上以確保安全, 同時我們也知道正常情況下會將金鑰存放在本機 %LOCALAPPDATA%\ASP.NET\DataProtection-Keys 路徑之下,所以在多機環境下可能會造成 密碼無法解密的問題所以存放在 Azure 也可以同時解決多機部屬的問題,也可以避免本伺服器中毒時密鑰文件有可能被外洩的問題

public static void AddCustomDataProtectionServices(
    this IServiceCollection services, IWebHostEnvironment env, GlobalSettings globalSettings)
{
    var builder = services.AddDataProtection().SetApplicationName("Bitwarden");
    if (env.IsDevelopment())
    {
        return;
    }

    if (globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.DataProtection.Directory))
    {
        builder.PersistKeysToFileSystem(new DirectoryInfo(globalSettings.DataProtection.Directory));
    }

    if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Storage?.ConnectionString))
    {
        X509Certificate2 dataProtectionCert = null;
        if (CoreHelpers.SettingHasValue(globalSettings.DataProtection.CertificateThumbprint))
        {
            dataProtectionCert = CoreHelpers.GetCertificate(
                globalSettings.DataProtection.CertificateThumbprint);
        }
        else if (CoreHelpers.SettingHasValue(globalSettings.DataProtection.CertificatePassword))
        {
            dataProtectionCert = CoreHelpers.GetBlobCertificateAsync(globalSettings.Storage.ConnectionString, "certificates",
                "dataprotection.pfx", globalSettings.DataProtection.CertificatePassword)
                .GetAwaiter().GetResult();
        }
        builder
            .PersistKeysToAzureBlobStorage(globalSettings.Storage.ConnectionString, "aspnet-dataprotection", "keys.xml")
            .ProtectKeysWithCertificate(dataProtectionCert);
    }
}

最後來查看 ProtectedSymmetricKey 的三個部份
第一個部份是 2. 這個數字是 enum type 用來對應加密的方式

export enum EncryptionType {
  AesCbc256_B64 = 0,
  AesCbc128_HmacSha256_B64 = 1,
  AesCbc256_HmacSha256_B64 = 2,
  Rsa2048_OaepSha256_B64 = 3,
  Rsa2048_OaepSha1_B64 = 4,
  Rsa2048_OaepSha256_HmacSha256_B64 = 5,
  Rsa2048_OaepSha1_HmacSha256_B64 = 6,
}

第二個部份是 IV 值與 AES 加密過後的 AesEncryptSymmetricKey,我們知道金庫裡所有的內容都需要使用解密過後的 SymmetricKey 才能還原出原始的數值,目前我們已經有 IV 值現在只缺少 EncKey, 要獲得 EncKey 也很簡單我們只要輸入主密碼就能獲得 EncKeyMacKey 之後我們可以輕鬆的還原出 SymmetricKey 來操作我們的金庫了

第三部份是是 HashEncryptSymmetricKey,我們知道這個值是由 IV 值與 AesEncryptSymmetricKey 使用 MacKey Hash 出來的 所以當我們登入 bitwarden 後從雲端下載資料時就可以拿著本地的 MacKey 去和 IV 值與 AesEncryptSymmetricKey 快速的 Hash 一次 即可知確保 IV 值與 AesEncryptSymmetricKey 的完整性


Summary

本文章學習了 Bitwarden 是如何加密會員的機密資料,從今天的內容中可以得知機密的資料只會存留在本機環境,雲端只會被用來同步資料並且可以確定 只有加密過得資料才會被上傳道 Bitwarden 資料庫,同時也相當重要的 DataProtectionKey 也保存在第三方環境這樣如果資料庫不小心外流只要沒有 DataProtectionKey 駭客想要解密資料庫是很難做到的,就算真的被解密之後還有 MasterPassword 這關做保護因為光是每次計算有添加 Salt 的 MasterPasswordHash 就最少要 Iterations 600001 次很難短時間內被計算出來,並且也只要定期更換主密碼就可以避免掉這個問題