Luke Arntz 2020-05-20
摘要
免责声明 #1:我不是密码学专家,甚至对 Rust 也不太熟练。在处理他人私密信息时,请务必谨慎。本文末尾的参考文献部分提供了更多详细且权威的信息。
免责声明 #2:如果你可以选择使用外部身份验证服务提供商,那通常比自己构建要更好。
密码学可能令人困惑,而且关于如何使用 sodiumoxide crate 的示例非常少。我需要存储一些密码哈希值,最初打算使用 ring crate 和 PBKDF2,但在 Rust 非官方 Discord 频道上被建议改用 sodiumoxide。
我花了一些时间查阅 sodiumoxide 的文档,虽然其中有一些可行且直接的示例,但并不清楚这些是否足以在生产环境中安全地进行密码哈希,或者是否还需要额外的步骤。
我认为我已经找到了最初问题的答案,于是决定撰写这篇文章,既作为自己的参考,也希望能帮助其他有类似需求的人。
crate 文档中的唯一示例确实说明了其工作原理,但缺乏详细解释。
希望本文能提供一些更清晰的说明。
本文中使用的所有代码都可以在 GitHub 仓库 sodiumoxide-password-hashing-examples 中找到。
关键要点
- 盐值(salt)由哈希函数自动生成,哈希值、盐值和哈希参数被一起存储为一个数据单元。
- 使用 Argon2id 和
pwhash函数时,不需要额外提供盐值。 - 根据你使用的数据库类型,可能需要在将哈希值存入数据库前对其进行处理。
- 整个流程其实很简单,但我希望提供一些展示如何在应用程序中使用它的代码。
密码哈希
密码哈希用于身份验证。基本原理是:将用户密码的哈希值存储在数据库中。当用户尝试登录时,你对其提供的密码进行哈希,并与之前存储的哈希值进行比较。
如果验证通过,就允许其登录。
为什么选择 Argon2id?
Argon2id 能够抵抗两种类型的攻击:旁路时序攻击(side channel timing attacks) 和 时间-内存权衡攻击(time-memory tradeoff attacks)。如果你感兴趣,可以查看 这个 StackExchange 问题 或 RFC 文档 获取更多细节。
为什么选择 sodiumoxide 和 libsodium?
主要是因为 libsodium 被广泛使用,并且受到比我更懂密码学的人的推荐。它提供了一个简单的 API——简单到几乎因缺乏示例和文档而让人困惑。
什么是 sodiumoxide?
sodiumoxide crate 是 C 语言库 libsodium 的 Rust 封装,可在 Rust 应用程序中使用。除了 Rust,libsodium 还被许多其他语言封装,包括 C#、Python 和 Java。
在 Rust 生态中,sodiumoxide 是 GitHub 上星标最多的仓库,这多少也算是一种认可吧?🤷
盐值(Salts)
使用 pwhash 函数时,盐值已包含在输出的哈希中。每次调用 pwhash 函数时,都会随机生成一个新的盐值。这意味着,即使你用相同的密码调用 pwhash 两次,也会因为使用了不同的盐值而产生不同的哈希结果。
这可能与你之前读到的内容有所不同。在很多情况下,你需要自己提供并存储盐值,以便安全地验证密码。
但当你使用 libsodium/sodiumoxide 时,不需要额外的盐值。
哈希值(Hashes)
Sodiumoxide 提供的哈希值是一个 [u8; 128] 类型的数组,即包含 128 个 u8 元素的数组。可以很容易地使用 std::str::from_utf8() 函数将其转换为 ASCII 文本进行存储。转换为 str 后,你会得到一个 128 个字符的字符串,但末尾填充了空字符(null characters)。在将该字符串存入 PostgreSQL 数据库之前,必须使用 trim_end_matches('\u{0}') 函数去除这些空字符。但请注意,如果你这样做了,在将哈希值用于验证之前,必须重新添加这些空填充。
PostgreSQL 也可以使用 bytea 类型的列来存储字节数据。如果你选择以二进制形式存储哈希值,则无需进行任何修剪操作。下面我会提供这两种选项的示例。
示例
sodiumoxide::init()
在开始哈希之前,你应该初始化 sodiumoxide 库。如果不初始化,只会导致哈希过程变慢。多次调用 init() 也不会造成任何问题。
use sodiumoxide::crypto::pwhash::argon2id13;
use std::time::Instant;
pub fn hash(passwd: &str) -> (String, argon2id13::HashedPassword) {
sodiumoxide::init().unwrap();
let hash = argon2id13::pwhash(
passwd.as_bytes(),
argon2id13::OPSLIMIT_INTERACTIVE,
argon2id13::MEMLIMIT_INTERACTIVE,
)
.unwrap();
let texthash = std::str::from_utf8(&hash.0).unwrap().to_string();
(texthash, hash)
}
sodium_init() 用于初始化库,应在调用 libsodium 提供的任何其他函数之前调用。从不同线程多次调用此函数是安全的——后续调用不会产生任何效果。
更多详情请参阅 libsodium 官方文档。
选择哈希算法
Sodiumoxide 提供了三种不同的哈希算法:
argon2i13– Argon2 总结了内存硬函数设计领域的最新成果。argon2id13– Argon2 总结了内存硬函数设计领域的最新成果。scryptsalsa208sha256– Scrypt、Salsa20/8 和 SHA-256 的一种特定组合。
业界共识认为 argon2id13 是最安全的。
如果你想使用推荐的 Argon2id 算法,需要这样导入:
use sodiumoxide::crypto::pwhash::argon2id13;
use std::time::Instant;
pub fn hash(passwd: &str) -> (String, argon2id13::HashedPassword) {
sodiumoxide::init().unwrap();
let hash = argon2id13::pwhash(
passwd.as_bytes(),
argon2id13::OPSLIMIT_INTERACTIVE,
argon2id13::MEMLIMIT_INTERACTIVE,
)
.unwrap();
let texthash = std::str::from_utf8(&hash.0).unwrap().to_string();
(texthash, hash)
}
创建哈希值
哈希值通过 pwhash() 函数(来自对应算法模块)创建。该函数接收密码的 as_bytes() 形式,并返回一个 HashedPassword 结构体。除了密码之外,我们还需要告诉 pwhash 函数在哈希过程中应执行多少工作量。工作量越大,攻击者破解密码所需的时间就越长。但这也意味着每次验证密码时,我们的应用程序也需要做更多工作,因此要谨慎选择并测试所选参数。
这些参数通过预定义的常量提供。
libsodium 文档对这些选项的描述如下:
对于交互式在线操作,
crypto_pwhash_OPSLIMIT_INTERACTIVE和crypto_pwhash_MEMLIMIT_INTERACTIVE为这两个参数提供了基准线。目前这需要 64 MiB 的专用 RAM。更高的值可能会提高安全性(见下文)。另外,也可以使用
crypto_pwhash_OPSLIMIT_MODERATE和crypto_pwhash_MEMLIMIT_MODERATE。这需要 256 MiB 的专用 RAM,在 2.8 GHz Core i7 CPU 上大约需要 0.7 秒。对于高度敏感的数据和非交互式操作,可以使用
crypto_pwhash_OPSLIMIT_SENSITIVE和crypto_pwhash_MEMLIMIT_SENSITIVE。使用这些参数时,在 2.8 GHz Core i7 CPU 上派生一个密钥大约需要 3.5 秒,并需要 1024 MiB 的专用 RAM。
注意:这些常量可以混合搭配使用。对应的 sodiumoxide 常量为:
MEMLIMIT_INTERACTIVE和OPSLIMIT_INTERACTIVEMEMLIMIT_MODERATE和OPSLIMIT_MODERATEMEMLIMIT_SENSITIVE和OPSLIMIT_SENSITIVE
根据 Argon2 RFC 的建议:
推荐在所有环境中默认使用 Argon2id 变体,参数为 t=1 并使用最大可用内存。
然而,这一推荐可能并不适合你的具体用例。你必须确保服务器能够承受这种负载,否则会更容易遭受拒绝服务(DoS)攻击。我在仓库中包含了对几种设置的粗略计时代码,可以轻松修改以测试任意参数组合。
sodiumoxide 使用的算法取决于你导入的模块。仅导入 sodiumoxide::crypto::pwhash 会默认使用 scryptsalsa208sha256 算法。
以下是哈希创建的代码示例:
use sodiumoxide::crypto::pwhash::argon2id13;
use std::time::Instant;
pub fn hash(passwd: &str) -> (String, argon2id13::HashedPassword) {
sodiumoxide::init().unwrap();
let hash = argon2id13::pwhash(
passwd.as_bytes(),
argon2id13::OPSLIMIT_INTERACTIVE,
argon2id13::MEMLIMIT_INTERACTIVE,
)
.unwrap();
let texthash = std::str::from_utf8(&hash.0).unwrap().to_string();
(texthash, hash)
}
验证密码
验证密码与创建哈希几乎一样简单。验证通过 pwhash_verify 函数完成。
pub fn verify(hash: [u8; 128], passwd: &str) -> bool {
sodiumoxide::init().unwrap();
match argon2id13::HashedPassword::from_slice(&hash) {
Some(hp) => argon2id13::pwhash_verify(&hp, passwd.as_bytes()),
_ => false,
}
}
该函数接受两个参数。第一个是包含已存储密码哈希的 HashedPassword 结构体。第二个是要验证的密码,必须以 as_bytes() 形式提供。
可以使用 HashedPassword::from_slice() 函数从已存储的密码哈希创建新的 HashedPassword 结构体。
重要提示:这里有一个陷阱。传递给 HashedPassword::from_slice() 函数的切片或数组 必须大小为 128!否则,即使密码正确,验证也会失败。
我之所以强调这一点,是因为在下一节讨论将哈希存储到数据库时会更加清楚。如果哈希在存储前经过了转换,那么在创建新的 HashedPassword 之前,必须将其恢复为正确的大小。
这意味着需要用 NULL 字符将数组/切片填充至 128 字节。
存储哈希值
如前所述,我们需要将哈希值转换为数据库可以接受的形式。
这里有两种选择:第一种是以二进制数据形式存储哈希值;第二种是将哈希值转换为 ASCII 文本并存储。
以 ASCII 形式存储
libsodium 的 crypto_pwhash_str 输出是 ASCII 编码的:
输出字符串以零结尾,仅包含 ASCII 字符,可以安全地存储在 SQL 数据库和其他数据存储中。验证密码时无需存储额外信息。
sodiumoxide 不返回字符串,而是返回一个 u8 数组,我们可以使用 std::str::from_utf8() 函数将其转换为 str。然而,生成的 str 会用 NULL 字符('\u{0}')填充,因此需要额外调用 trim_end_matches('\u{0}') 来移除它们。
我们之所以要移除这些 NULL 字符,是因为 PostgreSQL 在你尝试将包含 NULL 的字符串插入 text 类型列时会完全崩溃。
根据邮件列表的说法:
你试图插入一个包含
'\0'字符的字符串。服务器无法处理包含嵌入 NUL 的字符串,因为它在内部使用 C 风格的字符串终止方式。
这就又回到了一个问题:在使用 argon2id13::pwhash_verify() 进行验证之前,我们必须用 null 字符重新填充哈希值。
这个过程相当直接。首先,创建一个大小为 128 的全零数组。然后遍历哈希字符,将它们复制到数组中。
let user = database::get_user(String::from(args.get(1).unwrap())).await?;
let mut padded = [0u8; 128];
user.password_hash_char
.as_bytes()
.iter()
.enumerate()
.for_each(|(i, val)| {
padded[i] = val.clone();
});
然后就可以将 padded 数组与 HashedPassword::from_slice() 一起使用,创建一个新的 HashedPassword 结构体,如果给定的密码匹配,就能正确验证。
以二进制形式存储
PostgreSQL 还有一种名为 bytea 的二进制列类型,可用于存储未经转换的二进制哈希值。
要以二进制形式存储哈希值,只需将数组作为切片传递给 sqlx 即可。
pub async fn add_user(user: UserDBRecord) -> Result<u64, sqlx::Error> {
let conn =
PgConnection::connect(&env::var("DATABASE_URL").expect("can't get no env::var")).await?;
let result = sqlx::query!(
r#"INSERT INTO users(user_name,password_hash_bin,password_hash_char,email_address)
VALUES ($1,$2,$3,$4)
ON CONFLICT(user_name) DO UPDATE SET password_hash_bin = $2
"#,
user.user_name,
&user.password_hash_bin.0[..],
user.password_hash_char,
user.email_address
)
.execute(conn)
.await;
result
}
当我们需要验证密码时,可以直接使用从数据库返回的切片。
pub async fn get_user(user_name: String) -> Result<UserDBRecordWithId, sqlx::Error> {
let mut conn =
PgConnection::connect(&env::var("DATABASE_URL").expect("can't get no env::var")).await?;
let user_select = sqlx::query!(
r#"
SELECT id,user_name,password_hash_bin,password_hash_char,email_address
FROM users WHERE user_name = $1"#,
user_name
)
.fetch_one(&mut conn)
.await?;
Ok(UserDBRecordWithId {
id: user_select.id,
user_name: user_select.user_name,
password_hash_bin: HashedPassword::from_slice(&user_select.password_hash_bin).unwrap(),
password_hash_char: user_select.password_hash_char,
email_address: user_select.email_address,
})
}
Bytea 与 Text 的比较
那么哪种选项更好呢?根据 Pivotal 博客 2016 年的一篇文章,使用 text 列类型可获得约 15% 更好的读取性能,写入性能则相当。
我没有进行自己的测试,而且他们的测试是在 2016 年进行的,但即使考虑到我们的应用程序在转换和填充数据时需要做的额外工作,似乎将哈希值存储为文本仍然是更好的选择。
结论
本文中的所有代码都可以在 GitHub 仓库中找到。提供了完整的密码哈希和验证示例,还包括了以文本和二进制形式存储哈希值的示例,以及在需要时转换数据所需的代码。
如果你对仓库中的代码有优化建议,我很乐意听取。请创建 issue 或 pull request,让我们共同学习!
我本来不太想在这里提建议,但还是忍不住要说:
在 Rust 中进行密码哈希时,我会:
- 使用
sodiumoxidecrate。 - 使用
argon2id13模块(即 Argon2id 算法)。 - 除非你的应用程序拥有充足的 CPU 资源和/或低流量,或者哈希过程不受时间限制(例如离线任务),否则使用
OPSLIMIT_INTERACTIVE和MEMLIMIT_INTERACTIVE设置。 - 在使用 PostgreSQL 时,将密码哈希存储为 ASCII 文本。
希望本文至少能为密码哈希的新手提供一些有用的示例。我非常喜欢学习 Rust,也希望为其他初学者提供有帮助的信息。
感谢阅读!