bcrypt: native $2b$ hashing (blowfish + eksblowfish; pi constants via exact fixed-point machin) wired into password_hash + MKPASSWD
This commit is contained in:
parent
5ea543188d
commit
d68f3ce97e
4 changed files with 465 additions and 1 deletions
445
src/bcrypt.rs
Normal file
445
src/bcrypt.rs
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
pub type Uid = u64;
|
||||
|
||||
pub mod accounts;
|
||||
pub mod bcrypt;
|
||||
pub mod channels;
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ fn main() {
|
|||
.unwrap_or_else(|| "echoircd.conf".to_string());
|
||||
let cfg = Config::load(&path);
|
||||
|
||||
// precompute the bcrypt constants off-thread so the first hash never stalls the core
|
||||
thread::spawn(echoircd::bcrypt::warm);
|
||||
|
||||
// Client plaintext connections run on the mio reactor, so bind a mio listener
|
||||
// (fail fast if the main port is taken).
|
||||
let bind_addr: std::net::SocketAddr = match cfg.bind.parse() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
//! A stored password is either plaintext (no recognised prefix) or `"<algo>:<hex>"`:
|
||||
//! * `md5:` `sha1:` `sha256:` `sha512:` — a plain hex digest of the password
|
||||
//! * `pbkdf2:<iters>:<salthex>:<hashhex>` — PBKDF2-HMAC-SHA256, salted
|
||||
//! * `$2b$<cost>$...` — bcrypt (see [`crate::bcrypt`]); MKPASSWD accepts a
|
||||
//! `bcrypt` or `bcrypt:<cost>` algorithm
|
||||
//!
|
||||
//! Comparisons are constant-time (`openssl::memcmp`). The OPER handler calls [`verify`].
|
||||
|
||||
|
|
@ -72,6 +74,10 @@ fn pbkdf2(pass: &str, salt: &[u8], iters: usize, len: usize) -> Option<Vec<u8>>
|
|||
/// Verify `plaintext` against a `stored` credential. Plaintext (no known prefix)
|
||||
/// falls back to a constant-time string compare, so existing configs keep working.
|
||||
pub fn verify(stored: &str, plaintext: &str) -> bool {
|
||||
// bcrypt: $2a$/$2b$/$2y$<cost>$<salt><hash>
|
||||
if stored.starts_with("$2a$") || stored.starts_with("$2b$") || stored.starts_with("$2y$") {
|
||||
return crate::bcrypt::verify(stored, plaintext);
|
||||
}
|
||||
// pbkdf2:<iters>:<salthex>:<hashhex>
|
||||
if let Some(rest) = stored.strip_prefix("pbkdf2:") {
|
||||
let parts: Vec<&str> = rest.splitn(3, ':').collect();
|
||||
|
|
@ -102,6 +108,15 @@ pub fn verify(stored: &str, plaintext: &str) -> bool {
|
|||
/// Produce a stored-credential string for `algo` over `plaintext`. For pbkdf2 a
|
||||
/// fresh 16-byte salt and 60000 iterations are used.
|
||||
fn make(algo: &str, plaintext: &str) -> Option<String> {
|
||||
// bcrypt, optionally "bcrypt:<cost>" (default cost 10)
|
||||
let lower = algo.to_ascii_lowercase();
|
||||
if lower == "bcrypt" || lower.starts_with("bcrypt:") {
|
||||
let cost = lower
|
||||
.strip_prefix("bcrypt:")
|
||||
.and_then(|c| c.parse().ok())
|
||||
.unwrap_or(10);
|
||||
return crate::bcrypt::hash(cost, plaintext);
|
||||
}
|
||||
if algo.eq_ignore_ascii_case("pbkdf2") {
|
||||
let mut salt = [0u8; 16];
|
||||
rand_bytes(&mut salt).ok()?;
|
||||
|
|
@ -158,7 +173,7 @@ impl Command for MkPasswd {
|
|||
s.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :Unknown hash '{algo}' (try md5, sha1, sha256, sha512, pbkdf2)",
|
||||
":{} NOTICE {nick} :Unknown hash '{algo}' (try md5, sha1, sha256, sha512, pbkdf2, bcrypt)",
|
||||
s.name
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue