Skip to main content

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