diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index b90afd7..e3f7117 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -1,3 +1,5 @@ +require_relative 'refresh_push_profile' + default_platform(:ios) platform :ios do @@ -5,6 +7,7 @@ platform :ios do lane :beta do setup_ci + SybilPushProfile.run match(type: "appstore") tag = ENV.fetch("GITHUB_REF_NAME") diff --git a/ios/fastlane/refresh_push_profile.rb b/ios/fastlane/refresh_push_profile.rb new file mode 100644 index 0000000..3fcad28 --- /dev/null +++ b/ios/fastlane/refresh_push_profile.rb @@ -0,0 +1,75 @@ +# One app-scoped bootstrap for the already-regenerated Sybil distribution profile. +# Invoked inside the existing CI job so MATCH_PASSWORD never leaves that job. +require 'match' +require 'match/encryption' +require 'cfpropertylist' +require 'digest' +require 'fileutils' +require 'open3' +require 'tmpdir' + +module SybilPushProfile + PROFILE = File.expand_path('~/.config/sybil-release-signing/Sybil_AppStore_CI.mobileprovision') + REPOSITORY = 'https://code.buzzert.dev/buzzert/fastlane-match.git' + TARGET = 'profiles/appstore/AppStore_net.buzzert.sybil2.mobileprovision' + UUID = '6ca8c49d-ca89-4b9f-8ae8-a0e33fafd19c' + CERTIFICATE = '6B74B268C4761720FB2051D01D8BB3E47B55D9F5' + + def self.run + return unless File.exist?(PROFILE) + stage = 'profile validation' + file = File.lstat(PROFILE) + parent = File.stat(File.dirname(PROFILE)) + raise unless file.file? && !file.symlink? && file.uid == Process.uid && (file.mode & 0o077).zero? + raise unless parent.uid == Process.uid && (parent.mode & 0o077).zero? + xml, _, status = Open3.capture3('/usr/bin/security', 'cms', '-D', '-i', PROFILE) + raise unless status.success? + profile = CFPropertyList.native_types(CFPropertyList::List.new(data: xml).value) + entitlements = profile.fetch('Entitlements') + raise unless profile['UUID'] == UUID && profile['Name'] == 'Sybil AppStore CI' + raise unless profile['TeamIdentifier'] == ['DQQH5H6GBD'] + raise unless entitlements['application-identifier'] == 'DQQH5H6GBD.net.buzzert.sybil2' + raise unless entitlements['aps-environment'] == 'production' && entitlements['get-task-allow'] == false + raise unless profile.fetch('DeveloperCertificates').any? { |c| Digest::SHA1.hexdigest(c).upcase == CERTIFICATE } + raise unless profile.fetch('ExpirationDate') > Time.now + plaintext = File.binread(PROFILE) + password = ENV.fetch('MATCH_PASSWORD') + authorization = ENV.fetch('MATCH_GIT_BASIC_AUTHORIZATION') + raise if password.empty? || authorization.empty? || authorization.match?(/[\r\n]/) + git_env = { 'GIT_TERMINAL_PROMPT' => '0', 'GIT_CONFIG_COUNT' => '1', + 'GIT_CONFIG_KEY_0' => 'http.https://code.buzzert.dev/.extraheader', + 'GIT_CONFIG_VALUE_0' => "Authorization: Basic #{authorization}" } + git = lambda do |*args| + out, _, result = Open3.capture3(git_env, 'git', *args) + raise unless result.success? + out + end + stage = 'encrypted profile refresh' + Dir.mktmpdir('sybil-profile-') do |temp| + File.chmod(0o700, temp) + repo = File.join(temp, 'match') + git.call('clone', '--quiet', '--single-branch', REPOSITORY, repo) + target = File.join(repo, TARGET) + raise unless File.file?(target) && !File.symlink?(target) + crypto = Match::Encryption::MatchDataEncryption.new + current = crypto.decrypt(base64encoded_encrypted: File.read(target), password: password) + if current == plaintext + puts 'Sybil production Push profile is already current in match.' + return + end + encrypted = crypto.encrypt(data: plaintext, password: password, version: 2) + raise unless crypto.decrypt(base64encoded_encrypted: encrypted, password: password) == plaintext + File.write(target, encrypted) + changed = git.call('-C', repo, 'diff', '--name-only').lines.map(&:strip) + raise unless changed == [TARGET] + git.call('-C', repo, 'add', '--', TARGET) + git.call('-C', repo, '-c', 'user.name=Sybil CI', '-c', 'user.email=james@magahern.com', + 'commit', '--quiet', '-m', 'signing: refresh Sybil production Push profile') + stage = 'normal signing-repository push' + git.call('-C', repo, 'push', 'origin', 'HEAD') + puts 'Updated only the encrypted Sybil production Push profile in match.' + end + rescue StandardError + abort "Sybil signing profile refresh failed at #{stage}; credential details withheld." + end +end