apple_codesign/
signing.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//! High level signing primitives.
6
7use {
8    crate::{
9        bundle_signing::BundleSigner,
10        dmg::DmgSigner,
11        error::AppleCodesignError,
12        macho_signing::{write_macho_file, MachOSigner},
13        reader::PathType,
14        signing_settings::{SettingsScope, SigningSettings},
15    },
16    apple_xar::{reader::XarReader, signing::XarSigner},
17    log::{info, warn},
18    std::{fs::File, path::Path},
19};
20
21/// An entity for performing signing that is able to handle all supported target types.
22pub struct UnifiedSigner<'key> {
23    settings: SigningSettings<'key>,
24}
25
26impl<'key> UnifiedSigner<'key> {
27    /// Construct a new instance bound to a [SigningSettings].
28    pub fn new(settings: SigningSettings<'key>) -> Self {
29        Self { settings }
30    }
31
32    /// Signs `input_path` and writes the signed output to `output_path`.
33    pub fn sign_path(
34        &self,
35        input_path: impl AsRef<Path>,
36        output_path: impl AsRef<Path>,
37    ) -> Result<(), AppleCodesignError> {
38        let input_path = input_path.as_ref();
39
40        match PathType::from_path(input_path)? {
41            PathType::Bundle => self.sign_bundle(input_path, output_path),
42            PathType::Dmg => self.sign_dmg(input_path, output_path),
43            PathType::MachO => self.sign_macho(input_path, output_path),
44            PathType::Xar => self.sign_xar(input_path, output_path),
45            PathType::Zip | PathType::Other => Err(AppleCodesignError::UnrecognizedPathType),
46        }
47    }
48
49    /// Sign a filesystem path in place.
50    ///
51    /// This is just a convenience wrapper for [Self::sign_path()] with the same path passed
52    /// to both the input and output path.
53    pub fn sign_path_in_place(&self, path: impl AsRef<Path>) -> Result<(), AppleCodesignError> {
54        let path = path.as_ref();
55
56        self.sign_path(path, path)
57    }
58
59    /// Sign a Mach-O binary.
60    pub fn sign_macho(
61        &self,
62        input_path: impl AsRef<Path>,
63        output_path: impl AsRef<Path>,
64    ) -> Result<(), AppleCodesignError> {
65        let input_path = input_path.as_ref();
66        let output_path = output_path.as_ref();
67
68        warn!("signing {} as a Mach-O binary", input_path.display());
69        let macho_data = std::fs::read(input_path)?;
70
71        let mut settings = self.settings.clone();
72
73        settings.import_settings_from_macho(&macho_data)?;
74
75        if settings.binary_identifier(SettingsScope::Main).is_none() {
76            let identifier = path_identifier(input_path)?;
77
78            warn!("setting binary identifier to {}", identifier);
79            settings.set_binary_identifier(SettingsScope::Main, identifier);
80        }
81
82        warn!("parsing Mach-O");
83        let signer = MachOSigner::new(&macho_data)?;
84
85        let mut macho_data = vec![];
86        signer.write_signed_binary(&settings, &mut macho_data)?;
87        warn!("writing Mach-O to {}", output_path.display());
88        write_macho_file(input_path, output_path, &macho_data)?;
89
90        Ok(())
91    }
92
93    /// Sign a `.dmg` file.
94    pub fn sign_dmg(
95        &self,
96        input_path: impl AsRef<Path>,
97        output_path: impl AsRef<Path>,
98    ) -> Result<(), AppleCodesignError> {
99        let input_path = input_path.as_ref();
100        let output_path = output_path.as_ref();
101
102        warn!("signing {} as a DMG", input_path.display());
103
104        // There must be a binary identifier on the DMG. So try to derive one
105        // from the filename if one isn't present in the settings.
106        let mut settings = self.settings.clone();
107
108        if settings.binary_identifier(SettingsScope::Main).is_none() {
109            let file_name = input_path
110                .file_stem()
111                .ok_or_else(|| {
112                    AppleCodesignError::CliGeneralError("unable to resolve file name of DMG".into())
113                })?
114                .to_string_lossy();
115
116            warn!(
117                "setting binary identifier to {} (derived from file name)",
118                file_name
119            );
120            settings.set_binary_identifier(SettingsScope::Main, file_name);
121        }
122
123        // The DMG signer signs in place because it needs a `File` handle. So if
124        // the output path is different, copy the DMG first.
125
126        // This is not robust same file detection.
127        if input_path != output_path {
128            info!(
129                "copying {} to {} in preparation for signing",
130                input_path.display(),
131                output_path.display()
132            );
133            if let Some(parent) = output_path.parent() {
134                std::fs::create_dir_all(parent)?;
135            }
136
137            std::fs::copy(input_path, output_path)?;
138        }
139
140        let signer = DmgSigner::default();
141        let mut fh = std::fs::File::options()
142            .read(true)
143            .write(true)
144            .open(output_path)?;
145        signer.sign_file(&settings, &mut fh)?;
146
147        Ok(())
148    }
149
150    /// Sign a bundle.
151    pub fn sign_bundle(
152        &self,
153        input_path: impl AsRef<Path>,
154        output_path: impl AsRef<Path>,
155    ) -> Result<(), AppleCodesignError> {
156        let input_path = input_path.as_ref();
157        warn!("signing bundle at {}", input_path.display());
158
159        let mut signer = BundleSigner::new_from_path(input_path)?;
160        signer.collect_nested_bundles()?;
161        signer.write_signed_bundle(output_path, &self.settings)?;
162
163        Ok(())
164    }
165
166    pub fn sign_xar(
167        &self,
168        input_path: impl AsRef<Path>,
169        output_path: impl AsRef<Path>,
170    ) -> Result<(), AppleCodesignError> {
171        let input_path = input_path.as_ref();
172        let output_path = output_path.as_ref();
173
174        // The XAR can get corrupted if we sign into place. So we always go through a temporary
175        // file. We could potentially avoid the overhead if we're not signing in place...
176
177        let output_path_temp =
178            output_path.with_file_name(if let Some(file_name) = output_path.file_name() {
179                file_name.to_string_lossy().to_string() + ".tmp"
180            } else {
181                "xar.tmp".to_string()
182            });
183
184        warn!(
185            "signing XAR pkg installer at {} to {}",
186            input_path.display(),
187            output_path_temp.display()
188        );
189
190        let (signing_key, signing_cert) = self
191            .settings
192            .signing_key()
193            .ok_or(AppleCodesignError::XarNoAdhoc)?;
194
195        {
196            let reader = XarReader::new(File::open(input_path)?)?;
197            let mut signer = XarSigner::new(reader);
198
199            let mut fh = File::create(&output_path_temp)?;
200            signer.sign(
201                &mut fh,
202                signing_key,
203                signing_cert,
204                self.settings.time_stamp_url(),
205                self.settings.certificate_chain().iter().cloned(),
206            )?;
207        }
208
209        if output_path.exists() {
210            warn!("removing existing {}", output_path.display());
211            std::fs::remove_file(output_path)?;
212        }
213
214        warn!(
215            "renaming {} -> {}",
216            output_path_temp.display(),
217            output_path.display()
218        );
219        std::fs::rename(&output_path_temp, output_path)?;
220
221        Ok(())
222    }
223}
224
225pub fn path_identifier(path: impl AsRef<Path>) -> Result<String, AppleCodesignError> {
226    let path = path.as_ref();
227
228    // We only care about the file name.
229    let file_name = path
230        .file_name()
231        .ok_or_else(|| {
232            AppleCodesignError::PathIdentifier(format!("path {} lacks a file name", path.display()))
233        })?
234        .to_string_lossy()
235        .to_string();
236
237    // Remove the final file extension unless it is numeric.
238    let id = if let Some((prefix, extension)) = file_name.rsplit_once('.') {
239        if extension.chars().all(|c| c.is_ascii_digit()) {
240            file_name.as_str()
241        } else {
242            prefix
243        }
244    } else {
245        file_name.as_str()
246    };
247
248    let is_digit_or_dot = |c: char| c == '.' || c.is_ascii_digit();
249
250    // If begins with digit or dot, use as is, handling empty string special
251    // case.
252    let id = match id.chars().next() {
253        Some(first) => {
254            if is_digit_or_dot(first) {
255                return Ok(id.to_string());
256            } else {
257                id
258            }
259        }
260        None => {
261            return Ok(id.to_string());
262        }
263    };
264
265    // Strip all components having numeric *suffixes* except the first
266    // one. This doesn't strip extension components but *suffixes*. So
267    // e.g. libFoo1.2.3 -> libFoo1. Logically, we strip trailing digits
268    // + dot after the first dot preceded by digits.
269
270    let prefix = id.trim_end_matches(is_digit_or_dot);
271    let stripped = &id[prefix.len()..];
272
273    if stripped.is_empty() {
274        Ok(id.to_string())
275    } else {
276        // If the next character is a dot, add it back in.
277        let (prefix, stripped) = if matches!(stripped.chars().next(), Some('.')) {
278            (&id[0..prefix.len() + 1], &stripped[1..])
279        } else {
280            (prefix, stripped)
281        };
282
283        // Add back in any leading digits.
284
285        let id = prefix
286            .chars()
287            .chain(stripped.chars().take_while(|c| c.is_ascii_digit()))
288            .collect::<String>();
289
290        Ok(id)
291    }
292}
293
294#[cfg(test)]
295mod test {
296    use super::*;
297    #[test]
298    fn path_identifier_normalization() {
299        assert_eq!(path_identifier("foo").unwrap(), "foo");
300        assert_eq!(path_identifier("foo.dylib").unwrap(), "foo");
301        assert_eq!(path_identifier("/etc/foo.dylib").unwrap(), "foo");
302        assert_eq!(path_identifier("/etc/foo").unwrap(), "foo");
303
304        // Starts with digit or dot is preserved module final extension.
305        assert_eq!(path_identifier(".foo").unwrap(), "");
306        assert_eq!(path_identifier("123").unwrap(), "123");
307        assert_eq!(path_identifier(".foo.dylib").unwrap(), ".foo");
308        assert_eq!(path_identifier("123.dylib").unwrap(), "123");
309        assert_eq!(path_identifier("123.42").unwrap(), "123.42");
310
311        // Digit final extension preserved.
312
313        assert_eq!(path_identifier("foo1").unwrap(), "foo1");
314        assert_eq!(path_identifier("foo1.dylib").unwrap(), "foo1");
315        assert_eq!(path_identifier("foo1.2.dylib").unwrap(), "foo1");
316        assert_eq!(path_identifier("foo1.2").unwrap(), "foo1");
317        assert_eq!(path_identifier("foo1.2.3.4.dylib").unwrap(), "foo1");
318        assert_eq!(path_identifier("foo.1").unwrap(), "foo.1");
319        assert_eq!(path_identifier("foo.1.2.3").unwrap(), "foo.1");
320        assert_eq!(path_identifier("foo.1.2.dylib").unwrap(), "foo.1");
321        assert_eq!(path_identifier("foo.1.dylib").unwrap(), "foo.1");
322    }
323}