libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

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 }