Skip to main content

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}