├── .github ├── dependabot.yml └── workflows │ └── build_deploy.yml ├── .gitignore ├── .mvn ├── maven.config └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── CHANGELOG ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── icon.ico ├── misc └── banner.psd ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── at │ │ └── favre │ │ └── tools │ │ └── apksigner │ │ ├── SignTool.java │ │ ├── signing │ │ ├── AndroidApkSignerVerify.java │ │ ├── CertHashChecker.java │ │ ├── SigningConfig.java │ │ ├── SigningConfigGen.java │ │ └── ZipAlignExecutor.java │ │ ├── ui │ │ ├── Arg.java │ │ ├── CLIParser.java │ │ ├── FileArgParser.java │ │ └── MultiKeystoreParser.java │ │ └── util │ │ ├── AndroidApkSignerUtil.java │ │ ├── CmdUtil.java │ │ └── FileUtil.java └── resources │ ├── binary-lib │ ├── linux-lib64-33_0_2 │ │ └── libc++.so │ └── windows-33_0_2 │ │ └── libwinpthread-1.dll │ ├── debug.keystore │ ├── lib │ └── apksigner_33_0_2.jar │ ├── linux-zipalign-33_0_2 │ ├── mac-zipalign-33_0_2 │ └── win-zipalign_33_0_2.exe └── test ├── java └── at │ └── favre │ └── tools │ └── apksigner │ ├── CmdUtilTest.java │ ├── SignToolTest.java │ ├── ui │ ├── CLIParserTest.java │ ├── CertHashCheckerTest.java │ ├── FileArgParserTest.java │ └── MultiKeystoreParserTest.java │ └── util │ └── FileUtilTest.java └── resources ├── test-apks-signed ├── app-first-debug.apk ├── app-fourth-debug.apk ├── app-second-debug.apk ├── app-second-v1.apk ├── app-third-debug.apk └── app-third-v1.apk ├── test-apks-unsigned ├── app-first-release-unsigned.apk ├── app-fourth-release-unsigned.apk ├── app-second-release-unsigned.apk └── app-third-release-unsigned.apk ├── test-debug-to-release.lineage ├── test-debug.jks └── test-release-key.jks /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # dependabot analyzing maven dependencies 2 | version: 2 3 | updates: 4 | - package-ecosystem: "maven" 5 | directory: "/" 6 | open-pull-requests-limit: 3 7 | schedule: 8 | interval: "weekly" 9 | labels: 10 | - "dependencies" 11 | -------------------------------------------------------------------------------- /.github/workflows/build_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy with Maven 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' # Trigger on all tags 9 | pull_request: { } 10 | 11 | env: 12 | SONARQUBE_PROJECT: patrickfav_uber-apk-signer 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 23 | - name: Cache SonarCloud packages 24 | uses: actions/cache@v3 25 | with: 26 | path: ~/.sonar/cache 27 | key: ${{ runner.os }}-sonar 28 | restore-keys: ${{ runner.os }}-sonar 29 | - name: Cache Maven 30 | uses: actions/cache@v3 31 | with: 32 | path: ~/.m2 33 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 34 | restore-keys: ${{ runner.os }}-m2 35 | - name: Set up JDK 17 36 | uses: actions/setup-java@v3 37 | with: 38 | java-version: '17' 39 | distribution: 'temurin' 40 | - name: Build with Maven 41 | run: ./mvnw -B clean verify -DcommonConfig.jarSign.skip=true -Djacoco.skip=true 42 | - name: Prepare Jacoco Coverage Report # Because of strange bug, jacoco needs to be done in separate call 43 | run: ./mvnw -B jacoco:report 44 | - name: Analyze with SonaQube 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 47 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 48 | run: mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=$SONARQUBE_PROJECT 49 | 50 | build_multi_os: 51 | runs-on: ${{ matrix.os }} 52 | 53 | strategy: 54 | matrix: 55 | os: [windows-latest, macos-latest] 56 | 57 | steps: 58 | - name: Checkout Code 59 | uses: actions/checkout@v3 60 | - name: Cache Maven 61 | uses: actions/cache@v3 62 | with: 63 | path: ~/.m2 64 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 65 | restore-keys: ${{ runner.os }}-m2 66 | - name: Set up JDK 17 67 | uses: actions/setup-java@v3 68 | with: 69 | java-version: '17' 70 | distribution: 'temurin' 71 | - name: Build with Maven 72 | run: ./mvnw -B clean package "-DcommonConfig.jarSign.skip=true" 73 | 74 | deploy: 75 | needs: [build, build_multi_os] 76 | if: startsWith(github.ref, 'refs/tags/') 77 | runs-on: ubuntu-latest 78 | 79 | steps: 80 | - uses: actions/checkout@v3 81 | - name: Retrieve Keystore from secrets 82 | env: 83 | KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} 84 | run: | 85 | echo $KEYSTORE_BASE64 | base64 --decode > keystore.jks 86 | - name: Cache Maven 87 | uses: actions/cache@v3 88 | with: 89 | path: ~/.m2 90 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 91 | restore-keys: ${{ runner.os }}-m2 92 | - name: Set up Maven Central Repository 93 | uses: actions/setup-java@v3 94 | with: 95 | java-version: '17' 96 | distribution: 'temurin' 97 | - name: Build with Maven 98 | run: ./mvnw -B verify -DskipTests 99 | env: 100 | OPENSOURCE_PROJECTS_KS_PW: ${{ secrets.KEYSTORE_PASSWORD }} 101 | OPENSOURCE_PROJECTS_KEY_PW: ${{ secrets.KEYSTORE_KEY_PASSWORD }} 102 | - name: Create and upload Github Release 103 | uses: xresloader/upload-to-github-release@v1 104 | env: 105 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | with: 107 | file: "target/uber-apk-signer-*.jar;target/checksum-*.txt" 108 | tags: true 109 | draft: false 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .settings 3 | .metadata 4 | bin 5 | gen 6 | NewFile.* 7 | lint.xml 8 | local.properties 9 | app.properties 10 | junit-report.xml 11 | build 12 | target 13 | 14 | *.dex 15 | *.ap_ 16 | *.class 17 | 18 | out 19 | proguard 20 | /RemoteSystemsTempFiles 21 | .gradle 22 | Thumbs.db 23 | 24 | # ignored intellij 25 | .idea 26 | .navigation 27 | *.iml 28 | *.ipr 29 | 30 | 31 | # Eclipse project files 32 | .classpath 33 | .project 34 | 35 | signing.properties 36 | keystore.jks 37 | pom.xml.versionsBackup 38 | -------------------------------------------------------------------------------- /.mvn/maven.config: -------------------------------------------------------------------------------- 1 | -DcommonConfig.compiler.profile=jdk8 2 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.7/apache-maven-3.8.7-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar 19 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## v1.2.2 4 | 5 | * move to github actions & sonarqube 6 | * update some dependencies 7 | * fix flaky tests 8 | 9 | ## v1.3.0 10 | 11 | * update internal apksigner implementation to 33.0.2 and zipalign binaries 12 | * improve support for v3.1 and v4 signature schema 13 | 14 | 15 | ## v1.2.1 16 | 17 | * minor: fix using incorrect version number 18 | 19 | ## v1.2.0 20 | 21 | * fix using 'outfile.zip should use the same page alignment for all shared object files within infile.zip' by providing correct flag to zipalign (thx @subho007) 22 | * fix some minor vulnerabilities in tests 23 | * update maven dependencies & plugins 24 | 25 | ## v1.1.0 26 | 27 | * support apk signer schema v3 with lineage file support #18 28 | * updates apksigner to v29.0.2 29 | * updates zipalign to v29.0.2 win/mac/linux binaries 30 | * add OSWAP dependency check plugin to Maven POM 31 | 32 | ## v1.0.0 33 | 34 | * updates apksigner to v28.0.3 35 | * update zipalign to 28.0.3 win/linux binary 36 | * updates maven plugin versions 37 | * use maven wrapper 38 | * slightly improved debugging output 39 | 40 | ## v0.8.4 41 | * add file size info to log output 42 | * fix nullpointer when using apk file and not dir (thx @yihongyuelan) 43 | * updates apksigner to v27.0.3 44 | 45 | ## v0.8.3 46 | * do not use `javax.xml.bind.*` dependencies, that might crash on jre9 47 | 48 | ## v0.8.2 49 | * add jar signing 50 | * updates apksigner to v26.0.2 51 | 52 | ## v0.8.1 53 | * updates apksigner to v26.0.1 54 | * adds checksum to release artifacts 55 | 56 | ## v0.8.0 57 | * updates apksigner to v25.0.3 58 | 59 | ## v0.7.0 60 | * updates apksigner to v25.0.2 61 | * checks for empty input paths 62 | * masks pw input 63 | 64 | ## v0.6.0 65 | * adds verify sha256 feature 66 | * adds resign feature 67 | * adds multiple input files/folders feature 68 | * fixes bug where source would be deleted when using skipZipalign 69 | 70 | ## v0.5.0 71 | * adds embedded linux & mac zipalign (x64 only) 72 | * enhances log output (more structural with checksums etc.) 73 | 74 | ## v0.4.0 75 | * adds multi keystore sign feature 76 | * better verify output 77 | * adds --ksDebug to provide custom location for debug keystore 78 | * prints file checksums 79 | 80 | ## v0.3.0 81 | * fixes bug where wrong pw was used for release keystores 82 | * fixes bug where mac was identified as windows machine 83 | 84 | ## v0.2.0 85 | * fixes using release keystore 86 | * better signature verify output 87 | 88 | Issue: built-in zipalign for linux & mac do not work yet. 89 | 90 | ## v0.1.0 beta 91 | initial release 92 | 93 | * sign, zipalign and verify of multiple apks 94 | * built-in zipalign & debug.keystore 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We ❤ pull requests from everyone. 4 | 5 | If possible proof features and bugfixes with unit tests. 6 | This repo validates against checkstyle (import the xml found in the root to your IDE if possible) 7 | 8 | To run the tests (and checkstyle): 9 | 10 | ```shell 11 | mvn test 12 | ``` 13 | 14 | Tests automatically run against branches and pull requests 15 | via TravisCI, so you can also depend on that. 16 | -------------------------------------------------------------------------------- /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 2016 Patrick Favre-Bulle 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uber Apk Signer 2 | A tool that helps to sign, [zip aligning](https://developer.android.com/studio/command-line/zipalign.html) and verifying 3 | multiple Android application packages (APKs) with either debug or provided release certificates (or multiple). It 4 | supports [v1, v2](https://developer.android.com/about/versions/nougat/android-7.0.html#apk_signature_v2), [v3 Android signing scheme](https://source.android.com/security/apksigning/v3) 5 | and [v4 Android signing scheme](https://source.android.com/security/apksigning/v4). Easy and convenient debug signing 6 | with embedded debug keystore. Automatically verifies signature and zipalign after every signing. 7 | 8 | [![GitHub release](https://img.shields.io/github/release/patrickfav/uber-apk-signer.svg)](https://github.com/patrickfav/uber-apk-signer/releases/latest) 9 | [![Github Actions](https://github.com/patrickfav/uber-apk-signer/actions/workflows/build_deploy.yml/badge.svg)](https://github.com/patrickfav/uber-apk-signer/actions) 10 | [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=patrickfav_uber-apk-signer&metric=coverage)](https://sonarcloud.io/summary/new_code?id=patrickfav_uber-apk-signer) 11 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=patrickfav_uber-apk-signer&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=patrickfav_uber-apk-signer) 12 | 13 | Main features: 14 | 15 | * zipalign, (re)signing and verifying of multiple APKs in one step 16 | * verify signature (with hash check) and zipalign of multiple APKs in one step 17 | * built-in zipalign & debug keystore for convenient usage 18 | * supports v1, v2, v3 and v4 android apk singing scheme 19 | * support for multiple signatures for one APK 20 | * crypto/signing code relied upon official implementation 21 | 22 | Basic usage: 23 | 24 | java -jar uber-apk-signer.jar --apks /path/to/apks 25 | 26 | This should run on any Windows, Mac or Linux machine where JDK8 is installed. 27 | 28 | ### Requirements 29 | 30 | * JDK 8 31 | * Currently on Linux 32bit: zipalign must be set in `PATH` 32 | 33 | ## Download 34 | 35 | [Grab jar from the latest Release](https://github.com/patrickfav/uber-apk-signer/releases/latest) 36 | 37 | ## Demo 38 | 39 | [![asciicast](https://asciinema.org/a/91092.png)](https://asciinema.org/a/91092) 40 | 41 | ## Command Line Interface 42 | 43 | -a,--apks Can be a single apk or a folder containing multiple apks. These are used 44 | as source for zipalining/signing/verifying. It is also possible to 45 | provide multiple locations space seperated (can be mixed file folder): 46 | '/apk /apks2 my.apk'. Folder will be checked non-recursively. 47 | --allowResign If this flag is set, the tool will not show error on signed apks, but 48 | will sign them with the new certificate (therefore removing the old 49 | one). 50 | --debug Prints additional info for debugging. 51 | --dryRun Check what apks would be processed without actually doing anything. 52 | -h,--help Prints help docs. 53 | --ks The keystore file. If this isn't provided, will tryto sign with a debug 54 | keystore. The debug keystore will be searched in the same dir as 55 | execution and 'user_home/.android' folder. If it is not found there a 56 | built-in keystore will be used for convenience. It is possible to pass 57 | one or multiple keystores. The syntax for multiple params is 58 | '=' for example: '1=keystore.jks'. Must match the 59 | parameters of --ksAlias. 60 | --ksAlias The alias of the used key in the keystore. Must be provided if --ks is 61 | provided. It is possible to pass one or multiple aliases for multiple 62 | keystore configs. The syntax for multiple params is '=' 63 | for example: '1=my-alias'. Must match the parameters of --ks. 64 | --ksDebug Same as --ks parameter but with a debug keystore. With this option the 65 | default keystore alias and passwords are used and any arguments relating 66 | to these parameter are ignored. 67 | --ksKeyPass The password for the key. If this is not provided, caller will get a 68 | user prompt to enter it. It is possible to pass one or multiple 69 | passwords for multiple keystore configs. The syntax for multiple params 70 | is '='. Must match the parameters of --ks. 71 | --ksPass The password for the keystore. If this is not provided, caller will get 72 | a user prompt to enter it. It is possible to pass one or multiple 73 | passwords for multiple keystore configs. The syntax for multiple params 74 | is '='. Must match the parameters of --ks. 75 | -l,--lineage The lineage file for apk signer schema v3 if more then 1 signature is 76 | used. See here https://bit.ly/2mh6iAC for more info. 77 | -o,--out Where the aligned/signed apks will be copied to. Must be a folder. Will 78 | create, if it does not exist. 79 | --overwrite Will overwrite/delete the apks in-place 80 | --skipZipAlign Skips zipAlign process. Also affects verify. 81 | -v,--version Prints current version. 82 | --verbose Prints more output, especially useful for sign verify. 83 | --verifySha256 Provide one or multiple sha256 in string hex representation (ignoring 84 | case) to let the tool check it against hashes of the APK's certificate 85 | and use it in the verify process. All given hashes must be present in 86 | the signature to verify e.g. if 2 hashes are given the apk must have 2 87 | signatures with exact these hashes (providing only one hash, even if it 88 | matches one cert, will fail). 89 | -y,--onlyVerify If this is passed, the signature and alignment is only verified. 90 | --zipAlignPath Pass your own zipalign executable. If this is omitted the built-in 91 | version is used (available for win, mac and linux) 92 | 93 | ### Examples 94 | 95 | Provide your own out directory for signed apks 96 | 97 | java -jar uber-apk-signer.jar -a /path/to/apks --out /path/to/apks/out 98 | 99 | Only verify the signed apks 100 | 101 | java -jar uber-apk-signer.jar -a /path/to/apks --onlyVerify 102 | 103 | Sign with your own release keystore 104 | 105 | java -jar uber-apk-signer.jar -a /path/to/apks --ks /path/release.jks --ksAlias my_alias 106 | 107 | Provide your own zipalign executable 108 | 109 | java -jar uber-apk-signer.jar -a /path/to/apks --zipAlignPath /sdk/build-tools/24.0.3/zipalign 110 | 111 | Provide your own location of your debug keystore 112 | 113 | java -jar uber-apk-signer.jar -a /path/to/apks --ksDebug /path/debug.jks 114 | 115 | Sign with your multiple release keystores (see below on how to create a lineage file) 116 | 117 | java -jar uber-apk-signer.jar -a /path/to/apks --lineage /path/sig.lineage --ks 1=/path/release.jks 2=/path/release2.jks --ksAlias 1=my_alias1 2=my_alias2 118 | 119 | Use multiple locations or files (will ignore duplicate files) 120 | 121 | java -jar uber-apk-signer.jar -a /path/to/apks /path2 /path3/select1.apk /path3/select2.apk 122 | 123 | Provide your sha256 hash to check against the signature: 124 | 125 | java -jar uber-apk-signer.jar -a /path/to/apks --onlyVerify --verifySha256 ab318df27 126 | 127 | 128 | ### Process Return Value 129 | 130 | This application will return `0` if every signing/verifying was successful, `1` if an error happens (e.g. wrong arguments) and `2` if at least 1 sign/verify process was not successful. 131 | 132 | ### Debug Signing Mode 133 | 134 | If no keystore is provided the tool will try to automatically sign with a debug keystore. It will try to find on in the following locations (descending order): 135 | 136 | * Keystore location provided with `--ksDebug` 137 | * `debug.keystore` in the same directory as the jar executable 138 | * `debug.keystore` found in the `/user_home/.android` folder 139 | * Embedded `debug.keystore` packaged with the jar executable 140 | 141 | A log message will indicate which one was chosen. 142 | 143 | ### Zipalign Executable 144 | 145 | [`Zipalign`](https://developer.android.com/studio/command-line/zipalign.html) is a tool developed by Google to optimize zips (apks). It is needed if you want to upload it to the Playstore otherwise it is optional. By default, this tool will try to zipalign the apk, therefore it will need the location of the executable. If the path isn't passed in the command line interface, the tool checks if it is in `PATH` environment variable, otherwise it will try to use an embedded version of zipalign. 146 | 147 | If `--skipZipAlign` is passed no executable is needed. 148 | 149 | ### v1, v2 and v3 Signing Scheme 150 | 151 | [Android 7.0 introduces APK Signature Scheme v2](https://developer.android.com/about/versions/nougat/android-7.0.html#apk_signature_v2), a new app-signing scheme that offers faster app install times and more protection against unauthorized alterations to APK files. By default, Android Studio 2.2 and the Android Plugin for Gradle 2.2 sign your app using both APK Signature Scheme v2 and the traditional signing scheme, which uses JAR signing. 152 | 153 | [APK Signature Scheme v2 is a whole-file signature scheme](https://source.android.com/security/apksigning/v2.html) that increases verification speed and strengthens integrity guarantees by detecting any changes to the protected parts of the APK. The older jarsigning is called v1 schema. 154 | 155 | [APK Signature Scheme v3](https://source.android.com/security/apksigning/v3) is an extension to v2 which allows a new signature lineage feature for key rotation, which basically means it will be possible to change signature keys. 156 | 157 | #### Signature Lineage File in Schema v3 158 | 159 | This tool does not directly support the creation of lineage files as it is considered a task done very rarely. You can create a lineage file with a sequence of certificates with [Google's `apksigner rotate`](https://developer.android.com/studio/command-line/apksigner.html#options-sign-general) and apply it as `-- lineage` arguments when signing with multiple keystores: 160 | 161 | apksigner rotate --out sig.lineage \ 162 | --old-signer --ks debug1.keystore --ks-key-alias androiddebugkey \ 163 | --new-signer --ks debug2.keystore --ks-key-alias androiddebugkey 164 | 165 | java -jar uber-apk-signer.jar -a /path/to/apks --lineage sig.lineage (...) 166 | 167 | ## Signed Release Jar 168 | 169 | The provided JARs in the GitHub release page are signed with my private key: 170 | 171 | CN=Patrick Favre-Bulle, OU=Private, O=PF Github Open Source, L=Vienna, ST=Vienna, C=AT 172 | Validity: Thu Sep 07 16:40:57 SGT 2017 to: Fri Feb 10 16:40:57 SGT 2034 173 | SHA1: 06:DE:F2:C5:F7:BC:0C:11:ED:35:E2:0F:B1:9F:78:99:0F:BE:43:C4 174 | SHA256: 2B:65:33:B0:1C:0D:2A:69:4E:2D:53:8F:29:D5:6C:D6:87:AF:06:42:1F:1A:EE:B3:3C:E0:6D:0B:65:A1:AA:88 175 | 176 | Use the jarsigner tool (found in your `$JAVA_HOME/bin` folder) folder to verify. 177 | 178 | ### Build with Maven 179 | 180 | Use the Maven wrapper to create a jar including all dependencies 181 | 182 | ./mvnw clean install 183 | 184 | ### Checkstyle Config File 185 | 186 | This project uses my [`common-parent`](https://github.com/patrickfav/mvn-common-parent) which centralized a lot of 187 | the plugin versions as well as providing the checkstyle config rules. Specifically they are maintained in [`checkstyle-config`](https://github.com/patrickfav/checkstyle-config). Locally the files will be copied after you `mvnw install` into your `target` folder and is called 188 | `target/checkstyle-checker.xml`. So if you use a plugin for your IDE, use this file as your local configuration. 189 | 190 | ## Tech-Stack 191 | 192 | * Java 8 193 | * Maven 194 | 195 | # License 196 | 197 | Copyright 2016 Patrick Favre-Bulle 198 | 199 | Licensed under the Apache License, Version 2.0 (the "License"); 200 | you may not use this file except in compliance with the License. 201 | You may obtain a copy of the License at 202 | 203 | http://www.apache.org/licenses/LICENSE-2.0 204 | 205 | Unless required by applicable law or agreed to in writing, software 206 | distributed under the License is distributed on an "AS IS" BASIS, 207 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 208 | See the License for the specific language governing permissions and 209 | limitations under the License. 210 | -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/icon.ico -------------------------------------------------------------------------------- /misc/banner.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/misc/banner.psd -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /etc/mavenrc ] ; then 40 | . /etc/mavenrc 41 | fi 42 | 43 | if [ -f "$HOME/.mavenrc" ] ; then 44 | . "$HOME/.mavenrc" 45 | fi 46 | 47 | fi 48 | 49 | # OS specific support. $var _must_ be set to either true or false. 50 | cygwin=false; 51 | darwin=false; 52 | mingw=false 53 | case "`uname`" in 54 | CYGWIN*) cygwin=true ;; 55 | MINGW*) mingw=true;; 56 | Darwin*) darwin=true 57 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 58 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 59 | if [ -z "$JAVA_HOME" ]; then 60 | if [ -x "/usr/libexec/java_home" ]; then 61 | export JAVA_HOME="`/usr/libexec/java_home`" 62 | else 63 | export JAVA_HOME="/Library/Java/Home" 64 | fi 65 | fi 66 | ;; 67 | esac 68 | 69 | if [ -z "$JAVA_HOME" ] ; then 70 | if [ -r /etc/gentoo-release ] ; then 71 | JAVA_HOME=`java-config --jre-home` 72 | fi 73 | fi 74 | 75 | if [ -z "$M2_HOME" ] ; then 76 | ## resolve links - $0 may be a link to maven's home 77 | PRG="$0" 78 | 79 | # need this for relative symlinks 80 | while [ -h "$PRG" ] ; do 81 | ls=`ls -ld "$PRG"` 82 | link=`expr "$ls" : '.*-> \(.*\)$'` 83 | if expr "$link" : '/.*' > /dev/null; then 84 | PRG="$link" 85 | else 86 | PRG="`dirname "$PRG"`/$link" 87 | fi 88 | done 89 | 90 | saveddir=`pwd` 91 | 92 | M2_HOME=`dirname "$PRG"`/.. 93 | 94 | # make it fully qualified 95 | M2_HOME=`cd "$M2_HOME" && pwd` 96 | 97 | cd "$saveddir" 98 | # echo Using m2 at $M2_HOME 99 | fi 100 | 101 | # For Cygwin, ensure paths are in UNIX format before anything is touched 102 | if $cygwin ; then 103 | [ -n "$M2_HOME" ] && 104 | M2_HOME=`cygpath --unix "$M2_HOME"` 105 | [ -n "$JAVA_HOME" ] && 106 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 107 | [ -n "$CLASSPATH" ] && 108 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 109 | fi 110 | 111 | # For Mingw, ensure paths are in UNIX format before anything is touched 112 | if $mingw ; then 113 | [ -n "$M2_HOME" ] && 114 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 115 | [ -n "$JAVA_HOME" ] && 116 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 117 | fi 118 | 119 | if [ -z "$JAVA_HOME" ]; then 120 | javaExecutable="`which javac`" 121 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 122 | # readlink(1) is not available as standard on Solaris 10. 123 | readLink=`which readlink` 124 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 125 | if $darwin ; then 126 | javaHome="`dirname \"$javaExecutable\"`" 127 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 128 | else 129 | javaExecutable="`readlink -f \"$javaExecutable\"`" 130 | fi 131 | javaHome="`dirname \"$javaExecutable\"`" 132 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 133 | JAVA_HOME="$javaHome" 134 | export JAVA_HOME 135 | fi 136 | fi 137 | fi 138 | 139 | if [ -z "$JAVACMD" ] ; then 140 | if [ -n "$JAVA_HOME" ] ; then 141 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 142 | # IBM's JDK on AIX uses strange locations for the executables 143 | JAVACMD="$JAVA_HOME/jre/sh/java" 144 | else 145 | JAVACMD="$JAVA_HOME/bin/java" 146 | fi 147 | else 148 | JAVACMD="`which java`" 149 | fi 150 | fi 151 | 152 | if [ ! -x "$JAVACMD" ] ; then 153 | echo "Error: JAVA_HOME is not defined correctly." >&2 154 | echo " We cannot execute $JAVACMD" >&2 155 | exit 1 156 | fi 157 | 158 | if [ -z "$JAVA_HOME" ] ; then 159 | echo "Warning: JAVA_HOME environment variable is not set." 160 | fi 161 | 162 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 163 | 164 | # traverses directory structure from process work directory to filesystem root 165 | # first directory with .mvn subdirectory is considered project base directory 166 | find_maven_basedir() { 167 | 168 | if [ -z "$1" ] 169 | then 170 | echo "Path not specified to find_maven_basedir" 171 | return 1 172 | fi 173 | 174 | basedir="$1" 175 | wdir="$1" 176 | while [ "$wdir" != '/' ] ; do 177 | if [ -d "$wdir"/.mvn ] ; then 178 | basedir=$wdir 179 | break 180 | fi 181 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 182 | if [ -d "${wdir}" ]; then 183 | wdir=`cd "$wdir/.."; pwd` 184 | fi 185 | # end of workaround 186 | done 187 | echo "${basedir}" 188 | } 189 | 190 | # concatenates all lines of a file 191 | concat_lines() { 192 | if [ -f "$1" ]; then 193 | echo "$(tr -s '\n' ' ' < "$1")" 194 | fi 195 | } 196 | 197 | BASE_DIR=`find_maven_basedir "$(pwd)"` 198 | if [ -z "$BASE_DIR" ]; then 199 | exit 1; 200 | fi 201 | 202 | ########################################################################################## 203 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 204 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 205 | ########################################################################################## 206 | if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then 207 | if [ "$MVNW_VERBOSE" = true ]; then 208 | echo "Found .mvn/wrapper/maven-wrapper.jar" 209 | fi 210 | else 211 | if [ "$MVNW_VERBOSE" = true ]; then 212 | echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." 213 | fi 214 | if [ -n "$MVNW_REPOURL" ]; then 215 | jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 216 | else 217 | jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 218 | fi 219 | while IFS="=" read key value; do 220 | case "$key" in (wrapperUrl) jarUrl="$value"; break ;; 221 | esac 222 | done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" 223 | if [ "$MVNW_VERBOSE" = true ]; then 224 | echo "Downloading from: $jarUrl" 225 | fi 226 | wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" 227 | if $cygwin; then 228 | wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` 229 | fi 230 | 231 | if command -v wget > /dev/null; then 232 | if [ "$MVNW_VERBOSE" = true ]; then 233 | echo "Found wget ... using wget" 234 | fi 235 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 236 | wget "$jarUrl" -O "$wrapperJarPath" 237 | else 238 | wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" 239 | fi 240 | elif command -v curl > /dev/null; then 241 | if [ "$MVNW_VERBOSE" = true ]; then 242 | echo "Found curl ... using curl" 243 | fi 244 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 245 | curl -o "$wrapperJarPath" "$jarUrl" -f 246 | else 247 | curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f 248 | fi 249 | 250 | else 251 | if [ "$MVNW_VERBOSE" = true ]; then 252 | echo "Falling back to using Java to download" 253 | fi 254 | javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" 255 | # For Cygwin, switch paths to Windows format before running javac 256 | if $cygwin; then 257 | javaClass=`cygpath --path --windows "$javaClass"` 258 | fi 259 | if [ -e "$javaClass" ]; then 260 | if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 261 | if [ "$MVNW_VERBOSE" = true ]; then 262 | echo " - Compiling MavenWrapperDownloader.java ..." 263 | fi 264 | # Compiling the Java class 265 | ("$JAVA_HOME/bin/javac" "$javaClass") 266 | fi 267 | if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 268 | # Running the downloader 269 | if [ "$MVNW_VERBOSE" = true ]; then 270 | echo " - Running MavenWrapperDownloader.java ..." 271 | fi 272 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") 273 | fi 274 | fi 275 | fi 276 | fi 277 | ########################################################################################## 278 | # End of extension 279 | ########################################################################################## 280 | 281 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 282 | if [ "$MVNW_VERBOSE" = true ]; then 283 | echo $MAVEN_PROJECTBASEDIR 284 | fi 285 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 286 | 287 | # For Cygwin, switch paths to Windows format before running java 288 | if $cygwin; then 289 | [ -n "$M2_HOME" ] && 290 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 291 | [ -n "$JAVA_HOME" ] && 292 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 293 | [ -n "$CLASSPATH" ] && 294 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 295 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 296 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 297 | fi 298 | 299 | # Provide a "standardized" way to retrieve the CLI args that will 300 | # work with both Windows and non-Windows executions. 301 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" 302 | export MAVEN_CMD_LINE_ARGS 303 | 304 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 305 | 306 | exec "$JAVACMD" \ 307 | $MAVEN_OPTS \ 308 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 309 | "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 310 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 311 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM set title of command window 39 | title %0 40 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 41 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 42 | 43 | @REM set %HOME% to equivalent of $HOME 44 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 45 | 46 | @REM Execute a user defined script before this one 47 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 48 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 49 | if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" 50 | if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" 51 | :skipRcPre 52 | 53 | @setlocal 54 | 55 | set ERROR_CODE=0 56 | 57 | @REM To isolate internal variables from possible post scripts, we use another setlocal 58 | @setlocal 59 | 60 | @REM ==== START VALIDATION ==== 61 | if not "%JAVA_HOME%" == "" goto OkJHome 62 | 63 | echo. 64 | echo Error: JAVA_HOME not found in your environment. >&2 65 | echo Please set the JAVA_HOME variable in your environment to match the >&2 66 | echo location of your Java installation. >&2 67 | echo. 68 | goto error 69 | 70 | :OkJHome 71 | if exist "%JAVA_HOME%\bin\java.exe" goto init 72 | 73 | echo. 74 | echo Error: JAVA_HOME is set to an invalid directory. >&2 75 | echo JAVA_HOME = "%JAVA_HOME%" >&2 76 | echo Please set the JAVA_HOME variable in your environment to match the >&2 77 | echo location of your Java installation. >&2 78 | echo. 79 | goto error 80 | 81 | @REM ==== END VALIDATION ==== 82 | 83 | :init 84 | 85 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 86 | @REM Fallback to current working directory if not found. 87 | 88 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 89 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 90 | 91 | set EXEC_DIR=%CD% 92 | set WDIR=%EXEC_DIR% 93 | :findBaseDir 94 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 95 | cd .. 96 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 97 | set WDIR=%CD% 98 | goto findBaseDir 99 | 100 | :baseDirFound 101 | set MAVEN_PROJECTBASEDIR=%WDIR% 102 | cd "%EXEC_DIR%" 103 | goto endDetectBaseDir 104 | 105 | :baseDirNotFound 106 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 107 | cd "%EXEC_DIR%" 108 | 109 | :endDetectBaseDir 110 | 111 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 112 | 113 | @setlocal EnableExtensions EnableDelayedExpansion 114 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 115 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 116 | 117 | :endReadAdditionalConfig 118 | 119 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 120 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 121 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 122 | 123 | set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 124 | 125 | FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 126 | IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 127 | ) 128 | 129 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 130 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 131 | if exist %WRAPPER_JAR% ( 132 | if "%MVNW_VERBOSE%" == "true" ( 133 | echo Found %WRAPPER_JAR% 134 | ) 135 | ) else ( 136 | if not "%MVNW_REPOURL%" == "" ( 137 | SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 138 | ) 139 | if "%MVNW_VERBOSE%" == "true" ( 140 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 141 | echo Downloading from: %DOWNLOAD_URL% 142 | ) 143 | 144 | powershell -Command "&{"^ 145 | "$webclient = new-object System.Net.WebClient;"^ 146 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 147 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 148 | "}"^ 149 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ 150 | "}" 151 | if "%MVNW_VERBOSE%" == "true" ( 152 | echo Finished downloading %WRAPPER_JAR% 153 | ) 154 | ) 155 | @REM End of extension 156 | 157 | @REM Provide a "standardized" way to retrieve the CLI args that will 158 | @REM work with both Windows and non-Windows executions. 159 | set MAVEN_CMD_LINE_ARGS=%* 160 | 161 | %MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 162 | if ERRORLEVEL 1 goto error 163 | goto end 164 | 165 | :error 166 | set ERROR_CODE=1 167 | 168 | :end 169 | @endlocal & set ERROR_CODE=%ERROR_CODE% 170 | 171 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost 172 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 173 | if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" 174 | if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" 175 | :skipRcPost 176 | 177 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 178 | if "%MAVEN_BATCH_PAUSE%" == "on" pause 179 | 180 | if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% 181 | 182 | exit /B %ERROR_CODE% 183 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | at.favre.lib 9 | common-parent 10 | 20 11 | 12 | 13 | at.favre.tools 14 | uber-apk-signer 15 | 1.3.0 16 | 17 | 18 | false 19 | 20 | true 21 | 22 | patrickfav 23 | https://sonarcloud.io 24 | jacoco 25 | reuseReports 26 | java 27 | 28 | 29 | 30 | 31 | 32 | org.apache.maven.plugins 33 | maven-surefire-plugin 34 | 35 | 3.0.0-M4 36 | 37 | 38 | org.jacoco 39 | jacoco-maven-plugin 40 | 41 | 42 | 43 | **/lib/** 44 | 45 | 46 | 47 | 48 | org.apache.maven.plugins 49 | maven-compiler-plugin 50 | 51 | 52 | org.apache.maven.plugins 53 | maven-shade-plugin 54 | 3.5.0 55 | 56 | 57 | package 58 | 59 | shade 60 | 61 | 62 | false 63 | 64 | 66 | 68 | at.favre.tools.apksigner.SignTool 69 | 70 | ${project.version} 71 | 72 | 73 | 75 | 76 | /lib 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | com.googlecode.addjars-maven-plugin 86 | addjars-maven-plugin 87 | 1.0.5 88 | 89 | 90 | 91 | add-jars 92 | 93 | 94 | 95 | 96 | ${project.basedir}/src/main/resources/lib 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | org.apache.maven.plugins 105 | maven-jarsigner-plugin 106 | 107 | 108 | net.nicoulaj.maven.plugins 109 | checksum-maven-plugin 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | commons-cli 118 | commons-cli 119 | 1.5.0 120 | 121 | 122 | com.android 123 | apksigner 124 | 1.0 125 | system 126 | ${project.basedir}/src/main/resources/lib/apksigner_33_0_2.jar 127 | 128 | 129 | at.favre.lib 130 | bytes 131 | 132 | 133 | 134 | junit 135 | junit 136 | test 137 | 138 | 139 | org.apache.ant 140 | ant 141 | 1.10.14 142 | test 143 | 144 | 145 | 146 | 147 | scm:git:https://github.com/patrickfav/uber-apk-signer.git 148 | scm:git:https://github.com/patrickfav/uber-apk-signer.git 149 | https://github.com/patrickfav/uber-apk-signer 150 | 151 | 152 | 153 | Github 154 | https://github.com/patrickfav/uber-apk-signer/issues 155 | 156 | 157 | 158 | Github Actions 159 | https://github.com/patrickfav/uber-apk-signer/actions 160 | 161 | 162 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/SignTool.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner; 2 | 3 | import at.favre.tools.apksigner.signing.*; 4 | import at.favre.tools.apksigner.ui.Arg; 5 | import at.favre.tools.apksigner.ui.CLIParser; 6 | import at.favre.tools.apksigner.ui.FileArgParser; 7 | import at.favre.tools.apksigner.util.AndroidApkSignerUtil; 8 | import at.favre.tools.apksigner.util.CmdUtil; 9 | import at.favre.tools.apksigner.util.FileUtil; 10 | import com.android.apksigner.ApkSignerTool; 11 | 12 | import java.io.ByteArrayOutputStream; 13 | import java.io.File; 14 | import java.io.IOException; 15 | import java.io.PrintStream; 16 | import java.util.ArrayList; 17 | import java.util.Date; 18 | import java.util.List; 19 | import java.util.Locale; 20 | 21 | /** 22 | * The main tool that manages the logic of the main process while satisfying the passed arguments 23 | */ 24 | public final class SignTool { 25 | 26 | private static final String ZIPALIGN_ALIGNMENT = "4"; 27 | private static final String APK_FILE_EXTENSION = "apk"; 28 | 29 | private SignTool() { 30 | } 31 | 32 | public static void main(String[] args) { 33 | Result result = mainExecute(args); 34 | if (result != null && result.error) { 35 | System.exit(1); 36 | } else if (result != null && result.unsuccessful > 0) { 37 | System.exit(2); 38 | } 39 | } 40 | 41 | static Result mainExecute(String[] args) { 42 | Arg arguments = CLIParser.parse(args); 43 | 44 | if (arguments != null) { 45 | return execute(arguments); 46 | } 47 | return null; 48 | } 49 | 50 | private static Result execute(Arg args) { 51 | List executedCommands = new ArrayList<>(); 52 | ZipAlignExecutor zipAlignExecutor = null; 53 | SigningConfigGen signingConfigGen = null; 54 | 55 | int successCount = 0; 56 | int errorCount = 0; 57 | 58 | try { 59 | File outFolder = null; 60 | List targetApkFiles = new FileArgParser().parseAndSortUniqueFilesNonRecursive(args.apkFile, APK_FILE_EXTENSION); 61 | 62 | if (targetApkFiles.isEmpty()) { 63 | throw new IllegalStateException("no apk files found in given paths"); 64 | } 65 | 66 | log("source:"); 67 | 68 | for (String path : FileArgParser.getDirSummary(targetApkFiles)) { 69 | log("\t" + path); 70 | } 71 | 72 | if (args.out != null) { 73 | outFolder = new File(args.out); 74 | 75 | if (!outFolder.exists()) { 76 | outFolder.mkdirs(); 77 | } 78 | 79 | if (!outFolder.exists() || !outFolder.isDirectory()) { 80 | throw new IllegalArgumentException("if out directory is provided it must exist and be a path: " + args.out); 81 | } 82 | } 83 | 84 | 85 | if (!args.skipZipAlign) { 86 | zipAlignExecutor = new ZipAlignExecutor(args); 87 | log(zipAlignExecutor.toString()); 88 | } 89 | 90 | if (!args.onlyVerify) { 91 | log("keystore:"); 92 | signingConfigGen = new SigningConfigGen(args.signArgsList, args.ksIsDebug); 93 | for (SigningConfig signingConfig : signingConfigGen.signingConfig) { 94 | log("\t" + signingConfig.description()); 95 | } 96 | } 97 | 98 | if (args.lineageFilePath != null) { 99 | processLineagePath(args); 100 | } 101 | 102 | long startTime = System.currentTimeMillis(); 103 | 104 | int iterCount = 0; 105 | 106 | List tempFilesToDelete = new ArrayList<>(); 107 | for (File targetApkFile : targetApkFiles) { 108 | iterCount++; 109 | File rootTargetFile = targetApkFile; 110 | 111 | log("\n" + String.format("%02d", iterCount) + ". " + targetApkFile.getName()); 112 | 113 | if (args.dryRun) { 114 | log("\t- (skip)"); 115 | continue; 116 | } 117 | 118 | if (!args.onlyVerify) { 119 | AndroidApkSignerVerify.Result preCheck = verifySign(targetApkFile, rootTargetFile, args.checkCertSha256, false, true); 120 | 121 | if (preCheck != null && args.allowResign) { 122 | log("\tWARNING: already signed - will be resigned. Old certificate info: " + preCheck.getCertCountString() + preCheck.getSchemaVersionInfoString()); 123 | for (AndroidApkSignerVerify.CertInfo certInfo : preCheck.certInfoList) { 124 | log("\t\tSubject: " + certInfo.subjectDn); 125 | log("\t\tSHA256: " + certInfo.certSha256); 126 | } 127 | 128 | } else if (preCheck != null) { 129 | logErr("\t- already signed SKIP"); 130 | errorCount++; 131 | continue; 132 | } 133 | } 134 | 135 | if (!args.onlyVerify) { 136 | log("\n\tSIGN"); 137 | log("\tfile: " + rootTargetFile.getCanonicalPath() + " (" + FileUtil.getFileSizeMb(targetApkFile) + ")"); 138 | log("\tchecksum: " + FileUtil.createChecksum(rootTargetFile, "SHA-256") + " (sha256)"); 139 | 140 | targetApkFile = zipAlign(targetApkFile, rootTargetFile, outFolder, zipAlignExecutor, args, executedCommands); 141 | 142 | if (targetApkFile == null) { 143 | throw new IllegalStateException("could not execute zipalign"); 144 | } 145 | 146 | if (!args.overwrite && !args.skipZipAlign) { 147 | tempFilesToDelete.add(targetApkFile); 148 | } 149 | 150 | targetApkFile = sign(targetApkFile, outFolder, signingConfigGen.signingConfig, args); 151 | 152 | } 153 | 154 | log("\n\tVERIFY"); 155 | log("\tfile: " + targetApkFile.getCanonicalPath() + " (" + FileUtil.getFileSizeMb(targetApkFile) + ")"); 156 | log("\tchecksum: " + FileUtil.createChecksum(targetApkFile, "SHA-256") + " (sha256)"); 157 | 158 | boolean zipAlignVerified = args.skipZipAlign || verifyZipAlign(targetApkFile, rootTargetFile, zipAlignExecutor, args, executedCommands); 159 | boolean sigVerified = verifySign(targetApkFile, rootTargetFile, args.checkCertSha256, args.verbose, false) != null; 160 | 161 | if (zipAlignVerified && sigVerified) { 162 | successCount++; 163 | } else { 164 | errorCount++; 165 | } 166 | } 167 | 168 | if (iterCount == 0) { 169 | log("No apks found."); 170 | } 171 | 172 | deleteTempFiles(args, tempFilesToDelete); 173 | 174 | log(String.format(Locale.US, "\n[%s][v%s]\nSuccessfully processed %d APKs and %d errors in %.2f seconds.", 175 | new Date().toString(), CmdUtil.jarVersion(), successCount, errorCount, (double) (System.currentTimeMillis() - startTime) / 1000.0)); 176 | 177 | if (args.debug) { 178 | log(getCommandHistory(executedCommands)); 179 | } 180 | } catch (Exception e) { 181 | logException(args, executedCommands, e); 182 | return new Result(true, successCount, errorCount); 183 | } finally { 184 | cleanup(zipAlignExecutor, signingConfigGen); 185 | } 186 | return new Result(false, successCount, errorCount); 187 | } 188 | 189 | private static void processLineagePath(Arg args) throws IOException { 190 | File lineageFile = new File(args.lineageFilePath); 191 | if (!lineageFile.exists() || !lineageFile.isFile()) { 192 | throw new IllegalArgumentException("lineage file either does not exist or is not a file: " + args.lineageFilePath); 193 | } 194 | log("lineage:"); 195 | log("\t" + FileUtil.createChecksum(lineageFile, "SHA-256").substring(0, 8) + " " + lineageFile.getCanonicalPath()); 196 | } 197 | 198 | private static void deleteTempFiles(Arg args, List tempFilesToDelete) { 199 | for (File file : tempFilesToDelete) { 200 | if (args.verbose) { 201 | log("delete temp file " + file); 202 | } 203 | file.delete(); 204 | } 205 | } 206 | 207 | private static void cleanup(ZipAlignExecutor zipAlignExecutor, SigningConfigGen signingConfigGen) { 208 | if (zipAlignExecutor != null) { 209 | zipAlignExecutor.cleanUp(); 210 | } 211 | 212 | if (signingConfigGen != null) { 213 | signingConfigGen.cleanUp(); 214 | } 215 | } 216 | 217 | private static void logException(Arg args, List executedCommands, Exception e) { 218 | logErr(e.getMessage()); 219 | 220 | if (args.debug) { 221 | e.printStackTrace(); 222 | logErr(getCommandHistory(executedCommands)); 223 | } else { 224 | logErr("Run with '--debug' parameter to get additional information."); 225 | } 226 | } 227 | 228 | private static File zipAlign(File targetApkFile, File rootTargetFile, File outFolder, ZipAlignExecutor executor, Arg arguments, List cmdList) { 229 | if (!arguments.skipZipAlign) { 230 | 231 | String fileName = FileUtil.getFileNameWithoutExtension(targetApkFile); 232 | fileName = fileName.replace("-unaligned", ""); 233 | fileName += "-aligned"; 234 | File outFile = new File(outFolder != null ? outFolder : targetApkFile.getParentFile(), fileName + "." + FileUtil.getFileExtension(targetApkFile)); 235 | 236 | if (outFile.exists()) { 237 | outFile.delete(); 238 | } 239 | 240 | if (executor.isExecutableFound()) { 241 | String logMsg = "\t- "; 242 | 243 | CmdUtil.Result zipAlignResult = CmdUtil.runCmd(CmdUtil.concat(executor.getZipAlignExecutable(), new String[]{"-p", "-v", ZIPALIGN_ALIGNMENT, targetApkFile.getAbsolutePath(), outFile.getAbsolutePath()})); 244 | cmdList.add(zipAlignResult); 245 | if (zipAlignResult.success()) { 246 | logMsg += "zipalign success"; 247 | } else { 248 | logMsg += "could not align "; 249 | } 250 | 251 | logConditionally(logMsg, outFile, !rootTargetFile.equals(outFile), false); 252 | 253 | if (arguments.overwrite) { 254 | targetApkFile.delete(); 255 | outFile.renameTo(targetApkFile); 256 | outFile = targetApkFile; 257 | } 258 | return zipAlignResult.success() ? outFile : null; 259 | } else { 260 | throw new IllegalArgumentException("could not find zipalign - either skip it or provide a proper location"); 261 | } 262 | 263 | } 264 | return targetApkFile; 265 | } 266 | 267 | private static boolean verifyZipAlign(File targetApkFile, File rootTargetFile, ZipAlignExecutor executor, Arg arguments, List cmdList) { 268 | if (!arguments.skipZipAlign) { 269 | if (executor.isExecutableFound()) { 270 | String logMsg = "\t- "; 271 | 272 | CmdUtil.Result zipAlignVerifyResult = CmdUtil.runCmd(CmdUtil.concat(executor.getZipAlignExecutable(), new String[]{"-c", ZIPALIGN_ALIGNMENT, targetApkFile.getAbsolutePath()})); 273 | cmdList.add(zipAlignVerifyResult); 274 | boolean success = zipAlignVerifyResult.success(); 275 | 276 | if (success) { 277 | logMsg += "zipalign verified"; 278 | } else { 279 | logMsg += "zipalign VERIFY FAILED"; 280 | } 281 | 282 | logConditionally(logMsg, targetApkFile, !targetApkFile.equals(rootTargetFile), !success); 283 | 284 | return zipAlignVerifyResult.success(); 285 | } else { 286 | throw new IllegalArgumentException("could not find zipalign - either skip it or provide a proper location"); 287 | } 288 | } 289 | return true; 290 | } 291 | 292 | private static File sign(File targetApkFile, File outFolder, List signingConfigs, Arg arguments) { 293 | try { 294 | File outFile = targetApkFile; 295 | 296 | if (!arguments.overwrite) { 297 | String fileName = FileUtil.getFileNameWithoutExtension(targetApkFile); 298 | fileName = fileName.replace("-unsigned", ""); 299 | if (signingConfigs.size() == 1 && signingConfigs.get(0).isDebugType) { 300 | fileName += "-debugSigned"; 301 | } else { 302 | fileName += "-signed"; 303 | } 304 | outFile = new File(outFolder != null ? outFolder : targetApkFile.getParentFile(), fileName + "." + FileUtil.getFileExtension(targetApkFile)); 305 | 306 | if (outFile.exists()) { 307 | outFile.delete(); 308 | } 309 | } 310 | 311 | ByteArrayOutputStream apkSignerToolStream = new ByteArrayOutputStream(); 312 | PrintStream sout = System.out; 313 | System.setOut(new PrintStream(apkSignerToolStream)); 314 | ApkSignerTool.main(AndroidApkSignerUtil.createApkToolArgs(arguments, signingConfigs, targetApkFile, outFile)); 315 | String output = apkSignerToolStream.toString("UTF-8").trim(); 316 | System.setOut(sout); 317 | 318 | log("\t- sign success"); 319 | 320 | if (arguments.verbose && !output.isEmpty()) { 321 | log("\t\t" + output); 322 | } 323 | 324 | return outFile; 325 | } catch (Exception e) { 326 | e.printStackTrace(); 327 | throw new IllegalStateException("could not sign " + targetApkFile + ": " + e.getMessage(), e); 328 | } 329 | } 330 | 331 | private static AndroidApkSignerVerify.Result verifySign(File targetApkFile, File rootTargetFile, String[] checkHashes, boolean verbose, boolean preCheckVerify) { 332 | try { 333 | AndroidApkSignerVerify verifier = new AndroidApkSignerVerify(); 334 | AndroidApkSignerVerify.Result result = verifier.verify(targetApkFile, null, null, null, false); 335 | 336 | if (!preCheckVerify) { 337 | String logMsg; 338 | 339 | if (result.verified) { 340 | logMsg = "\t- signature verified " + result.getCertCountString() + result.getSchemaVersionInfoString(); 341 | } else { 342 | logMsg = "\t- signature VERIFY FAILED (" + targetApkFile.getName() + ")"; 343 | } 344 | 345 | logConditionally(logMsg, targetApkFile, !rootTargetFile.equals(targetApkFile), !result.verified); 346 | 347 | if (!result.errors.isEmpty()) { 348 | for (String e : result.errors) { 349 | logErr("\t\t" + e); 350 | } 351 | } 352 | 353 | if (verbose && !result.warnings.isEmpty()) { 354 | for (String w : result.warnings) { 355 | log("\t\t" + w); 356 | } 357 | } else if (!result.warnings.isEmpty()) { 358 | log("\t\t" + result.warnings.size() + " warnings"); 359 | } 360 | 361 | if (result.verified) { 362 | for (int i = 0; i < result.certInfoList.size(); i++) { 363 | AndroidApkSignerVerify.CertInfo certInfo = result.certInfoList.get(i); 364 | 365 | log("\t\t" + certInfo.subjectDn); 366 | log("\t\tSHA256: " + certInfo.certSha256 + " / " + certInfo.sigAlgo); 367 | if (verbose) { 368 | log("\t\tSHA1: " + certInfo.certSha1); 369 | log("\t\t" + certInfo.issuerDn); 370 | log("\t\tPublic Key SHA256: " + certInfo.pubSha256); 371 | log("\t\tPublic Key SHA1: " + certInfo.pubSha1); 372 | log("\t\tPublic Key Algo: " + certInfo.pubAlgo + " " + certInfo.pubKeysize); 373 | log("\t\tIssue Date: " + certInfo.beginValidity); 374 | 375 | } 376 | log("\t\tExpires: " + certInfo.expiry.toString()); 377 | 378 | if (i < result.certInfoList.size() - 1) { 379 | log(""); 380 | } 381 | } 382 | } 383 | 384 | CertHashChecker.Result certHashResult = new CertHashChecker().check(result, checkHashes); 385 | if (certHashResult != null) { 386 | if (!certHashResult.verified) { 387 | log("\t- verify with provided hash check failed " + certHashResult.hashSummary()); 388 | logErr("\t\tERROR: " + certHashResult.errorString); 389 | } else { 390 | log("\t- verify with provided hash successful " + certHashResult.hashSummary()); 391 | } 392 | return result.verified && certHashResult.verified ? result : null; 393 | } 394 | 395 | } 396 | return result.verified ? result : null; 397 | } catch (Exception e) { 398 | throw new IllegalStateException("could not verify " + targetApkFile + ": " + e.getMessage(), e); 399 | } 400 | } 401 | 402 | private static String getCommandHistory(List executedCommands) { 403 | StringBuilder sb = new StringBuilder("\nCmd history for debugging purpose:\n-----------------------\n"); 404 | for (CmdUtil.Result executedCommand : executedCommands) { 405 | sb.append(executedCommand.toString()); 406 | } 407 | return sb.toString(); 408 | } 409 | 410 | private static void logErr(String msg) { 411 | System.err.println(msg); 412 | } 413 | 414 | private static void log(String msg) { 415 | System.out.println(msg); 416 | } 417 | 418 | private static void logConditionally(String logMsg, File file, boolean appendFile, boolean error) { 419 | if (appendFile && error) { 420 | logMsg += " (" + file.getName() + ")"; 421 | } 422 | 423 | if (error) { 424 | logErr(logMsg); 425 | } else { 426 | log(logMsg); 427 | } 428 | } 429 | 430 | static class Result { 431 | final boolean error; 432 | final int success; 433 | final int unsuccessful; 434 | 435 | Result(boolean error, int success, int unsuccessful) { 436 | this.error = error; 437 | this.success = success; 438 | this.unsuccessful = unsuccessful; 439 | } 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/signing/AndroidApkSignerVerify.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.signing; 2 | 3 | import at.favre.lib.bytes.Bytes; 4 | import com.android.apksig.ApkVerifier; 5 | 6 | import java.io.File; 7 | import java.security.MessageDigest; 8 | import java.security.PublicKey; 9 | import java.security.cert.X509Certificate; 10 | import java.security.interfaces.DSAKey; 11 | import java.security.interfaces.DSAParams; 12 | import java.security.interfaces.ECKey; 13 | import java.security.interfaces.RSAKey; 14 | import java.util.*; 15 | import java.util.stream.Collectors; 16 | 17 | /** 18 | * Mirrors the logic of the apksigner.jar from Google, but provides more structural log output. 19 | */ 20 | public class AndroidApkSignerVerify { 21 | 22 | public Result verify(File apk, Integer minSdkVersion, Integer maxSdkVersion, File v4SchemeSignatureFile, boolean warningsTreatedAsErrors) throws Exception { 23 | StringBuilder logMsg = new StringBuilder(); 24 | List certInfoList = new ArrayList<>(); 25 | List warnings = new ArrayList<>(); 26 | 27 | ApkVerifier.Builder builder = new ApkVerifier.Builder(apk); 28 | if (minSdkVersion != null) { 29 | builder.setMinCheckedPlatformVersion(minSdkVersion); 30 | } 31 | if (maxSdkVersion != null) { 32 | builder.setMaxCheckedPlatformVersion(maxSdkVersion); 33 | } 34 | if (v4SchemeSignatureFile != null) { 35 | builder.setV4SignatureFile(v4SchemeSignatureFile); 36 | } 37 | 38 | ApkVerifier.Result apkVerifierResult = builder.build().verify(); 39 | boolean verified = apkVerifierResult.isVerified(); 40 | Iterator iter; 41 | if (verified) { 42 | List signerCertificates = apkVerifierResult.getSignerCertificates(); 43 | logMsg.append("Verifies\n"); 44 | logMsg.append("Verified using v1 scheme (JAR signing): ").append(apkVerifierResult.isVerifiedUsingV1Scheme()); 45 | logMsg.append("Verified using v2 scheme (APK Signature Scheme v2): ").append(apkVerifierResult.isVerifiedUsingV2Scheme()); 46 | logMsg.append("Verified using v3 scheme (APK Signature Scheme v3): ").append(apkVerifierResult.isVerifiedUsingV3Scheme()); 47 | logMsg.append("Verified using v3.1 scheme (APK Signature Scheme v3.1): ").append(apkVerifierResult.isVerifiedUsingV31Scheme()); 48 | logMsg.append("Verified using v4 scheme (APK Signature Scheme v4): ").append(apkVerifierResult.isVerifiedUsingV4Scheme()); 49 | logMsg.append("Number of signers: ").append(signerCertificates.size()); 50 | 51 | MessageDigest sha256Digest = MessageDigest.getInstance("SHA-256"); 52 | MessageDigest sha1Digest = MessageDigest.getInstance("SHA-1"); 53 | iter = signerCertificates.iterator(); 54 | 55 | while (iter.hasNext()) { 56 | CertInfo certInfo = new CertInfo(); 57 | 58 | X509Certificate x509Certificate = (X509Certificate) iter.next(); 59 | byte[] encodedCert = x509Certificate.getEncoded(); 60 | 61 | certInfo.subjectDn = "Subject: " + x509Certificate.getSubjectDN().toString(); 62 | certInfo.issuerDn = "Issuer: " + x509Certificate.getIssuerDN().toString(); 63 | certInfo.sigAlgo = x509Certificate.getSigAlgName(); 64 | certInfo.certSha1 = Bytes.wrap(sha1Digest.digest(encodedCert)).encodeHex(); 65 | certInfo.certSha256 = Bytes.wrap(sha256Digest.digest(encodedCert)).encodeHex(); 66 | certInfo.expiry = x509Certificate.getNotAfter(); 67 | certInfo.beginValidity = x509Certificate.getNotBefore(); 68 | 69 | PublicKey publicKey = x509Certificate.getPublicKey(); 70 | 71 | certInfo.pubAlgo = publicKey.getAlgorithm(); 72 | int keySize = -1; 73 | if (publicKey instanceof RSAKey) { 74 | keySize = ((RSAKey) publicKey).getModulus().bitLength(); 75 | } else if (publicKey instanceof ECKey) { 76 | keySize = ((ECKey) publicKey).getParams().getOrder().bitLength(); 77 | } else if (publicKey instanceof DSAKey) { 78 | DSAParams encodedKey = ((DSAKey) publicKey).getParams(); 79 | if (encodedKey != null) { 80 | keySize = encodedKey.getP().bitLength(); 81 | } 82 | } 83 | 84 | certInfo.pubKeysize = keySize; 85 | byte[] pubKey = publicKey.getEncoded(); 86 | certInfo.pubSha1 = Bytes.wrap(sha1Digest.digest(pubKey)).encodeHex(); 87 | certInfo.pubSha256 = Bytes.wrap(sha256Digest.digest(pubKey)).encodeHex(); 88 | certInfoList.add(certInfo); 89 | } 90 | } else { 91 | logMsg.append("DOES NOT VERIFY\n"); 92 | } 93 | 94 | List errors = apkVerifierResult.getErrors().stream().map(error -> "ERROR: " + error).collect(Collectors.toList()); 95 | 96 | for (ApkVerifier.IssueWithParams issues : apkVerifierResult.getWarnings()) { 97 | warnings.add("WARNING: " + issues); 98 | } 99 | 100 | extractV1SchemeIssues(warnings, apkVerifierResult, errors); 101 | 102 | extractV2SchemeIssues(warnings, apkVerifierResult, errors); 103 | 104 | extractV3SchemeIssues(warnings, apkVerifierResult, errors); 105 | 106 | extractV31SchemeIssues(warnings, apkVerifierResult, errors); 107 | 108 | extractV4SchemeIssues(warnings, apkVerifierResult, errors); 109 | 110 | if (!verified || warningsTreatedAsErrors && !warnings.isEmpty()) { 111 | return new Result( 112 | false, 113 | warnings, 114 | errors, 115 | logMsg.toString(), 116 | apkVerifierResult.isVerifiedUsingV1Scheme(), 117 | apkVerifierResult.isVerifiedUsingV2Scheme(), 118 | apkVerifierResult.isVerifiedUsingV3Scheme(), 119 | apkVerifierResult.isVerifiedUsingV31Scheme(), 120 | apkVerifierResult.isVerifiedUsingV4Scheme(), 121 | certInfoList 122 | ); 123 | } 124 | 125 | return new Result( 126 | true, 127 | warnings, 128 | errors, 129 | logMsg.toString(), 130 | apkVerifierResult.isVerifiedUsingV1Scheme(), 131 | apkVerifierResult.isVerifiedUsingV2Scheme(), 132 | apkVerifierResult.isVerifiedUsingV3Scheme(), 133 | apkVerifierResult.isVerifiedUsingV31Scheme(), 134 | apkVerifierResult.isVerifiedUsingV4Scheme(), 135 | certInfoList 136 | ); 137 | } 138 | 139 | private static void extractV4SchemeIssues(List warnings, ApkVerifier.Result apkVerifierResult, List errors) { 140 | ApkVerifier.IssueWithParams issueWithParams; 141 | Iterator iter; 142 | for (ApkVerifier.Result.V4SchemeSignerInfo signerInfo : apkVerifierResult.getV4SchemeSigners()) { 143 | String name = "signer #" + (signerInfo.getIndex() + 1); 144 | iter = signerInfo.getErrors().iterator(); 145 | 146 | while (iter.hasNext()) { 147 | issueWithParams = iter.next(); 148 | errors.add("ERROR: APK Signature Scheme v4 " + name + ": " + issueWithParams); 149 | } 150 | 151 | iter = signerInfo.getWarnings().iterator(); 152 | 153 | while (iter.hasNext()) { 154 | issueWithParams = iter.next(); 155 | warnings.add("WARNING: APK Signature Scheme v4 " + name + ": " + issueWithParams); 156 | } 157 | } 158 | } 159 | 160 | private static void extractV31SchemeIssues(List warnings, ApkVerifier.Result apkVerifierResult, List errors) { 161 | ApkVerifier.IssueWithParams issueWithParams; 162 | Iterator iter; 163 | for (ApkVerifier.Result.V3SchemeSignerInfo signerInfo : apkVerifierResult.getV31SchemeSigners()) { 164 | String name = "signer #" + (signerInfo.getIndex() + 1); 165 | iter = signerInfo.getErrors().iterator(); 166 | 167 | while (iter.hasNext()) { 168 | issueWithParams = iter.next(); 169 | errors.add("ERROR: APK Signature Scheme v3.1 " + name + ": " + issueWithParams); 170 | } 171 | 172 | iter = signerInfo.getWarnings().iterator(); 173 | 174 | while (iter.hasNext()) { 175 | issueWithParams = iter.next(); 176 | warnings.add("WARNING: APK Signature Scheme v3.1 " + name + ": " + issueWithParams); 177 | } 178 | } 179 | } 180 | 181 | private static void extractV3SchemeIssues(List warnings, ApkVerifier.Result apkVerifierResult, List errors) { 182 | ApkVerifier.IssueWithParams issueWithParams; 183 | Iterator iter; 184 | for (ApkVerifier.Result.V3SchemeSignerInfo signerInfo : apkVerifierResult.getV3SchemeSigners()) { 185 | String name = "signer #" + (signerInfo.getIndex() + 1); 186 | iter = signerInfo.getErrors().iterator(); 187 | 188 | while (iter.hasNext()) { 189 | issueWithParams = iter.next(); 190 | errors.add("ERROR: APK Signature Scheme v3 " + name + ": " + issueWithParams); 191 | } 192 | 193 | iter = signerInfo.getWarnings().iterator(); 194 | 195 | while (iter.hasNext()) { 196 | issueWithParams = iter.next(); 197 | warnings.add("WARNING: APK Signature Scheme v3 " + name + ": " + issueWithParams); 198 | } 199 | } 200 | } 201 | 202 | private static void extractV2SchemeIssues(List warnings, ApkVerifier.Result apkVerifierResult, List errors) { 203 | ApkVerifier.IssueWithParams issueWithParams; 204 | Iterator iter; 205 | for (ApkVerifier.Result.V2SchemeSignerInfo signerInfo : apkVerifierResult.getV2SchemeSigners()) { 206 | String name = "signer #" + (signerInfo.getIndex() + 1); 207 | iter = signerInfo.getErrors().iterator(); 208 | 209 | while (iter.hasNext()) { 210 | issueWithParams = iter.next(); 211 | errors.add("ERROR: APK Signature Scheme v2 " + name + ": " + issueWithParams); 212 | } 213 | 214 | iter = signerInfo.getWarnings().iterator(); 215 | 216 | while (iter.hasNext()) { 217 | issueWithParams = iter.next(); 218 | warnings.add("WARNING: APK Signature Scheme v2 " + name + ": " + issueWithParams); 219 | } 220 | } 221 | } 222 | 223 | private static void extractV1SchemeIssues(List warnings, ApkVerifier.Result apkVerifierResult, List errors) { 224 | ApkVerifier.IssueWithParams issueWithParams; 225 | Iterator iter; 226 | for (ApkVerifier.Result.V1SchemeSignerInfo signerInfo : apkVerifierResult.getV1SchemeSigners()) { 227 | String name = signerInfo.getName(); 228 | iter = signerInfo.getErrors().iterator(); 229 | 230 | while (iter.hasNext()) { 231 | issueWithParams = iter.next(); 232 | errors.add("ERROR: JAR signer " + name + ": " + issueWithParams); 233 | } 234 | 235 | iter = signerInfo.getWarnings().iterator(); 236 | 237 | while (iter.hasNext()) { 238 | issueWithParams = iter.next(); 239 | warnings.add("WARNING: JAR signer " + issueWithParams); 240 | } 241 | } 242 | } 243 | 244 | public static class Result { 245 | public final boolean verified; 246 | public final List warnings; 247 | public final List errors; 248 | public final String log; 249 | public final boolean v1Schema; 250 | public final boolean v2Schema; 251 | public final boolean v3Schema; 252 | public final boolean v31Schema; 253 | public final boolean v4Schema; 254 | public final List certInfoList; 255 | 256 | public Result(boolean verified, List warnings, List errors, String log, boolean v1Schema, boolean v2Schema, boolean v3Schema, boolean v31Schema, boolean v4Schema, List certInfoList) { 257 | this.verified = verified; 258 | this.warnings = warnings; 259 | this.errors = errors; 260 | this.log = log; 261 | this.v1Schema = v1Schema; 262 | this.v2Schema = v2Schema; 263 | this.v3Schema = v3Schema; 264 | this.v31Schema = v31Schema; 265 | this.v4Schema = v4Schema; 266 | this.certInfoList = certInfoList; 267 | } 268 | 269 | public String getSchemaVersionInfoString() { 270 | StringJoiner stringJoiner = new StringJoiner(", ", "[", "]"); 271 | 272 | if (v1Schema) { 273 | stringJoiner.add("v1"); 274 | } 275 | if (v2Schema) { 276 | stringJoiner.add("v2"); 277 | } 278 | if (v3Schema) { 279 | stringJoiner.add("v3"); 280 | } 281 | if (v31Schema) { 282 | stringJoiner.add("v3.1"); 283 | } 284 | if (v4Schema) { 285 | stringJoiner.add("v4"); 286 | } 287 | 288 | return stringJoiner.toString(); 289 | } 290 | 291 | public String getCertCountString() { 292 | if (certInfoList.size() > 1) { 293 | return "(" + certInfoList.size() + ") "; 294 | } 295 | return ""; 296 | } 297 | } 298 | 299 | //CHECKSTYLE:OFF -- I do want a concise class with only public access 300 | public static class CertInfo { 301 | public String certSha1; 302 | public String certSha256; 303 | public String pubSha1; 304 | public String pubSha256; 305 | public String subjectDn; 306 | public String issuerDn; 307 | public String sigAlgo; 308 | public String pubAlgo; 309 | public int pubKeysize; 310 | public Date expiry; 311 | public Date beginValidity; 312 | } 313 | //CHECKSTYLE:ON 314 | } 315 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/signing/CertHashChecker.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.signing; 2 | 3 | import java.util.Arrays; 4 | import java.util.List; 5 | import java.util.stream.Collectors; 6 | 7 | /** 8 | * Used to check if given sha hashes equals the ones found in a APKs signature 9 | */ 10 | public class CertHashChecker { 11 | 12 | public Result check(AndroidApkSignerVerify.Result verifyResult, String[] hashes) { 13 | if (verifyResult == null) { 14 | throw new IllegalArgumentException("parameters must not be null"); 15 | } 16 | 17 | if (hashes == null || !verifyResult.verified) { 18 | return null; 19 | } 20 | 21 | if (verifyResult.certInfoList.isEmpty()) { 22 | throw new IllegalArgumentException("no certs info found in verify result - strange..."); 23 | } 24 | 25 | if (verifyResult.certInfoList.size() != hashes.length) { 26 | return new Result(false, "not the same count of signatures and provided check hashes (found " + verifyResult.certInfoList.size() + " signatures)", hashes); 27 | } 28 | 29 | List apkHashes = verifyResult.certInfoList.stream().map(certInfo -> certInfo.certSha256).distinct().sorted().collect(Collectors.toList()); 30 | List providedHashes = Arrays.stream(hashes).distinct().sorted().collect(Collectors.toList()); 31 | 32 | for (int i = 0; i < apkHashes.size(); i++) { 33 | if (!apkHashes.get(i).equalsIgnoreCase(providedHashes.get(i))) { 34 | return new Result(false, "The following hash does not match with the provided: " + apkHashes.get(i) + " <> " + providedHashes.get(i), hashes); 35 | } 36 | } 37 | return new Result(true, null, hashes); 38 | } 39 | 40 | public static class Result { 41 | public final boolean verified; 42 | public final String errorString; 43 | public final String[] sha256; 44 | 45 | public Result(boolean verified, String errorString, String[] sha256) { 46 | this.verified = verified; 47 | this.errorString = errorString; 48 | this.sha256 = sha256; 49 | } 50 | 51 | public String hashSummary() { 52 | StringBuilder sb = new StringBuilder(); 53 | String sep = ""; 54 | if (sha256 != null) { 55 | sb.append("("); 56 | for (String s : sha256) { 57 | sb.append(sep); 58 | sep = ","; 59 | if (s.length() > 8) { 60 | sb.append(s.substring(0, 8)); 61 | } else { 62 | sb.append(s); 63 | } 64 | } 65 | sb.append(")"); 66 | } 67 | return sb.toString(); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/signing/SigningConfig.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.signing; 2 | 3 | import at.favre.tools.apksigner.util.FileUtil; 4 | 5 | import java.io.File; 6 | 7 | /** 8 | * Model for defining a signing config 9 | */ 10 | public class SigningConfig { 11 | public enum KeystoreLocation { 12 | DEBUG_ANDROID_FOLDER, DEBUG_SAME_FOLDER, DEBUG_EMBEDDED, DEBUG_CUSTOM_LOCATION, RELEASE_CUSTOM 13 | } 14 | 15 | public final KeystoreLocation location; 16 | public final int configIndex; 17 | public final boolean isDebugType; 18 | public final File keystore; 19 | public final String ksAlias; 20 | public final String ksPass; 21 | public final String ksKeyPass; 22 | 23 | public SigningConfig(KeystoreLocation location, int configIndex, boolean isDebugType, File keystore, String ksAlias, String ksPass, String ksKeyPass) { 24 | this.location = location; 25 | this.configIndex = configIndex; 26 | this.isDebugType = isDebugType; 27 | this.keystore = keystore; 28 | this.ksAlias = ksAlias; 29 | this.ksPass = ksPass; 30 | this.ksKeyPass = ksKeyPass; 31 | } 32 | 33 | public String description() throws Exception { 34 | String checksum = FileUtil.createChecksum(keystore, "SHA-256"); 35 | return "[" + configIndex + "] " + checksum.substring(0, 8) + " " + keystore.getCanonicalPath() + " (" + location + ")"; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/signing/SigningConfigGen.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.signing; 2 | 3 | import at.favre.tools.apksigner.ui.Arg; 4 | import at.favre.tools.apksigner.util.CmdUtil; 5 | 6 | import java.io.File; 7 | import java.nio.file.Files; 8 | import java.nio.file.StandardCopyOption; 9 | import java.util.ArrayList; 10 | import java.util.Collections; 11 | import java.util.List; 12 | import java.util.Scanner; 13 | 14 | /** 15 | * Responsible of creating {@link SigningConfig} from the arguments. Will decide weather to 16 | * use a debug keystore (and its logic) or a release keystore (or multiple). 17 | */ 18 | public class SigningConfigGen { 19 | 20 | private static String WIN_DEBUG_KS_DEFAULT = "\\.android\\debug.keystore"; 21 | private static String NIX_DEBUG_KS_DEFAULT = "~/.android/debug.keystore"; 22 | private static String DEBUG_KEYSTORE = "debug.keystore"; 23 | 24 | private File tempDebugFile; 25 | 26 | public final List signingConfig; 27 | 28 | public SigningConfigGen(List signArgsList, boolean ksIsDebug) { 29 | signingConfig = generate(signArgsList, ksIsDebug); 30 | } 31 | 32 | private List generate(List signArgsList, boolean ksIsDebug) { 33 | if (ksIsDebug || signArgsList.isEmpty()) { 34 | File debugKeystore = null; 35 | SigningConfig.KeystoreLocation location = SigningConfig.KeystoreLocation.DEBUG_EMBEDDED; 36 | CmdUtil.OS osType = CmdUtil.getOsType(); 37 | 38 | if (ksIsDebug && !signArgsList.isEmpty()) { 39 | debugKeystore = new File(signArgsList.get(0).ksFile); 40 | 41 | if (!debugKeystore.exists() || !debugKeystore.isFile()) { 42 | throw new IllegalArgumentException("debug keystore '" + signArgsList.get(0).ksFile + "' does not exist or is not a file"); 43 | } 44 | location = SigningConfig.KeystoreLocation.DEBUG_CUSTOM_LOCATION; 45 | } 46 | 47 | if (debugKeystore == null) { 48 | try { 49 | File rootFolder = new File(SigningConfigGen.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath()).getParentFile(); 50 | File sameFolderStore = new File(rootFolder, DEBUG_KEYSTORE); 51 | if (sameFolderStore.exists()) { 52 | debugKeystore = sameFolderStore; 53 | location = SigningConfig.KeystoreLocation.DEBUG_SAME_FOLDER; 54 | } 55 | } catch (Exception ignored) { 56 | } 57 | } 58 | 59 | if (debugKeystore == null) { 60 | if (osType == CmdUtil.OS.WIN) { 61 | String userPath = System.getenv().get("USERPROFILE"); 62 | if (userPath != null) { 63 | File userDebugKeystoreFile = new File(userPath, WIN_DEBUG_KS_DEFAULT); 64 | if (userDebugKeystoreFile.exists()) { 65 | debugKeystore = userDebugKeystoreFile; 66 | } 67 | } 68 | location = SigningConfig.KeystoreLocation.DEBUG_ANDROID_FOLDER; 69 | } else if (new File(NIX_DEBUG_KS_DEFAULT).exists()) { 70 | debugKeystore = new File(NIX_DEBUG_KS_DEFAULT); 71 | location = SigningConfig.KeystoreLocation.DEBUG_ANDROID_FOLDER; 72 | } 73 | } 74 | 75 | if (debugKeystore == null) { 76 | try { 77 | tempDebugFile = File.createTempFile("temp_", "_" + DEBUG_KEYSTORE); 78 | Files.copy(getClass().getClassLoader().getResourceAsStream(DEBUG_KEYSTORE), tempDebugFile.toPath(), StandardCopyOption.REPLACE_EXISTING); 79 | location = SigningConfig.KeystoreLocation.DEBUG_EMBEDDED; 80 | debugKeystore = tempDebugFile; 81 | } catch (Exception e) { 82 | throw new IllegalStateException("could not load embedded debug keystore: " + e.getMessage(), e); 83 | } 84 | } 85 | 86 | return Collections.singletonList(new SigningConfig( 87 | location, 88 | 0, true, 89 | debugKeystore, 90 | "androiddebugkey", 91 | "android", 92 | "android" 93 | )); 94 | } else { 95 | List signingConfigs = new ArrayList<>(); 96 | 97 | for (Arg.SignArgs signArgs : signArgsList) { 98 | File keystore = new File(signArgs.ksFile); 99 | 100 | if (signArgs.ksFile == null || !keystore.exists() || keystore.isDirectory()) { 101 | throw new IllegalArgumentException("passed keystore does not exist: " + signArgs.ksFile); 102 | } 103 | 104 | if (signArgs.alias == null || signArgs.alias.trim().isEmpty()) { 105 | throw new IllegalArgumentException("when you provide your own keystore you must pass the keystore alias name"); 106 | } 107 | 108 | Scanner s = new Scanner(System.in); 109 | 110 | if (signArgs.pass == null) { 111 | System.out.println("Please enter the keystore password for config [" + signArgs.index + "] '" + signArgs.ksFile + "':"); 112 | if (System.console() != null) { 113 | signArgs.pass = String.valueOf(System.console().readPassword()); 114 | } else { 115 | signArgs.pass = s.next(); 116 | } 117 | } 118 | 119 | if (signArgs.keyPass == null) { 120 | System.out.println("Please enter the key password for config [" + signArgs.index + "] alias '" + signArgs.alias + "' and keystore '" + signArgs.ksFile + "':"); 121 | if (System.console() != null) { 122 | signArgs.keyPass = String.valueOf(System.console().readPassword()); 123 | } else { 124 | signArgs.keyPass = s.next(); 125 | } 126 | } 127 | 128 | s.close(); 129 | 130 | signingConfigs.add(new SigningConfig( 131 | SigningConfig.KeystoreLocation.RELEASE_CUSTOM, 132 | signArgs.index, false, keystore, 133 | signArgs.alias, 134 | signArgs.pass, 135 | signArgs.keyPass)); 136 | } 137 | return signingConfigs; 138 | } 139 | } 140 | 141 | public void cleanUp() { 142 | if (tempDebugFile != null && tempDebugFile.exists()) { 143 | tempDebugFile.delete(); 144 | tempDebugFile = null; 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/signing/ZipAlignExecutor.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.signing; 2 | 3 | import at.favre.tools.apksigner.ui.Arg; 4 | import at.favre.tools.apksigner.util.CmdUtil; 5 | import at.favre.tools.apksigner.util.FileUtil; 6 | 7 | import java.io.File; 8 | import java.nio.file.Files; 9 | import java.nio.file.StandardCopyOption; 10 | import java.nio.file.attribute.PosixFilePermission; 11 | import java.util.*; 12 | 13 | /** 14 | * Responsible for deciding and finding the zipalign executable used by the tool. 15 | */ 16 | public class ZipAlignExecutor { 17 | private enum Location { 18 | CUSTOM, PATH, BUILT_IN 19 | } 20 | 21 | public static final String ZIPALIGN_NAME = "zipalign"; 22 | 23 | private String[] zipAlignExecutable; 24 | private Location location; 25 | private File tmpFolder; 26 | 27 | public ZipAlignExecutor(Arg arg) { 28 | findLocation(arg); 29 | } 30 | 31 | private void findLocation(Arg arg) { 32 | try { 33 | if (arg.zipAlignPath != null && new File(arg.zipAlignPath).exists()) { 34 | File passedPath = new File(arg.zipAlignPath); 35 | if (passedPath.exists() && passedPath.isFile()) { 36 | zipAlignExecutable = new String[]{new File(arg.zipAlignPath).getAbsolutePath()}; 37 | location = Location.CUSTOM; 38 | } 39 | } else { 40 | File pathFile = CmdUtil.checkAndGetFromPATHEnvVar(ZIPALIGN_NAME); 41 | 42 | if (pathFile != null) { 43 | zipAlignExecutable = new String[]{pathFile.getAbsolutePath()}; 44 | location = Location.PATH; 45 | return; 46 | } 47 | 48 | if (zipAlignExecutable == null) { 49 | CmdUtil.OS osType = CmdUtil.getOsType(); 50 | 51 | String zipAlignFileName, libFolder; 52 | List libFiles = new ArrayList<>(); 53 | if (osType == CmdUtil.OS.WIN) { 54 | zipAlignFileName = "win-zipalign_33_0_2.exe"; 55 | libFolder = "binary-lib/windows-33_0_2/"; 56 | libFiles.add(libFolder + "libwinpthread-1.dll"); 57 | } else if (osType == CmdUtil.OS.MAC) { 58 | zipAlignFileName = "mac-zipalign-33_0_2"; 59 | } else { 60 | zipAlignFileName = "linux-zipalign-33_0_2"; 61 | libFolder = "binary-lib/linux-lib64-33_0_2/"; 62 | libFiles.add(libFolder + "libc++.so"); 63 | } 64 | 65 | tmpFolder = Files.createTempDirectory("uapksigner-").toFile(); 66 | File tmpZipAlign = File.createTempFile(zipAlignFileName, null, tmpFolder); 67 | Files.copy( 68 | Objects.requireNonNull( 69 | getClass().getClassLoader().getResourceAsStream(zipAlignFileName), 70 | "could not load built-in zipalign " + zipAlignFileName 71 | ), 72 | tmpZipAlign.toPath(), 73 | StandardCopyOption.REPLACE_EXISTING 74 | ); 75 | 76 | if (osType != CmdUtil.OS.WIN) { 77 | Set perms = new HashSet<>(); 78 | perms.add(PosixFilePermission.OWNER_EXECUTE); 79 | 80 | Files.setPosixFilePermissions(tmpZipAlign.toPath(), perms); 81 | 82 | File libFolderFile = new File(tmpFolder, "lib64"); 83 | if (!libFolderFile.mkdirs()) { 84 | throw new IllegalStateException("could not create " + libFolderFile); 85 | } 86 | 87 | for (String libFile : libFiles) { 88 | File lib64File = new File(libFolderFile, new File(libFile).getName()); 89 | 90 | if (!lib64File.createNewFile()) { 91 | throw new IllegalStateException("could not create " + lib64File); 92 | } 93 | 94 | Files.setPosixFilePermissions(lib64File.toPath(), perms); 95 | 96 | Files.copy( 97 | Objects.requireNonNull( 98 | getClass().getClassLoader().getResourceAsStream(libFile), 99 | "could not load built-in lib file " + libFile 100 | ), 101 | lib64File.toPath(), 102 | StandardCopyOption.REPLACE_EXISTING 103 | ); 104 | } 105 | } else { 106 | for (String libFile : libFiles) { 107 | System.out.println(libFile); 108 | System.out.println(tmpFolder); 109 | 110 | Files.copy( 111 | Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream(libFile), "could not load lib file " + libFile), 112 | new File(tmpFolder, new File(libFile).getName()).toPath(), 113 | StandardCopyOption.REPLACE_EXISTING 114 | ); 115 | } 116 | 117 | } 118 | 119 | zipAlignExecutable = new String[]{tmpZipAlign.getAbsolutePath()}; 120 | location = Location.BUILT_IN; 121 | } 122 | } 123 | } catch (Exception e) { 124 | throw new IllegalStateException("Could not find location for zipalign. Try to set it in PATH or use the --zipAlignPath argument. Optionally you could skip zipalign with --skipZipAlign. " + e.getMessage(), e); 125 | } 126 | } 127 | 128 | public boolean isExecutableFound() { 129 | return zipAlignExecutable != null; 130 | } 131 | 132 | public void cleanUp() { 133 | if (tmpFolder != null) { 134 | FileUtil.removeRecursive(tmpFolder.toPath()); 135 | tmpFolder = null; 136 | } 137 | } 138 | 139 | public String[] getZipAlignExecutable() { 140 | return zipAlignExecutable; 141 | } 142 | 143 | @Override 144 | public String toString() { 145 | return "zipalign location: " + location + " \n\t" + zipAlignExecutable[0]; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/ui/Arg.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.ui; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Arrays; 5 | import java.util.List; 6 | import java.util.Objects; 7 | 8 | /** 9 | * The model for the passed arguments 10 | */ 11 | public class Arg { 12 | //CHECKSTYLE:OFF -- I do want a concise class with only public access 13 | public String[] apkFile; 14 | public String out; 15 | 16 | public List signArgsList = new ArrayList<>(); 17 | public String lineageFilePath; 18 | 19 | public boolean overwrite = false; 20 | public boolean dryRun = false; 21 | public boolean verbose = false; 22 | public boolean skipZipAlign = false; 23 | public boolean debug = false; 24 | public boolean onlyVerify = false; 25 | public boolean ksIsDebug = false; 26 | public boolean allowResign; 27 | 28 | public String zipAlignPath; 29 | public String[] checkCertSha256; 30 | //CHECKSTYLE:ON 31 | 32 | Arg() { 33 | } 34 | 35 | Arg(String[] apkFile, String out, List list, 36 | boolean overwrite, boolean dryRun, boolean verbose, boolean skipZipAlign, boolean debug, boolean onlyVerify, 37 | String zipAlignPath, boolean ksIsDebug, boolean allowResign, String[] checkCertSha256, String lineageFilePath) { 38 | this.apkFile = apkFile; 39 | this.out = out; 40 | this.signArgsList = list; 41 | this.overwrite = overwrite; 42 | this.dryRun = dryRun; 43 | this.verbose = verbose; 44 | this.skipZipAlign = skipZipAlign; 45 | this.debug = debug; 46 | this.onlyVerify = onlyVerify; 47 | this.zipAlignPath = zipAlignPath; 48 | this.ksIsDebug = ksIsDebug; 49 | this.allowResign = allowResign; 50 | this.checkCertSha256 = checkCertSha256; 51 | this.lineageFilePath = lineageFilePath; 52 | } 53 | 54 | @Override 55 | public boolean equals(Object o) { 56 | if (this == o) return true; 57 | if (o == null || getClass() != o.getClass()) return false; 58 | Arg arg = (Arg) o; 59 | return overwrite == arg.overwrite && 60 | dryRun == arg.dryRun && 61 | verbose == arg.verbose && 62 | skipZipAlign == arg.skipZipAlign && 63 | debug == arg.debug && 64 | onlyVerify == arg.onlyVerify && 65 | ksIsDebug == arg.ksIsDebug && 66 | allowResign == arg.allowResign && 67 | Arrays.equals(apkFile, arg.apkFile) && 68 | Objects.equals(out, arg.out) && 69 | Objects.equals(signArgsList, arg.signArgsList) && 70 | Objects.equals(lineageFilePath, arg.lineageFilePath) && 71 | Objects.equals(zipAlignPath, arg.zipAlignPath) && 72 | Arrays.equals(checkCertSha256, arg.checkCertSha256); 73 | } 74 | 75 | @Override 76 | public int hashCode() { 77 | int result = Objects.hash(out, signArgsList, lineageFilePath, overwrite, dryRun, verbose, skipZipAlign, debug, onlyVerify, ksIsDebug, allowResign, zipAlignPath); 78 | result = 31 * result + Arrays.hashCode(apkFile); 79 | result = 31 * result + Arrays.hashCode(checkCertSha256); 80 | return result; 81 | } 82 | 83 | @Override 84 | public String toString() { 85 | return "Arg{" + 86 | "apkFile=" + Arrays.toString(apkFile) + 87 | ", out='" + out + '\'' + 88 | ", signArgsList=" + signArgsList + 89 | ", lineageFile='" + lineageFilePath + '\'' + 90 | ", overwrite=" + overwrite + 91 | ", dryRun=" + dryRun + 92 | ", verbose=" + verbose + 93 | ", skipZipAlign=" + skipZipAlign + 94 | ", debug=" + debug + 95 | ", onlyVerify=" + onlyVerify + 96 | ", ksIsDebug=" + ksIsDebug + 97 | ", allowResign=" + allowResign + 98 | ", zipAlignPath='" + zipAlignPath + '\'' + 99 | ", checkCertSha256=" + Arrays.toString(checkCertSha256) + 100 | '}'; 101 | } 102 | 103 | //CHECKSTYLE:OFF 104 | public static class SignArgs implements Comparable { 105 | public int index; 106 | public String ksFile; 107 | public String alias; 108 | public String pass; 109 | public String keyPass; 110 | 111 | SignArgs(int index, String ksFile, String alias, String pass, String keyPass) { 112 | this.index = index; 113 | this.ksFile = ksFile; 114 | this.alias = alias; 115 | this.pass = pass; 116 | this.keyPass = keyPass; 117 | } 118 | 119 | @Override 120 | public boolean equals(Object o) { 121 | if (this == o) return true; 122 | if (o == null || getClass() != o.getClass()) return false; 123 | 124 | SignArgs signArgs = (SignArgs) o; 125 | 126 | if (index != signArgs.index) return false; 127 | if (ksFile != null ? !ksFile.equals(signArgs.ksFile) : signArgs.ksFile != null) return false; 128 | if (alias != null ? !alias.equals(signArgs.alias) : signArgs.alias != null) return false; 129 | if (pass != null ? !pass.equals(signArgs.pass) : signArgs.pass != null) return false; 130 | return keyPass != null ? keyPass.equals(signArgs.keyPass) : signArgs.keyPass == null; 131 | 132 | } 133 | 134 | @Override 135 | public int hashCode() { 136 | int result = index; 137 | result = 31 * result + (ksFile != null ? ksFile.hashCode() : 0); 138 | result = 31 * result + (alias != null ? alias.hashCode() : 0); 139 | result = 31 * result + (pass != null ? pass.hashCode() : 0); 140 | result = 31 * result + (keyPass != null ? keyPass.hashCode() : 0); 141 | return result; 142 | } 143 | 144 | @Override 145 | public String toString() { 146 | return "SignArgs{" + 147 | "index=" + index + 148 | ", ksFile='" + ksFile + '\'' + 149 | ", alias='" + alias + '\'' + 150 | ", pass='" + pass + '\'' + 151 | ", keyPass='" + keyPass + '\'' + 152 | '}'; 153 | } 154 | 155 | @Override 156 | public int compareTo(SignArgs o) { 157 | return Integer.valueOf(index).compareTo(o.index); 158 | } 159 | } 160 | //CHECKSTYLE:ON 161 | } 162 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/ui/CLIParser.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.ui; 2 | 3 | import at.favre.tools.apksigner.util.CmdUtil; 4 | import org.apache.commons.cli.*; 5 | 6 | /** 7 | * Parses the command line input and converts it to a structured model ({@link Arg} 8 | */ 9 | public final class CLIParser { 10 | 11 | public static final String ARG_APK_FILE = "a"; 12 | public static final String ARG_APK_OUT = "o"; 13 | public static final String ARG_VERIFY = "onlyVerify"; 14 | public static final String ARG_SKIP_ZIPALIGN = "skipZipAlign"; 15 | 16 | private CLIParser() { 17 | } 18 | 19 | public static Arg parse(String[] inputArgs) { 20 | Options options = setupOptions(); 21 | CommandLineParser parser = new DefaultParser(); 22 | Arg argument = new Arg(); 23 | 24 | try { 25 | CommandLine commandLine = parser.parse(options, inputArgs); 26 | 27 | if (commandLine.hasOption("h") || commandLine.hasOption("help")) { 28 | printHelp(options); 29 | return null; 30 | } 31 | 32 | if (commandLine.hasOption("v") || commandLine.hasOption("version")) { 33 | System.out.println("Version: " + CLIParser.class.getPackage().getImplementationVersion()); 34 | return null; 35 | } 36 | 37 | argument.apkFile = commandLine.getOptionValues(ARG_APK_FILE); 38 | argument.zipAlignPath = commandLine.getOptionValue("zipAlignPath"); 39 | argument.out = commandLine.getOptionValue(ARG_APK_OUT); 40 | 41 | if (commandLine.hasOption("lineage")) { 42 | argument.lineageFilePath = commandLine.getOptionValue("lineage"); 43 | } 44 | if (commandLine.hasOption("ksDebug") && commandLine.hasOption("ks")) { 45 | throw new IllegalArgumentException("Either provide normal keystore or debug keystore location, not both."); 46 | } 47 | 48 | if (commandLine.hasOption("verifySha256")) { 49 | argument.checkCertSha256 = commandLine.getOptionValues("verifySha256"); 50 | } 51 | 52 | argument.signArgsList = new MultiKeystoreParser().parse(commandLine); 53 | argument.ksIsDebug = commandLine.hasOption("ksDebug"); 54 | argument.onlyVerify = commandLine.hasOption(ARG_VERIFY); 55 | argument.dryRun = commandLine.hasOption("dryRun"); 56 | argument.debug = commandLine.hasOption("debug"); 57 | argument.overwrite = commandLine.hasOption("overwrite"); 58 | argument.verbose = commandLine.hasOption("verbose"); 59 | argument.allowResign = commandLine.hasOption("allowResign"); 60 | argument.skipZipAlign = commandLine.hasOption(ARG_SKIP_ZIPALIGN); 61 | 62 | if (argument.apkFile == null || argument.apkFile.length == 0) { 63 | throw new IllegalArgumentException("must provide apk file or folder"); 64 | } 65 | 66 | if (argument.overwrite && argument.out != null) { 67 | throw new IllegalArgumentException("either provide out path or overwrite argument, cannot process both"); 68 | } 69 | 70 | } catch (Exception e) { 71 | System.err.println(e.getMessage()); 72 | 73 | CLIParser.printHelp(options); 74 | 75 | argument = null; 76 | } 77 | 78 | return argument; 79 | } 80 | 81 | static Options setupOptions() { 82 | Options options = new Options(); 83 | Option apkPathOpt = Option.builder(ARG_APK_FILE).longOpt("apks").argName("file/folder").hasArgs().desc("Can be a single apk or " + 84 | "a folder containing multiple apks. These are used as source for zipalining/signing/verifying. It is also possible to provide " + 85 | "multiple locations space seperated (can be mixed file folder): '/apk /apks2 my.apk'. Folder will be checked non-recursively.").build(); 86 | Option outOpt = Option.builder(ARG_APK_OUT).longOpt("out").argName("path").hasArg().desc("Where the aligned/signed apks will be copied " + 87 | "to. Must be a folder. Will create, if it does not exist.").build(); 88 | Option lineagePath = Option.builder("l").longOpt("lineage").argName("path").hasArg().desc("The lineage file for apk signer schema v3 if more then 1 signature is used. See here https://bit.ly/2mh6iAC for more info.").build(); 89 | 90 | Option ksOpt = Option.builder().longOpt("ks").argName("keystore").hasArgs().desc("The keystore file. If this isn't provided, will try" + 91 | "to sign with a debug keystore. The debug keystore will be searched in the same dir as execution and 'user_home/.android' folder. " + 92 | "If it is not found there a built-in keystore will be used for convenience. It is possible to pass one or multiple keystores. " + 93 | "The syntax for multiple params is '" + MultiKeystoreParser.sep + "' for example: '1" + MultiKeystoreParser.sep + 94 | "keystore.jks'. Must match the parameters of --ksAlias.").build(); 95 | Option ksDebugOpt = Option.builder().longOpt("ksDebug").argName("keystore").hasArg().desc("Same as --ks parameter but with a debug keystore." + 96 | " With this option the default keystore alias and passwords are used and any arguments relating to these parameter are ignored.").build(); 97 | Option ksPassOpt = Option.builder().longOpt("ksPass").argName("password").hasArgs().desc("The password for the keystore. If this is " + 98 | "not provided, caller will get a user prompt to enter it. It is possible to pass one or multiple passwords for multiple keystore " + 99 | "configs. The syntax for multiple params is '" + MultiKeystoreParser.sep + "'. Must match the parameters of --ks.").build(); 100 | Option ksKeyPassOpt = Option.builder().longOpt("ksKeyPass").argName("password").hasArgs().desc("The password for the key. If this is not" + 101 | " provided, caller will get a user prompt to enter it. It is possible to pass one or multiple passwords for multiple keystore configs." + 102 | " The syntax for multiple params is '" + MultiKeystoreParser.sep + "'. Must match the parameters of --ks.").build(); 103 | Option ksAliasOpt = Option.builder().longOpt("ksAlias").argName("alias").hasArgs().desc("The alias of the used key in the keystore. Must be" + 104 | " provided if --ks is provided. It is possible to pass one or multiple aliases for multiple keystore configs. The syntax for multiple" + 105 | " params is '" + MultiKeystoreParser.sep + "' for example: '1" + MultiKeystoreParser.sep + "my-alias'. Must match the parameters of --ks.").build(); 106 | Option zipAlignPathOpt = Option.builder().longOpt("zipAlignPath").argName("path").hasArg().desc("Pass your own zipalign executable. If this " + 107 | "is omitted the built-in version is used (available for win, mac and linux)").build(); 108 | 109 | Option checkSh256Opt = Option.builder().longOpt("verifySha256").argName("cert-sha256").hasArgs().desc("Provide one or multiple sha256 in " + 110 | "string hex representation (ignoring case) to let the tool check it against hashes of the APK's certificate and use it in the verify" + 111 | " process. All given hashes must be present in the signature to verify e.g. if 2 hashes are given the apk must have 2 signatures with" + 112 | " exact these hashes (providing only one hash, even if it matches one cert, will fail).").build(); 113 | 114 | Option verifyOnlyOpt = Option.builder("y").longOpt(ARG_VERIFY).hasArg(false).desc("If this is passed, the signature and alignment is only verified.").build(); 115 | Option dryRunOpt = Option.builder().longOpt("dryRun").hasArg(false).desc("Check what apks would be processed without actually doing anything.").build(); 116 | Option skipZipOpt = Option.builder().longOpt(ARG_SKIP_ZIPALIGN).hasArg(false).desc("Skips zipAlign process. Also affects verify.").build(); 117 | Option overwriteOpt = Option.builder().longOpt("overwrite").hasArg(false).desc("Will overwrite/delete the apks in-place").build(); 118 | Option verboseOpt = Option.builder().longOpt("verbose").hasArg(false).desc("Prints more output, especially useful for sign verify.").build(); 119 | Option debugOpt = Option.builder().longOpt("debug").hasArg(false).desc("Prints additional info for debugging.").build(); 120 | Option resignOpt = Option.builder().longOpt("allowResign").hasArg(false).desc("If this flag is set, the tool will not show error on signed apks, but will " + 121 | "sign them with the new certificate (therefore removing the old one).").build(); 122 | 123 | Option help = Option.builder("h").longOpt("help").desc("Prints help docs.").build(); 124 | Option version = Option.builder("v").longOpt("version").desc("Prints current version.").build(); 125 | 126 | OptionGroup mainArgs = new OptionGroup(); 127 | mainArgs.addOption(apkPathOpt).addOption(help).addOption(version); 128 | mainArgs.setRequired(true); 129 | 130 | options.addOptionGroup(mainArgs); 131 | options.addOption(ksOpt).addOption(ksPassOpt).addOption(ksKeyPassOpt).addOption(ksAliasOpt).addOption(verifyOnlyOpt) 132 | .addOption(dryRunOpt).addOption(skipZipOpt).addOption(overwriteOpt).addOption(verboseOpt).addOption(debugOpt) 133 | .addOption(zipAlignPathOpt).addOption(outOpt).addOption(lineagePath).addOption(ksDebugOpt).addOption(resignOpt).addOption(checkSh256Opt); 134 | 135 | return options; 136 | } 137 | 138 | private static void printHelp(Options options) { 139 | HelpFormatter help = new HelpFormatter(); 140 | help.setWidth(110); 141 | help.setLeftPadding(4); 142 | help.printHelp("uber-apk-signer", "Version: " + CmdUtil.jarVersion(), options, "", true); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/ui/FileArgParser.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.ui; 2 | 3 | import at.favre.tools.apksigner.util.FileUtil; 4 | 5 | import java.io.File; 6 | import java.io.IOException; 7 | import java.util.*; 8 | import java.util.stream.Collectors; 9 | 10 | /** 11 | * Parses and checks the file input argument 12 | */ 13 | public class FileArgParser { 14 | 15 | public List parseAndSortUniqueFilesNonRecursive(String[] files, String extensionFilter) { 16 | if (files == null) { 17 | throw new IllegalArgumentException("input files must not be null"); 18 | } 19 | 20 | if (files.length == 0) { 21 | return Collections.emptyList(); 22 | } 23 | 24 | Set fileSet = new HashSet<>(); 25 | 26 | for (String file : files) { 27 | File apkFile = new File(file); 28 | 29 | if (apkFile.exists() && apkFile.isDirectory()) { 30 | for (File dirFile : apkFile.listFiles()) { 31 | if (isCorrectFile(dirFile, extensionFilter)) { 32 | fileSet.add(dirFile); 33 | } 34 | } 35 | } else if (isCorrectFile(apkFile, extensionFilter)) { 36 | fileSet.add(apkFile); 37 | } else { 38 | throw new IllegalArgumentException("provided apk path or file '" + file + "' does not exist"); 39 | } 40 | } 41 | 42 | List resultList = new ArrayList<>(fileSet); 43 | Collections.sort(resultList); 44 | return resultList; 45 | } 46 | 47 | public static List getDirSummary(List files) { 48 | Set parents = new HashSet<>(); 49 | for (File file : files) { 50 | if (file.isDirectory()) { 51 | parents.add(file); 52 | } else { 53 | try { 54 | file = new File(file.getCanonicalPath()); 55 | parents.add(file.getParentFile()); 56 | } catch (IOException e) { 57 | throw new IllegalStateException("could not add parent folder", e); 58 | } 59 | } 60 | } 61 | 62 | return parents.stream().map(f -> { 63 | try { 64 | return f.getCanonicalPath(); 65 | } catch (IOException e) { 66 | throw new IllegalStateException("could not get dir summary", e); 67 | } 68 | }).sorted().collect(Collectors.toList()); 69 | } 70 | 71 | private static boolean isCorrectFile(File f, String extensionFilter) { 72 | if (f != null && f.exists() && f.isFile()) { 73 | return FileUtil.getFileExtension(f).equalsIgnoreCase(extensionFilter); 74 | } 75 | return false; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/ui/MultiKeystoreParser.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.ui; 2 | 3 | import org.apache.commons.cli.CommandLine; 4 | 5 | import java.util.*; 6 | import java.util.stream.Collectors; 7 | 8 | import static java.util.Collections.singletonList; 9 | 10 | /** 11 | * Responsible for parsing the signing config arguments, especially if multiple configs are passed. 12 | */ 13 | public class MultiKeystoreParser { 14 | public static final String sep = "="; 15 | 16 | List parse(CommandLine commandLine) { 17 | if (commandLine.hasOption("ksDebug")) { 18 | return singletonList(new Arg.SignArgs(0, commandLine.getOptionValue("ksDebug"), null, null, null)); 19 | } else { 20 | List signArgsList = new ArrayList<>(); 21 | String[] ksArgs = commandLine.getOptionValues("ks"); 22 | if (!commandLine.hasOption("ks")) { 23 | return signArgsList; 24 | } else if (ksArgs.length == 1) { 25 | signArgsList.add(new Arg.SignArgs(0, commandLine.getOptionValue("ks"), commandLine.getOptionValue("ksAlias"), 26 | commandLine.getOptionValue("ksPass"), commandLine.getOptionValue("ksKeyPass"))); 27 | } else if (ksArgs.length > 1) { 28 | Map ksArgList = new HashMap<>(); 29 | Map ksAliasArgList = new HashMap<>(); 30 | Map ksPassArgList = new HashMap<>(); 31 | Map ksKeyPassARgList = new HashMap<>(); 32 | 33 | for (String arg : ksArgs) { 34 | Entry entry = parseEntry(arg); 35 | ksArgList.put(entry.index, entry.value); 36 | } 37 | 38 | for (String arg : commandLine.getOptionValues("ksAlias")) { 39 | Entry entry = parseEntry(arg); 40 | ksAliasArgList.put(entry.index, entry.value); 41 | } 42 | 43 | if (commandLine.hasOption("ksPass")) { 44 | for (String arg : commandLine.getOptionValues("ksPass")) { 45 | Entry entry = parseEntry(arg); 46 | ksPassArgList.put(entry.index, entry.value); 47 | } 48 | } 49 | 50 | if (commandLine.hasOption("ksKeyPass")) { 51 | for (String arg : commandLine.getOptionValues("ksKeyPass")) { 52 | Entry entry = parseEntry(arg); 53 | ksKeyPassARgList.put(entry.index, entry.value); 54 | } 55 | } 56 | 57 | if (ksArgList.size() != ksAliasArgList.size()) { 58 | throw new IllegalArgumentException("must provide the same count of --ks as --ksAlias"); 59 | } 60 | 61 | signArgsList.addAll(ksArgList.entrySet().stream().map(entry -> new Arg.SignArgs(entry.getKey(), entry.getValue(), ksAliasArgList.get(entry.getKey()), 62 | ksPassArgList.get(entry.getKey()), ksKeyPassARgList.get(entry.getKey()))).collect(Collectors.toList())); 63 | } 64 | 65 | for (int i = 0; i < signArgsList.size(); i++) { 66 | Arg.SignArgs signArgs = signArgsList.get(i); 67 | 68 | if (signArgs.ksFile == null && (signArgs.pass != null || signArgs.keyPass != null || signArgs.alias != null)) { 69 | throw new IllegalArgumentException("must provide keystore file if any keystore config is given for sign config " + i); 70 | } 71 | 72 | if (signArgs.ksFile != null && signArgs.alias == null) { 73 | throw new IllegalArgumentException("must provide alias if keystore is given for sign config " + i); 74 | } 75 | } 76 | Collections.sort(signArgsList); 77 | 78 | return signArgsList; 79 | } 80 | } 81 | 82 | private Entry parseEntry(String ksArgs) { 83 | String[] parts = ksArgs.trim().split(sep); 84 | 85 | if (parts.length != 2) { 86 | throw new IllegalArgumentException("must be of syntax " + sep + " - " + ksArgs); 87 | } 88 | 89 | if (!parts[0].chars().allMatch(Character::isDigit)) { 90 | throw new IllegalArgumentException("first parm of " + sep + " must be integer: " + parts[0]); 91 | } 92 | return new Entry(Integer.valueOf(parts[0]), parts[1]); 93 | } 94 | 95 | private static class Entry { 96 | final int index; 97 | final String value; 98 | 99 | Entry(int index, String value) { 100 | this.index = index; 101 | this.value = value; 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/util/AndroidApkSignerUtil.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.util; 2 | 3 | import at.favre.tools.apksigner.signing.SigningConfig; 4 | import at.favre.tools.apksigner.ui.Arg; 5 | 6 | import java.io.File; 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | public final class AndroidApkSignerUtil { 11 | private AndroidApkSignerUtil() { 12 | } 13 | 14 | public static String[] createApkToolArgs(Arg arguments, List list, File targetApkFile, File outFile) { 15 | List args = new ArrayList<>(); 16 | args.add("sign"); 17 | 18 | if (arguments.verbose) { 19 | args.add("--verbose"); 20 | } 21 | 22 | if (arguments.lineageFilePath != null) { 23 | args.add("--lineage"); 24 | args.add(arguments.lineageFilePath); 25 | } 26 | 27 | for (int i = 0; i < list.size(); i++) { 28 | args.add("--ks"); 29 | args.add(list.get(i).keystore.getAbsolutePath()); 30 | args.add("--ks-key-alias"); 31 | args.add(list.get(i).ksAlias); 32 | args.add("--ks-pass"); 33 | args.add("pass:" + list.get(i).ksPass); 34 | args.add("--key-pass"); 35 | args.add("pass:" + list.get(i).ksKeyPass); 36 | args.add("--out"); 37 | args.add(outFile.getAbsolutePath()); 38 | 39 | if (i + 1 < list.size()) { 40 | args.add("--next-signer"); 41 | } 42 | } 43 | 44 | args.add("--v4-signing-enabled"); 45 | 46 | args.add(targetApkFile.getAbsolutePath()); 47 | 48 | return args.toArray(new String[0]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/util/CmdUtil.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.util; 2 | 3 | import java.io.BufferedReader; 4 | import java.io.File; 5 | import java.io.InputStreamReader; 6 | import java.util.Arrays; 7 | 8 | public final class CmdUtil { 9 | 10 | private CmdUtil() { 11 | } 12 | 13 | public static Result runCmd(String[] cmdArray) { 14 | StringBuilder logStringBuilder = new StringBuilder(); 15 | Exception exception = null; 16 | int exitValue = -1; 17 | try { 18 | ProcessBuilder pb = new ProcessBuilder(cmdArray); 19 | pb.redirectErrorStream(true); 20 | Process process = pb.start(); 21 | try (BufferedReader inStreamReader = new BufferedReader( 22 | new InputStreamReader(process.getInputStream()))) { 23 | String s; 24 | while ((s = inStreamReader.readLine()) != null) { 25 | if (!s.isEmpty()) logStringBuilder.append(s).append("\n"); 26 | } 27 | } 28 | process.waitFor(); 29 | exitValue = process.exitValue(); 30 | } catch (Exception e) { 31 | exception = e; 32 | } 33 | return new Result(logStringBuilder.toString(), exception, cmdArray, exitValue); 34 | } 35 | 36 | public static boolean canRunCmd(String[] cmd) { 37 | Result result = runCmd(cmd); 38 | return result.exception == null; 39 | } 40 | 41 | public static T[] concat(T[] first, T[] second) { 42 | T[] result = Arrays.copyOf(first, first.length + second.length); 43 | System.arraycopy(second, 0, result, first.length, second.length); 44 | return result; 45 | } 46 | 47 | public static File checkAndGetFromPATHEnvVar(final String matchesExecutable) { 48 | String separator = ":"; 49 | if (getOsType() == OS.WIN) { 50 | separator = ";"; 51 | } 52 | 53 | String[] pathParts = System.getenv("PATH").split(separator); 54 | for (String pathPart : pathParts) { 55 | File pathFile = new File(pathPart); 56 | 57 | if (pathFile.isFile() && pathFile.getName().toLowerCase().contains(matchesExecutable)) { 58 | return pathFile; 59 | } else if (pathFile.isDirectory()) { 60 | File[] matchedFiles = pathFile.listFiles(pathname -> pathname.getName().toLowerCase().contains(matchesExecutable)); 61 | 62 | for (File matchedFile : matchedFiles) { 63 | if (CmdUtil.canRunCmd(new String[]{matchedFile.getAbsolutePath()})) { 64 | return matchedFile; 65 | } 66 | } 67 | } 68 | } 69 | return null; 70 | } 71 | 72 | public static OS getOsType() { 73 | String osName = System.getProperty("os.name").toLowerCase(); 74 | 75 | if (osName.contains("win")) { 76 | return OS.WIN; 77 | } 78 | if (osName.contains("mac")) { 79 | return OS.MAC; 80 | } 81 | 82 | return OS._NIX; 83 | } 84 | 85 | public enum OS { 86 | WIN, MAC, _NIX 87 | } 88 | 89 | public static class Result { 90 | public final Exception exception; 91 | public final String out; 92 | public final String cmd; 93 | public final int exitValue; 94 | 95 | public Result(String out, Exception exception, String[] cmd, int exitValue) { 96 | this.out = out; 97 | this.exception = exception; 98 | this.cmd = Arrays.toString(cmd); 99 | this.exitValue = exitValue; 100 | } 101 | 102 | @Override 103 | public String toString() { 104 | return cmd + "\n" + out + " (" + exitValue + ")\n"; 105 | } 106 | 107 | public boolean success() { 108 | return exitValue == 0; 109 | } 110 | } 111 | 112 | public static String jarVersion() { 113 | return CmdUtil.class.getPackage().getImplementationVersion(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/main/java/at/favre/tools/apksigner/util/FileUtil.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.util; 2 | 3 | import java.io.File; 4 | import java.io.FileInputStream; 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | import java.math.BigInteger; 8 | import java.nio.file.FileVisitResult; 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.nio.file.SimpleFileVisitor; 12 | import java.nio.file.attribute.BasicFileAttributes; 13 | import java.security.MessageDigest; 14 | import java.text.DecimalFormat; 15 | import java.text.NumberFormat; 16 | import java.util.Locale; 17 | 18 | public final class FileUtil { 19 | 20 | private FileUtil() { 21 | } 22 | 23 | public static String getFileExtension(File file) { 24 | if (file == null) { 25 | return ""; 26 | } 27 | return file.getName().substring(file.getName().lastIndexOf(".") + 1); 28 | } 29 | 30 | public static String getFileNameWithoutExtension(File file) { 31 | String fileName = file.getName(); 32 | int pos = fileName.lastIndexOf("."); 33 | if (pos > 0) { 34 | fileName = fileName.substring(0, pos); 35 | } 36 | return fileName; 37 | } 38 | 39 | public static String createChecksum(File file, String shaAlgo) { 40 | try { 41 | InputStream fis = new FileInputStream(file); 42 | byte[] buffer = new byte[1024]; 43 | MessageDigest complete = MessageDigest.getInstance(shaAlgo); 44 | int numRead; 45 | 46 | do { 47 | numRead = fis.read(buffer); 48 | if (numRead > 0) { 49 | complete.update(buffer, 0, numRead); 50 | } 51 | } while (numRead != -1); 52 | 53 | fis.close(); 54 | return new BigInteger(1, complete.digest()).toString(16).toLowerCase(); 55 | } catch (Exception e) { 56 | throw new IllegalStateException("could not create checksum for " + file + " and algo " + shaAlgo + ": " + e.getMessage(), e); 57 | } 58 | } 59 | 60 | public static void removeRecursive(Path path) { 61 | try { 62 | Files.walkFileTree(path, new SimpleFileVisitor() { 63 | @Override 64 | public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { 65 | Files.delete(file); 66 | return FileVisitResult.CONTINUE; 67 | } 68 | 69 | @Override 70 | public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { 71 | Files.delete(file); 72 | return FileVisitResult.CONTINUE; 73 | } 74 | 75 | @Override 76 | public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { 77 | if (exc == null) { 78 | Files.delete(dir); 79 | return FileVisitResult.CONTINUE; 80 | } else { 81 | throw exc; 82 | } 83 | } 84 | }); 85 | } catch (Exception e) { 86 | throw new IllegalStateException("could not delete " + path + ": " + e.getMessage(), e); 87 | } 88 | } 89 | 90 | public static String getFileSizeMb(File file) { 91 | try { 92 | DecimalFormat df = (DecimalFormat) NumberFormat.getNumberInstance(Locale.US); 93 | df.applyPattern("0.0#"); 94 | long fileSizeInBytes = file.length(); 95 | return df.format(fileSizeInBytes / (1024.0f * 1024.0f)) + " MiB"; 96 | } catch (Exception e) { 97 | return ""; 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/resources/binary-lib/linux-lib64-33_0_2/libc++.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/main/resources/binary-lib/linux-lib64-33_0_2/libc++.so -------------------------------------------------------------------------------- /src/main/resources/binary-lib/windows-33_0_2/libwinpthread-1.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/main/resources/binary-lib/windows-33_0_2/libwinpthread-1.dll -------------------------------------------------------------------------------- /src/main/resources/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/main/resources/debug.keystore -------------------------------------------------------------------------------- /src/main/resources/lib/apksigner_33_0_2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/main/resources/lib/apksigner_33_0_2.jar -------------------------------------------------------------------------------- /src/main/resources/linux-zipalign-33_0_2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/main/resources/linux-zipalign-33_0_2 -------------------------------------------------------------------------------- /src/main/resources/mac-zipalign-33_0_2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/main/resources/mac-zipalign-33_0_2 -------------------------------------------------------------------------------- /src/main/resources/win-zipalign_33_0_2.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/main/resources/win-zipalign_33_0_2.exe -------------------------------------------------------------------------------- /src/test/java/at/favre/tools/apksigner/CmdUtilTest.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner; 2 | 3 | import at.favre.tools.apksigner.util.CmdUtil; 4 | import org.junit.Test; 5 | 6 | import static org.junit.Assert.assertFalse; 7 | import static org.junit.Assert.assertTrue; 8 | 9 | public class CmdUtilTest { 10 | 11 | @Test 12 | public void testCanRunCommand() throws Exception { 13 | assertFalse("should not be able to run random", CmdUtil.canRunCmd(new String[]{"Thisadhpiwadahdjsahduhduwaheuawez27371236"})); 14 | assertTrue("should be able to run cmd 'java -version'", CmdUtil.canRunCmd(new String[]{"java", "-version"})); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/test/java/at/favre/tools/apksigner/SignToolTest.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner; 2 | 3 | import at.favre.tools.apksigner.signing.AndroidApkSignerVerify; 4 | import at.favre.tools.apksigner.ui.CLIParser; 5 | import at.favre.tools.apksigner.ui.CLIParserTest; 6 | import at.favre.tools.apksigner.ui.MultiKeystoreParser; 7 | import at.favre.tools.apksigner.util.FileUtil; 8 | import org.junit.Before; 9 | import org.junit.Rule; 10 | import org.junit.Test; 11 | import org.junit.rules.TemporaryFolder; 12 | 13 | import java.io.File; 14 | import java.nio.file.Files; 15 | import java.util.ArrayList; 16 | import java.util.Arrays; 17 | import java.util.Collections; 18 | import java.util.List; 19 | import java.util.stream.Collectors; 20 | 21 | import static junit.framework.TestCase.*; 22 | 23 | @SuppressWarnings("ALL") 24 | public class SignToolTest { 25 | private final static String ksAlias = "app"; 26 | private final static String ksPass = "password"; 27 | private final static String keyPass = "keypass"; 28 | private final static String releaseCertSha256 = "29728d7bffedbc3a8e3e3a9cbd1959cc724ae7c178cacf01547f0831fe64c3f1"; 29 | private final static String debugCertSha256 = "3b9e8ae8fadc373d4fff5da150c2e94cc0ad642e7886ffeb9d0fc9327bc66388"; 30 | 31 | @Rule 32 | public TemporaryFolder temporaryFolder = new TemporaryFolder(); 33 | private File originalFolder, outFolder, testReleaseKs, testDebugKeystore, lineageFile; 34 | 35 | private List unsingedApks; 36 | private List singedApks; 37 | 38 | @Before 39 | public void setUp() throws Exception { 40 | originalFolder = temporaryFolder.newFolder("signer-test", "apks"); 41 | outFolder = temporaryFolder.newFolder("signer-test", "out"); 42 | testReleaseKs = new File(getClass().getClassLoader().getResource("test-release-key.jks").toURI().getPath()); 43 | testDebugKeystore = new File(getClass().getClassLoader().getResource("test-debug.jks").toURI().getPath()); 44 | lineageFile = new File(getClass().getClassLoader().getResource("test-debug-to-release.lineage").toURI().getPath()); 45 | 46 | File signedFolder = new File(getClass().getClassLoader().getResource("test-apks-signed").toURI().getPath()); 47 | File unsignedFolder = new File(getClass().getClassLoader().getResource("test-apks-unsigned").toURI().getPath()); 48 | 49 | singedApks = Arrays.asList(signedFolder.listFiles()).stream() 50 | .sorted((a, b) -> a.getAbsolutePath().compareTo(b.getAbsolutePath())) 51 | .collect(Collectors.toList()); 52 | unsingedApks = Arrays.asList(unsignedFolder.listFiles()).stream() 53 | .sorted((a, b) -> a.getAbsolutePath().compareTo(b.getAbsolutePath())) 54 | .collect(Collectors.toList()); 55 | 56 | assertFalse(singedApks.isEmpty()); 57 | assertFalse(unsingedApks.isEmpty()); 58 | } 59 | 60 | @Test 61 | public void testSignMultipleApks() throws Exception { 62 | List uApks = copyToTestPath(originalFolder, unsingedApks); 63 | 64 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + " " + outFolder.getAbsolutePath() + " --" + CLIParser.ARG_SKIP_ZIPALIGN; 65 | testAndCheck(cmd, originalFolder, outFolder, uApks); 66 | } 67 | 68 | @Test 69 | public void testSignSingleApk() throws Exception { 70 | List uApks = copyToTestPath(originalFolder, unsingedApks.subList(0, 1)); 71 | System.out.println("found " + uApks.size() + " apks in out folder"); 72 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.listFiles()[0].getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + " " + outFolder.getAbsolutePath() + " --" + CLIParser.ARG_SKIP_ZIPALIGN; 73 | testAndCheck(cmd, originalFolder, outFolder, uApks); 74 | } 75 | 76 | @Test 77 | public void testSignMultipleApksCustomCert() throws Exception { 78 | List uApks = copyToTestPath(originalFolder, unsingedApks); 79 | 80 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + " " + outFolder.getAbsolutePath() + " --" + CLIParser.ARG_SKIP_ZIPALIGN + " --ks " + testReleaseKs.getAbsolutePath() + " --ksPass " + ksPass + " --ksKeyPass " + keyPass + " --ksAlias " + ksAlias; 81 | testAndCheck(cmd, originalFolder, outFolder, uApks); 82 | } 83 | 84 | @Test 85 | public void testSignMultipleApksMultipleCustomCert() throws Exception { 86 | List uApks = copyToTestPath(originalFolder, unsingedApks); 87 | 88 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT 89 | + " " + outFolder.getAbsolutePath() + " --lineage " + lineageFile.getAbsolutePath() + " --" + CLIParser.ARG_SKIP_ZIPALIGN 90 | + " --debug --ks 1" + MultiKeystoreParser.sep + testReleaseKs.getAbsolutePath() + " 2" + MultiKeystoreParser.sep + testDebugKeystore.getAbsolutePath() + " --ksPass 1" + MultiKeystoreParser.sep + ksPass + " 2" + MultiKeystoreParser.sep + "android --ksKeyPass 1" + MultiKeystoreParser.sep + keyPass + " 2" + MultiKeystoreParser.sep + "android --ksAlias 1" + MultiKeystoreParser.sep + ksAlias + " 2" + MultiKeystoreParser.sep + "androiddebugkey"; 91 | testAndCheck(cmd, originalFolder, outFolder, uApks); 92 | } 93 | 94 | @Test 95 | public void testSignMultipleApksCustomDebugCert() throws Exception { 96 | List uApks = copyToTestPath(originalFolder, unsingedApks); 97 | 98 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + 99 | " " + outFolder.getAbsolutePath() + " --" + CLIParser.ARG_SKIP_ZIPALIGN + " --ksDebug " + testDebugKeystore.getAbsolutePath(); 100 | testAndCheck(cmd, originalFolder, outFolder, uApks); 101 | } 102 | 103 | @Test 104 | public void testNoApksGiven() throws Exception { 105 | copyToTestPath(originalFolder, Collections.singletonList(testReleaseKs)); 106 | 107 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + " " + outFolder.getAbsolutePath() + " --" + CLIParser.ARG_SKIP_ZIPALIGN; 108 | testAndCheck(cmd, null, outFolder, Collections.emptyList()); 109 | } 110 | 111 | @Test 112 | public void testSignMultiApkWithZipalign() throws Exception { 113 | List uApks = copyToTestPath(originalFolder, unsingedApks); 114 | 115 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + " " + outFolder.getAbsolutePath() + " --debug"; 116 | testAndCheck(cmd, originalFolder, outFolder, uApks); 117 | } 118 | 119 | @Test 120 | public void testVerify() throws Exception { 121 | copyToTestPath(originalFolder, singedApks); 122 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " --" + CLIParser.ARG_VERIFY + " --" + CLIParser.ARG_SKIP_ZIPALIGN; 123 | System.out.println(cmd); 124 | SignTool.Result result = SignTool.mainExecute(CLIParserTest.asArgArray(cmd)); 125 | 126 | assertNotNull(result); 127 | assertEquals(0, result.unsuccessful); 128 | assertEquals(singedApks.size(), result.success); 129 | } 130 | 131 | @Test 132 | public void testVerifySingleApk() throws Exception { 133 | copyToTestPath(originalFolder, singedApks.subList(0, 1)); 134 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.listFiles()[0].getAbsolutePath() + " --" + CLIParser.ARG_VERIFY + " --" + CLIParser.ARG_SKIP_ZIPALIGN; 135 | System.out.println(cmd); 136 | 137 | SignTool.Result result = SignTool.mainExecute(CLIParserTest.asArgArray(cmd)); 138 | assertNotNull(result); 139 | assertEquals(0, result.unsuccessful); 140 | assertEquals(1, result.success); 141 | } 142 | 143 | @Test 144 | public void testVerifyShouldNotBe() throws Exception { 145 | copyToTestPath(originalFolder, unsingedApks); 146 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " --" + CLIParser.ARG_VERIFY + " --" + CLIParser.ARG_SKIP_ZIPALIGN; 147 | System.out.println(cmd); 148 | 149 | SignTool.Result result = SignTool.mainExecute(CLIParserTest.asArgArray(cmd)); 150 | assertNotNull(result); 151 | assertEquals(unsingedApks.size(), result.unsuccessful); 152 | assertEquals(0, result.success); 153 | } 154 | 155 | @Test 156 | public void testSignMultipleApksOverwrite() throws Exception { 157 | List uApks = copyToTestPath(originalFolder, unsingedApks); 158 | 159 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " --overwrite --" + CLIParser.ARG_SKIP_ZIPALIGN; 160 | testAndCheck(cmd, null, originalFolder, uApks); 161 | } 162 | 163 | @Test 164 | public void testSignMultipleApksFromMultiLocations() throws Exception { 165 | copyToTestPath(originalFolder, unsingedApks); 166 | 167 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.listFiles()[0].getAbsolutePath() + " " + originalFolder.listFiles()[1].getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + " " + outFolder.getAbsolutePath() + " --" + CLIParser.ARG_SKIP_ZIPALIGN; 168 | testAndCheck(cmd, null, outFolder, Arrays.asList(originalFolder.listFiles()[0], originalFolder.listFiles()[1])); 169 | } 170 | 171 | @Test 172 | public void testResign() throws Exception { 173 | List signedApks = copyToTestPath(originalFolder, Collections.singletonList(singedApks.get(0))); 174 | File signedApk = signedApks.get(0); 175 | 176 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + signedApk.getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + " " + outFolder.getAbsolutePath() + " --allowResign --debug --ks " + testReleaseKs.getAbsolutePath() + " --ksPass " + ksPass + " --ksKeyPass " + keyPass + " --ksAlias " + ksAlias; 177 | 178 | System.out.println(cmd); 179 | SignTool.Result result = SignTool.mainExecute(CLIParserTest.asArgArray(cmd)); 180 | assertNotNull(result); 181 | assertEquals(0, result.unsuccessful); 182 | assertEquals(1, result.success); 183 | assertEquals(2, outFolder.listFiles().length); // contains apk and v4 apk.idsign 184 | AndroidApkSignerVerify.Result verifyResult = new AndroidApkSignerVerify().verify(outFolder.listFiles()[0], null, null, null, false); 185 | assertTrue(verifyResult.verified); 186 | assertEquals(0, verifyResult.warnings.size()); 187 | assertEquals(0, verifyResult.errors.size()); 188 | assertEquals(releaseCertSha256, verifyResult.certInfoList.get(0).certSha256); 189 | } 190 | 191 | @Test 192 | public void testCheckHash() throws Exception { 193 | List uApks = copyToTestPath(originalFolder, Collections.singletonList(unsingedApks.get(0))); 194 | 195 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " -" + CLIParser.ARG_APK_OUT + 196 | " " + outFolder.getAbsolutePath() + " --debug --verifySha256 " + debugCertSha256 + " --" + CLIParser.ARG_SKIP_ZIPALIGN + " --ksDebug " + testDebugKeystore.getAbsolutePath(); 197 | testAndCheck(cmd, originalFolder, outFolder, uApks); 198 | } 199 | 200 | @Test 201 | public void testVerifyWithCheckHashShouldNotBe() throws Exception { 202 | copyToTestPath(originalFolder, Collections.singletonList(singedApks.get(0))); 203 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " --" + CLIParser.ARG_VERIFY + " --" + CLIParser.ARG_SKIP_ZIPALIGN + " --debug --verifySha256 abcdef1234567890"; 204 | System.out.println(cmd); 205 | 206 | SignTool.Result result = SignTool.mainExecute(CLIParserTest.asArgArray(cmd)); 207 | assertNotNull(result); 208 | assertEquals(1, result.unsuccessful); 209 | assertEquals(0, result.success); 210 | } 211 | 212 | @Test 213 | public void testVerifyWithCheckHash() throws Exception { 214 | copyToTestPath(originalFolder, Collections.singletonList(singedApks.get(0))); 215 | String cmd = "-" + CLIParser.ARG_APK_FILE + " " + originalFolder.getAbsolutePath() + " --" + CLIParser.ARG_VERIFY + " --" + CLIParser.ARG_SKIP_ZIPALIGN + " --debug --verifySha256 a18bc579adba6819a57a665cdf2bfe0b6f2a81263cb2d6860a3d35fac428999a"; 216 | System.out.println(cmd); 217 | 218 | SignTool.Result result = SignTool.mainExecute(CLIParserTest.asArgArray(cmd)); 219 | assertNotNull(result); 220 | assertEquals(0, result.unsuccessful); 221 | assertEquals(1, result.success); 222 | } 223 | 224 | private static void testAndCheck(String cmd, File originalFolder, File outFolder, List copyApks) throws Exception { 225 | System.out.println(cmd); 226 | SignTool.Result result = SignTool.mainExecute(CLIParserTest.asArgArray(cmd)); 227 | assertNotNull(result); 228 | assertEquals(0, result.unsuccessful); 229 | assertEquals(copyApks.size(), result.success); 230 | assertSigned(outFolder, copyApks); 231 | 232 | if (originalFolder != null) { 233 | assertEquals(copyApks.size(), originalFolder.listFiles().length); 234 | } 235 | } 236 | 237 | private static void assertSigned(File outFolder, List uApks) throws Exception { 238 | assertNotNull(outFolder); 239 | File[] outFiles = outFolder.listFiles(pathname -> FileUtil.getFileExtension(pathname).toLowerCase().equals("apk")); 240 | System.out.println("Found " + outFiles.length + " apks in out dir " + outFolder); 241 | assertNotNull(outFiles); 242 | assertEquals("should be same count of apks in out folder", uApks.size(), outFiles.length); 243 | 244 | for (File outFile : outFiles) { 245 | AndroidApkSignerVerify.Result verifyResult = new AndroidApkSignerVerify().verify(outFile, null, null, null, false); 246 | assertTrue(verifyResult.verified); 247 | assertEquals(0, verifyResult.warnings.size()); 248 | assertEquals(0, verifyResult.errors.size()); 249 | assertFalse(verifyResult.certInfoList.isEmpty()); 250 | } 251 | } 252 | 253 | private static List copyToTestPath(File target, List source) throws Exception { 254 | List copiedFiles = new ArrayList<>(); 255 | for (File file : source) { 256 | File dstFile = new File(target, file.getName()); 257 | Files.copy(file.toPath(), dstFile.toPath()); 258 | copiedFiles.add(dstFile); 259 | } 260 | return copiedFiles; 261 | } 262 | 263 | } 264 | -------------------------------------------------------------------------------- /src/test/java/at/favre/tools/apksigner/ui/CLIParserTest.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.ui; 2 | 3 | import org.apache.tools.ant.types.Commandline; 4 | import org.junit.Test; 5 | 6 | import java.util.Collections; 7 | 8 | import static org.junit.Assert.assertEquals; 9 | import static org.junit.Assert.assertNull; 10 | 11 | public class CLIParserTest { 12 | @Test 13 | public void testSimpleWithOnlyApkFile() { 14 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./")); 15 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, false, false, false, false, null, false, false, null, null); 16 | assertEquals(expectedArg, parsedArg); 17 | } 18 | 19 | @Test 20 | public void testWithOut() { 21 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./ -" + CLIParser.ARG_APK_OUT + " ./test")); 22 | Arg expectedArg = new Arg(new String[]{"./"}, "./test", Collections.emptyList(), false, false, false, false, false, false, null, false, false, null, null); 23 | assertEquals(expectedArg, parsedArg); 24 | } 25 | 26 | @Test 27 | public void testWithKeystoreAndAlias() { 28 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./ --ks my-keystore.jks --ksAlias debugAlias")); 29 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.singletonList(new Arg.SignArgs(0, "my-keystore.jks", "debugAlias", null, null)), false, false, false, false, false, false, null, false, false, null, null); 30 | assertEquals(expectedArg, parsedArg); 31 | } 32 | 33 | @Test 34 | public void testWithDebugKeystore() { 35 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./ --ksDebug my-debug-keystore.jks")); 36 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.singletonList(new Arg.SignArgs(0, "my-debug-keystore.jks", null, null, null)), false, false, false, false, false, false, null, true, false, null, null); 37 | assertEquals(expectedArg, parsedArg); 38 | } 39 | 40 | @Test 41 | public void testWithDebugKeystoreAndNormalKeystore() { 42 | assertNull(CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./ --ksDebug my-debug-keystore.jks --ks my-keystore.jks"))); 43 | } 44 | 45 | @Test 46 | public void testWithKeystoreAndAliasAndPws() { 47 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./ --ks my-keystore.jks --ksAlias debugAlias --ksPass secret --ksKeyPass secret2")); 48 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.singletonList(new Arg.SignArgs(0, "my-keystore.jks", "debugAlias", "secret", "secret2")), false, false, false, false, false, false, null, false, false, null, null); 49 | assertEquals(expectedArg, parsedArg); 50 | } 51 | 52 | @Test 53 | public void testWithOnlyVerify() { 54 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./ --" + CLIParser.ARG_VERIFY)); 55 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, false, false, false, true, null, false, false, null, null); 56 | assertEquals(expectedArg, parsedArg); 57 | } 58 | 59 | @Test 60 | public void testWithDebug() { 61 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + "./ --debug")); 62 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, false, false, true, false, null, false, false, null, null); 63 | assertEquals(expectedArg, parsedArg); 64 | } 65 | 66 | @Test 67 | public void testWithSkipZipAlign() { 68 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + "./ --" + CLIParser.ARG_SKIP_ZIPALIGN)); 69 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, false, true, false, false, null, false, false, null, null); 70 | assertEquals(expectedArg, parsedArg); 71 | } 72 | 73 | @Test 74 | public void testWithOverwrite() { 75 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + "./ --overwrite")); 76 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), true, false, false, false, false, false, null, false, false, null, null); 77 | assertEquals(expectedArg, parsedArg); 78 | } 79 | 80 | @Test 81 | public void testWithDryRun() { 82 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + "./ --dryRun")); 83 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, true, false, false, false, false, null, false, false, null, null); 84 | assertEquals(expectedArg, parsedArg); 85 | } 86 | 87 | @Test 88 | public void testWithVerbose() { 89 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + "./ --verbose")); 90 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, true, false, false, false, null, false, false, null, null); 91 | assertEquals(expectedArg, parsedArg); 92 | } 93 | 94 | @Test 95 | public void testWithAllowResign() { 96 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + "./ --allowResign")); 97 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, false, false, false, false, null, false, true, null, null); 98 | assertEquals(expectedArg, parsedArg); 99 | } 100 | 101 | @Test 102 | public void testMultipleFiles() { 103 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./ apk1.apk apk2.apk")); 104 | Arg expectedArg = new Arg(new String[]{"./", "apk1.apk", "apk2.apk"}, null, Collections.emptyList(), false, false, false, false, false, false, null, false, false, null, null); 105 | assertEquals(expectedArg, parsedArg); 106 | } 107 | 108 | @Test 109 | public void testProvideSha256Hashes() { 110 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + "./ --verifySha256 a18bc579adba6819a57a665cdf2bfe0b6f2a81263cb2d6860a3d35fac428999a")); 111 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, false, false, false, false, null, false, false, new String[]{"a18bc579adba6819a57a665cdf2bfe0b6f2a81263cb2d6860a3d35fac428999a"}, null); 112 | assertEquals(expectedArg, parsedArg); 113 | } 114 | 115 | @Test 116 | public void testProvideTwoSha256Hashes() { 117 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + "./ --verifySha256 a18bc579adba6819a57a665cdf2b fe0b6f2a81263cb2d6860a3d35fac428999a")); 118 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, false, false, false, false, null, false, false, new String[]{"a18bc579adba6819a57a665cdf2b", "fe0b6f2a81263cb2d6860a3d35fac428999a"}, null); 119 | assertEquals(expectedArg, parsedArg); 120 | } 121 | 122 | @Test 123 | public void testWithKeystoreAndLineage() { 124 | Arg parsedArg = CLIParser.parse(asArgArray("-" + CLIParser.ARG_APK_FILE + " ./ --lineage ./file.lineage")); 125 | Arg expectedArg = new Arg(new String[]{"./"}, null, Collections.emptyList(), false, false, false, false, false, false, null, false, false, null, "./file.lineage"); 126 | assertEquals(expectedArg, parsedArg); 127 | } 128 | 129 | @Test 130 | public void testHelp() { 131 | Arg parsedArg = CLIParser.parse(asArgArray("--help")); 132 | assertNull(parsedArg); 133 | } 134 | 135 | @Test 136 | public void testVersion() { 137 | Arg parsedArg = CLIParser.parse(asArgArray("--version")); 138 | assertNull(parsedArg); 139 | } 140 | 141 | public static String[] asArgArray(String cmd) { 142 | return Commandline.translateCommandline(cmd); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/test/java/at/favre/tools/apksigner/ui/CertHashCheckerTest.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.ui; 2 | 3 | import at.favre.tools.apksigner.signing.AndroidApkSignerVerify; 4 | import at.favre.tools.apksigner.signing.CertHashChecker; 5 | import org.junit.Test; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | import static junit.framework.TestCase.*; 11 | 12 | public class CertHashCheckerTest { 13 | 14 | public static final String sha256 = "3b9e8ae8fadc373d4fff5da150c2e94cc0ad642e7886ffeb9d0fc9327bc66388"; 15 | public static final String sha256_2 = "29728d7bffedbc3a8e3e3a9cbd1959cc724ae7c178cacf01547f0831fe64c3f1"; 16 | public static final String sha256_3 = "99728d7bffedbc3a8e3e3a9cbd1959cc724ae7c178cacf01547f0831fe64c3f1"; 17 | public static final String sha256_3_upper = "99728D7bffedbc3A8e3e3a9Cbd1959cc724ae7c178cacf01547f0831fe64c3f1"; 18 | 19 | @Test 20 | public void testSingleHash() { 21 | assertTrue(new CertHashChecker().check(getVerifyResult(sha256), new String[]{sha256}).verified); 22 | } 23 | 24 | @Test 25 | public void testTwoHash() { 26 | assertTrue(new CertHashChecker().check(getVerifyResult(sha256, sha256_2), new String[]{sha256_2, sha256}).verified); 27 | } 28 | 29 | @Test 30 | public void testCountDoesNotMathc() { 31 | assertFalse(new CertHashChecker().check(getVerifyResult(sha256, sha256_2), new String[]{sha256_2, sha256, sha256_3}).verified); 32 | } 33 | 34 | @Test 35 | public void testHashNotMatch() { 36 | assertFalse(new CertHashChecker().check(getVerifyResult(sha256, sha256_3), new String[]{sha256_2, sha256}).verified); 37 | } 38 | 39 | @Test 40 | public void testHashNotMatchSingle() { 41 | assertFalse(new CertHashChecker().check(getVerifyResult(sha256_3), new String[]{sha256_2}).verified); 42 | } 43 | 44 | @Test 45 | public void testSingleHashIgnoreCase() { 46 | assertTrue(new CertHashChecker().check(getVerifyResult(sha256_3), new String[]{sha256_3_upper}).verified); 47 | } 48 | 49 | @Test(expected = IllegalArgumentException.class) 50 | public void testNullVerify_ShouldThrowException() { 51 | new CertHashChecker().check(null, new String[]{sha256}); 52 | } 53 | 54 | @Test 55 | public void testNullProvided() { 56 | assertNull(new CertHashChecker().check(getVerifyResult(sha256_3), null)); 57 | } 58 | 59 | @Test 60 | public void testNotVerified() { 61 | assertNull(new CertHashChecker().check(new AndroidApkSignerVerify.Result(false, null, null, null, true, true, true, true, true, null), null)); 62 | } 63 | 64 | private static AndroidApkSignerVerify.Result getVerifyResult(String... shas256) { 65 | List certInfos = new ArrayList<>(); 66 | for (String s : shas256) { 67 | AndroidApkSignerVerify.CertInfo info = new AndroidApkSignerVerify.CertInfo(); 68 | info.certSha256 = s; 69 | certInfos.add(info); 70 | } 71 | 72 | return new AndroidApkSignerVerify.Result(true, null, null, null, true, true, true, true, true, certInfos); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/test/java/at/favre/tools/apksigner/ui/FileArgParserTest.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.ui; 2 | 3 | import org.junit.Before; 4 | import org.junit.Test; 5 | 6 | import java.io.File; 7 | import java.util.ArrayList; 8 | import java.util.Arrays; 9 | import java.util.Collections; 10 | import java.util.List; 11 | 12 | import static junit.framework.TestCase.assertEquals; 13 | 14 | public class FileArgParserTest { 15 | File signedFolder, unsignedFolder; 16 | List sortedSinged, sortedUnsinged; 17 | private String extFilter = "apk"; 18 | 19 | @Before 20 | public void setUp() throws Exception { 21 | signedFolder = new File(getClass().getClassLoader().getResource("test-apks-signed").toURI().getPath()); 22 | unsignedFolder = new File(getClass().getClassLoader().getResource("test-apks-unsigned").toURI().getPath()); 23 | 24 | sortedSinged = Arrays.asList(signedFolder.listFiles()); 25 | Collections.sort(sortedSinged); 26 | 27 | sortedUnsinged = Arrays.asList(unsignedFolder.listFiles()); 28 | Collections.sort(sortedUnsinged); 29 | } 30 | 31 | @Test 32 | public void testSingleFolder() throws Exception { 33 | List result = new FileArgParser().parseAndSortUniqueFilesNonRecursive(new String[]{signedFolder.getAbsolutePath()}, extFilter); 34 | assertEquals(sortedSinged, result); 35 | } 36 | 37 | @Test 38 | public void testTwoFolder() throws Exception { 39 | List result = new FileArgParser().parseAndSortUniqueFilesNonRecursive(new String[]{signedFolder.getAbsolutePath(), unsignedFolder.getAbsolutePath()}, extFilter); 40 | 41 | List all = new ArrayList<>(sortedSinged.size() + sortedUnsinged.size()); 42 | all.addAll(sortedSinged); 43 | all.addAll(sortedUnsinged); 44 | Collections.sort(all); 45 | assertEquals(all, result); 46 | } 47 | 48 | @Test 49 | public void testSingleFile() throws Exception { 50 | File apk = signedFolder.listFiles()[0]; 51 | List result = new FileArgParser().parseAndSortUniqueFilesNonRecursive(new String[]{apk.getAbsolutePath()}, extFilter); 52 | assertEquals(Collections.singletonList(apk), result); 53 | } 54 | 55 | @Test 56 | public void testTwoFiles() throws Exception { 57 | File apk = signedFolder.listFiles()[0]; 58 | File apk2 = signedFolder.listFiles()[1]; 59 | 60 | List result = new FileArgParser().parseAndSortUniqueFilesNonRecursive(new String[]{apk.getAbsolutePath(), apk2.getAbsolutePath()}, extFilter); 61 | 62 | List all = Arrays.asList(apk, apk2); 63 | Collections.sort(all); 64 | assertEquals(all, result); 65 | } 66 | 67 | @Test 68 | public void testThreeFilesShouldIgnoreDouble() throws Exception { 69 | File apk = signedFolder.listFiles()[0]; 70 | File apk2 = signedFolder.listFiles()[1]; 71 | File apk3 = signedFolder.listFiles()[0]; 72 | 73 | List result = new FileArgParser().parseAndSortUniqueFilesNonRecursive(new String[]{apk.getAbsolutePath(), apk2.getAbsolutePath(), apk3.getAbsolutePath()}, extFilter); 74 | 75 | List all = Arrays.asList(apk, apk2); 76 | Collections.sort(all); 77 | assertEquals(all, result); 78 | } 79 | 80 | @Test 81 | public void testThreeFilesShouldIgnoreDoubleWithFolder() throws Exception { 82 | File apk = signedFolder.listFiles()[0]; 83 | 84 | List result = new FileArgParser().parseAndSortUniqueFilesNonRecursive(new String[]{apk.getAbsolutePath(), signedFolder.getAbsolutePath()}, extFilter); 85 | 86 | assertEquals(sortedSinged, result); 87 | } 88 | 89 | @Test 90 | public void testFileAndFolderMix() throws Exception { 91 | File apk = signedFolder.listFiles()[0]; 92 | 93 | List result = new FileArgParser().parseAndSortUniqueFilesNonRecursive(new String[]{apk.getAbsolutePath(), unsignedFolder.getAbsolutePath()}, extFilter); 94 | 95 | List all = new ArrayList<>(sortedUnsinged.size() + 1); 96 | all.addAll(sortedUnsinged); 97 | all.add(apk); 98 | Collections.sort(all); 99 | assertEquals(all, result); 100 | } 101 | 102 | @Test 103 | public void testNotMatchingFilterExtension() throws Exception { 104 | List result = new FileArgParser().parseAndSortUniqueFilesNonRecursive(new String[]{signedFolder.getAbsolutePath()}, "unk"); 105 | assertEquals(Collections.emptyList(), result); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/test/java/at/favre/tools/apksigner/ui/MultiKeystoreParserTest.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.ui; 2 | 3 | import org.apache.commons.cli.DefaultParser; 4 | import org.junit.Test; 5 | 6 | import java.util.List; 7 | 8 | import static junit.framework.TestCase.assertEquals; 9 | 10 | public class MultiKeystoreParserTest { 11 | private MultiKeystoreParser multiKeystoreParser = new MultiKeystoreParser(); 12 | 13 | @Test 14 | public void testSingleCustomReleaseKs() throws Exception { 15 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks test-release.jks --ksPass ksPass --ksKeyPass keyPass --ksAlias ksAlias"; 16 | List signArgsList = multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 17 | 18 | assertEquals(1, signArgsList.size()); 19 | assertEquals(new Arg.SignArgs(0, "test-release.jks", "ksAlias", "ksPass", "keyPass"), signArgsList.get(0)); 20 | } 21 | 22 | @Test 23 | public void testSingleCustomReleaseKsNoKsPass() throws Exception { 24 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks test-release.jks --ksKeyPass keyPass --ksAlias ksAlias"; 25 | List signArgsList = multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 26 | 27 | assertEquals(1, signArgsList.size()); 28 | assertEquals(new Arg.SignArgs(0, "test-release.jks", "ksAlias", null, "keyPass"), signArgsList.get(0)); 29 | } 30 | 31 | @Test 32 | public void testSingleCustomReleaseKsNoKsPassAndKeyPass() throws Exception { 33 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks test-release.jks --ksAlias ksAlias"; 34 | List signArgsList = multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 35 | 36 | assertEquals(1, signArgsList.size()); 37 | assertEquals(new Arg.SignArgs(0, "test-release.jks", "ksAlias", null, null), signArgsList.get(0)); 38 | } 39 | 40 | @Test(expected = IllegalArgumentException.class) 41 | public void testSingleCustomReleaseKsNoKsPassAndKeyPassNoAlias_shouldThrowException() throws Exception { 42 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks test-release.jks "; 43 | multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 44 | } 45 | 46 | @Test 47 | public void testTwoCustomReleaseKs() throws Exception { 48 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks 1" + MultiKeystoreParser.sep + "test-release.jks 2" + MultiKeystoreParser.sep + "test2-release.jks --ksPass 1" + MultiKeystoreParser.sep + "ksPass1 2" + MultiKeystoreParser.sep + "ksPass2 --ksKeyPass 1" + MultiKeystoreParser.sep + "ksKeyPass1 2" + MultiKeystoreParser.sep + "ksKeyPass2 --ksAlias 1" + MultiKeystoreParser.sep + "ksAlias1 2" + MultiKeystoreParser.sep + "ksAlias2"; 49 | List signArgsList = multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 50 | 51 | assertEquals(2, signArgsList.size()); 52 | assertEquals(new Arg.SignArgs(1, "test-release.jks", "ksAlias1", "ksPass1", "ksKeyPass1"), signArgsList.get(0)); 53 | assertEquals(new Arg.SignArgs(2, "test2-release.jks", "ksAlias2", "ksPass2", "ksKeyPass2"), signArgsList.get(1)); 54 | } 55 | 56 | @Test(expected = IllegalArgumentException.class) 57 | public void testTwoCustomReleaseKsWithMissingAlias_shouldThrowException() throws Exception { 58 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks 1" + MultiKeystoreParser.sep + "test-release.jks 2" + MultiKeystoreParser.sep + "test2-release.jks --ksAlias 2" + MultiKeystoreParser.sep + "ksAlias2"; 59 | multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 60 | } 61 | 62 | @Test(expected = IllegalArgumentException.class) 63 | public void testTwoCustomReleaseKsWithUnknownAlias_shouldThrowException() throws Exception { 64 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks 1" + MultiKeystoreParser.sep + "test-release.jks 2" + MultiKeystoreParser.sep + "test2-release.jks --ksAlias 3" + MultiKeystoreParser.sep + "ksAlias3 2" + MultiKeystoreParser.sep + "ksAlias2"; 65 | multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 66 | } 67 | 68 | @Test 69 | public void testTwoCustomReleaseKsWithoutPw() throws Exception { 70 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks 1" + MultiKeystoreParser.sep + "test-release.jks 2" + MultiKeystoreParser.sep + "test2-release.jks --ksAlias 2" + MultiKeystoreParser.sep + "ksAlias2 1" + MultiKeystoreParser.sep + "ksAlias1"; 71 | List signArgsList = multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 72 | 73 | assertEquals(2, signArgsList.size()); 74 | assertEquals(new Arg.SignArgs(1, "test-release.jks", "ksAlias1", null, null), signArgsList.get(0)); 75 | assertEquals(new Arg.SignArgs(2, "test2-release.jks", "ksAlias2", null, null), signArgsList.get(1)); 76 | } 77 | 78 | @Test 79 | public void testTwoCustomReleaseKsWithPartialPw() throws Exception { 80 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks 2" + MultiKeystoreParser.sep + "test2-release.jks 1" + MultiKeystoreParser.sep + "test-release.jks --ksPass 2" + MultiKeystoreParser.sep + "ksPass2 --ksKeyPass 1" + MultiKeystoreParser.sep + "ksKeyPass1 --ksAlias 1" + MultiKeystoreParser.sep + "ksAlias1 2" + MultiKeystoreParser.sep + "ksAlias2"; 81 | List signArgsList = multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 82 | 83 | assertEquals(2, signArgsList.size()); 84 | assertEquals(new Arg.SignArgs(1, "test-release.jks", "ksAlias1", null, "ksKeyPass1"), signArgsList.get(0)); 85 | assertEquals(new Arg.SignArgs(2, "test2-release.jks", "ksAlias2", "ksPass2", null), signArgsList.get(1)); 86 | } 87 | 88 | @Test 89 | public void testThreeCustomReleaseKsWithRndIndex() throws Exception { 90 | String cmd = "-" + CLIParser.ARG_APK_FILE + " ./ --ks 97" + MultiKeystoreParser.sep + "test-release.jks 4" + MultiKeystoreParser.sep + "test2-release.jks 7899" + MultiKeystoreParser.sep + "test3-release.jks " + 91 | "--ksPass 4" + MultiKeystoreParser.sep + "ksPass2 97" + MultiKeystoreParser.sep + "ksPass1 7899" + MultiKeystoreParser.sep + "ksPass3 --ksKeyPass 4" + MultiKeystoreParser.sep + "ksKeyPass2 --ksAlias 7899" + MultiKeystoreParser.sep + "ksAlias3 97" + MultiKeystoreParser.sep + "ksAlias1 4" + MultiKeystoreParser.sep + "ksAlias2"; 92 | List signArgsList = multiKeystoreParser.parse(new DefaultParser().parse(CLIParser.setupOptions(), CLIParserTest.asArgArray(cmd))); 93 | 94 | assertEquals(3, signArgsList.size()); 95 | assertEquals(new Arg.SignArgs(4, "test2-release.jks", "ksAlias2", "ksPass2", "ksKeyPass2"), signArgsList.get(0)); 96 | assertEquals(new Arg.SignArgs(97, "test-release.jks", "ksAlias1", "ksPass1", null), signArgsList.get(1)); 97 | assertEquals(new Arg.SignArgs(7899, "test3-release.jks", "ksAlias3", "ksPass3", null), signArgsList.get(2)); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/test/java/at/favre/tools/apksigner/util/FileUtilTest.java: -------------------------------------------------------------------------------- 1 | package at.favre.tools.apksigner.util; 2 | 3 | import org.junit.Before; 4 | import org.junit.Rule; 5 | import org.junit.Test; 6 | import org.junit.rules.TemporaryFolder; 7 | 8 | import java.io.File; 9 | 10 | import static org.junit.Assert.assertNotNull; 11 | import static org.junit.Assert.assertTrue; 12 | 13 | public class FileUtilTest { 14 | @Rule 15 | public TemporaryFolder temporaryFolder = new TemporaryFolder(); 16 | private File file; 17 | 18 | @Before 19 | public void setUp() throws Exception { 20 | file = temporaryFolder.newFile(); 21 | } 22 | 23 | @Test 24 | public void createChecksum() throws Exception { 25 | String s = FileUtil.createChecksum(file, "SHA-256"); 26 | assertNotNull(s); 27 | assertTrue(s.length() % 2 == 0); 28 | assertTrue(s.length() > 0); 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /src/test/resources/test-apks-signed/app-first-debug.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-signed/app-first-debug.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-signed/app-fourth-debug.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-signed/app-fourth-debug.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-signed/app-second-debug.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-signed/app-second-debug.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-signed/app-second-v1.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-signed/app-second-v1.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-signed/app-third-debug.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-signed/app-third-debug.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-signed/app-third-v1.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-signed/app-third-v1.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-unsigned/app-first-release-unsigned.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-unsigned/app-first-release-unsigned.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-unsigned/app-fourth-release-unsigned.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-unsigned/app-fourth-release-unsigned.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-unsigned/app-second-release-unsigned.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-unsigned/app-second-release-unsigned.apk -------------------------------------------------------------------------------- /src/test/resources/test-apks-unsigned/app-third-release-unsigned.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-apks-unsigned/app-third-release-unsigned.apk -------------------------------------------------------------------------------- /src/test/resources/test-debug-to-release.lineage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-debug-to-release.lineage -------------------------------------------------------------------------------- /src/test/resources/test-debug.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-debug.jks -------------------------------------------------------------------------------- /src/test/resources/test-release-key.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickfav/uber-apk-signer/76cbf02556b4454f4c2bd6c908b4355003c4e37d/src/test/resources/test-release-key.jks --------------------------------------------------------------------------------