diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..905f673 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,11 @@ +name: Set up JDK and Gradle +description: The JDK and Gradle setup that every Gradle job uses. Run it after actions/checkout. + +runs: + using: composite + steps: + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54de035..330117e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,8 @@ name: CI on: push: - branches: - - master + branches: [master] pull_request: - branches: - - master permissions: contents: read @@ -16,93 +13,93 @@ concurrency: cancel-in-progress: true jobs: - build: - name: Format, lint, and build + lint: + name: Lint runs-on: ubuntu-latest - timeout-minutes: 30 - + timeout-minutes: 15 steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up JDK 17 - uses: actions/setup-java@v5 + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - run: ./gradlew spotlessCheck lint + - uses: actions/upload-artifact@v7 + if: failure() with: - distribution: temurin - java-version: 17 - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Check formatting - run: ./gradlew spotlessCheck - - - name: Run Android lint - run: ./gradlew lint - - - name: Build TrustKit library - run: ./gradlew :trustkit:assembleRelease - - - name: Build Java demo app - run: ./gradlew :app:assembleDebug - - - name: Build Kotlin demo app - run: ./gradlew :demoappkotlin:assembleDebug + name: lint-reports + path: "**/build/reports/**" + if-no-files-found: ignore + retention-days: 7 - - name: Upload build reports + unit-tests: + name: Unit tests + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - run: ./gradlew :trustkit:testDebugUnitTest + - uses: actions/upload-artifact@v7 if: failure() - uses: actions/upload-artifact@v7 with: - name: build-reports - path: | - **/build/reports/** - **/build/outputs/logs/** + name: unit-test-reports + path: trustkit/build/reports/tests/** if-no-files-found: ignore retention-days: 7 + script-tests: + name: Script tests + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - run: ./scripts/test_publish_maven_central.sh + + release-bundle: + name: Build release bundle + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - run: ./scripts/build_release_bundle.sh + + demo: + name: Build demo ${{ matrix.module }} + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + module: [app, demoappkotlin] + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - run: ./gradlew :${{ matrix.module }}:assembleDebug + instrumentation-tests: name: Instrumentation tests (API ${{ matrix.api-level }}) - needs: build + needs: lint runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false matrix: - api-level: - - 27 - - 36 - + api-level: [27, 36] steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@v2.37.0 + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - run: ./scripts/enable_kvm.sh + - uses: reactivecircus/android-emulator-runner@v2.37.0 with: api-level: ${{ matrix.api-level }} arch: x86_64 target: default script: ./gradlew :trustkit:connectedDebugAndroidTest - - - name: Upload instrumentation test reports + - uses: actions/upload-artifact@v7 if: failure() - uses: actions/upload-artifact@v7 with: name: instrumentation-reports-api-${{ matrix.api-level }} path: | @@ -111,23 +108,3 @@ jobs: trustkit/build/outputs/managed_device_android_test_additional_output/** if-no-files-found: ignore retention-days: 7 - - ci-checks: - name: CI checks - runs-on: ubuntu-latest - if: ${{ always() }} - needs: - - build - - instrumentation-tests - steps: - - name: Verify required jobs succeeded - env: - BUILD_RESULT: ${{ needs.build.result }} - INSTRUMENTATION_TESTS_RESULT: ${{ needs.instrumentation-tests.result }} - run: | - if [[ "$BUILD_RESULT" != "success" || "$INSTRUMENTATION_TESTS_RESULT" != "success" ]]; then - echo "Build result: $BUILD_RESULT" - echo "Instrumentation tests result: $INSTRUMENTATION_TESTS_RESULT" - exit 1 - fi - echo "All necessary CI jobs completed successfully" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1791bd4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +# github.ref_name can be empty on release events. +env: + TAG: ${{ github.event.release.tag_name }} + +# One group per tag: a shared group cancels pending runs, which would skip a release. +concurrency: + group: maven-central-release-${{ github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + verify: + name: Verify release version + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - run: ./scripts/verify_release_version.sh "$TAG" + + publish: + name: Publish release bundle + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: maven-central + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - run: ./scripts/build_release_bundle.sh + env: + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + # Attach first: this step can be re-run, but the Maven Central upload cannot. + - run: gh release upload "$TAG" output/trustkit-*.zip --clobber + env: + GH_TOKEN: ${{ github.token }} + - run: ./scripts/publish_maven_central.sh + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} diff --git a/.gitignore b/.gitignore index 769831a..d43ff6f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ *.iml local.properties */debug/* +/output/ diff --git a/README.md b/README.md index 4792b80..122f7d8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ TrustKit Android [![CI](https://github.com/datatheorem/TrustKit-Android/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/datatheorem/TrustKit-Android/actions/workflows/ci.yml?query=branch%3Amaster) [![API](https://img.shields.io/badge/API-16%2B-blue.svg?style=flat)](https://android-arsenal.com/api?level=16) -[![Version](https://img.shields.io/bintray/v/datatheoremoss/TrustKit-Android/trustkit.svg)](https://bintray.com/datatheoremoss/TrustKit-Android/trustkit) +[![Maven Central](https://img.shields.io/maven-central/v/com.datatheorem.android.trustkit/trustkit.svg)](https://central.sonatype.com/artifact/com.datatheorem.android.trustkit/trustkit) [![MIT License](https://img.shields.io/github/license/datatheorem/trustkit-android.svg)](https://en.wikipedia.org/wiki/MIT_License) [![Gitter chat](https://badges.gitter.im/datatheorem/gitter.png)](https://gitter.im/TrustKit/Lobby) @@ -42,6 +42,10 @@ Add TrustKit to your project's _build.gradle_: `implementation 'com.datatheorem.android.trustkit:trustkit:'` +To receive pinning failure reports with a local broadcast receiver, also add: + +`implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'` + ### Configuring a Pinning Policy Deploying SSL pinning in the App requires initializing TrustKit with a pinning policy (domains, pins, and additional settings). The policy is wrapped in the official [Android N Network Security Configuration](https://developer.android.com/training/articles/security-config.html) i.e : @@ -205,6 +209,21 @@ On Android M and earlier devices, TrustKit provides uses its own implementation For consumers of TrustKit's OkHttpHelper solutions, redirects must to be disabled as Pinning will currently only work properly on the initial request and not any redirects +Releasing +--------- + +1. Set `trustkitVersionName` (and increment `trustkitVersionCode`) in `gradle/libs.versions.toml` and merge to `master`. +2. Publish a GitHub release whose tag equals that version, for example `1.2.0`. Every published release, including pre-releases, goes to Maven Central. + +The `Release` workflow then attaches `trustkit-.zip` to the GitHub release and publishes the same zip to Maven Central. It does not run CI again: release a commit that already passed CI on `master`. A Maven Central release cannot be removed, so check the version before you publish. + +The zip is a Maven repository: the AAR, POM, Gradle module metadata, sources and javadoc, each with checksums and a signature. To use it without Maven Central, unzip it and add the folder as a `maven { url }` repository. `scripts/build_release_bundle.sh` builds it locally (unsigned unless `SIGNING_KEY` is set). + +The workflow reads these secrets from the `maven-central` GitHub environment: + +* `MAVEN_CENTRAL_USERNAME`, `MAVEN_CENTRAL_PASSWORD`: a Central Portal user token. +* `SIGNING_KEY`, `SIGNING_KEY_PASSWORD`: the ASCII-armored PGP private key and its passphrase. + License ------- diff --git a/deploymentScripts/publish-mavencentral.gradle b/deploymentScripts/publish-mavencentral.gradle deleted file mode 100644 index 1c6adfa..0000000 --- a/deploymentScripts/publish-mavencentral.gradle +++ /dev/null @@ -1,138 +0,0 @@ -apply plugin: 'maven-publish' -apply plugin: 'signing' - -ext { - PUBLISH_GROUP_ID = 'com.datatheorem.android.trustkit' - PUBLISH_VERSION = project.version.toString() - PUBLISH_ARTIFACT_ID = 'trustkit' - DESCRIPTION = 'TrustKit Android is an open source library that makes it easy to deploy SSL public key pinning and reporting in any Android App.' - LICENSE_NAME = 'The MIT License (MIT)' - LICENSE_URL = 'https://github.com/datatheorem/TrustKit-Android/blob/master/LICENSE' - // Name of properties file stored in project root - contains signing key, auth details, etc. - PROPERTIES_FILE_NAME = 'local.properties' - // Version control info - VERSION_CONTROL_CONNECTION = 'scm:git:github.com/datatheorem/TrustKit-Android.git' - VERSION_CONTROL_BROWSABLE_URL = 'https://github.com/datatheorem/TrustKit-Android' -} - -// Gradle task to create a 'sources' jar -task androidSourcesJar(type: Jar) { - archiveClassifier.set('sources') - from android.sourceSets.main.java.srcDirs -} - -// Specify 'androidSourcesJar' as an artifact -// This artifact will get packaged along with the executable, compiled code. -artifacts { - archives androidSourcesJar -} - -// 'group' = namespace for identifying the artifact (.aar file) produced by the build -group = PUBLISH_GROUP_ID - -// 'version' = version of the artifact (.aar file) being published -version = PUBLISH_VERSION - -// Set all the variables to a dummy empty string to enable project sync and build without the publication values set up -// Useful for contributors to the open-source project who don't have MavenCentral deployment credentials in the local.properties file -ext["signing.keyId"] = '' -ext["signing.password"] = '' -ext["signing.secretKeyRingFile"] = '' -ext["ossrhUsername"] = '' -ext["ossrhPassword"] = '' -ext["sonatypeStagingProfileId"] = '' - -/* -Read attributes from the 'local.properties' file placed inside project root (Not in version control) -'local.properties' should contain the following attributes for the deployment to succeed: -- ossrhPassword -- signing.secretKeyRingFile -- signing.password -- ossrhUsername -- signing.keyId -*/ -File secretPropsFile = project.rootProject.file(PROPERTIES_FILE_NAME) -Properties properties = new Properties() -if (secretPropsFile.exists()) { - new FileInputStream(secretPropsFile).withCloseable { is -> properties.load(is)} -} -// Create 'ext' key-value pairs accessible throughout the gradle file -properties.each { - name, value -> ext[name] = value -} - -publishing { - publications { - release(MavenPublication) { - // 'groupId' = namespace, 'artifactId' = library name, 'version' = library version - groupId = PUBLISH_GROUP_ID - artifactId = PUBLISH_ARTIFACT_ID - version = PUBLISH_VERSION - // Two artifacts, the truskit '.aar' and the sources '.jar' - artifact("$buildDir/outputs/aar/${project.getName()}-release.aar") - artifact androidSourcesJar - // .pom file metadata - pom { - name = PUBLISH_ARTIFACT_ID - description = DESCRIPTION - url = VERSION_CONTROL_BROWSABLE_URL - licenses { - license { - name = LICENSE_NAME - url = LICENSE_URL - } - } - developers { - developer { - id = 'nabla-c0d3' - name = 'Alban Diquet' - email = 'ad@datatheorem.io' - } - developer { - id = 'jobot0' - name = 'Jordan Bouellat' - email = 'jb@datatheorem.com' - } - } - // Version control metadata - scm { - connection = VERSION_CONTROL_CONNECTION - developerConnection = VERSION_CONTROL_CONNECTION - url = VERSION_CONTROL_BROWSABLE_URL - } - // Create and populate a 'dependencies' node in .pom file - withXml { - def dependenciesNode = asNode().appendNode('dependencies') - project.configurations.implementation.allDependencies.each { - def dependencyNode = dependenciesNode.appendNode('dependency') - dependencyNode.appendNode('groupId', it.group) - dependencyNode.appendNode('artifactId', it.name) - dependencyNode.appendNode('version', it.version) - } - } - } - } - } - - // Specify repositories where the library needs to be published - repositories { - maven { - /* The name field can be given an arbitrary value. However changing the name will change the name of the deployment gradle task. - For example, - if name = "mavencentral", task name will be "publishReleasePublicationToMavencentralRepository" - if name = "foo", task name will be "publishReleasePublicationToFooRepository" - */ - name = "mavencentral" - url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" - credentials { - username = ossrhUsername - password = ossrhPassword - } - } - } - -} - -signing { - sign publishing.publications -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 23088aa..0de067f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,8 +22,8 @@ okhttp3 = "3.11.0" play-services-base = "11.0.0" spotless = "8.10.0" targetSdk = "36" -trustkitVersionCode = "10" -trustkitVersionName = "1.1.5" +trustkitVersionCode = "11" +trustkitVersionName = "1.2.0" [libraries] androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } diff --git a/scripts/build_release_bundle.sh b/scripts/build_release_bundle.sh new file mode 100755 index 0000000..4bc585c --- /dev/null +++ b/scripts/build_release_bundle.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +. "$(dirname "$0")/lib.sh" + +# Signs the bundle when SIGNING_KEY and SIGNING_KEY_PASSWORD are set. +VERSION="$(trustkit_version)" +REPOSITORY="trustkit/build/release-bundle" +BUNDLE="${PWD}/output/trustkit-${VERSION}.zip" +rm -rf "$REPOSITORY" "$BUNDLE" +mkdir -p output + +./gradlew :trustkit:publishReleasePublicationToReleaseBundleRepository + +# Only the version folder: Central does not want the artifact-level maven-metadata.xml. +(cd "$REPOSITORY" && zip -r "$BUNDLE" "com/datatheorem/android/trustkit/trustkit/${VERSION}") diff --git a/scripts/enable_kvm.sh b/scripts/enable_kvm.sh new file mode 100755 index 0000000..5959056 --- /dev/null +++ b/scripts/enable_kvm.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +. "$(dirname "$0")/lib.sh" + +# Hardware acceleration for the Android emulator on GitHub's Linux runners. +echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | + sudo tee /etc/udev/rules.d/99-kvm4all.rules +sudo udevadm control --reload-rules +sudo udevadm trigger --name-match=kvm diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..1435c15 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +cd "$(dirname "$0")/.." + +# stderr, so a failure inside $(...) is still shown. +fail() { + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::error::$*" >&2 + else + echo "error: $*" >&2 + fi + exit 1 +} + +trustkit_version() { + local version + version="$(sed -n 's/^trustkitVersionName = "\(.*\)"$/\1/p' gradle/libs.versions.toml)" + [[ -n "$version" ]] || fail "Could not read trustkitVersionName from gradle/libs.versions.toml." + echo "$version" +} diff --git a/scripts/publish_maven_central.sh b/scripts/publish_maven_central.sh new file mode 100755 index 0000000..5f8a582 --- /dev/null +++ b/scripts/publish_maven_central.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +. "$(dirname "$0")/lib.sh" + +: "${MAVEN_CENTRAL_USERNAME:?Set MAVEN_CENTRAL_USERNAME to the Central Portal user token username}" +: "${MAVEN_CENTRAL_PASSWORD:?Set MAVEN_CENTRAL_PASSWORD to the Central Portal user token password}" + +VERSION="$(trustkit_version)" +BUNDLE="output/trustkit-${VERSION}.zip" +API="https://central.sonatype.com/api/v1/publisher" +DEPLOYMENTS="https://central.sonatype.com/publishing/deployments" + +[[ -f "$BUNDLE" ]] || fail "$BUNDLE not found. Run scripts/build_release_bundle.sh first." +unzip -l "$BUNDLE" "*/trustkit-${VERSION}.aar.asc" >/dev/null 2>&1 || + fail "$BUNDLE is not signed. Set SIGNING_KEY and SIGNING_KEY_PASSWORD, then run scripts/build_release_bundle.sh again." + +TOKEN="$(printf '%s:%s' "$MAVEN_CENTRAL_USERNAME" "$MAVEN_CENTRAL_PASSWORD" | base64 | tr -d '\n')" +if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::add-mask::$TOKEN" +fi + +# The header goes in on stdin: a -H argument would show the token in the process list. +central_api() { + printf 'Authorization: Bearer %s\n' "$TOKEN" | curl --fail-with-body -sS -X POST -H @- "$@" +} + +# No --retry here: a repeated upload creates a second deployment. +if ! DEPLOYMENT_ID="$(central_api --form "bundle=@${BUNDLE}" \ + "$API/upload?publishingType=AUTOMATIC&name=trustkit-${VERSION}")"; then + fail "Central Portal rejected the upload: $DEPLOYMENT_ID" +fi +echo "Uploaded $BUNDLE as Central Portal deployment $DEPLOYMENT_ID: $DEPLOYMENTS" + +STATE="" +for _ in $(seq 120); do + if ! RESPONSE="$(central_api --retry 3 "$API/status?id=${DEPLOYMENT_ID}")"; then + fail "Could not read the status of deployment $DEPLOYMENT_ID: $RESPONSE. The upload already happened, so do not re-run this job. Check $DEPLOYMENTS." + fi + STATE="$(jq -r .deploymentState <<<"$RESPONSE")" + echo "Deployment state: $STATE" + case "$STATE" in + PUBLISHED) exit 0 ;; + FAILED) + jq . <<<"$RESPONSE" + fail "Central Portal rejected deployment $DEPLOYMENT_ID. See the errors above or $DEPLOYMENTS." + ;; + esac + sleep 20 +done +fail "Deployment $DEPLOYMENT_ID is still $STATE after 40 minutes. The upload already happened, so do not re-run this job. Check $DEPLOYMENTS." diff --git a/scripts/test_publish_maven_central.sh b/scripts/test_publish_maven_central.sh new file mode 100755 index 0000000..a4fec99 --- /dev/null +++ b/scripts/test_publish_maven_central.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +. "$(dirname "$0")/lib.sh" + +# Runs publish_maven_central.sh in a throwaway copy of the repo, against a fake curl. +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/gradle" "$WORK/bin" "$WORK/output" +cp -R scripts "$WORK/" +echo 'trustkitVersionName = "9.9.9"' >"$WORK/gradle/libs.versions.toml" + +cat >"$WORK/bin/curl" <<'EOF' +#!/usr/bin/env bash +echo "$*" >>"$CALLS" +cat >>"$CALLS.stdin" +case "${*: -1}" in + *publisher/upload*) + if [[ "$SCENARIO" == upload-rejected ]]; then echo '{"error":"Invalid token"}'; exit 22; fi + echo -n "deployment-1" ;; + *publisher/status*) + case "$SCENARIO" in + rejected) echo '{"deploymentState":"FAILED","errors":["Missing javadoc"]}' ;; + stuck) echo '{"deploymentState":"PUBLISHING"}' ;; + *) if (($(grep -c publisher/status "$CALLS") < 2)); then echo '{"deploymentState":"VALIDATING"}'; else echo '{"deploymentState":"PUBLISHED"}'; fi ;; + esac ;; +esac +EOF +printf '#!/usr/bin/env bash\n' >"$WORK/bin/sleep" +chmod +x "$WORK/bin/"* + +# Same files as a real bundle. A short listing hides bugs that only show with the real size. +make_bundle() { + local signed="$1" dir="$WORK/repo/com/datatheorem/android/trustkit/trustkit/9.9.9" suffix sum f + local files=() + rm -rf "$WORK/repo" "$WORK/output/trustkit-9.9.9.zip" + mkdir -p "$dir" + for suffix in .aar .pom .module -sources.jar -javadoc.jar; do + files+=("$dir/trustkit-9.9.9$suffix") + if $signed; then files+=("$dir/trustkit-9.9.9$suffix.asc"); fi + done + for f in "${files[@]}"; do + touch "$f" + for sum in md5 sha1 sha256 sha512; do touch "$f.$sum"; done + done + (cd "$WORK/repo" && zip -qr "$WORK/output/trustkit-9.9.9.zip" .) +} + +FAILED=0 +expect() { + local scenario="$1" want_exit="$2" want_text="$3" out rc + : >"$WORK/calls" + : >"$WORK/calls.stdin" + # GITHUB_ACTIONS is unset so the expected failures do not show up as CI annotations. + out="$(env -u GITHUB_ACTIONS PATH="$WORK/bin:$PATH" CALLS="$WORK/calls" SCENARIO="$scenario" \ + MAVEN_CENTRAL_USERNAME=user MAVEN_CENTRAL_PASSWORD=pass \ + "$WORK/scripts/publish_maven_central.sh" 2>&1)" && rc=0 || rc=$? + if [[ $rc -eq $want_exit && "$out" == *"$want_text"* ]]; then + echo "ok: $scenario" + else + echo "FAIL: $scenario exited $rc, expected $want_exit and output containing '$want_text':" + echo "$out" + FAILED=1 + fi +} + +make_bundle true +expect published 0 "Deployment state: PUBLISHED" +# dXNlcjpwYXNz is base64 of user:pass. Arguments show in the process list; stdin does not. +if grep -q dXNlcjpwYXNz "$WORK/calls" || ! grep -qx 'Authorization: Bearer dXNlcjpwYXNz' "$WORK/calls.stdin"; then + echo "FAIL: published: the token must reach curl on stdin, never as an argument" + FAILED=1 +fi +expect rejected 1 "Missing javadoc" +expect upload-rejected 1 "Invalid token" +expect stuck 1 "do not re-run this job" + +make_bundle false +expect unsigned 1 "is not signed" +if [[ -s "$WORK/calls" ]]; then + echo "FAIL: unsigned: the bundle was uploaded" + FAILED=1 +fi + +exit $FAILED diff --git a/scripts/verify_release_version.sh b/scripts/verify_release_version.sh new file mode 100755 index 0000000..e2b3c5d --- /dev/null +++ b/scripts/verify_release_version.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +. "$(dirname "$0")/lib.sh" + +TAG="${1:?usage: $0 }" +VERSION="$(trustkit_version)" + +[[ "${TAG#v}" == "$VERSION" ]] || + fail "Release tag '$TAG' does not match trustkitVersionName '$VERSION'. Tag the commit that sets this version." +[[ "$VERSION" != *-SNAPSHOT ]] || + fail "Snapshot version '$VERSION' cannot be released to Maven Central." + +POM_URL="https://repo1.maven.org/maven2/com/datatheorem/android/trustkit/trustkit/${VERSION}/trustkit-${VERSION}.pom" +STATUS="$(curl -sS --retry 3 -o /dev/null -w '%{http_code}' "$POM_URL" || true)" +case "$STATUS" in + 404) echo "TrustKit $VERSION is not on Maven Central yet. OK to release." ;; + 200) fail "Version $VERSION is already on Maven Central. Bump trustkitVersionName and create a new release." ;; + *) fail "Could not check Maven Central for $VERSION (HTTP $STATUS). Re-run the workflow." ;; +esac diff --git a/trustkit/build.gradle b/trustkit/build.gradle index b9f1594..e6c94de 100644 --- a/trustkit/build.gradle +++ b/trustkit/build.gradle @@ -1,8 +1,11 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + id 'maven-publish' + id 'signing' } +group = 'com.datatheorem.android.trustkit' version = libs.versions.trustkitVersionName.get() android { @@ -29,6 +32,13 @@ android { kotlinOptions { jvmTarget = libs.versions.jvmTarget.get() } + + publishing { + singleVariant('release') { + withSourcesJar() + withJavadocJar() + } + } } dependencies { @@ -49,5 +59,53 @@ dependencies { androidTestImplementation libs.google.play.services.base } -// MavenCentral deployment gradle script -apply from: "${rootProject.projectDir}/deploymentScripts/publish-mavencentral.gradle" +// AGP only creates components.release after the project is evaluated. +afterEvaluate { + publishing { + publications { + release(MavenPublication) { + from components.release + artifactId = 'trustkit' + pom { + name = 'trustkit' + description = 'TrustKit Android is an open source library that makes it easy to deploy SSL public key pinning and reporting in any Android App.' + url = 'https://github.com/datatheorem/TrustKit-Android' + licenses { + license { + name = 'The MIT License (MIT)' + url = 'https://github.com/datatheorem/TrustKit-Android/blob/master/LICENSE' + } + } + developers { + developer { + id = 'datatheorem' + name = 'Data Theorem Support' + email = 'support@datatheorem.com' + } + } + scm { + connection = 'scm:git:github.com/datatheorem/TrustKit-Android.git' + developerConnection = 'scm:git:github.com/datatheorem/TrustKit-Android.git' + url = 'https://github.com/datatheorem/TrustKit-Android' + } + } + } + } + + repositories { + // scripts/build_release_bundle.sh zips this repository for GitHub and Maven Central. + maven { + name = 'releaseBundle' + url = layout.buildDirectory.dir('release-bundle') + } + } + } + + def signingKey = providers.environmentVariable('SIGNING_KEY').orNull + if (signingKey) { + signing { + useInMemoryPgpKeys(signingKey, providers.environmentVariable('SIGNING_KEY_PASSWORD').orNull) + sign publishing.publications.release + } + } +} diff --git a/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/pinning/SSLSocketFactoryTest.java b/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/pinning/SSLSocketFactoryTest.java index 0592c94..4c4a029 100644 --- a/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/pinning/SSLSocketFactoryTest.java +++ b/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/pinning/SSLSocketFactoryTest.java @@ -237,7 +237,7 @@ public void testPinnedDomainSuccessAnchor() throws IOException { } @Test - public void testPinnedDomainSuccessLeaf() throws IOException { + public void testPinnedDomainSuccessIntermediate() throws IOException { String serverHostname = "datatheorem.com"; TestableTrustKit.initializeWithNetworkSecurityConfiguration( InstrumentationRegistry.getInstrumentation().getContext(), mockReporter); @@ -303,7 +303,7 @@ public void testPinnedDomainInvalidPin() throws IOException { @Test public void testPinnedDomainInvalidPinAndPinningNotEnforced() throws IOException { - String serverHostname = "www.github.com"; + String serverHostname = "yahoo.com"; TestableTrustKit.initializeWithNetworkSecurityConfiguration( InstrumentationRegistry.getInstrumentation().getContext(), mockReporter); @@ -335,7 +335,7 @@ public void testPinnedDomainInvalidPinAndPinningNotEnforced() throws IOException @Test public void testPinnedDomainInvalidPinAndPolicyExpired() throws IOException { - String serverHostname = "www.microsoft.com"; + String serverHostname = "login.yahoo.com"; TestableTrustKit.initializeWithNetworkSecurityConfiguration( InstrumentationRegistry.getInstrumentation().getContext(), mockReporter); @@ -359,7 +359,7 @@ public void testPinnedDomainInvalidPinAndPolicyExpired() throws IOException { @Test public void testPinnedDomainUntrustedChainAndPinningNotEnforced() throws IOException { - String serverHostname = "untrusted-root.badssl.com"; + String serverHostname = "self-signed.badssl.com"; TestableTrustKit.initializeWithNetworkSecurityConfiguration( InstrumentationRegistry.getInstrumentation().getContext(), mockReporter); @@ -614,7 +614,7 @@ public void testDebugOverridesInvalidPin() throws IOException, CertificateExcept // region Tests for when the domain is NOT pinned @Test public void testNonPinnedDomainUntrustedRootChain() throws IOException { - String serverHostname = "www.cacert.org"; + String serverHostname = "self-signed.badssl.com"; final DomainPinningPolicy domainPolicy = new DomainPinningPolicy.Builder() .setHostname("other.domain.com") @@ -669,7 +669,7 @@ public void testNonPinnedDomainUntrustedRootChain() throws IOException { @Test public void testNonPinnedDomainSuccess() throws IOException { // Initialize TrustKit - String serverHostname = "www.google.com"; + String serverHostname = "overmind.datatheorem.com"; TestableTrustKit.initializeWithNetworkSecurityConfiguration( InstrumentationRegistry.getInstrumentation().getContext(), mockReporter); @@ -764,7 +764,7 @@ public void testDebugOverridesSystemCa() throws IOException, CertificateExceptio return; } - String serverHostname = "www.google.com"; + String serverHostname = "overmind.datatheorem.com"; // Create a policy for a different domain final DomainPinningPolicy domainPolicy = new DomainPinningPolicy.Builder() diff --git a/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/reporting/BackgroundReporterTaskTest.java b/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/reporting/BackgroundReporterTaskTest.java index ab6b42f..6bbcfcf 100644 --- a/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/reporting/BackgroundReporterTaskTest.java +++ b/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/reporting/BackgroundReporterTaskTest.java @@ -74,7 +74,7 @@ public void testExecuteSucceedHttps() throws MalformedURLException { // Add two report URIs with the first one failing, to ensure both are called and last one // succeeded - taskParameters.add(new URL("https://www.google.com/fake")); + taskParameters.add(new URL("https://overmind.datatheorem.com/fake")); taskParameters.add(new URL("https://overmind.datatheorem.com/trustkit/report")); // Run the task synchronously and ensure it succeeded @@ -97,7 +97,7 @@ public void testExecuteSucceedHttp() throws MalformedURLException { // Add two report URIs with the first one failing, to ensure both are called and last one // succeeded - taskParameters.add(new URL("http://www.google.com/fake")); + taskParameters.add(new URL("http://overmind.datatheorem.com/fake")); taskParameters.add(new URL("http://overmind.datatheorem.com/trustkit/report")); // Run the task synchronously and ensure it succeeded @@ -121,7 +121,7 @@ public void testExecuteFailedHttpError() throws MalformedURLException { // Add two report URIs with the first one succeeding, to ensure both are called // and last one failed taskParameters.add(new URL("https://overmind.datatheorem.com/trustkit/report")); - taskParameters.add(new URL("https://www.google.com/fake")); + taskParameters.add(new URL("https://overmind.datatheorem.com/fake")); // Run the task synchronously and ensure it failed Integer lastResponseCode = testTask.doInBackground(taskParameters.toArray()); diff --git a/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/reporting/BackgroundReporterTest.java b/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/reporting/BackgroundReporterTest.java index efe9ca6..2515c4d 100644 --- a/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/reporting/BackgroundReporterTest.java +++ b/trustkit/src/androidTest/java/com/datatheorem/android/trustkit/reporting/BackgroundReporterTest.java @@ -51,10 +51,10 @@ public void testPinValidationFailed() throws MalformedURLException, JSONExceptio } Context context = InstrumentationRegistry.getInstrumentation().getContext(); // Initialize TrustKit - String serverHostname = "mail.google.com"; + String serverHostname = "mail.yahoo.com"; final DomainPinningPolicy domainPolicy = new DomainPinningPolicy.Builder() - .setHostname("google.com") + .setHostname("yahoo.com") .setShouldIncludeSubdomains(true) .setShouldEnforcePinning(true) .setPublicKeyHashes( @@ -130,15 +130,15 @@ private void validateSentReport(PinningFailureReport reportSent) throws JSONExce assertEquals("com.unit.tests", reportSentJson.getString("app-bundle-id")); assertEquals("1.2", reportSentJson.getString("app-version")); assertEquals("ANDROID", reportSentJson.getString("app-platform")); - assertEquals("mail.google.com", reportSentJson.getString("hostname")); - assertEquals("google.com", reportSentJson.getString("noted-hostname")); + assertEquals("mail.yahoo.com", reportSentJson.getString("hostname")); + assertEquals("yahoo.com", reportSentJson.getString("noted-hostname")); assertEquals(443, reportSentJson.getInt("port")); assertTrue(reportSentJson.getBoolean("include-subdomains")); assertTrue(reportSentJson.getBoolean("enforce-pinning")); assertEquals( PinningValidationResult.FAILED.ordinal(), reportSentJson.getInt("validation-result")); - assertEquals("google.com", reportSentJson.getString("noted-hostname")); + assertEquals("yahoo.com", reportSentJson.getString("noted-hostname")); assertNotNull(reportSentJson.getString("app-vendor-id")); assertNotNull(reportSentJson.getString("trustkit-version")); diff --git a/trustkit/src/androidTest/res/xml/network_security_config.xml b/trustkit/src/androidTest/res/xml/network_security_config.xml index 3b4fa6a..a7b4da0 100644 --- a/trustkit/src/androidTest/res/xml/network_security_config.xml +++ b/trustkit/src/androidTest/res/xml/network_security_config.xml @@ -5,20 +5,20 @@ www.datatheorem.com - + + hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - u91x/rv9ivMrXO0du6N2ABI6BiiVfD0T99DTEzoqSv4= - + datatheorem.com - + + OdSlmQD9NWJh4EbcOHBxkhygPwNSwA9Q91eounfbcoE= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc= @@ -36,7 +36,7 @@ - www.github.com + yahoo.com AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= @@ -47,7 +47,7 @@ - www.microsoft.com + login.yahoo.com AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= @@ -69,11 +69,11 @@ - untrusted-root.badssl.com + self-signed.badssl.com - + + 9SLklscvzMYj8f+52lp5ze/hY0CFHyLSPQzSpYYIBm8= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=