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