tauri_macos_sign/
lib.rs

1// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use std::{
6  ffi::{OsStr, OsString},
7  path::{Path, PathBuf},
8  process::{Command, ExitStatus},
9};
10
11use serde::Deserialize;
12
13pub mod certificate;
14mod keychain;
15mod provisioning_profile;
16
17pub use keychain::{Keychain, Team};
18pub use provisioning_profile::ProvisioningProfile;
19
20#[derive(Debug, thiserror::Error)]
21pub enum Error {
22  #[error("failed to create temp directory: {0}")]
23  TempDir(std::io::Error),
24  #[error("failed to resolve home dir")]
25  ResolveHomeDir,
26  #[error("failed to resolve signing identity")]
27  ResolveSigningIdentity,
28  #[error("failed to decode provisioning profile")]
29  FailedToDecodeProvisioningProfile,
30  #[error("could not find provisioning profile UUID")]
31  FailedToFindProvisioningProfileUuid,
32  #[error("{context} {path}: {error}")]
33  Plist {
34    context: &'static str,
35    path: PathBuf,
36    error: plist::Error,
37  },
38  #[error("failed to upload app to Apple's notarization servers: {error}")]
39  FailedToUploadApp { error: std::io::Error },
40  #[error("failed to notarize app: {0}")]
41  Notarize(String),
42  #[error("failed to parse notarytool output as JSON: {output}")]
43  ParseNotarytoolOutput { output: String },
44  #[error("failed to run command {command}: {error}")]
45  CommandFailed {
46    command: String,
47    error: std::io::Error,
48  },
49  #[error("{context} {path}: {error}")]
50  Fs {
51    context: &'static str,
52    path: PathBuf,
53    error: std::io::Error,
54  },
55  #[error("failed to parse X509 certificate: {error}")]
56  X509Certificate {
57    error: x509_certificate::X509CertificateError,
58  },
59  #[error("failed to create PFX from self signed certificate")]
60  FailedToCreatePFX,
61  #[error("failed to create self signed certificate: {error}")]
62  FailedToCreateSelfSignedCertificate {
63    error: Box<apple_codesign::AppleCodesignError>,
64  },
65  #[error("failed to encode DER: {error}")]
66  FailedToEncodeDER { error: std::io::Error },
67  #[error("certificate missing common name")]
68  CertificateMissingCommonName,
69  #[error("certificate missing organization unit for common name {common_name}")]
70  CertificateMissingOrganizationUnit { common_name: String },
71}
72
73pub type Result<T> = std::result::Result<T, Error>;
74
75trait CommandExt {
76  // The `pipe` function sets the stdout and stderr to properly
77  // show the command output in the Node.js wrapper.
78  fn piped(&mut self) -> std::io::Result<ExitStatus>;
79}
80
81impl CommandExt for Command {
82  fn piped(&mut self) -> std::io::Result<ExitStatus> {
83    self.stdin(os_pipe::dup_stdin()?);
84    self.stdout(os_pipe::dup_stdout()?);
85    self.stderr(os_pipe::dup_stderr()?);
86    let program = self.get_program().to_string_lossy().into_owned();
87    log::debug!(action = "Running"; "Command `{} {}`", program, self.get_args().map(|arg| arg.to_string_lossy()).fold(String::new(), |acc, arg| format!("{acc} {arg}")));
88
89    self.status()
90  }
91}
92
93pub enum ApiKey {
94  Path(PathBuf),
95  Raw(Vec<u8>),
96}
97
98pub enum AppleNotarizationCredentials {
99  AppleId {
100    apple_id: OsString,
101    password: OsString,
102    team_id: OsString,
103  },
104  ApiKey {
105    issuer: OsString,
106    key_id: OsString,
107    key: ApiKey,
108  },
109}
110
111#[derive(Deserialize)]
112struct NotarytoolSubmitOutput {
113  id: String,
114  #[serde(default)]
115  status: Option<String>,
116  message: String,
117}
118
119pub fn notarize(
120  keychain: &Keychain,
121  app_bundle_path: &Path,
122  auth: &AppleNotarizationCredentials,
123) -> Result<()> {
124  notarize_inner(keychain, app_bundle_path, auth, true)
125}
126
127pub fn notarize_without_stapling(
128  keychain: &Keychain,
129  app_bundle_path: &Path,
130  auth: &AppleNotarizationCredentials,
131) -> Result<()> {
132  notarize_inner(keychain, app_bundle_path, auth, false)
133}
134
135fn notarize_inner(
136  keychain: &Keychain,
137  app_bundle_path: &Path,
138  auth: &AppleNotarizationCredentials,
139  wait: bool,
140) -> Result<()> {
141  let bundle_stem = app_bundle_path
142    .file_stem()
143    .expect("failed to get bundle filename");
144
145  let tmp_dir = tempfile::tempdir().map_err(Error::TempDir)?;
146  let zip_path = tmp_dir
147    .path()
148    .join(format!("{}.zip", bundle_stem.to_string_lossy()));
149  let zip_args = vec![
150    "-c",
151    "-k",
152    "--keepParent",
153    "--sequesterRsrc",
154    app_bundle_path
155      .to_str()
156      .expect("failed to convert bundle_path to string"),
157    zip_path
158      .to_str()
159      .expect("failed to convert zip_path to string"),
160  ];
161
162  // use ditto to create a PKZip almost identical to Finder
163  // this remove almost 99% of false alarm in notarization
164  assert_command(
165    Command::new("ditto").args(zip_args).piped(),
166    "failed to zip app with ditto",
167  )
168  .map_err(|error| Error::CommandFailed {
169    command: "ditto".to_string(),
170    error,
171  })?;
172
173  // sign the zip file
174  keychain.sign(&zip_path, None, false)?;
175
176  let mut notarize_args = vec![
177    "notarytool",
178    "submit",
179    zip_path
180      .to_str()
181      .expect("failed to convert zip_path to string"),
182    "--output-format",
183    "json",
184  ];
185  if wait {
186    notarize_args.push("--wait");
187  }
188  let notarize_args = notarize_args;
189
190  println!("Notarizing {}", app_bundle_path.display());
191
192  let output = Command::new("xcrun")
193    .args(notarize_args)
194    .notarytool_args(auth, tmp_dir.path())?
195    .output()
196    .map_err(|error| Error::FailedToUploadApp { error })?;
197
198  if !output.status.success() {
199    return Err(Error::Notarize(
200      String::from_utf8_lossy(&output.stderr).into_owned(),
201    ));
202  }
203
204  let output_str = String::from_utf8_lossy(&output.stdout);
205  if let Ok(submit_output) = serde_json::from_str::<NotarytoolSubmitOutput>(&output_str) {
206    let log_message = format!(
207      "{} with status {} for id {} ({})",
208      if wait { "Finished" } else { "Submitted" },
209      submit_output.status.as_deref().unwrap_or("Pending"),
210      submit_output.id,
211      submit_output.message
212    );
213    // status is empty when not waiting for the notarization to finish
214    if submit_output.status.map_or(!wait, |s| s == "Accepted") {
215      println!("Notarizing {log_message}");
216
217      if wait {
218        println!("Stapling app...");
219        staple_app(app_bundle_path.to_path_buf())?;
220      } else {
221        println!("Not waiting for notarization to finish.");
222        println!("You can use `xcrun notarytool log` to check the notarization progress.");
223        println!(
224          "When it's done you can optionally staple your app via `xcrun stapler staple {}`",
225          app_bundle_path.display()
226        );
227      }
228
229      Ok(())
230    } else if let Ok(output) = Command::new("xcrun")
231      .args(["notarytool", "log"])
232      .arg(&submit_output.id)
233      .notarytool_args(auth, tmp_dir.path())?
234      .output()
235    {
236      Err(Error::Notarize(format!(
237        "{log_message}\nLog:\n{}",
238        String::from_utf8_lossy(&output.stdout)
239      )))
240    } else {
241      Err(Error::Notarize(log_message))
242    }
243  } else {
244    Err(Error::ParseNotarytoolOutput {
245      output: output_str.into_owned(),
246    })
247  }
248}
249
250fn staple_app(mut app_bundle_path: PathBuf) -> Result<()> {
251  let app_bundle_path_clone = app_bundle_path.clone();
252  let filename = app_bundle_path_clone
253    .file_name()
254    .expect("failed to get bundle filename")
255    .to_str()
256    .expect("failed to convert bundle filename to string");
257
258  app_bundle_path.pop();
259
260  Command::new("xcrun")
261    .args(vec!["stapler", "staple", "-v", filename])
262    .current_dir(app_bundle_path)
263    .output()
264    .map_err(|error| Error::CommandFailed {
265      command: "xcrun stapler staple".to_string(),
266      error,
267    })?;
268
269  Ok(())
270}
271
272pub trait NotarytoolCmdExt {
273  fn notarytool_args(
274    &mut self,
275    auth: &AppleNotarizationCredentials,
276    temp_dir: &Path,
277  ) -> Result<&mut Self>;
278}
279
280impl NotarytoolCmdExt for Command {
281  fn notarytool_args(
282    &mut self,
283    auth: &AppleNotarizationCredentials,
284    temp_dir: &Path,
285  ) -> Result<&mut Self> {
286    match auth {
287      AppleNotarizationCredentials::AppleId {
288        apple_id,
289        password,
290        team_id,
291      } => Ok(
292        self
293          .arg("--apple-id")
294          .arg(apple_id)
295          .arg("--password")
296          .arg(password)
297          .arg("--team-id")
298          .arg(team_id),
299      ),
300      AppleNotarizationCredentials::ApiKey {
301        key,
302        key_id,
303        issuer,
304      } => {
305        let key_path = match key {
306          ApiKey::Raw(k) => {
307            let key_path = temp_dir.join("AuthKey.p8");
308            std::fs::write(&key_path, k).map_err(|error| Error::Fs {
309              context: "failed to write notarization API key to temp file",
310              path: key_path.clone(),
311              error,
312            })?;
313            key_path
314          }
315          ApiKey::Path(p) => p.to_owned(),
316        };
317
318        Ok(
319          self
320            .arg("--key-id")
321            .arg(key_id)
322            .arg("--key")
323            .arg(key_path)
324            .arg("--issuer")
325            .arg(issuer),
326        )
327      }
328    }
329  }
330}
331
332fn decode_base64(base64: &OsStr, out_path: &Path) -> Result<()> {
333  let tmp_dir = tempfile::tempdir().map_err(Error::TempDir)?;
334
335  let src_path = tmp_dir.path().join("src");
336  let base64 = base64
337    .to_str()
338    .expect("failed to convert base64 to string")
339    .as_bytes();
340
341  // as base64 contain whitespace decoding may be broken
342  // https://github.com/marshallpierce/rust-base64/issues/105
343  // we'll use builtin base64 command from the OS
344  std::fs::write(&src_path, base64).map_err(|error| Error::Fs {
345    context: "failed to write base64 to temp file",
346    path: src_path.clone(),
347    error,
348  })?;
349
350  assert_command(
351    std::process::Command::new("base64")
352      .arg("--decode")
353      .arg("-i")
354      .arg(&src_path)
355      .arg("-o")
356      .arg(out_path)
357      .piped(),
358    "failed to decode certificate",
359  )
360  .map_err(|error| Error::CommandFailed {
361    command: "base64 --decode".to_string(),
362    error,
363  })?;
364
365  Ok(())
366}
367
368fn assert_command(
369  response: std::result::Result<std::process::ExitStatus, std::io::Error>,
370  error_message: &str,
371) -> std::io::Result<()> {
372  let status =
373    response.map_err(|e| std::io::Error::new(e.kind(), format!("{error_message}: {e}")))?;
374  if !status.success() {
375    Err(std::io::Error::other(error_message))
376  } else {
377    Ok(())
378  }
379}