ravenrs_sheet/model/
password.rs1use serde::{Deserialize, Serialize};
8use sha2::Digest;
9
10pub const DEFAULT_SPIN_COUNT: u32 = 100_000;
12pub const MAX_SPIN_COUNT: u32 = 10_000_000;
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub enum PasswordHash {
18 Legacy(u16),
20 Iterated { algorithm: String, hash: String, salt: String, spin_count: u32 },
22}
23
24impl PasswordHash {
25 pub fn new_sha512(password: &str, salt: &[u8]) -> PasswordHash {
27 let hash = iterated::<sha2::Sha512>(password, salt, DEFAULT_SPIN_COUNT);
28 PasswordHash::Iterated {
29 algorithm: "SHA-512".into(),
30 hash: crate::model::b64::encode(&hash),
31 salt: crate::model::b64::encode(salt),
32 spin_count: DEFAULT_SPIN_COUNT,
33 }
34 }
35
36 pub fn verify(&self, password: &str) -> Result<bool, String> {
39 match self {
40 PasswordHash::Legacy(h) => Ok(legacy_hash(password) == *h),
41 PasswordHash::Iterated { algorithm, hash, salt, spin_count } => {
42 if algorithm.is_empty() {
43 return Err("the stored password hash is damaged".into());
44 }
45 if *spin_count > MAX_SPIN_COUNT {
46 return Err(format!("the password uses a spin count of {spin_count}, more than the {MAX_SPIN_COUNT} allowed"));
47 }
48 let (Some(want), Some(salt)) = (crate::model::b64::decode(hash), crate::model::b64::decode(salt)) else {
49 return Err("the stored password hash is damaged".into());
50 };
51 let got = match algorithm.to_ascii_uppercase().replace('_', "-").as_str() {
52 "SHA-512" => iterated::<sha2::Sha512>(password, &salt, *spin_count),
53 "SHA-384" => iterated::<sha2::Sha384>(password, &salt, *spin_count),
54 "SHA-256" => iterated::<sha2::Sha256>(password, &salt, *spin_count),
55 _ => return Err(format!("passwords hashed with {algorithm} aren't supported")),
56 };
57 Ok(got == want)
58 }
59 }
60 }
61}
62
63pub fn salt_from(seed: &[u8]) -> [u8; 16] {
65 let h = sha2::Sha512::digest(seed);
66 let mut out = [0u8; 16];
67 for (o, b) in out.iter_mut().zip(h.iter()) {
68 *o = *b;
69 }
70 out
71}
72
73pub fn legacy_hash(password: &str) -> u16 {
77 let bytes: Vec<u16> = password.encode_utf16().map(|u| if u & 0xFF != 0 { u & 0xFF } else { u >> 8 }).collect();
78 let rotate = |v: u16| ((v >> 14) & 1) | ((v << 1) & 0x7FFF);
79 let mut h: u16 = 0;
80 for b in bytes.iter().rev() {
81 h = rotate(h) ^ b;
82 }
83 h = rotate(h) ^ (bytes.len() as u16);
84 h ^ 0xCE4B
85}
86
87fn iterated<D: Digest>(password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
89 let mut d = D::new();
90 d.update(salt);
91 for u in password.encode_utf16() {
92 d.update(u.to_le_bytes());
93 }
94 let mut h = d.finalize().to_vec();
95 for i in 0..spin {
96 let mut d = D::new();
97 d.update(&h);
98 d.update(i.to_le_bytes());
99 h = d.finalize().to_vec();
100 }
101 h
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn legacy_hash_matches_the_published_example() -> Result<(), Box<dyn std::error::Error>> {
110 assert_eq!(legacy_hash("password"), 0x83AF);
112 assert!(PasswordHash::Legacy(0x83AF).verify("password")?);
113 assert!(!PasswordHash::Legacy(0x83AF).verify("Password")?);
114 assert_eq!(legacy_hash(""), 0xCE4B);
115 Ok(())
116 }
117
118 #[test]
119 fn iterated_hash_verifies_and_refuses_what_it_cannot_check() -> Result<(), Box<dyn std::error::Error>> {
120 let h = PasswordHash::new_sha512("s3cret", b"0123456789abcdef");
121 assert!(h.verify("s3cret")?);
122 assert!(!h.verify("s3cret ")?);
123 assert!(matches!(&h, PasswordHash::Iterated { algorithm, spin_count: DEFAULT_SPIN_COUNT, .. } if algorithm == "SHA-512"));
124 let PasswordHash::Iterated { hash, salt, .. } = h else { return Err("not an iterated hash".into()) };
125 let other =
126 |algorithm: &str, spin_count| PasswordHash::Iterated { algorithm: algorithm.into(), hash: hash.clone(), salt: salt.clone(), spin_count };
127 assert!(other("SHA-1", 10).verify("s3cret").is_err());
128 assert!(other("MD5", 10).verify("s3cret").is_err());
129 assert!(other("SHA-512", MAX_SPIN_COUNT + 1).verify("s3cret").is_err());
130 assert!(!other("SHA-256", 2).verify("s3cret")?);
131 let broken = PasswordHash::Iterated { algorithm: "SHA-512".into(), hash: "!!".into(), salt: salt.clone(), spin_count: 1 };
132 assert!(broken.verify("s3cret").is_err());
133 Ok(())
134 }
135}