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