Skip to content

Commit

Permalink
[match]: renew expired certificates
Browse files Browse the repository at this point in the history
  • Loading branch information
nekrich committed Dec 4, 2023
1 parent 6e71101 commit 0388ecb
Show file tree
Hide file tree
Showing 9 changed files with 210 additions and 21 deletions.
5 changes: 5 additions & 0 deletions match/lib/match/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,11 @@ def self.available_options
description: "Renew the provisioning profiles if the certificate count on the developer portal has changed. Works only for the 'development' provisioning profile type. Requires 'include_all_certificates' option to be 'true'",
type: Boolean,
default_value: false),
FastlaneCore::ConfigItem.new(key: :renew_expired_certs,
env_name: "MATCH_RENEW_EXPIRED_CERTS",
description: "Automatically renew expired certificates. Notice: to renew developer_id and developer_id_installer certificates you must login with Account Holder account",
type: Boolean,
default_value: false),
FastlaneCore::ConfigItem.new(key: :skip_confirmation,
env_name: "MATCH_SKIP_CONFIRMATION",
description: "Disables confirmation prompts during nuke, answering them with yes",
Expand Down
56 changes: 47 additions & 9 deletions match/lib/match/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ module Match
# rubocop:disable Metrics/ClassLength
class Runner
attr_accessor :files_to_commit
attr_accessor :files_to_delete
attr_accessor :spaceship

attr_accessor :storage

# rubocop:disable Metrics/PerceivedComplexity
def run(params)
self.files_to_commit = []
self.files_to_delete = []

FileUtils.mkdir_p(params[:output_path]) if params[:output_path]

Expand Down Expand Up @@ -70,12 +72,12 @@ def run(params)
end

# Certificate
cert_id = fetch_certificate(params: params, working_directory: storage.working_directory)
cert_id = fetch_certificate(params: params, renew_expired_certs: params[:renew_expired_certs])

# Mac Installer Distribution Certificate
additional_cert_types = params[:additional_cert_types] || []
cert_ids = additional_cert_types.map do |additional_cert_type|
fetch_certificate(params: params, working_directory: storage.working_directory, specific_cert_type: additional_cert_type)
fetch_certificate(params: params, renew_expired_certs: params[:renew_expired_certs], specific_cert_type: additional_cert_type)
end

cert_ids << cert_id
Expand All @@ -93,9 +95,10 @@ def run(params)
end
end

if self.files_to_commit.count > 0 && !params[:readonly]
has_file_changes = self.files_to_commit.count > 0 || self.files_to_delete.count > 0
if has_file_changes && !params[:readonly]
encryption.encrypt_files if encryption
storage.save_changes!(files_to_commit: self.files_to_commit)
storage.save_changes!(files_to_commit: self.files_to_commit, files_to_delete: self.files_to_delete)
end

# Print a summary table for each app_identifier
Expand Down Expand Up @@ -133,13 +136,47 @@ def update_optional_values_depending_on_storage_type(params)
end
end

def fetch_certificate(params: nil, working_directory: nil, specific_cert_type: nil)
RENEWABLE_CERT_TYPES = [:mac_installer_distribution, :development, :distribution, :enterprise]

def fetch_certificate(params: nil, renew_expired_certs: false, specific_cert_type: nil)
cert_type = Match.cert_type_sym(specific_cert_type || params[:type])

certs = Dir[File.join(prefixed_working_directory, "certs", cert_type.to_s, "*.cer")]
keys = Dir[File.join(prefixed_working_directory, "certs", cert_type.to_s, "*.p12")]

if certs.count == 0 || keys.count == 0
storage_has_certs = certs.count != 0 && keys.count != 0

# Determine if cert is renewable.
# Can't renew developer_id certs with Connect API token. Account holder access is required.
is_authenticated_with_login = Spaceship::ConnectAPI.token.nil?
is_cert_renewable_via_api = RENEWABLE_CERT_TYPES.include?(cert_type)
is_cert_renewable = is_authenticated_with_login || is_cert_renewable_via_api

# Validate existing certificate first.
if renew_expired_certs && is_cert_renewable && storage_has_certs
cert_path = select_cert_or_key(paths: certs)

unless Utils.is_cert_valid?(cert_path)
UI.important("Removing invalid certificate '#{File.basename(cert_path)}'")

# Remove expired cert.
self.files_to_delete << cert_path
File.delete(cert_path)

# Remove expired key .p12 file.
key_path = cert_path.split('.')[0...-1].join('.') + '.p12'
if File.exist?(key_path)
self.files_to_delete << key_path
File.delete(key_path)
end

certs = []
keys = []
storage_has_certs = false
end
end

if !storage_has_certs
UI.important("Couldn't find a valid code signing identity for #{cert_type}... creating one for you now")
UI.crash!("No code signing identity found and can not create a new one because you enabled `readonly`") if params[:readonly]
cert_path = Generator.generate_certificate(params, cert_type, prefixed_working_directory, specific_cert_type: specific_cert_type)
Expand Down Expand Up @@ -257,9 +294,6 @@ def fetch_provisioning_profile(params: nil, certificate_id: nil, app_identifier:
self.files_to_commit << profile
end

if Helper.mac?
installed_profile = FastlaneCore::ProvisioningProfile.install(profile, keychain_path)
end
parsed = FastlaneCore::ProvisioningProfile.parse(profile, keychain_path)
uuid = parsed["UUID"]

Expand All @@ -277,6 +311,10 @@ def fetch_provisioning_profile(params: nil, certificate_id: nil, app_identifier:
return nil
end

if Helper.mac?
installed_profile = FastlaneCore::ProvisioningProfile.install(profile, keychain_path)
end

Utils.fill_environment(Utils.environment_variable_name(app_identifier: app_identifier,
type: prov_type,
platform: params[:platform]),
Expand Down
11 changes: 8 additions & 3 deletions match/lib/match/storage/git_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,14 @@ def human_readable_description
end

def delete_files(files_to_delete: [], custom_message: nil)
# No specific list given, e.g. this happens on `fastlane match nuke`
# We just want to run `git add -A` to commit everything
git_push(commands: ["git add -A"], commit_message: custom_message)
if files_to_delete.count > 0
commands = files_to_delete.map { |filename| "git rm #{filename}" }
git_push(commands: commands, commit_message: custom_message)
else
# No specific list given, e.g. this happens on `fastlane match nuke`
# We just want to run `git add -A` to commit everything
git_push(commands: ["git add -A"], commit_message: custom_message)
end
end

def upload_files(files_to_upload: [], custom_message: nil)
Expand Down
14 changes: 9 additions & 5 deletions match/lib/match/storage/interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,14 @@ def save_changes!(files_to_commit: nil, files_to_delete: nil, custom_message: ni
# Custom init to `[]` in case `nil` is passed
files_to_commit ||= []
files_to_delete ||= []
files_to_delete -= files_to_commit # Make sure we are not removing added files.

if files_to_commit.count == 0 && files_to_delete.count == 0
UI.user_error!("Neither `files_to_commit` nor `files_to_delete` were provided to the `save_changes!` method call")
end

Dir.chdir(File.expand_path(self.working_directory)) do
if files_to_commit.count > 0 # everything that isn't `match nuke`
UI.user_error!("You can't provide both `files_to_delete` and `files_to_commit` right now") if files_to_delete.count > 0

if !File.exist?(MATCH_VERSION_FILE_NAME) || File.read(MATCH_VERSION_FILE_NAME) != Fastlane::VERSION.to_s
files_to_commit << MATCH_VERSION_FILE_NAME
File.write(MATCH_VERSION_FILE_NAME, Fastlane::VERSION) # stored unencrypted
Expand All @@ -74,13 +77,14 @@ def save_changes!(files_to_commit: nil, files_to_delete: nil, custom_message: ni

self.upload_files(files_to_upload: files_to_commit, custom_message: custom_message)
UI.message("Finished uploading files to #{self.human_readable_description}")
elsif files_to_delete.count > 0
end

if files_to_delete.count > 0
self.delete_files(files_to_delete: files_to_delete, custom_message: custom_message)
UI.message("Finished deleting files from #{self.human_readable_description}")
else
UI.user_error!("Neither `files_to_commit` nor `files_to_delete` were provided to the `save_changes!` method call")
end
end
ensure # Always clear working_directory after save
self.clear_changes
end

Expand Down
Binary file not shown.
24 changes: 24 additions & 0 deletions match/spec/fixtures/invalid/certs/distribution/F7P4EE896K.p12
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAyTm/v40AZb6I1eXRPVaUF+q683gm+XTRaRC9fjqK3seL117n
gLte6+YOihzt88v7uJvEP0NN5pFLU4x8v/s+S/VC9Rp2Qd7CZIU1P+LJVWbIjJ31
HPW9vVPLILbFERgTE8IblCkUa52KLcegTkvpqE/uS+ERXCsQM8FpK2urMHvIisCa
c2f7O+B/7my+DOaAQaAEqvQtaIxMIIEogIBAAKCAQEAyTm/v40AZb6I1eXRPVaUF+q683gm+XTRaRC9fjqK3seL117n
gLte6+YOihzt88v7uJvEP0NN5pFLU4x8v/s+S/VC9Rp2Qd7CZIU1P+LJVWbIjJ31
HPW9vVPLILbFERgTE8IblCkUa52KLcegTkvpqE/uS+ERXCsQM8FpK2urMHvIisCa
c2f7O+B/7my+DOaAQaAEqvQtaIxMIIEogIBAAKCAQEAyTm/v40AZb6I1eXRPVaUF+q683gm+XTRaRC9fjqK3seL117n
gLte6+YOihzt88v7uJvEP0NN5pFLU4x8v/s+S/VC9Rp2Qd7CZIU1P+LJVWbIjJ31
HPW9vVPLILbFERgTE8IblCkUa52KLcegTkvpqE/uS+ERXCsQM8FpK2urMHvIisCa
c2f7O+B/7my+DOaAQaAEqvQtaIxMIIEogIBAAKCAQEAyTm/v40AZb6I1eXRPVaUF+q683gm+XTRaRC9fjqK3seL117n
gLte6+YOihzt88v7uJvEP0NN5pFLU4x8v/s+S/VC9Rp2Qd7CZIU1P+LJVWbIjJ31
HPW9vVPLILbFERgTE8IblCkUa52KLcegTkvpqE/uS+ERXCsQM8FpK2urMHvIisCa
c2f7O+B/7my+DOaAQaAEqvQtaIxMIIEogIBAAKCAQEAyTm/v40AZb6I1eXRPVaUF+q683gm+XTRaRC9fjqK3seL117n
gLte6+YOihzt88v7uJvEP0NN5pFLU4x8v/s+S/VC9Rp2Qd7CZIU1P+LJVWbIjJ31
HPW9vVPLILbFERgTE8IblCkUa52KLcegTkvpqE/uS+ERXCsQM8FpK2urMHvIisCa
c2f7O+B/7my+DOaAQaAEqvQtaIxMIIEogIBAAKCAQEAyTm/v40AZb6I1eXRPVaUF+q683gm+XTRaRC9fjqK3seL117n
gLte6+YOihzt88v7uJvEP0NN5pFLU4x8v/s+S/VC9Rp2Qd7CZIU1P+LJVWbIjJ31
HPW9vVPLILbFERgTE8IblCkUa52KLcegTkvpqE/uS+ERXCsQM8FpK2urMHvIisCa
c2f7O+B/7my+DOaAQaAEqvQtaIxMIIEogIBAAKCAQEAyTm/v40AZb6I1eXRPVaUF+q683gm+XTRaRC9fjqK3seL117n
gLte6+YOihzt88v7uJvEP0NN5pFLU4x8v/s+S/VC9Rp2Qd7CZIU1P+LJVWbIjJ31
HPW9vVPLILbFERgTE8IblCkUa52KLcegTkvpqE/uS+ERXCsQM8FpK2urMHvIisCa
c2f7O+B/7my+DOaAQaAEqvQtaIx
-----END RSA PRIVATE KEY-----
103 changes: 101 additions & 2 deletions match/spec/runner_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
File.join(repo_dir, "something.cer"),
File.join(repo_dir, "something.p12"), # this is important, as a cert consists out of 2 files
"./match/spec/fixtures/test.mobileprovision"
]
],
files_to_delete: []
)

spaceship = "spaceship"
Expand Down Expand Up @@ -223,6 +224,103 @@
end.to raise_error("Your certificate 'E7P4EE896K.cer' is not valid, please check end date and renew it if necessary")
end

it "renews an outdated certificate", requires_security: true do
git_url = "https://github.com/fastlane/fastlane/tree/master/certificates"
values = {
app_identifier: "tools.fastlane.app",
type: "appstore",
git_url: git_url,
username: "flapple@something.com",
renew_expired_certs: true
}

config = FastlaneCore::Configuration.create(Match::Options.available_options, values)
repo_dir = "./match/spec/fixtures/invalid"
invalid_cert_path = "./match/spec/fixtures/invalid/certs/distribution/F7P4EE896K.cer"
invalid_key_path = "./match/spec/fixtures/invalid/certs/distribution/F7P4EE896K.p12"

valid_cert_path = "./match/spec/fixtures/valid/certs/distribution/E7P4EE896K.cer"
valid_key_path = "./match/spec/fixtures/valid/certs/distribution/E7P4EE896K.p12"

profile_path = "./match/spec/fixtures/test.mobileprovision"
keychain_path = FastlaneCore::Helper.keychain_path("login.keychain") # can be .keychain or .keychain-db
destination = File.expand_path("~/Library/MobileDevice/Provisioning Profiles/98264c6b-5151-4349-8d0f-66691e48ae35.mobileprovision")

fake_storage = "fake_storage"
expect(Match::Storage::GitStorage).to receive(:configure).with({
git_url: git_url,
shallow_clone: false,
skip_docs: false,
git_branch: "master",
git_full_name: nil,
git_user_email: nil,
clone_branch_directly: false,
git_basic_authorization: nil,
git_bearer_authorization: nil,
git_private_key: nil,
type: config[:type],
platform: config[:platform]
}).and_return(fake_storage)

expect(fake_storage).to receive(:download).and_return(nil)
expect(fake_storage).to receive(:clear_changes).and_return(nil)
allow(fake_storage).to receive(:git_url).and_return(git_url)
allow(fake_storage).to receive(:working_directory).and_return(repo_dir)
allow(fake_storage).to receive(:prefixed_working_directory).and_return(repo_dir)
expect(Match::Generator).to receive(:generate_certificate).with(config, :distribution, fake_storage.working_directory, specific_cert_type: nil).and_return(valid_cert_path)
expect(Match::Generator).to receive(:generate_provisioning_profile).with(params: config,
prov_type: :appstore,
certificate_id: anything,
app_identifier: values[:app_identifier],
force: false,
working_directory: fake_storage.working_directory).and_return(profile_path)

expect(FastlaneCore::ProvisioningProfile).to receive(:install).with(profile_path, keychain_path).and_return(destination)

expect(fake_storage).to receive(:save_changes!).with(
files_to_commit: [
valid_cert_path,
valid_key_path, # this is important, as a cert consists out of 2 files
"./match/spec/fixtures/test.mobileprovision"
],
files_to_delete: [
invalid_cert_path,
invalid_key_path
]
)

fake_encryption = "fake_encryption"
expect(Match::Encryption::OpenSSL).to receive(:new).with(keychain_name: fake_storage.git_url, working_directory: fake_storage.working_directory).and_return(fake_encryption)
expect(fake_encryption).to receive(:decrypt_files).and_return(nil)
expect(fake_encryption).to receive(:encrypt_files).and_return(nil)

expect(File).to receive(:delete).with(invalid_cert_path).and_return(nil)
expect(File).to receive(:delete).with(invalid_key_path).and_return(nil)

spaceship = "spaceship"
allow(spaceship).to receive(:team_id).and_return("")
expect(Match::SpaceshipEnsure).to receive(:new).and_return(spaceship)
expect(spaceship).to receive(:bundle_identifier_exists).and_return(true)
expect(spaceship).to receive(:profile_exists).with(platform: 'ios', type: :appstore, username: anything, uuid: '98264c6b-5151-4349-8d0f-66691e48ae35').and_return(true)
expect(Match::Utils).to receive(:get_cert_info).and_return([["Common Name", "fastlane certificate name"]])
expect(Match::Utils).to receive(:is_cert_valid?).with(invalid_cert_path).and_return(false)
expect(spaceship).to receive(:certificates_exists).with(username: anything, certificate_ids: ['E7P4EE896K']).and_return(true)

Match::Runner.new.run(config)

expect(ENV[Match::Utils.environment_variable_name(app_identifier: "tools.fastlane.app",
type: "appstore")]).to eql('98264c6b-5151-4349-8d0f-66691e48ae35')
expect(ENV[Match::Utils.environment_variable_name_team_id(app_identifier: "tools.fastlane.app",
type: "appstore")]).to eql('439BBM9367')
expect(ENV[Match::Utils.environment_variable_name_profile_name(app_identifier: "tools.fastlane.app",
type: "appstore")]).to eql('tools.fastlane.app AppStore')
profile_path = File.expand_path('~/Library/MobileDevice/Provisioning Profiles/98264c6b-5151-4349-8d0f-66691e48ae35.mobileprovision')
expect(ENV[Match::Utils.environment_variable_name_profile_path(app_identifier: "tools.fastlane.app",
type: "appstore")]).to eql(profile_path)
expect(ENV[Match::Utils.environment_variable_name_certificate_name(app_identifier: "tools.fastlane.app",
type: "appstore")]).to eql("fastlane certificate name")
end

it "skips provisioning profiles when skip_provisioning_profiles set to true", requires_security: true do
git_url = "https://github.com/fastlane/fastlane/tree/master/certificates"
values = {
Expand Down Expand Up @@ -267,7 +365,8 @@
files_to_commit: [
File.join(repo_dir, "something.cer"),
File.join(repo_dir, "something.p12") # this is important, as a cert consists out of 2 files
]
],
files_to_delete: []
)

spaceship = "spaceship"
Expand Down
9 changes: 8 additions & 1 deletion spaceship/lib/spaceship/connect_api/models/profile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ def self.type
end

def valid?
return profile_state == ProfileState::ACTIVE
# Provisioning profiles are not invalidated automatically on the dev portal when the certificate expires.
# They become Invalid only when opened directly in the portal 馃し.
# We need to do an extra check on the expiration date to ensure the profile is valid.
expired = Time.now.utc > Time.parse(self.expiration_date)

is_valid = profile_state == ProfileState::ACTIVE && !expired

return is_valid
end

#
Expand Down
9 changes: 8 additions & 1 deletion spaceship/lib/spaceship/portal/provisioning_profile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,14 @@ def certificate_valid?
# @return (Bool) Is the current provisioning profile valid?
# To also verify the certificate call certificate_valid?
def valid?
return status == 'Active'
# Provisioning profiles are not invalidated automatically on the dev portal when the certificate expires.
# They become Invalid only when opened directly in the portal 馃し.
# We need to do an extra check on the expiration date to ensure the profile is valid.
expired = Time.now.utc > Time.parse(self.expires)

is_valid = status == 'Active' && !expired

return is_valid
end

# @return (Bool) Is this profile managed by Xcode?
Expand Down

0 comments on commit 0388ecb

Please sign in to comment.