Skip to main content

bridge/
pairing.rs

1use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
2use std::time::{Duration, Instant};
3use subtle::ConstantTimeEq;
4use thiserror::Error;
5
6/// Errors that can occur during pairing
7#[derive(Error, Debug)]
8pub enum PairingError {
9    #[error("Pairing code is invalid or expired")]
10    InvalidCode,
11    #[error("Pairing code has already been used")]
12    CodeAlreadyUsed,
13    #[error("Too many failed attempts. Please restart the bridge to get a new code.")]
14    RateLimited,
15}
16
17/// Result type for pairing response
18#[derive(serde::Serialize)]
19pub struct PairingResponse {
20    /// Stable agent identity shared across all transports.
21    #[serde(rename = "agentId")]
22    pub agent_id: String,
23    pub url: String,
24    pub protocol: String,
25    pub version: String,
26    #[serde(rename = "authToken")]
27    pub auth_token: String,
28    #[serde(rename = "certFingerprint", skip_serializing_if = "Option::is_none")]
29    pub cert_fingerprint: Option<String>,
30    #[serde(rename = "clientId", skip_serializing_if = "Option::is_none")]
31    pub client_id: Option<String>,
32    #[serde(rename = "clientSecret", skip_serializing_if = "Option::is_none")]
33    pub client_secret: Option<String>,
34    /// The working directory where the bridge was started.
35    pub cwd: String,
36    /// Push relay URL, present only when push is fully configured on the bridge.
37    /// Mobile clients use this to know whether to register their push token.
38    #[serde(rename = "pushRelayUrl", skip_serializing_if = "Option::is_none")]
39    pub relay_url: Option<String>,
40}
41
42/// Error response for failed pairing attempts
43#[derive(serde::Serialize)]
44pub struct PairingErrorResponse {
45    pub error: String,
46    pub message: String,
47}
48
49impl PairingErrorResponse {
50    pub fn invalid_code() -> Self {
51        Self {
52            error: "invalid_code".to_string(),
53            message: "Pairing code is invalid or expired".to_string(),
54        }
55    }
56
57    pub fn rate_limited() -> Self {
58        Self {
59            error: "rate_limited".to_string(),
60            message: "Too many failed attempts. Please restart the bridge to get a new code.".to_string(),
61        }
62    }
63}
64
65/// Manages one-time pairing codes for secure client registration
66pub struct PairingManager {
67    /// Stable agent identity included in every pairing response.
68    pub agent_id: String,
69    /// Current 6-digit pairing code
70    code: String,
71    /// When the code was created (for expiration)
72    created_at: Instant,
73    /// Whether the code has been successfully used
74    used: AtomicBool,
75    /// Number of failed validation attempts (for rate limiting)
76    attempts: AtomicU32,
77    /// Connection details to return on successful pairing
78    websocket_url: String,
79    auth_token: String,
80    cert_fingerprint: Option<String>,
81    client_id: Option<String>,
82    client_secret: Option<String>,
83    /// The working directory where the bridge was started.
84    cwd: String,
85    /// Push relay URL included in the pairing response when push is configured.
86    relay_url: Option<String>,
87    /// Code expiration duration
88    expiry_duration: Duration,
89    /// Maximum failed attempts before rate limiting
90    max_attempts: u32,
91    /// Whether to emit /pair/tailscale instead of /pair/local in the QR URL
92    tailscale_path: bool,
93}
94
95impl PairingManager {
96    /// Create a new PairingManager including Cloudflare service token credentials.
97    ///
98    /// `agent_id` is a stable UUID that will be included in the pairing response
99    /// so mobile clients can deduplicate connections across transports.
100    pub fn new_with_cf(
101        agent_id: String,
102        websocket_url: String,
103        auth_token: String,
104        cert_fingerprint: Option<String>,
105        client_id: Option<String>,
106        client_secret: Option<String>,
107        cwd: String,
108    ) -> Self {
109        let code = generate_pairing_code();
110        Self {
111            agent_id,
112            code,
113            created_at: Instant::now(),
114            used: AtomicBool::new(false),
115            attempts: AtomicU32::new(0),
116            websocket_url,
117            auth_token,
118            cert_fingerprint,
119            client_id,
120            client_secret,
121            cwd,
122            relay_url: None,
123            expiry_duration: Duration::from_secs(60),
124            max_attempts: 5,
125            tailscale_path: false,
126        }
127    }
128
129    /// Mark this manager as using Tailscale transport (emits /pair/tailscale in QR URL)
130    pub fn with_tailscale_path(mut self) -> Self {
131        self.tailscale_path = true;
132        self
133    }
134
135    /// Set the push relay URL to include in the pairing response.
136    /// Only set when push is fully configured (url + client_id both non-empty).
137    pub fn with_relay_url(mut self, url: String) -> Self {
138        self.relay_url = Some(url);
139        self
140    }
141
142    /// Get the current pairing code
143    #[allow(dead_code)]
144    pub fn get_code(&self) -> &str {
145        &self.code
146    }
147
148    /// Get the pairing URL (for QR code)
149    pub fn get_pairing_url(&self, base_url: &str) -> String {
150        if self.client_id.is_some() {
151            // Cloudflare mode: use /pair/cloudflare path, no fingerprint needed
152            format!("{}/pair/cloudflare?code={}", base_url, self.code)
153        } else if self.tailscale_path {
154            // Tailscale mode: /pair/tailscale; fingerprint present for ip mode, absent for serve mode
155            let mut url = format!("{}/pair/tailscale?code={}", base_url, self.code);
156            if let Some(ref fp) = self.cert_fingerprint {
157                url.push_str("&fp=");
158                url.push_str(&urlencoding::encode(fp));
159            }
160            url
161        } else {
162            let mut url = format!("{}/pair/local?code={}", base_url, self.code);
163            if let Some(ref fp) = self.cert_fingerprint {
164                url.push_str("&fp=");
165                url.push_str(&urlencoding::encode(fp));
166            }
167            url
168        }
169    }
170
171    /// Check if the code has expired
172    pub fn is_expired(&self) -> bool {
173        self.created_at.elapsed() > self.expiry_duration
174    }
175
176    /// Check if the code has been used
177    #[allow(dead_code)]
178    pub fn is_used(&self) -> bool {
179        self.used.load(Ordering::SeqCst)
180    }
181
182    /// Get remaining seconds until expiration
183    pub fn seconds_remaining(&self) -> u64 {
184        let elapsed = self.created_at.elapsed();
185        if elapsed > self.expiry_duration {
186            0
187        } else {
188            (self.expiry_duration - elapsed).as_secs()
189        }
190    }
191
192    /// Validate a pairing code and return connection details if valid
193    pub fn validate(&self, code: &str) -> Result<PairingResponse, PairingError> {
194        // Check rate limiting first
195        let attempts = self.attempts.load(Ordering::SeqCst);
196        if attempts >= self.max_attempts {
197            return Err(PairingError::RateLimited);
198        }
199
200        // Check if already used
201        if self.used.load(Ordering::SeqCst) {
202            return Err(PairingError::CodeAlreadyUsed);
203        }
204
205        // Check expiration
206        if self.is_expired() {
207            return Err(PairingError::InvalidCode);
208        }
209
210        // Validate code using constant-time comparison to prevent timing side-channel attacks.
211        // A standard != on a 6-digit string would leak information about how many characters
212        // match, reducing the effective search space before the rate limit is reached.
213        let code_matches = code.as_bytes().ct_eq(self.code.as_bytes());
214        if code_matches.unwrap_u8() == 0 {
215            self.attempts.fetch_add(1, Ordering::SeqCst);
216            return Err(PairingError::InvalidCode);
217        }
218
219        // Mark as used
220        if self.used.swap(true, Ordering::SeqCst) {
221            // Another thread already used it
222            return Err(PairingError::CodeAlreadyUsed);
223        }
224
225        Ok(PairingResponse {
226            agent_id: self.agent_id.clone(),
227            url: self.websocket_url.clone(),
228            protocol: "acp".to_string(),
229            version: "1.0".to_string(),
230            auth_token: self.auth_token.clone(),
231            cert_fingerprint: self.cert_fingerprint.clone(),
232            client_id: self.client_id.clone(),
233            client_secret: self.client_secret.clone(),
234            cwd: self.cwd.clone(),
235            relay_url: self.relay_url.clone(),
236        })
237    }
238
239    /// Get the certificate fingerprint (if available)
240    #[allow(dead_code)]
241    pub fn get_cert_fingerprint(&self) -> Option<&str> {
242        self.cert_fingerprint.as_deref()
243    }
244}
245
246/// Generate a cryptographically random 6-digit pairing code
247fn generate_pairing_code() -> String {
248    let code: u32 = rand::random_range(100000..1000000);
249    code.to_string()
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_code_generation() {
258        let code = generate_pairing_code();
259        assert_eq!(code.len(), 6);
260        assert!(code.chars().all(|c| c.is_ascii_digit()));
261    }
262
263    #[test]
264    fn test_pairing_manager_valid_code() {
265        let manager = PairingManager::new_with_cf(
266            "test-agent-id".to_string(),
267            "wss://192.168.1.100:8080".to_string(),
268            "test-token".to_string(),
269            Some("SHA256:ABC123".to_string()),
270            None,
271            None,
272            "/tmp/test".to_string(),
273        );
274
275        let code = manager.get_code().to_string();
276        let result = manager.validate(&code);
277        assert!(result.is_ok());
278
279        let response = result.unwrap();
280        assert_eq!(response.url, "wss://192.168.1.100:8080");
281        assert_eq!(response.auth_token, "test-token");
282    }
283
284    #[test]
285    fn test_pairing_manager_invalid_code() {
286        let manager = PairingManager::new_with_cf(
287            "test-agent-id".to_string(),
288            "wss://192.168.1.100:8080".to_string(),
289            "test-token".to_string(),
290            None,
291            None,
292            None,
293            "/tmp/test".to_string(),
294        );
295
296        let result = manager.validate("000000");
297        assert!(matches!(result, Err(PairingError::InvalidCode)));
298    }
299
300    #[test]
301    fn test_pairing_manager_one_time_use() {
302        let manager = PairingManager::new_with_cf(
303            "test-agent-id".to_string(),
304            "wss://192.168.1.100:8080".to_string(),
305            "test-token".to_string(),
306            None,
307            None,
308            None,
309            "/tmp/test".to_string(),
310        );
311
312        let code = manager.get_code().to_string();
313
314        // First use should succeed
315        assert!(manager.validate(&code).is_ok());
316
317        // Second use should fail
318        let result = manager.validate(&code);
319        assert!(matches!(result, Err(PairingError::CodeAlreadyUsed)));
320    }
321
322    #[test]
323    fn test_pairing_manager_rate_limiting() {
324        let manager = PairingManager::new_with_cf(
325            "test-agent-id".to_string(),
326            "wss://192.168.1.100:8080".to_string(),
327            "test-token".to_string(),
328            None,
329            None,
330            None,
331            "/tmp/test".to_string(),
332        );
333
334        // Make 5 failed attempts
335        for _ in 0..5 {
336            let _ = manager.validate("000000");
337        }
338
339        // Next attempt should be rate limited
340        let result = manager.validate("000000");
341        assert!(matches!(result, Err(PairingError::RateLimited)));
342    }
343
344    #[test]
345    fn test_pairing_url_generation() {
346        let manager = PairingManager::new_with_cf(
347            "test-agent-id".to_string(),
348            "wss://192.168.1.100:8080".to_string(),
349            "test-token".to_string(),
350            Some("SHA256:ABC123".to_string()),
351            None,
352            None,
353            "/tmp/test".to_string(),
354        );
355
356        let url = manager.get_pairing_url("https://192.168.1.100:8080");
357        assert!(url.starts_with("https://192.168.1.100:8080/pair/local?code="));
358        assert!(url.contains("&fp=SHA256"));
359    }
360
361    #[test]
362    fn test_tailscale_serve_pairing_url() {
363        // serve mode: no fingerprint, /pair/tailscale path
364        let manager = PairingManager::new_with_cf(
365            "test-agent-id".to_string(),
366            "wss://my-laptop.tail1234.ts.net".to_string(),
367            "test-token".to_string(),
368            None,
369            None,
370            None,
371            "/tmp/test".to_string(),
372        ).with_tailscale_path();
373
374        let url = manager.get_pairing_url("https://my-laptop.tail1234.ts.net");
375        assert!(url.contains("/pair/tailscale?code="), "Expected /pair/tailscale in URL, got: {}", url);
376        assert!(!url.contains("&fp="), "serve mode should have no fingerprint");
377    }
378
379    #[test]
380    fn test_tailscale_ip_pairing_url() {
381        // ip mode: fingerprint present, /pair/tailscale path
382        let manager = PairingManager::new_with_cf(
383            "test-agent-id".to_string(),
384            "wss://100.64.0.1:8080".to_string(),
385            "test-token".to_string(),
386            Some("SHA256:AB:CD:EF".to_string()),
387            None,
388            None,
389            "/tmp/test".to_string(),
390        ).with_tailscale_path();
391
392        let url = manager.get_pairing_url("https://100.64.0.1:8080");
393        assert!(url.contains("/pair/tailscale?code="), "Expected /pair/tailscale in URL, got: {}", url);
394        assert!(url.contains("&fp=SHA256"), "ip mode should include fingerprint");
395    }
396}