agentmux_srv/backend/viewer/
pairing.rs1use std::collections::{HashMap, VecDeque};
15use std::net::IpAddr;
16use std::time::{Duration, Instant};
17
18use agentmux_common::secret_eq::secret_eq;
19use base64::Engine as _;
20use sha2::{Digest, Sha256};
21
22pub const CODE_LEN: usize = 10;
23pub const CODE_TTL: Duration = Duration::from_secs(120);
24pub const WRONG_PER_ADDRESS: usize = 5;
27pub const WRONG_WINDOW: Duration = Duration::from_secs(60);
28pub const WRONG_IN_A_ROW: u32 = 10;
30pub const TOKEN_PREFIX: &str = "amxv_";
31
32const BASE32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
33
34pub fn random_bytes<const N: usize>() -> [u8; N] {
36 let mut buf = [0u8; N];
37 rustls::crypto::ring::default_provider()
38 .secure_random
39 .fill(&mut buf)
40 .expect("the system random source failed");
41 buf
42}
43
44pub fn new_code() -> String {
46 random_bytes::<CODE_LEN>().iter().map(|b| BASE32[(b % 32) as usize] as char).collect()
47}
48
49pub fn new_token() -> String {
51 format!(
52 "{TOKEN_PREFIX}{}",
53 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random_bytes::<32>())
54 )
55}
56
57pub fn token_hash(token: &str) -> String {
59 hex::encode(Sha256::digest(token.as_bytes()))
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Redeem {
65 Accepted,
66 Refused,
68 RateLimited,
70}
71
72#[derive(Default)]
73struct Inner {
74 codes: Vec<(String, Instant)>,
76 wrong_by_address: HashMap<Option<IpAddr>, VecDeque<Instant>>,
77 wrong_in_a_row: u32,
78}
79
80#[derive(Default)]
81pub struct Pairing {
82 inner: parking_lot::Mutex<Inner>,
83}
84
85impl Pairing {
86 pub fn new() -> Self {
87 Self::default()
88 }
89
90 pub fn start(&self) -> (String, Duration) {
93 self.start_at(Instant::now())
94 }
95
96 pub(crate) fn start_at(&self, now: Instant) -> (String, Duration) {
97 let code = new_code();
98 let mut inner = self.inner.lock();
99 inner.codes = vec![(code.clone(), now + CODE_TTL)];
100 (code, CODE_TTL)
101 }
102
103 pub fn redeem(&self, code: &str, from: Option<IpAddr>) -> Redeem {
106 self.redeem_at(code, from, Instant::now())
107 }
108
109 pub(crate) fn redeem_at(&self, code: &str, from: Option<IpAddr>, now: Instant) -> Redeem {
110 let mut inner = self.inner.lock();
111 inner.codes.retain(|(_, until)| *until > now);
112 let recent = inner.wrong_by_address.entry(from).or_default();
113 while recent.front().is_some_and(|at| now.duration_since(*at) >= WRONG_WINDOW) {
114 recent.pop_front();
115 }
116 if recent.len() >= WRONG_PER_ADDRESS {
117 return Redeem::RateLimited;
118 }
119 let presented = code.trim().to_ascii_uppercase();
120 let mut matched = None;
122 for (i, (c, _)) in inner.codes.iter().enumerate() {
123 if secret_eq(c.as_bytes(), presented.as_bytes()) {
124 matched = Some(i);
125 }
126 }
127 match matched {
128 Some(i) => {
129 inner.codes.remove(i);
130 inner.wrong_in_a_row = 0;
131 Redeem::Accepted
132 }
133 None => {
134 inner.wrong_by_address.entry(from).or_default().push_back(now);
135 inner.wrong_in_a_row += 1;
136 if inner.wrong_in_a_row >= WRONG_IN_A_ROW {
137 tracing::warn!(attempts = inner.wrong_in_a_row, "too many wrong pairing codes; cancelling the outstanding code");
138 inner.codes.clear();
139 inner.wrong_in_a_row = 0;
140 }
141 inner.wrong_by_address.retain(|_, v| v.back().is_some_and(|at| now.duration_since(*at) < WRONG_WINDOW));
143 Redeem::Refused
144 }
145 }
146 }
147
148 #[cfg(test)]
149 pub(crate) fn outstanding(&self) -> usize {
150 self.inner.lock().codes.len()
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn addr(last: u8) -> Option<IpAddr> {
159 Some(IpAddr::from([198, 51, 100, last]))
160 }
161
162 #[test]
163 fn a_code_is_ten_base32_characters() {
164 for _ in 0..50 {
165 let code = new_code();
166 assert_eq!(code.len(), CODE_LEN);
167 assert!(code.bytes().all(|b| BASE32.contains(&b)), "{code}");
168 }
169 assert_ne!(new_code(), new_code());
170 }
171
172 #[test]
173 fn a_token_has_the_prefix_and_43_base64url_characters() {
174 let t = new_token();
175 let body = t.strip_prefix(TOKEN_PREFIX).unwrap();
176 assert_eq!(body.len(), 43, "32 bytes, unpadded");
177 assert!(body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
178 assert_eq!(base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(body).unwrap().len(), 32);
179 assert_eq!(token_hash(&t).len(), 64);
180 assert_ne!(token_hash(&t), token_hash(&new_token()));
181 }
182
183 #[test]
184 fn a_code_works_once() {
185 let p = Pairing::new();
186 let (code, ttl) = p.start();
187 assert_eq!(ttl, CODE_TTL);
188 assert_eq!(p.redeem(&code, addr(1)), Redeem::Accepted);
189 assert_eq!(p.redeem(&code, addr(1)), Redeem::Refused, "single use");
190 }
191
192 #[test]
193 fn a_code_is_case_and_whitespace_tolerant() {
194 let p = Pairing::new();
195 let (code, _) = p.start();
196 assert_eq!(p.redeem(&format!(" {} ", code.to_lowercase()), addr(1)), Redeem::Accepted);
197 }
198
199 #[test]
200 fn a_code_expires_after_120_seconds() {
201 let p = Pairing::new();
202 let t0 = Instant::now();
203 let (code, _) = p.start_at(t0);
204 assert_eq!(p.redeem_at(&code, addr(1), t0 + CODE_TTL), Redeem::Refused);
205 let (code, _) = p.start_at(t0);
206 assert_eq!(p.redeem_at(&code, addr(1), t0 + CODE_TTL - Duration::from_secs(1)), Redeem::Accepted);
207 }
208
209 #[test]
210 fn a_new_code_cancels_the_last() {
211 let p = Pairing::new();
212 let (old, _) = p.start();
213 let (new, _) = p.start();
214 assert_eq!(p.redeem(&old, addr(1)), Redeem::Refused);
215 assert_eq!(p.redeem(&new, addr(1)), Redeem::Accepted);
216 }
217
218 #[test]
219 fn five_wrong_codes_in_a_minute_give_429_to_that_address_only() {
220 let p = Pairing::new();
221 let t0 = Instant::now();
222 let (code, _) = p.start_at(t0);
223 for i in 0..WRONG_PER_ADDRESS {
224 assert_eq!(p.redeem_at("AAAAAAAAAA", addr(1), t0 + Duration::from_secs(i as u64)), Redeem::Refused);
225 }
226 let t = t0 + Duration::from_secs(10);
227 assert_eq!(p.redeem_at(&code, addr(1), t), Redeem::RateLimited, "even the right code");
228 assert_eq!(p.redeem_at(&code, addr(2), t), Redeem::Accepted, "another address is not limited");
229
230 let (code, _) = p.start_at(t);
232 let later = t0 + WRONG_WINDOW + Duration::from_secs(5);
233 assert_eq!(p.redeem_at(&code, addr(1), later), Redeem::Accepted);
234 }
235
236 #[test]
237 fn ten_wrong_codes_in_a_row_cancel_the_outstanding_code() {
238 let p = Pairing::new();
239 let t0 = Instant::now();
240 let (code, _) = p.start_at(t0);
241 for i in 0..WRONG_IN_A_ROW {
243 let from = addr((i / 2) as u8);
244 assert_eq!(p.redeem_at("BBBBBBBBBB", from, t0), Redeem::Refused);
245 }
246 assert_eq!(p.outstanding(), 0);
247 assert_eq!(p.redeem_at(&code, addr(200), t0), Redeem::Refused, "cancelled");
248 }
249
250 #[test]
251 fn a_right_code_resets_the_run_of_wrong_ones() {
252 let p = Pairing::new();
253 let t0 = Instant::now();
254 for i in 0..(WRONG_IN_A_ROW - 1) {
255 p.redeem_at("CCCCCCCCCC", addr(i as u8), t0);
256 }
257 let (code, _) = p.start_at(t0);
258 assert_eq!(p.redeem_at(&code, addr(100), t0), Redeem::Accepted);
259 let (code, _) = p.start_at(t0);
260 p.redeem_at("CCCCCCCCCC", addr(101), t0);
261 assert_eq!(p.outstanding(), 1, "the run started over");
262 assert_eq!(p.redeem_at(&code, addr(102), t0), Redeem::Accepted);
263 }
264}