445 lines
14 KiB
Rust
445 lines
14 KiB
Rust
//! Native bcrypt (`$2b$`) — the Blowfish-based password hash, dependency-free.
|
|
//!
|
|
//! bcrypt needs Blowfish's *modified* ("expensive") key schedule, which OpenSSL's
|
|
//! Blowfish EVP doesn't expose, so the cipher and the eksblowfish schedule are
|
|
//! implemented here. The Blowfish P-array and S-boxes are the fractional hex digits
|
|
//! of pi; rather than hard-code ~1000 magic constants we derive them once with an
|
|
//! exact fixed-point Machin computation (validated against the known values in a
|
|
//! test). Only [`hash`] and [`verify`] are public; [`crate::modules::password_hash`]
|
|
//! calls them.
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
// --- exact fixed-point big integer (only what the pi computation needs) --------
|
|
|
|
const BITS: usize = 33408; // 33344 result bits + 64 guard bits (all multiples of 32)
|
|
const NLIMB: usize = 1046; // 32-bit limbs, little-endian; > (BITS+2) bits
|
|
|
|
#[derive(Clone)]
|
|
struct Big(Vec<u32>);
|
|
|
|
impl Big {
|
|
fn zero() -> Big {
|
|
Big(vec![0u32; NLIMB])
|
|
}
|
|
fn pow2(bits: usize) -> Big {
|
|
let mut b = Big::zero();
|
|
b.0[bits / 32] |= 1 << (bits % 32);
|
|
b
|
|
}
|
|
/// Divide in place by a small divisor (d fits well within 32 bits).
|
|
fn div_small(&mut self, d: u64) {
|
|
self.div_small_upto(d, NLIMB - 1);
|
|
}
|
|
/// As [`div_small`] but only over limbs `0..=hi` (limbs above `hi` are zero).
|
|
fn div_small_upto(&mut self, d: u64, hi: usize) {
|
|
let mut rem: u64 = 0;
|
|
for i in (0..=hi).rev() {
|
|
let cur = (rem << 32) | self.0[i] as u64;
|
|
self.0[i] = (cur / d) as u32;
|
|
rem = cur % d;
|
|
}
|
|
}
|
|
fn mul_small(&mut self, m: u64) {
|
|
let mut carry: u64 = 0;
|
|
for i in 0..NLIMB {
|
|
let cur = self.0[i] as u64 * m + carry;
|
|
self.0[i] = cur as u32;
|
|
carry = cur >> 32;
|
|
}
|
|
}
|
|
/// Add `o`'s limbs `0..=hi` into self, propagating any carry above `hi`.
|
|
fn add_assign_upto(&mut self, o: &Big, hi: usize) {
|
|
let mut carry: u64 = 0;
|
|
for i in 0..=hi {
|
|
let s = self.0[i] as u64 + o.0[i] as u64 + carry;
|
|
self.0[i] = s as u32;
|
|
carry = s >> 32;
|
|
}
|
|
let mut i = hi + 1;
|
|
while carry != 0 && i < NLIMB {
|
|
let s = self.0[i] as u64 + carry;
|
|
self.0[i] = s as u32;
|
|
carry = s >> 32;
|
|
i += 1;
|
|
}
|
|
}
|
|
/// Subtract `o`'s limbs `0..=hi` from self, propagating any borrow above `hi`.
|
|
fn sub_assign_upto(&mut self, o: &Big, hi: usize) {
|
|
let mut borrow: i64 = 0;
|
|
for i in 0..=hi {
|
|
let d = self.0[i] as i64 - o.0[i] as i64 - borrow;
|
|
if d < 0 {
|
|
self.0[i] = (d + (1 << 32)) as u32;
|
|
borrow = 1;
|
|
} else {
|
|
self.0[i] = d as u32;
|
|
borrow = 0;
|
|
}
|
|
}
|
|
let mut i = hi + 1;
|
|
while borrow != 0 && i < NLIMB {
|
|
let d = self.0[i] as i64 - borrow;
|
|
if d < 0 {
|
|
self.0[i] = (d + (1 << 32)) as u32;
|
|
borrow = 1;
|
|
} else {
|
|
self.0[i] = d as u32;
|
|
borrow = 0;
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
fn sub_assign(&mut self, o: &Big) {
|
|
let mut borrow: i64 = 0;
|
|
for i in 0..NLIMB {
|
|
let d = self.0[i] as i64 - o.0[i] as i64 - borrow;
|
|
if d < 0 {
|
|
self.0[i] = (d + (1 << 32)) as u32;
|
|
borrow = 1;
|
|
} else {
|
|
self.0[i] = d as u32;
|
|
borrow = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `atan(1/x) * 2^BITS` as a big integer (Gregory series; alternating + decreasing,
|
|
/// so partial sums stay non-negative). Uses one reused scratch buffer and only
|
|
/// touches the still-significant low limbs of `term` (which shrinks each step).
|
|
fn atan_inv(x: u64) -> Big {
|
|
let mut term = Big::pow2(BITS);
|
|
term.div_small(x); // 2^BITS / x
|
|
let mut sum = Big::zero();
|
|
let mut c = Big::zero(); // reused: term / (2k+1)
|
|
let x2 = x * x;
|
|
let mut k: u64 = 0;
|
|
let mut hi = NLIMB - 1; // highest limb of `term` that can be non-zero
|
|
loop {
|
|
c.0[..=hi].copy_from_slice(&term.0[..=hi]);
|
|
c.div_small_upto(2 * k + 1, hi);
|
|
if k % 2 == 0 {
|
|
sum.add_assign_upto(&c, hi);
|
|
} else {
|
|
sum.sub_assign_upto(&c, hi);
|
|
}
|
|
term.div_small_upto(x2, hi);
|
|
while hi > 0 && term.0[hi] == 0 {
|
|
hi -= 1;
|
|
}
|
|
if hi == 0 && term.0[0] == 0 {
|
|
break;
|
|
}
|
|
k += 1;
|
|
}
|
|
sum
|
|
}
|
|
|
|
/// The 1042 Blowfish init words (P[18] then S[4][256]) = the fractional hex digits
|
|
/// of pi, via Machin's `pi = 16*atan(1/5) - 4*atan(1/239)`.
|
|
fn pi_words() -> &'static [u32; 1042] {
|
|
static WORDS: OnceLock<[u32; 1042]> = OnceLock::new();
|
|
WORDS.get_or_init(|| {
|
|
let mut pi = atan_inv(5);
|
|
pi.mul_small(16);
|
|
let mut a239 = atan_inv(239);
|
|
a239.mul_small(4);
|
|
pi.sub_assign(&a239); // pi * 2^BITS
|
|
let mut three = Big::pow2(BITS);
|
|
three.mul_small(3);
|
|
pi.sub_assign(&three); // frac(pi) * 2^BITS, integer part removed
|
|
// top word (limb 1043) is the most significant; guard bits are limbs 0..1
|
|
let mut out = [0u32; 1042];
|
|
for (i, w) in out.iter_mut().enumerate() {
|
|
*w = pi.0[1043 - i];
|
|
}
|
|
out
|
|
})
|
|
}
|
|
|
|
// --- Blowfish cipher ----------------------------------------------------------
|
|
|
|
struct Bf {
|
|
p: [u32; 18],
|
|
s: [[u32; 256]; 4],
|
|
}
|
|
|
|
impl Bf {
|
|
fn init() -> Bf {
|
|
let w = pi_words();
|
|
let mut p = [0u32; 18];
|
|
p.copy_from_slice(&w[0..18]);
|
|
let mut s = [[0u32; 256]; 4];
|
|
for (i, row) in s.iter_mut().enumerate() {
|
|
row.copy_from_slice(&w[18 + i * 256..18 + (i + 1) * 256]);
|
|
}
|
|
Bf { p, s }
|
|
}
|
|
|
|
fn f(&self, x: u32) -> u32 {
|
|
let a = (x >> 24) as usize & 0xff;
|
|
let b = (x >> 16) as usize & 0xff;
|
|
let c = (x >> 8) as usize & 0xff;
|
|
let d = x as usize & 0xff;
|
|
((self.s[0][a].wrapping_add(self.s[1][b])) ^ self.s[2][c]).wrapping_add(self.s[3][d])
|
|
}
|
|
|
|
fn encrypt(&self, mut l: u32, mut r: u32) -> (u32, u32) {
|
|
for i in 0..16 {
|
|
l ^= self.p[i];
|
|
r ^= self.f(l);
|
|
std::mem::swap(&mut l, &mut r);
|
|
}
|
|
std::mem::swap(&mut l, &mut r); // undo the final swap
|
|
r ^= self.p[16];
|
|
l ^= self.p[17];
|
|
(l, r)
|
|
}
|
|
}
|
|
|
|
/// Read the next big-endian 32-bit word from `bytes`, cycling, advancing `*off`.
|
|
fn next_word(bytes: &[u8], off: &mut usize) -> u32 {
|
|
let mut v = 0u32;
|
|
for _ in 0..4 {
|
|
v = (v << 8) | bytes[*off] as u32;
|
|
*off = (*off + 1) % bytes.len();
|
|
}
|
|
v
|
|
}
|
|
|
|
/// The bcrypt key schedule step: XOR `key` (cycled) into P, then run the Blowfish
|
|
/// blockcrypt XORing `salt` (cycled) into the running block. `salt` all-zero =
|
|
/// the "expand0state" variant.
|
|
fn expand_key(bf: &mut Bf, salt: &[u8], key: &[u8]) {
|
|
let mut kp = 0;
|
|
for i in 0..18 {
|
|
bf.p[i] ^= next_word(key, &mut kp);
|
|
}
|
|
let mut sp = 0;
|
|
let (mut l, mut r) = (0u32, 0u32);
|
|
for i in (0..18).step_by(2) {
|
|
l ^= next_word(salt, &mut sp);
|
|
r ^= next_word(salt, &mut sp);
|
|
let (nl, nr) = bf.encrypt(l, r);
|
|
l = nl;
|
|
r = nr;
|
|
bf.p[i] = l;
|
|
bf.p[i + 1] = r;
|
|
}
|
|
for i in 0..4 {
|
|
for j in (0..256).step_by(2) {
|
|
l ^= next_word(salt, &mut sp);
|
|
r ^= next_word(salt, &mut sp);
|
|
let (nl, nr) = bf.encrypt(l, r);
|
|
l = nl;
|
|
r = nr;
|
|
bf.s[i][j] = l;
|
|
bf.s[i][j + 1] = r;
|
|
}
|
|
}
|
|
}
|
|
|
|
// "OrpheanBeholderScryDoubt" as six big-endian words.
|
|
const MAGIC: [u32; 6] = [
|
|
0x4f727068, 0x65616e42, 0x65686f6c, 0x64657253, 0x63727944, 0x6f756274,
|
|
];
|
|
|
|
/// The core bcrypt: 23 raw hash bytes for `cost`, 16-byte `salt`, `password`.
|
|
fn bcrypt_raw(cost: u32, salt: &[u8; 16], password: &[u8]) -> [u8; 23] {
|
|
// key = password (max 72 bytes) + a NUL terminator (the $2b behaviour)
|
|
let mut key: Vec<u8> = password.iter().take(72).copied().collect();
|
|
key.push(0);
|
|
let zero = [0u8; 16];
|
|
|
|
let mut bf = Bf::init();
|
|
expand_key(&mut bf, salt, &key);
|
|
let rounds = 1u64 << cost;
|
|
for _ in 0..rounds {
|
|
expand_key(&mut bf, &zero, &key);
|
|
expand_key(&mut bf, &zero, salt);
|
|
}
|
|
|
|
let mut ct = MAGIC;
|
|
for _ in 0..64 {
|
|
for i in (0..6).step_by(2) {
|
|
let (l, r) = bf.encrypt(ct[i], ct[i + 1]);
|
|
ct[i] = l;
|
|
ct[i + 1] = r;
|
|
}
|
|
}
|
|
let mut out = [0u8; 23];
|
|
for (i, chunk) in out.chunks_mut(4).enumerate() {
|
|
let be = ct[i].to_be_bytes();
|
|
chunk.copy_from_slice(&be[..chunk.len()]); // last chunk is 3 bytes → 23 total
|
|
}
|
|
out
|
|
}
|
|
|
|
// --- bcrypt's own base64 ("./A-Za-z0-9", no padding) --------------------------
|
|
|
|
const B64: &[u8; 64] = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
|
fn b64_encode(data: &[u8]) -> String {
|
|
let mut out = String::new();
|
|
let mut i = 0;
|
|
while i < data.len() {
|
|
let b0 = data[i] as usize;
|
|
out.push(B64[b0 >> 2] as char);
|
|
if i + 1 >= data.len() {
|
|
out.push(B64[(b0 & 0x03) << 4] as char);
|
|
break;
|
|
}
|
|
let b1 = data[i + 1] as usize;
|
|
out.push(B64[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
|
|
if i + 2 >= data.len() {
|
|
out.push(B64[(b1 & 0x0f) << 2] as char);
|
|
break;
|
|
}
|
|
let b2 = data[i + 2] as usize;
|
|
out.push(B64[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
|
|
out.push(B64[b2 & 0x3f] as char);
|
|
i += 3;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn b64_val(c: u8) -> Option<u8> {
|
|
B64.iter().position(|&x| x == c).map(|p| p as u8)
|
|
}
|
|
|
|
/// Decode `n` bytes from a bcrypt-base64 string.
|
|
fn b64_decode(s: &[u8], n: usize) -> Option<Vec<u8>> {
|
|
let mut out = Vec::with_capacity(n);
|
|
let mut i = 0;
|
|
while out.len() < n {
|
|
let c0 = b64_val(*s.get(i)?)?;
|
|
let c1 = b64_val(*s.get(i + 1)?)?;
|
|
out.push((c0 << 2) | (c1 >> 4));
|
|
if out.len() == n {
|
|
break;
|
|
}
|
|
let c2 = b64_val(*s.get(i + 2)?)?;
|
|
out.push(((c1 & 0x0f) << 4) | (c2 >> 2));
|
|
if out.len() == n {
|
|
break;
|
|
}
|
|
let c3 = b64_val(*s.get(i + 3)?)?;
|
|
out.push(((c2 & 0x03) << 6) | c3);
|
|
i += 4;
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
// --- public API ---------------------------------------------------------------
|
|
|
|
/// Force the one-time pi-constant computation. Call once at boot from a background
|
|
/// thread so the first real bcrypt use doesn't stall the single-threaded core.
|
|
pub fn warm() {
|
|
let _ = pi_words();
|
|
}
|
|
|
|
/// Produce a `$2b$<cost>$...` hash of `password` with a fresh random salt.
|
|
pub fn hash(cost: u32, password: &str) -> Option<String> {
|
|
let cost = cost.clamp(4, 31);
|
|
let mut salt = [0u8; 16];
|
|
openssl::rand::rand_bytes(&mut salt).ok()?;
|
|
let raw = bcrypt_raw(cost, &salt, password.as_bytes());
|
|
Some(format!(
|
|
"$2b${cost:02}${}{}",
|
|
b64_encode(&salt),
|
|
b64_encode(&raw)
|
|
))
|
|
}
|
|
|
|
/// Verify `password` against a stored `$2a$`/`$2b$`/`$2y$` bcrypt hash.
|
|
pub fn verify(stored: &str, password: &str) -> bool {
|
|
let b = stored.as_bytes();
|
|
if b.len() != 60 || &b[0..2] != b"$2" {
|
|
return false;
|
|
}
|
|
// $2X$CC$<22 salt><31 hash>
|
|
if b[3] != b'$' || b[6] != b'$' {
|
|
return false;
|
|
}
|
|
let cost: u32 = match stored[4..6].parse() {
|
|
Ok(c) => c,
|
|
Err(_) => return false,
|
|
};
|
|
if !(4..=31).contains(&cost) {
|
|
return false;
|
|
}
|
|
let salt = match b64_decode(&b[7..29], 16) {
|
|
Some(s) => s,
|
|
None => return false,
|
|
};
|
|
let mut salt16 = [0u8; 16];
|
|
salt16.copy_from_slice(&salt);
|
|
let raw = bcrypt_raw(cost, &salt16, password.as_bytes());
|
|
let want = &b[29..60];
|
|
let got = b64_encode(&raw);
|
|
got.len() == want.len() && openssl::memcmp::eq(got.as_bytes(), want)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn pi_constants_match_blowfish() {
|
|
// the canonical Blowfish P-array head = pi's fractional hex digits
|
|
let w = pi_words();
|
|
assert_eq!(w[0], 0x243f6a88);
|
|
assert_eq!(w[1], 0x85a308d3);
|
|
assert_eq!(w[2], 0x13198a2e);
|
|
assert_eq!(w[3], 0x03707344);
|
|
assert_eq!(w[4], 0xa4093822);
|
|
assert_eq!(w[5], 0x299f31d0);
|
|
// first S-box word (S[0][0])
|
|
assert_eq!(w[18], 0xd1310ba6);
|
|
}
|
|
|
|
#[test]
|
|
fn openbsd_test_vectors() {
|
|
// classic OpenBSD bcrypt vectors (cost 5). $2a and our $2b agree for
|
|
// these (no NUL/length edge cases), so we compare the full string.
|
|
let cases = [
|
|
(
|
|
"U*U",
|
|
"$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW",
|
|
),
|
|
(
|
|
"U*U*",
|
|
"$2a$05$CCCCCCCCCCCCCCCCCCCCC.VGOzA784oUp/Z0DY336zx7pLYAy0lwK",
|
|
),
|
|
(
|
|
"U*U*U",
|
|
"$2a$05$XXXXXXXXXXXXXXXXXXXXXOAcXxm9kjPGEMsLznoKqmqw7tc8WCx4a",
|
|
),
|
|
(
|
|
"",
|
|
"$2a$05$CCCCCCCCCCCCCCCCCCCCC.7uG0VCzI2bS7j6ymqJi9CdcdxiRTWNy",
|
|
),
|
|
];
|
|
for (pw, h) in cases {
|
|
assert!(verify(h, pw), "should verify {pw:?}");
|
|
assert!(!verify(h, "wrong"), "should reject wrong pw for {h}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hash_then_verify_roundtrip() {
|
|
let h = hash(6, "correct horse battery staple").unwrap();
|
|
assert!(h.starts_with("$2b$06$"));
|
|
assert_eq!(h.len(), 60);
|
|
assert!(verify(&h, "correct horse battery staple"));
|
|
assert!(!verify(&h, "wrong pass"));
|
|
}
|
|
|
|
#[test]
|
|
fn base64_roundtrips() {
|
|
let data = [0u8, 1, 2, 250, 128, 64, 32, 16, 255, 3, 7, 200, 199, 9, 11, 42];
|
|
let enc = b64_encode(&data);
|
|
assert_eq!(b64_decode(enc.as_bytes(), 16).unwrap(), data);
|
|
}
|
|
}
|