pw.rs (6705B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2026 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 use anyhow::anyhow; 18 use aws_lc_rs::digest::SHA256; 19 use rand::{TryRng as _, rngs::SysRng}; 20 use taler_api::error::{ApiResult, failure}; 21 use taler_common::{encoding::base64, error_code::ErrorCode}; 22 23 // NIST Password Guidelines 2024 24 const PASSWORD_MIN_LEN: usize = 8; 25 const PASSWORD_MAX_LEN: usize = 64; 26 27 /** Check if a string is a valid password */ 28 pub fn checkpw(pw: &str, check_quality: bool) -> ApiResult<()> { 29 if !check_quality { 30 return Ok(()); 31 } 32 let len = pw.len(); 33 34 if len < PASSWORD_MIN_LEN { 35 Err(failure( 36 ErrorCode::BANK_PASSWORD_TOO_SHORT, 37 format_args!( 38 "Password is too short, expect at least {PASSWORD_MIN_LEN} characters got {len}" 39 ), 40 )) 41 } else if len > PASSWORD_MAX_LEN { 42 Err(failure( 43 ErrorCode::BANK_PASSWORD_TOO_LONG, 44 format_args!( 45 "Password is too long, expect at most {PASSWORD_MAX_LEN} characters got {len}", 46 ), 47 )) 48 } else { 49 Ok(()) 50 } 51 } 52 53 #[derive(Debug, PartialEq, Eq)] 54 pub struct PwCheck { 55 pub matches: bool, 56 pub outdated: bool, 57 } 58 59 pub enum PwCrypto { 60 Bcrypt { cost: u32 }, 61 Sha256, 62 } 63 64 fn bcrypt(cost: u32, salt: [u8; 16], pw: &[u8]) -> [u8; 24] { 65 // The bcrypt spec specifies that passwords should be null terminated 66 // strings, but if longer than 72 bytes, are truncated at 72 bytes (thereby 67 // losing the null byte at the end). 68 let copy_len = pw.len().min(72); 69 let mut pass = [0u8; 72]; 70 pass[..copy_len].copy_from_slice(&pw[..copy_len]); 71 let used = (copy_len + 1).min(72); 72 let truncated = &pass[..used]; 73 bcrypt::bcrypt(cost, salt, truncated) 74 } 75 76 impl PwCrypto { 77 /** Hash [pw] using [cfg] hashing method */ 78 pub fn hashpw(&self, pw: &str) -> String { 79 match self { 80 PwCrypto::Bcrypt { cost } => { 81 let mut salt = [0u8; 16]; 82 SysRng.try_fill_bytes(&mut salt).unwrap(); 83 let pwh = bcrypt(*cost, salt, pw.as_bytes()); 84 format!("bcrypt${cost}${}${}", base64::fmt(salt), base64::fmt(pwh)) 85 } 86 PwCrypto::Sha256 => { 87 let pwh = aws_lc_rs::digest::digest(&SHA256, pw.as_bytes()); 88 format!("sha256${}", base64::fmt(pwh)) 89 } 90 } 91 } 92 93 /** Check whether [pw] match hashed [storedPwHash] and if it should be rehashed */ 94 pub fn checkpw(&self, pw: &str, stored_pw_hash: &str) -> anyhow::Result<PwCheck> { 95 let (alg, args) = stored_pw_hash 96 .split_once('$') 97 .ok_or(anyhow!("bad password hash format"))?; 98 let (matches, outdated) = match alg { 99 "sha256" => { 100 let [hash] = split_n(args, '$').ok_or(anyhow!("bad password hash format"))?; 101 let pwh = aws_lc_rs::digest::digest(&SHA256, pw.as_bytes()); 102 let pwh = base64::encode(pwh); 103 (pwh == hash, true) 104 } 105 "sha256-salted" => { 106 let [salt, hash] = split_n(args, '$').ok_or(anyhow!("bad password hash format"))?; 107 let pwh = aws_lc_rs::digest::digest(&SHA256, format!("{salt}|{pw}").as_bytes()); 108 let pwh = base64::encode(pwh); 109 (pwh == hash, true) 110 } 111 "bcrypt" => { 112 let [cost, salt, hash] = 113 split_n(args, '$').ok_or(anyhow!("bad password hash format"))?; 114 let cost: u32 = cost.parse()?; 115 let salt = base64::decode(salt)? 116 .try_into() 117 .map_err(|_| anyhow!("bad password hash format"))?; 118 let pwh = bcrypt(cost, salt, pw.as_bytes()); 119 let pwh = base64::encode(pwh); 120 ( 121 pwh == hash, 122 match self { 123 PwCrypto::Bcrypt { cost: expected } => cost != *expected, 124 PwCrypto::Sha256 => false, 125 }, 126 ) 127 } 128 alg => return Err(anyhow!("unsupported hash algo: {alg}")), 129 }; 130 131 Ok(PwCheck { matches, outdated }) 132 } 133 } 134 135 fn split_n<const N: usize>(input: &str, sep: char) -> Option<[&str; N]> { 136 let mut iter = input.split(sep); 137 138 let mut result = [""; N]; 139 140 for split in result.iter_mut().take(N) { 141 *split = iter.next()?; 142 } 143 144 if iter.next().is_some() { 145 None 146 } else { 147 Some(result) 148 } 149 } 150 151 #[test] 152 fn pwh() { 153 let pw = "myinsecurepw"; 154 let crypto = PwCrypto::Bcrypt { cost: 4 }; 155 // Check roundtrip 156 let hash = crypto.hashpw(pw); 157 assert_eq!( 158 crypto.checkpw(pw, &hash).unwrap(), 159 PwCheck { 160 matches: true, 161 outdated: false 162 } 163 ); 164 assert_eq!( 165 crypto.checkpw("other", &hash).unwrap(), 166 PwCheck { 167 matches: false, 168 outdated: false 169 } 170 ); 171 172 // Check outdated algorithm 173 let outdated = PwCrypto::Sha256.hashpw(pw); 174 assert_eq!( 175 crypto.checkpw(pw, &outdated).unwrap(), 176 PwCheck { 177 matches: true, 178 outdated: true 179 } 180 ); 181 assert_eq!( 182 crypto.checkpw("other", &outdated).unwrap(), 183 PwCheck { 184 matches: false, 185 outdated: true 186 } 187 ); 188 189 // Check outdated options 190 let better = PwCrypto::Bcrypt { cost: 5 }; 191 assert_eq!( 192 better.checkpw(pw, &hash).unwrap(), 193 PwCheck { 194 matches: true, 195 outdated: true 196 } 197 ); 198 assert_eq!( 199 better.checkpw("other", &hash).unwrap(), 200 PwCheck { 201 matches: false, 202 outdated: true 203 } 204 ); 205 206 // Check compatibility with kotlin hashes 207 let hash = "bcrypt$4$PfNp4JBeMU/t5mS5zaUY3A==$0Tjw0KzNA3ca3y9BVkh/e3TJDlHsdkEA"; 208 assert_eq!( 209 crypto.checkpw("password", hash).unwrap(), 210 PwCheck { 211 matches: true, 212 outdated: false 213 } 214 ); 215 }