apple_codesign/
stapling.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5/*! Attach Apple notarization tickets to signed entities.
6
7Stapling refers to the act of taking an Apple issued notarization
8ticket (generated after uploading content to Apple for inspection)
9and attaching that ticket to the entity that was uploaded. The
10mechanism varies, but stapling is literally just fetching a payload
11from Apple and attaching it to something else.
12*/
13
14use {
15    crate::{
16        bundle_signing::SignedMachOInfo,
17        cryptography::DigestType,
18        dmg::{DmgReader, DmgSigner},
19        embedded_signature::Blob,
20        reader::PathType,
21        ticket_lookup::{default_client, lookup_notarization_ticket},
22        AppleCodesignError,
23    },
24    apple_bundles::DirectoryBundle,
25    apple_xar::reader::XarReader,
26    log::{error, info, warn},
27    reqwest::blocking::Client,
28    scroll::{IOread, IOwrite, Pread, Pwrite, SizeWith},
29    std::{
30        fmt::Debug,
31        fs::File,
32        io::{Read, Seek, SeekFrom, Write},
33        path::Path,
34    },
35};
36
37/// Resolve the notarization ticket record name from a bundle.
38///
39/// The record name is derived from the digest of the code directory of the
40/// main binary within the bundle.
41pub fn record_name_from_executable_bundle(
42    bundle: &DirectoryBundle,
43) -> Result<String, AppleCodesignError> {
44    let main_exe = bundle
45        .files(false)
46        .map_err(AppleCodesignError::DirectoryBundle)?
47        .into_iter()
48        .find(|file| matches!(file.is_main_executable(), Ok(true)))
49        .ok_or(AppleCodesignError::StapleMainExecutableNotFound)?;
50
51    // Now extract the code signature so we can resolve the code directory.
52    info!(
53        "resolving bundle's record name from {}",
54        main_exe.absolute_path().display()
55    );
56    let macho_data = std::fs::read(main_exe.absolute_path())?;
57
58    let signed = SignedMachOInfo::parse_data(&macho_data)?;
59
60    let record_name = signed.notarization_ticket_record_name()?;
61
62    Ok(record_name)
63}
64
65/// Staple a ticket to a bundle as defined by the path to a directory.
66///
67/// Stapling a bundle (e.g. `MyApp.app`) is literally just writing a
68/// `Contents/CodeResources` file containing the raw ticket data.
69pub fn staple_ticket_to_bundle(
70    bundle: &DirectoryBundle,
71    ticket_data: &[u8],
72) -> Result<(), AppleCodesignError> {
73    let path = bundle.resolve_path("CodeResources");
74
75    warn!("writing notarization ticket to {}", path.display());
76    std::fs::write(&path, ticket_data)?;
77
78    Ok(())
79}
80
81/// Magic header for xar trailer struct.
82///
83/// `t8lr`.
84const XAR_NOTARIZATION_TRAILER_MAGIC: [u8; 4] = [0x74, 0x38, 0x6c, 0x72];
85
86#[derive(Clone, Copy, Debug, IOread, IOwrite, Pread, Pwrite, SizeWith)]
87pub struct XarNotarizationTrailer {
88    /// "t8lr"
89    pub magic: [u8; 4],
90    pub version: u16,
91    pub typ: u16,
92    pub length: u32,
93    pub unused: u32,
94}
95
96#[derive(Clone, Copy, Debug)]
97#[repr(u16)]
98pub enum XarNotarizationTrailerType {
99    Invalid = 0,
100    Terminator = 1,
101    Ticket = 2,
102}
103
104/// Obtain the notarization trailer data for a XAR archive.
105///
106/// The trailer data consists of a [XarNotarizationTrailer] of type `Terminator`
107/// to denote the end of XAR content followed by the raw ticket data followed by a
108/// [XarNotarizationTrailer] with type `Ticket`. Essentially, a reader can look for
109/// a ticket trailer at the end of the file then quickly seek to the beginning of
110/// ticket data.
111pub fn xar_notarization_trailer(ticket_data: &[u8]) -> Result<Vec<u8>, AppleCodesignError> {
112    let terminator = XarNotarizationTrailer {
113        magic: XAR_NOTARIZATION_TRAILER_MAGIC,
114        version: 1,
115        typ: XarNotarizationTrailerType::Terminator as u16,
116        length: 0,
117        unused: 0,
118    };
119    let ticket = XarNotarizationTrailer {
120        magic: XAR_NOTARIZATION_TRAILER_MAGIC,
121        version: 1,
122        typ: XarNotarizationTrailerType::Ticket as u16,
123        length: ticket_data.len() as _,
124        unused: 0,
125    };
126
127    let mut cursor = std::io::Cursor::new(Vec::new());
128    cursor.iowrite_with(terminator, scroll::LE)?;
129    cursor.write_all(ticket_data)?;
130    cursor.iowrite_with(ticket, scroll::LE)?;
131
132    Ok(cursor.into_inner())
133}
134
135/// Handles stapling operations.
136pub struct Stapler {
137    client: Client,
138}
139
140impl Stapler {
141    /// Construct a new instance with defaults.
142    pub fn new() -> Result<Self, AppleCodesignError> {
143        Ok(Self {
144            client: default_client()?,
145        })
146    }
147
148    /// Set the HTTP client to use for ticket lookups.
149    pub fn set_client(&mut self, client: Client) {
150        self.client = client;
151    }
152
153    /// Look up a notarization ticket for an app bundle.
154    ///
155    /// This will resolve the notarization ticket record name from the contents
156    /// of the bundle then attempt to look up that notarization ticket against
157    /// Apple's servers.
158    ///
159    /// This errors if there is a problem deriving the notarization ticket record name
160    /// or if a failure occurs when looking up the notarization ticket. This can include
161    /// a notarization ticket not existing for the requested record.
162    pub fn lookup_ticket_for_executable_bundle(
163        &self,
164        bundle: &DirectoryBundle,
165    ) -> Result<Vec<u8>, AppleCodesignError> {
166        let record_name = record_name_from_executable_bundle(bundle)?;
167
168        let response = lookup_notarization_ticket(&self.client, &record_name)?;
169
170        let ticket_data = response.signed_ticket(&record_name)?;
171
172        Ok(ticket_data)
173    }
174
175    /// Attempt to staple a bundle by obtaining a notarization ticket automatically.
176    pub fn staple_bundle(&self, bundle: &DirectoryBundle) -> Result<(), AppleCodesignError> {
177        warn!(
178            "attempting to find notarization ticket for bundle at {}",
179            bundle.root_dir().display()
180        );
181        let ticket_data = self.lookup_ticket_for_executable_bundle(bundle)?;
182        staple_ticket_to_bundle(bundle, &ticket_data)?;
183
184        Ok(())
185    }
186
187    /// Look up ticket data for DMG file.
188    pub fn lookup_ticket_for_dmg(&self, dmg: &DmgReader) -> Result<Vec<u8>, AppleCodesignError> {
189        // The ticket is derived from the code directory digest from the signature in the
190        // DMG.
191        let signature = dmg
192            .embedded_signature()?
193            .ok_or(AppleCodesignError::DmgStapleNoSignature)?;
194        let cd = signature
195            .code_directory()?
196            .ok_or(AppleCodesignError::DmgStapleNoSignature)?;
197
198        let mut digest = cd.digest_with(cd.digest_type)?;
199        digest.truncate(20);
200        let digest = hex::encode(digest);
201
202        let digest_type: u8 = cd.digest_type.into();
203
204        let record_name = format!("2/{digest_type}/{digest}");
205
206        let response = lookup_notarization_ticket(&self.client, &record_name)?;
207
208        response.signed_ticket(&record_name)
209    }
210
211    /// Attempt to staple a DMG by obtaining a notarization ticket automatically.
212    pub fn staple_dmg(&self, path: &Path) -> Result<(), AppleCodesignError> {
213        let mut fh = File::options().read(true).write(true).open(path)?;
214
215        warn!(
216            "attempting to find notarization ticket for DMG at {}",
217            path.display()
218        );
219        let reader = DmgReader::new(&mut fh)?;
220
221        let ticket_data = self.lookup_ticket_for_dmg(&reader)?;
222        warn!("found notarization ticket; proceeding with stapling");
223
224        let signer = DmgSigner::default();
225        signer.staple_file(&mut fh, ticket_data)?;
226
227        Ok(())
228    }
229
230    /// Lookup ticket data for a XAR archive (e.g. a `.pkg` file).
231    pub fn lookup_ticket_for_xar<R: Read + Seek + Sized + Debug>(
232        &self,
233        reader: &mut XarReader<R>,
234    ) -> Result<Vec<u8>, AppleCodesignError> {
235        let mut digest = reader.checksum_data()?;
236        digest.truncate(20);
237        let digest = hex::encode(digest);
238
239        let digest_type = DigestType::try_from(reader.table_of_contents().checksum.style)?;
240        let digest_type: u8 = digest_type.into();
241
242        let record_name = format!("2/{digest_type}/{digest}");
243
244        let response = lookup_notarization_ticket(&self.client, &record_name)?;
245
246        response.signed_ticket(&record_name)
247    }
248
249    /// Staple a XAR archive.
250    ///
251    /// Takes the handle to a readable, writable, and seekable object.
252    ///
253    /// The stream will be opened as a XAR file. If a ticket is found, that ticket
254    /// will be appended to the end of the file.
255    pub fn staple_xar<F: Read + Write + Seek + Sized + Debug>(
256        &self,
257        mut xar: XarReader<F>,
258    ) -> Result<(), AppleCodesignError> {
259        let ticket_data = self.lookup_ticket_for_xar(&mut xar)?;
260
261        warn!("found notarization ticket; proceeding with stapling");
262
263        let mut fh = xar.into_inner();
264
265        // As a convenience, we look for an existing ticket trailer so we can tell
266        // the user we're effectively overwriting it. We could potentially try to
267        // delete or overwrite the old trailer. BUt it is just easier to append,
268        // as a writer likely only looks for the ticket trailer at the tail end
269        // of the file.
270        let trailer_size = 16;
271        fh.seek(SeekFrom::End(-trailer_size))?;
272
273        let trailer = fh.ioread_with::<XarNotarizationTrailer>(scroll::LE)?;
274        if trailer.magic == XAR_NOTARIZATION_TRAILER_MAGIC {
275            let trailer_type = match trailer.typ {
276                x if x == XarNotarizationTrailerType::Invalid as u16 => "invalid",
277                x if x == XarNotarizationTrailerType::Ticket as u16 => "ticket",
278                x if x == XarNotarizationTrailerType::Terminator as u16 => "terminator",
279                _ => "unknown",
280            };
281
282            warn!("found an existing XAR trailer of type {}", trailer_type);
283            warn!("this existing trailer will be preserved and will likely be ignored");
284        }
285
286        let trailer = xar_notarization_trailer(&ticket_data)?;
287
288        warn!(
289            "stapling notarization ticket trailer ({} bytes) to end of XAR",
290            trailer.len()
291        );
292        fh.write_all(&trailer)?;
293
294        Ok(())
295    }
296
297    /// Attempt to staple an entity at a given filesystem path.
298    ///
299    /// The path will be modified on successful stapling operation.
300    pub fn staple_path(&self, path: impl AsRef<Path>) -> Result<(), AppleCodesignError> {
301        let path = path.as_ref();
302        warn!("attempting to staple {}", path.display());
303
304        match PathType::from_path(path)? {
305            PathType::MachO => {
306                error!("cannot staple Mach-O binaries");
307                Err(AppleCodesignError::StapleUnsupportedPath(
308                    path.to_path_buf(),
309                ))
310            }
311            PathType::Dmg => {
312                warn!("activating DMG stapling mode");
313                self.staple_dmg(path)
314            }
315            PathType::Bundle => {
316                warn!("activating bundle stapling mode");
317                let bundle = DirectoryBundle::new_from_path(path)
318                    .map_err(AppleCodesignError::DirectoryBundle)?;
319                self.staple_bundle(&bundle)
320            }
321            PathType::Xar => {
322                warn!("activating XAR stapling mode");
323                let xar = XarReader::new(File::options().read(true).write(true).open(path)?)?;
324                self.staple_xar(xar)
325            }
326            PathType::Zip | PathType::Other => Err(AppleCodesignError::StapleUnsupportedPath(
327                path.to_path_buf(),
328            )),
329        }
330    }
331}