Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

[match][sigh] add option to automatically renew expired certificates (defaults to enabled) #21691

Merged
merged 9 commits into from
Jan 15, 2024
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. Note: to renew `developer_id` and `developer_id_installer` certificates you must log in with the Account Holder account by using username and password; App Store Connect API key doesn't work in this case",
type: Boolean,
default_value: true),
FastlaneCore::ConfigItem.new(key: :skip_confirmation,
env_name: "MATCH_SKIP_CONFIRMATION",
description: "Disables confirmation prompts during nuke, answering them with yes",
Expand Down
51 changes: 45 additions & 6 deletions match/lib/match/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module Match
# rubocop:disable Metrics/ClassLength
class Runner
attr_accessor :files_to_commit
attr_accessor :files_to_delete
attr_accessor :spaceship

attr_accessor :storage
Expand All @@ -28,6 +29,7 @@ class Runner
# 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 @@ -82,12 +84,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

profile_type = Sigh.profile_type_for_distribution_type(
Expand All @@ -112,9 +114,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 @@ -152,13 +155,48 @@ def update_optional_values_depending_on_storage_type(params)
end
end

def fetch_certificate(params: nil, working_directory: nil, specific_cert_type: nil)
lacostej marked this conversation as resolved.
Show resolved Hide resolved
RENEWABLE_CERT_TYPES_VIA_API = [: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_VIA_API.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)}'")
lacostej marked this conversation as resolved.
Show resolved Hide resolved

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

# Key filename is the same as cert but with .p12 extension.
key_path = cert_path.gsub(/\.cer$/, ".p12")
# Remove expired key .p12 file.
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 cannot 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 @@ -318,6 +356,7 @@ def fetch_provisioning_profile(params: nil, profile_type:, certificate_id: nil,

if params[:output_path]
FileUtils.cp(stored_profile_path, params[:output_path])
installed_profile = FastlaneCore::ProvisioningProfile.install(profile, keychain_path)
end

Utils.fill_environment(Utils.environment_variable_name(app_identifier: app_identifier,
Expand Down
7 changes: 4 additions & 3 deletions match/lib/match/storage/git_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ 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.shellescape}" }
git_push(commands: commands, 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
lacostej marked this conversation as resolved.
Show resolved Hide resolved

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃憤

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-----
129 changes: 101 additions & 28 deletions match/spec/runner_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,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 @@ -178,13 +179,15 @@
type: "appstore")]).to eql("fastlane certificate name")
end

it "fails because of an outdated certificate", requires_security: true do
it "fails because of an outdated certificate in readonly mode", 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"
username: "flapple@something.com",
renew_expired_certs: false,
readonly: true
}

config = FastlaneCore::Configuration.create(Match::Options.available_options, values)
Expand All @@ -194,36 +197,15 @@

create_fake_cache

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)
fake_storage = create_fake_storage(match_config: config, repo_dir: repo_dir)

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)

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(Match::SpaceshipEnsure).not_to receive(:new)

expect(Match::Utils).to receive(:is_cert_valid?).and_return(false)

Expand Down Expand Up @@ -264,7 +246,7 @@

# Utils
# Ensure match validates stored certificate.
expect(Match::Utils).to receive(:is_cert_valid?).with(stored_valid_cert_path).and_return(true)
expect(Match::Utils).to receive(:is_cert_valid?).with(stored_valid_cert_path).and_return(true).twice

# Certificates
# Ensure a new certificate is not generated.
Expand All @@ -284,6 +266,96 @@
# Rely on expectations defined above.
end

it "renews an outdated certificate", requires_security: true do
lacostej marked this conversation as resolved.
Show resolved Hide resolved
# GIVEN

# Downloaded and decrypted storage location.
repo_dir = "./match/spec/fixtures/invalid"
# Invalid cert and key
stored_invalid_cert_path = "#{repo_dir}/certs/distribution/F7P4EE896K.cer"
stored_invalid_key_path = "#{repo_dir}/certs/distribution/F7P4EE896K.p12"

