1use {
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
55pub 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 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
117pub enum NotarizationUpload {
119 UploadId(String),
123
124 NotaryResponse(notary_api::SubmissionResponse),
126}
127
128enum UploadKind {
129 Data(Vec<u8>),
130 Path(PathBuf),
131}
132
133#[derive(Clone)]
139pub struct Notarizer {
140 token_encoder: ConnectTokenEncoder,
141
142 wait_poll_interval: Duration,
144}
145
146impl Notarizer {
147 fn new(token_encoder: ConnectTokenEncoder) -> Self {
149 Self {
150 token_encoder,
151 wait_poll_interval: Duration::from_secs(3),
152 }
153 }
154
155 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 pub fn from_api_key(path: &Path) -> Result<Self, AppleCodesignError> {
168 Ok(Self::new(UnifiedApiKey::from_json_path(path)?.try_into()?))
169 }
170
171 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 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 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 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 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: ¬ary_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 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 .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 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 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 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 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}