问题背景:证书过期为何难以提前发现
iOS 开发者都有过这样的经历:某天早上 CI 突然报错,日志里显示code sign error,排查半天才发现是 .p12 证书过期,或者描述文件里的设备列表已经变更。证书过期不像代码编译错误那样有明确的报错位置,它更像一颗定时炸弹——在签名那一刻才爆炸。
更麻烦的是,证书和描述文件并不是同一时间过期的。一个 .mobileprovision 描述文件的有效期通常取决于其中包含的证书,而企业证书和开发证书的过期策略又不一致。手动打开 Keychain 逐个查看,再对比描述文件里的ExpirationDate字段,效率很低,也容易遗漏。
把证书检测做成一条 API,目的就是让脚本能够在构建前主动检查证书状态,而不是等签名失败之后再去抢救。
接口能力边界
POST https://v1.apizero.cn/api/ios-cert接收两个文件:.p12证书文件和.mobileprovision描述文件。请求体以 Base64 编码传输,响应中会给出以下信息:
- 证书的基本信息:名称、有效期剩余天数、是否被吊销
- 描述文件的类型:Development(开发)、Distribution(分发)、Enterprise(企业)
- Team ID
- 描述文件包含的设备列表
- 25 项 entitlements 权限声明
- 证书与描述文件的匹配性验证结果
这个接口不负责生成证书,也不提供签名服务,它只做解析和校验。理解这一点很重要:它是检查工具,不是签名工具。
请求参数与鉴权
接口要求两个 Header:
| Header | 必填 | 说明 |
|---|---|---|
Authorization | 是 | 接口鉴权凭证,通常使用 API Key |
Content-Type | 是 | 固定为application/json |
请求体是一个 JSON 对象,包含三个字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
cert | string | 是 | Base64 编码的 .p12 文件内容 |
provision | string | 是 | Base64 编码的 .mobileprovision 文件内容 |
password | string | 否 | 证书密码,默认为空字符串 |
注意:.p12文件是二进制格式,无法直接放进 JSON。需要先用命令行工具转成 Base64 字符串,再作为cert字段的值发送。.mobileprovision文件本身是 XML 格式,但同样建议用 Base64 传输,避免 JSON 转义问题。
还要注意.p12的密码问题。开发证书在创建时通常设置了密码,导出.p12文件时也会要求输入密码。如果证书导出时使用了密码,请求中的password字段就必须填写,否则服务端无法解析 .p12 文件。
用 curl 快速验证接口
先写一个可复制的 curl 示例。实际使用前,需要先执行 Base64 编码操作,将文件转为字符串:
# 假设本地有 cert.p12 和 profile.mobileprovision 两个文件 export CERT_B64=$(base64 < cert.p12) export PROV_B64=$(base64 < profile.mobileprovision) curl -sS \ -X POST \ -H "Authorization: Bearer $APIZERO_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"cert\": \"$CERT_B64\", \"provision\": \"$PROV_B64\", \"password\": \"\"}" \ "https://v1.apizero.cn/api/ios-cert"这里有一个 shell 转义陷阱:-d参数里的双引号必须用\"转义,否则 shell 会把 JSON 截断。如果你的 API 网关要求X-API-Key而不是 Bearer Token,把Authorization那行替换成-H "X-API-Key: $APIZERO_API_KEY"即可,具体以接口文档为准。
如果希望响应更易读,可以加上| jq .管道格式化 JSON 输出:
curl -sS \ -X POST \ -H "Authorization: Bearer $APIZERO_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"cert\": \"$CERT_B64\", \"provision\": \"$PROV_B64\"}" \ "https://v1.apizero.cn/api/ios-cert" | jq .返回字段解读
成功的响应结构如下:
{ "code": 0, "msg": "成功", "data": { "certificate": { "is_revoked": false, "name": "iPhone Developer: ...", "status": "正常" }, "mobileprovision": { "cert_end_days": 350, "cert_type": "Development" }, "is_matching": true, "permissions": { "aps": true, "debug": true, "keychain": true } } }几个关键字段的工程含义:
certificate 对象
is_revoked表示证书是否被 Apple 吊销。吊销的证书即使未过期也不能用于签名,所以这个字段比有效期更值得关注。name是证书的 Common Name,通常形如iPhone Developer: xxx (TEAMID),可以用来核对证书归属人。status是服务端对证书状态的汇总描述。
mobileprovision 对象
cert_end_days表示证书剩余有效期天数。cert_type返回Development、Distribution或Enterprise三者之一。这三种类型的描述文件使用场景差异很大:Development 用于开发调试,Distribution 用于 App Store 提交,Enterprise 用于企业内部分发。拿到这个字段后,可以判断当前描述文件是否被误用在错误的构建环境中。
is_matching 字段
is_matching是布尔值,表示描述文件里包含的证书与传入的 .p12 证书是否匹配。这个验证解决了一个常见问题:开发者手上有多个证书,导出 .p12 时选错了,或者 CI 配置里证书文件和描述文件来自不同的开发者账号。当is_matching为false时,即使签名不报错,最终产物也可能无法安装。
permissions 对象
permissions是一个扁平 JSON 对象,包含 25 个布尔字段,如aps(推送)、keychain(钥匙串共享)、debug(调试权限)。这些字段直接反映描述文件中声明的 entitlements 值。如果应用需要使用推送功能,但检测结果显示aps为false,说明描述文件中没有包含 Push Notification 能力,需要去开发者后台重新生成描述文件。
实际使用:用脚本做证书巡检
curl 适合手动调试。在持续集成场景中,更好的做法是把检测逻辑封装成一个脚本函数,在每次 CI 构建开始前执行。下面是一个用 Python 封装的最小示例:
import base64 import json import sys import urllib.request API_URL = "https://v1.apizero.cn/api/ios-cert" def read_b64(path: str) -> str: with open(path, "rb") as fp: return base64.b64encode(fp.read()).decode("utf-8") def check_cert(cert_path: str, provision_path: str, api_key: str, password: str = ""): payload = { "cert": read_b64(cert_path), "provision": read_b64(provision_path), "password": password, } req = urllib.request.Request( API_URL, data=json.dumps(payload).encode("utf-8"), headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, method="POST", ) with urllib.request.urlopen(req) as resp: result = json.loads(resp.read().decode("utf-8")) if result.get("code") != 0: print(f"API 调用失败: {result.get('msg')}") sys.exit(1) data = result["data"] cert = data["certificate"] prov = data["mobileprovision"] print(f"证书名称: {cert['name']}") print(f"证书状态: {cert['status']}, 吊销: {cert['is_revoked']}") print(f"剩余天数: {prov['cert_end_days']}") print(f"证书类型: {prov['cert_type']}") print(f"证书与描述文件匹配: {data['is_matching']}") # 可在这里添加阈值判断,比如剩余天数小于 30 时告警 if prov["cert_end_days"] < 30: print("[WARN] 证书将在 30 天内过期,请安排更换") sys.exit(2) if data["is_matching"] is False: print("[ERROR] 证书与描述文件不匹配") sys.exit(3) if __name__ == "__main__": check_cert( cert_path="cert.p12", provision_path="profile.mobileprovision", api_key="your_api_key_here", password="", )这个脚本做了几件在工程上有价值的事情:
- 把文件读取和 Base64 编码封装在内部,调用方只需传文件路径。
- 检查 API 返回的
code字段,业务错误直接退出。 - 对
cert_end_days设置告警阈值,剩余天数不足 30 天时以非零退出码终止构建。 - 对
is_matching做硬校验,不匹配时直接失败,避免带病构建。
在 CI 中,只需在正式编译之前执行这个脚本,就能把证书问题拦截在签名阶段之前。
常见错误与处理
401 Unauthorized
Authorization 头缺失或 API Key 无效。检查环境变量是否设置,以及请求中使用的 Header 格式是否和文档一致。
400 Bad Request
请求体 JSON 格式错误,或者必填字段cert、provision缺失。常见原因是 Base64 字符串中包含换行符——使用base64命令时默认会按 76 字符换行,需要去掉换行:
base64 < cert.p12 | tr -d '\n'注意这里使用<而不是cat,避免 shell 将二进制文件内容解释为命令行参数。
解析失败
服务端无法解析 .p12 文件。最常见的原因有两种:
- 密码错误或未填写
password字段 - 传入的文件根本不是 .p12 格式,例如把
.cer或.pem文件误当作 .p12 提交
证书已过期但接口返回代码正常
接口只负责解析和检测,不会因为证书过期而拒绝处理——这正是需要调用方自行判断cert_end_days字段的原因。如果希望“过期即报错”,需要在客户端代码里判断,就像上面的 Python 示例中那样。
工程化注意事项
不要在证书过期前一周才处理
证书过期不是瞬时事件,它有一个时间窗口。常见的做法是在 CI 中设置两级阈值:
- 剩余 60 天:在构建日志中输出警告
- 剩余 14 天:发送报警通知,并允许构建继续
- 剩余 0 天:构建失败
多个证书如何管理
一个 iOS 项目可能同时存在开发证书、发布证书、企业证书。建议把每个证书的检测结果输出到独立文件,或者把 Team ID 作为标签放入文件名,方便对照。
Base64 传输的边界
描述文件大小通常在几 KB 到几十 KB 之间,Base64 编码后会膨胀约 33%,在正常 HTTP 请求体积范围内没有问题。如果你的请求体达到数 MB,需要确认网关是否有请求体大小限制,以文档为准。
不要把 API Key 提交到仓库
这是老生常谈,但在代码示例中仍然值得提醒:curl命令和 Python 脚本中的api_key都应从环境变量读取,不要硬编码。CI 平台一般内置 Secret 管理功能,可以直接把 Key 注入到环境变量中。
参考文档
- 接口文档:https://apizero.cn/aidocs/ios-cert
- 原始文档:https://apizero.cn/aidocs/ios-cert/raw.md