Skip to main content

ios_core/lockdown/
supervised_pair.rs

1//! Supervised P12 certificate-based pairing (no user trust dialog required).
2//!
3//! Supervised devices enrolled with an organization identity (P12 certificate)
4//! can be paired without any user interaction on the device. This module
5//! implements the lockdown Pair protocol with PKCS7 challenge-response flow:
6//!
7//! 1. Connect to lockdown (port 62078 via usbmux) — raw, no TLS
8//! 2. GetValue(nil, "DevicePublicKey") → device's RSA public key (PEM)
9//! 3. Generate cert chain: root CA, host cert, device cert
10//! 4. Send Pair request with PairRecord + PairingOptions{SupervisorCertificate}
11//! 5. Receive MCChallengeRequired with PairingChallenge in ExtendedResponse
12//! 6. Sign challenge with PKCS7 using supervisor's P12 private key
13//! 7. Send second Pair request with PairingOptions{ChallengeResponse}
14//! 8. Receive success with EscrowBag
15//!
16//! Reference: go-ios pair.go PairSupervised(), crypto_utils.go
17
18use openssl::asn1::Asn1Time;
19use openssl::bn::BigNum;
20use openssl::hash::MessageDigest;
21use openssl::pkcs12::Pkcs12;
22use openssl::pkcs7::{Pkcs7, Pkcs7Flags};
23use openssl::pkey::{HasPublic, PKey, Private};
24use openssl::rsa::Rsa;
25use openssl::stack::Stack;
26use openssl::x509::extension::{BasicConstraints, KeyUsage, SubjectKeyIdentifier};
27use openssl::x509::{X509Builder, X509NameBuilder, X509};
28use serde::{Deserialize, Serialize};
29use tokio::io::{AsyncRead, AsyncWrite};
30
31use crate::lockdown::protocol::{recv_lockdown, send_lockdown, GetValueRequest};
32use crate::lockdown::LockdownError;
33
34// ── Serializable pair record for the Pair request ────────────────────────────
35
36/// Full pair record data sent inside the lockdown Pair request.
37///
38/// All certificate and key fields are PEM-encoded, matching the format
39/// that `PairRecord::load()` expects on disk.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "PascalCase")]
42pub struct FullPairRecord {
43    #[serde(with = "serde_bytes")]
44    pub device_certificate: Vec<u8>,
45    #[serde(with = "serde_bytes")]
46    pub host_certificate: Vec<u8>,
47    #[serde(with = "serde_bytes")]
48    pub host_private_key: Vec<u8>,
49    #[serde(with = "serde_bytes")]
50    pub root_certificate: Vec<u8>,
51    #[serde(with = "serde_bytes")]
52    pub root_private_key: Vec<u8>,
53    #[serde(rename = "HostID")]
54    pub host_id: String,
55    #[serde(rename = "SystemBUID")]
56    pub system_buid: String,
57}
58
59// ── Certificate generation ───────────────────────────────────────────────────
60
61/// Generate the full certificate chain for lockdown pairing.
62///
63/// Produces a `FullPairRecord` containing:
64/// - A self-signed root CA certificate
65/// - A host certificate signed by the root CA
66/// - A device certificate signed by the root CA (using the device's public key)
67/// - The corresponding private keys (PEM-encoded)
68/// - Generated HostID and SystemBUID
69pub fn generate_pair_certs(
70    device_public_key_pem: &[u8],
71    system_buid: &str,
72) -> Result<FullPairRecord, LockdownError> {
73    // 1. Parse device public key from PEM
74    let device_pkey = PKey::public_key_from_pem(device_public_key_pem)
75        .map_err(|e| LockdownError::Protocol(format!("failed to parse device public key: {e}")))?;
76
77    // 2. Generate root RSA key pair (2048-bit, matching go-ios)
78    let root_rsa = Rsa::generate(2048)
79        .map_err(|e| LockdownError::Protocol(format!("RSA key generation failed: {e}")))?;
80    let root_pkey = PKey::from_rsa(root_rsa)
81        .map_err(|e| LockdownError::Protocol(format!("PKey from RSA failed: {e}")))?;
82
83    // 3. Generate host RSA key pair
84    let host_rsa = Rsa::generate(2048)
85        .map_err(|e| LockdownError::Protocol(format!("RSA key generation failed: {e}")))?;
86    let host_pkey = PKey::from_rsa(host_rsa)
87        .map_err(|e| LockdownError::Protocol(format!("PKey from RSA failed: {e}")))?;
88
89    // 4. Create root certificate (self-signed CA)
90    let root_cert = build_root_cert(&root_pkey)?;
91
92    // 5. Create host certificate (signed by root, with host's own key)
93    let host_cert = build_signed_cert(&host_pkey, &root_cert, &root_pkey)?;
94
95    // 6. Create device certificate (signed by root, using device's public key)
96    let device_cert = build_signed_cert(&device_pkey, &root_cert, &root_pkey)?;
97
98    // 7. Generate HostID
99    let host_id = uuid::Uuid::new_v4().to_string().to_uppercase();
100
101    Ok(FullPairRecord {
102        device_certificate: device_cert
103            .to_pem()
104            .map_err(|e| LockdownError::Protocol(format!("cert to PEM failed: {e}")))?,
105        host_certificate: host_cert
106            .to_pem()
107            .map_err(|e| LockdownError::Protocol(format!("cert to PEM failed: {e}")))?,
108        host_private_key: host_pkey
109            .private_key_to_pem_pkcs8()
110            .map_err(|e| LockdownError::Protocol(format!("key to PEM failed: {e}")))?,
111        root_certificate: root_cert
112            .to_pem()
113            .map_err(|e| LockdownError::Protocol(format!("cert to PEM failed: {e}")))?,
114        root_private_key: root_pkey
115            .private_key_to_pem_pkcs8()
116            .map_err(|e| LockdownError::Protocol(format!("key to PEM failed: {e}")))?,
117        host_id,
118        system_buid: system_buid.to_string(),
119    })
120}
121
122/// Build a self-signed root CA certificate.
123///
124/// Matches go-ios crypto_utils.go `createRootCert`:
125/// - Serial 0, empty subject, 10-year validity
126/// - BasicConstraints CA=true
127/// - SubjectKeyIdentifier (SHA1 of public key)
128/// - SHA1WithRSA signature
129fn build_root_cert(pkey: &PKey<Private>) -> Result<X509, LockdownError> {
130    let mut builder = X509Builder::new()
131        .map_err(|e| LockdownError::Protocol(format!("X509Builder::new failed: {e}")))?;
132
133    // X.509 v3
134    builder
135        .set_version(2)
136        .map_err(|e| LockdownError::Protocol(format!("set_version failed: {e}")))?;
137
138    // Serial number = 0
139    let serial = BigNum::from_u32(0)
140        .and_then(|bn| bn.to_asn1_integer())
141        .map_err(|e| LockdownError::Protocol(format!("serial number failed: {e}")))?;
142    builder
143        .set_serial_number(&serial)
144        .map_err(|e| LockdownError::Protocol(format!("set_serial_number failed: {e}")))?;
145
146    // Empty subject name (matching go-ios pkix.Name{})
147    let name = X509NameBuilder::new()
148        .map(|b| b.build())
149        .map_err(|e| LockdownError::Protocol(format!("X509Name build failed: {e}")))?;
150    builder
151        .set_subject_name(&name)
152        .map_err(|e| LockdownError::Protocol(format!("set_subject_name failed: {e}")))?;
153    builder
154        .set_issuer_name(&name)
155        .map_err(|e| LockdownError::Protocol(format!("set_issuer_name failed: {e}")))?;
156
157    // 10-year validity
158    let not_before = Asn1Time::days_from_now(0)
159        .map_err(|e| LockdownError::Protocol(format!("Asn1Time failed: {e}")))?;
160    let not_after = Asn1Time::days_from_now(3650)
161        .map_err(|e| LockdownError::Protocol(format!("Asn1Time failed: {e}")))?;
162    builder
163        .set_not_before(&not_before)
164        .map_err(|e| LockdownError::Protocol(format!("set_not_before failed: {e}")))?;
165    builder
166        .set_not_after(&not_after)
167        .map_err(|e| LockdownError::Protocol(format!("set_not_after failed: {e}")))?;
168
169    builder
170        .set_pubkey(pkey)
171        .map_err(|e| LockdownError::Protocol(format!("set_pubkey failed: {e}")))?;
172
173    // BasicConstraints: CA=true, critical
174    let basic = BasicConstraints::new()
175        .critical()
176        .ca()
177        .build()
178        .map_err(|e| LockdownError::Protocol(format!("BasicConstraints build failed: {e}")))?;
179    builder
180        .append_extension(basic)
181        .map_err(|e| LockdownError::Protocol(format!("append_extension failed: {e}")))?;
182
183    // SubjectKeyIdentifier — SHA1 hash of the public key
184    // We need to build this from the context of the builder itself
185    let ski = SubjectKeyIdentifier::new()
186        .build(&builder.x509v3_context(None, None))
187        .map_err(|e| LockdownError::Protocol(format!("SKI build failed: {e}")))?;
188    builder
189        .append_extension(ski)
190        .map_err(|e| LockdownError::Protocol(format!("append SKI failed: {e}")))?;
191
192    // Sign with SHA1 (matching go-ios SHA1WithRSA)
193    builder
194        .sign(pkey, MessageDigest::sha1())
195        .map_err(|e| LockdownError::Protocol(format!("sign failed: {e}")))?;
196
197    Ok(builder.build())
198}
199
200/// Build a certificate signed by the issuer (root CA).
201///
202/// Used for both host and device certificates. Matches go-ios `createHostCert`
203/// and `createDeviceCert`:
204/// - Serial 0, empty subject, 10-year validity
205/// - KeyUsage: digitalSignature | keyEncipherment
206/// - BasicConstraints: CA=false
207/// - SubjectKeyIdentifier
208/// - SHA1WithRSA signature (signed by root)
209fn build_signed_cert(
210    subject_pkey: &PKey<impl HasPublic>,
211    issuer_cert: &X509,
212    issuer_pkey: &PKey<Private>,
213) -> Result<X509, LockdownError> {
214    let mut builder = X509Builder::new()
215        .map_err(|e| LockdownError::Protocol(format!("X509Builder::new failed: {e}")))?;
216
217    // X.509 v3
218    builder
219        .set_version(2)
220        .map_err(|e| LockdownError::Protocol(format!("set_version failed: {e}")))?;
221
222    // Serial number = 0
223    let serial = BigNum::from_u32(0)
224        .and_then(|bn| bn.to_asn1_integer())
225        .map_err(|e| LockdownError::Protocol(format!("serial number failed: {e}")))?;
226    builder
227        .set_serial_number(&serial)
228        .map_err(|e| LockdownError::Protocol(format!("set_serial_number failed: {e}")))?;
229
230    // Empty subject name
231    let name = X509NameBuilder::new()
232        .map(|b| b.build())
233        .map_err(|e| LockdownError::Protocol(format!("X509Name build failed: {e}")))?;
234    builder
235        .set_subject_name(&name)
236        .map_err(|e| LockdownError::Protocol(format!("set_subject_name failed: {e}")))?;
237
238    // Issuer = root cert's subject
239    builder
240        .set_issuer_name(issuer_cert.subject_name())
241        .map_err(|e| LockdownError::Protocol(format!("set_issuer_name failed: {e}")))?;
242
243    // 10-year validity
244    let not_before = Asn1Time::days_from_now(0)
245        .map_err(|e| LockdownError::Protocol(format!("Asn1Time failed: {e}")))?;
246    let not_after = Asn1Time::days_from_now(3650)
247        .map_err(|e| LockdownError::Protocol(format!("Asn1Time failed: {e}")))?;
248    builder
249        .set_not_before(&not_before)
250        .map_err(|e| LockdownError::Protocol(format!("set_not_before failed: {e}")))?;
251    builder
252        .set_not_after(&not_after)
253        .map_err(|e| LockdownError::Protocol(format!("set_not_after failed: {e}")))?;
254
255    builder
256        .set_pubkey(subject_pkey)
257        .map_err(|e| LockdownError::Protocol(format!("set_pubkey failed: {e}")))?;
258
259    // BasicConstraints: CA=false
260    let basic = BasicConstraints::new()
261        .critical()
262        .build()
263        .map_err(|e| LockdownError::Protocol(format!("BasicConstraints build failed: {e}")))?;
264    builder
265        .append_extension(basic)
266        .map_err(|e| LockdownError::Protocol(format!("append_extension failed: {e}")))?;
267
268    // KeyUsage: digitalSignature | keyEncipherment (matching go-ios)
269    let key_usage = KeyUsage::new()
270        .digital_signature()
271        .key_encipherment()
272        .build()
273        .map_err(|e| LockdownError::Protocol(format!("KeyUsage build failed: {e}")))?;
274    builder
275        .append_extension(key_usage)
276        .map_err(|e| LockdownError::Protocol(format!("append KeyUsage failed: {e}")))?;
277
278    // SubjectKeyIdentifier
279    let ski = SubjectKeyIdentifier::new()
280        .build(&builder.x509v3_context(Some(issuer_cert), None))
281        .map_err(|e| LockdownError::Protocol(format!("SKI build failed: {e}")))?;
282    builder
283        .append_extension(ski)
284        .map_err(|e| LockdownError::Protocol(format!("append SKI failed: {e}")))?;
285
286    // Sign with SHA1 by issuer's private key
287    builder
288        .sign(issuer_pkey, MessageDigest::sha1())
289        .map_err(|e| LockdownError::Protocol(format!("sign failed: {e}")))?;
290
291    Ok(builder.build())
292}
293
294// ── Lockdown protocol messages for Pair ──────────────────────────────────────
295
296/// The PairRecord payload embedded in a Pair request.
297/// Only contains the certificates and IDs (no private keys sent to device).
298#[derive(Serialize)]
299#[serde(rename_all = "PascalCase")]
300struct PairRecordPayload {
301    #[serde(with = "serde_bytes")]
302    device_certificate: Vec<u8>,
303    #[serde(with = "serde_bytes")]
304    host_certificate: Vec<u8>,
305    #[serde(with = "serde_bytes")]
306    root_certificate: Vec<u8>,
307    #[serde(rename = "HostID")]
308    host_id: String,
309    #[serde(rename = "SystemBUID")]
310    system_buid: String,
311}
312
313/// PairingOptions for the first Pair request (supervisor cert).
314#[derive(Serialize)]
315#[serde(rename_all = "PascalCase")]
316struct PairingOptionsFirst {
317    extended_pairing_errors: bool,
318    #[serde(with = "serde_bytes")]
319    supervisor_certificate: Vec<u8>,
320}
321
322/// PairingOptions for the second Pair request (challenge response).
323#[derive(Serialize)]
324#[serde(rename_all = "PascalCase")]
325struct PairingOptionsChallenge {
326    #[serde(with = "serde_bytes")]
327    challenge_response: Vec<u8>,
328}
329
330/// First Pair request (with SupervisorCertificate).
331#[derive(Serialize)]
332#[serde(rename_all = "PascalCase")]
333struct PairRequestFirst {
334    label: &'static str,
335    protocol_version: &'static str,
336    request: &'static str,
337    pair_record: PairRecordPayload,
338    pairing_options: PairingOptionsFirst,
339}
340
341/// Second Pair request (with ChallengeResponse).
342#[derive(Serialize)]
343#[serde(rename_all = "PascalCase")]
344struct PairRequestChallenge {
345    label: &'static str,
346    protocol_version: &'static str,
347    request: &'static str,
348    pair_record: PairRecordPayload,
349    pairing_options: PairingOptionsChallenge,
350}
351
352/// Generic GetValue response (used to extract DevicePublicKey).
353#[derive(Debug, Deserialize)]
354#[serde(rename_all = "PascalCase")]
355struct GetValueRawResponse {
356    #[serde(default)]
357    error: Option<String>,
358    value: Option<plist::Value>,
359}
360
361/// Build the PairRecordPayload from a FullPairRecord (strips private keys).
362fn pair_record_payload(record: &FullPairRecord) -> PairRecordPayload {
363    PairRecordPayload {
364        device_certificate: record.device_certificate.clone(),
365        host_certificate: record.host_certificate.clone(),
366        root_certificate: record.root_certificate.clone(),
367        host_id: record.host_id.clone(),
368        system_buid: record.system_buid.clone(),
369    }
370}
371
372// ── Supervised Pair protocol ─────────────────────────────────────────────────
373
374/// Perform supervised pairing with a P12 certificate on a raw lockdown stream.
375///
376/// The stream must be a raw TCP connection to lockdown port 62078 (via usbmux),
377/// NOT a TLS-wrapped connection. This is because we are pairing for the first
378/// time and do not yet have the certificates needed for TLS.
379///
380/// Returns `(pair_record, escrow_bag)` on success. The `pair_record` contains
381/// all certificates and keys needed for future TLS sessions, and should be
382/// saved to disk in the standard lockdown pair record directory.
383///
384/// # Arguments
385/// * `stream` - Raw lockdown stream (usbmux connection to port 62078)
386/// * `p12_bytes` - Raw bytes of the P12 supervisor certificate file
387/// * `p12_password` - Password for the P12 file
388/// * `system_buid` - System BUID (from usbmuxd ReadBUID)
389pub async fn pair_supervised<S>(
390    stream: &mut S,
391    p12_bytes: &[u8],
392    p12_password: &str,
393    system_buid: &str,
394) -> Result<(FullPairRecord, Vec<u8>), LockdownError>
395where
396    S: AsyncRead + AsyncWrite + Unpin,
397{
398    // 1. Parse P12 to extract supervisor cert and private key
399    let pkcs12 = Pkcs12::from_der(p12_bytes)
400        .map_err(|e| LockdownError::Protocol(format!("P12 parse failed: {e}")))?;
401    let parsed = pkcs12
402        .parse2(p12_password)
403        .map_err(|e| LockdownError::Protocol(format!("P12 parse2 failed: {e}")))?;
404    let supervisor_cert = parsed
405        .cert
406        .ok_or_else(|| LockdownError::Protocol("P12 missing certificate".into()))?;
407    let supervisor_pkey = parsed
408        .pkey
409        .ok_or_else(|| LockdownError::Protocol("P12 missing private key".into()))?;
410
411    // 2. Get device public key via raw lockdown GetValue
412    let device_public_key_pem = get_device_public_key(stream).await?;
413
414    // 3. Generate certificate chain
415    let pair_record = generate_pair_certs(&device_public_key_pem, system_buid)?;
416
417    // 4. Send first Pair request with SupervisorCertificate (DER-encoded)
418    let supervisor_cert_der = supervisor_cert
419        .to_der()
420        .map_err(|e| LockdownError::Protocol(format!("supervisor cert to DER failed: {e}")))?;
421
422    let payload = pair_record_payload(&pair_record);
423    let first_request = PairRequestFirst {
424        label: "ios-rs",
425        protocol_version: "2",
426        request: "Pair",
427        pair_record: payload,
428        pairing_options: PairingOptionsFirst {
429            extended_pairing_errors: true,
430            supervisor_certificate: supervisor_cert_der,
431        },
432    };
433    send_lockdown(stream, &first_request).await?;
434
435    // 5. Receive MCChallengeRequired response and extract PairingChallenge
436    let challenge = recv_pairing_challenge(stream).await?;
437
438    // 6. Sign challenge with PKCS7 using supervisor's private key
439    let certs =
440        Stack::new().map_err(|e| LockdownError::Protocol(format!("Stack::new failed: {e}")))?;
441    let signed = Pkcs7::sign(
442        &supervisor_cert,
443        &supervisor_pkey,
444        &certs,
445        &challenge,
446        Pkcs7Flags::BINARY,
447    )
448    .and_then(|p7| p7.to_der())
449    .map_err(|e| LockdownError::Protocol(format!("PKCS7 sign failed: {e}")))?;
450
451    // 7. Send second Pair request with ChallengeResponse
452    let payload2 = pair_record_payload(&pair_record);
453    let challenge_request = PairRequestChallenge {
454        label: "ios-rs",
455        protocol_version: "2",
456        request: "Pair",
457        pair_record: payload2,
458        pairing_options: PairingOptionsChallenge {
459            challenge_response: signed,
460        },
461    };
462    send_lockdown(stream, &challenge_request).await?;
463
464    // 8. Receive success response with EscrowBag
465    let escrow_bag = recv_pair_success(stream).await?;
466
467    Ok((pair_record, escrow_bag))
468}
469
470/// Send GetValue request for DevicePublicKey on a raw (non-TLS) lockdown stream.
471async fn get_device_public_key<S>(stream: &mut S) -> Result<Vec<u8>, LockdownError>
472where
473    S: AsyncRead + AsyncWrite + Unpin,
474{
475    let request = GetValueRequest {
476        label: "ios-rs",
477        request: "GetValue",
478        domain: None,
479        key: Some("DevicePublicKey"),
480    };
481    send_lockdown(stream, &request).await?;
482
483    let resp: GetValueRawResponse = recv_lockdown(stream).await?;
484    if let Some(err) = resp.error {
485        return Err(LockdownError::Protocol(format!(
486            "GetValue DevicePublicKey failed: {err}"
487        )));
488    }
489
490    match resp.value {
491        Some(plist::Value::Data(data)) => Ok(data),
492        other => Err(LockdownError::Protocol(format!(
493            "DevicePublicKey: expected Data, got {other:?}"
494        ))),
495    }
496}
497
498/// Receive and validate the MCChallengeRequired response, extracting the
499/// PairingChallenge bytes from ExtendedResponse.
500async fn recv_pairing_challenge<S>(stream: &mut S) -> Result<Vec<u8>, LockdownError>
501where
502    S: AsyncRead + AsyncWrite + Unpin,
503{
504    // Parse as generic plist to handle the nested structure
505    let resp: plist::Value = recv_lockdown(stream).await?;
506    let dict = resp
507        .as_dictionary()
508        .ok_or_else(|| LockdownError::Protocol("Pair response is not a dictionary".into()))?;
509
510    // Verify we got MCChallengeRequired error
511    let error = dict
512        .get("Error")
513        .and_then(plist::Value::as_string)
514        .ok_or_else(|| {
515            LockdownError::Protocol(format!("Pair response missing Error field: {dict:?}"))
516        })?;
517
518    if error != "MCChallengeRequired" {
519        return Err(LockdownError::Protocol(format!(
520            "expected MCChallengeRequired error, got: {error}"
521        )));
522    }
523
524    // Extract PairingChallenge from ExtendedResponse
525    let extended = dict
526        .get("ExtendedResponse")
527        .and_then(plist::Value::as_dictionary)
528        .ok_or_else(|| LockdownError::Protocol("Pair response missing ExtendedResponse".into()))?;
529
530    let challenge = extended
531        .get("PairingChallenge")
532        .and_then(plist::Value::as_data)
533        .ok_or_else(|| {
534            LockdownError::Protocol("ExtendedResponse missing PairingChallenge".into())
535        })?;
536
537    Ok(challenge.to_vec())
538}
539
540/// Receive the successful Pair response and extract the EscrowBag.
541async fn recv_pair_success<S>(stream: &mut S) -> Result<Vec<u8>, LockdownError>
542where
543    S: AsyncRead + AsyncWrite + Unpin,
544{
545    let resp: plist::Value = recv_lockdown(stream).await?;
546    let dict = resp
547        .as_dictionary()
548        .ok_or_else(|| LockdownError::Protocol("Pair response is not a dictionary".into()))?;
549
550    // Check for errors
551    if let Some(error) = dict.get("Error").and_then(plist::Value::as_string) {
552        return Err(LockdownError::Protocol(format!("Pair failed: {error}")));
553    }
554
555    // Extract EscrowBag
556    let escrow_bag = dict
557        .get("EscrowBag")
558        .and_then(plist::Value::as_data)
559        .ok_or_else(|| LockdownError::Protocol("Pair success response missing EscrowBag".into()))?;
560
561    Ok(escrow_bag.to_vec())
562}
563
564/// Save a [`FullPairRecord`] to disk as a plist file compatible with
565/// `PairRecord::load()`.
566///
567/// The saved record includes all certificates, host private key, root private
568/// key, HostID, SystemBUID, EscrowBag, and optionally the WiFi MAC address.
569pub fn save_pair_record(
570    record: &FullPairRecord,
571    escrow_bag: &[u8],
572    wifi_mac: Option<&str>,
573    path: &std::path::Path,
574) -> Result<(), LockdownError> {
575    use plist::Value;
576
577    let mut dict = plist::Dictionary::new();
578    dict.insert(
579        "DeviceCertificate".into(),
580        Value::Data(record.device_certificate.clone()),
581    );
582    dict.insert(
583        "HostCertificate".into(),
584        Value::Data(record.host_certificate.clone()),
585    );
586    dict.insert(
587        "HostPrivateKey".into(),
588        Value::Data(record.host_private_key.clone()),
589    );
590    dict.insert(
591        "RootCertificate".into(),
592        Value::Data(record.root_certificate.clone()),
593    );
594    dict.insert(
595        "RootPrivateKey".into(),
596        Value::Data(record.root_private_key.clone()),
597    );
598    dict.insert("HostID".into(), Value::String(record.host_id.clone()));
599    dict.insert(
600        "SystemBUID".into(),
601        Value::String(record.system_buid.clone()),
602    );
603    dict.insert("EscrowBag".into(), Value::Data(escrow_bag.to_vec()));
604
605    if let Some(mac) = wifi_mac {
606        dict.insert("WiFiMACAddress".into(), Value::String(mac.to_string()));
607    }
608
609    // Ensure parent directory exists
610    if let Some(parent) = path.parent() {
611        if !parent.as_os_str().is_empty() {
612            std::fs::create_dir_all(parent).map_err(|e| {
613                LockdownError::Protocol(format!(
614                    "failed to create pair record directory {}: {e}",
615                    parent.display()
616                ))
617            })?;
618        }
619    }
620
621    let plist_value = Value::Dictionary(dict);
622    let mut buf = Vec::new();
623    plist::to_writer_xml(&mut buf, &plist_value)
624        .map_err(|e| LockdownError::Protocol(format!("plist serialization failed: {e}")))?;
625    std::fs::write(path, &buf).map_err(|e| {
626        LockdownError::Protocol(format!(
627            "failed to write pair record to {}: {e}",
628            path.display()
629        ))
630    })?;
631
632    Ok(())
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn test_generate_pair_certs_structure() {
641        // Generate a mock device RSA key and convert to PEM
642        let device_rsa = Rsa::generate(2048).unwrap();
643        let device_pkey = PKey::from_rsa(device_rsa).unwrap();
644        let device_pub_pem = device_pkey.public_key_to_pem().unwrap();
645
646        let record = generate_pair_certs(&device_pub_pem, "TEST-BUID").unwrap();
647
648        // Verify all fields are non-empty
649        assert!(!record.device_certificate.is_empty());
650        assert!(!record.host_certificate.is_empty());
651        assert!(!record.host_private_key.is_empty());
652        assert!(!record.root_certificate.is_empty());
653        assert!(!record.root_private_key.is_empty());
654        assert_eq!(record.host_id.len(), 36); // UUID format
655        assert_eq!(record.system_buid, "TEST-BUID");
656
657        // Verify certificates are valid PEM
658        let _ = X509::from_pem(&record.device_certificate).unwrap();
659        let _ = X509::from_pem(&record.host_certificate).unwrap();
660        let _ = X509::from_pem(&record.root_certificate).unwrap();
661
662        // Verify private keys are valid PEM
663        let _ = PKey::private_key_from_pem(&record.host_private_key).unwrap();
664        let _ = PKey::private_key_from_pem(&record.root_private_key).unwrap();
665    }
666
667    #[test]
668    fn test_root_cert_is_ca() {
669        let rsa = Rsa::generate(2048).unwrap();
670        let pkey = PKey::from_rsa(rsa).unwrap();
671        let cert = build_root_cert(&pkey).unwrap();
672
673        // Verify the certificate is valid and can be serialized
674        let pem = cert.to_pem().unwrap();
675        let parsed = X509::from_pem(&pem).unwrap();
676        // Root cert should be self-signed (issuer == subject)
677        assert_eq!(
678            parsed.subject_name().entries().count(),
679            parsed.issuer_name().entries().count()
680        );
681    }
682
683    #[test]
684    fn test_signed_cert_not_ca() {
685        let root_rsa = Rsa::generate(2048).unwrap();
686        let root_pkey = PKey::from_rsa(root_rsa).unwrap();
687        let root_cert = build_root_cert(&root_pkey).unwrap();
688
689        let host_rsa = Rsa::generate(2048).unwrap();
690        let host_pkey = PKey::from_rsa(host_rsa).unwrap();
691        let host_cert = build_signed_cert(&host_pkey, &root_cert, &root_pkey).unwrap();
692
693        // Verify the host cert can be serialized and parsed
694        let pem = host_cert.to_pem().unwrap();
695        let parsed = X509::from_pem(&pem).unwrap();
696        // Host cert should be signed by root (issuer matches root subject)
697        assert_eq!(
698            parsed.issuer_name().entries().count(),
699            root_cert.subject_name().entries().count()
700        );
701    }
702
703    #[test]
704    fn test_pair_record_payload_strips_keys() {
705        let record = FullPairRecord {
706            device_certificate: b"dev-cert".to_vec(),
707            host_certificate: b"host-cert".to_vec(),
708            host_private_key: b"SECRET-HOST-KEY".to_vec(),
709            root_certificate: b"root-cert".to_vec(),
710            root_private_key: b"SECRET-ROOT-KEY".to_vec(),
711            host_id: "HOST-ID".to_string(),
712            system_buid: "BUID".to_string(),
713        };
714        let payload = pair_record_payload(&record);
715        assert_eq!(payload.device_certificate, b"dev-cert");
716        assert_eq!(payload.host_id, "HOST-ID");
717        // payload should not contain private keys (it's a different struct)
718    }
719}