| 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 |
fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String { |
| 44 |
use hmac::{Hmac, Mac}; |
| 45 |
use sha2::Sha256; |
| 46 |
|
| 47 |
let message = format!("feed:{user_id}:{version}"); |
| 48 |
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) |
| 49 |
.expect("HMAC-SHA256 accepts any key length"); |
| 50 |
mac.update(message.as_bytes()); |
| 51 |
hex::encode(mac.finalize().into_bytes()) |
| 52 |
} |
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
pub fn generate_feed_url( |
| 62 |
host_url: &str, |
| 63 |
user_id: crate::db::UserId, |
| 64 |
version: i32, |
| 65 |
secret: &str, |
| 66 |
) -> String { |
| 67 |
let sig = feed_signature(user_id, version, secret); |
| 68 |
format!("{}/feed/{}?v={}&sig={}", host_url, user_id, version, sig) |
| 69 |
} |
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
pub fn verify_feed_signature( |
| 76 |
user_id: crate::db::UserId, |
| 77 |
version: i32, |
| 78 |
signature: &str, |
| 79 |
secret: &str, |
| 80 |
) -> bool { |
| 81 |
let expected = feed_signature(user_id, version, secret); |
| 82 |
constant_time_compare(&expected, signature) |
| 83 |
} |
| 84 |
|
| 85 |
#[cfg(test)] |
| 86 |
mod tests { |
| 87 |
use super::*; |
| 88 |
|
| 89 |
|
| 90 |
|
| 91 |
#[test] |
| 92 |
fn compare_equal_strings() { |
| 93 |
assert!(constant_time_compare("abc123", "abc123")); |
| 94 |
} |
| 95 |
|
| 96 |
#[test] |
| 97 |
fn compare_different_strings() { |
| 98 |
assert!(!constant_time_compare("abc123", "abc124")); |
| 99 |
} |
| 100 |
|
| 101 |
#[test] |
| 102 |
fn compare_different_lengths() { |
| 103 |
assert!(!constant_time_compare("short", "longer")); |
| 104 |
} |
| 105 |
|
| 106 |
#[test] |
| 107 |
fn compare_empty_strings() { |
| 108 |
assert!(constant_time_compare("", "")); |
| 109 |
} |
| 110 |
|
| 111 |
#[test] |
| 112 |
fn adversarial_timing_safety() { |
| 113 |
assert!(!constant_time_compare("a", "b")); |
| 114 |
assert!(!constant_time_compare("a", "aa")); |
| 115 |
assert!(!constant_time_compare("", "x")); |
| 116 |
assert!(constant_time_compare("same", "same")); |
| 117 |
} |
| 118 |
|
| 119 |
|
| 120 |
|
| 121 |
#[test] |
| 122 |
fn key_code_format() { |
| 123 |
let code = generate_key_code(); |
| 124 |
let parts: Vec<&str> = code.split('-').collect(); |
| 125 |
assert_eq!(parts.len(), 6, "Key code should have 6 words"); |
| 126 |
for word in &parts { |
| 127 |
assert!(word.len() >= 3, "Each word should be at least 3 chars: {}", word); |
| 128 |
assert!(word.len() <= 6, "Each word should be at most 6 chars: {}", word); |
| 129 |
assert!(word.chars().all(|c| c.is_ascii_lowercase()), "Words should be lowercase: {}", word); |
| 130 |
} |
| 131 |
} |
| 132 |
|
| 133 |
#[test] |
| 134 |
fn key_code_uniqueness() { |
| 135 |
let codes: std::collections::HashSet<crate::db::KeyCode> = (0..100).map(|_| generate_key_code()).collect(); |
| 136 |
assert_eq!(codes.len(), 100, "100 generated key codes should all be unique"); |
| 137 |
} |
| 138 |
|
| 139 |
|
| 140 |
|
| 141 |
|
| 142 |
fn sig_of(url: &str) -> &str { |
| 143 |
url.split("sig=").nth(1).unwrap() |
| 144 |
} |
| 145 |
|
| 146 |
#[test] |
| 147 |
fn feed_url_round_trip() { |
| 148 |
let user_id = crate::db::UserId::new(); |
| 149 |
let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); |
| 150 |
assert!(url.contains(&user_id.to_string())); |
| 151 |
assert!(url.contains("v=0")); |
| 152 |
assert!(url.contains("sig=")); |
| 153 |
assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret")); |
| 154 |
} |
| 155 |
|
| 156 |
#[test] |
| 157 |
fn feed_url_wrong_secret_rejected() { |
| 158 |
let user_id = crate::db::UserId::new(); |
| 159 |
let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); |
| 160 |
assert!(!verify_feed_signature(user_id, 0, sig_of(&url), "wrong-secret")); |
| 161 |
} |
| 162 |
|
| 163 |
#[test] |
| 164 |
fn feed_url_wrong_user_rejected() { |
| 165 |
let user_id = crate::db::UserId::new(); |
| 166 |
let other_id = crate::db::UserId::new(); |
| 167 |
let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); |
| 168 |
assert!(!verify_feed_signature(other_id, 0, sig_of(&url), "secret")); |
| 169 |
} |
| 170 |
|
| 171 |
#[test] |
| 172 |
fn feed_url_stale_version_rejected() { |
| 173 |
|
| 174 |
|
| 175 |
let user_id = crate::db::UserId::new(); |
| 176 |
let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); |
| 177 |
assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret")); |
| 178 |
assert!(!verify_feed_signature(user_id, 1, sig_of(&url), "secret")); |
| 179 |
} |
| 180 |
|
| 181 |
#[test] |
| 182 |
fn feed_signature_empty_string_rejected() { |
| 183 |
let user_id = crate::db::UserId::new(); |
| 184 |
assert!(!verify_feed_signature(user_id, 0, "", "secret")); |
| 185 |
} |
| 186 |
|
| 187 |
#[test] |
| 188 |
fn feed_signature_tampered_rejected() { |
| 189 |
let user_id = crate::db::UserId::new(); |
| 190 |
let url = generate_feed_url("https://makenot.work", user_id, 0, "secret"); |
| 191 |
let sig = sig_of(&url); |
| 192 |
let mut tampered = sig.to_string(); |
| 193 |
let first = tampered.remove(0); |
| 194 |
tampered.insert(0, if first == '0' { '1' } else { '0' }); |
| 195 |
assert!(!verify_feed_signature(user_id, 0, &tampered, "secret")); |
| 196 |
} |
| 197 |
} |
| 198 |
|