Skip to content
187 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Polish: phones, copy boxes, the plan page, the landing page, a real glide1use 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
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)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
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm49/// The proof an invite email's link carries that whoever follows it reads
50/// that inbox: an HMAC-SHA256 under `key` (IDENTITY_KEY) of the invite's id
51/// and the address it is bound to, trimmed and lowercased. Only the email
52/// has it: the inviter sees the code, never this, and nobody can make one
53/// without the key.
54pub fn invite_proof(key: &[u8], invite_id: &str, email: &str) -> String {
55 use hmac::{Hmac, Mac};
56 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key length");
57 mac.update(b"g1t invite email proof\0");
58 mac.update(invite_id.as_bytes());
59 mac.update(b"\0");
60 mac.update(email.trim().to_lowercase().as_bytes());
61 hex::encode(mac.finalize().into_bytes())
62}
63
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)64/// Whether two strings are equal, in time that depends on their length only.
65pub fn same(a: &str, b: &str) -> bool {
66 a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
67}
68
Polish: phones, copy boxes, the plan page, the landing page, a real glide69fn derive(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
70 let mut hash = [0u8; 32];
71 pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut hash);
72 hash
73}
74
75/// Format: `pbkdf2$<iterations>$<salt base64>$<hash base64>`.
76pub fn hash_password(password: &str) -> String {
77 let mut salt = [0u8; 16];
78 getrandom::getrandom(&mut salt).expect("no source of randomness");
79 let hash = derive(password, &salt, PBKDF2_ITERATIONS);
80 format!(
81 "pbkdf2${PBKDF2_ITERATIONS}${}${}",
82 STANDARD.encode(salt),
83 STANDARD.encode(hash)
84 )
85}
86
87pub fn verify_password(password: &str, stored: &str) -> bool {
88 let parts: Vec<&str> = stored.split('$').collect();
89 let [scheme, iterations, salt, hash] = parts[..] else {
90 return false;
91 };
92 let (Ok(iterations), Ok(salt), Ok(expected)) = (
93 iterations.parse::<u32>(),
94 STANDARD.decode(salt),
95 STANDARD.decode(hash),
96 ) else {
97 return false;
98 };
99 if scheme != "pbkdf2" || expected.len() != 32 {
100 return false;
101 }
102 let given = derive(password, &salt, iterations);
103 // Constant-time comparison.
104 given
105 .iter()
106 .zip(&expected)
107 .fold(0u8, |diff, (a, b)| diff | (a ^ b))
108 == 0
109}
110
111pub struct ParsedKey {
112 /// `<type> <base64 blob>`, without the comment.
113 pub public_key: String,
114 /// Matches `ssh-keygen -lf`: `SHA256:` then unpadded base64.
115 pub fingerprint: String,
116 pub comment: String,
117}
118
119/// Parses one line in OpenSSH public key format.
120pub fn parse_ssh_key(line: &str) -> Option<ParsedKey> {
121 let mut parts = line.split_whitespace();
122 let kind = parts.next()?;
123 let blob = parts.next()?;
124 let supported = matches!(
125 kind,
126 "ssh-ed25519"
127 | "ssh-rsa"
128 | "ecdsa-sha2-nistp256"
129 | "ecdsa-sha2-nistp384"
130 | "ecdsa-sha2-nistp521"
131 );
132 if !supported {
133 return None;
134 }
135 let bytes = STANDARD.decode(blob).ok()?;
136 // The blob starts with its own length-prefixed copy of the key type.
137 let length = u32::from_be_bytes(bytes.get(..4)?.try_into().ok()?) as usize;
138 if bytes.get(4..4 + length)? != kind.as_bytes() {
139 return None;
140 }
141 Some(ParsedKey {
142 public_key: format!("{kind} {blob}"),
143 fingerprint: format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(&bytes))),
144 comment: parts.collect::<Vec<_>>().join(" "),
145 })
146}
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn confirmation_codes_are_six_digits_and_every_digit_turns_up() {
154 let mut seen = [0u32; 10];
155 for _ in 0..2000 {
156 let code = random_digits(6);
157 assert_eq!(code.len(), 6);
158 for digit in code.bytes() {
159 assert!(digit.is_ascii_digit());
160 seen[usize::from(digit - b'0')] += 1;
161 }
162 }
163 // 12,000 digits: each about 1,200 times.
164 assert!(seen.iter().all(|count| (900..1500).contains(count)), "{seen:?}");
165 }
166
167 #[test]
168 fn a_code_is_kept_as_a_keyed_hash_bound_to_its_link() {
169 let hash = code_hash(b"key", "link-a", "482913");
170 assert_eq!(hash.len(), 64);
171 assert!(!hash.contains("482913"));
172 assert_eq!(hash, code_hash(b"key", "link-a", "482913"));
173 assert_ne!(hash, code_hash(b"key", "link-b", "482913"));
174 assert_ne!(hash, code_hash(b"other", "link-a", "482913"));
175 assert_ne!(hash, code_hash(b"key", "link-a", "482914"));
176 // The separator keeps "link-a1" + "23456" apart from "link-a" + "123456".
177 assert_ne!(code_hash(b"key", "link-a1", "23456"), code_hash(b"key", "link-a", "123456"));
178 }
179
180 #[test]
181 fn same_compares_whole_strings() {
182 assert!(same("abc", "abc"));
183 assert!(!same("abc", "abd"));
184 assert!(!same("abc", "abcd"));
185 assert!(same("", ""));
186 }
187}

This file's history is long; its oldest lines are credited to the oldest commit read.