├── .flake8 ├── .gitignore ├── LICENSE ├── README.md ├── README_EN.md ├── docs ├── Authentication.md ├── api.text └── img │ ├── img1.png │ ├── img2.png │ ├── img3.png │ └── img4.png ├── icertools ├── __init__.py ├── _proto.py ├── _utils.py ├── api.py └── resign.py └── requirements.txt /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 150 3 | ignore = 4 | E501 5 | F401 6 | E402 7 | W292 8 | F403 9 | F821 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | .DS_Store 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | *.ipa 30 | *dev.py 31 | _tmp 32 | *.p8 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # pdm 110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 111 | #pdm.lock 112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 113 | # in version control. 114 | # https://pdm.fming.dev/#use-with-ide 115 | .pdm.toml 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | venv* 131 | env/ 132 | venv/ 133 | ENV/ 134 | env.bak/ 135 | venv.bak/ 136 | 137 | # Spyder project settings 138 | .spyderproject 139 | .spyproject 140 | 141 | # Rope project settings 142 | .ropeproject 143 | 144 | # mkdocs documentation 145 | /site 146 | 147 | # mypy 148 | .mypy_cache/ 149 | .dmypy.json 150 | dmypy.json 151 | 152 | # Pyre type checker 153 | .pyre/ 154 | 155 | # pytype static type analyzer 156 | .pytype/ 157 | 158 | # Cython debug symbols 159 | cython_debug/ 160 | 161 | # PyCharm 162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 163 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 164 | # and can be added to the global gitignore or merged into this file. For a more nuclear 165 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 166 | #.idea/ 167 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # icertools 2 | 3 | 纯Python3实现的**iOS证书管理**和**IPA重签名工具**,无需提前登录[苹果开发者网站](https://developer.apple.com/account/resources/devices/list) 进行相关操作。原理参考[XCode自动管理证书](https://www.jianshu.com/p/035ae1f1e563) 4 | 5 | 6 | 没有这个工具前, 使用个人开发者账号对IPA`重签`时,需要进入苹果开发者网站(输入验证码登录),添加新设备,然后生成profile文件, 然后导出profile文件, 再用一些重签名工具进行重签, 非常麻烦。 现在有了这个工具,只需调用一个API即可完成重签,非常之高效。 7 | 8 | [English](README_EN.md) 9 | 10 | # Requirements 11 | 12 | - Python3.6+ 13 | - MacOS 14 | 15 | 16 | # Features 17 | 18 | - IPA重签名 19 | - 添加注册设备 20 | - 查找设备列表 21 | - 查找证书列表 22 | - 查找BundleId列表 23 | - 创建BundleId 24 | - 查找Profile列表 25 | - 创建Profile 26 | - 删除证书 27 | 28 | 29 | # Usage 30 | 31 | ## Auth 32 | - 需提前去[苹果开发者网站](https://developer.apple.com/account/resources/devices/list)创建一个证书,安装到电脑上(只用执行这一次,以后都不用) 33 | - 参考 [API Authentication.md](/docs/Authentication.md) 获取API鉴权信息, `key_file`, `key_id`, `issuer_id`, `user_id` 34 | 35 | 36 | ## Code Example 37 | 38 | ```python3 39 | import json 40 | from icertools.api import Api 41 | from icertools.resign import Resign 42 | 43 | # Auth info 44 | key_file = '/Users/xx/AuthKey_xxx.p8' # replace with your key file path 45 | key_id = "xxx" # replace with your key id 46 | issuer_id = "xxx" # replace with your issuer id 47 | user_id = "xxx" # replace with your user id 48 | 49 | 50 | api = Api( 51 | key_file=key_file, 52 | key_id=key_id, 53 | issuer_id=issuer_id, 54 | user_id=user_id) 55 | 56 | 57 | # List Devices 58 | data = api.list_devices() 59 | print(json.dumps(data, indent=4)) 60 | 61 | 62 | # List Bundle ID 63 | data = api.list_bundle_ids() 64 | print(json.dumps(data, indent=4)) 65 | 66 | 67 | # Create Bundle ID 68 | data = api.create_bundle_id("test", "*") 69 | print(json.dumps(data, indent=4)) 70 | 71 | 72 | # List Certificates 73 | data = api.list_certificates() 74 | print(json.dumps(data, indent=4)) 75 | 76 | 77 | # Create Certificate 78 | csr_path = "/Users/xx/CertificateSigningRequest.certSigningRequest" # replace with your certSigningRequest path 79 | data = api.create_certificate(csr_path) 80 | print(json.dumps(data, indent=4)) 81 | 82 | 83 | # List Profiles 84 | data = api.list_profiles() 85 | print(json.dumps(data, indent=4)) 86 | 87 | 88 | # Delete Profile 89 | api.delete_profile("8SX4Z2FBUL") 90 | 91 | 92 | # Register Device 93 | data = api.register_device("00008030-0004598921BB802E", "iPhone") 94 | print(json.dumps(data, indent=4)) 95 | 96 | 97 | # Resign IPA 98 | input_ipa_path = "/Users/develop/tmp/ios-test.ipa" 99 | output_ipa_path = "/Users/develop/tmp/resgin/r-ios-test.ipa" 100 | r = Resign(api, input_ipa_path, output_ipa_path) 101 | r.resign_ipa() 102 | ``` 103 | 104 | 105 | # Refer to 106 | - [Xcode自动管理证书](https://www.jianshu.com/p/035ae1f1e563) 107 | - https://github.com/Ponytech/appstoreconnectapi 108 | - https://developer.apple.com/documentation/appstoreconnectapi 109 | - https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # icertools 2 | 3 | A pure Python 3 implementation for **iOS certificate management** and **IPA re-signing**, Don't need to login the https://developer.apple.com/account/resources/devices/list for related operations. The principle reference to https://www.jianshu.com/p/035ae1f1e563. 4 | 5 | Before this tool, using a personal developer account to re-sign an IPA required logging into the Apple Developer Website (with a verification code), adding new devices, generating a profile file, exporting it, and then using some re-signing tools. It was quite cumbersome. Now, with this tool, you can complete the re-signing with just one API call, making it extremely efficient. 6 | 7 | 8 | [中文](README.md) 9 | 10 | # Requirements 11 | 12 | - Python3.6+ 13 | - MacOS 14 | 15 | 16 | # Features 17 | 18 | - IPA re-signing 19 | - Add registered devices 20 | - Retrieve device list 21 | - Retrieve certificate list 22 | - Retrieve Bundle ID list 23 | - Create Bundle ID 24 | - Retrieve Profile list 25 | - Create Profile 26 | - Delete certificates 27 | 28 | 29 | # Usage 30 | 31 | ## Auth 32 | - You need to create a certificate on the https://developer.apple.com/account/resources/devices/list and install it on your computer (only once, no need to repeat). 33 | - Refer to [API Authentication.md](/docs/Authentication.md) to obtain API authentication information: `key_file`, `key_id`, `issuer_id`, `user_id`. 34 | 35 | 36 | ## Code Example 37 | 38 | ```python3 39 | import json 40 | from icertools.api import Api 41 | from icertools.resign import Resign 42 | 43 | # Auth info 44 | key_file = '/Users/xx/AuthKey_xxx.p8' # replace with your key file path 45 | key_id = "xxx" # replace with your key id 46 | issuer_id = "xxx" # replace with your issuer id 47 | user_id = "xxx" # replace with your user id 48 | 49 | 50 | api = Api( 51 | key_file=key_file, 52 | key_id=key_id, 53 | issuer_id=issuer_id, 54 | user_id=user_id) 55 | 56 | 57 | # List Devices 58 | data = api.list_devices() 59 | print(json.dumps(data, indent=4)) 60 | 61 | 62 | # List Bundle ID 63 | data = api.list_bundle_ids() 64 | print(json.dumps(data, indent=4)) 65 | 66 | 67 | # Create Bundle ID 68 | data = api.create_bundle_id("test", "*") 69 | print(json.dumps(data, indent=4)) 70 | 71 | 72 | # List Certificates 73 | data = api.list_certificates() 74 | print(json.dumps(data, indent=4)) 75 | 76 | 77 | # Create Certificate 78 | csr_path = "/Users/xx/CertificateSigningRequest.certSigningRequest" # replace with your certSigningRequest path 79 | data = api.create_certificate(csr_path) 80 | print(json.dumps(data, indent=4)) 81 | 82 | 83 | # List Profiles 84 | data = api.list_profiles() 85 | print(json.dumps(data, indent=4)) 86 | 87 | 88 | # Delete Profile 89 | api.delete_profile("8SX4Z2FBUL") 90 | 91 | 92 | # Register Device 93 | data = api.register_device("00008030-0004598921BB802E", "iPhone") 94 | print(json.dumps(data, indent=4)) 95 | 96 | 97 | # Resign IPA 98 | input_ipa_path = "/Users/develop/tmp/ios-test.ipa" 99 | output_ipa_path = "/Users/develop/tmp/resgin/r-ios-test.ipa" 100 | r = Resign(api, input_ipa_path, output_ipa_path) 101 | r.resign_ipa() 102 | ``` 103 | 104 | 105 | # Refer to 106 | - https://www.jianshu.com/p/035ae1f1e563 107 | - https://github.com/Ponytech/appstoreconnectapi 108 | - https://developer.apple.com/documentation/appstoreconnectapi 109 | - https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api -------------------------------------------------------------------------------- /docs/Authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication info 2 | 3 | 1. 登录https://appstoreconnect.apple.com, 进入到 用户和访问 -> 集成 -> 团队密钥 4 | 5 | ![avatar](/docs/img/img1.png) 6 | 1. 创建 API 密钥 7 | ![avatar](/docs/img/img2.png) 8 | 9 | 1. 获取鉴权信息: `key_file`, `key_id`, `issuer_id` 10 | ![avatar](/docs/img/img3.png) 11 | 12 | 1. 登录 https://developer.apple.com/account/resources/devices/list 获取`user_id` 13 | 14 | ![avatar](/docs/img/img4.png) 15 | -------------------------------------------------------------------------------- /docs/api.text: -------------------------------------------------------------------------------- 1 | http request 2 | { 3 | "data": [ 4 | { 5 | "type": "bundleIds", 6 | "id": "97JNQ33YXK", 7 | "attributes": { 8 | "name": "XC Wildcard", 9 | "identifier": "*", 10 | "platform": "UNIVERSAT", 11 | "seedId": "MH2PUTT4SO" 12 | }, 13 | "relationships": { 14 | "bundleIdCapabilities": { 15 | "meta": { 16 | "paging": { 17 | "total": 0, 18 | "limit": 2147483647 19 | } 20 | }, 21 | "links": { 22 | "self": "https://api.appstoreconnect.apple.com/v1/bundleIds/97JNQ33YXK/relationships/bundleIdCapabilities", 23 | "related": "https://api.appstoreconnect.apple.com/v1/bundleIds/97JNQ33YXK/bundleIdCapabilities" 24 | } 25 | }, 26 | "profiles": { 27 | "meta": { 28 | "paging": { 29 | "total": 0, 30 | "limit": 2147483647 31 | } 32 | }, 33 | "links": { 34 | "self": "https://api.appstoreconnect.apple.com/v1/bundleIds/97JNQ33YXK/relationships/profiles", 35 | "related": "https://api.appstoreconnect.apple.com/v1/bundleIds/97JNQ33YXK/profiles" 36 | } 37 | } 38 | }, 39 | "links": { 40 | "self": "https://api.appstoreconnect.apple.com/v1/bundleIds/97JNQ33YXK" 41 | } 42 | }, 43 | { 44 | "type": "bundleIds", 45 | "id": "AXLFKB96G9", 46 | "attributes": { 47 | "name": "test-desktop", 48 | "identifier": "com.test.perf", 49 | "platform": "UNIVERSAL", 50 | "seedId": "MH2PUTT4S7" 51 | }, 52 | "relationships": { 53 | "bundleIdCapabilities": { 54 | "meta": { 55 | "paging": { 56 | "total": 0, 57 | "limit": 2147483647 58 | } 59 | }, 60 | "links": { 61 | "self": "https://api.appstoreconnect.apple.com/v1/bundleIds/AXLFKB96G9/relationships/bundleIdCapabilities", 62 | "related": "https://api.appstoreconnect.apple.com/v1/bundleIds/AXLFKB96G9/bundleIdCapabilities" 63 | } 64 | }, 65 | "profiles": { 66 | "meta": { 67 | "paging": { 68 | "total": 0, 69 | "limit": 2147483647 70 | } 71 | }, 72 | "links": { 73 | "self": "https://api.appstoreconnect.apple.com/v1/bundleIds/AXLFKB96G9/relationships/profiles", 74 | "related": "https://api.appstoreconnect.apple.com/v1/bundleIds/AXLFKB96G9/profiles" 75 | } 76 | } 77 | }, 78 | "links": { 79 | "self": "https://api.appstoreconnect.apple.com/v1/bundleIds/AXLFKB96G9" 80 | } 81 | } 82 | ], 83 | "links": { 84 | "self": "https://api.appstoreconnect.apple.com/v1/bundleIds" 85 | }, 86 | "meta": { 87 | "paging": { 88 | "total": 3, 89 | "limit": 20 90 | } 91 | } 92 | } 93 | 94 | 95 | http request 96 | { 97 | "data": [ 98 | { 99 | "type": "devices", 100 | "id": "46YMD2K9VP", 101 | "attributes": { 102 | "addedDate": "2023-05-17T03:35:45.000+00:00", 103 | "name": "ban-1", 104 | "deviceClass": "IPHONE", 105 | "model": "iPhone 6s Plus", 106 | "udid": "bfcc8c312f6111099877c45129f51a65c4bf9fef", 107 | "platform": "IOS", 108 | "status": "ENABLED" 109 | }, 110 | "links": { 111 | "self": "https://api.appstoreconnect.apple.com/v1/devices/46YMD2K9VP" 112 | } 113 | }, 114 | { 115 | "type": "devices", 116 | "id": "495KTSS9N3", 117 | "attributes": { 118 | "addedDate": "2023-05-17T03:34:28.000+00:00", 119 | "name": "ban-2", 120 | "deviceClass": "IPHONE", 121 | "model": "iPhone 11", 122 | "udid": "00008030-001E58D23CD2802E", 123 | "platform": "IOS", 124 | "status": "ENABLED" 125 | }, 126 | "links": { 127 | "self": "https://api.appstoreconnect.apple.com/v1/devices/495KTSS9N3" 128 | } 129 | }, 130 | { 131 | "type": "devices", 132 | "id": "4FKYV8U654", 133 | "attributes": { 134 | "addedDate": "2023-05-17T03:49:34.000+00:00", 135 | "name": "ban-3", 136 | "deviceClass": "IPHONE", 137 | "model": "iPhone 12 mini", 138 | "udid": "00008101-000D158E0AE8001E", 139 | "platform": "IOS", 140 | "status": "ENABLED" 141 | }, 142 | "links": { 143 | "self": "https://api.appstoreconnect.apple.com/v1/devices/4FKYV8U654" 144 | } 145 | } 146 | ], 147 | "links": { 148 | "self": "https://api.appstoreconnect.apple.com/v1/devices", 149 | "next": "https://api.appstoreconnect.apple.com/v1/devices?cursor=eyJvZmZzZXQiOiIyMCJ9&limit=20" 150 | }, 151 | "meta": { 152 | "paging": { 153 | "total": 100, 154 | "limit": 20 155 | } 156 | } 157 | } 158 | 159 | 160 | http request 161 | { 162 | "data": [ 163 | { 164 | "type": "certificates", 165 | "id": "HDR65Q849Q", 166 | "attributes": { 167 | "serialNumber": "MIIFojCCBIqgAwXXX", 168 | "certificateContent": "MIIFojCCBIqgAwXXX", 169 | "displayName": "Zhao", 170 | "name": "Developer ID Application: MIIFojCCBIqgAwXXX", 171 | "csrContent": null, 172 | "platform": "MAC_OS", 173 | "expirationDate": "2027-02-01T22:12:15.000+00:00", 174 | "certificateType": "DEVELOPER_ID_APPLICATION" 175 | }, 176 | "relationships": { 177 | "passTypeId": { 178 | "links": { 179 | "self": "https://api.appstoreconnect.apple.com/v1/certificates/HDR65Q849Q/relationships/passTypeId", 180 | "related": "https://api.appstoreconnect.apple.com/v1/certificates/HDR65Q849Q/passTypeId" 181 | } 182 | } 183 | }, 184 | "links": { 185 | "self": "https://api.appstoreconnect.apple.com/v1/certificates/HDR65Q849Q" 186 | } 187 | } 188 | ], 189 | "links": { 190 | "self": "https://api.appstoreconnect.apple.com/v1/certificates" 191 | }, 192 | "meta": { 193 | "paging": { 194 | "total": 2, 195 | "limit": 20 196 | } 197 | } 198 | } 199 | 200 | http request 201 | { 202 | "data": { 203 | "type": "certificates", 204 | "id": "6D632G9UGZ", 205 | "attributes": { 206 | "serialNumber": "612539D199DF296A0F52A36E49925D1C", 207 | "certificateContent": "MIIFojCCBIqgAwXXX", 208 | "displayName": "Created via API", 209 | "name": "iOS Development: Created via API", 210 | "csrContent": null, 211 | "platform": "IOS", 212 | "expirationDate": "2025-05-16T06:35:52.000+00:00", 213 | "certificateType": "IOS_DEVELOPMENT" 214 | }, 215 | "relationships": { 216 | "passTypeId": { 217 | "links": { 218 | "self": "https://api.appstoreconnect.apple.com/v1/certificates/6D632G9UGZ/relationships/passTypeId", 219 | "related": "https://api.appstoreconnect.apple.com/v1/certificates/6D632G9UGZ/passTypeId" 220 | } 221 | } 222 | }, 223 | "links": { 224 | "self": "https://api.appstoreconnect.apple.com/v1/certificates/6D632G9UGZ" 225 | } 226 | }, 227 | "links": { 228 | "self": "https://api.appstoreconnect.apple.com/v1/certificates" 229 | } 230 | } 231 | 232 | 233 | http request 234 | { 235 | "data": [ 236 | { 237 | "type": "profiles", 238 | "id": "W532Q3BJ4L", 239 | "attributes": { 240 | "profileState": "INVALID", 241 | "createdDate": "2023-09-04T03:31:21.000+00:00", 242 | "profileType": "IOS_APP_DEVELOPMENT", 243 | "name": "kmonkeyzmlprofile", 244 | "profileContent": "MIIFojCCBIqgAwXXXxxxxxxx", 245 | "uuid": "37eaa64c-c512-4bf7-a051-xxxxx", 246 | "platform": "IOS", 247 | "expirationDate": "2024-09-03T03:31:21.000+00:00" 248 | }, 249 | "relationships": { 250 | "bundleId": { 251 | "links": { 252 | "self": "https://api.appstoreconnect.apple.com/v1/profiles/W532Q3BJ4L/relationships/bundleId", 253 | "related": "https://api.appstoreconnect.apple.com/v1/profiles/W532Q3BJ4L/bundleId" 254 | } 255 | }, 256 | "certificates": { 257 | "meta": { 258 | "paging": { 259 | "total": 0, 260 | "limit": 2147483647 261 | } 262 | }, 263 | "links": { 264 | "self": "https://api.appstoreconnect.apple.com/v1/profiles/W532Q3BJ4L/relationships/certificates", 265 | "related": "https://api.appstoreconnect.apple.com/v1/profiles/W532Q3BJ4L/certificates" 266 | } 267 | }, 268 | "devices": { 269 | "meta": { 270 | "paging": { 271 | "total": 0, 272 | "limit": 2147483647 273 | } 274 | }, 275 | "links": { 276 | "self": "https://api.appstoreconnect.apple.com/v1/profiles/W532Q3BJ4L/relationships/devices", 277 | "related": "https://api.appstoreconnect.apple.com/v1/profiles/W532Q3BJ4L/devices" 278 | } 279 | } 280 | }, 281 | "links": { 282 | "self": "https://api.appstoreconnect.apple.com/v1/profiles/W532Q3BJ4L" 283 | } 284 | } 285 | ], 286 | "links": { 287 | "self": "https://api.appstoreconnect.apple.com/v1/profiles" 288 | }, 289 | "meta": { 290 | "paging": { 291 | "total": 1, 292 | "limit": 20 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /docs/img/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/icertools/0ea29300fbe5fee9d5b746579b2c2c209e13f8b5/docs/img/img1.png -------------------------------------------------------------------------------- /docs/img/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/icertools/0ea29300fbe5fee9d5b746579b2c2c209e13f8b5/docs/img/img2.png -------------------------------------------------------------------------------- /docs/img/img3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/icertools/0ea29300fbe5fee9d5b746579b2c2c209e13f8b5/docs/img/img3.png -------------------------------------------------------------------------------- /docs/img/img4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/icertools/0ea29300fbe5fee9d5b746579b2c2c209e13f8b5/docs/img/img4.png -------------------------------------------------------------------------------- /icertools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codematrixer/icertools/0ea29300fbe5fee9d5b746579b2c2c209e13f8b5/icertools/__init__.py -------------------------------------------------------------------------------- /icertools/_proto.py: -------------------------------------------------------------------------------- 1 | import json 2 | from enum import Enum, auto 3 | 4 | 5 | class Platform(Enum): 6 | IOS = auto() 7 | MAC_OS = auto() 8 | 9 | 10 | class CertificateType(Enum): 11 | IOS_DEVELOPMENT = auto() 12 | IOS_DISTRIBUTION = auto() 13 | MAC_APP_DISTRIBUTION = auto() 14 | MAC_INSTALLER_DISTRIBUTION = auto() 15 | MAC_APP_DEVELOPMENT = auto() 16 | DEVELOPER_ID_KEXT = auto() 17 | DEVELOPER_ID_APPLICATION = auto() 18 | DEVELOPMENT = auto() 19 | DISTRIBUTION = auto() 20 | PASS_TYPE_ID = auto() 21 | PASS_TYPE_ID_WITH_NFC = auto() 22 | 23 | 24 | class ProfileType(Enum): 25 | IOS_APP_DEVELOPMENT = auto() 26 | IOS_APP_STORE = auto() 27 | IOS_APP_ADHOC = auto() 28 | IOS_APP_INHOUSE = auto() 29 | MAC_APP_DEVELOPMENT = auto() 30 | MAC_APP_STORE = auto() 31 | MAC_APP_DIRECT = auto() 32 | TVOS_APP_DEVELOPMENT = auto() 33 | TVOS_APP_STORE = auto() 34 | TVOS_APP_ADHOC = auto() 35 | TVOS_APP_INHOUSE = auto() 36 | MAC_CATALYST_APP_DEVELOPMENT = auto() 37 | MAC_CATALYST_APP_STORE = auto() 38 | MAC_CATALYST_APP_DIRECT = auto() 39 | 40 | 41 | class HTTPResponse: 42 | def __init__(self, content: bytes) -> None: 43 | self.content = content 44 | 45 | def json(self): 46 | return json.loads(self.content) 47 | 48 | @property 49 | def text(self): 50 | return self.content.decode("utf-8", errors="ignore") 51 | 52 | 53 | class BaseInfo: 54 | def __init__(self, _id: str, _type: str) -> None: 55 | self._id = _id 56 | self._type = _type 57 | 58 | def json(self): 59 | return { 60 | "id": self._id, 61 | "type": self._type 62 | } 63 | 64 | 65 | class CertificateInfo(BaseInfo): 66 | pass 67 | 68 | 69 | class BundleInfo(BaseInfo): 70 | pass 71 | 72 | 73 | class DeviceInfo(BaseInfo): 74 | pass 75 | 76 | 77 | class WildCardProfileResult: 78 | def __init__(self, profile_id: str, bundle_info: BundleInfo) -> None: 79 | self.profile_id = profile_id 80 | self.bundle_info = bundle_info 81 | 82 | def json(self): 83 | return { 84 | "profile_id": self.profile_id, 85 | "bundle_info": self.bundle_info.json() 86 | } -------------------------------------------------------------------------------- /icertools/_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import base64 3 | import re 4 | import shutil 5 | import zipfile 6 | import stat 7 | import subprocess 8 | 9 | from cryptography import x509 10 | from cryptography.hazmat.backends import default_backend 11 | from cryptography.hazmat.primitives import hashes 12 | 13 | 14 | # 废弃(需依赖openssl) 15 | def extract_uuid_from_certificate(certificate_content: str) -> str: 16 | """ 17 | Get the fingerprint from the given base64-encoded X.509 certificate. 18 | 19 | Args: 20 | certificate_content (str): A base64-encoded string of the certificate. 21 | 22 | Returns: 23 | str: The fingerprint extracted from the certificate. 24 | 25 | Raises: 26 | ValueError: If the fingerprint cannot be extracted. 27 | 28 | """ 29 | cer_content = base64.b64decode(certificate_content) 30 | 31 | # Use OpenSSL to extract the fingerprint from the certificate 32 | try: 33 | proc = subprocess.Popen( 34 | ['openssl', 'x509', '-noout', '-fingerprint'], 35 | stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE 36 | ) 37 | stdout, stderr = proc.communicate(input=cer_content) 38 | stdout = stdout.decode() 39 | except subprocess.SubprocessError as e: 40 | raise RuntimeError("Failed to run openssl subprocess.") from e 41 | 42 | fingerprint_pattern = r'([0-9A-Fa-f]{2}:){19}[0-9A-Fa-f]{2}' 43 | match = re.search(fingerprint_pattern, stdout) 44 | 45 | if match: 46 | # Remove colons to format the fingerprint as a plain string 47 | return match.group(0).replace(':', '') 48 | else: 49 | error_msg = stderr.decode().strip() if stderr else 'No error message available.' 50 | raise ValueError(f"Fingerprint extraction failed: {error_msg}") 51 | 52 | 53 | def extract_uuid_from_certificate_v2(certificate_content: str) -> str: 54 | """ 55 | Get the fingerprint from the given base64-encoded X.509 certificate. 56 | 57 | Args: 58 | certificate_content (str): A base64-encoded string of the certificate. 59 | 60 | Returns: 61 | str: The fingerprint extracted from the certificate. 62 | 63 | Raises: 64 | ValueError: If the fingerprint cannot be extracted. 65 | 66 | """ 67 | try: 68 | cer_content = base64.b64decode(certificate_content) 69 | 70 | cert = x509.load_der_x509_certificate(cer_content, default_backend()) 71 | 72 | # Extract the fingerprint 73 | fingerprint = cert.fingerprint(hashes.SHA1()) 74 | 75 | # Format the fingerprint as a colon-separated string 76 | fingerprint_hex = fingerprint.hex() 77 | formatted_fingerprint = ":".join(fingerprint_hex[i:i + 2].upper() for i in range(0, len(fingerprint_hex), 2)) 78 | 79 | return formatted_fingerprint.replace(':', '') 80 | 81 | except Exception as e: 82 | raise ValueError(f"Fingerprint extraction failed: {e}") 83 | 84 | 85 | def find_matching_local_certificate(cer_uuids: list) -> str: 86 | """ 87 | Searches through all local certificates bound to the machine and finds 88 | a certificate suitable for code signing that matches the provided cer uuids. 89 | 90 | Args: 91 | uuid_list (list): A list of cer uuids to check against available certificates. 92 | 93 | Returns: 94 | str: The uuid of the matching certificate. 95 | 96 | Raises: 97 | RuntimeError: If no matching certificate is found. 98 | """ 99 | command = ['security', 'find-identity', '-p', 'codesigning', '-v'] 100 | try: 101 | process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 102 | stdout, stderr = process.communicate() 103 | stdout_decoded = stdout.decode() 104 | stderr_decoded = stderr.decode().strip() 105 | except subprocess.SubprocessError as e: 106 | raise RuntimeError(f"Failed to execute system command ({' '.join(command)}) for retrieving certificates: {e}") 107 | 108 | print("All available local certificates:", stdout_decoded) 109 | matched_uuid = None 110 | 111 | # Search for the first match where one of the UUIDs in the list appears in the certificates output 112 | for line in stdout_decoded.splitlines(): 113 | for uuid in cer_uuids: 114 | if uuid and uuid in line: 115 | pattern = r'\d+\) [^"]*"([^"]+)"' 116 | match = re.search(pattern, line) 117 | if match: 118 | matched_uuid = uuid 119 | print(f"Matched a certificate: {match.group(0)}") 120 | return matched_uuid 121 | 122 | if not matched_uuid: 123 | error_message = stderr_decoded or "No error message provided." 124 | raise RuntimeError(f"Failed to match any available certificate for signing: {error_message}") 125 | 126 | return matched_uuid 127 | 128 | 129 | def zip_directory(res_dir, zip_file_path): 130 | with zipfile.ZipFile(zip_file_path, 'w', zipfile.ZIP_DEFLATED) as zipf: 131 | for dirpath, dirnames, filenames in os.walk(res_dir): 132 | for filename in filenames: 133 | file_path = os.path.join(dirpath, filename) 134 | arcname = os.path.relpath(file_path, res_dir) 135 | zipf.write(file_path, arcname) 136 | 137 | 138 | def unzip_file(zip_path: str, extract_to: str): 139 | 140 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: 141 | zip_ref.extractall(extract_to) 142 | print(f"Unzip {zip_path} to {extract_to}") 143 | 144 | # Iterate over the extracted files and directories, and set the execute permissions. 145 | for root, dirs, files in os.walk(extract_to): 146 | for dir_name in dirs: 147 | dir_path = os.path.join(root, dir_name) 148 | os.chmod(dir_path, os.stat(dir_path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) 149 | for file_name in files: 150 | file_path = os.path.join(root, file_name) 151 | os.chmod(file_path, os.stat(file_path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) 152 | 153 | return extract_to 154 | 155 | 156 | def remove_subdirectory(directory: str, subdirectory: str): 157 | subdirectory_path = os.path.join(directory, subdirectory) 158 | if os.path.exists(subdirectory_path): 159 | shutil.rmtree(subdirectory_path) 160 | 161 | 162 | def remove_files_in_directory(directory: str, files: list): 163 | for file_name in files: 164 | file_path = os.path.join(directory, file_name) 165 | if os.path.exists(file_path): 166 | os.remove(file_path) 167 | 168 | 169 | def find_executable_files(base_dir: str, maxdepth=None): 170 | executables = [] 171 | for root, dirs, files in os.walk(base_dir): 172 | if maxdepth is not None and root.count(os.sep) - base_dir.count(os.sep) >= maxdepth: 173 | continue 174 | for name in files: 175 | path = os.path.join(root, name) 176 | if os.access(path, os.X_OK): # Check if the file is executable 177 | executables.append(path) 178 | return executables -------------------------------------------------------------------------------- /icertools/api.py: -------------------------------------------------------------------------------- 1 | import time 2 | import jwt 3 | import requests 4 | import json 5 | from typing import List, Dict, Any 6 | 7 | from requests import HTTPError 8 | from cached_property import cached_property 9 | 10 | from ._proto import HTTPResponse, CertificateType, ProfileType, CertificateInfo, BundleInfo, DeviceInfo 11 | 12 | 13 | ALGORITHM = "RS256" 14 | BASE_URL = 'https://api.appstoreconnect.apple.com/v1' 15 | 16 | 17 | def dohttp(method: str, url, data: Dict[str, Any] = None, headers={}, timeout=15) -> HTTPResponse: 18 | try: 19 | print(f"Http request ") 20 | 21 | _data = json.dumps(data) if data else None 22 | r = requests.request(method, url, data=_data, headers=headers, timeout=timeout) 23 | r.raise_for_status() 24 | response = HTTPResponse(r.content) 25 | 26 | time.sleep(1) # apple api limit 1000/hour 27 | 28 | return response 29 | except requests.RequestException as e: 30 | raise HTTPError(f"HTTP request failed: {e}") 31 | 32 | 33 | class Api: 34 | 35 | # refer: https://developer.apple.com/documentation/appstoreconnectapi 36 | 37 | def __init__(self, key_file, key_id, issuer_id, user_id): 38 | self.key_file = key_file 39 | self.key_id = key_id 40 | self.issuer_id = issuer_id 41 | self.user_id = user_id 42 | 43 | @property 44 | def _api_key(self): 45 | with open(self.key_file, 'r', encoding='utf-8') as file: 46 | return file.read() 47 | 48 | @property 49 | def _token(self): 50 | headers = { 51 | 'alg': 'ES256', 52 | 'kid': self.key_id, 53 | 'typ': 'JWT' 54 | } 55 | payload = { 56 | 'iss': self.issuer_id, 57 | 'exp': time.time() + 60 * 10, 58 | 'aud': 'appstoreconnect-v1' 59 | } 60 | token = jwt.encode(payload, self._api_key, headers=headers, algorithm=ALGORITHM) 61 | return token 62 | 63 | @cached_property 64 | def _headers(self): 65 | headers = { 66 | 'Authorization': f"Bearer {self._token}", 67 | "Content-Type": "application/json" 68 | } 69 | return headers 70 | 71 | def list_devices(self) -> list: 72 | url = f'{BASE_URL}/devices?limit=200' 73 | response = dohttp("GET", url, headers=self._headers) 74 | data = response.json().get("data") 75 | return data 76 | 77 | def register_device(self, udid: str, name: str, platform="IOS"): 78 | url = f'{BASE_URL}/devices' 79 | data = { 80 | 'data': { 81 | 'attributes': { 82 | 'name': name, 83 | 'udid': udid, 84 | 'platform': platform 85 | }, 86 | 'type': 'devices' 87 | } 88 | } 89 | response = dohttp("POST", url, data=data, headers=self._headers) 90 | data = response.json() 91 | return data 92 | 93 | def list_bundle_ids(self) -> list: 94 | url = f'{BASE_URL}/bundleIds' 95 | response = dohttp("GET", url, headers=self._headers) 96 | data = response.json().get("data") 97 | return data 98 | 99 | def gete_bundleid_related_profile(self, profile_id): 100 | url = f"https://api.appstoreconnect.apple.com/v1/profiles/{profile_id}/bundleId" 101 | response = dohttp("GET", url, headers=self._headers) 102 | data = response.json().get("data") 103 | return data 104 | 105 | def create_bundle_id(self, name: str, bundle_id: str, platform="IOS"): 106 | url = f'{BASE_URL}/bundleIds' 107 | data = { 108 | 'data': { 109 | 'attributes': { 110 | "name": name, 111 | 'identifier': bundle_id, 112 | "seedId": self.user_id, 113 | "platform": platform, 114 | }, 115 | 'type': 'bundleIds' 116 | } 117 | } 118 | response = dohttp("POST", url, data=data, headers=self._headers) 119 | data = response.json() 120 | return data 121 | 122 | def list_certificates(self) -> list: 123 | url = f'{BASE_URL}/certificates' 124 | response = dohttp("GET", url, headers=self._headers) 125 | data = response.json().get("data") 126 | return data 127 | 128 | def _validate_certificate_type(self, cert_type): 129 | try: 130 | certificate = CertificateType[cert_type] 131 | print(f"Validated certificate type: {certificate.name}") 132 | except KeyError: 133 | valid_types = ", ".join([type.name for type in CertificateType]) 134 | raise ValueError(f"Invalid certificate type: {cert_type}. Please choose from {valid_types}") 135 | 136 | def _read_csr_content(self, path): 137 | with open(path, 'r', encoding='utf-8') as file: 138 | return file.read() 139 | 140 | def create_certificate(self, csr_path, cert_type="IOS_DEVELOPMENT"): 141 | """ 142 | Create certificate 143 | 144 | Args: 145 | csr_path (str): the file 'CertificateSigningRequest.certSigningRequest' path 146 | cert_type (str): CertificateType 147 | 148 | """ 149 | url = f'{BASE_URL}/certificates' 150 | self._validate_certificate_type(cert_type) 151 | data = { 152 | 'data': { 153 | 'attributes': { 154 | 'certificateType': cert_type, 155 | 'csrContent': self._read_csr_content(csr_path), 156 | }, 157 | 'type': 'certificates' 158 | } 159 | } 160 | response = dohttp("POST", url, data=data, headers=self._headers) 161 | data = response.json() 162 | return data 163 | 164 | def list_profiles(self) -> list: 165 | url = f'{BASE_URL}/profiles' 166 | response = dohttp("GET", url, headers=self._headers) 167 | data = response.json().get("data") 168 | return data 169 | 170 | def _validate_profile_type(self, profile_type): 171 | try: 172 | profile = ProfileType[profile_type] 173 | print(f"Validated profile type: {profile.name}") 174 | except KeyError: 175 | valid_types = ", ".join([type.name for type in ProfileType]) 176 | raise ValueError(f"Invalid profile type: {profile_type}. Please choose from {valid_types}") 177 | 178 | def create_profile(self, 179 | profile_name: str, 180 | bundle_info: BundleInfo, 181 | certificates: List[CertificateInfo], 182 | devices: List[DeviceInfo], 183 | profile_type="IOS_APP_DEVELOPMENT"): 184 | """ 185 | Create profile 186 | 187 | Args: 188 | profile_name (str): Profile name 189 | bundle (BundleInfo): Select BundleInfo 190 | certificates (List[CertificateInfo]): Select List[certificate.json()] 191 | devices (List[DeviceInfo]): Select List[device.json()] 192 | profile_type (str): ProfileType 193 | 194 | """ 195 | if len(certificates) == 0: 196 | raise Exception('Failed to reate profile, certificates is null') 197 | if len(devices) == 0: 198 | raise Exception('Failed to reate profile, devices is null') 199 | 200 | _bundle_info = bundle_info.json() 201 | _certificates = [cer.json() for cer in certificates] 202 | _devices = [d.json() for d in devices] 203 | data = { 204 | 'data': { 205 | 'attributes': { 206 | 'name': profile_name, 207 | 'profileType': profile_type 208 | }, 209 | 'relationships': { 210 | 'bundleId': {'data': _bundle_info}, 211 | 'certificates': {'data': _certificates}, 212 | 'devices': {'data': _devices} 213 | }, 214 | 'type': 'profiles' 215 | } 216 | } 217 | 218 | url = f'{BASE_URL}/profiles' 219 | response = dohttp("POST", url, headers=self._headers, data=data) 220 | data = response.json().get("data") 221 | return data 222 | 223 | def delete_profile(self, profile_id): 224 | url = f'{BASE_URL}/profiles/{profile_id}' 225 | dohttp("DELETE", url, headers=self._headers) -------------------------------------------------------------------------------- /icertools/resign.py: -------------------------------------------------------------------------------- 1 | import os 2 | import base64 3 | import shutil 4 | import subprocess 5 | from typing import List, Tuple 6 | 7 | from .api import Api 8 | from ._utils import * 9 | from ._proto import * 10 | 11 | 12 | class Resign: 13 | def __init__(self, api: Api, input_ipa_path: str, output_ipa_path) -> None: 14 | self.api = api 15 | self.input_ipa_path = input_ipa_path 16 | self.output_ipa_path = output_ipa_path 17 | 18 | self.workspace = None 19 | self._tmp_path = None 20 | self._init_workspace_path() 21 | 22 | def _init_workspace_path(self): 23 | # Check input path 24 | if not os.path.exists(self.input_ipa_path): 25 | raise RuntimeError(f"Ipa file not exists.<{self.input_ipa_path}>") 26 | 27 | # Init output dir 28 | output_dir = os.path.dirname(self.output_ipa_path) 29 | if not output_dir: 30 | raise RuntimeError(f"Invaild output path.<{self.output_ipa_path}>") 31 | if not os.path.exists(output_dir): 32 | os.makedirs(output_dir, exist_ok=True) 33 | 34 | self.workspace = output_dir 35 | self._tmp_path = os.path.join(self.workspace, "_tmp") 36 | 37 | def _find_wildcard_profile_and_bundle(self) -> WildCardProfileResult: 38 | """ 39 | Searches the current account for a profile with an identifier of "*". 40 | Retrieves the bundle through the profile, then obtains the identifier. 41 | """ 42 | print("list profiles...") 43 | profiles = self.api.list_profiles() 44 | print(f"{len(profiles)} profiles found") 45 | 46 | profile_id = None 47 | wildcard_bundle_info: BundleInfo = None 48 | 49 | for profile in profiles: 50 | profile_id = profile.get("id") 51 | bundleid_data = self.api.gete_bundleid_related_profile(profile_id) 52 | 53 | identifier = bundleid_data['attributes']['identifier'] 54 | if '*' == identifier: 55 | print(f"Match identifier == '*' profile: <{profile_id}>") 56 | wildcard_bundle_info = BundleInfo(_id=bundleid_data['id'], _type=bundleid_data['type']) 57 | return WildCardProfileResult(profile_id, wildcard_bundle_info) 58 | 59 | if wildcard_bundle_info is None: 60 | print('No profile matched identifier == "*"!') 61 | 62 | return WildCardProfileResult(None, None) 63 | 64 | def _get_or_create_wildcard_bundle(self) -> BundleInfo: 65 | """ 66 | Search the current account for a bundle with an identifier of "*". 67 | If not found, create one. 68 | 69 | Returns: 70 | BundleInfo: The bundle information if a bundle is found or created. 71 | 72 | Raises: 73 | RuntimeError: If no bundle is found and creation fails. 74 | """ 75 | bundles = self.api.list_bundle_ids() 76 | 77 | for bundle in bundles: 78 | identifier = bundle['attributes']['identifier'] 79 | if identifier == '*': 80 | print(f"Matched identifier == '*' bundle: <{bundle['id']}>") 81 | return BundleInfo(_id=bundle['id'], _type=bundle['type']) 82 | 83 | print('No bundle_id matched identifier == "*"!, so Create one') 84 | try: 85 | data = self.api.create_bundle_id(name="API Wildcard", bundle_id="*") 86 | return BundleInfo(_id=data['data']['attributes']['identifier'], _type=data['data']['type']) 87 | except Exception: 88 | print('Failed to create bundle_id: identifier == "*"') 89 | raise RuntimeError('Failed to create bundle_id: identifier == "*"') 90 | 91 | def _get_all_certificates_info(self) -> Tuple[List[str], List[CertificateInfo]]: 92 | """ 93 | Retrieves all certificates' UUIDs and info. 94 | Returns a tuple containing a list of UUIDs and a list of CertificateInfo objects. 95 | 96 | Raises: 97 | RuntimeError: If there are no valid certificates in the current account. 98 | """ 99 | certificates = self.api.list_certificates() 100 | if not certificates: 101 | raise RuntimeError("No valid certificates found in the current account!") 102 | 103 | cer_uuids = [] 104 | cer_infos = [] 105 | for certificate in certificates: 106 | certificateContent = certificate['attributes']['certificateContent'] 107 | uuid = extract_uuid_from_certificate_v2(certificateContent) 108 | cer_uuids.append(uuid) 109 | certificate_info = CertificateInfo( 110 | _id=certificate['id'], 111 | _type=certificate['type'] 112 | ) 113 | cer_infos.append(certificate_info) 114 | 115 | return cer_uuids, cer_infos 116 | 117 | def _get_all_device_infos(self) -> List[DeviceInfo]: 118 | """ 119 | Retrieves all devices info. 120 | 121 | Returns: 122 | List[DeviceInfo]: deivces info. 123 | """ 124 | devices = self.api.list_devices() 125 | devices_infos: List[DeviceInfo] = [] 126 | for device in devices: 127 | info = DeviceInfo(_id=device['id'], _type=device['type']) 128 | devices_infos.append(info) 129 | 130 | return devices_infos 131 | 132 | def _create_wildcard_profile( 133 | self, 134 | wildcard_bundle_info: BundleInfo, 135 | certificate_infos: List[CertificateInfo], 136 | device_infos: List[DeviceInfo], 137 | output_dir: str 138 | ) -> str: 139 | """ 140 | Creates a new profile with identifier == "*". 141 | 142 | Args: 143 | wildcard_cer_info (BundleInfo): Information about the matched wildcard. 144 | certificate_infos (List[CertificateInfo]): Information about the certificates to be bound to the profile. 145 | device_infos (List[DeviceInfo]): Information about the devices to be bound to the profile. 146 | workspace (str): Directory for temporary workspace storage. 147 | 148 | Returns: 149 | str: Path where the profile was saved. 150 | """ 151 | wildcard_profile = self.api.create_profile("wildcard-auto", wildcard_bundle_info, certificate_infos, device_infos) 152 | profile_content = wildcard_profile['attributes']['profileContent'] 153 | profile_name = wildcard_profile['attributes']['name'] 154 | 155 | profile_path = os.path.join(output_dir, f"{profile_name}.mobileprovision") 156 | with open(profile_path, 'wb') as file: 157 | file.write(base64.b64decode(profile_content)) 158 | 159 | print(f"Profile saved successfully: {profile_path}") 160 | return profile_path 161 | 162 | def _gen_entitlements_plist(self, profile_path: str, plist_dir: str) -> str: 163 | """ 164 | Generates entitlements plist from the profile path. 165 | 166 | Args: 167 | profile_path (str): Path to the profile. 168 | plist_dir (str): Directory to save the generated plist. 169 | 170 | Returns: 171 | str: Path to the generated entitlements plist. 172 | """ 173 | embedded_plist_path: str = os.path.join(plist_dir, 'embedded.plist') 174 | entitlements_plist_path: str = os.path.join(plist_dir, 'entitlements.plist') 175 | 176 | # Run 'security cms -D -i' command to extract the plist content 177 | subprocess.run( 178 | ["security", "cms", "-D", "-i", profile_path], 179 | stdout=open(embedded_plist_path, 'w'), 180 | check=True 181 | ) 182 | 183 | # Run 'PlistBuddy' command to extract entitlements 184 | subprocess.run( 185 | ["/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", embedded_plist_path], 186 | stdout=open(entitlements_plist_path, 'w'), 187 | check=True 188 | ) 189 | 190 | return entitlements_plist_path 191 | 192 | def _codesign_file(self, app_dir: str, cer_uuid: str, entitlements_plist_path: str) -> None: 193 | """ 194 | Codesigns the specified app directory. 195 | 196 | Args: 197 | app_dir (str): Directory of the app to be codesigned. 198 | cer_uuid (str): UUID of the certificate. 199 | entitlements_plist_path (str): Path to the entitlements plist. 200 | """ 201 | frameworks_dir: str = os.path.join(app_dir, "Frameworks") 202 | plugIns_dir: str = os.path.join(app_dir, "PlugIns") 203 | 204 | _apps: List[str] = find_executable_files(app_dir, maxdepth=1) 205 | _frameworks: List[str] = find_executable_files(frameworks_dir) if os.path.exists(frameworks_dir) else [] 206 | _plugIns: List[str] = find_executable_files(plugIns_dir) if os.path.exists(plugIns_dir) else [] 207 | 208 | for item in _frameworks + _plugIns + _apps: 209 | subprocess.run([ 210 | "codesign", "-f", "-s", cer_uuid, 211 | f"--entitlements={entitlements_plist_path}", 212 | item 213 | ], check=True) 214 | 215 | def _pack_ipa(self, unzipped_ipa_dir: str, profile_path: str, entitlements_plist_path: str, cer_uuid: str): 216 | """ 217 | Re-signs the unpacked IPA file and compresses it back into a new IPA file. 218 | 219 | Args: 220 | unzipped_ipa_dir (BundleInfo): Information about the matched wildcard. 221 | unzipped_ipa_dir (str): Path to the unpacked original IPA file 222 | profile_path (str): Path to the newly generated profile file 223 | entitlements_plist_path (str): Path to the entitlements plist file 224 | cer_uuid (str): UUID of the certificate for re-signing the IPA file 225 | 226 | Returns: 227 | """ 228 | 229 | def __get_app_directory() -> str: 230 | """ 231 | Gets the directory of the .app file inside the unzipped IPA directory. 232 | """ 233 | payload_dir = os.path.join(unzipped_ipa_dir, 'Payload') 234 | if not os.path.exists(payload_dir): 235 | raise RuntimeError("Payload directory does not exist in the unzipped IPA!") 236 | 237 | app_dirs = [os.path.join(payload_dir, f) for f in os.listdir(payload_dir) if f.endswith('.app')] 238 | 239 | if not app_dirs: 240 | raise RuntimeError(".app directory not found!") 241 | return app_dirs[0] 242 | 243 | app_dir = __get_app_directory() # eg. ~/develop/icertools/_tmp/_tmp/Payload/ios-wda.app 244 | 245 | app_profile = f'{app_dir}/embedded.mobileprovision' 246 | if os.path.exists(app_profile): 247 | os.remove(app_profile) 248 | 249 | shutil.copy(profile_path, app_profile) 250 | 251 | print("Codesigning app file...") 252 | self._codesign_file(app_dir, cer_uuid, entitlements_plist_path) 253 | 254 | print("Zipping app file to IPA...") 255 | zip_directory(unzipped_ipa_dir, self.output_ipa_path) 256 | 257 | def resign_ipa(self): 258 | """ 259 | Resigns the IPA file by performing the following steps: 260 | 1. Extracts the contents of the IPA file to a temporary directory. 261 | 2. Searches for a wildcard profile and bundle. 262 | 3. If a profile is found, deletes it; otherwise, searches for a wildcard bundle. 263 | 4. Retrieves information about all certificates and finds a matching local certificate. 264 | 5. Retrieves information about all devices. 265 | 6. Creates a wildcard profile using the found or generated bundle, certificates, and devices. 266 | 7. Generates entitlements plist based on the created profile. 267 | 8. Raises a RuntimeError if entitlements.plist creation fails. 268 | 9. Packs the IPA file using the unzipped contents, created profile, entitlements plist, and local certificate. 269 | 10. Prints the path to the output IPA file once the process is done. 270 | """ 271 | 272 | extract_to = os.path.join(self._tmp_path, '__tmp') 273 | if os.path.exists(extract_to): 274 | shutil.rmtree(extract_to) 275 | 276 | unzipped_ipa_dir = unzip_file(self.input_ipa_path, extract_to) # eg. ~/develop/icertools/_tmp/__tmp 277 | 278 | print("Find wildcard profile and bundle") 279 | result: WildCardProfileResult = self._find_wildcard_profile_and_bundle() 280 | profile_id = result.profile_id 281 | wildcard_bundle_info = result.bundle_info 282 | 283 | if profile_id: 284 | print(f"Delete profile: {profile_id}") 285 | self.api.delete_profile(profile_id) 286 | else: 287 | wildcard_bundle_info: BundleInfo = self._get_or_create_wildcard_bundle() 288 | 289 | cer_uuids, cer_infos = self._get_all_certificates_info() 290 | local_cer_uuid = find_matching_local_certificate(cer_uuids) # eg. B87C5FAC4EX30EE46...BE79BE2DE916E8503F4X 291 | 292 | devices_info: List[DeviceInfo] = self._get_all_device_infos() 293 | print("Create wildcard profile...") 294 | profile_path = self._create_wildcard_profile(wildcard_bundle_info, cer_infos, devices_info, self._tmp_path) 295 | 296 | print("Generate entitlements plist...") 297 | entitlements_plist_path = self._gen_entitlements_plist(profile_path, self._tmp_path) 298 | 299 | if not os.path.exists(entitlements_plist_path): 300 | raise RuntimeError("Faild to created entitlements.plist!!") 301 | 302 | self._pack_ipa(unzipped_ipa_dir, profile_path, entitlements_plist_path, local_cer_uuid) 303 | print(f"Done: output IPA: {self.output_ipa_path}") -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | cryptography 3 | cached_property 4 | PyJWT==2.8.0 --------------------------------------------------------------------------------