apple_codesign/
notarization.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/*! Apple notarization functionality.
6
7Notarization works by uploading a payload to Apple servers and waiting for
8Apple to scan the submitted content. If Apple is appeased by your submission,
9they issue a notarization ticket, which can be downloaded and *stapled* (just
10a fancy word for *attached*) to the content you upload.
11
12This module implements functionality for uploading content to Apple
13and waiting on the availability of a notarization ticket.
14*/
15
16use {
17    crate::{reader::PathType, AppleCodesignError},
18    app_store_connect::{notary_api, AppStoreConnectClient, ConnectTokenEncoder, UnifiedApiKey},
19    apple_bundles::DirectoryBundle,
20    aws_sdk_s3::config::{Credentials, Region},
21    aws_smithy_types::byte_stream::ByteStream,
22    log::warn,
23    sha2::Digest,
24    std::{
25        fs::File,
26        io::{Read, Seek, SeekFrom, Write},
27        path::{Path, PathBuf},
28        time::Duration,
29    },
30};
31
32fn digest<H: Digest, R: Read>(reader: &mut R) -> Result<(u64, Vec<u8>), AppleCodesignError> {
33    let mut hasher = H::new();
34    let mut size = 0;
35
36    loop {
37        let mut buffer = [0u8; 16384];
38        let count = reader.read(&mut buffer)?;
39
40        size += count as u64;
41        hasher.update(&buffer[0..count]);
42
43        if count < buffer.len() {
44            break;
45        }
46    }
47
48    Ok((size, hasher.finalize().to_vec()))
49}
50
51fn digest_sha256<R: Read>(reader: &mut R) -> Result<(u64, Vec<u8>), AppleCodesignError> {
52    digest::<sha2::Sha256, R>(reader)
53}
54
55/// Produce zip file data from a [DirectoryBundle].
56///
57/// The built zip file will contain all the files from the bundle under a directory
58/// tree having the bundle name. e.g. if you pass `MyApp.app`, the zip will have
59/// files like `MyApp.app/Contents/Info.plist`.
60pub fn bundle_to_zip(bundle: &DirectoryBundle) -> Result<Vec<u8>, AppleCodesignError> {
61    let mut zf = zip::ZipWriter::new(std::io::Cursor::new(vec![]));
62
63    let mut symlinks = vec![];
64
65    for file in bundle
66        .files(true)
67        .map_err(AppleCodesignError::DirectoryBundle)?
68    {
69        let entry = file
70            .as_file_entry()
71            .map_err(AppleCodesignError::DirectoryBundle)?;
72
73        let name =
74            format!("{}/{}", bundle.name(), file.relative_path().display()).replace('\\', "/");
75
76        let options = zip::write::SimpleFileOptions::default();
77
78        let options = if entry.link_target().is_some() {
79            symlinks.push(name.as_bytes().to_vec());
80            options.compression_method(zip::CompressionMethod::Stored)
81        } else if entry.is_executable() {
82            options.unix_permissions(0o755)
83        } else {
84            options.unix_permissions(0o644)
85        };
86
87        zf.start_file(name, options)?;
88
89        if let Some(target) = entry.link_target() {
90            zf.write_all(target.to_string_lossy().replace('\\', "/").as_bytes())?;
91        } else {
92            zf.write_all(&entry.resolve_content()?)?;
93        }
94    }
95
96    let mut writer = zf.finish()?;
97
98    // Current versions of the zip crate don't support writing symlinks. We
99    // added that support upstream but it isn't released yet.
100    // TODO remove this hackery once we upgrade the zip crate.
101    let eocd = zip_structs::zip_eocd::ZipEOCD::from_reader(&mut writer)?;
102    let cd_entries =
103        zip_structs::zip_central_directory::ZipCDEntry::all_from_eocd(&mut writer, &eocd)?;
104
105    for mut cd in cd_entries {
106        if symlinks.contains(&cd.file_name_raw) {
107            cd.external_file_attributes =
108                (0o120777 << 16) | (cd.external_file_attributes & 0x0000ffff);
109            writer.seek(SeekFrom::Start(cd.starting_position_with_signature))?;
110            cd.write(&mut writer)?;
111        }
112    }
113
114    Ok(writer.into_inner())
115}
116
117/// Represents the result of a notarization upload.
118pub enum NotarizationUpload {
119    /// We performed the upload and only have the upload ID / UUID for it.
120    ///
121    /// (We probably didn't wait for the upload to finish processing.)
122    UploadId(String),
123
124    /// We performed an upload and have upload state from the server.
125    NotaryResponse(notary_api::SubmissionResponse),
126}
127
128enum UploadKind {
129    Data(Vec<u8>),
130    Path(PathBuf),
131}
132
133/// An entity for performing notarizations.
134///
135/// Notarization works by uploading content to Apple, waiting for Apple to inspect
136/// and react to that upload, then downloading a notarization "ticket" from Apple
137/// and incorporating it into the entity being signed.
138#[derive(Clone)]
139pub struct Notarizer {
140    token_encoder: ConnectTokenEncoder,
141
142    /// How long to wait between polling the server for upload status.
143    wait_poll_interval: Duration,
144}
145
146impl Notarizer {
147    /// Construct a new instance.
148    fn new(token_encoder: ConnectTokenEncoder) -> Self {
149        Self {
150            token_encoder,
151            wait_poll_interval: Duration::from_secs(3),
152        }
153    }
154
155    /// Construct an instance from an API issuer ID and API key.
156    pub fn from_api_key_id(
157        issuer_id: impl ToString,
158        key_id: impl ToString,
159    ) -> Result<Self, AppleCodesignError> {
160        Ok(Self::new(ConnectTokenEncoder::from_api_key_id(
161            key_id.to_string(),
162            issuer_id.to_string(),
163        )?))
164    }
165
166    /// Construct an instance from a file containing a JSON encoded API key.
167    pub fn from_api_key(path: &Path) -> Result<Self, AppleCodesignError> {
168        Ok(Self::new(UnifiedApiKey::from_json_path(path)?.try_into()?))
169    }
170
171    /// Attempt to notarize an asset defined by a filesystem path.
172    ///
173    /// The type of path is sniffed out and the appropriate notarization routine is called.
174    pub fn notarize_path(
175        &self,
176        path: &Path,
177        wait_limit: Option<Duration>,
178    ) -> Result<NotarizationUpload, AppleCodesignError> {
179        match PathType::from_path(path)? {
180            PathType::Bundle => {
181                let bundle = DirectoryBundle::new_from_path(path)
182                    .map_err(AppleCodesignError::DirectoryBundle)?;
183                self.notarize_bundle(&bundle, wait_limit)
184            }
185            PathType::Xar => self.notarize_flat_package(path, wait_limit),
186            PathType::Zip => self.notarize_flat_package(path, wait_limit),
187            PathType::Dmg => self.notarize_dmg(path, wait_limit),
188            PathType::MachO | PathType::Other => Err(AppleCodesignError::NotarizeUnsupportedPath(
189                path.to_path_buf(),
190            )),
191        }
192    }
193
194    /// Attempt to notarize an on-disk bundle.
195    ///
196    /// If `wait_limit` is provided, we will wait for the upload to finish processing.
197    /// Otherwise, this returns as soon as the upload is performed.
198    pub fn notarize_bundle(
199        &self,
200        bundle: &DirectoryBundle,
201        wait_limit: Option<Duration>,
202    ) -> Result<NotarizationUpload, AppleCodesignError> {
203        let zipfile = bundle_to_zip(bundle)?;
204        let digest = sha2::Sha256::digest(&zipfile);
205
206        let submission = self.create_submission(&digest, &format!("{}.zip", bundle.name()))?;
207
208        self.upload_s3_and_maybe_wait(submission, UploadKind::Data(zipfile), wait_limit)
209    }
210
211    /// Attempt to notarize a DMG file.
212    pub fn notarize_dmg(
213        &self,
214        dmg_path: &Path,
215        wait_limit: Option<Duration>,
216    ) -> Result<NotarizationUpload, AppleCodesignError> {
217        let filename = dmg_path
218            .file_name()
219            .map(|x| x.to_string_lossy().to_string())
220            .unwrap_or_else(|| "dmg".to_string());
221
222        let (_, digest) = digest_sha256(&mut File::open(dmg_path)?)?;
223
224        let submission = self.create_submission(&digest, &filename)?;
225
226        self.upload_s3_and_maybe_wait(
227            submission,
228            UploadKind::Path(dmg_path.to_path_buf()),
229            wait_limit,
230        )
231    }
232
233    /// Attempt to notarize a flat package (`.pkg`) installer or a .zip file.
234    pub fn notarize_flat_package(
235        &self,
236        pkg_path: &Path,
237        wait_limit: Option<Duration>,
238    ) -> Result<NotarizationUpload, AppleCodesignError> {
239        let filename = pkg_path
240            .file_name()
241            .map(|x| x.to_string_lossy().to_string())
242            .unwrap_or_else(|| "pkg".to_string());
243
244        let (_, digest) = digest_sha256(&mut File::open(pkg_path)?)?;
245
246        let submission = self.create_submission(&digest, &filename)?;
247
248        self.upload_s3_and_maybe_wait(
249            submission,
250            UploadKind::Path(pkg_path.to_path_buf()),
251            wait_limit,
252        )
253    }
254}
255
256impl Notarizer {
257    fn client(&self) -> Result<AppStoreConnectClient, AppleCodesignError> {
258        Ok(AppStoreConnectClient::new(self.token_encoder.clone())?)
259    }
260
261    /// Tell the notary service to expect an upload to S3.
262    fn create_submission(
263        &self,
264        raw_digest: &[u8],
265        name: &str,
266    ) -> Result<notary_api::NewSubmissionResponse, AppleCodesignError> {
267        let client = self.client()?;
268
269        let digest = hex::encode(raw_digest);
270        warn!(
271            "creating Notary API submission for {} (sha256: {})",
272            name, digest
273        );
274
275        let submission = client.create_submission(&digest, name)?;
276
277        warn!("created submission ID: {}", submission.data.id);
278
279        Ok(submission)
280    }
281
282    fn upload_s3_package(
283        &self,
284        submission: &notary_api::NewSubmissionResponse,
285        upload: UploadKind,
286    ) -> Result<(), AppleCodesignError> {
287        let rt = tokio::runtime::Builder::new_current_thread()
288            .enable_all()
289            .build()?;
290        let bytestream = match upload {
291            UploadKind::Data(data) => ByteStream::from(data),
292            UploadKind::Path(path) => rt.block_on(ByteStream::from_path(path))?,
293        };
294
295        // upload using s3 api
296        warn!("resolving AWS S3 configuration from Apple-provided credentials");
297        let config = rt.block_on(
298            aws_config::defaults(aws_config::BehaviorVersion::latest())
299                .credentials_provider(Credentials::new(
300                    submission.data.attributes.aws_access_key_id.clone(),
301                    submission.data.attributes.aws_secret_access_key.clone(),
302                    Some(submission.data.attributes.aws_session_token.clone()),
303                    None,
304                    "apple-codesign",
305                ))
306                // The region is not given anywhere in the Apple documentation. From
307                // manually testing all available regions, it appears to be
308                // us-west-2.
309                .region(Region::new("us-west-2"))
310                .load(),
311        );
312
313        let s3_client = aws_sdk_s3::Client::new(&config);
314
315        warn!(
316            "uploading asset to s3://{}/{}",
317            submission.data.attributes.bucket, submission.data.attributes.object
318        );
319        warn!("(you may see additional log output from S3 client)");
320
321        // TODO: Support multi-part upload.
322        // Unfortunately, aws-sdk-s3 does not have a simple upload_file helper
323        // like it does in other languages.
324        // See https://github.com/awslabs/aws-sdk-rust/issues/494
325        let fut = s3_client
326            .put_object()
327            .bucket(submission.data.attributes.bucket.clone())
328            .key(submission.data.attributes.object.clone())
329            .body(bytestream)
330            .send();
331
332        rt.block_on(fut).map_err(|e| {
333            AppleCodesignError::AwsS3PutObject(
334                aws_smithy_types::error::display::DisplayErrorContext(e),
335            )
336        })?;
337
338        warn!("S3 upload completed successfully");
339
340        Ok(())
341    }
342
343    fn upload_s3_and_maybe_wait(
344        &self,
345        submission: notary_api::NewSubmissionResponse,
346        upload_data: UploadKind,
347        wait_limit: Option<Duration>,
348    ) -> Result<NotarizationUpload, AppleCodesignError> {
349        self.upload_s3_package(&submission, upload_data)?;
350
351        let status = if let Some(wait_limit) = wait_limit {
352            self.wait_on_notarization_and_fetch_log(&submission.data.id, wait_limit)?
353        } else {
354            return Ok(NotarizationUpload::UploadId(submission.data.id));
355        };
356
357        // Make sure notarization was successful.
358        let status = status.into_result()?;
359
360        Ok(NotarizationUpload::NotaryResponse(status))
361    }
362
363    pub fn get_submission(
364        &self,
365        submission_id: &str,
366    ) -> Result<notary_api::SubmissionResponse, AppleCodesignError> {
367        Ok(self.client()?.get_submission(submission_id)?)
368    }
369
370    pub fn wait_on_notarization(
371        &self,
372        submission_id: &str,
373        wait_limit: Duration,
374    ) -> Result<notary_api::SubmissionResponse, AppleCodesignError> {
375        warn!(
376            "waiting up to {}s for package upload {} to finish processing",
377            wait_limit.as_secs(),
378            submission_id
379        );
380
381        let start_time = std::time::Instant::now();
382
383        loop {
384            let status = self.get_submission(submission_id)?;
385
386            let elapsed = start_time.elapsed();
387
388            warn!(
389                "poll state after {}s: {:?}",
390                elapsed.as_secs(),
391                status.data.attributes.status
392            );
393
394            if status.data.attributes.status != notary_api::SubmissionResponseStatus::InProgress {
395                warn!("Notary API Server has finished processing the uploaded asset");
396
397                return Ok(status);
398            }
399
400            if elapsed >= wait_limit {
401                warn!("reached wait limit after {}s", elapsed.as_secs());
402                return Err(AppleCodesignError::NotarizeWaitLimitReached);
403            }
404
405            std::thread::sleep(self.wait_poll_interval);
406        }
407    }
408
409    /// Obtain the processing log from an upload.
410    pub fn fetch_notarization_log(
411        &self,
412        submission_id: &str,
413    ) -> Result<serde_json::Value, AppleCodesignError> {
414        warn!("fetching notarization log for {}", submission_id);
415        Ok(self.client()?.get_submission_log(submission_id)?)
416    }
417
418    /// Waits on an app store package upload and fetches and logs the upload log.
419    ///
420    /// This is just a convenience around [Self::wait_on_app_store_package_upload()] and
421    /// [Self::fetch_upload_log()].
422    pub fn wait_on_notarization_and_fetch_log(
423        &self,
424        submission_id: &str,
425        wait_limit: Duration,
426    ) -> Result<notary_api::SubmissionResponse, AppleCodesignError> {
427        let status = self.wait_on_notarization(submission_id, wait_limit)?;
428
429        let log = self.fetch_notarization_log(submission_id)?;
430
431        for line in serde_json::to_string_pretty(&log)?.lines() {
432            warn!("notary log> {}", line);
433        }
434
435        Ok(status)
436    }
437
438    pub fn list_submissions(
439        &self,
440    ) -> Result<notary_api::ListSubmissionResponse, AppleCodesignError> {
441        Ok(self.client()?.list_submissions()?)
442    }
443}