| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
pub fn constant_time_compare(a: &str, b: &str) -> bool { |
| 13 |
use subtle::ConstantTimeEq; |
| 14 |
let a = a.as_bytes(); |
| 15 |
let b = b.as_bytes(); |
| 16 |
if a.len() != b.len() { |
| 17 |
return false; |
| 18 |
} |
| 19 |
a.ct_eq(b).into() |
| 20 |
} |
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
pub fn generate_key_code() -> crate::db::KeyCode { |
| 31 |
use rand::Rng; |
| 32 |
let mut rng = rand::rng(); |
| 33 |
let words: Vec<&str> = (0..6) |
| 34 |
.map(|_| { |
| 35 |
let idx = rng.random_range(0..crate::wordlist::WORDLIST.len()); |
| 36 |
crate::wordlist::WORDLIST[idx] |
| 37 |
}) |
| 38 |
.collect(); |
| 39 |
crate::db::KeyCode::from_trusted(words.join("-")) |
| 40 |
} |
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
|
| 46 |
pub fn generate_feed_url(host_url: &str, user_id: crate::db::UserId, secret: &str) -> String { |
| 47 |
use hmac::{Hmac, Mac}; |
| 48 |
use sha2::Sha256; |
| 49 |
|
| 50 |
let message = format!("feed:{}", user_id); |
| 51 |
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) |
| 52 |
.expect("HMAC-SHA256 accepts any key length"); |
| 53 |
mac.update(message.as_bytes()); |
| 54 |
let sig = hex::encode(mac.finalize().into_bytes()); |
| 55 |
|
| 56 |
format!("{}/feed/{}?sig={}", host_url, user_id, sig) |
| 57 |
} |
| 58 |
|
| 59 |
|
| 60 |
pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret: &str) -> bool { |
| 61 |
use hmac::{Hmac, Mac}; |
| 62 |
use sha2::Sha256; |
| 63 |
|
| 64 |
let message = format!("feed:{}", user_id); |
| 65 |
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) |
| 66 |
.expect("HMAC-SHA256 accepts any key length"); |
| 67 |
mac.update(message.as_bytes()); |
| 68 |
let expected = hex::encode(mac.finalize().into_bytes()); |
| 69 |
|
| 70 |
constant_time_compare(&expected, signature) |
| 71 |
} |
| 72 |
|
| 73 |
#[cfg(test)] |
| 74 |
mod tests { |
| 75 |
use super::*; |
| 76 |
|
| 77 |
|
| 78 |
|
| 79 |
#[test] |
| 80 |
fn compare_equal_strings() { |
| 81 |
assert!(constant_time_compare("abc123", "abc123")); |
| 82 |
} |
| 83 |
|
| 84 |
#[test] |
| 85 |
fn compare_different_strings() { |
| 86 |
assert!(!constant_time_compare("abc123", "abc124")); |
| 87 |
} |
| 88 |
|
| 89 |
#[test] |
| 90 |
fn compare_different_lengths() { |
| 91 |
assert!(!constant_time_compare("short", "longer")); |
| 92 |
} |
| 93 |
|
| 94 |
#[test] |
| 95 |
fn compare_empty_strings() { |
| 96 |
assert!(constant_time_compare("", "")); |
| 97 |
} |
| 98 |
|
| 99 |
#[test] |
| 100 |
fn adversarial_timing_safety() { |
| 101 |
assert!(!constant_time_compare("a", "b")); |
| 102 |
assert!(!constant_time_compare("a", "aa")); |
| 103 |
assert!(!constant_time_compare("", "x")); |
| 104 |
assert!(constant_time_compare("same", "same")); |
| 105 |
} |
| 106 |
|
| 107 |
|
| 108 |
|
| 109 |
#[test] |
| 110 |
fn key_code_format() { |
| 111 |
let code = generate_key_code(); |
| 112 |
let parts: Vec<&str> = code.split('-').collect(); |
| 113 |
assert_eq!(parts.len(), 6, "Key code should have 6 words"); |
| 114 |
for word in &parts { |
| 115 |
assert!(word.len() >= 3, "Each word should be at least 3 chars: {}", word); |
| 116 |
assert!(word.len() <= 6, "Each word should be at most 6 chars: {}", word); |
| 117 |
assert!(word.chars().all(|c| c.is_ascii_lowercase()), "Words should be lowercase: {}", word); |
| 118 |
} |
| 119 |
} |
| 120 |
|
| 121 |
#[test] |
| 122 |
fn key_code_uniqueness() { |
| 123 |
let codes: std::collections::HashSet<crate::db::KeyCode> = (0..100).map(|_| generate_key_code()).collect(); |
| 124 |
assert_eq!(codes.len(), 100, "100 generated key codes should all be unique"); |
| 125 |
} |
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
#[test] |
| 130 |
fn feed_url_round_trip() { |
| 131 |
let user_id = crate::db::UserId::new(); |
| 132 |
let url = generate_feed_url("https://makenot.work", user_id, "secret"); |
| 133 |
assert!(url.contains(&user_id.to_string())); |
| 134 |
assert!(url.contains("sig=")); |
| 135 |
let sig = url.split("sig=").nth(1).unwrap(); |
| 136 |
assert!(verify_feed_signature(user_id, sig, "secret")); |
| 137 |
} |
| 138 |
|
| 139 |
#[test] |
| 140 |
fn feed_url_wrong_secret_rejected() { |
| 141 |
let user_id = crate::db::UserId::new(); |
| 142 |
let url = generate_feed_url("https://makenot.work", user_id, "secret"); |
| 143 |
let sig = url.split("sig=").nth(1).unwrap(); |
| 144 |
assert!(!verify_feed_signature(user_id, sig, "wrong-secret")); |
| 145 |
} |
| 146 |
|
| 147 |
#[test] |
| 148 |
fn feed_url_wrong_user_rejected() { |
| 149 |
let user_id = crate::db::UserId::new(); |
| 150 |
let other_id = crate::db::UserId::new(); |
| 151 |
let url = generate_feed_url("https://makenot.work", user_id, "secret"); |
| 152 |
let sig = url.split("sig=").nth(1).unwrap(); |
| 153 |
assert!(!verify_feed_signature(other_id, sig, "secret")); |
| 154 |
} |
| 155 |
|
| 156 |
#[test] |
| 157 |
fn feed_signature_empty_string_rejected() { |
| 158 |
let user_id = crate::db::UserId::new(); |
| 159 |
assert!(!verify_feed_signature(user_id, "", "secret")); |
| 160 |
} |
| 161 |
|
| 162 |
#[test] |
| 163 |
fn feed_signature_tampered_rejected() { |
| 164 |
let user_id = crate::db::UserId::new(); |
| 165 |
let url = generate_feed_url("https://makenot.work", user_id, "secret"); |
| 166 |
let sig = url.split("sig=").nth(1).unwrap(); |
| 167 |
let mut tampered = sig.to_string(); |
| 168 |
let first = tampered.remove(0); |
| 169 |
tampered.insert(0, if first == '0' { '1' } else { '0' }); |
| 170 |
assert!(!verify_feed_signature(user_id, &tampered, "secret")); |
| 171 |
} |
| 172 |
} |
| 173 |
|