blob: f476a5f67a45b1260e7cfe86956d7553a98a160c [file] [log] [blame]
Xilai Zhang098a4b62022-07-15 13:56:04 -07001// Copyright 2019 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
Xilai Zhang46c6c542022-08-01 10:35:03 -07006import 'dart:io' as io;
7
Xilai Zhang098a4b62022-07-15 13:56:04 -07008import 'package:file/file.dart';
9import 'package:process/process.dart';
Xilai Zhang46c6c542022-08-01 10:35:03 -070010
Xilai Zhang177ac762022-09-22 13:41:59 -070011import 'google_cloud_storage.dart';
Xilai Zhang46c6c542022-08-01 10:35:03 -070012import 'log.dart';
13import 'utils.dart';
Xilai Zhang098a4b62022-07-15 13:56:04 -070014
15/// Statuses reported by Apple's Notary Server.
16///
17/// See more:
18/// * https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow
19enum NotaryStatus {
20 pending,
21 failed,
22 succeeded,
23}
24
Xilai Zhang098a4b62022-07-15 13:56:04 -070025/// Codesign and notarize all files within a [RemoteArchive].
26class FileCodesignVisitor {
27 FileCodesignVisitor({
28 required this.commitHash,
29 required this.codesignCertName,
30 required this.codesignUserName,
31 required this.appSpecificPassword,
32 required this.codesignAppstoreId,
33 required this.codesignTeamId,
34 required this.codesignFilepaths,
35 required this.fileSystem,
Xilai Zhang177ac762022-09-22 13:41:59 -070036 required this.rootDirectory,
Xilai Zhang098a4b62022-07-15 13:56:04 -070037 required this.processManager,
Xilai Zhang177ac762022-09-22 13:41:59 -070038 required this.googleCloudStorage,
Xilai Zhang098a4b62022-07-15 13:56:04 -070039 this.production = false,
Xilai Zhang177ac762022-09-22 13:41:59 -070040 this.notarizationTimerDuration = const Duration(seconds: 5),
Xilai Zhang36249b32022-08-12 09:55:04 -070041 }) {
Xilai Zhang177ac762022-09-22 13:41:59 -070042 entitlementsFile = rootDirectory.childFile('Entitlements.plist')..writeAsStringSync(_entitlementsFileContents);
43 remoteDownloadsDir = rootDirectory.childDirectory('downloads')..createSync();
44 codesignedZipsDir = rootDirectory.childDirectory('codesigned_zips')..createSync();
Xilai Zhang36249b32022-08-12 09:55:04 -070045 }
Xilai Zhang098a4b62022-07-15 13:56:04 -070046
47 /// Temp [Directory] to download/extract files to.
48 ///
49 /// This file will be deleted if [validateAll] completes successfully.
Xilai Zhang177ac762022-09-22 13:41:59 -070050 final Directory rootDirectory;
Xilai Zhang098a4b62022-07-15 13:56:04 -070051 final FileSystem fileSystem;
Xilai Zhang098a4b62022-07-15 13:56:04 -070052 final ProcessManager processManager;
Xilai Zhang177ac762022-09-22 13:41:59 -070053 final GoogleCloudStorage googleCloudStorage;
Xilai Zhang098a4b62022-07-15 13:56:04 -070054
55 final String commitHash;
56 final String codesignCertName;
57 final String codesignUserName;
58 final String appSpecificPassword;
59 final String codesignAppstoreId;
60 final String codesignTeamId;
61 final bool production;
Xilai Zhang177ac762022-09-22 13:41:59 -070062 final Duration notarizationTimerDuration;
Xilai Zhang098a4b62022-07-15 13:56:04 -070063
Xilai Zhang098a4b62022-07-15 13:56:04 -070064 // TODO(xilaizhang): add back utitlity in later splits
65 Set<String> fileWithEntitlements = <String>{};
66 Set<String> fileWithoutEntitlements = <String>{};
67 Set<String> fileConsumed = <String>{};
Xilai Zhang46c6c542022-08-01 10:35:03 -070068 Set<String> directoriesVisited = <String>{};
Xilai Zhang098a4b62022-07-15 13:56:04 -070069 List<String> codesignFilepaths;
70
71 late final File entitlementsFile;
72 late final Directory remoteDownloadsDir;
73 late final Directory codesignedZipsDir;
74
75 int _remoteDownloadIndex = 0;
76 int get remoteDownloadIndex => _remoteDownloadIndex++;
77
78 static const String _entitlementsFileContents = '''
79<?xml version="1.0" encoding="UTF-8"?>
80<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
81<plist version="1.0">
82 <dict>
83 <key>com.apple.security.cs.allow-jit</key>
84 <true/>
85 <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
86 <true/>
87 <key>com.apple.security.cs.allow-dyld-environment-variables</key>
88 <true/>
89 <key>com.apple.security.network.client</key>
90 <true/>
91 <key>com.apple.security.network.server</key>
92 <true/>
93 <key>com.apple.security.cs.disable-library-validation</key>
94 <true/>
95 </dict>
96</plist>
97''';
Xilai Zhang697fa8f2022-09-02 10:04:49 -070098 static final RegExp _notarytoolStatusCheckPattern = RegExp(r'[ ]*status: ([a-zA-z ]+)');
Xilai Zhang5092fc82022-09-08 15:01:17 -070099 static final RegExp _notarytoolRequestPattern = RegExp(r'id: ([a-z0-9-]+)');
Xilai Zhang098a4b62022-07-15 13:56:04 -0700100
Xilai Zhang36249b32022-08-12 09:55:04 -0700101 static const String fixItInstructions = '''
102Codesign test failed.
103
104We compared binary files in engine artifacts with those listed in
105entitlement.txt and withoutEntitlements.txt, and the binary files do not match.
106*entitlements.txt is the configuartion file encoded in engine artifact zip,
107built by BUILD.gn and Ninja, to detail the list of entitlement files.
108Either an expected file was not found in *entitlements.txt, or an unexpected
109file was found in entitlements.txt.
110
111This usually happens during an engine roll.
112If this is a valid change, then BUILD.gn needs to be changed.
113Binaries that will run on a macOS host require entitlements, and
114binaries that run on an iOS device must NOT have entitlements.
115For example, if this is a new binary that runs on macOS host, add it
116to [entitlements.txt] file inside the zip artifact produced by BUILD.gn.
117If this is a new binary that needs to be run on iOS device, add it
118to [withoutEntitlements.txt].
119If there are obsolete binaries in entitlements configuration files, please delete or
120update these file paths accordingly.
121''';
Xilai Zhang098a4b62022-07-15 13:56:04 -0700122
123 /// The entrance point of examining and code signing an engine artifact.
124 Future<void> validateAll() async {
Xilai Zhang46c6c542022-08-01 10:35:03 -0700125 await Future<void>.value(null);
Xilai Zhang177ac762022-09-22 13:41:59 -0700126 log.info('Codesigned all binaries in ${rootDirectory.path}');
Xilai Zhang098a4b62022-07-15 13:56:04 -0700127
Xilai Zhang177ac762022-09-22 13:41:59 -0700128 await rootDirectory.delete(recursive: true);
129 }
130
131 /// Retrieve engine artifact from google cloud storage and kick start a
132 /// recursive visit of its contents.
133 ///
134 /// Invokes [visitDirectory] to recursively visit the contents of the remote
135 /// zip. Also downloads, notarizes and uploads the engine artifact.
136 Future<void> processRemoteZip({
137 required String artifactFilePath,
138 required Directory parentDirectory,
139 }) async {
140 final FileSystem fs = rootDirectory.fileSystem;
141
142 // namespace by hashcode otherwise there will be collisions
143 final String localFilePath = '${artifactFilePath.hashCode}_${fs.path.basename(artifactFilePath)}';
144
145 // download the zip file
146 final File originalFile = await googleCloudStorage.downloadEngineArtifact(
147 from: artifactFilePath,
148 destination: remoteDownloadsDir.childFile(localFilePath).path,
149 );
150
151 await unzip(
152 inputZip: originalFile,
153 outDir: parentDirectory,
154 processManager: processManager,
155 );
156
157 //extract entitlements file.
158 fileWithEntitlements = await parseEntitlements(parentDirectory, true);
159 fileWithoutEntitlements = await parseEntitlements(parentDirectory, false);
160 log.info('parsed binaries with entitlements are $fileWithEntitlements');
161 log.info('parsed binaries without entitlements are $fileWithEntitlements');
162
163 // recursively visit extracted files
164 await visitDirectory(directory: parentDirectory, entitlementParentPath: artifactFilePath);
165
166 final File codesignedFile = codesignedZipsDir.childFile(localFilePath);
167
168 await zip(
169 inputDir: parentDirectory,
170 outputZip: codesignedFile,
171 processManager: processManager,
172 );
173
174 // notarize
175 await notarize(codesignedFile);
176
177 await googleCloudStorage.uploadEngineArtifact(
178 from: codesignedFile.path,
179 destination: artifactFilePath,
180 );
Xilai Zhang098a4b62022-07-15 13:56:04 -0700181 }
Xilai Zhang46c6c542022-08-01 10:35:03 -0700182
183 /// Visit a [Directory] type while examining the file system extracted from an artifact.
184 Future<void> visitDirectory({
185 required Directory directory,
186 required String entitlementParentPath,
187 }) async {
Xilai Zhang36249b32022-08-12 09:55:04 -0700188 log.info('Visiting directory ${directory.absolute.path}');
Xilai Zhang46c6c542022-08-01 10:35:03 -0700189 if (directoriesVisited.contains(directory.absolute.path)) {
190 log.warning(
191 'Warning! You are visiting a directory that has been visited before, the directory is ${directory.absolute.path}');
192 }
193 directoriesVisited.add(directory.absolute.path);
194 final List<FileSystemEntity> entities = await directory.list().toList();
195 for (FileSystemEntity entity in entities) {
196 if (entity is io.Directory) {
197 await visitDirectory(
198 directory: directory.childDirectory(entity.basename),
199 entitlementParentPath: entitlementParentPath,
200 );
201 continue;
202 }
Xilai Zhang177ac762022-09-22 13:41:59 -0700203 if (entity.basename == 'entitlements.txt' || entity.basename == 'without_entitlements.txt') {
204 continue;
205 }
Xilai Zhang46c6c542022-08-01 10:35:03 -0700206 final FileType childType = getFileType(
207 entity.absolute.path,
208 processManager,
209 );
210 if (childType == FileType.zip) {
211 await visitEmbeddedZip(
212 zipEntity: entity,
213 entitlementParentPath: entitlementParentPath,
214 );
Xilai Zhang36249b32022-08-12 09:55:04 -0700215 } else if (childType == FileType.binary) {
216 await visitBinaryFile(binaryFile: entity as File, entitlementParentPath: entitlementParentPath);
Xilai Zhang46c6c542022-08-01 10:35:03 -0700217 }
Xilai Zhang36249b32022-08-12 09:55:04 -0700218 log.info('Child file of directory ${directory.basename} is ${entity.basename}');
Xilai Zhang46c6c542022-08-01 10:35:03 -0700219 }
220 }
221
222 /// Unzip an [EmbeddedZip] and visit its children.
223 Future<void> visitEmbeddedZip({
224 required FileSystemEntity zipEntity,
225 required String entitlementParentPath,
226 }) async {
Xilai Zhang36249b32022-08-12 09:55:04 -0700227 log.info('This embedded file is ${zipEntity.path} and entitlementParentPath is $entitlementParentPath');
228 final String currentFileName = zipEntity.basename;
Xilai Zhang177ac762022-09-22 13:41:59 -0700229 final Directory newDir = rootDirectory.childDirectory('embedded_zip_${zipEntity.absolute.path.hashCode}');
Xilai Zhang46c6c542022-08-01 10:35:03 -0700230 await unzip(
231 inputZip: zipEntity,
232 outDir: newDir,
233 processManager: processManager,
234 );
235
236 // the virtual file path is advanced by the name of the embedded zip
237 final String currentZipEntitlementPath = '$entitlementParentPath/$currentFileName';
238 await visitDirectory(
239 directory: newDir,
240 entitlementParentPath: currentZipEntitlementPath,
241 );
242 await zipEntity.delete();
243 await zip(
244 inputDir: newDir,
245 outputZip: zipEntity,
246 processManager: processManager,
247 );
248 }
Xilai Zhang36249b32022-08-12 09:55:04 -0700249
250 /// Visit and codesign a binary with / without entitlement.
251 ///
252 /// At this stage, the virtual [entitlementCurrentPath] accumulated through the recursive visit, is compared
253 /// against the paths extracted from [fileWithEntitlements], to help determine if this file should be signed
254 /// with entitlements.
255 Future<void> visitBinaryFile({required File binaryFile, required String entitlementParentPath}) async {
256 final String currentFileName = binaryFile.basename;
257 final String entitlementCurrentPath = '$entitlementParentPath/$currentFileName';
258
259 if (!fileWithEntitlements.contains(entitlementCurrentPath) &&
260 !fileWithoutEntitlements.contains(entitlementCurrentPath)) {
261 log.severe('The system has detected a binary file at $entitlementCurrentPath.'
262 'but it is not in the entitlements configuartion files you provided.'
263 'if this is a new engine artifact, please add it to one of the entitlements.txt files');
264 throw CodesignException(fixItInstructions);
265 }
266 log.info('signing file at path ${binaryFile.absolute.path}');
267 log.info('the virtual entitlement path associated with file is $entitlementCurrentPath');
268 log.info('the decision to sign with entitlement is ${fileWithEntitlements.contains(entitlementCurrentPath)}');
269 final List<String> args = <String>[
270 'codesign',
271 '-f', // force
272 '-s', // use the cert provided by next argument
273 codesignCertName,
274 binaryFile.absolute.path,
275 '--timestamp', // add a secure timestamp
276 '--options=runtime', // hardened runtime
277 if (fileWithEntitlements.contains(entitlementCurrentPath)) ...<String>[
278 '--entitlements',
279 entitlementsFile.absolute.path
280 ],
281 ];
282 final io.ProcessResult result = await processManager.run(args);
283 if (result.exitCode != 0) {
284 throw CodesignException(
285 'Failed to codesign ${binaryFile.absolute.path} with args: ${args.join(' ')}\n'
286 'stdout:\n${(result.stdout as String).trim()}'
287 'stderr:\n${(result.stderr as String).trim()}',
288 );
289 }
290 fileConsumed.add(entitlementCurrentPath);
291 }
Xilai Zhang6a8052c2022-08-18 10:33:04 -0700292
293 /// Extract entitlements configurations from downloaded zip files.
294 ///
295 /// Parse and store codesign configurations detailed in configuration files.
296 /// File paths of entilement files and non entitlement files will be parsed and stored in [fileWithEntitlements].
297 Future<Set<String>> parseEntitlements(Directory parent, bool entitlements) async {
298 final String entitlementFilePath = entitlements
299 ? fileSystem.path.join(parent.path, 'entitlements.txt')
300 : fileSystem.path.join(parent.path, 'without_entitlements.txt');
301 if (!(await fileSystem.file(entitlementFilePath).exists())) {
302 throw CodesignException('$entitlementFilePath not found \n'
303 'make sure you have provided them along with the engine artifacts \n');
304 }
305
306 final Set<String> fileWithEntitlements = <String>{};
307 fileWithEntitlements.addAll(await fileSystem.file(entitlementFilePath).readAsLines());
308 return fileWithEntitlements;
309 }
Xilai Zhang697fa8f2022-09-02 10:04:49 -0700310
311 /// Upload a zip archive to the notary service and verify the build succeeded.
312 ///
313 /// The apple notarization service will unzip the artifact, validate all
314 /// binaries are properly codesigned, and notarize the entire archive.
315 Future<void> notarize(File file) async {
316 final Completer<void> completer = Completer<void>();
317 final String uuid = uploadZipToNotary(file);
318
319 Future<void> callback(Timer timer) async {
320 final bool notaryFinished = checkNotaryJobFinished(uuid);
321 if (notaryFinished) {
322 timer.cancel();
323 log.info('successfully notarized ${file.path}');
324 completer.complete();
325 }
326 }
327
328 // check on results
329 Timer.periodic(
Xilai Zhang177ac762022-09-22 13:41:59 -0700330 notarizationTimerDuration,
Xilai Zhang697fa8f2022-09-02 10:04:49 -0700331 callback,
332 );
333 await completer.future;
334 }
335
Xilai Zhang697fa8f2022-09-02 10:04:49 -0700336 /// Make a request to the notary service to see if the notary job is finished.
337 ///
338 /// A return value of true means that notarization finished successfully,
339 /// false means that the job is still pending. If the notarization fails, this
340 /// function will throw a [ConductorException].
341 bool checkNotaryJobFinished(String uuid) {
342 final List<String> args = <String>[
343 'xcrun',
344 'notarytool',
345 'info',
346 uuid,
347 '--password',
348 appSpecificPassword,
349 '--apple-id',
350 codesignAppstoreId,
351 '--team-id',
352 codesignTeamId,
353 ];
354
355 log.info('checking notary status with ${args.join(' ')}');
356 final io.ProcessResult result = processManager.runSync(args);
357 final String combinedOutput = (result.stdout as String) + (result.stderr as String);
358
359 final RegExpMatch? match = _notarytoolStatusCheckPattern.firstMatch(combinedOutput);
360
361 if (match == null) {
362 throw CodesignException(
363 'Malformed output from "${args.join(' ')}"\n${combinedOutput.trim()}',
364 );
365 }
366
367 final String status = match.group(1)!;
368
369 if (status == 'Accepted') {
370 return true;
371 }
372 if (status == 'In Progress') {
373 log.info('job $uuid still pending');
374 return false;
375 }
376 throw CodesignException('Notarization failed with: $status\n$combinedOutput');
377 }
Xilai Zhang5092fc82022-09-08 15:01:17 -0700378
379 /// Upload artifact to Apple notary service.
380 String uploadZipToNotary(File localFile, [int retryCount = 3, int sleepTime = 1]) {
381 while (retryCount > 0) {
382 final List<String> args = <String>[
383 'xcrun',
384 'notarytool',
385 'submit',
386 localFile.absolute.path,
387 '--apple-id',
388 codesignAppstoreId,
389 '--password',
390 appSpecificPassword,
391 '--team-id',
392 codesignTeamId,
393 ];
394
395 log.info('uploading ${args.join(' ')}');
396 final io.ProcessResult result = processManager.runSync(args);
397 if (result.exitCode != 0) {
398 throw CodesignException(
399 'Command "${args.join(' ')}" failed with exit code ${result.exitCode}\nStdout: ${result.stdout}\nStderr: ${result.stderr}');
400 }
401
402 final String combinedOutput = (result.stdout as String) + (result.stderr as String);
403 final RegExpMatch? match;
404 match = _notarytoolRequestPattern.firstMatch(combinedOutput);
405
406 if (match == null) {
407 log.warning('Failed to upload to the notary service with args: ${args.join(' ')}');
408 log.warning('{combinedOutput.trim()}');
409 retryCount -= 1;
410 log.warning('Trying again $retryCount more time${retryCount > 1 ? 's' : ''}...');
411 io.sleep(Duration(seconds: sleepTime));
412 continue;
413 }
414
415 final String requestUuid = match.group(1)!;
416 log.info('RequestUUID for ${localFile.path} is: $requestUuid');
417
418 return requestUuid;
419 }
420 log.warning('The upload to notary service failed after retries, and'
421 ' the output format does not match the current notary tool version.'
422 ' If after inspecting the output, you believe the process finished '
423 'successfully but was not detected, please contact flutter release engineers');
424 throw CodesignException('Failed to upload ${localFile.path} to the notary service');
425 }
Xilai Zhang098a4b62022-07-15 13:56:04 -0700426}