如何使用 fastlane 读取 iOS 证书信息

在 iOS 开发中,证书管理一直是一个重要且复杂的话题。随着 fastlane 的更新,我们处理证书信息的方式也在不断演进。今天,我想分享我在使用 fastlane 读取和加密 iOS 证书信息时的经历,以及如何应对 fastlane 更新带来的变化。

初始方法:直接读取证书文件

最初,我创建了一个自定义的 fastlane Action 来直接读取 .cer 文件,这里使用的是 OpenSSL 进行解密:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ReadSpecificFileAction < Action
def self.decrypt_specific_file(path: nil, password: nil, hash_algorithm: "MD5")
stored_data = Base64.decode64(File.read(path))
salt = stored_data[8..15]
data_to_decrypt = stored_data[16..-1]

decipher = ::OpenSSL::Cipher.new('AES-256-CBC')
decipher.decrypt
decipher.pkcs5_keyivgen(password, salt, 1, hash_algorithm)

decrypted_data = decipher.update(data_to_decrypt) + decipher.final

File.binwrite(path, decrypted_data)
rescue => error
fallback_hash_algorithm = "SHA256"
if hash_algorithm != fallback_hash_algorithm
decrypt_specific_file(path: path, password: password, hash_algorithm: fallback_hash_algorithm)
else
UI.error(error.to_s)
UI.crash!("Error decrypting '#{path}'")
end
end
end

这种方法简单直接,但随着 fastlane 的更新,fastlane 应该是修改了加密解密的逻辑,上面的方案已经无法使用,我们需要寻找新的实现方案。

寻找新的实现方案

Step 1: 查阅 fastlane match 插件的官方文档

在阅读 match 文档的过程中我看到一个手动解密的信息,原文如下:

Manual Decrypt

If you want to manually decrypt or encrypt a file, you can use the companion script match_file:

1
2
3
match_file encrypt "<fileYouWantToEncryptPath>" ["<encryptedFilePath>"]

match_file decrypt "<fileYouWantToDecryptPath>" ["<decryptedFilePath>"]

The password will be asked interactively.

Note: You may need to swap double quotes " for single quotes ' if your match password contains an exclamation mark !.

Step 2: 查看 match_file 命令的源码实现

既然我们知道了这里是使用 match_file 命令进行手动解密的,那么我们可以去 github 看一下他是如何实现的。match_file

1
2
3
4
5
6
begin
Match::Encryption::MatchFileEncryption.new.send(method_name, file_path: input_file, password: password, output_path: output_file)
rescue => e
puts("ERROR #{method_name}ing. [#{e}]. Check your password")
usage
end

这是脚本的核心部分,使用 Match::Encryption::MatchFileEncryption 类来执行加密或解密操作。如果发生错误(例如密码错误),会捕获异常并显示错误信息。

我们来看一下这个方法的参数都是什么:

method_name

  • 这是第一个参数,它决定了要执行的操作。
  • 值可以是 encryptdecrypt
  • 这个参数使用 send 方法动态调用相应的方法(加密或解密)。

file_path: input_file

  • 这是一个命名参数,指定要处理的 iOS 证书文件的路径。
  • input_file 是之前从命令行参数中获取的值。

password: password

  • 这个命名参数提供用于加密或解密的密码。

output_path: output_file

  • 这个命名参数指定处理后文件的输出路径。

新方法:基于 match_file 的实现

我创建了一个新的 Action,名为 MatchFileEncryptionAction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MatchFileEncryptionAction < Action
def self.run(params)
method_name = params[:method]
input_file = params[:input_file]
output_file = params[:output_file] || input_file
password = params[:password]

begin
Match::Encryption::MatchFileEncryption.new.send(method_name, file_path: input_file, password: password, output_path: output_file)
rescue => e
UI.user_error!("ERROR #{method_name}ing. [#{e}]. Check your password")
end
end
end

可以看到,这个方法只是做了加密解密相关的事情,没有对证书信息进行读取,我将对证书的解析封装到了另一个 ReadCertificateInfoAction 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class ReadCertificateInfoAction < Action
def self.run(params)
cert_path = params[:cert_path]

UI.user_error!("Certificate file not found at path: #{cert_path}") unless File.exist?(cert_path)

begin
cert = OpenSSL::X509::Certificate.new(File.read(cert_path))

subject = parse_subject(cert.subject)

info = {
subject: cert.subject.to_s,
issuer: cert.issuer.to_s,
serial: cert.serial.to_s,
not_before: cert.not_before,
not_after: cert.not_after,
public_key: cert.public_key.to_pem,
team_id: extract_team_id(subject),
description: extract_description(subject)
}

# Extract Subject Alternative Names if present
ext = cert.extensions.find { |e| e.oid == 'subjectAltName' }
info[:subject_alt_names] = ext.value if ext

return info
rescue OpenSSL::X509::CertificateError => e
UI.user_error!("Failed to read certificate: #{e.message}")
rescue => e
UI.user_error!("An error occurred: #{e.message}")
end
end

def self.parse_subject(subject)
subject.to_a.each_with_object({}) do |field, hash|
hash[field[0]] = field[1]
end
end

def self.extract_team_id(subject)
subject['OU'] || "Not found"
end

def self.extract_description(subject)
cn = subject['CN'] || ""
o = subject['O'] || ""
"#{cn}#{o.empty? ? '' : " (#{o})"}"
end
end

至此,我们已经能够在新版 fastlane 中进行对证书的解密和对证书的解析。

最后我们来看一下,iOS 证书里面我解析出了哪些信息

  • subject: 证书的主题,通常包含证书持有者的信息。
  • issuer: 证书的颁发者,即签发这个证书的机构(通常是Apple)。
  • serial: 证书的序列号,是一个唯一标识符。
  • not_before: 证书的生效日期,在这个日期之前证书是无效的。
  • not_after: 证书的过期日期,在这个日期之后证书将失效。
  • public_key: 证书中包含的公钥,用于验证与之配对的私钥签名的内容。
  • team_id: 开发者团队ID,这是从证书主题中提取出来的。每个Apple开发者账号都有一个唯一的团队ID。
  • description: 证书的描述信息,也是从证书主题中提取的。