Bitwarden 如何加密和解密秘密How Bitwarden Encrypts and Decrypts Secrets
文章探讨了 Bitwarden 密码管理器在自托管场景下的加密机制,重点分析了其使用 SQLite 数据库存储加密数据的技术实现。作者研究了 Vaultwarden——一个开源的 Bitwarden 服务器克隆项目,发现它通过客户端加密将敏感信息在传输前就进行加密,服务器端仅存储密文。文章揭示了 Bitwarden 采用 AES-256-GCM 算法和基于用户主密码的密钥派生机制,确保即使数据库泄露也不会暴露明文密码。最终结论是,这种设计使得自托管方案既安全又便于备份,为减少对大型科技公司的依赖提供了可行路径。
Miguel Grinberg
为了减少对大型科技公司的依赖,我一直在研究如何自建密码管理器。Vaultwarden 是一个非常有前景的开源方案,它是 Bitwarden 云服务器的克隆版本。这个服务器的一个有趣之处在于,它使用标准的 SQLite 数据库存储所有密钥,因此除了拥有一个自托管的密码服务器外,我还可以将数据库备份到本地机器上,并直接查询其中的数据。但当然,这些密钥在数据库中是加密的,除非我学会如何解密它们——就像 Bitwarden 客户端所做的那样,否则这些数据毫无用处。
说到 Bitwarden 客户端,就在我写这篇文章的时候,官方 Bitwarden CLI 客户端被曝出在一次供应链攻击中被植入了恶意代码。这是我个人在所有电脑上都会使用的工具,所以这件事对我来说是一次警醒。幸运的是,我并没有自己安装那个被污染的版本,但我认为,与其依赖那些黑客们都在盯着的现成客户端,不如自己动手开发一个自己的密钥管理客户端,这确实有它的道理!
在这篇文章中,我将分享 Bitwarden 及其克隆项目 Vaultwarden 中密钥加密的工作原理。我还会在文中附上可运行的 Python 代码,如果你也想动手尝试,或者像我一样对构建自己的安全工具感兴趣,这些代码应该会对你有所帮助。
宏观视角
好了,让我们开始吧,首先从足够高的层面来简化问题。
Bitwarden、Vaultwarden 以及几乎所有像样的密码管理器都会在服务器端将所有你的密钥进行加密存储。当我提到“密钥”时,我指的不仅仅是密码,还包括用户名、网址、备注、附件等与每个密钥相关的所有内容。Bitwarden 甚至还会加密你给每个密钥起的名字。只有客户端才知道如何进行加密或解密操作,而且它总是在将数据发送到服务器之前先对其进行加密。服务器只知道如何从数据库中存储和检索加密后的数据块。
为了加密和解密密钥,客户端会使用一个与你的账户关联的主密钥(master key)。这个主密钥是在你创建账户时由客户端随机生成的一组字节序列。和密钥本身一样,这个主密钥也会在发送到服务器存储之前被加密。用于加密主密钥的算法与加密密钥的算法类似,但这次用来加密的密钥是由用户为保护账户而选择的口令短语(passphrase)生成的。
所以你明白了吧,很多人以为口令短语是直接用来加密你的密钥的,其实不然,它只是用来加密主密钥的。要解密你的密钥,Bitwarden 客户端首先需要用你的口令短语来解密主密钥,然后再用主密钥去解密你真正的密钥。当客户端保持你的保险库处于解锁状态时,这意味着它只是在内存中保留了一份已解密的主密钥(或者整个已解密的保险库),这样你就可以在不再次输入口令短语的情况下获取密钥。要锁定你的保险库,客户端只需要丢弃主密钥即可。
主密钥
接下来我会详细讲解具体的工作原理,据我所知这些细节目前并没有正式文档说明。我不得不深入查阅 Bitwarden 和 Vaultwarden 的源代码才能搞清楚其中很多细节。
如前所述,Bitwarden 上的每个用户都有一个主密钥,它在加密和解密秘密时被用作加密密钥。主密钥是一个随机生成的 64 字节序列,因此它本身并无特别之处。以下是如何直接在 Python 控制台中生成主密钥的示例:
>>> import random
>>> random.randbytes(64)
b'\xa3?\xbc\x86\x18\x7f\x9c|\xe2\xf1\x10\xd4\xee B\xde\x93\x12g\x03\\\x83\x9a\xc5S<!\x18\xc0\x0eRp\xb5\xbc`\xfc\xceu)\x93Q\x84r\xaa\xd6\xde\x1f\xc6Y\x92\x85?\xf8j\x95\xe98\x8e\xe5\xe0\x98\xd8\x85\x9c'Bitwarden 将密钥分为两个各为 32 字节的半部分。前半部分用作 256 位加密密钥,后半部分用于生成消息认证码(MAC),即验证加密字符串完整性的密码学签名。让我们将密钥拆分为两个组成部分,我将其分别称为 enc_key 和 mac_key:
# the following master key is used for demonstration purposes, never write a real master key in your code!
master_key = b'\xa3?\xbc\x86\x18\x7f\x9c|\xe2\xf1\x10\xd4\xee B\xde\x93\x12g\x03\\\x83\x9a\xc5S<!\x18\xc0\x0eRp\xb5\xbc`\xfc\xceu)\x93Q\x84r\xaa\xd6\xde\x1f\xc6Y\x92\x85?\xf8j\x95\xe98\x8e\xe5\xe0\x98\xd8\x85\x9c'
enc_key = master_key[:32]
mac_key = master_key[32:]解码 Bitwarden 秘密
现在我们有了主密钥,可以来看看如何解码一个秘密。为此,我先展示一个秘密的示例,其格式与存储在 Bitwarden 或 Vaultwarden 数据库中的完全一致。如下所示:
encrypted_secret = '2.IkWFb104bXv7Zwl7eFbsnQ==|SB42jIOvjhV32hSusW/J7WfAnQV8DKIV/CJQB7IDaiz4lQv4lIcXzWp9+IT0ncVQ|S8Tcp2klhcOOzZvoA0C9WRURaWUq+U1F9jbuBskDIz0='加密的秘密以加密格式版本号开头,后跟一个句点。当前使用的版本是版本 2。句点之后是加密的有效载荷,包含三个由管道符分隔的部分:
这三个部分均为采用 base64 编码的二进制序列,确保所有字符均可打印。以下是更多用于将秘密解码为其各部分的 Python 代码:
from base64 import b64decode
# ...
version, payload = encrypted_secret.split('.', 2)
if version != '2':
raise ValueError('Unsupported encryption version')
fields = payload.split('|')
if len(fields) != 3:
raise ValueError('Invalid encrypted data')
iv = b64decode(fields[0])
ciphertext = b64decode(fields[1])
mac = b64decode(fields[2])在尝试解密秘密之前,我们需要确保加密字符串未被损坏或篡改。为此,我们可以独立计算 MAC 签名,然后将其与秘密中包含的 mac 值进行比较。如果两者不同,则说明加密字符串已被损坏或篡改,此时应丢弃该秘密,视为无效。
对于签名,Bitwarden 使用 SHA-256 哈希函数对 iv 和 ciphertext 的连接结果计算标准的 HMAC 哈希。用于计算此哈希的密钥是主密钥中的 mac_key 部分。如果你不熟悉密码学函数,只需知道这是一个标准的密码学计算,非常常见,Python 标准库中已有支持。以下是计算 mac 值并确认其是否与秘密附带的 mac 值一致的 Python 代码:
import hashlib
import hmac
# ...
calculated_mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
if calculated_mac != mac:
raise ValueError('Invalid data or key')如果 MAC 测试通过,则一切正常,可以继续解密该值。
Bitwarden 使用的加密算法也广为人知,名为 AES,即高级加密标准。如果可以接受一点额外的密码学术语,我可以补充说明:该加密采用 CBC 操作模式、128 位块大小以及 PKCS#7 填充。秘密中的 iv 部分是 AES 加密所需的初始化向量参数,而主密钥中的 enc_key 部分则作为加密或解密密钥使用。你无需理解所有这些术语的含义,因为我稍后会展示一段 Python 代码来处理全部细节。
遗憾的是,Python 标准库不包含对 AES 的支持,因此需要安装第三方包。我找到了几个选项,选择了 pyaes,它是一个纯 Python 编写的库。如果你打算在自己的计算机上安装它,建议先创建一个虚拟环境。
pip install pyaes以下是解密该密钥的 Python 代码:
import pyaes
# ...
decrypter = pyaes.Decrypter(pyaes.AESModeOfOperationCBC(enc_key, iv))
secret = decrypter.feed(ciphertext) + decrypter.feed()
print(secret)pyaes 包中的解密器对象通过其 feed() 方法接收数据。它设计为可以分块传入加密数据,因此当所有数据发送完毕后,必须调用一个不带参数的 feed() 方法来表示数据已全部发送。该方法也会以分块的形式返回解密后的数据,因此需要将所有返回值拼接起来。
想试试吗?以下是一个完整的 Python 脚本,用于解密该密钥:
from base64 import b64decode
import hashlib
import hmac
import pyaes
# the following master key is used for demonstration purposes, never write a real master key in your code!
master_key = b'\xa3?\xbc\x86\x18\x7f\x9c|\xe2\xf1\x10\xd4\xee B\xde\x93\x12g\x03\\\x83\x9a\xc5S<!\x18\xc0\x0eRp\xb5\xbc`\xfc\xceu)\x93Q\x84r\xaa\xd6\xde\x1f\xc6Y\x92\x85?\xf8j\x95\xe98\x8e\xe5\xe0\x98\xd8\x85\x9c'
enc_key = master_key[:32]
mac_key = master_key[32:]
encrypted_secret = '2.IkWFb104bXv7Zwl7eFbsnQ==|SB42jIOvjhV32hSusW/J7WfAnQV8DKIV/CJQB7IDaiz4lQv4lIcXzWp9+IT0ncVQ|S8Tcp2klhcOOzZvoA0C9WRURaWUq+U1F9jbuBskDIz0='
version, payload = encrypted_secret.split('.', 2)
if version != '2':
raise ValueError('Unsupported encryption version')
fields = payload.split('|')
if len(fields) != 3:
raise ValueError('Invalid encrypted data')
iv = b64decode(fields[0])
ciphertext = b64decode(fields[1])
mac = b64decode(fields[2])
calculated_mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
if calculated_mac != mac:
raise ValueError('Invalid data or key')
decrypter = pyaes.Decrypter(pyaes.AESModeOfOperationCBC(enc_key, iv))
secret = decrypter.feed(ciphertext) + decrypter.feed()
print(secret)运行该脚本即可看到我在本例中加密的密文消息:
$ python decrypt.py
b'The quick brown fox jumps over the lazy dog'保护主密钥
上一节的示例包含了用于解密 Bitwarden 密钥所需的大部分逻辑,但其中的主密钥以明文形式直接写在代码中。Bitwarden 服务器将主密钥以加密字符串的形式返回给客户端,客户端需要用户输入邮箱和密码短语才能解密该密钥。现在我们将这部分功能添加到脚本中。
和上一节处理密钥的方式一样,我将先展示一个加密后的主密钥示例,然后逐步说明如何对其进行解密。下面是我之前使用的主密钥,现已加密:
encrypted_master_key = '2.i5dH92a79wJ8L8tsqQEdLw==|a5swb8CeW5cTM2N+XQZCF+mX263BMaag+ghxiu+ci4W+fqqLZ82g+i7ReIcdiPLafoCAmeWZE48PETGJOsoOb6DcrK3sRdvHCx8xbRt1Xas=|MhY1/he1RntesSYJyZqFh8s8dQTXqmPRQM2hVcsjIWk='你觉得怎么样?看起来是不是很熟悉?Bitwarden 对主密钥的加密方式与对密钥的加密方式相同。唯一不同的是,这次加密所用的 enc_key 和 mac_key 是从用户的邮箱地址和密码短语派生而来的。
由于我们需要先运行解密算法来解密主密钥,然后再用主密钥解密其他内容,因此将所有这些逻辑重构为一个函数是有意义的:
from base64 import b64decode
import hashlib
import hmac
import pyaes
def decrypt(encrypted_data, enc_key, mac_key):
version, payload = encrypted_data.split('.', 2)
if version != '2':
raise ValueError('Unsupported encryption version')
fields = payload.split('|')
if len(fields) != 3:
raise ValueError('Invalid encrypted data')
iv = b64decode(fields[0])
ciphertext = b64decode(fields[1])
mac = b64decode(fields[2])
calculated_mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
if calculated_mac != mac:
raise ValueError('Invalid data or key')
decrypter = pyaes.Decrypter(pyaes.AESModeOfOperationCBC(enc_key, iv))
return decrypter.feed(ciphertext) + decrypter.feed()现在我们需要的,是生成 enc_key 和 mac_key 的值,以便将它们与加密的主密钥一起传递给该函数。为此,Bitwarden 使用了两种标准密码学操作——密钥派生和密钥拉伸的组合,并将其应用于用户输入的信息。
让我们先从用户那里获取他们的信息:
from getpass import getpass
# ...
email = input('Email address: ')
passphrase = getpass('Passphrase: ')现在我们需要利用这些信息“派生”出一个我们可以使用的密钥。对于密钥派生,Bitwarden 使用的是 PBKDF2 函数,而该函数在 Python 标准库中也有提供,因此非常常见:
temp_key = hashlib.pbkdf2_hmac('sha256', passphrase.encode(), email.encode(), 600000, 32)你可能已经注意到,我们使用的绝大多数密码学函数都是基于 SHA-256 哈希函数构建的操作。密钥派生也不例外,但对于这个函数,哈希函数的名称必须以字符串形式给出。
密钥派生函数接受密码短语和盐作为参数。在密码学中,盐是一种额外的输入,添加到主要载荷中,目的是增加攻击者进行暴力破解的成本。在此场景中,Bitwarden 使用电子邮件地址作为盐值。该函数需要密码短语和电子邮件地址的二进制形式,因此我使用 encode() 方法将它们转换为字节。
密钥派生函数通过重复执行基本操作来实现多次迭代。这样做一方面是为了尽可能地将派生出的密钥与原始密码短语分离,另一方面则是为了提高计算成本——如果有人试图用暴力破解的方式获取密钥,你自然希望这个过程对攻击者来说慢得难以忍受。目前 Bitwarden 账户使用的是 60 万次迭代。Bitwarden 客户端会从服务器接收与该账户关联的正确迭代次数,因此这个数值不一定是固定的 60 万。随着硬件性能提升,该迭代次数也会逐步增加以维持安全性。
密钥派生函数的最后一个参数是派生长度,单位为字节。Bitwarden 使用的密钥长度为 32 字节。
需要说明的是,Bitwarden 允许用户将密钥派生函数更换为另一种名为 Argon2id 的算法。Argon2id 被认为比 PBKDF2 更安全,但同时也更消耗内存和计算资源。我推测未来某个时候 Bitwarden 可能会把 Argon2id 设为默认选项。
不过你可能已经注意到,我在上面的代码中称这个派生密钥为 temp_key。这是因为我们还没完成——还记得吗?我们需要两个各为 32 字节的密钥:一个用于加密,另一个用于 MAC 签名计算。Bitwarden 使用 HKDF-Expand 密钥扩展算法从 temp_key 生成这两个密钥。
HKDF 算法并未包含在 Python 标准库中,因此需要安装第三方包:
pip install hkdf下面是如何将 temp_key“拉伸”为我们所需的两个密钥:
from hkdf import hkdf_expand
# ...
master_enc_key = hkdf_expand(temp_key, b'enc', 32, hashlib.sha256)
master_mac_key = hkdf_expand(temp_key, b'mac', 32, hashlib.sha256)密钥拉伸算法接受源密钥、提供操作上下文的 "info" 参数、拉伸后密钥的长度(以字节为单位),以及再次使用的底层加密哈希函数(如前所述,此处仍为 SHA-256)。
Bitwarden 分别为每个密钥拉伸操作使用 enc 和 mac 作为上下文。这使得我们可以使用相同的源密钥,却能得到完全不同的拉伸结果密钥。
现在我们可以用之前提到的 decrypt() 函数来解码主密钥了:
master_key = decrypt(encrypted_master_key, master_enc_key, master_mac_key)现在我们已经有了主密钥,就可以用它配合 decrypt() 函数来解密 secret 了:
enc_key = master_key[:32]
mac_key = master_key[32:]
print('Secret:', decrypt(encrypted_secret, enc_key, mac_key).decode())为了保持代码整洁,我决定把解密主密钥的逻辑重构为一个函数:
def decrypt_master_key(encrypted_master_key, email, passphrase, iterations=600000):
temp_key = hashlib.pbkdf2_hmac('sha256', passphrase.encode(), email.encode(), iterations, 32)
master_enc_key = hkdf_expand(temp_key, b'enc', 32, hashlib.sha256)
master_mac_key = hkdf_expand(temp_key, b'mac', 32, hashlib.sha256)
return decrypt(encrypted_master_key, master_enc_key, master_mac_key)以下是完整的解密脚本,这次代码中不再包含任何敏感信息:
from base64 import b64decode
from getpass import getpass
import hashlib
import hmac
import pyaes
from hkdf import hkdf_expand
def decrypt(encrypted_data, enc_key, mac_key):
version, payload = encrypted_data.split('.', 2)
if version != '2':
raise ValueError('Unsupported encryption version')
fields = payload.split('|')
if len(fields) != 3:
raise ValueError('Invalid encrypted data')
iv = b64decode(fields[0])
ciphertext = b64decode(fields[1])
mac = b64decode(fields[2])
calculated_mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
if calculated_mac != mac:
raise ValueError('Invalid data or key')
decrypter = pyaes.Decrypter(pyaes.AESModeOfOperationCBC(enc_key, iv))
return decrypter.feed(ciphertext) + decrypter.feed()
def decrypt_master_key(encrypted_master_key, email, passphrase, iterations=600000):
temp_key = hashlib.pbkdf2_hmac('sha256', passphrase.encode(), email.encode(), iterations, 32)
master_enc_key = hkdf_expand(temp_key, b'enc', 32, hashlib.sha256)
master_mac_key = hkdf_expand(temp_key, b'mac', 32, hashlib.sha256)
return decrypt(encrypted_master_key, master_enc_key, master_mac_key)
encrypted_master_key = '2.i5dH92a79wJ8L8tsqQEdLw==|a5swb8CeW5cTM2N+XQZCF+mX263BMaag+ghxiu+ci4W+fqqLZ82g+i7ReIcdiPLafoCAmeWZE48PETGJOsoOb6DcrK3sRdvHCx8xbRt1Xas=|MhY1/he1RntesSYJyZqFh8s8dQTXqmPRQM2hVcsjIWk='
encrypted_secret = '2.IkWFb104bXv7Zwl7eFbsnQ==|SB42jIOvjhV32hSusW/J7WfAnQV8DKIV/CJQB7IDaiz4lQv4lIcXzWp9+IT0ncVQ|S8Tcp2klhcOOzZvoA0C9WRURaWUq+U1F9jbuBskDIz0='
email = input('Email address: ')
passphrase = getpass('Passphrase: ')
master_key = decrypt_master_key(encrypted_master_key, email, passphrase)
enc_key = master_key[:32]
mac_key = master_key[32:]
print('Secret:', decrypt(encrypted_secret, enc_key, mac_key).decode())要解码上面示例中的 secret,你需要使用我为本次示例选择的邮箱和密码:somebody@example.com 和 the moon landing was fake。下面是脚本运行的一个示例输出,展示了已解密的 secret:
$ python decrypt.py
Email: somebody@example.com
Passphrase: the moon landing was fake
Secret: The quick brown fox jumps over the lazy dog加密 secrets
上一节分享的脚本可以作为完整解密方案的基础,适用于像 Vaultwarden 维护的那种数据库,完全不需要调用 Web API 或进行任何网络交互。
此时我开始思考是否也能实现加密功能,这样就能像 Bitwarden 客户端一样插入新的 secret。事实证明,在搞懂了解密之后,添加加密反而变得很简单。
我只需要逆向推导 decrypt() 函数,反向构建出 encrypt() 即可:
import random
# ...
def encrypt(secret, enc_key, mac_key):
if isinstance(secret, str):
secret = secret.encode()
iv = random.randbytes(16)
encrypter = pyaes.Encrypter(pyaes.AESModeOfOperationCBC(enc_key, iv))
ciphertext = encrypter.feed(secret) + encrypter.feed()
mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
return f'2.{b64encode(iv).decode()}|{b64encode(ciphertext).decode()}|{b64encode(mac).decode()}'首先,仅为了方便起见,如果传递给函数的密钥是字符串,我会将其转换为字节。加密和解密始终使用字节进行。
如你所记,密钥由三部分组成:iv、密文和 mac。加密时,iv 部分可以是任意 16 个随机字节序列,用于初始化加密引擎。只要解密方使用相同的字节,具体使用哪些并不重要。密文部分是经过 AES 算法加密后的密钥内容,因此这里我使用了 pyaes 来生成它。最后,我们已经知道如何计算 mac 签名,所以这部分处理方式与之前完全相同。
所有部分生成后,函数会返回一个 Bitwarden 所偏好的格式字符串:以版本号 2 开头,接着是一个点,然后是三个用 | 分隔的加密密钥组成部分。
作为额外功能,我还创建了一个用于加密主密钥的函数:
def encrypt_master_key(master_key, email, passphrase, iterations=600000):
temp_key = hashlib.pbkdf2_hmac('sha256', passphrase.encode(), email.encode(), iterations, 32)
master_enc_key = hkdf_expand(temp_key, b'enc', 32, hashlib.sha256)
master_mac_key = hkdf_expand(temp_key, b'mac', 32, hashlib.sha256)
return encrypt(master_key, master_enc_key, master_mac_key)现在有了 encrypt() 和 encrypt_master_key() 这两个函数,我就能生成与 Bitwarden 和 Vaultwarden 存储完全兼容的密钥。我甚至可以为自己的账户生成新的主密钥。以下示例演示了如何生成全新的主密钥,并用它加密一个密钥,然后分别输出加密后的主密钥和加密后的普通密钥:
email = input('Email address: ')
passphrase = getpass('Passphrase: ')
master_key = random.randbytes(64)
encrypted_master_key = encrypt_master_key(master_key, email, passphrase)
print(f'Master key: {encrypted_master_key}')
enc_key = master_key[:32]
mac_key = master_key[32:]
encrypted_secret = encrypt('The quick brown fox jumps over the lazy dog', enc_key, mac_key)
print(f'Secret: {encrypted_secret}')如果有人获取了你的加密主密钥和普通密钥,他们仍需猜测你的邮箱和密码短语才能解码。没有这些信息,他们就无法利用这些密钥。
结论
既然我已经了解了 Bitwarden 密钥的加密机制,我想我可能会自己动手写一个小型客户端。我喜欢将所有密钥方便地存储在本地 SQLite 数据库中的想法,所以我可能会构建一个能与 Vaultwarden 数据库同步,但密钥管理保持本地的解决方案。
无论如何,希望你能享受和我一起探索 Bitwarden 密钥的工作原理!Bitwarden 还有很多其他特性值得深入研究,这很可能成为一系列文章的开篇之作。欢迎在评论区留言,告诉我你还想了解 Bitwarden 或 Vaultwarden 的哪些方面!
感谢访问我的博客!如果你喜欢这篇文章,请考虑通过“Buy me a coffee”支持我的工作,哪怕是一次性小额捐赠也能让我精神百倍。谢谢!
需要完整排版与评论请前往来源站点阅读。