Skip to main content

ios_core/
credentials.rs

1//! Pairing credential persistence.
2//!
3//! Saves/loads the host identity generated during SRP pairing.
4//! Stored as JSON at a platform-specific path.
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PersistedCredentials {
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub remote_identifier: Option<String>,
14    pub host_identifier: String,
15    /// Ed25519 public key (hex-encoded)
16    pub host_public_key_hex: String,
17    /// Ed25519 private key seed (hex-encoded) used for future verifyManualPairing.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub host_private_key_hex: Option<String>,
20    /// Base64-encoded remote unlock host key returned by createRemoteUnlockKey.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub remote_unlock_host_key: Option<String>,
23    /// IPv6 address of the paired device
24    pub device_address: String,
25    /// RSD port at time of pairing
26    pub rsd_port: u16,
27}
28
29impl PersistedCredentials {
30    /// Default directory for storing pair credentials.
31    pub fn default_dir() -> PathBuf {
32        if cfg!(target_os = "macos") {
33            dirs_next::home_dir()
34                .unwrap_or_else(|| PathBuf::from("/tmp"))
35                .join(".ios-rs")
36        } else if cfg!(windows) {
37            std::env::var("APPDATA")
38                .map(PathBuf::from)
39                .unwrap_or_else(|_| PathBuf::from("C:\\ProgramData"))
40                .join("ios-rs")
41        } else {
42            dirs_next::home_dir()
43                .unwrap_or_else(|| PathBuf::from("/tmp"))
44                .join(".ios-rs")
45        }
46    }
47
48    /// Compatibility directory used by pymobiledevice3 remote pairing records.
49    pub fn pymobiledevice3_dir() -> PathBuf {
50        dirs_next::home_dir()
51            .unwrap_or_else(|| PathBuf::from("/tmp"))
52            .join(".pymobiledevice3")
53    }
54
55    /// Path to the credential file for a specific device.
56    pub fn path_for(dir: &std::path::Path, device_addr: &str) -> PathBuf {
57        let safe_addr = device_addr.replace([':', '%'], "_");
58        dir.join(format!("{safe_addr}.json"))
59    }
60
61    /// Save to disk.
62    pub fn save(&self, dir: &std::path::Path) -> std::io::Result<()> {
63        std::fs::create_dir_all(dir)?;
64        let path = Self::path_for(dir, &self.device_address);
65        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
66        std::fs::write(path, json)
67    }
68
69    /// Load from disk by device address.
70    pub fn load(dir: &std::path::Path, device_addr: &str) -> Option<Self> {
71        let path = Self::path_for(dir, device_addr);
72        let json = std::fs::read_to_string(path).ok()?;
73        serde_json::from_str(&json).ok()
74    }
75
76    /// List all saved credentials in a directory.
77    pub fn list(dir: &std::path::Path) -> Vec<Self> {
78        let Ok(entries) = std::fs::read_dir(dir) else {
79            return vec![];
80        };
81        entries
82            .filter_map(|e| e.ok())
83            .filter(|e| e.path().extension().map(|x| x == "json").unwrap_or(false))
84            .filter_map(|e| std::fs::read_to_string(e.path()).ok())
85            .filter_map(|s| serde_json::from_str(&s).ok())
86            .collect()
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct RemotePairingRecord {
92    pub public_key: Vec<u8>,
93    pub private_key: Vec<u8>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub remote_unlock_host_key: Option<String>,
96}
97
98impl RemotePairingRecord {
99    pub fn path_for_identifier(dir: &std::path::Path, remote_identifier: &str) -> PathBuf {
100        dir.join(format!("remote_{remote_identifier}.plist"))
101    }
102
103    pub fn save_for_identifier(
104        &self,
105        dir: &std::path::Path,
106        remote_identifier: &str,
107    ) -> std::io::Result<()> {
108        std::fs::create_dir_all(dir)?;
109        plist::to_file_xml(Self::path_for_identifier(dir, remote_identifier), self)
110            .map_err(std::io::Error::other)
111    }
112
113    pub fn load_for_identifier(dir: &std::path::Path, remote_identifier: &str) -> Option<Self> {
114        plist::from_file(Self::path_for_identifier(dir, remote_identifier)).ok()
115    }
116
117    pub fn list(dir: &std::path::Path) -> Vec<(String, Self)> {
118        let Ok(entries) = std::fs::read_dir(dir) else {
119            return vec![];
120        };
121
122        entries
123            .filter_map(|entry| entry.ok())
124            .filter_map(|entry| {
125                let path = entry.path();
126                let file_name = path.file_name()?.to_str()?;
127                let remote_identifier = file_name
128                    .strip_prefix("remote_")?
129                    .strip_suffix(".plist")?
130                    .to_string();
131                let record = plist::from_file(&path).ok()?;
132                Some((remote_identifier, record))
133            })
134            .collect()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_roundtrip() {
144        let dir = std::env::temp_dir().join("ios_rs_test_creds");
145        let cred = PersistedCredentials {
146            remote_identifier: Some("test-remote".into()),
147            host_identifier: "test-id".into(),
148            host_public_key_hex: "deadbeef".into(),
149            host_private_key_hex: Some("cafebabe".into()),
150            remote_unlock_host_key: Some("host-key".into()),
151            device_address: "fd00::1".into(),
152            rsd_port: 58783,
153        };
154        cred.save(&dir).unwrap();
155        let loaded = PersistedCredentials::load(&dir, "fd00::1").unwrap();
156        assert_eq!(loaded.remote_identifier.as_deref(), Some("test-remote"));
157        assert_eq!(loaded.host_identifier, "test-id");
158        assert_eq!(loaded.rsd_port, 58783);
159        assert_eq!(loaded.host_private_key_hex.as_deref(), Some("cafebabe"));
160        assert_eq!(loaded.remote_unlock_host_key.as_deref(), Some("host-key"));
161        // cleanup
162        let _ = std::fs::remove_dir_all(&dir);
163    }
164
165    #[test]
166    fn test_backward_compatible_load_without_private_key() {
167        let dir = std::env::temp_dir().join("ios_rs_test_creds_legacy");
168        std::fs::create_dir_all(&dir).unwrap();
169        let path = PersistedCredentials::path_for(&dir, "fd00::2");
170        std::fs::write(
171            &path,
172            r#"{
173  "host_identifier": "legacy-id",
174  "host_public_key_hex": "deadbeef",
175  "device_address": "fd00::2",
176  "rsd_port": 58783
177}"#,
178        )
179        .unwrap();
180
181        let loaded = PersistedCredentials::load(&dir, "fd00::2").unwrap();
182        assert_eq!(loaded.host_identifier, "legacy-id");
183        assert!(loaded.host_private_key_hex.is_none());
184        assert!(loaded.remote_identifier.is_none());
185        assert!(loaded.remote_unlock_host_key.is_none());
186
187        let _ = std::fs::remove_dir_all(&dir);
188    }
189
190    #[test]
191    fn test_remote_pairing_record_roundtrip() {
192        let dir = std::env::temp_dir().join("ios_rs_test_remote_pair_record");
193        let record = RemotePairingRecord {
194            public_key: vec![0x01, 0x02, 0x03],
195            private_key: vec![0x04, 0x05, 0x06],
196            remote_unlock_host_key: Some("PcV5xhyuJBL7Qq9HOGeGVwtU4sJLe1jtl/vRy1tRKcI=".into()),
197        };
198
199        record
200            .save_for_identifier(&dir, "00008150-000D6D6A1122401C")
201            .unwrap();
202
203        let loaded =
204            RemotePairingRecord::load_for_identifier(&dir, "00008150-000D6D6A1122401C").unwrap();
205        assert_eq!(loaded, record);
206
207        let listed = RemotePairingRecord::list(&dir);
208        assert_eq!(listed.len(), 1);
209        assert_eq!(listed[0].0, "00008150-000D6D6A1122401C");
210        assert_eq!(listed[0].1, record);
211
212        let _ = std::fs::remove_dir_all(&dir);
213    }
214}