Skip to main content

rustant_core/
pairing.rs

1//! # DM Pairing Protocol
2//!
3//! Implements a challenge-response device pairing protocol for authenticated
4//! direct-message communication between the agent and trusted devices.
5//!
6//! The flow:
7//! 1. Device requests pairing via [`PairingManager::create_challenge`].
8//! 2. Agent presents a challenge (nonce) to the user.
9//! 3. Device computes an HMAC response over the nonce using the shared secret.
10//! 4. Agent verifies the response via [`PairingManager::verify_response`].
11//! 5. On success the device is added to the paired-devices list.
12
13use chrono::{DateTime, Duration, Utc};
14use hmac::{Hmac, Mac};
15use rand::Rng;
16use serde::{Deserialize, Serialize};
17use sha2::Sha256;
18use uuid::Uuid;
19
20type HmacSha256 = Hmac<Sha256>;
21
22// ---------------------------------------------------------------------------
23// Types
24// ---------------------------------------------------------------------------
25
26/// Identity of a paired device.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct DeviceIdentity {
29    pub device_id: Uuid,
30    pub device_name: String,
31    pub public_key: String,
32    pub created_at: DateTime<Utc>,
33    pub last_seen: DateTime<Utc>,
34}
35
36/// A challenge issued to a device during the pairing flow.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct PairingChallenge {
39    pub challenge_id: Uuid,
40    pub nonce: String,
41    pub expires_at: DateTime<Utc>,
42}
43
44/// A device's response to a pairing challenge.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PairingResponse {
47    pub challenge_id: Uuid,
48    pub device_id: Uuid,
49    pub device_name: String,
50    pub public_key: String,
51    pub response_hmac: String,
52}
53
54/// Outcome of a pairing attempt.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub enum PairingResult {
57    Accepted,
58    Rejected,
59    Expired,
60}
61
62/// TOTP-style code generator and verifier (time-based one-time password).
63#[derive(Debug, Clone)]
64pub struct TotpVerifier {
65    secret: Vec<u8>,
66    /// Period in seconds for each TOTP code.
67    period: u64,
68    /// Number of digits in the TOTP code.
69    digits: u32,
70}
71
72impl TotpVerifier {
73    /// Create a new TOTP verifier with the given secret.
74    pub fn new(secret: &[u8], period: u64, digits: u32) -> Self {
75        Self {
76            secret: secret.to_vec(),
77            period,
78            digits,
79        }
80    }
81
82    /// Generate the current TOTP code.
83    pub fn generate(&self) -> String {
84        self.generate_at(Utc::now().timestamp() as u64)
85    }
86
87    /// Generate a TOTP code for a specific Unix timestamp.
88    pub fn generate_at(&self, timestamp: u64) -> String {
89        let counter = timestamp / self.period;
90        let counter_bytes = counter.to_be_bytes();
91
92        let mut mac =
93            HmacSha256::new_from_slice(&self.secret).expect("HMAC can take key of any size");
94        mac.update(&counter_bytes);
95        let result = mac.finalize().into_bytes();
96
97        // Dynamic truncation (simplified)
98        let offset = (result[result.len() - 1] & 0x0f) as usize;
99        let truncated = u32::from_be_bytes([
100            result[offset] & 0x7f,
101            result[offset + 1],
102            result[offset + 2],
103            result[offset + 3],
104        ]);
105
106        let code = truncated % 10u32.pow(self.digits);
107        format!("{:0>width$}", code, width = self.digits as usize)
108    }
109
110    /// Verify a TOTP code, allowing for +-1 period drift.
111    pub fn verify(&self, code: &str) -> bool {
112        let now = Utc::now().timestamp() as u64;
113        // Check current, previous, and next period
114        for offset in [0i64, -1, 1] {
115            let ts = (now as i64 + offset * self.period as i64) as u64;
116            if self.generate_at(ts) == code {
117                return true;
118            }
119        }
120        false
121    }
122
123    /// Verify a TOTP code at a specific timestamp.
124    pub fn verify_at(&self, code: &str, timestamp: u64) -> bool {
125        for offset in [0i64, -1, 1] {
126            let ts = (timestamp as i64 + offset * self.period as i64) as u64;
127            if self.generate_at(ts) == code {
128                return true;
129            }
130        }
131        false
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Manager
137// ---------------------------------------------------------------------------
138
139/// Manages the device pairing lifecycle: challenge creation, verification,
140/// device listing, and revocation.
141#[derive(Debug, Clone, Default)]
142pub struct PairingManager {
143    shared_secret: Vec<u8>,
144    paired_devices: Vec<DeviceIdentity>,
145    pending_challenges: Vec<PairingChallenge>,
146    /// Challenge validity duration in seconds.
147    challenge_ttl_secs: i64,
148}
149
150impl PairingManager {
151    /// Create a new pairing manager with the given shared secret.
152    pub fn new(shared_secret: &[u8]) -> Self {
153        Self {
154            shared_secret: shared_secret.to_vec(),
155            paired_devices: Vec::new(),
156            pending_challenges: Vec::new(),
157            challenge_ttl_secs: 300, // 5 minutes
158        }
159    }
160
161    /// Create a new pairing challenge.
162    pub fn create_challenge(&mut self) -> PairingChallenge {
163        let mut rng = rand::thread_rng();
164        let nonce_bytes: [u8; 32] = rng.r#gen();
165        let nonce = hex::encode(nonce_bytes);
166
167        let challenge = PairingChallenge {
168            challenge_id: Uuid::new_v4(),
169            nonce,
170            expires_at: Utc::now() + Duration::seconds(self.challenge_ttl_secs),
171        };
172
173        self.pending_challenges.push(challenge.clone());
174        challenge
175    }
176
177    /// Verify a pairing response against a pending challenge.
178    pub fn verify_response(&mut self, response: &PairingResponse) -> PairingResult {
179        // Find and remove the matching challenge
180        let challenge_idx = self
181            .pending_challenges
182            .iter()
183            .position(|c| c.challenge_id == response.challenge_id);
184
185        let Some(idx) = challenge_idx else {
186            return PairingResult::Rejected;
187        };
188
189        let challenge = self.pending_challenges.remove(idx);
190
191        // Check expiry
192        if Utc::now() > challenge.expires_at {
193            return PairingResult::Expired;
194        }
195
196        // Verify HMAC: HMAC-SHA256(shared_secret, nonce)
197        let expected = compute_hmac(&self.shared_secret, challenge.nonce.as_bytes());
198        if expected != response.response_hmac {
199            return PairingResult::Rejected;
200        }
201
202        // Add device
203        let now = Utc::now();
204        self.paired_devices.push(DeviceIdentity {
205            device_id: response.device_id,
206            device_name: response.device_name.clone(),
207            public_key: response.public_key.clone(),
208            created_at: now,
209            last_seen: now,
210        });
211
212        PairingResult::Accepted
213    }
214
215    /// List all currently paired devices.
216    pub fn paired_devices(&self) -> &[DeviceIdentity] {
217        &self.paired_devices
218    }
219
220    /// Revoke a paired device by its ID.
221    pub fn revoke_device(&mut self, device_id: &Uuid) -> bool {
222        let before = self.paired_devices.len();
223        self.paired_devices.retain(|d| d.device_id != *device_id);
224        self.paired_devices.len() < before
225    }
226
227    /// Remove expired challenges.
228    pub fn cleanup_expired(&mut self) {
229        let now = Utc::now();
230        self.pending_challenges.retain(|c| c.expires_at > now);
231    }
232
233    /// Number of pending (unexpired) challenges.
234    pub fn pending_count(&self) -> usize {
235        self.pending_challenges.len()
236    }
237}
238
239/// Compute HMAC-SHA256 and return the hex-encoded result.
240fn compute_hmac(key: &[u8], data: &[u8]) -> String {
241    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
242    mac.update(data);
243    let result = mac.finalize().into_bytes();
244    hex::encode(result)
245}
246
247// Tiny hex encoding helper (no external dep needed).
248mod hex {
249    pub fn encode(bytes: impl AsRef<[u8]>) -> String {
250        bytes
251            .as_ref()
252            .iter()
253            .map(|b| format!("{:02x}", b))
254            .collect()
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Tests
260// ---------------------------------------------------------------------------
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn test_secret() -> Vec<u8> {
267        b"test-shared-secret-key-32bytes!!".to_vec()
268    }
269
270    // -- Challenge generation -----------------------------------------------
271
272    #[test]
273    fn test_create_challenge() {
274        let mut mgr = PairingManager::new(&test_secret());
275        let challenge = mgr.create_challenge();
276
277        assert!(!challenge.challenge_id.is_nil());
278        assert!(!challenge.nonce.is_empty());
279        assert!(challenge.expires_at > Utc::now());
280        assert_eq!(mgr.pending_count(), 1);
281    }
282
283    #[test]
284    fn test_create_multiple_challenges() {
285        let mut mgr = PairingManager::new(&test_secret());
286        mgr.create_challenge();
287        mgr.create_challenge();
288        mgr.create_challenge();
289        assert_eq!(mgr.pending_count(), 3);
290    }
291
292    // -- Response verification ----------------------------------------------
293
294    #[test]
295    fn test_valid_pairing_flow() {
296        let secret = test_secret();
297        let mut mgr = PairingManager::new(&secret);
298        let challenge = mgr.create_challenge();
299
300        // Simulate device computing HMAC
301        let response_hmac = compute_hmac(&secret, challenge.nonce.as_bytes());
302        let response = PairingResponse {
303            challenge_id: challenge.challenge_id,
304            device_id: Uuid::new_v4(),
305            device_name: "My Phone".into(),
306            public_key: "pk-abc123".into(),
307            response_hmac,
308        };
309
310        let result = mgr.verify_response(&response);
311        assert_eq!(result, PairingResult::Accepted);
312        assert_eq!(mgr.paired_devices().len(), 1);
313        assert_eq!(mgr.paired_devices()[0].device_name, "My Phone");
314    }
315
316    #[test]
317    fn test_wrong_hmac_rejected() {
318        let mut mgr = PairingManager::new(&test_secret());
319        let challenge = mgr.create_challenge();
320
321        let response = PairingResponse {
322            challenge_id: challenge.challenge_id,
323            device_id: Uuid::new_v4(),
324            device_name: "Evil Device".into(),
325            public_key: "pk-evil".into(),
326            response_hmac: "wrong-hmac".into(),
327        };
328
329        let result = mgr.verify_response(&response);
330        assert_eq!(result, PairingResult::Rejected);
331        assert!(mgr.paired_devices().is_empty());
332    }
333
334    #[test]
335    fn test_unknown_challenge_rejected() {
336        let mut mgr = PairingManager::new(&test_secret());
337
338        let response = PairingResponse {
339            challenge_id: Uuid::new_v4(), // no matching challenge
340            device_id: Uuid::new_v4(),
341            device_name: "Unknown".into(),
342            public_key: "pk".into(),
343            response_hmac: "whatever".into(),
344        };
345
346        let result = mgr.verify_response(&response);
347        assert_eq!(result, PairingResult::Rejected);
348    }
349
350    #[test]
351    fn test_expired_challenge() {
352        let secret = test_secret();
353        let mut mgr = PairingManager::new(&secret);
354        mgr.challenge_ttl_secs = -1; // already expired
355
356        let challenge = mgr.create_challenge();
357        let response_hmac = compute_hmac(&secret, challenge.nonce.as_bytes());
358
359        let response = PairingResponse {
360            challenge_id: challenge.challenge_id,
361            device_id: Uuid::new_v4(),
362            device_name: "Late".into(),
363            public_key: "pk".into(),
364            response_hmac,
365        };
366
367        let result = mgr.verify_response(&response);
368        assert_eq!(result, PairingResult::Expired);
369    }
370
371    // -- Device lifecycle ---------------------------------------------------
372
373    #[test]
374    fn test_revoke_device() {
375        let secret = test_secret();
376        let mut mgr = PairingManager::new(&secret);
377        let challenge = mgr.create_challenge();
378
379        let device_id = Uuid::new_v4();
380        let response_hmac = compute_hmac(&secret, challenge.nonce.as_bytes());
381        let response = PairingResponse {
382            challenge_id: challenge.challenge_id,
383            device_id,
384            device_name: "Phone".into(),
385            public_key: "pk".into(),
386            response_hmac,
387        };
388
389        mgr.verify_response(&response);
390        assert_eq!(mgr.paired_devices().len(), 1);
391
392        assert!(mgr.revoke_device(&device_id));
393        assert!(mgr.paired_devices().is_empty());
394    }
395
396    #[test]
397    fn test_revoke_nonexistent_device() {
398        let mut mgr = PairingManager::new(&test_secret());
399        assert!(!mgr.revoke_device(&Uuid::new_v4()));
400    }
401
402    // -- TOTP ---------------------------------------------------------------
403
404    #[test]
405    fn test_totp_generate_deterministic() {
406        let verifier = TotpVerifier::new(b"secret", 30, 6);
407        let code1 = verifier.generate_at(1000000);
408        let code2 = verifier.generate_at(1000000);
409        assert_eq!(code1, code2);
410        assert_eq!(code1.len(), 6);
411    }
412
413    #[test]
414    fn test_totp_different_timestamps_different_codes() {
415        let verifier = TotpVerifier::new(b"secret", 30, 6);
416        let code1 = verifier.generate_at(0);
417        let code2 = verifier.generate_at(30);
418        // Different periods should (almost certainly) produce different codes
419        // There's a tiny collision probability, but with 6 digits it's 1/1M
420        assert_ne!(code1, code2);
421    }
422
423    #[test]
424    fn test_totp_verify_at_exact() {
425        let verifier = TotpVerifier::new(b"my-key", 30, 6);
426        let ts = 1700000000u64;
427        let code = verifier.generate_at(ts);
428        assert!(verifier.verify_at(&code, ts));
429    }
430
431    #[test]
432    fn test_totp_verify_at_with_drift() {
433        let verifier = TotpVerifier::new(b"my-key", 30, 6);
434        let ts = 1700000000u64;
435        // Generate code for the previous period
436        let code = verifier.generate_at(ts - 30);
437        // Should still verify within +-1 period
438        assert!(verifier.verify_at(&code, ts));
439    }
440
441    #[test]
442    fn test_totp_reject_wrong_code() {
443        let verifier = TotpVerifier::new(b"my-key", 30, 6);
444        assert!(!verifier.verify_at("000000", 1700000000));
445    }
446
447    // -- Cleanup ------------------------------------------------------------
448
449    #[test]
450    fn test_cleanup_expired_challenges() {
451        let mut mgr = PairingManager::new(&test_secret());
452        mgr.challenge_ttl_secs = -1; // create already-expired
453        mgr.create_challenge();
454        mgr.create_challenge();
455        assert_eq!(mgr.pending_count(), 2);
456
457        mgr.cleanup_expired();
458        assert_eq!(mgr.pending_count(), 0);
459    }
460
461    // -- Serialization ------------------------------------------------------
462
463    #[test]
464    fn test_device_identity_serialization() {
465        let now = Utc::now();
466        let device = DeviceIdentity {
467            device_id: Uuid::new_v4(),
468            device_name: "Test".into(),
469            public_key: "pk-123".into(),
470            created_at: now,
471            last_seen: now,
472        };
473
474        let json = serde_json::to_string(&device).unwrap();
475        let restored: DeviceIdentity = serde_json::from_str(&json).unwrap();
476        assert_eq!(device.device_id, restored.device_id);
477        assert_eq!(device.device_name, restored.device_name);
478    }
479
480    #[test]
481    fn test_pairing_result_serialization() {
482        let json = serde_json::to_string(&PairingResult::Accepted).unwrap();
483        let restored: PairingResult = serde_json::from_str(&json).unwrap();
484        assert_eq!(restored, PairingResult::Accepted);
485    }
486}