Mac公证脚本-Web公证方式

公证方式

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 进行脚本自动化公证

https://developer.apple.com/documentation/notaryapi/submitting_software_for_notarization_over_the_web

流程

具体的流程大概如下:

  1. 获取 API密钥 (Private Key)
  2. 使用 API密钥 (Private Key) 生成 JSON Web Token (JWT), 相当于授权令牌 token
  3. 请求 Notary API 并在 HTTP 请求头中,携带 JWT 内容
  4. 得到请求结果

1. 获取 API密钥

https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api

官方文档阐述

  1. 登录App Store Connect
  2. 选择 [用户和访问] 这栏 ,然后选择API Keys选项卡
  3. 单击生成API密钥或添加(+)按钮。
  4. 输入密钥的名称。
  5. 在Access下,为密钥选择角色
  6. 点击生成
  7. 点击下载秘钥 (注意下载后苹果不再保存,你只能下载一次)

根据上述流程获取你的 API 秘钥

2. 生成令牌 Token

https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests

2.1. 环境安装
  1. 根据 https://jwt.io/libraries 所述,安装 pyjwt 库
  2. 由于本地环境 python3, 使用 pip3 install pyjwt 安装, 并参考官网使用说明 https://pyjwt.readthedocs.io/en/stable/usage.html#encoding-decoding-tokens-with-hs256
  3. pyjwt 依赖 cryptography 库,需要额外安装 pip3 install cryptography
  4. 文件上传依赖 boto3 库,pip3 install boto3
2.2. JWT Encode/Decode

参考 https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests 所述进行设置,值得注意的是,苹果会拒绝大多数设置过期时间为 20 分钟以上的 JWT 票据,所以我们需要间隔生成 JWT

  1. header

    {
    "alg": "ES256",
    "kid": "2X9R4HXF34",
    "typ": "JWT"
    }

  2. body

    {
    "iss": "57246542-96fe-1a63-e053-0824d011072a",
    "iat": 1528407600,
    "exp": 1528408800,
    "aud": "appstoreconnect-v1",
    "scope": [
    "GET /notary/v2/submissions",
    "POST /notary/v2/submissions",
    ]
    }

  3. 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. 公证上传

公证处理的流程如下

  1. POST https://appstoreconnect.apple.com/notary/v2/submissions 请求submission s3 上传凭证

  2. 使用 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 ...')
    
  3. 查询公证状态,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 False

     sub_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
    
  4. 获取日志内容

    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))

  5. 如果 步骤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

  1. 替换你的秘钥文件 (例如 AuthKey_2X9R4HXF34.p8)

    private_key = f"./../../res/AuthKey_2X9R4HXF34.p8"

  2. 设置你的 kid

    设置 JWT 的 header

     jwt_header = {
         "alg": "ES256",
         "kid": "2X9R4HXF34",
         "typ": "JWT"
     }
    
  3. 设置你的 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",
         ]
     }
    
  4. 调用脚本

    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

相关推荐
LZXCyrus7 分钟前
【杂记】vLLM如何指定GPU单卡/多卡离线推理
人工智能·经验分享·python·深度学习·语言模型·llm·vllm
Enougme10 分钟前
Appium常用的使用方法(一)
python·appium
懷淰メ16 分钟前
PyQt飞机大战游戏(附下载地址)
开发语言·python·qt·游戏·pyqt·游戏开发·pyqt5
hummhumm30 分钟前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
Rstln1 小时前
【DP】个人练习-Leetcode-2019. The Score of Students Solving Math Expression
算法·leetcode·职场和发展
每天吃饭的羊1 小时前
python里的数据结构
开发语言·python
卡卡_R-Python1 小时前
UCI Heart Disease Data Set—— UCI 心脏病数据集介绍
python·plotly·django·virtualenv·pygame
饮长安千年月1 小时前
浅谈就如何解出Reverse-迷宫题之老鼠走迷宫的一些思考
python·网络安全·逆向·ctf
好看资源平台1 小时前
网络爬虫——爬虫项目案例
爬虫·python