pr_01m47d15m3e54sn21z27rpy5n9/services/identity/src/crypto.rs

96 lines3,025 bytesCodeBlame
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
19fn derive(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
20 let mut hash = [0u8; 32];
21 pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut hash);
22 hash
23}
24
25/// Format: `pbkdf2$<iterations>$<salt base64>$<hash base64>`.
26pub fn hash_password(password: &str) -> String {
27 let mut salt = [0u8; 16];
28 getrandom::getrandom(&mut salt).expect("no source of randomness");
29 let hash = derive(password, &salt, PBKDF2_ITERATIONS);
30 format!(
31 "pbkdf2${PBKDF2_ITERATIONS}${}${}",
32 STANDARD.encode(salt),
33 STANDARD.encode(hash)
34 )
35}
36
37pub fn verify_password(password: &str, stored: &str) -> bool {
38 let parts: Vec<&str> = stored.split('$').collect();
39 let [scheme, iterations, salt, hash] = parts[..] else {
40 return false;
41 };
42 let (Ok(iterations), Ok(salt), Ok(expected)) = (
43 iterations.parse::<u32>(),
44 STANDARD.decode(salt),
45 STANDARD.decode(hash),
46 ) else {
47 return false;
48 };
49 if scheme != "pbkdf2" || expected.len() != 32 {
50 return false;
51 }
52 let given = derive(password, &salt, iterations);
53 // Constant-time comparison.
54 given
55 .iter()
56 .zip(&expected)
57 .fold(0u8, |diff, (a, b)| diff | (a ^ b))
58 == 0
59}
60
61pub struct ParsedKey {
62 /// `<type> <base64 blob>`, without the comment.
63 pub public_key: String,
64 /// Matches `ssh-keygen -lf`: `SHA256:` then unpadded base64.
65 pub fingerprint: String,
66 pub comment: String,
67}
68
69/// Parses one line in OpenSSH public key format.
70pub fn parse_ssh_key(line: &str) -> Option<ParsedKey> {
71 let mut parts = line.split_whitespace();
72 let kind = parts.next()?;
73 let blob = parts.next()?;
74 let supported = matches!(
75 kind,
76 "ssh-ed25519"
77 | "ssh-rsa"
78 | "ecdsa-sha2-nistp256"
79 | "ecdsa-sha2-nistp384"
80 | "ecdsa-sha2-nistp521"
81 );
82 if !supported {
83 return None;
84 }
85 let bytes = STANDARD.decode(blob).ok()?;
86 // The blob starts with its own length-prefixed copy of the key type.
87 let length = u32::from_be_bytes(bytes.get(..4)?.try_into().ok()?) as usize;
88 if bytes.get(4..4 + length)? != kind.as_bytes() {
89 return None;
90 }
91 Some(ParsedKey {
92 public_key: format!("{kind} {blob}"),
93 fingerprint: format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(&bytes))),
94 comment: parts.collect::<Vec<_>>().join(" "),
95 })
96}