# Valid cert and key
new_stored_valid_cert_path = "./match/spec/fixtures/valid/certs/distribution/E7P4EE896K.cer"
new_stored_valid_key_path = "./match/spec/fixtures/valid/certs/distribution/E7P4EE896K.p12"

# match options
match_test_options = {
renew_expired_certs: true, # Current test suite.
skip_provisioning_profiles: true # We test certificate renewal, not profile.
}
match_config = create_match_config_with_git_storage(extra_values: match_test_options)

fake_cache = create_fake_cache

# EXPECTATIONS

# Storage
fake_storage = create_fake_storage(match_config: match_config, repo_dir: repo_dir)
begin # Ensure old certificates are removed from the storage and new are added.
expect(fake_storage).to receive(:save_changes!).with(
files_to_commit: [
new_stored_valid_cert_path,
new_stored_valid_key_path # this is important, as a cert consists out of 2 files
],
files_to_delete: [
stored_invalid_cert_path,
stored_invalid_key_path
]
)
end

# Encryption
fake_encryption = create_fake_encryption(storage: fake_storage)
# Ensure new files are encrypted.
expect(fake_encryption).to receive(:encrypt_files).and_return(nil)

# Certificate generator
# Ensure a new certificate is generated.
expect(Match::Generator).to receive(:generate_certificate).with(match_config, :distribution, fake_storage.working_directory, specific_cert_type: nil).and_return(new_stored_valid_cert_path)

# Spaceship ensure helper
spaceship_ensure = create_fake_spaceship_ensure
begin # Ensure match checks validity of the new certificate.
profile_type = Sigh.profile_type_for_distribution_type(
platform: match_config[:platform],
distribution_type: match_config[:type]
)

certificates_exists_params = {
username: match_config[:username],
certificate_ids: ['E7P4EE896K'],
cached_certificates: fake_cache.certificates,
platform: match_config[:platform],
profile_type: profile_type
}
expect(spaceship_ensure).to receive(:certificates_exists).with(certificates_exists_params).and_return(true)
end

# Utils
# Ensure match validates stored certificate and make it invalid for the current test suite.
expect(Match::Utils).to receive(:is_cert_valid?).with(stored_invalid_cert_path).and_return(false)

# File system
begin # Ensure old certificates are removed from the file system.
expect(File).to receive(:delete).with(stored_invalid_cert_path).and_return(nil)
expect(File).to receive(:delete).with(stored_invalid_key_path).and_return(nil)
end

# Extra
begin # Ensure profiles are not created, not installed, and not validated.
expect(Match::Generator).not_to receive(:generate_provisioning_profile)
expect(FastlaneCore::ProvisioningProfile).not_to receive(:install)
expect(spaceship_ensure).not_to receive(:profile_exists)
end

# WHEN
Match::Runner.new.run(match_config)

# THEN
# Rely on expectations defined above.
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 @@ -330,7 +402,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
10 changes: 5 additions & 5 deletions match/spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ def before_each_match
def create_fake_storage(match_config:, repo_dir:)
rogerluan marked this conversation as resolved.
Show resolved Hide resolved
fake_storage = "fake_storage"
expect(Match::Storage::GitStorage).to receive(:configure).with({
git_url: match_config[:git_url],
shallow_clone: true,
skip_docs: false,
git_branch: "master",
git_url: match_config[:git_url] || default_git_url,
shallow_clone: match_config[:shallow_clone] || false,
skip_docs: match_config[:skip_docs] || false,
git_branch: match_config[:git_branch] || "master",
git_full_name: nil,
git_user_email: nil,
clone_branch_directly: false,
clone_branch_directly: match_config[:clone_branch_directly] || false,
git_basic_authorization: nil,
git_bearer_authorization: nil,
git_private_key: nil,
Expand Down