Skip to main content

agentmux_srv/backend/viewer/
pairing.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pairing codes and viewer tokens (agentmux-mobile's
5//! SPEC_AGENT_STATUS_AND_LIVE_PANE_FEED_2026_10_07 ยง13.2).
6//!
7//! A code is shown in the desktop's QR, so whoever redeems it has seen this
8//! screen. It is 10 characters of base32, works once, for 120 s, and only the
9//! newest one shown works: showing a new QR cancels the last. Guessing is
10//! bounded twice over: an address with five wrong codes in the last minute is
11//! refused with 429 before its next code is even compared, and ten wrong codes
12//! in a row, from anywhere, cancel every outstanding code.
13
14use 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);
24/// Wrong codes one address may send per [`WRONG_WINDOW`]; the next attempt
25/// gets 429.
26pub const WRONG_PER_ADDRESS: usize = 5;
27pub const WRONG_WINDOW: Duration = Duration::from_secs(60);
28/// Wrong codes in a row, from any address, that cancel every outstanding code.
29pub const WRONG_IN_A_ROW: u32 = 10;
30pub const TOKEN_PREFIX: &str = "amxv_";
31
32const BASE32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
33
34/// `n` bytes from the operating system's CSPRNG (ring's, through rustls).
35pub 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
44/// A fresh pairing code. 32 divides 256, so `byte % 32` is uniform.
45pub fn new_code() -> String {
46    random_bytes::<CODE_LEN>().iter().map(|b| BASE32[(b % 32) as usize] as char).collect()
47}
48
49/// A fresh viewer token: `amxv_` + base64url (no padding) of 32 random bytes.
50pub 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
57/// What a token is stored as: lowercase hex SHA-256.
58pub fn token_hash(token: &str) -> String {
59    hex::encode(Sha256::digest(token.as_bytes()))
60}
61
62/// The outcome of presenting a code.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Redeem {
65    Accepted,
66    /// Unknown, expired, already used or cancelled: 401.
67    Refused,
68    /// Too many wrong codes from this address: 429.
69    RateLimited,
70}
71
72#[derive(Default)]
73struct Inner {
74    /// Outstanding codes and when each stops working. At most one in practice.
75    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    /// Show a new code: cancels any earlier one. Returns it and how long it
91    /// works.
92    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    /// Present `code` from `from` (`None` when the source is unknown, which
104    /// counts as one address).
105    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        // Every outstanding code is compared, in constant time, whatever matched.
121        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                // Forget addresses with nothing recent, so the map stays small.
142                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        // A minute after the first wrong code the address may try again.
231        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        // From several addresses, so no single one is rate limited.
242        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}