Skip to content
172 linesCodeBlameRaw
1use base64::Engine;
2use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
3use sha2::{Digest, Sha256};
4
5// Kept at the cap the Workers WebCrypto API imposes, so hashes made by
6// either implementation verify under the other.
7const PBKDF2_ITERATIONS: u32 = 100_000;
8
9pub fn sha256_hex(value: &str) -> String {
10 hex::encode(Sha256::digest(value.as_bytes()))
11}
12
13pub fn random_hex(bytes: usize) -> String {
14 let mut buffer = vec![0u8; bytes];
15 getrandom::getrandom(&mut buffer).expect("no source of randomness");
16 hex::encode(buffer)
17}
18
19/// A confirmation code: `digits` decimal digits, each uniformly random
20/// (bytes of 250 and up are drawn again, so no digit is likelier).
21pub fn random_digits(digits: usize) -> String {
22 let mut code = String::with_capacity(digits);
23 let mut byte = [0u8; 1];
24 while code.len() < digits {
25 getrandom::getrandom(&mut byte).expect("no source of randomness");
26 if byte[0] < 250 {
27 code.push(char::from(b'0' + byte[0] % 10));
28 }
29 }
30 code
31}
32
33/// What is kept of a confirmation code: an HMAC-SHA256 under `key` of the
34/// code, bound to the link it was sent with (`token_id`, the hash of the
35/// link's token, which names the user and the address). Six digits are few
36/// enough to try every one, so a key nobody reading the database has is
37/// what keeps the hash from giving the code away. `key` is IDENTITY_KEY;
38/// without one (a development setup) the hash is unkeyed.
39pub fn code_hash(key: &[u8], token_id: &str, code: &str) -> String {
40 use hmac::{Hmac, Mac};
41 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key length");
42 mac.update(b"g1t email confirmation code\0");
43 mac.update(token_id.as_bytes());
44 mac.update(b"\0");
45 mac.update(code.as_bytes());
46 hex::encode(mac.finalize().into_bytes())
47}
48
49/// Whether two strings are equal, in time that depends on their length only.
50pub fn same(a: &str, b: &str) -> bool {
51 a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
52}
53
54fn derive(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
55 let mut hash = [0u8; 32];
56 pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut hash);
57 hash
58}
59
60/// Format: `pbkdf2$<iterations>$<salt base64>$<hash base64>`.
61pub fn hash_password(password: &str) -> String {
62 let mut salt = [0u8; 16];
63 getrandom::getrandom(&mut salt).expect("no source of randomness");
64 let hash = derive(password, &salt, PBKDF2_ITERATIONS);
65 format!(
66 "pbkdf2${PBKDF2_ITERATIONS}${}${}",
67 STANDARD.encode(salt),
68 STANDARD.encode(hash)
69 )
70}
71
72pub fn verify_password(password: &str, stored: &str) -> bool {
73 let parts: Vec<&str> = stored.split('$').collect();
74 let [scheme, iterations, salt, hash] = parts[..] else {
75 return false;
76 };
77 let (Ok(iterations), Ok(salt), Ok(expected)) = (
78 iterations.parse::<u32>(),
79 STANDARD.decode(salt),
80 STANDARD.decode(hash),
81 ) else {
82 return false;
83 };
84 if scheme != "pbkdf2" || expected.len() != 32 {
85 return false;
86 }
87 let given = derive(password, &salt, iterations);
88 // Constant-time comparison.
89 given
90 .iter()
91 .zip(&expected)
92 .fold(0u8, |diff, (a, b)| diff | (a ^ b))
93 == 0
94}
95
96pub struct ParsedKey {
97 /// `<type> <base64 blob>`, without the comment.
98 pub public_key: String,
99 /// Matches `ssh-keygen -lf`: `SHA256:` then unpadded base64.
100 pub fingerprint: String,
101 pub comment: String,
102}
103
104/// Parses one line in OpenSSH public key format.
105pub fn parse_ssh_key(line: &str) -> Option<ParsedKey> {
106 let mut parts = line.split_whitespace();
107 let kind = parts.next()?;
108 let blob = parts.next()?;
109 let supported = matches!(
110 kind,
111 "ssh-ed25519"
112 | "ssh-rsa"
113 | "ecdsa-sha2-nistp256"
114 | "ecdsa-sha2-nistp384"
115 | "ecdsa-sha2-nistp521"
116 );
117 if !supported {
118 return None;
119 }
120 let bytes = STANDARD.decode(blob).ok()?;
121 // The blob starts with its own length-prefixed copy of the key type.
122 let length = u32::from_be_bytes(bytes.get(..4)?.try_into().ok()?) as usize;
123 if bytes.get(4..4 + length)? != kind.as_bytes() {
124 return None;
125 }
126 Some(ParsedKey {
127 public_key: format!("{kind} {blob}"),
128 fingerprint: format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(&bytes))),
129 comment: parts.collect::<Vec<_>>().join(" "),
130 })
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn confirmation_codes_are_six_digits_and_every_digit_turns_up() {
139 let mut seen = [0u32; 10];
140 for _ in 0..2000 {
141 let code = random_digits(6);
142 assert_eq!(code.len(), 6);
143 for digit in code.bytes() {
144 assert!(digit.is_ascii_digit());
145 seen[usize::from(digit - b'0')] += 1;
146 }
147 }
148 // 12,000 digits: each about 1,200 times.
149 assert!(seen.iter().all(|count| (900..1500).contains(count)), "{seen:?}");
150 }
151
152 #[test]
153 fn a_code_is_kept_as_a_keyed_hash_bound_to_its_link() {
154 let hash = code_hash(b"key", "link-a", "482913");
155 assert_eq!(hash.len(), 64);
156 assert!(!hash.contains("482913"));
157 assert_eq!(hash, code_hash(b"key", "link-a", "482913"));
158 assert_ne!(hash, code_hash(b"key", "link-b", "482913"));
159 assert_ne!(hash, code_hash(b"other", "link-a", "482913"));
160 assert_ne!(hash, code_hash(b"key", "link-a", "482914"));
161 // The separator keeps "link-a1" + "23456" apart from "link-a" + "123456".
162 assert_ne!(code_hash(b"key", "link-a1", "23456"), code_hash(b"key", "link-a", "123456"));
163 }
164
165 #[test]
166 fn same_compares_whole_strings() {
167 assert!(same("abc", "abc"));
168 assert!(!same("abc", "abd"));
169 assert!(!same("abc", "abcd"));
170 assert!(same("", ""));
171 }
172}