Skip to main content

ios_core/services/misagent/
mod.rs

1//! MiSAgent – provisioning profile management.
2//!
3//! Service: `com.apple.misagent`
4//! Protocol: plist-framed (same 4-byte BE length prefix as lockdown).
5
6use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
7
8pub const SERVICE_NAME: &str = "com.apple.misagent";
9
10service_error!(
11    MisagentError,
12    #[error("status {0}")]
13    Status(u32),
14);
15
16/// Provisioning profile entry.
17#[derive(Debug, Clone)]
18pub struct Profile {
19    pub uuid: String,
20    pub name: String,
21    pub app_id: String,
22    pub expiry_date: Option<String>,
23    pub raw_data: Vec<u8>,
24}
25
26/// MiSAgent client.
27pub struct MisagentClient<S> {
28    stream: S,
29}
30
31impl<S: AsyncRead + AsyncWrite + Unpin> MisagentClient<S> {
32    pub fn new(stream: S) -> Self {
33        Self { stream }
34    }
35
36    /// Copy (retrieve) all installed provisioning profiles.
37    pub async fn copy_all(&mut self) -> Result<Vec<Vec<u8>>, MisagentError> {
38        self.send_value(plist::Value::Dictionary(plist::Dictionary::from_iter([
39            (
40                "MessageType".to_string(),
41                plist::Value::String("CopyAll".into()),
42            ),
43            (
44                "ProfileType".to_string(),
45                plist::Value::String("Provisioning".into()),
46            ),
47        ])))
48        .await?;
49
50        let data = self.recv_raw().await?;
51        let val: plist::Value =
52            plist::from_bytes(&data).map_err(|e| MisagentError::Plist(e.to_string()))?;
53
54        let status = val
55            .as_dictionary()
56            .and_then(|d| d.get("Status"))
57            .and_then(|v| v.as_unsigned_integer())
58            .unwrap_or(0) as u32;
59
60        if status != 0 {
61            return Err(MisagentError::Status(status));
62        }
63
64        let profiles = val
65            .as_dictionary()
66            .and_then(|d| d.get("Payload"))
67            .and_then(|v| v.as_array())
68            .map(|arr| {
69                arr.iter()
70                    .filter_map(|v| v.as_data().map(|d| d.to_vec()))
71                    .collect()
72            })
73            .unwrap_or_default();
74
75        Ok(profiles)
76    }
77
78    /// Copy all installed provisioning profiles and decode basic metadata.
79    pub async fn list_profiles(&mut self) -> Result<Vec<Profile>, MisagentError> {
80        let raw_profiles = self.copy_all().await?;
81        raw_profiles
82            .into_iter()
83            .map(|raw_data| decode_profile(&raw_data))
84            .collect()
85    }
86
87    /// Install a provisioning profile (raw DER/XML data).
88    pub async fn install(&mut self, profile_data: &[u8]) -> Result<(), MisagentError> {
89        self.send_value(plist::Value::Dictionary(plist::Dictionary::from_iter([
90            (
91                "MessageType".to_string(),
92                plist::Value::String("Install".into()),
93            ),
94            (
95                "ProfileType".to_string(),
96                plist::Value::String("Provisioning".into()),
97            ),
98            (
99                "Profile".to_string(),
100                plist::Value::Data(profile_data.to_vec()),
101            ),
102        ])))
103        .await?;
104        let data = self.recv_raw().await?;
105        let val: plist::Value =
106            plist::from_bytes(&data).map_err(|e| MisagentError::Plist(e.to_string()))?;
107        let status = val
108            .as_dictionary()
109            .and_then(|d| d.get("Status"))
110            .and_then(|v| v.as_unsigned_integer())
111            .unwrap_or(0) as u32;
112        if status != 0 {
113            return Err(MisagentError::Status(status));
114        }
115        Ok(())
116    }
117
118    /// Remove a provisioning profile by UUID.
119    pub async fn remove(&mut self, uuid: &str) -> Result<(), MisagentError> {
120        self.send_value(plist::Value::Dictionary(plist::Dictionary::from_iter([
121            (
122                "MessageType".to_string(),
123                plist::Value::String("Remove".into()),
124            ),
125            (
126                "ProfileType".to_string(),
127                plist::Value::String("Provisioning".into()),
128            ),
129            (
130                "ProfileID".to_string(),
131                plist::Value::String(uuid.to_string()),
132            ),
133        ])))
134        .await?;
135        let data = self.recv_raw().await?;
136        let val: plist::Value =
137            plist::from_bytes(&data).map_err(|e| MisagentError::Plist(e.to_string()))?;
138        let status = val
139            .as_dictionary()
140            .and_then(|d| d.get("Status"))
141            .and_then(|v| v.as_unsigned_integer())
142            .unwrap_or(0) as u32;
143        if status != 0 {
144            return Err(MisagentError::Status(status));
145        }
146        Ok(())
147    }
148
149    async fn send_value(&mut self, plist_val: plist::Value) -> Result<(), MisagentError> {
150        let mut buf = Vec::new();
151        plist::to_writer_xml(&mut buf, &plist_val)
152            .map_err(|e| MisagentError::Plist(e.to_string()))?;
153        self.stream
154            .write_all(&(buf.len() as u32).to_be_bytes())
155            .await?;
156        self.stream.write_all(&buf).await?;
157        self.stream.flush().await?;
158        Ok(())
159    }
160
161    async fn recv_raw(&mut self) -> Result<Vec<u8>, MisagentError> {
162        let mut len_buf = [0u8; 4];
163        self.stream.read_exact(&mut len_buf).await?;
164        let len = u32::from_be_bytes(len_buf) as usize;
165        const MAX_PLIST_SIZE: usize = 4 * 1024 * 1024;
166        if len > MAX_PLIST_SIZE {
167            return Err(MisagentError::Io(std::io::Error::new(
168                std::io::ErrorKind::InvalidData,
169                format!("plist length {len} exceeds maximum of {MAX_PLIST_SIZE}"),
170            )));
171        }
172        let mut buf = vec![0u8; len];
173        self.stream.read_exact(&mut buf).await?;
174        Ok(buf)
175    }
176}
177
178fn decode_profile(raw_data: &[u8]) -> Result<Profile, MisagentError> {
179    let plist_bytes = embedded_plist_bytes(raw_data)?;
180    let value: plist::Value =
181        plist::from_bytes(plist_bytes).map_err(|e| MisagentError::Plist(e.to_string()))?;
182    let dict = value.into_dictionary().ok_or_else(|| {
183        MisagentError::Protocol("provisioning profile payload was not a dictionary".into())
184    })?;
185
186    let uuid = required_string(&dict, "UUID")?;
187    let name = dict
188        .get("Name")
189        .and_then(plist::Value::as_string)
190        .unwrap_or(&uuid)
191        .to_string();
192    let app_id = dict
193        .get("AppIDName")
194        .and_then(plist::Value::as_string)
195        .or_else(|| {
196            dict.get("ApplicationIdentifierPrefix")
197                .and_then(plist::Value::as_array)
198                .and_then(|arr| arr.first())
199                .and_then(plist::Value::as_string)
200        })
201        .unwrap_or("")
202        .to_string();
203    let expiry_date = dict.get("ExpirationDate").map(plist_value_to_string);
204
205    Ok(Profile {
206        uuid,
207        name,
208        app_id,
209        expiry_date,
210        raw_data: raw_data.to_vec(),
211    })
212}
213
214fn embedded_plist_bytes(raw_data: &[u8]) -> Result<&[u8], MisagentError> {
215    let start = find_bytes(raw_data, b"<?xml").or_else(|| find_bytes(raw_data, b"<plist"));
216    let end = find_bytes(raw_data, b"</plist>");
217    match (start, end) {
218        (Some(start), Some(end)) if end >= start => Ok(&raw_data[start..end + b"</plist>".len()]),
219        _ => Err(MisagentError::Protocol(
220            "could not locate embedded plist in provisioning profile".into(),
221        )),
222    }
223}
224
225fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
226    haystack
227        .windows(needle.len())
228        .position(|window| window == needle)
229}
230
231fn required_string(dict: &plist::Dictionary, key: &str) -> Result<String, MisagentError> {
232    dict.get(key)
233        .and_then(plist::Value::as_string)
234        .map(ToOwned::to_owned)
235        .ok_or_else(|| MisagentError::Protocol(format!("missing provisioning profile key {key}")))
236}
237
238fn plist_value_to_string(value: &plist::Value) -> String {
239    match value {
240        plist::Value::String(s) => s.clone(),
241        plist::Value::Date(d) => d.to_xml_format(),
242        other => format!("{other:?}"),
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use crate::test_util::MockStream;
249
250    use super::*;
251
252    #[test]
253    fn decode_profile_extracts_basic_metadata_from_embedded_plist() {
254        let xml = br#"garbage<?xml version="1.0" encoding="UTF-8"?>
255<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
256<plist version="1.0"><dict>
257<key>UUID</key><string>ABC-123</string>
258<key>Name</key><string>Example Dev Profile</string>
259<key>AppIDName</key><string>Example App</string>
260<key>ExpirationDate</key><date>2026-04-08T00:00:00Z</date>
261</dict></plist>trailer"#;
262
263        let profile = decode_profile(xml).unwrap();
264        assert_eq!(profile.uuid, "ABC-123");
265        assert_eq!(profile.name, "Example Dev Profile");
266        assert_eq!(profile.app_id, "Example App");
267        assert_eq!(profile.expiry_date.as_deref(), Some("2026-04-08T00:00:00Z"));
268    }
269
270    #[test]
271    fn decode_profile_errors_without_embedded_plist() {
272        let err = decode_profile(b"not-a-profile").unwrap_err();
273        assert!(
274            matches!(err, MisagentError::Protocol(message) if message.contains("embedded plist"))
275        );
276    }
277
278    #[tokio::test]
279    async fn copy_all_uses_copy_all_message_type() {
280        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([
281            ("Status".to_string(), plist::Value::Integer(0.into())),
282            ("Payload".to_string(), plist::Value::Array(Vec::new())),
283        ]));
284        let mut stream = MockStream::with_response(response);
285        let mut client = MisagentClient::new(&mut stream);
286
287        let _ = client.copy_all().await.unwrap();
288
289        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
290        let payload = &stream.written[4..4 + len];
291        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
292        assert_eq!(
293            dict.get("MessageType").and_then(plist::Value::as_string),
294            Some("CopyAll")
295        );
296    }
297
298    #[tokio::test]
299    async fn install_uses_profile_field() {
300        let response = plist::Value::Dictionary(plist::Dictionary::from_iter([(
301            "Status".to_string(),
302            plist::Value::Integer(0.into()),
303        )]));
304        let mut stream = MockStream::with_response(response);
305        let mut client = MisagentClient::new(&mut stream);
306
307        client.install(b"PROFILE-DATA").await.unwrap();
308
309        let len = u32::from_be_bytes(stream.written[..4].try_into().unwrap()) as usize;
310        let payload = &stream.written[4..4 + len];
311        let dict: plist::Dictionary = plist::from_bytes(payload).unwrap();
312        assert_eq!(
313            dict.get("MessageType").and_then(plist::Value::as_string),
314            Some("Install")
315        );
316        assert_eq!(
317            dict.get("Profile").and_then(plist::Value::as_data),
318            Some(&b"PROFILE-DATA"[..])
319        );
320    }
321}