purecrypto/kdf/bcrypt_pbkdf.rs
1//! OpenSSH `bcrypt_pbkdf` — the KDF OpenSSH uses to derive symmetric keys
2//! for protecting new-format private key files from a passphrase.
3//!
4//! **Not interoperable with regular bcrypt.** Regular bcrypt is a
5//! password-verification hash; `bcrypt_pbkdf` is a key-derivation
6//! function. Both share the EksBlowfishSetup primitive, but the wrapper
7//! around it (the PBKDF2-style outer loop, the SHA-512 password/salt
8//! preprocessing, the "OxychromaticBlowfishSwatDynamite" payload, and
9//! the stride-distributed output) is `bcrypt_pbkdf`-specific.
10//!
11//! Reference implementations:
12//! - OpenBSD `lib/libutil/bcrypt_pbkdf.c` (canonical).
13//! - `golang.org/x/crypto/ssh/internal/bcrypt_pbkdf`.
14//!
15//! Tuning: `rounds` is the iteration count of the inner PRF; OpenSSH's
16//! current default is 16, older default was 6 — at 16 rounds the
17//! function takes roughly 100 ms on modern hardware, which is the design
18//! intent. `keylen` is the output length in bytes (typically 32 for an
19//! `aes256-ctr` key, 48 with the IV concatenated).
20
21extern crate alloc;
22
23use alloc::vec;
24use alloc::vec::Vec;
25
26use crate::cipher::blowfish::Blowfish;
27use crate::hash::{Digest, Sha512};
28
29/// Maximum permitted output length, mirroring OpenSSH's cap.
30const MAX_KEYLEN: usize = 1024;
31
32/// Length of the inner PRF output, in bytes (one Blowfish "encipher"
33/// applied to the 32-byte payload).
34const BCRYPT_HASHSIZE: usize = 32;
35
36/// Parameter-validation errors.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum Error {
40 /// One of: `rounds == 0`, `keylen == 0`, or `keylen > 1024`.
41 InvalidParameters,
42}
43
44impl core::fmt::Display for Error {
45 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
46 f.write_str("bcrypt_pbkdf: invalid parameters")
47 }
48}
49
50impl core::error::Error for Error {}
51
52/// Derives `keylen` bytes from `(password, salt)` using OpenSSH's
53/// `bcrypt_pbkdf` with `rounds` iterations. Returns an error when
54/// `rounds == 0`, `keylen == 0`, or `keylen > 1024`.
55pub fn bcrypt_pbkdf(
56 password: &[u8],
57 salt: &[u8],
58 rounds: u32,
59 keylen: usize,
60) -> Result<Vec<u8>, Error> {
61 if rounds == 0 || keylen == 0 || keylen > MAX_KEYLEN {
62 return Err(Error::InvalidParameters);
63 }
64
65 // OpenSSH layout: each PRF call produces 32 bytes, distributed across
66 // the output by "stride" so that recovering any contiguous subkey
67 // requires running the full derivation. Mirrors OpenBSD:
68 // stride = ceil(keylen / 32)
69 // amt = ceil(keylen / stride)
70 // then `key[i * stride + (count - 1)] = out[i]`.
71 let stride: usize = keylen.div_ceil(BCRYPT_HASHSIZE);
72 let initial_amt: usize = keylen.div_ceil(stride);
73
74 // SHA-512 of the password — same value across every PRF block.
75 let sha2pass: [u8; 64] = Sha512::digest(password);
76
77 let mut out = vec![0u8; keylen];
78 let mut remaining = keylen;
79 let mut count: u32 = 1;
80
81 while remaining > 0 {
82 // First round: salt is `salt || count_be32`.
83 let mut salt_hasher = Sha512::new();
84 salt_hasher.update(salt);
85 salt_hasher.update(&count.to_be_bytes());
86 let mut sha2salt: [u8; 64] = salt_hasher.finalize();
87
88 // Initial PRF.
89 let mut tmpout = bcrypt_hash(&sha2pass, &sha2salt);
90 let mut block = tmpout;
91
92 // Iterate `rounds - 1` more PRF calls; salt becomes the prior
93 // PRF output (after SHA-512), accumulator XORs each PRF result.
94 for _ in 1..rounds {
95 sha2salt = Sha512::digest(&tmpout);
96 tmpout = bcrypt_hash(&sha2pass, &sha2salt);
97 for j in 0..BCRYPT_HASHSIZE {
98 block[j] ^= tmpout[j];
99 }
100 }
101
102 // Stride-distributed write. `amt` clamps so we never write past
103 // the last partial block.
104 let amt = initial_amt.min(remaining);
105 let mut written = 0usize;
106 for (i, &b) in block.iter().take(amt).enumerate() {
107 let dest = i * stride + (count as usize - 1);
108 if dest >= keylen {
109 break;
110 }
111 out[dest] = b;
112 written += 1;
113 }
114 remaining -= written;
115 count += 1;
116 }
117
118 Ok(out)
119}
120
121/// Inner PRF: 32-byte output from a 64-byte `sha2pass` "key" and a
122/// 64-byte `sha2salt` salt, via EksBlowfishSetup + a fixed payload.
123///
124/// Watch-out points (from the OpenBSD reference):
125/// 1. The 32-byte payload `"OxychromaticBlowfishSwatDynamite"` is packed
126/// into eight `u32`s read **big-endian** (so `0x4f787963` = "Oxyc").
127/// 2. The outer expansion runs `eks_setup` once, then 64 iterations of
128/// `expand_key(sha2salt)` + `expand_key(sha2pass)`.
129/// 3. The inner encipher loop runs 64 *full* passes; each pass enciphers
130/// all four 64-bit pairs of the 8-word state.
131/// 4. The output writes each `u32` **little-endian** — note the
132/// asymmetry with point 1.
133fn bcrypt_hash(sha2pass: &[u8; 64], sha2salt: &[u8; 64]) -> [u8; BCRYPT_HASHSIZE] {
134 // Key schedule.
135 let mut state = Blowfish::new();
136 state.eks_setup(sha2salt, sha2pass);
137 for _ in 0..64 {
138 state.expand_key(sha2salt);
139 state.expand_key(sha2pass);
140 }
141
142 // "OxychromaticBlowfishSwatDynamite" as eight big-endian u32s.
143 let mut cdata: [u32; 8] = [
144 0x4f78_7963, // "Oxyc"
145 0x6872_6f6d, // "hrom"
146 0x6174_6963, // "atic"
147 0x426c_6f77, // "Blow"
148 0x6669_7368, // "fish"
149 0x5377_6174, // "Swat"
150 0x4479_6e61, // "Dyna"
151 0x6d69_7465, // "mite"
152 ];
153
154 // 64 outer iterations, each enciphering all four pairs in place.
155 for _ in 0..64 {
156 let mut i = 0;
157 while i < 8 {
158 let (mut l, mut r) = (cdata[i], cdata[i + 1]);
159 state.encipher(&mut l, &mut r);
160 cdata[i] = l;
161 cdata[i + 1] = r;
162 i += 2;
163 }
164 }
165
166 // Little-endian byte serialisation (OpenBSD quirk).
167 let mut out = [0u8; BCRYPT_HASHSIZE];
168 for i in 0..8 {
169 out[4 * i..4 * i + 4].copy_from_slice(&cdata[i].to_le_bytes());
170 }
171 out
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn from_hex(s: &str) -> Vec<u8> {
179 let bytes: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
180 assert!(bytes.len().is_multiple_of(2));
181 bytes
182 .chunks(2)
183 .map(|p| {
184 let hi = (p[0] as char).to_digit(16).unwrap() as u8;
185 let lo = (p[1] as char).to_digit(16).unwrap() as u8;
186 (hi << 4) | lo
187 })
188 .collect()
189 }
190
191 /// `golang.org/x/crypto` `TestBcryptHash`: with `pass[i] = i` and
192 /// `salt[i] = i + 64` for `i = 0..64`, `bcryptHash` returns
193 /// `87904870eef9deddf8e7611a140106e6aaf1a363d9a2c504db356443721eb555`.
194 /// Source: `golang.org/x/crypto/ssh/internal/bcrypt_pbkdf/bcrypt_pbkdf_test.go`.
195 #[test]
196 fn go_bcrypt_hash_kat() {
197 let mut pass = [0u8; 64];
198 let mut salt = [0u8; 64];
199 for i in 0..64 {
200 pass[i] = i as u8;
201 salt[i] = (i + 64) as u8;
202 }
203 let out = bcrypt_hash(&pass, &salt);
204 let expected = from_hex("87904870eef9deddf8e7611a140106e6aaf1a363d9a2c504db356443721eb555");
205 assert_eq!(&out[..], &expected[..]);
206 }
207
208 /// Vector 1 from the task prompt: password="password", salt="salt",
209 /// rounds=4, keylen=32.
210 #[test]
211 fn pbkdf_vector_1() {
212 let out = bcrypt_pbkdf(b"password", b"salt", 4, 32).unwrap();
213 let expected = from_hex("5bbf0cc293587f1c3635555c27796598d47e579071bf427e9d8fbe842aba34d9");
214 assert_eq!(out, expected);
215 }
216
217 /// Go vector (`golang.org/x/crypto`): rounds=12, password="password",
218 /// salt="salt", keylen=32.
219 #[test]
220 fn pbkdf_go_vector_rounds12() {
221 let out = bcrypt_pbkdf(b"password", b"salt", 12, 32).unwrap();
222 let expected = from_hex("1ae42c05d487bc02f64921a4ebe4ea93bcacfe135fda99974c06b7b01fae149a");
223 assert_eq!(out, expected);
224 }
225
226 /// Go vector with embedded NULs and a longer derived key — exercises
227 /// the stride layout (keylen > 32 forces multiple output blocks).
228 #[test]
229 fn pbkdf_go_vector_stride() {
230 // password and salt as in TestKey vector 2.
231 let pwd: &[u8] = b"passwordy\x00PASSWORD\x00";
232 let salt: &[u8] = b"salty\x00SALT\x00";
233 let out = bcrypt_pbkdf(pwd, salt, 3, 32).unwrap();
234 let expected = from_hex("7f310bd3e78c3280c59ce4595211a2928e8d4ec744c1ed2efc9f764e3388e0ad");
235 assert_eq!(out, expected);
236 }
237
238 /// Go vector 3: long output (88 bytes) exercising both the stride
239 /// layout and the partial-final-block clamp. Source URL cited in the
240 /// upstream test: <http://thread.gmane.org/gmane.os.openbsd.bugs/20542>.
241 #[test]
242 fn pbkdf_go_vector_long_output() {
243 let pwd = "секретное слово".as_bytes();
244 let salt = "посолить немножко".as_bytes();
245 let out = bcrypt_pbkdf(pwd, salt, 8, 88).unwrap();
246 let expected = from_hex(
247 "8df43fc6fe131fc47f0c9e39224bd94c70b6fcc8ee8135faddf61156e6cb2733\
248 ea765f315a3e1e4afc35bf8687d189254c1e05a6fe80c0617f9183d67260d6a1\
249 15c6c94e3603e2303fbb43a76a64523ffda686b1d4518543",
250 );
251 assert_eq!(out.len(), 88);
252 assert_eq!(out, expected);
253 }
254
255 #[test]
256 fn rejects_zero_rounds() {
257 assert_eq!(
258 bcrypt_pbkdf(b"x", b"y", 0, 32),
259 Err(Error::InvalidParameters)
260 );
261 }
262
263 #[test]
264 fn rejects_zero_keylen() {
265 assert_eq!(
266 bcrypt_pbkdf(b"x", b"y", 1, 0),
267 Err(Error::InvalidParameters)
268 );
269 }
270
271 #[test]
272 fn rejects_too_large_keylen() {
273 assert_eq!(
274 bcrypt_pbkdf(b"x", b"y", 1, 1025),
275 Err(Error::InvalidParameters)
276 );
277 }
278
279 #[test]
280 fn accepts_max_keylen() {
281 let out = bcrypt_pbkdf(b"password", b"salt", 1, 1024).unwrap();
282 assert_eq!(out.len(), 1024);
283 }
284}