Skip to main content

roboticus_agent/
device.rs

1use chrono::{DateTime, Utc};
2use k256::ecdsa::{SigningKey, signature::Signer};
3use roboticus_core::{Result, RoboticusError};
4use serde::{Deserialize, Serialize};
5use sha2::Digest;
6use std::collections::HashMap;
7use tracing::{debug, info};
8
9/// Unique device identity derived from an ECDSA keypair.
10#[derive(Clone, Serialize, Deserialize)]
11pub struct DeviceIdentity {
12    pub device_id: String,
13    pub public_key_hex: String,
14    pub created_at: DateTime<Utc>,
15    #[serde(default)]
16    pub device_name: String,
17    #[serde(skip)]
18    pub signing_key: Option<SigningKey>,
19}
20
21impl std::fmt::Debug for DeviceIdentity {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.debug_struct("DeviceIdentity")
24            .field("device_id", &self.device_id)
25            .field("public_key_hex", &self.public_key_hex)
26            .field("created_at", &self.created_at)
27            .field("device_name", &self.device_name)
28            .field(
29                "signing_key",
30                &self.signing_key.as_ref().map(|_| "[REDACTED]"),
31            )
32            .finish()
33    }
34}
35
36impl DeviceIdentity {
37    /// Generate a new device identity with a random ID.
38    pub fn generate(device_name: &str) -> Self {
39        let device_id = format!("dev_{}", generate_short_id());
40        let (public_key_hex, signing_key) = generate_keypair();
41
42        info!(device_id = %device_id, name = %device_name, "generated device identity");
43
44        Self {
45            device_id,
46            public_key_hex,
47            created_at: Utc::now(),
48            device_name: device_name.to_string(),
49            signing_key: Some(signing_key),
50        }
51    }
52
53    pub fn fingerprint(&self) -> String {
54        let hash = sha2::Sha256::digest(self.public_key_hex.as_bytes());
55        hex::encode(&hash[..8])
56    }
57
58    pub fn sign(&self, data: &[u8]) -> Result<Vec<u8>> {
59        let key = self
60            .signing_key
61            .as_ref()
62            .ok_or_else(|| RoboticusError::Config("no signing key available".into()))?;
63        let signature: k256::ecdsa::Signature = key.sign(data);
64        Ok(signature.to_bytes().to_vec())
65    }
66}
67
68/// Pairing state for device-to-device trust.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum PairingState {
71    Pending,
72    Verified,
73    Rejected,
74    Expired,
75}
76
77/// A paired device record.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PairedDevice {
80    pub device_id: String,
81    pub public_key_hex: String,
82    pub device_name: String,
83    pub state: PairingState,
84    pub paired_at: Option<DateTime<Utc>>,
85    pub last_seen: Option<DateTime<Utc>>,
86}
87
88/// Manages device identity and pairing.
89pub struct DeviceManager {
90    identity: DeviceIdentity,
91    paired_devices: HashMap<String, PairedDevice>,
92    max_paired: usize,
93}
94
95impl DeviceManager {
96    pub fn new(identity: DeviceIdentity, max_paired: usize) -> Self {
97        Self {
98            identity,
99            paired_devices: HashMap::new(),
100            max_paired,
101        }
102    }
103
104    pub fn identity(&self) -> &DeviceIdentity {
105        &self.identity
106    }
107
108    /// Initiate pairing with another device.
109    pub fn initiate_pairing(
110        &mut self,
111        remote_id: &str,
112        remote_pubkey: &str,
113        remote_name: &str,
114    ) -> Result<()> {
115        if self.paired_devices.len() >= self.max_paired {
116            return Err(RoboticusError::Config(format!(
117                "maximum paired devices ({}) reached",
118                self.max_paired
119            )));
120        }
121
122        if self.paired_devices.contains_key(remote_id) {
123            return Err(RoboticusError::Config(format!(
124                "device '{}' is already in pairing list",
125                remote_id
126            )));
127        }
128
129        self.paired_devices.insert(
130            remote_id.to_string(),
131            PairedDevice {
132                device_id: remote_id.to_string(),
133                public_key_hex: remote_pubkey.to_string(),
134                device_name: remote_name.to_string(),
135                state: PairingState::Pending,
136                paired_at: None,
137                last_seen: None,
138            },
139        );
140
141        debug!(remote = %remote_id, "pairing initiated");
142        Ok(())
143    }
144
145    /// Verify a pending pairing (after mutual authentication succeeds).
146    pub fn verify_pairing(&mut self, remote_id: &str) -> Result<()> {
147        let device = self
148            .paired_devices
149            .get_mut(remote_id)
150            .ok_or_else(|| RoboticusError::Config(format!("device '{}' not found", remote_id)))?;
151
152        if device.state != PairingState::Pending {
153            return Err(RoboticusError::Config(format!(
154                "device '{}' is not in pending state",
155                remote_id
156            )));
157        }
158
159        device.state = PairingState::Verified;
160        device.paired_at = Some(Utc::now());
161        device.last_seen = Some(Utc::now());
162
163        info!(remote = %remote_id, "pairing verified");
164        Ok(())
165    }
166
167    /// Reject a pending pairing.
168    pub fn reject_pairing(&mut self, remote_id: &str) -> Result<()> {
169        let device = self
170            .paired_devices
171            .get_mut(remote_id)
172            .ok_or_else(|| RoboticusError::Config(format!("device '{}' not found", remote_id)))?;
173
174        device.state = PairingState::Rejected;
175        debug!(remote = %remote_id, "pairing rejected");
176        Ok(())
177    }
178
179    /// Remove a device from the pairing list.
180    pub fn unpair(&mut self, remote_id: &str) -> Result<()> {
181        self.paired_devices
182            .remove(remote_id)
183            .ok_or_else(|| RoboticusError::Config(format!("device '{}' not found", remote_id)))?;
184
185        info!(remote = %remote_id, "device unpaired");
186        Ok(())
187    }
188
189    /// Record that a paired device was seen (for sync/heartbeat).
190    pub fn record_seen(&mut self, remote_id: &str) {
191        if let Some(device) = self.paired_devices.get_mut(remote_id) {
192            device.last_seen = Some(Utc::now());
193        }
194    }
195
196    /// List all verified (trusted) devices.
197    pub fn trusted_devices(&self) -> Vec<&PairedDevice> {
198        self.paired_devices
199            .values()
200            .filter(|d| d.state == PairingState::Verified)
201            .collect()
202    }
203
204    /// List all paired devices regardless of state.
205    pub fn all_devices(&self) -> Vec<&PairedDevice> {
206        self.paired_devices.values().collect()
207    }
208
209    pub fn paired_count(&self) -> usize {
210        self.paired_devices.len()
211    }
212
213    /// Check if a device is trusted (verified pairing).
214    pub fn is_trusted(&self, remote_id: &str) -> bool {
215        self.paired_devices
216            .get(remote_id)
217            .is_some_and(|d| d.state == PairingState::Verified)
218    }
219}
220
221fn generate_short_id() -> String {
222    format!("{:016x}", rand::random::<u64>())
223}
224
225fn generate_keypair() -> (String, SigningKey) {
226    let signing_key = SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
227    let point = signing_key.verifying_key().to_encoded_point(true);
228    (hex::encode(point.as_bytes()), signing_key)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn test_identity() -> DeviceIdentity {
236        DeviceIdentity::generate("test-device")
237    }
238
239    fn test_manager() -> DeviceManager {
240        DeviceManager::new(test_identity(), 5)
241    }
242
243    #[test]
244    fn generate_identity() {
245        let id = DeviceIdentity::generate("laptop");
246        assert!(id.device_id.starts_with("dev_"));
247        assert_eq!(id.device_id.len(), 20);
248        assert!(!id.public_key_hex.is_empty());
249        assert_eq!(id.device_name, "laptop");
250    }
251
252    #[test]
253    fn identity_fingerprint() {
254        let id = test_identity();
255        let fp = id.fingerprint();
256        assert_eq!(fp.len(), 16);
257    }
258
259    #[test]
260    fn initiate_pairing() {
261        let mut mgr = test_manager();
262        mgr.initiate_pairing("remote-1", "04abcdef", "phone")
263            .unwrap();
264        assert_eq!(mgr.paired_count(), 1);
265        assert!(!mgr.is_trusted("remote-1"));
266    }
267
268    #[test]
269    fn verify_pairing() {
270        let mut mgr = test_manager();
271        mgr.initiate_pairing("remote-1", "04abcdef", "phone")
272            .unwrap();
273        mgr.verify_pairing("remote-1").unwrap();
274        assert!(mgr.is_trusted("remote-1"));
275        assert_eq!(mgr.trusted_devices().len(), 1);
276    }
277
278    #[test]
279    fn reject_pairing() {
280        let mut mgr = test_manager();
281        mgr.initiate_pairing("remote-1", "04abcdef", "phone")
282            .unwrap();
283        mgr.reject_pairing("remote-1").unwrap();
284        assert!(!mgr.is_trusted("remote-1"));
285    }
286
287    #[test]
288    fn unpair() {
289        let mut mgr = test_manager();
290        mgr.initiate_pairing("remote-1", "04abcdef", "phone")
291            .unwrap();
292        mgr.unpair("remote-1").unwrap();
293        assert_eq!(mgr.paired_count(), 0);
294    }
295
296    #[test]
297    fn max_paired_limit() {
298        let mut mgr = DeviceManager::new(test_identity(), 2);
299        mgr.initiate_pairing("d1", "key1", "dev1").unwrap();
300        mgr.initiate_pairing("d2", "key2", "dev2").unwrap();
301        let err = mgr.initiate_pairing("d3", "key3", "dev3").unwrap_err();
302        assert!(err.to_string().contains("maximum"));
303    }
304
305    #[test]
306    fn duplicate_pairing_rejected() {
307        let mut mgr = test_manager();
308        mgr.initiate_pairing("d1", "key1", "dev1").unwrap();
309        let err = mgr.initiate_pairing("d1", "key1", "dev1").unwrap_err();
310        assert!(err.to_string().contains("already"));
311    }
312
313    #[test]
314    fn verify_nonexistent_fails() {
315        let mut mgr = test_manager();
316        assert!(mgr.verify_pairing("nope").is_err());
317    }
318
319    #[test]
320    fn verify_non_pending_fails() {
321        let mut mgr = test_manager();
322        mgr.initiate_pairing("d1", "key1", "dev1").unwrap();
323        mgr.verify_pairing("d1").unwrap();
324        assert!(mgr.verify_pairing("d1").is_err());
325    }
326
327    #[test]
328    fn record_seen() {
329        let mut mgr = test_manager();
330        mgr.initiate_pairing("d1", "key1", "dev1").unwrap();
331        mgr.verify_pairing("d1").unwrap();
332        mgr.record_seen("d1");
333        let devs = mgr.trusted_devices();
334        assert!(devs[0].last_seen.is_some());
335    }
336
337    #[test]
338    fn pairing_state_serde() {
339        for state in [
340            PairingState::Pending,
341            PairingState::Verified,
342            PairingState::Rejected,
343            PairingState::Expired,
344        ] {
345            let json = serde_json::to_string(&state).unwrap();
346            let back: PairingState = serde_json::from_str(&json).unwrap();
347            assert_eq!(state, back);
348        }
349    }
350
351    #[test]
352    fn identity_serde() {
353        let id = test_identity();
354        let json = serde_json::to_string(&id).unwrap();
355        let back: DeviceIdentity = serde_json::from_str(&json).unwrap();
356        assert_eq!(id.device_id, back.device_id);
357    }
358
359    // ── Coverage: Debug impl for DeviceIdentity ─────────────────
360
361    #[test]
362    fn identity_debug_format() {
363        let id = test_identity();
364        let dbg = format!("{:?}", id);
365        assert!(dbg.contains("DeviceIdentity"));
366        assert!(dbg.contains("device_id"));
367        assert!(dbg.contains("public_key_hex"));
368        assert!(dbg.contains("created_at"));
369        assert!(dbg.contains("device_name"));
370        // signing_key should show [REDACTED], not the actual key
371        assert!(dbg.contains("[REDACTED]"));
372    }
373
374    #[test]
375    fn identity_debug_without_signing_key() {
376        let mut id = test_identity();
377        id.signing_key = None;
378        let dbg = format!("{:?}", id);
379        assert!(dbg.contains("DeviceIdentity"));
380        assert!(dbg.contains("None"));
381    }
382
383    // ── Coverage: sign method ────────────────────────────────────
384
385    #[test]
386    fn identity_sign_succeeds() {
387        let id = test_identity();
388        let sig = id.sign(b"hello world").unwrap();
389        assert!(!sig.is_empty());
390    }
391
392    #[test]
393    fn identity_sign_without_key_fails() {
394        let mut id = test_identity();
395        id.signing_key = None;
396        let result = id.sign(b"test data");
397        assert!(result.is_err());
398        assert!(result.unwrap_err().to_string().contains("no signing key"));
399    }
400
401    // ── Coverage: DeviceManager::identity accessor ───────────────
402
403    #[test]
404    fn manager_identity_accessor() {
405        let mgr = test_manager();
406        let id = mgr.identity();
407        assert!(id.device_id.starts_with("dev_"));
408    }
409
410    // ── Coverage: DeviceManager::all_devices ─────────────────────
411
412    #[test]
413    fn all_devices_includes_all_states() {
414        let mut mgr = test_manager();
415        mgr.initiate_pairing("d1", "k1", "dev1").unwrap();
416        mgr.initiate_pairing("d2", "k2", "dev2").unwrap();
417        mgr.verify_pairing("d1").unwrap();
418        mgr.reject_pairing("d2").unwrap();
419
420        let all = mgr.all_devices();
421        assert_eq!(all.len(), 2);
422    }
423}