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/// scrypt 派生器(可复用)。
22#[derive(Debug, Clone, Copy)]
23pub struct ScryptKdf {
24    params: Params,
25}
26
27impl ScryptKdf {
28    pub const IDENTIFIER: &'static str = "scrypt";
29
30    /// 使用 [`Params::recommended()`](log_n=17、r=8、p=1,约 128 MiB 内存/次)。
31    pub fn new() -> Self {
32        Self {
33            params: Params::recommended(),
34        }
35    }
36
37    /// 自定义参数:`N = 2^log_n`(内存与 CPU 随 N 线性增长),`r` 为块大小,
38    /// `p` 为并行度。参数非法(如 log_n ≥ 64、r = 0、p = 0)返回
39    /// [`Error::InvalidKdfParams`]。
40    pub fn with_params(log_n: u8, r: u32, p: u32) -> Result<Self> {
41        // Params 的 len 字段只在 password-hash 表示层使用,裸 scrypt() 不校验它,
42        // 派生长度以 derive_from_password 的 length 参数为准,这里填推荐值。
43        let params = Params::new(log_n, r, p, Params::RECOMMENDED_LEN)
44            .map_err(|_| Error::InvalidKdfParams { label: "scrypt" })?;
45        Ok(Self { params })
46    }
47
48    pub fn log_n(&self) -> u8 {
49        self.params.log_n()
50    }
51
52    pub fn r(&self) -> u32 {
53        self.params.r()
54    }
55
56    pub fn p(&self) -> u32 {
57        self.params.p()
58    }
59}
60
61impl Default for ScryptKdf {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl Identified for ScryptKdf {
68    fn identifier(&self) -> &str {
69        Self::IDENTIFIER
70    }
71}
72
73impl PasswordBasedKdf for ScryptKdf {
74    fn derive_from_password(&self, password: &[u8], salt: &[u8], length: usize) -> Result<Vec<u8>> {
75        if length == 0 {
76            return Err(Error::InvalidOutputLength {
77                requested: 0,
78                max: usize::MAX,
79            });
80        }
81
82        let mut output = vec![0u8; length];
83        scrypt(password, salt, &self.params, &mut output)
84            // scrypt() 只拒绝 0 长度与超过 (2^32−1)×32 的输出;前置校验后
85            // 剩余失败仅为理论上限(先于它撞上分配失败),一并归入长度非法。
86            .map_err(|_| Error::InvalidOutputLength {
87                requested: length,
88                max: usize::MAX,
89            })?;
90        Ok(output)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    /// RFC 7914 §12 官方测试向量:P=""、S=""、N=16、r=1、p=1、dkLen=64。
99    ///
100    /// N=16 即 log_n=4,与默认(log_n=17,约 128 MiB)相差甚远,正是
101    /// [`ScryptKdf::with_params`] 存在的理由。
102    #[test]
103    fn matches_rfc7914_test_vector() {
104        let kdf = ScryptKdf::with_params(4, 1, 1).unwrap();
105        let derived = kdf.derive_from_password(b"", b"", 64).unwrap();
106        assert_eq!(
107            crate::internal::hex_encode(&derived),
108            concat!(
109                "77d6576238657b203b19ca42c18a0497",
110                "f16b4844e3074ae8dfdffa3fede21442",
111                "fcd0069ded0948f8326a753a0fc81f17",
112                "e8d3e0fb2e0d3628cf35e20c38d18906"
113            )
114        );
115    }
116
117    /// RFC 7914 §12 第二组官方测试向量:P="password"、S="NaCl"、N=1024、r=8、
118    /// p=16、dkLen=64(额外覆盖 p > 1 与非空盐)。
119    #[test]
120    fn matches_rfc7914_test_vector_par16() {
121        let kdf = ScryptKdf::with_params(10, 8, 16).unwrap();
122        let derived = kdf.derive_from_password(b"password", b"NaCl", 64).unwrap();
123        assert_eq!(
124            crate::internal::hex_encode(&derived),
125            concat!(
126                "fdbabe1c9d3472007856e7190d01e9fe",
127                "7c6ad7cbc8237830e77376634b373162",
128                "2eaf30d92e22a3886ff109279d9830da",
129                "c727afb94a83ee6d8360cbdfa2cc0640"
130            )
131        );
132    }
133
134    /// 默认参数的构成(不在测试中真跑 128 MiB 派生,只钉参数)。
135    #[test]
136    fn default_params_are_recommended() {
137        let kdf = ScryptKdf::default();
138        assert_eq!((kdf.log_n(), kdf.r(), kdf.p()), (17, 8, 1));
139    }
140
141    #[test]
142    fn same_inputs_same_output_and_length_validation() {
143        let kdf = ScryptKdf::with_params(4, 1, 1).unwrap();
144        let first = kdf.derive_from_password(b"pw", b"salt", 16).unwrap();
145        let second = kdf.derive_from_password(b"pw", b"salt", 16).unwrap();
146        assert_eq!(first, second);
147        assert_eq!(first.len(), 16);
148        // 长度参数生效:不同 dkLen 产出对应长度的结果(末尾是 PBKDF2 收尾,
149        // 短输出是长输出的前缀,故只断言长度不断言内容差异)。
150        assert_eq!(
151            kdf.derive_from_password(b"pw", b"salt", 64).unwrap().len(),
152            64
153        );
154        assert_eq!(
155            kdf.derive_from_password(b"pw", b"salt", 0).unwrap_err(),
156            Error::InvalidOutputLength {
157                requested: 0,
158                max: usize::MAX
159            }
160        );
161    }
162
163    #[test]
164    fn password_and_salt_both_affect_output() {
165        let kdf = ScryptKdf::with_params(4, 1, 1).unwrap();
166        let base = kdf.derive_from_password(b"pw", b"salt", 32).unwrap();
167        assert_ne!(base, kdf.derive_from_password(b"pw2", b"salt", 32).unwrap());
168        assert_ne!(base, kdf.derive_from_password(b"pw", b"salt2", 32).unwrap());
169    }
170
171    #[test]
172    fn invalid_params_are_rejected() {
173        assert_eq!(
174            ScryptKdf::with_params(64, 1, 1).unwrap_err(),
175            Error::InvalidKdfParams { label: "scrypt" }
176        );
177        assert_eq!(
178            ScryptKdf::with_params(4, 0, 1).unwrap_err(),
179            Error::InvalidKdfParams { label: "scrypt" }
180        );
181        assert_eq!(
182            ScryptKdf::with_params(4, 1, 0).unwrap_err(),
183            Error::InvalidKdfParams { label: "scrypt" }
184        );
185    }
186}