Skip to main content

encryption/kdf/
scrypt.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3//! scrypt(RFC 7914):从人类可读口令派生密钥。
4//!
5//! **默认参数为 [`Params::recommended()`]:log_n=17(N=2^17)、r=8、p=1,
6//! 每次派生约 128 MiB 内存开销(128 × N × r 字节)——Web 场景必须评估 DoS
7//! 面**:在按请求执行派生的接口上,等于给攻击者一个 128 MiB / 次的内存放大器,
8//! 应在入口限流,或用 [`ScryptKdf::with_params`] 调低 log_n 换取更小的内存足迹。
9//!
10//! 盐值无长度下限(RFC 7914 §12 的官方向量即含空盐);生产应使用随机盐,
11//! 建议 ≥16 字节(与 RFC 9106 的建议一致)。
12//!
13//! 已知限制:scrypt 0.11.0 未提供 zeroize feature,派生过程约 128 MiB 的内部
14//! 工作内存不保证在释放前清零(对照:本库 argon2 已开 zeroize,内部状态清零)。
15
16use scrypt::{Params, scrypt};
17
18use crate::contract::{Identified, PasswordBasedKdf};
19use crate::error::{Error, Result};
20
21/// 输出长度上限:与 [`crate::kdf::Argon2Kdf`]、[`crate::kdf::Pbkdf2Sha256`]
22/// 同一约定(32 位长度的 `u32::MAX`)。scrypt 自身只拒绝超过 (2^32−1)×32 字节
23/// 的输出,本上限把「先按请求分配、再必然失败」的巨量请求拦在
24/// `vec![0u8; length]` 之前。
25const MAX_OUTPUT_LEN: usize = u32::MAX as usize;
26
27/// scrypt 派生器(可复用)。
28#[derive(Debug, Clone, Copy)]
29pub struct ScryptKdf {
30    params: Params,
31}
32
33impl ScryptKdf {
34    pub const IDENTIFIER: &'static str = "scrypt";
35
36    /// 使用 [`Params::recommended()`](log_n=17、r=8、p=1,约 128 MiB 内存/次)。
37    pub fn new() -> Self {
38        Self {
39            params: Params::recommended(),
40        }
41    }
42
43    /// 自定义参数:`N = 2^log_n`(内存与 CPU 随 N 线性增长),`r` 为块大小,
44    /// `p` 为并行度。参数非法(如 log_n ≥ 64、r = 0、p = 0)返回
45    /// [`Error::InvalidKdfParams`]。
46    pub fn with_params(log_n: u8, r: u32, p: u32) -> Result<Self> {
47        // Params 的 len 字段只在 password-hash 表示层使用,裸 scrypt() 不校验它,
48        // 派生长度以 derive_from_password 的 length 参数为准,这里填推荐值。
49        let params = Params::new(log_n, r, p, Params::RECOMMENDED_LEN)
50            .map_err(|_| Error::InvalidKdfParams { label: "scrypt" })?;
51        Ok(Self { params })
52    }
53
54    pub fn log_n(&self) -> u8 {
55        self.params.log_n()
56    }
57
58    pub fn r(&self) -> u32 {
59        self.params.r()
60    }
61
62    pub fn p(&self) -> u32 {
63        self.params.p()
64    }
65}
66
67impl Default for ScryptKdf {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Identified for ScryptKdf {
74    fn identifier(&self) -> &str {
75        Self::IDENTIFIER
76    }
77}
78
79impl PasswordBasedKdf for ScryptKdf {
80    fn derive_from_password(&self, password: &[u8], salt: &[u8], length: usize) -> Result<Vec<u8>> {
81        if length == 0 || length > MAX_OUTPUT_LEN {
82            return Err(Error::InvalidOutputLength {
83                requested: length,
84                max: MAX_OUTPUT_LEN,
85            });
86        }
87
88        let mut output = vec![0u8; length];
89        scrypt(password, salt, &self.params, &mut output)
90            // 前置校验已把长度压到 MAX_OUTPUT_LEN 之内(远低于 scrypt() 自身
91            // 的 (2^32−1)×32 上限),此分支仅为防御性兜底,一并归入长度非法。
92            .map_err(|_| Error::InvalidOutputLength {
93                requested: length,
94                max: MAX_OUTPUT_LEN,
95            })?;
96        Ok(output)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// RFC 7914 §12 官方测试向量:P=""、S=""、N=16、r=1、p=1、dkLen=64。
105    ///
106    /// N=16 即 log_n=4,与默认(log_n=17,约 128 MiB)相差甚远,正是
107    /// [`ScryptKdf::with_params`] 存在的理由。
108    #[test]
109    fn matches_rfc7914_test_vector() {
110        let kdf = ScryptKdf::with_params(4, 1, 1).unwrap();
111        let derived = kdf.derive_from_password(b"", b"", 64).unwrap();
112        assert_eq!(
113            crate::internal::hex_encode(&derived),
114            concat!(
115                "77d6576238657b203b19ca42c18a0497",
116                "f16b4844e3074ae8dfdffa3fede21442",
117                "fcd0069ded0948f8326a753a0fc81f17",
118                "e8d3e0fb2e0d3628cf35e20c38d18906"
119            )
120        );
121    }
122
123    /// RFC 7914 §12 第二组官方测试向量:P="password"、S="NaCl"、N=1024、r=8、
124    /// p=16、dkLen=64(额外覆盖 p > 1 与非空盐)。
125    #[test]
126    fn matches_rfc7914_test_vector_par16() {
127        let kdf = ScryptKdf::with_params(10, 8, 16).unwrap();
128        let derived = kdf.derive_from_password(b"password", b"NaCl", 64).unwrap();
129        assert_eq!(
130            crate::internal::hex_encode(&derived),
131            concat!(
132                "fdbabe1c9d3472007856e7190d01e9fe",
133                "7c6ad7cbc8237830e77376634b373162",
134                "2eaf30d92e22a3886ff109279d9830da",
135                "c727afb94a83ee6d8360cbdfa2cc0640"
136            )
137        );
138    }
139
140    /// 默认参数的构成(不在测试中真跑 128 MiB 派生,只钉参数)。
141    #[test]
142    fn default_params_are_recommended() {
143        let kdf = ScryptKdf::default();
144        assert_eq!((kdf.log_n(), kdf.r(), kdf.p()), (17, 8, 1));
145    }
146
147    #[test]
148    fn same_inputs_same_output_and_length_validation() {
149        let kdf = ScryptKdf::with_params(4, 1, 1).unwrap();
150        let first = kdf.derive_from_password(b"pw", b"salt", 16).unwrap();
151        let second = kdf.derive_from_password(b"pw", b"salt", 16).unwrap();
152        assert_eq!(first, second);
153        assert_eq!(first.len(), 16);
154        // 长度参数生效:不同 dkLen 产出对应长度的结果(末尾是 PBKDF2 收尾,
155        // 短输出是长输出的前缀,故只断言长度不断言内容差异)。
156        assert_eq!(
157            kdf.derive_from_password(b"pw", b"salt", 64).unwrap().len(),
158            64
159        );
160        assert_eq!(
161            kdf.derive_from_password(b"pw", b"salt", 0).unwrap_err(),
162            Error::InvalidOutputLength {
163                requested: 0,
164                max: MAX_OUTPUT_LEN
165            }
166        );
167        // 上限之上:必须在 `vec![0u8; length]` 之前干净报错,而不是先申请
168        // 2^63 字节撞上 abort/OOM(修复前的行为)。
169        assert_eq!(
170            kdf.derive_from_password(b"pw", b"salt", usize::MAX / 2)
171                .unwrap_err(),
172            Error::InvalidOutputLength {
173                requested: usize::MAX / 2,
174                max: MAX_OUTPUT_LEN
175            }
176        );
177        // 边界:上限 + 1 即拒(上限本身会真分配 4 GiB,不在此断言)。
178        assert_eq!(
179            kdf.derive_from_password(b"pw", b"salt", MAX_OUTPUT_LEN + 1)
180                .unwrap_err(),
181            Error::InvalidOutputLength {
182                requested: MAX_OUTPUT_LEN + 1,
183                max: MAX_OUTPUT_LEN
184            }
185        );
186    }
187
188    #[test]
189    fn password_and_salt_both_affect_output() {
190        let kdf = ScryptKdf::with_params(4, 1, 1).unwrap();
191        let base = kdf.derive_from_password(b"pw", b"salt", 32).unwrap();
192        assert_ne!(base, kdf.derive_from_password(b"pw2", b"salt", 32).unwrap());
193        assert_ne!(base, kdf.derive_from_password(b"pw", b"salt2", 32).unwrap());
194    }
195
196    #[test]
197    fn invalid_params_are_rejected() {
198        assert_eq!(
199            ScryptKdf::with_params(64, 1, 1).unwrap_err(),
200            Error::InvalidKdfParams { label: "scrypt" }
201        );
202        assert_eq!(
203            ScryptKdf::with_params(4, 0, 1).unwrap_err(),
204            Error::InvalidKdfParams { label: "scrypt" }
205        );
206        assert_eq!(
207            ScryptKdf::with_params(4, 1, 0).unwrap_err(),
208            Error::InvalidKdfParams { label: "scrypt" }
209        );
210    }
211}