Skip to main content

ravenrs_sheet/model/
password.rs

1//! Excel-compatible password hashes for protection (ECMA-376 Part 1 §18.2.29 and §18.3.1.66
2//! attribute sets): the legacy 16-bit hash (`password="CC1A"`) and the iterated, salted hash
3//! (`algorithmName`, `hashValue`, `saltValue`, `spinCount`). Protection is a convenience, not
4//! security, but verification really checks the password: an unsupported variant is refused,
5//! never accepted.
6
7use serde::{Deserialize, Serialize};
8use sha2::Digest;
9
10/// Excel's default spin count for new passwords.
11pub const DEFAULT_SPIN_COUNT: u32 = 100_000;
12/// Largest spin count accepted when verifying (the documented maximum); a file asking for more
13/// would only make verification hang.
14pub const MAX_SPIN_COUNT: u32 = 10_000_000;
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub enum PasswordHash {
18    /// The legacy 16-bit hash.
19    Legacy(u16),
20    /// An iterated, salted hash as stored in the file: algorithm name, base64 hash and salt.
21    Iterated { algorithm: String, hash: String, salt: String, spin_count: u32 },
22}
23
24impl PasswordHash {
25    /// A SHA-512 hash with Excel's default spin count, for a new password.
26    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    /// Whether `password` matches. `Err` when the hash can't be checked (unsupported algorithm,
37    /// broken base64, spin count over [`MAX_SPIN_COUNT`]).
38    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
63/// A 16-byte salt derived from `seed` (callers mix in what varies: names, a counter, the clock).
64pub 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
73/// The legacy 16-bit password hash: each UTF-16 unit contributes its low byte (its high byte
74/// when the low one is zero), folded in from the last character with a 15-bit rotation, then
75/// the length and the constant `0xCE4B`.
76pub 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
87/// H0 = H(salt + UTF-16LE password); Hn = H(Hn-1 + n as 32-bit little-endian), `spin` times.
88fn 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        // The hash Excel writes for the password "password" (`password="83AF"`).
111        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}