| // Copyright 2019 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:io' as io; |
| |
| import 'package:file/file.dart'; |
| import 'package:process/process.dart'; |
| |
| import 'log.dart'; |
| import 'utils.dart'; |
| |
| /// Statuses reported by Apple's Notary Server. |
| /// |
| /// See more: |
| /// * https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow |
| enum NotaryStatus { |
| pending, |
| failed, |
| succeeded, |
| } |
| |
| /// Codesign and notarize all files within a [RemoteArchive]. |
| class FileCodesignVisitor { |
| FileCodesignVisitor({ |
| required this.codesignCertName, |
| required this.fileSystem, |
| required this.rootDirectory, |
| required this.processManager, |
| required this.inputZipPath, |
| required this.outputZipPath, |
| required this.appSpecificPasswordFilePath, |
| required this.codesignAppstoreIDFilePath, |
| required this.codesignTeamIDFilePath, |
| this.dryrun = true, |
| this.notarizationTimerDuration = const Duration(seconds: 5), |
| }) { |
| entitlementsFile = rootDirectory.childFile('Entitlements.plist')..writeAsStringSync(_entitlementsFileContents); |
| } |
| |
| /// Temp [Directory] to download/extract files to. |
| /// |
| /// This file will be deleted if [validateAll] completes successfully. |
| final Directory rootDirectory; |
| final FileSystem fileSystem; |
| final ProcessManager processManager; |
| |
| final String codesignCertName; |
| final String inputZipPath; |
| final String outputZipPath; |
| final String appSpecificPasswordFilePath; |
| final String codesignAppstoreIDFilePath; |
| final String codesignTeamIDFilePath; |
| final bool dryrun; |
| final Duration notarizationTimerDuration; |
| |
| // 'Apple developer account email used for authentication with notary service.' |
| late String codesignAppstoreId; |
| // Unique password of the apple developer account.' |
| late String appSpecificPassword; |
| // Team-id is used by notary service for xcode version 13+. |
| late String codesignTeamId; |
| |
| Set<String> fileWithEntitlements = <String>{}; |
| Set<String> fileWithoutEntitlements = <String>{}; |
| Set<String> fileConsumed = <String>{}; |
| Set<String> directoriesVisited = <String>{}; |
| Map<String, String> availablePasswords = { |
| 'CODESIGN_APPSTORE_ID': '', |
| 'CODESIGN_TEAM_ID': '', |
| 'APP_SPECIFIC_PASSWORD': '', |
| }; |
| |
| late final File entitlementsFile; |
| |
| int _remoteDownloadIndex = 0; |
| int get remoteDownloadIndex => _remoteDownloadIndex++; |
| |
| static const String _entitlementsFileContents = ''' |
| <?xml version="1.0" encoding="UTF-8"?> |
| <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| <plist version="1.0"> |
| <dict> |
| <key>com.apple.security.cs.allow-jit</key> |
| <true/> |
| <key>com.apple.security.cs.allow-unsigned-executable-memory</key> |
| <true/> |
| <key>com.apple.security.cs.allow-dyld-environment-variables</key> |
| <true/> |
| <key>com.apple.security.network.client</key> |
| <true/> |
| <key>com.apple.security.network.server</key> |
| <true/> |
| <key>com.apple.security.cs.disable-library-validation</key> |
| <true/> |
| </dict> |
| </plist> |
| '''; |
| static final RegExp _notarytoolStatusCheckPattern = RegExp(r'[ ]*status: ([a-zA-z ]+)'); |
| static final RegExp _notarytoolRequestPattern = RegExp(r'id: ([a-z0-9-]+)'); |
| |
| static const String fixItInstructions = ''' |
| Codesign test failed. |
| |
| We compared binary files in engine artifacts with those listed in |
| entitlement.txt and withoutEntitlements.txt, and the binary files do not match. |
| *entitlements.txt is the configuration file encoded in engine artifact zip, |
| built by BUILD.gn and Ninja, to detail the list of entitlement files. |
| Either an expected file was not found in *entitlements.txt, or an unexpected |
| file was found in entitlements.txt. |
| |
| This usually happens during an engine roll. |
| If this is a valid change, then BUILD.gn needs to be changed. |
| Binaries that will run on a macOS host require entitlements, and |
| binaries that run on an iOS device must NOT have entitlements. |
| For example, if this is a new binary that runs on macOS host, add it |
| to [entitlements.txt] file inside the zip artifact produced by BUILD.gn. |
| If this is a new binary that needs to be run on iOS device, add it |
| to [withoutEntitlements.txt]. |
| If there are obsolete binaries in entitlements configuration files, please delete or |
| update these file paths accordingly. |
| '''; |
| |
| /// Read a single line of password stored at [passwordFilePath]. |
| Future<String> readPassword(String passwordFilePath) async { |
| if (!(await fileSystem.file(passwordFilePath).exists())) { |
| throw CodesignException('$passwordFilePath not found \n' |
| 'make sure you have provided codesign credentials in a file \n'); |
| } |
| return fileSystem.file(passwordFilePath).readAsString(); |
| } |
| |
| /// The entrance point of examining and code signing an engine artifact. |
| Future<void> validateAll() async { |
| codesignAppstoreId = await readPassword(codesignAppstoreIDFilePath); |
| codesignTeamId = await readPassword(codesignTeamIDFilePath); |
| appSpecificPassword = await readPassword(appSpecificPasswordFilePath); |
| |
| await processRemoteZip(); |
| |
| log.info('Codesign completed. Codesigned zip is located at $outputZipPath.' |
| 'If you have uploaded the artifacts back to google cloud storage, please delete' |
| ' the folder $outputZipPath and $inputZipPath.'); |
| if (dryrun) { |
| log.info('code signing dry run has completed, this is a quick sanity check without' |
| 'going through the notary service. To run the full codesign process, use --no-dryrun flag.'); |
| } |
| } |
| |
| /// Process engine artifacts from [inputZipPath] and kick start a |
| /// recursive visit of its contents. |
| /// |
| /// Invokes [visitDirectory] to recursively visit the contents of the remote |
| /// zip. Notarizes the engine artifact if [dryrun] is false. |
| /// Returns null as result if [dryrun] is true. |
| Future<String?> processRemoteZip() async { |
| // download the zip file |
| final File originalFile = rootDirectory.fileSystem.file(inputZipPath); |
| |
| // This is the starting directory of the unzipped artifact. |
| final Directory parentDirectory = rootDirectory.childDirectory('single_artifact'); |
| |
| await unzip( |
| inputZip: originalFile, |
| outDir: parentDirectory, |
| processManager: processManager, |
| ); |
| |
| //extract entitlements file. |
| fileWithEntitlements = await parseEntitlements(parentDirectory, true); |
| fileWithoutEntitlements = await parseEntitlements(parentDirectory, false); |
| log.info('parsed binaries with entitlements are $fileWithEntitlements'); |
| log.info('parsed binaries without entitlements are $fileWithEntitlements'); |
| |
| // recursively visit extracted files |
| await visitDirectory(directory: parentDirectory, parentVirtualPath: ''); |
| |
| await zip( |
| inputDir: parentDirectory, |
| outputZipPath: outputZipPath, |
| processManager: processManager, |
| ); |
| |
| await parentDirectory.delete(recursive: true); |
| |
| // `dryrun` flag defaults to true to save time for a faster sanity check |
| if (!dryrun) { |
| await notarize(fileSystem.file(outputZipPath)); |
| |
| return outputZipPath; |
| } |
| return null; |
| } |
| |
| /// Visit a [Directory] type while examining the file system extracted from an artifact. |
| Future<void> visitDirectory({ |
| required Directory directory, |
| required String parentVirtualPath, |
| }) async { |
| log.info('Visiting directory ${directory.absolute.path}'); |
| if (directoriesVisited.contains(directory.absolute.path)) { |
| log.warning( |
| 'Warning! You are visiting a directory that has been visited before, the directory is ${directory.absolute.path}', |
| ); |
| } |
| directoriesVisited.add(directory.absolute.path); |
| |
| await cleanupEntitlements(directory); |
| |
| final List<FileSystemEntity> entities = await directory.list(followLinks: false).toList(); |
| for (FileSystemEntity entity in entities) { |
| if (entity is io.Link) { |
| log.info('current file or direcotry ${entity.path} is a symlink to ${(entity as io.Link).targetSync()}, ' |
| 'codesign is therefore skipped for the current file or directory.'); |
| continue; |
| } |
| if (entity is io.Directory) { |
| await visitDirectory( |
| directory: directory.childDirectory(entity.basename), |
| parentVirtualPath: joinEntitlementPaths(parentVirtualPath, entity.basename), |
| ); |
| continue; |
| } |
| if (entity.basename == 'entitlements.txt' || entity.basename == 'without_entitlements.txt') { |
| continue; |
| } |
| final FileType childType = getFileType( |
| entity.absolute.path, |
| processManager, |
| ); |
| if (childType == FileType.zip) { |
| await visitEmbeddedZip( |
| zipEntity: entity, |
| parentVirtualPath: parentVirtualPath, |
| ); |
| } else if (childType == FileType.binary) { |
| await visitBinaryFile(binaryFile: entity as File, parentVirtualPath: parentVirtualPath); |
| } |
| log.info('Child file of directory ${directory.basename} is ${entity.basename}'); |
| } |
| } |
| |
| /// Unzip an [EmbeddedZip] and visit its children. |
| Future<void> visitEmbeddedZip({ |
| required FileSystemEntity zipEntity, |
| required String parentVirtualPath, |
| }) async { |
| log.info('This embedded file is ${zipEntity.path} and parentVirtualPath is $parentVirtualPath'); |
| final String currentFileName = zipEntity.basename; |
| final Directory newDir = rootDirectory.childDirectory('embedded_zip_${zipEntity.absolute.path.hashCode}'); |
| await unzip( |
| inputZip: zipEntity, |
| outDir: newDir, |
| processManager: processManager, |
| ); |
| |
| // the virtual file path is advanced by the name of the embedded zip |
| final String currentZipEntitlementPath = joinEntitlementPaths(parentVirtualPath, currentFileName); |
| await visitDirectory( |
| directory: newDir, |
| parentVirtualPath: currentZipEntitlementPath, |
| ); |
| await zipEntity.delete(); |
| await zip( |
| inputDir: newDir, |
| outputZipPath: zipEntity.absolute.path, |
| processManager: processManager, |
| ); |
| await newDir.delete(recursive: true); |
| } |
| |
| /// Visit and codesign a binary with / without entitlement. |
| /// |
| /// At this stage, the virtual [entitlementCurrentPath] accumulated through the recursive visit, is compared |
| /// against the paths extracted from [fileWithEntitlements], to help determine if this file should be signed |
| /// with entitlements. |
| Future<void> visitBinaryFile({ |
| required File binaryFile, |
| required String parentVirtualPath, |
| int retryCount = 3, |
| int sleepTime = 1, |
| }) async { |
| final String currentFileName = binaryFile.basename; |
| final String entitlementCurrentPath = joinEntitlementPaths(parentVirtualPath, currentFileName); |
| |
| if (!fileWithEntitlements.contains(entitlementCurrentPath) && |
| !fileWithoutEntitlements.contains(entitlementCurrentPath)) { |
| log.severe('the binary file $currentFileName is causing an issue. \n' |
| 'This file is located at $entitlementCurrentPath in the flutter engine artifact.'); |
| log.severe('The system has detected a binary file at $entitlementCurrentPath. ' |
| 'But it is not in the entitlements configuration files you provided. ' |
| 'If this is a new engine artifact, please add it to one of the entitlements.txt files.'); |
| throw CodesignException(fixItInstructions); |
| } |
| log.info('signing file at path ${binaryFile.absolute.path}'); |
| log.info('the virtual entitlement path associated with file is $entitlementCurrentPath'); |
| log.info('the decision to sign with entitlement is ${fileWithEntitlements.contains(entitlementCurrentPath)}'); |
| fileConsumed.add(entitlementCurrentPath); |
| if (dryrun) { |
| return; |
| } |
| final List<String> args = <String>[ |
| '/usr/bin/codesign', |
| '--keychain', |
| 'build.keychain', // specify the keychain to look for cert |
| '-f', // force |
| '-s', // use the cert provided by next argument |
| codesignCertName, |
| binaryFile.absolute.path, |
| '--timestamp', // add a secure timestamp |
| '--options=runtime', // hardened runtime |
| if (fileWithEntitlements.contains(entitlementCurrentPath)) ...<String>[ |
| '--entitlements', |
| entitlementsFile.absolute.path, |
| ], |
| ]; |
| |
| io.ProcessResult? result; |
| while (retryCount > 0) { |
| log.info('Executing: ${args.join(' ')}\n'); |
| result = await processManager.run(args); |
| if (result.exitCode == 0) { |
| return; |
| } |
| |
| log.severe( |
| 'Failed to codesign ${binaryFile.absolute.path} with args: ${args.join(' ')}\n' |
| 'stdout:\n${(result.stdout as String).trim()}' |
| 'stderr:\n${(result.stderr as String).trim()}', |
| ); |
| |
| retryCount -= 1; |
| await Future.delayed(Duration(seconds: sleepTime)); |
| sleepTime *= 2; |
| } |
| throw CodesignException('Failed to codesign ${binaryFile.absolute.path} with args: ${args.join(' ')}\n' |
| 'stdout:\n${(result!.stdout as String).trim()}\n' |
| 'stderr:\n${(result.stderr as String).trim()}'); |
| } |
| |
| /// Delete codesign metadata at ALL places inside engine binary. |
| /// |
| /// Context: https://github.com/flutter/flutter/issues/126705. This is a temporary workaround. |
| /// Once flutter tools is ready we can remove this logic. |
| Future<void> cleanupEntitlements(Directory parent) async { |
| final String metadataEntitlements = fileSystem.path.join(parent.path, 'entitlements.txt'); |
| final String metadataWithoutEntitlements = fileSystem.path.join(parent.path, 'without_entitlements.txt'); |
| for (String metadataPath in [metadataEntitlements, metadataWithoutEntitlements]) { |
| if (await fileSystem.file(metadataPath).exists()) { |
| log.warning('cleaning up codesign metadata at $metadataPath.'); |
| await fileSystem.file(metadataPath).delete(); |
| } |
| } |
| } |
| |
| /// Extract entitlements configurations from downloaded zip files. |
| /// |
| /// Parse and store codesign configurations detailed in configuration files. |
| /// File paths of entilement files and non entitlement files will be parsed and stored in [fileWithEntitlements]. |
| Future<Set<String>> parseEntitlements(Directory parent, bool entitlements) async { |
| final String entitlementFilePath = entitlements |
| ? fileSystem.path.join(parent.path, 'entitlements.txt') |
| : fileSystem.path.join(parent.path, 'without_entitlements.txt'); |
| if (!(await fileSystem.file(entitlementFilePath).exists())) { |
| log.warning('$entitlementFilePath not found. ' |
| 'by default, system will assume there is no ${entitlements ? '' : 'without_'}entitlements file. ' |
| 'As a result, no binary will be codesigned.' |
| 'if this is not intended, please provide them along with the engine artifacts.'); |
| return <String>{}; |
| } |
| |
| final Set<String> fileWithEntitlements = <String>{}; |
| |
| fileWithEntitlements.addAll(await fileSystem.file(entitlementFilePath).readAsLines()); |
| // TODO(xilaizhang) : add back metadata information after https://github.com/flutter/flutter/issues/126705 |
| // is resolved. |
| await fileSystem.file(entitlementFilePath).delete(); |
| |
| return fileWithEntitlements; |
| } |
| |
| /// Upload a zip archive to the notary service and verify the build succeeded. |
| /// |
| /// The apple notarization service will unzip the artifact, validate all |
| /// binaries are properly codesigned, and notarize the entire archive. |
| Future<void> notarize(File file) async { |
| final Completer<void> completer = Completer<void>(); |
| final String uuid = uploadZipToNotary(file); |
| |
| Future<void> callback(Timer timer) async { |
| final bool notaryFinished = checkNotaryJobFinished(uuid); |
| if (notaryFinished) { |
| timer.cancel(); |
| log.info('successfully notarized ${file.path}'); |
| completer.complete(); |
| } |
| } |
| |
| // check on results |
| Timer.periodic( |
| notarizationTimerDuration, |
| callback, |
| ); |
| await completer.future; |
| } |
| |
| /// Make a request to the notary service to see if the notary job is finished. |
| /// |
| /// A return value of true means that notarization finished successfully, |
| /// false means that the job is still pending. If the notarization fails, this |
| /// function will throw a [ConductorException]. |
| bool checkNotaryJobFinished(String uuid) { |
| final List<String> args = <String>[ |
| 'xcrun', |
| 'notarytool', |
| 'info', |
| uuid, |
| '--password', |
| appSpecificPassword, |
| '--apple-id', |
| codesignAppstoreId, |
| '--team-id', |
| codesignTeamId, |
| ]; |
| |
| log.info('checking notary status with ${args.join(' ')}'); |
| final io.ProcessResult result = processManager.runSync(args); |
| final String combinedOutput = (result.stdout as String) + (result.stderr as String); |
| |
| final RegExpMatch? match = _notarytoolStatusCheckPattern.firstMatch(combinedOutput); |
| |
| if (match == null) { |
| throw CodesignException( |
| 'Malformed output from "${args.join(' ')}"\n${combinedOutput.trim()}', |
| ); |
| } |
| |
| final String status = match.group(1)!; |
| |
| if (status == 'Accepted') { |
| return true; |
| } |
| if (status == 'In Progress') { |
| log.info('job $uuid still pending'); |
| return false; |
| } |
| throw CodesignException('Notarization failed with: $status\n$combinedOutput'); |
| } |
| |
| /// Upload artifact to Apple notary service. |
| String uploadZipToNotary(File localFile, [int retryCount = 3, int sleepTime = 1]) { |
| while (retryCount > 0) { |
| final List<String> args = <String>[ |
| 'xcrun', |
| 'notarytool', |
| 'submit', |
| localFile.absolute.path, |
| '--apple-id', |
| codesignAppstoreId, |
| '--password', |
| appSpecificPassword, |
| '--team-id', |
| codesignTeamId, |
| ]; |
| |
| log.info('uploading ${args.join(' ')}'); |
| final io.ProcessResult result = processManager.runSync(args); |
| if (result.exitCode != 0) { |
| throw CodesignException( |
| 'Command "${args.join(' ')}" failed with exit code ${result.exitCode}\nStdout: ${result.stdout}\nStderr: ${result.stderr}', |
| ); |
| } |
| |
| final String combinedOutput = (result.stdout as String) + (result.stderr as String); |
| final RegExpMatch? match; |
| match = _notarytoolRequestPattern.firstMatch(combinedOutput); |
| |
| if (match == null) { |
| log.warning('Failed to upload to the notary service with args: ${args.join(' ')}'); |
| log.warning('{combinedOutput.trim()}'); |
| retryCount -= 1; |
| log.warning('Trying again $retryCount more time${retryCount > 1 ? 's' : ''}...'); |
| io.sleep(Duration(seconds: sleepTime)); |
| continue; |
| } |
| |
| final String requestUuid = match.group(1)!; |
| log.info('RequestUUID for ${localFile.path} is: $requestUuid'); |
| |
| return requestUuid; |
| } |
| log.warning('The upload to notary service failed after retries, and' |
| ' the output format does not match the current notary tool version.' |
| ' If after inspecting the output, you believe the process finished ' |
| 'successfully but was not detected, please contact flutter release engineers'); |
| throw CodesignException('Failed to upload ${localFile.path} to the notary service'); |
| } |
| } |