公证方式
Mac 公证方式有三种
|----------------|-------------------|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 公证方法 | 优点 | 缺点 | 阐述 |
| Xcode | Xcode携带的图形界面,使用方便 | 无法进行自动化公证 | 单个App应用上架使用较多 |
| altool(旧版) | 支持pkg,dmg,脚本自动化 | 2023/11/01 将会过期 | 已经是弃用,不建议使用 Mac开发-公证流程记录Notarization-附带脚本_xcrun altool --notarize-app --primary-bundle-id-CSDN博客 |
| notarytool(新版) | 支持pkg,dmg,脚本自动化 | 必须依赖macos环境 | 必须要依赖macos环境,并且更新Xcode版本和mac版本,有一定的环境限制 |
| Notary API | 支持pkg,dmg,脚本自动化 | 无需依赖macos环境 | 使用的是苹果官方提供的Web API进行公证,不受运行环境限制 |
这里 Notary API 有较大的优势,之前 altool 脚本公证的方式我们已经做过,由于 2023/11/01 将被弃用,考虑后续跨平台的需要,使用 notary API 进行脚本自动化公证
流程
具体的流程大概如下:
- 获取 API密钥 (Private Key)
- 使用 API密钥 (Private Key) 生成 JSON Web Token (JWT), 相当于授权令牌 token
- 请求 Notary API 并在 HTTP 请求头中,携带 JWT 内容
- 得到请求结果
1. 获取 API密钥
官方文档阐述
- 登录App Store Connect
- 选择 [用户和访问] 这栏 ,然后选择API Keys选项卡
- 单击生成API密钥或添加(+)按钮。
- 输入密钥的名称。
- 在Access下,为密钥选择角色
- 点击生成
- 点击下载秘钥 (注意下载后苹果不再保存,你只能下载一次)
根据上述流程获取你的 API 秘钥
2. 生成令牌 Token
https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests
2.1. 环境安装
- 根据 https://jwt.io/libraries 所述,安装 pyjwt 库
- 由于本地环境 python3, 使用 pip3 install pyjwt 安装, 并参考官网使用说明 https://pyjwt.readthedocs.io/en/stable/usage.html#encoding-decoding-tokens-with-hs256
- pyjwt 依赖 cryptography 库,需要额外安装 pip3 install cryptography
- 文件上传依赖 boto3 库,pip3 install boto3
2.2. JWT Encode/Decode
参考 https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests 所述进行设置,值得注意的是,苹果会拒绝大多数设置过期时间为 20 分钟以上的 JWT 票据,所以我们需要间隔生成 JWT
-
header
{
"alg": "ES256",
"kid": "2X9R4HXF34",
"typ": "JWT"
} -
body
{
"iss": "57246542-96fe-1a63-e053-0824d011072a",
"iat": 1528407600,
"exp": 1528408800,
"aud": "appstoreconnect-v1",
"scope": [
"GET /notary/v2/submissions",
"POST /notary/v2/submissions",
]
} -
API 秘钥
对应的生成逻辑如下
def get_jwt_token():
# 读取私钥文件内容
with open('./AuthKey_xxxxx.p8', 'rb') as f:
jwt_secret_key = f.read()
# 获取当前时间
now = datetime.datetime.now()
# 计算过期时间(当前时间往后 20 分钟)
expires = now + datetime.timedelta(minutes=20)
# 设置 JWT 的 header
jwt_header = {
"alg": "ES256",
"kid": "2X9R4HXF34",
"typ": "JWT"
}
# 检查文件是否存在
if os.path.exists("./jwt_token"):
# 读取
with open('./jwt_token', 'rb') as f:
jwt_pre_token = f.read()
print('[info]','jwt token %s' % (jwt_pre_token))
try:
decoded = jwt.decode(
jwt_pre_token,
jwt_secret_key,
algorithms="ES256",
audience="appstoreconnect-v1"
)
except Exception as e:
print('[error]', 'decode exception %s' % (e))
else:
exp = datetime.datetime.fromtimestamp(decoded["exp"])
if exp - datetime.timedelta(seconds=60) < now:
print("jwt token 已过期,重新生成")
else:
print("jwt token 有效,使用之前token")
return jwt_pre_token
# 设置 JWT 的 payload
jwt_payload = {
"iss": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"iat": int(now.timestamp()),
"exp": int(expires.timestamp()),
"aud": "appstoreconnect-v1",
"scope": [
"GET /notary/v2/submissions",
"POST /notary/v2/submissions",
]
}
print('[info]', 'jwt_header %s' % (jwt_header), 'jwt_payload %s' % jwt_payload)
token = jwt.encode(
jwt_payload,
jwt_secret_key,
algorithm="ES256",
headers=jwt_header,
)
# 打开文件,如果文件不存在则创建文件
with open("./jwt_token", "w") as f:
# 将 token 写入文件
f.write(token)
print('[info]', 'jwt token is %s' % (token))
return token
3. 公证上传
公证处理的流程如下
-
POST https://appstoreconnect.apple.com/notary/v2/submissions 请求submission s3 上传凭证
-
使用 boto3 框架根据获取的凭证信息,查询一次公证状态,如果非 Accepted 状态,进行上传文件。(阻塞式)
file_md5=None
def post_submissison(filepath):
global file_md5
body = get_body(filepath)
token = get_jwt_token()
file_md5 = get_md5(filepath)# 指定文件夹路径 folder_path = './output' # 缓存路径 cache_path = f"{folder_path}/{file_md5}" # 检查文件夹是否存在 if not os.path.exists(folder_path): # 如果文件夹不存在,则创建文件夹 os.makedirs(folder_path) else: # 如果文件夹已经存在,则进行相应的处理 print("[info]", '%s 已经存在' % folder_path) # 检查文件是否存在 if os.path.exists(cache_path): # 读取 with open(cache_path, 'rb') as f: string = f.read().decode() output = json.loads(string) print('[info]', '使用上次 submission s3 上传凭证 = %s' % (output)) else: resp = requests.post("https://appstoreconnect.apple.com/notary/v2/submissions", json=body, headers={"Authorization": "Bearer " + token}) resp.raise_for_status() output = resp.json() print('[info]', '获取 submission s3上传凭证 = %s' % (output)) # 打开文件,如果文件不存在则创建文件 with open(cache_path, "w") as f: # 将 resp 写入文件 f.write(resp.content.decode()) # 读取 output 中的内容 aws_info = output["data"]["attributes"] bucket = aws_info["bucket"] key = aws_info["object"] # sub_id = output["data"]["id"] # 如果已经完成了公证 state = get_submission_state(filepath, True) if state == True: print('[info]', 'file %s alreay finished notarization' % (filepath)) staple_pkg(filepath) exit(0) s3 = boto3.client( "s3", aws_access_key_id=aws_info["awsAccessKeyId"], aws_secret_access_key=aws_info["awsSecretAccessKey"], aws_session_token=aws_info["awsSessionToken"], config=Config(s3={"use_accelerate_endpoint": True}) ) print('[info]', 'start upload files ...... please wait 2-15 mins') # 上传文件 s3.upload_file(filepath, bucket, key) print('[info]', 'upload file complete ...')
-
查询公证状态,Accepted 、In Progress、Invalid 目前探测到这三种状态
def get_submission_state(filepath, once=False):
print('[info]', 'get_submission_state %s %s ' % (filepath, once))
global file_md5
if not file_md5:
file_md5 = get_md5(filepath)
# 指定文件夹路径
folder_path = './output'
# 缓存路径
cache_path = f"{folder_path}/{file_md5}"
# 检查文件是否存在
if os.path.exists(cache_path):
# 获取文件大小
file_size = os.path.getsize(cache_path)
if file_size == 0:
# 文件内容为空
print('[info]', ' %s 内容为空,未获取到submission信息' % (filepath))
return False
else:
# 读取缓存内容
with open(cache_path, 'rb') as f:
string = f.read().decode()
output = json.loads(string)
else:
return Falsesub_id = output["data"]["id"] url = f"https://appstoreconnect.apple.com/notary/v2/submissions/{sub_id}" ret = False while True: try: # 获取submission token = get_jwt_token() resp = requests.get(url, headers={"Authorization": "Bearer " + token}) resp.raise_for_status() except Exception as e: # 异常处理 print("[Error]", ' %s get status failed, code = %s ' % filepath % resp.status_code) return False else: # 200 正常返回处理 # 检查 status resp_json = resp.json() print('[info]', 'GET %s resp is %s , header is %s' % (url,resp_json,resp.headers)) status = resp_json["data"]["attributes"]["status"] if status == "Accepted": print("[info]", ' %s notarization succesfull' % filepath) ret = True break if status == "Invalid": print("[info]", ' %s notarization failed' % filepath) ret = False break if once == False: # 暂停 30 秒 time.sleep(30) else: print("[info]", 'get_submission_state run once') break if once == False: print_submission_logs(sub_id) return ret
-
获取日志内容
def print_submission_logs(identifier):
try:
url = f"https://appstoreconnect.apple.com/notary/v2/submissions/{identifier}/logs"
token = get_jwt_token()
resp = requests.get(url, headers={"Authorization": "Bearer " + token})
resp.raise_for_status()
except Exception as e:
print("[Error]", '/notary/v2/submissions/%s/logs failed, code = %s ' % (identifier, resp.status_code))
else:
resp_json = resp.json()
print('[info]', 'notarization %s logs is %s' % (identifier, resp_json)) -
如果 步骤3 查询到结果为 Accepted,则使用 stapler 工具打上票据,进行分发
def staple_pkg(filepath):
global file_md5
if not file_md5:
file_md5 = get_md5(filepath)
# 完成公证
subprocess.run(["xcrun", "stapler", "staple", filepath])
now = datetime.datetime.now()
# 验证公证结果
temp_output_file = f"./temp_file_{file_md5}"
with open(temp_output_file, "w") as f:
subprocess.run(["xcrun", "stapler", "validate", filepath], stdout=f, stderr=subprocess.STDOUT)# 读取验证结果 with open(temp_output_file, "r") as f: validate_result = f.read() os.remove(temp_output_file) # 检查验证结果 if "The validate action worked!" not in validate_result: print('[error]',"\033[31m[error] stapler validate invalid, may be notarization failed!\033[0m") return False else: print('[info]','staple_pkg succesfull') return True
4. 脚本使用方式
脚本文件 https://github.com/CaicaiNo/Apple-Mac-Notarized-script/blob/master/notarize-web/notarize.py
在执行下列步骤前,请先阅读 Generating Tokens for API Requests | Apple Developer Documentation
-
替换你的秘钥文件 (例如 AuthKey_2X9R4HXF34.p8)
private_key = f"./../../res/AuthKey_2X9R4HXF34.p8"
-
设置你的 kid
设置 JWT 的 header
jwt_header = { "alg": "ES256", "kid": "2X9R4HXF34", "typ": "JWT" }
-
设置你的 iss
设置 JWT 的 payload
jwt_payload = { "iss": "57246542-96fe-1a63-e053-0824d011072a", "iat": int(now.timestamp()), "exp": int(expires.timestamp()), "aud": "appstoreconnect-v1", "scope": [ "GET /notary/v2/submissions", "POST /notary/v2/submissions", ] }
-
调用脚本
python3 -u ./notarize.py --pkg "./Output/{PACKAGE_NAME}_TIME_INDEX.pkg" --private-key "./../../res/AuthKey_2X9R4HXF34.p8"
if [ ? -eq 0 ]; then echo "./Output/aTrustInstaller_TIME_INDEX.pkg notarization successful"
// 公证成功
else
// 公证失败
echo "./Output/aTrustInstaller_$TIME_INDEX.pkg notarization failed"
exit 1
fi