├── library ├── .gitignore ├── proguard-rules.pro ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── com │ │ │ └── wultra │ │ │ └── android │ │ │ └── sslpinning │ │ │ ├── UpdateMode.kt │ │ │ ├── util │ │ │ ├── DateTypeAdapter.kt │ │ │ ├── ByteArrayTypeAdapter.kt │ │ │ └── CertUtils.kt │ │ │ ├── ValidationResult.kt │ │ │ ├── UpdateResult.kt │ │ │ ├── UpdateObserver.kt │ │ │ ├── interfaces │ │ │ ├── SecureDataStore.kt │ │ │ └── CryptoProvider.kt │ │ │ ├── integration │ │ │ ├── DefaultSecureDataStore.kt │ │ │ ├── SSLPinningX509TrustManager.kt │ │ │ ├── powerauth │ │ │ │ ├── PowerAuthSslPinningValidationStrategy.kt │ │ │ │ ├── PowerAuthSecureDataStore.kt │ │ │ │ ├── PowerAuthCryptoProvider.kt │ │ │ │ └── PowerAuthIntegration.kt │ │ │ ├── DefaultUpdateObserver.kt │ │ │ ├── DefaultCryptoProvider.kt │ │ │ └── SSLPinningIntegration.kt │ │ │ ├── ValidationObserver.kt │ │ │ ├── UpdateType.kt │ │ │ ├── model │ │ │ ├── CachedData.kt │ │ │ ├── CertificateInfo.kt │ │ │ └── GetFingerprintResponse.kt │ │ │ ├── service │ │ │ ├── WultraDebug.kt │ │ │ ├── RemoteDataProvider.kt │ │ │ ├── UpdateScheduler.kt │ │ │ └── RestApi.kt │ │ │ └── SslValidationStrategy.kt │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── wultra │ │ │ └── android │ │ │ └── sslpinning │ │ │ ├── TestPA2ECPublicKey.kt │ │ │ ├── UpdateWrapper.kt │ │ │ ├── util │ │ │ ├── CertUtilsTest.kt │ │ │ └── DistinguishedNameParserTest.java │ │ │ ├── integration │ │ │ ├── powerauth │ │ │ │ ├── PowerAuthIntegrationTestKt.kt │ │ │ │ ├── PowerAuthIntegrationTest.java │ │ │ │ └── PowerAuthSslPinningValidationStrategyTest.kt │ │ │ └── SSLPinningIntegrationTest.java │ │ │ ├── model │ │ │ ├── CachedDataTest.kt │ │ │ ├── CertificateInfoTest.kt │ │ │ └── GetFingerprintResponseTest.kt │ │ │ ├── ValidationObserverTest.kt │ │ │ ├── CommonKotlinTest.kt │ │ │ ├── TestUtils.kt │ │ │ ├── CertStoreValidationTest.kt │ │ │ ├── CertStoreConfigurationTest.java │ │ │ └── CertStoreUpdateTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── wultra │ │ └── android │ │ └── sslpinning │ │ ├── UpdateWrapperInstr.kt │ │ ├── CertStoreChallengeTest.kt │ │ ├── CommonTest.kt │ │ ├── CertStoreValidateTest.kt │ │ ├── SecureDataStoreTest.kt │ │ ├── CertStoreUpdateTest.kt │ │ ├── CertStoreLoadSaveTest.kt │ │ ├── TestUtils.kt │ │ ├── CertStoreUpdateTestJava.java │ │ └── SSLPinningIntegrationTest.kt ├── gradle.properties ├── build.gradle.kts └── android-release-aar.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── scripts ├── build-publish-local.sh ├── deploy.cfg.sh ├── release.sh └── prepare-tests.sh ├── .limedeploy ├── configs └── integration-tests.properties ├── .github └── workflows │ ├── build.yml │ ├── lint.yml │ └── test.yml ├── Deploy └── gradle.properties ├── settings.gradle.kts ├── .run ├── library.run.xml ├── unitTests.run.xml └── androidTests.run.xml ├── .gitignore ├── gradle.properties └── gradlew.bat /library/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /jacoco.exec 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wultra/ssl-pinning-android/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /scripts/build-publish-local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | TOP=$(dirname $0) 4 | opt=${1:--nc -ns} 5 | "${TOP}/android-publish-build.sh" ${opt} local 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Feb 17 17:13:41 CET 2025 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /library/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -keepattributes Signature 2 | 3 | # classes that will be serialized/deserialized over Gson 4 | -keepclassmembers class com.wultra.android.sslpinning.model.** { 5 | ; 6 | } 7 | # necessary for R8 fullMode 8 | -keep,allowobfuscation class com.wultra.android.sslpinning.model.** -------------------------------------------------------------------------------- /.limedeploy: -------------------------------------------------------------------------------- 1 | # Common params 2 | DEPLOY_LIB_NAME='WultraSslPinning' 3 | DEPLOY_MODE="gradle" 4 | 5 | # Gradle 6 | DEPLOY_GRADLE_PATH="." 7 | DEPLOY_GRADLE_PARAMS="clean assembleRelease publishToMavenLocal bintrayUpload" 8 | 9 | # Versioning files 10 | DEPLOY_VERSIONING_FILES=( "Deploy/gradle.properties,library/gradle.properties" ) -------------------------------------------------------------------------------- /configs/integration-tests.properties: -------------------------------------------------------------------------------- 1 | # Note that following values are just placeholders. You need to provide valid configuration or 2 | # all the tests will fail. 3 | # 4 | # You can also use "private-integration-tests.properties" file for your own configuration. 5 | 6 | test.sslPinning.baseUrl=https://my-mus-deployment.com 7 | test.sslPinning.appName=my-app-name 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | buildJob: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout the repo 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up JDK 17 15 | uses: actions/setup-java@v4 16 | with: 17 | java-version: '17' 18 | distribution: 'temurin' 19 | 20 | - name: Assemble Library 21 | run: ./gradlew library:assemble 22 | -------------------------------------------------------------------------------- /library/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | name: Lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout the repo 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up JDK 17 15 | uses: actions/setup-java@v4 16 | with: 17 | java-version: '17' 18 | distribution: 'temurin' 19 | 20 | - name: Lint 21 | run: ./gradlew library:lint 22 | 23 | - name: Upload lint results artifact 24 | if: always() 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: lint-results-debug 28 | path: library/build/reports/lint-results-debug.html 29 | retention-days: 5 30 | -------------------------------------------------------------------------------- /Deploy/gradle.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 Wultra s.r.o. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions 14 | # and limitations under the License. 15 | # 16 | 17 | VERSION_NAME=%DEPLOY_VERSION% 18 | GROUP_ID=com.wultra.android.sslpinning 19 | ARTIFACT_ID=wultra-ssl-pinning 20 | -------------------------------------------------------------------------------- /library/gradle.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 Wultra s.r.o. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions 14 | # and limitations under the License. 15 | # 16 | 17 | VERSION_NAME=1.5.0-SNAPSHOT 18 | GROUP_ID=com.wultra.android.sslpinning 19 | ARTIFACT_ID=wultra-ssl-pinning 20 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | dependencyResolutionManagement { 17 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 18 | repositories { 19 | mavenCentral() 20 | mavenLocal() 21 | google() 22 | } 23 | } 24 | include(":library") 25 | -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/TestPA2ECPublicKey.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import com.wultra.android.sslpinning.interfaces.ECPublicKey 20 | 21 | /** 22 | * Test PA2ECPublicKey class used to avoid loading native code in EcPublicKey. 23 | */ 24 | data class TestPA2ECPublicKey(val data: ByteArray) : ECPublicKey -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/UpdateWrapper.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | /** 20 | * Helper wrapper for passing update type and result outside of callbacks. 21 | * 22 | * @author Tomas Kypta, tomas.kypta@wultra.com 23 | */ 24 | data class UpdateWrapper(var updateResult: UpdateResult? = null, 25 | var updateType: UpdateType? = null) -------------------------------------------------------------------------------- /.run/library.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/UpdateWrapperInstr.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | /** 20 | * Helper wrapper for passing update type and result outside of callbacks. 21 | * 22 | * @author Tomas Kypta, tomas.kypta@wultra.com 23 | */ 24 | data class UpdateWrapperInstr(var updateResult: UpdateResult? = null, 25 | var updateType: UpdateType? = null) -------------------------------------------------------------------------------- /.run/unitTests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | false 19 | true 20 | false 21 | true 22 | 23 | 25 | 26 | -------------------------------------------------------------------------------- /.run/androidTests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | false 22 | 23 | 25 | 26 | -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/util/CertUtilsTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.util 18 | 19 | import com.wultra.android.sslpinning.TestUtils 20 | import org.junit.Assert.* 21 | import org.junit.Test 22 | 23 | /** 24 | * @author Tomas Kypta, tomas.kypta@wultra.com 25 | */ 26 | class CertUtilsTest { 27 | 28 | @Test 29 | fun testParseCommonName() { 30 | val cert = TestUtils.getCertificateFromUrl("https://github.com") 31 | val cn = CertUtils.parseCommonName(cert) 32 | assertNotNull(cn) 33 | assertEquals("github.com", cn) 34 | } 35 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/UpdateMode.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | /** 20 | * Defines modes of update request. 21 | * 22 | * @author Tomas Kypta, tomas.kypta@wultra.com 23 | */ 24 | enum class UpdateMode { 25 | /** 26 | * Default update following periodicity defined in [CertStoreConfiguration]. 27 | */ 28 | DEFAULT, 29 | 30 | /** 31 | * Forced update performed immediately. 32 | * Use only if a "validate*" method returns [ValidationResult.EMPTY]. 33 | * Otherwise [DEFAULT] value is recommended. 34 | * 35 | * Note that this value causes synchronous (blocking) update request. 36 | */ 37 | FORCED 38 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # PinningTool used in tests 6 | pinningtool 7 | 8 | # Test resources 9 | library/src/test/resources 10 | library/src/androidTest/assets 11 | 12 | # Test configs 13 | configs 14 | 15 | # Files for the ART/Dalvik VM 16 | *.dex 17 | 18 | # Java class files 19 | *.class 20 | 21 | # Generated files 22 | bin/ 23 | gen/ 24 | out/ 25 | 26 | # Gradle files 27 | .gradle/ 28 | build/ 29 | 30 | # Local configuration file (sdk path, etc) 31 | local.properties 32 | 33 | # Proguard folder generated by Eclipse 34 | proguard/ 35 | 36 | # Log Files 37 | *.log 38 | 39 | # Android Studio Navigation editor temp files 40 | .navigation/ 41 | 42 | # Android Studio captures folder 43 | captures/ 44 | 45 | # IntelliJ 46 | *.iml 47 | .idea 48 | 49 | # Keystore files 50 | # Uncomment the following line if you do not want to check your keystore files in. 51 | #*.jks 52 | 53 | # External native build folder generated in Android Studio 2.2 and later 54 | .externalNativeBuild 55 | 56 | # Google Services (e.g. APIs or Firebase) 57 | google-services.json 58 | 59 | # Freeline 60 | freeline.py 61 | freeline/ 62 | freeline_project_description.json 63 | 64 | # fastlane 65 | fastlane/report.xml 66 | fastlane/Preview.html 67 | fastlane/screenshots 68 | fastlane/test_output 69 | fastlane/readme.md 70 | 71 | # jEnv 72 | .java-version -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 Wultra s.r.o. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions 14 | # and limitations under the License. 15 | # 16 | 17 | # Project-wide Gradle settings. 18 | # IDE (e.g. Android Studio) users: 19 | # Gradle settings configured through the IDE *will override* 20 | # any settings specified in this file. 21 | # For more details on how to configure your build environment visit 22 | # http://www.gradle.org/docs/current/userguide/build_environment.html 23 | # Specifies the JVM arguments used for the daemon process. 24 | # The setting is particularly useful for tweaking memory settings. 25 | org.gradle.jvmargs=-Xmx1536m 26 | # When configured, Gradle will run in incubating parallel mode. 27 | # This option should only be used with decoupled projects. More details, visit 28 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 29 | # org.gradle.parallel=true 30 | android.useAndroidX=true 31 | -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/util/DateTypeAdapter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.util 18 | 19 | import com.google.gson.* 20 | import java.lang.reflect.Type 21 | import java.util.* 22 | 23 | /** 24 | * Type adapter for parsing [Date] from update json. 25 | * 26 | * @author Tomas Kypta, tomas.kypta@wultra.com 27 | */ 28 | class DateTypeAdapter : JsonDeserializer, JsonSerializer { 29 | 30 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext?): Date { 31 | return Date(json.asLong * 1000) 32 | } 33 | 34 | override fun serialize(src: Date, typeOfSrc: Type, context: JsonSerializationContext?): JsonElement { 35 | val seconds = src.time / 1000 36 | return JsonPrimitive(seconds) 37 | } 38 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/util/ByteArrayTypeAdapter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.util 18 | 19 | import android.util.Base64 20 | import com.google.gson.* 21 | import java.lang.reflect.Type 22 | 23 | /** 24 | * GSON type adapter for handling ByteArray - Base64 (de)serialization. 25 | * 26 | * @author Tomas Kypta, tomas.kypta@wultra.com 27 | */ 28 | class ByteArrayTypeAdapter : JsonSerializer, JsonDeserializer { 29 | 30 | override fun serialize(src: ByteArray, typeOfSrc: Type, context: JsonSerializationContext?): JsonElement { 31 | return JsonPrimitive(Base64.encodeToString(src, Base64.NO_WRAP)) 32 | } 33 | 34 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext?): ByteArray { 35 | return Base64.decode(json.asString, Base64.NO_WRAP) 36 | } 37 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/ValidationResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | /** 20 | * The result of fingerprint validation. 21 | * 22 | * @author Tomas Kypta, tomas.kypta@wultra.com 23 | */ 24 | enum class ValidationResult { 25 | 26 | /** 27 | * The challenged server certificate is trusted. 28 | */ 29 | TRUSTED, 30 | /** 31 | * The challenged server certificate is not trusted. 32 | */ 33 | UNTRUSTED, 34 | /** 35 | * The fingerprint database is empty. Or there's no fingerprint for validated common name. 36 | * Both these situations mean that the store is unable to determine whether the server 37 | * can be trusted or not. 38 | * 39 | * In this case it is recommended to update the list of certificate fingerprints 40 | * and not to trust the server. 41 | */ 42 | EMPTY 43 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/UpdateResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | /** 20 | * Result of update request. 21 | * 22 | * @author Tomas Kypta, tomas.kypta@wultra.com 23 | */ 24 | enum class UpdateResult { 25 | /** 26 | * Update succeeded. Everything is ok. 27 | */ 28 | OK, 29 | /** 30 | * [CertStore] is empty. There's no valid certificate fingerprint to validate server cert against. 31 | * Might happen when all the certificate fingerprints are already expired. 32 | */ 33 | STORE_IS_EMPTY, 34 | /** 35 | * There was an error in network communication with the server. 36 | */ 37 | NETWORK_ERROR, 38 | /** 39 | * The update request returned invalid data from the server. 40 | */ 41 | INVALID_DATA, 42 | /** 43 | * The update request returned data which did not pass the signature validation. 44 | */ 45 | INVALID_SIGNATURE 46 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/util/CertUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.util 18 | 19 | import java.security.cert.X509Certificate 20 | 21 | /** 22 | * Utility methods for handling certificates. 23 | * 24 | * @author Tomas Kypta, tomas.kypta@wultra.com 25 | */ 26 | class CertUtils { 27 | 28 | companion object { 29 | 30 | /** 31 | * Parse common name (CN) out of certificate's distinguished name (DN). 32 | * 33 | * Note: on Android there's no native API for parsing it. 34 | * And we don't want to include Spongy Castle (https://rtyley.github.io/spongycastle) 35 | * for this task. 36 | * The parsing is taken from OkHttp library [DistinguishedNameParser]. 37 | */ 38 | fun parseCommonName(certificate: X509Certificate): String { 39 | val dnParser = DistinguishedNameParser(certificate.subjectX500Principal) 40 | return dnParser.findMostSpecific("CN") 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/UpdateObserver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import androidx.annotation.MainThread 20 | 21 | /** 22 | * Observer for receiving update results from asynchronous 23 | * [CertStore.update] method. 24 | * 25 | * Callbacks are executed on the main thread. 26 | * 27 | * @author Tomas Kypta, tomas.kypta@wultra.com 28 | * 29 | * @since 0.9.0 30 | */ 31 | interface UpdateObserver { 32 | 33 | /** 34 | * Called when an update has been started and [UpdateType] has been evaluated. 35 | * 36 | * The method is called on the main thread. 37 | * 38 | * @param type Type of update that was selected based on the input parameters and stored data. 39 | */ 40 | @MainThread 41 | fun onUpdateStarted(type: UpdateType) 42 | 43 | /** 44 | * Called when an update finishes. 45 | * 46 | * The method is called on the main thread. 47 | * In case of [UpdateType.NO_UPDATE], it's called immediately after [onUpdateStarted]. 48 | * 49 | * @param type Type of update 50 | * @param result Result of the update. 51 | */ 52 | @MainThread 53 | fun onUpdateFinished(type: UpdateType, result: UpdateResult) 54 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/interfaces/SecureDataStore.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.interfaces 18 | 19 | /** 20 | * The `SecureDataStore` protocol defines interface for saving to, and loading data 21 | * from the underlying secure storage. The implementing class should store data as 22 | * secure as possible. On iOS that typically means that iOS Keychain should be used. 23 | * 24 | * @author Tomas Kypta, tomas.kypta@wultra.com 25 | */ 26 | interface SecureDataStore { 27 | 28 | /** 29 | * Save data to the secure data store. 30 | * 31 | * @param data Data to be saved 32 | * @param key Identifier for the saved data 33 | * @return True if the data has been properly saved 34 | */ 35 | fun save(data: ByteArray, key: String): Boolean 36 | 37 | /** 38 | * Loads data previously stored for given key. 39 | * 40 | * @param key Identifier for stored data 41 | * @return Data object if the store contains previously stored data, null otherwise 42 | */ 43 | fun load(key: String): ByteArray? 44 | 45 | /** 46 | * Remove data previously stored for given key. 47 | * 48 | * @param key Identifier for the stored data 49 | */ 50 | fun remove(key: String) 51 | } -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/CertStoreChallengeTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import androidx.test.ext.junit.runners.AndroidJUnit4 20 | import com.wultra.android.sslpinning.integration.DefaultCryptoProvider 21 | import com.wultra.android.sslpinning.integration.DefaultSecureDataStore 22 | import com.wultra.android.sslpinning.integration.powerauth.powerAuthCertStore 23 | import org.junit.Test 24 | import org.junit.runner.RunWith 25 | import java.util.UUID 26 | 27 | @RunWith(AndroidJUnit4::class) 28 | class CertStoreChallengeTest: CommonTest() { 29 | 30 | private lateinit var certStores: Array 31 | 32 | override fun setUp() { 33 | super.setUp() 34 | val config = CertStoreConfiguration.Builder(serviceUrl, pubKey).useChallenge(true).build() 35 | certStores = arrayOf( 36 | CertStore.powerAuthCertStore(config, appContext, UUID.randomUUID().toString()), 37 | CertStore(config, DefaultCryptoProvider(), DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 38 | ) 39 | } 40 | 41 | @Test 42 | fun validateUpdateWithChallenge() { 43 | certStores.forEach { store -> 44 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/DefaultSecureDataStore.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025, Wultra s.r.o. (www.wultra.com). 3 | * 4 | * All rights reserved. This source code can be used only for purposes specified 5 | * by the given license contract signed by the rightful deputy of Wultra s.r.o. 6 | * This source code can be used only by the owner of the license. 7 | * 8 | * Any disputes arising in respect of this agreement (license) shall be brought 9 | * before the Municipal Court of Prague. 10 | */ 11 | 12 | package com.wultra.android.sslpinning.integration 13 | 14 | import android.content.Context 15 | import androidx.security.crypto.EncryptedSharedPreferences 16 | import androidx.security.crypto.MasterKeys 17 | import com.wultra.android.sslpinning.interfaces.SecureDataStore 18 | 19 | class DefaultSecureDataStore @JvmOverloads constructor( 20 | appContext: Context, 21 | identifier: String = defaultIdentifier 22 | ): SecureDataStore { 23 | 24 | companion object { 25 | @JvmStatic 26 | val defaultIdentifier = "com.wultra.DefaultWultraCertStore" 27 | } 28 | 29 | private val preferences = EncryptedSharedPreferences.create( 30 | identifier, 31 | MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), 32 | appContext, 33 | EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 34 | EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM 35 | ) 36 | 37 | override fun save(data: ByteArray, key: String): Boolean { 38 | // save raw data as ISO 8859-1 to avoid encoding 39 | preferences.edit().putString(key, String(data, Charsets.ISO_8859_1)).apply() 40 | return true 41 | } 42 | 43 | override fun load(key: String): ByteArray? { 44 | return preferences.getString(key, null)?.toByteArray(Charsets.ISO_8859_1) 45 | } 46 | 47 | override fun remove(key: String) { 48 | preferences.edit().remove(key).apply() 49 | } 50 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/SSLPinningX509TrustManager.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration 18 | 19 | import android.annotation.SuppressLint 20 | import com.wultra.android.sslpinning.CertStore 21 | import com.wultra.android.sslpinning.ValidationResult 22 | import java.security.cert.CertificateException 23 | import java.security.cert.X509Certificate 24 | import javax.net.ssl.X509TrustManager 25 | 26 | /** 27 | * Trust manager for validating server certificates with WultraSSLPinning. 28 | * 29 | * @author Tomas Kypta, tomas.kypta@wultra.com 30 | */ 31 | @Suppress("CustomX509TrustManager") 32 | class SSLPinningX509TrustManager(private val certStore: CertStore) : X509TrustManager { 33 | 34 | @SuppressLint("TrustAllX509TrustManager") 35 | override fun checkClientTrusted(chain: Array, authType: String) { 36 | } 37 | 38 | override fun checkServerTrusted(chain: Array, authType: String) { 39 | if (certStore.validateCertificate(chain[0]) != ValidationResult.TRUSTED) { 40 | // reject 41 | throw CertificateException("WultraSSLpinning doesn't trust the server certificate") 42 | } 43 | } 44 | 45 | override fun getAcceptedIssuers(): Array { 46 | return arrayOf() 47 | } 48 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/ValidationObserver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import androidx.annotation.MainThread 20 | 21 | /** 22 | * Observer for validation failures. 23 | * 24 | * When registered receives all validation failures happening on the given [CertStore]. 25 | * Callbacks are executed on the main thread. 26 | * 27 | * @author Tomas Kypta, tomas.kypta@wultra.com 28 | * 29 | * @since 0.9.0 30 | */ 31 | interface ValidationObserver { 32 | 33 | /** 34 | * Called when a validation for a common name is deemed [ValidationResult.TRUSTED]. 35 | * 36 | * @param commonName The common name that was validated with result [ValidationResult.TRUSTED]. 37 | */ 38 | @MainThread 39 | fun onValidationTrusted(commonName: String) 40 | 41 | /** 42 | * Called when a validation for a common name is deemed [ValidationResult.UNTRUSTED]. 43 | * 44 | * @param commonName The common name that was validated with result [ValidationResult.UNTRUSTED]. 45 | */ 46 | @MainThread 47 | fun onValidationUntrusted(commonName: String) 48 | 49 | /** 50 | * Called when a validation for a common name is deemed [ValidationResult.EMPTY]. 51 | * 52 | * @param commonName The common name that was validated with result [ValidationResult.EMPTY]. 53 | */ 54 | @MainThread 55 | fun onValidationEmpty(commonName: String) 56 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/powerauth/PowerAuthSslPinningValidationStrategy.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration.powerauth 18 | 19 | import com.wultra.android.sslpinning.CertStore 20 | import com.wultra.android.sslpinning.integration.SSLPinningIntegration 21 | import com.wultra.android.sslpinning.integration.SSLPinningX509TrustManager 22 | import io.getlime.security.powerauth.networking.ssl.HttpClientValidationStrategy 23 | import javax.net.ssl.HostnameVerifier 24 | import javax.net.ssl.SSLSocketFactory 25 | import javax.net.ssl.X509TrustManager 26 | 27 | /** 28 | * Validation strategy for PowerAuth SDK incorporating WultraSSLPinning. 29 | * 30 | * It adds extra [X509TrustManager] to the SSLContext in front of the default trust managers. 31 | * This way first WultraSSLPinning is checked. Then the standard certificate validation 32 | * has its way. 33 | * 34 | * @property certStore Instance of [CertStore] based on PowerAuthSDK. 35 | */ 36 | class PowerAuthSslPinningValidationStrategy(private val certStore: CertStore) : HttpClientValidationStrategy { 37 | 38 | /** 39 | * Trust manager for validating server certificates with WultraSSLPinning. 40 | */ 41 | private val sslPinningTrustManager = SSLPinningX509TrustManager(certStore) 42 | 43 | override fun getHostnameVerifier(): HostnameVerifier? { 44 | return null 45 | } 46 | 47 | override fun getSSLSocketFactory(): SSLSocketFactory? { 48 | return SSLPinningIntegration.createSSLPinningSocketFactory(sslPinningTrustManager) 49 | } 50 | } -------------------------------------------------------------------------------- /scripts/deploy.cfg.sh: -------------------------------------------------------------------------------- 1 | # gradle.properties file where library version is stored 2 | DEPLOY_VERSION_FILES=("library/gradle.properties") 3 | # Name of remote repository. Variable is used in communication with user. 4 | DEPLOY_REMOTE_NAME="Maven Central" 5 | # Gradle task to publish library to remote repository 6 | DEPLOY_REMOTE_TASK="publishReleasePublicationToSonatypeRepository" 7 | # Set 0 / 1 whether signing is allowed 8 | DEPLOY_ALLOW_SIGN=1 9 | 10 | # Sources root 11 | SRC_ROOT="`( cd \"$TOP/..\" && pwd )`" 12 | 13 | # ----------------------------------------------------------------------------- 14 | # Function that adjusts GRADLE_PARAMS global variable with parameters required 15 | # for proper library deployment to remote repository. 16 | # ----------------------------------------------------------------------------- 17 | function DEPLOY_PREPARE_GRADLE_PARAMS 18 | { 19 | # Find proper signing tool 20 | set +e 21 | local HAS_GPG=`which gpg` 22 | local HAS_GPG2=`which gpg2` 23 | set -e 24 | 25 | [[ -z $HAS_GPG ]] && [[ -z $HAS_GPG2 ]] && FAILURE "gpg or gpg2 tool is missing." 26 | 27 | # Load and validate API credentials 28 | LOAD_API_CREDENTIALS 29 | [[ x$NEXUS_USER == x ]] && FAILURE "Missing NEXUS_USER variable in API credentials." 30 | [[ x$NEXUS_PASSWORD == x ]] && FAILURE "Missing NEXUS_PASSWORD variable in API credentials." 31 | [[ x$SIGN_GPG_KEY_ID == x ]] && FAILURE "Missing SIGN_GPG_KEY_ID variable in API credentials." 32 | [[ x$SIGN_GPG_KEY_PASS == x ]] && FAILURE "Missing SIGN_GPG_KEY_PASS variable in API credentials." 33 | #[[ x$NEXUS_STAGING_PROFILE_ID == x ]] && FAILURE "Missing NEXUS_STAGING_PROFILE_ID variable in API credentials." 34 | 35 | # Configure gpg for gradle task 36 | GRADLE_PARAMS+=" -Psigning.gnupg.keyName=$SIGN_GPG_KEY_ID" 37 | GRADLE_PARAMS+=" -Psigning.gnupg.passphrase=$SIGN_GPG_KEY_PASS" 38 | if [ ! -z $HAS_GPG ] && [ -z $HAS_GPG2 ]; then 39 | GRADLE_PARAMS+=" -Psigning.gnupg.executable=gpg" 40 | fi 41 | # Configure nexus credentials 42 | GRADLE_PARAMS+=" -Pnexus.user=${NEXUS_USER}" 43 | GRADLE_PARAMS+=" -Pnexus.password=${NEXUS_PASSWORD}" 44 | #GRADLE_PARAMS+=" -Pnexus.stagingProfileId=${NEXUS_STAGING_PROFILE_ID}" 45 | } 46 | -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/UpdateType.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | /** 20 | * Type of update initiated by [CertStore.update] method. 21 | * 22 | * @author Tomas Kypta, tomas.kypta@wultra.com 23 | * 24 | * @since 0.9.0 25 | */ 26 | enum class UpdateType { 27 | 28 | /** 29 | * The update is direct. 30 | * 31 | * App should wait for the update to finish because it means there is some serious problem 32 | * with current fingerprint data. Network requests (SSL handshakes) might fail. 33 | */ 34 | DIRECT, 35 | 36 | /** 37 | * The update is silent. 38 | * 39 | * App can continue normally. But it's time to check for updates. 40 | * It's improbable that network requests (SSL handshakes) 41 | * would fail since the local fingerprint data are still valid. 42 | * 43 | * Note that even though the local data are valid, there might be some remote changes 44 | * (changed server certificate) and network requests (SSL handshakes) might still fail. 45 | */ 46 | SILENT, 47 | 48 | /** 49 | * No update is performed. 50 | * 51 | * App con continue normally. 52 | * There's no need to perform update based on the locally stored fingerprints. 53 | * 54 | * Note that even though the local data are valid, there might be some remote changes 55 | * (changed server certificate) and network requests (SSL handshakes) might still fail. 56 | */ 57 | NO_UPDATE; 58 | 59 | /** 60 | * Check if this type of update is actually performing update. 61 | */ 62 | val isPerformingUpdate: Boolean 63 | get() { 64 | return this != NO_UPDATE 65 | } 66 | } -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/integration/powerauth/PowerAuthIntegrationTestKt.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration.powerauth 18 | 19 | import com.wultra.android.sslpinning.CertStore 20 | import com.wultra.android.sslpinning.CertStoreConfiguration 21 | import com.wultra.android.sslpinning.CommonKotlinTest 22 | import com.wultra.android.sslpinning.TestUtils 23 | import org.junit.Assert 24 | import org.junit.Test 25 | import java.net.URL 26 | 27 | /** 28 | * Testing format of Java compatible APIs from Kotlin. 29 | * 30 | * @author Tomas Kypta, tomas.kypta@wultra.com 31 | */ 32 | class PowerAuthIntegrationTestKt : CommonKotlinTest() { 33 | 34 | @Test 35 | fun testApis() { 36 | val url = URL("https://gist.githubusercontent.com/hvge/7c5a3f9ac50332a52aa974d90ea2408c/raw/c5b021db0fcd40b1262ab513bf375e4641834925/ssl-pinning-signatures.json") 37 | val publicKey = "BC3kV9OIDnMuVoCdDR9nEA/JidJLTTDLuSA2TSZsGgODSshfbZg31MS90WC/HdbU/A5WL5GmyDkE/iks6INv+XE=" 38 | val publicKeyBytes = java.util.Base64.getDecoder().decode(publicKey) 39 | 40 | val configuration = CertStoreConfiguration.Builder(url, publicKeyBytes) 41 | .build() 42 | val store1 = PowerAuthCertStore.createInstance(configuration, context, null) 43 | TestUtils.assignHandler(store1, handler) 44 | Assert.assertNotNull(store1) 45 | val store2 = PowerAuthCertStore.createInstance(configuration, context) 46 | TestUtils.assignHandler(store2, handler) 47 | Assert.assertNotNull(store2) 48 | 49 | // Kotlin API 50 | val store3 = CertStore.powerAuthCertStore(configuration, context, "") 51 | TestUtils.assignHandler(store3, handler) 52 | Assert.assertNotNull(store3) 53 | } 54 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/model/CachedData.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.model 18 | 19 | import java.util.* 20 | 21 | /** 22 | * Data class for stored data - list of certificates and next update date. 23 | * 24 | * @author Tomas Kypta, tomas.kypta@wultra.com 25 | */ 26 | internal data class CachedData(var certificates: Array, 27 | var nextUpdate: Date) { 28 | 29 | internal fun numberOfValidCertificates(date: Date): Int { 30 | var result = 0 31 | for (cert in certificates) { 32 | if (!cert.isExpired(date)) { 33 | result += 1 34 | } 35 | } 36 | return result 37 | } 38 | 39 | /** 40 | * Sorts certificates stored in CachedData structure. Entries are alphabetically sorted 41 | * by the common name. For entries with the same common name, the entries with expiration 42 | * in more distant future will be first. This order allows to have more recent certs at first positions, 43 | * so we can more easily calculate when the next silent update will be scheduled. 44 | */ 45 | internal fun sort() { 46 | certificates.sort() 47 | } 48 | 49 | override fun equals(other: Any?): Boolean { 50 | if (this === other) return true 51 | if (javaClass != other?.javaClass) return false 52 | 53 | other as CachedData 54 | 55 | if (!certificates.contentEquals(other.certificates)) return false 56 | if (nextUpdate != other.nextUpdate) return false 57 | 58 | return true 59 | } 60 | 61 | override fun hashCode(): Int { 62 | var result = certificates.contentHashCode() 63 | result = 31 * result + nextUpdate.hashCode() 64 | return result 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | unitTests: 8 | name: Unit Tests 9 | runs-on: macos-latest 10 | steps: 11 | - name: Checkout the repo 12 | uses: actions/checkout@v4 13 | - name: Set up JDK 17 14 | uses: actions/setup-java@v4 15 | with: 16 | java-version: '17' 17 | distribution: 'temurin' 18 | - name: Prepare tests 19 | run: sh ./scripts/prepare-tests.sh --url ${{ secrets.SSL_PINNING_TEST_BASE_URL }} --app ${{ secrets.SSL_PINNING_TEST_APP_NAME }} --username ${{ secrets.SSL_PINNING_TEST_ADMIN_USERNAME }} --password ${{ secrets.SSL_PINNING_TEST_ADMIN_PASSWORD }} 20 | - name: Unit Test 21 | run: ./gradlew library:test 22 | - name: Upload unit test results artifact 23 | if: always() 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: unit-test-results-debug 27 | path: library/build/reports/tests/testDebugUnitTest/ 28 | retention-days: 5 29 | 30 | intrTests: 31 | name: Instrumentation Tests 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout the repo 35 | uses: actions/checkout@v4 36 | - name: Set up JDK 17 37 | uses: actions/setup-java@v4 38 | with: 39 | java-version: '17' 40 | distribution: 'temurin' 41 | - name: Prepare tests 42 | run: ./scripts/prepare-tests.sh --url ${{ secrets.SSL_PINNING_TEST_BASE_URL }} --app ${{ secrets.SSL_PINNING_TEST_APP_NAME }} --username ${{ secrets.SSL_PINNING_TEST_ADMIN_USERNAME }} --password ${{ secrets.SSL_PINNING_TEST_ADMIN_PASSWORD }} 43 | - name: Enable KVM 44 | run: | 45 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 46 | sudo udevadm control --reload-rules 47 | sudo udevadm trigger --name-match=kvm 48 | - name: Run instrumentation tests on emulator 49 | uses: reactivecircus/android-emulator-runner@v2 50 | with: 51 | api-level: 29 52 | script: ./gradlew library:connectedAndroidTest --info 53 | - name: Upload instrumentation test results artifact 54 | if: always() 55 | uses: actions/upload-artifact@v4 56 | with: 57 | name: instrumentation-test-results-debug 58 | path: library/build/reports/androidTests/connected/ 59 | retention-days: 5 -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/service/WultraDebug.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.service 18 | 19 | import android.util.Log 20 | 21 | /** 22 | * Simple logging abstraction for the library. 23 | * 24 | * @author Tomas Kypta, tomas.kypta@wultra.com 25 | */ 26 | class WultraDebug { 27 | 28 | /** 29 | * List of logging levels for [WultraDebug]. 30 | */ 31 | enum class WultraLoggingLevel { 32 | /** 33 | * Log all log including debug logs. 34 | * 35 | * Don't use on release builds. 36 | */ 37 | DEBUG, 38 | 39 | /** 40 | * Log only logs suitable for release (warning and error). 41 | */ 42 | RELEASE, 43 | 44 | /** 45 | * Don't log anything. 46 | */ 47 | NONE 48 | } 49 | 50 | companion object { 51 | const val LOG_TAG = "Wultra-SSL-Pinning" 52 | 53 | /** 54 | * Current logging level. 55 | */ 56 | var loggingLevel = WultraLoggingLevel.RELEASE 57 | 58 | /** 59 | * Log info level message. 60 | */ 61 | fun info(message: String) { 62 | if (loggingLevel == WultraLoggingLevel.DEBUG) { 63 | Log.i(LOG_TAG, message) 64 | } 65 | } 66 | 67 | /** 68 | * Log warning level message. 69 | */ 70 | fun warning(message: String) { 71 | if (loggingLevel != WultraLoggingLevel.NONE) { 72 | Log.w(LOG_TAG, message) 73 | } 74 | } 75 | 76 | /** 77 | * Log error level message. 78 | */ 79 | fun error(message: String) { 80 | if (loggingLevel != WultraLoggingLevel.NONE) { 81 | Log.e(LOG_TAG, message) 82 | } 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ############################################################################### 3 | # Include common functions... 4 | # ----------------------------------------------------------------------------- 5 | TOP=$(dirname $0) 6 | source "${TOP}/common-functions.sh" 7 | source "${TOP}/deploy.cfg.sh" 8 | 9 | [[ -z "$SRC_ROOT" ]] && FAILURE "Missing SRC_ROOT in deploy.cfg.sh" 10 | 11 | # ----------------------------------------------------------------------------- 12 | # USAGE prints help and exits the script with error code from provided parameter 13 | # Parameters: 14 | # $1 - error code to be used as return code from the script 15 | # ----------------------------------------------------------------------------- 16 | function USAGE 17 | { 18 | echo "" 19 | echo "Usage: $CMD [options] version" 20 | echo "" 21 | echo " This tool helps with library publication to $DEPLOY_REMOTE_NAME" 22 | echo "" 23 | echo "Where:" 24 | echo "" 25 | echo " version Is new library version in x.y.z format" 26 | echo "" 27 | echo "options:" 28 | echo "" 29 | echo " -v0 turn off all prints to stdout" 30 | echo " -v1 print only basic log about build progress" 31 | echo " -v2 print full build log with rich debug info" 32 | echo " -h | --help print this help information" 33 | echo "" 34 | exit $1 35 | } 36 | 37 | VERBOSE_SWITCH='' 38 | 39 | while [[ $# -gt 0 ]] 40 | do 41 | opt="$1" 42 | case "$opt" in 43 | -h | --help) 44 | USAGE 0 ;; 45 | -v*) 46 | VERBOSE_SWITCH=$opt 47 | SET_VERBOSE_LEVEL_FROM_SWITCH $opt ;; 48 | *) 49 | VALIDATE_AND_SET_VERSION_STRING $opt ;; 50 | esac 51 | shift 52 | done 53 | 54 | # make sure that new version and build unmber is available 55 | if [ "$VERSION" == "" ]; then 56 | FAILURE "You have to specify version in X.Y.Z format." 57 | fi 58 | 59 | REQUIRE_COMMAND git 60 | 61 | LOG "Configuring version..." 62 | "${TOP}/android-publish-build.sh" $VERBOSE_SWITCH -r $VERSION 63 | 64 | LOG "Publishing release to $DEPLOY_REMOTE_NAME..." 65 | "${TOP}/android-publish-build.sh" $VERBOSE_SWITCH remote 66 | 67 | # Apply changes to git 68 | 69 | LOG "Creating version in git..." 70 | PUSH_DIR "$SRC_ROOT" 71 | 72 | # Commit changes 73 | git commit -m "Version bump to ${VERSION}" 74 | git push 75 | # Create tag 76 | git tag "${VERSION}" 77 | git push --tags 78 | 79 | POP_DIR 80 | 81 | EXIT_SUCCESS -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/interfaces/CryptoProvider.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.interfaces 18 | 19 | 20 | /** 21 | * The `CryptoProvider` protocol defines interface for performing several 22 | * cryptographic primitives, required by this library. 23 | * 24 | * @author Tomas Kypta, tomas.kypta@wultra.com 25 | */ 26 | interface CryptoProvider { 27 | 28 | /** 29 | * Validates whether data has not been modified. 30 | * 31 | * @param signedData Array of SignedData structures 32 | * @param publicKey EC public key 33 | * @return True if all signatures are correct 34 | */ 35 | fun ecdsaValidateSignature(signedData: SignedData, publicKey: ECPublicKey): Boolean 36 | 37 | /** 38 | * Constructs a new ECPublicKey object from given ASN.1 formatted data blob. 39 | * 40 | * @param publicKey ASN.1 formatted data blob with EC public ket. 41 | * @return Object representing public key or nil in case of error. 42 | */ 43 | fun importECPublicKey(publicKey: ByteArray): ECPublicKey? 44 | 45 | /** 46 | * Computes SHA-256 hash from given data. 47 | * 48 | * @param data Data to be hashed 49 | * @return 32 bytes hash, calculated as `SHA256(data)` 50 | */ 51 | fun hashSha256(data: ByteArray): ByteArray 52 | 53 | /** 54 | * Generate random data. 55 | * 56 | * @param length Number of random bytes to be generated 57 | * @return Random bytes 58 | */ 59 | fun getRandomData(length: Int): ByteArray 60 | } 61 | 62 | /** 63 | * The `SignedData` structure contains data and signature calculated for the data. 64 | */ 65 | data class SignedData(val data: ByteArray, 66 | val signature: ByteArray) 67 | 68 | /** 69 | * The `ECPublicKey` protocol is an abstract interface representing 70 | * a public key in EC based cryptography. 71 | */ 72 | interface ECPublicKey -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/model/CertificateInfo.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.model 18 | 19 | import java.io.Serializable 20 | import java.util.Date 21 | 22 | /** 23 | * Data class for holding certificate info necessary for certificate validation. 24 | * 25 | * @author Tomas Kypta, tomas.kypta@wultra.com 26 | */ 27 | data class CertificateInfo(val commonName: String, 28 | val fingerprint: ByteArray, 29 | val expires: Date) : Serializable, Comparable { 30 | 31 | internal constructor(responseEntry: GetFingerprintResponse.Entry) : 32 | this(commonName = responseEntry.name, fingerprint = responseEntry.fingerprint, 33 | expires = responseEntry.expires) 34 | 35 | /** 36 | * Check if the info is expired. 37 | */ 38 | internal fun isExpired(date: Date): Boolean { 39 | return expires.before(date) 40 | } 41 | 42 | override fun compareTo(other: CertificateInfo): Int { 43 | if (this.commonName == other.commonName) { 44 | return -this.expires.compareTo(other.expires) 45 | } 46 | return this.commonName.compareTo(other.commonName) 47 | } 48 | 49 | override fun equals(other: Any?): Boolean { 50 | if (this === other) return true 51 | if (javaClass != other?.javaClass) return false 52 | 53 | other as CertificateInfo 54 | 55 | if (commonName != other.commonName) return false 56 | if (!fingerprint.contentEquals(other.fingerprint)) return false 57 | if (expires != other.expires) return false 58 | 59 | return true 60 | } 61 | 62 | override fun hashCode(): Int { 63 | var result = commonName.hashCode() 64 | result = 31 * result + fingerprint.contentHashCode() 65 | result = 31 * result + expires.hashCode() 66 | return result 67 | } 68 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/powerauth/PowerAuthSecureDataStore.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration.powerauth 18 | 19 | import android.content.Context 20 | import com.wultra.android.sslpinning.interfaces.SecureDataStore 21 | import io.getlime.security.powerauth.keychain.Keychain 22 | import io.getlime.security.powerauth.keychain.KeychainFactory 23 | import io.getlime.security.powerauth.keychain.KeychainProtection 24 | 25 | /** 26 | * The [PowerAuthSecureDataStore] implements [SecureDataStore] interface with using 27 | * [Keychain] as underlying data storage. 28 | * To initialize the data store, you have to provide keychain identifier. 29 | * 30 | * @property contains Application context 31 | * @param keychainIdentifier Identifier for the data store. Used to distinguish multiple instances. 32 | * @param minimumKeychainProtection The minimum level of PowerAuth Keychain content protection. 33 | * 34 | * @author Tomas Kypta, tomas.kypta@wultra.com 35 | */ 36 | class PowerAuthSecureDataStore @JvmOverloads constructor(private val context: Context, 37 | keychainIdentifier: String = defaultKeychainIdentifier, 38 | minimumKeychainProtection: Int = KeychainProtection.NONE) : SecureDataStore { 39 | 40 | companion object { 41 | @JvmStatic 42 | val defaultKeychainIdentifier = "com.wultra.WultraCertStore" 43 | } 44 | 45 | private val keychain: Keychain = KeychainFactory.getKeychain(context, keychainIdentifier, minimumKeychainProtection) 46 | 47 | 48 | override fun save(data: ByteArray, key: String): Boolean { 49 | keychain.putData(data, key) 50 | return true 51 | } 52 | 53 | override fun load(key: String): ByteArray? { 54 | return keychain.getData(key) 55 | } 56 | 57 | override fun remove(key: String) { 58 | keychain.remove(key) 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/integration/SSLPinningIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration; 18 | 19 | import com.wultra.android.sslpinning.CertStore; 20 | import com.wultra.android.sslpinning.CertStoreConfiguration; 21 | import com.wultra.android.sslpinning.CommonKotlinTest; 22 | import com.wultra.android.sslpinning.TestUtils; 23 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthCertStore; 24 | 25 | import org.junit.Assert; 26 | import org.junit.Test; 27 | 28 | import java.net.URL; 29 | 30 | import javax.net.ssl.SSLSocketFactory; 31 | 32 | /** 33 | * Testing format of Java compatible APIs. 34 | * 35 | * @author Tomas Kypta, tomas.kypta@wultra.com 36 | */ 37 | public class SSLPinningIntegrationTest extends CommonKotlinTest { 38 | 39 | @Test 40 | public void testSSLPinningIntegrationApis() throws Exception { 41 | URL url = new URL("https://gist.githubusercontent.com/hvge/7c5a3f9ac50332a52aa974d90ea2408c/raw/c5b021db0fcd40b1262ab513bf375e4641834925/ssl-pinning-signatures.json"); 42 | String publicKey = "BC3kV9OIDnMuVoCdDR9nEA/JidJLTTDLuSA2TSZsGgODSshfbZg31MS90WC/HdbU/A5WL5GmyDkE/iks6INv+XE="; 43 | byte[] publicKeyBytes = java.util.Base64.getDecoder().decode(publicKey); 44 | 45 | CertStoreConfiguration configuration = new CertStoreConfiguration.Builder(url, publicKeyBytes) 46 | .build(); 47 | CertStore store = PowerAuthCertStore.createInstance(configuration, context); 48 | TestUtils.assignHandler(store, handler); 49 | 50 | Assert.assertNotNull(store); 51 | 52 | SSLSocketFactory factory1 = SSLPinningIntegration.createSSLPinningSocketFactory(store); 53 | Assert.assertNotNull(factory1); 54 | 55 | SSLPinningX509TrustManager trustManager = new SSLPinningX509TrustManager(store); 56 | SSLSocketFactory factory2 = SSLPinningIntegration.createSSLPinningSocketFactory(trustManager); 57 | Assert.assertNotNull(factory2); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/DefaultUpdateObserver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration 18 | 19 | import androidx.annotation.MainThread 20 | import com.wultra.android.sslpinning.UpdateObserver 21 | import com.wultra.android.sslpinning.UpdateResult 22 | import com.wultra.android.sslpinning.UpdateType 23 | 24 | /** 25 | * Utility [UpdateObserver] that simplifies integration of CertStore updates. 26 | * 27 | * It continues execution that's supposed to happen after successful 28 | * [com.wultra.android.sslpinning.CertStore] update. 29 | * @author Tomas Kypta, tomas.kypta@wultra.com 30 | * 31 | * @since 0.9.0 32 | */ 33 | abstract class DefaultUpdateObserver : UpdateObserver { 34 | 35 | override fun onUpdateStarted(type: UpdateType) { 36 | when (type) { 37 | UpdateType.NO_UPDATE, 38 | UpdateType.SILENT -> 39 | continueExecution() 40 | else -> {} 41 | } 42 | } 43 | 44 | override fun onUpdateFinished(type: UpdateType, result: UpdateResult) { 45 | if (result == UpdateResult.OK) { 46 | if (type == UpdateType.DIRECT) { 47 | continueExecution() 48 | } 49 | } else { 50 | handleFailedUpdate(type, result) 51 | } 52 | } 53 | 54 | /** 55 | * Method to contain code that's supposed to run after successful CertStore update. 56 | * 57 | * The method is invoked when it's safe to continue with the execution. 58 | * That means after finished [UpdateType.DIRECT] or after started 59 | * [UpdateType.SILENT], [UpdateType.NO_UPDATE]. 60 | * 61 | * The method is called on the main thread. 62 | */ 63 | @MainThread 64 | abstract fun continueExecution() 65 | 66 | /** 67 | * Common handling of failed update (not [UpdateResult.OK]). 68 | * 69 | * The method is called on the main thread. 70 | * 71 | * @param type Type of update 72 | * @param result Result of the update. 73 | */ 74 | @MainThread 75 | open fun handleFailedUpdate(type: UpdateType, result: UpdateResult) {} 76 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/service/RemoteDataProvider.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.service 18 | 19 | import androidx.annotation.WorkerThread 20 | 21 | /** 22 | * Abstract remote APIs of SSL pinning library. 23 | * Defines interface for getting fingerprints from remote server. 24 | * 25 | * @author Tomas Kypta, tomas.kypta@wultra.com 26 | */ 27 | interface RemoteDataProvider { 28 | 29 | /** 30 | * Gets data containing fingerprints from the remote server. 31 | * 32 | * Always invoke on a worker thread. 33 | * @param request Request object 34 | * @return Response object. 35 | */ 36 | @WorkerThread 37 | fun getFingerprints(request: RemoteDataRequest): RemoteDataResponse 38 | } 39 | 40 | /** 41 | * Class contains information required for constructing HTTP request to acquire data 42 | * from the remote server. 43 | */ 44 | data class RemoteDataRequest(val requestHeaders: Map) 45 | 46 | /** 47 | * Class contains response received from the server. Note that response headers contains lowercase 48 | * header names. 49 | */ 50 | data class RemoteDataResponse( 51 | /** 52 | * HTTP response code. 53 | */ 54 | val responseCode: Int, 55 | /** 56 | * Response headers. 57 | */ 58 | val responseHeaders: Map, 59 | /** 60 | * Received data. 61 | */ 62 | val data: ByteArray 63 | ) { 64 | override fun equals(other: Any?): Boolean { 65 | if (this === other) return true 66 | if (javaClass != other?.javaClass) return false 67 | 68 | other as RemoteDataResponse 69 | 70 | if (responseCode != other.responseCode) return false 71 | if (responseHeaders != other.responseHeaders) return false 72 | if (!data.contentEquals(other.data)) return false 73 | 74 | return true 75 | } 76 | 77 | override fun hashCode(): Int { 78 | var result = responseCode 79 | result = 31 * result + responseHeaders.hashCode() 80 | result = 31 * result + data.contentHashCode() 81 | return result 82 | } 83 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/powerauth/PowerAuthCryptoProvider.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration.powerauth 18 | 19 | import com.wultra.android.sslpinning.interfaces.CryptoProvider 20 | import com.wultra.android.sslpinning.interfaces.ECPublicKey 21 | import com.wultra.android.sslpinning.interfaces.SignedData 22 | import io.getlime.security.powerauth.core.CryptoUtils 23 | import io.getlime.security.powerauth.core.EcPublicKey 24 | import java.lang.IllegalArgumentException 25 | import java.security.SecureRandom 26 | 27 | /** 28 | * Implementation of [CryptoProvider] using crypto function provided by PowerAuthSDK. 29 | * If your app is already using PowerAuth, this is the recommended implementation for you. 30 | * 31 | * @author Tomas Kypta, tomas.kypta@wultra.com 32 | */ 33 | class PowerAuthCryptoProvider : CryptoProvider { 34 | 35 | private val randomGenerator = SecureRandom() 36 | 37 | override fun ecdsaValidateSignature(signedData: SignedData, publicKey: ECPublicKey): Boolean { 38 | val ecKey = publicKey as? PA2ECPublicKey ?: throw IllegalArgumentException("Invalid ECPublicKey object.") 39 | return CryptoUtils.ecdsaValidateSignature(signedData.data, signedData.signature, ecKey.ecPublicKey) 40 | } 41 | 42 | override fun importECPublicKey(publicKey: ByteArray): ECPublicKey? { 43 | // TODO consider validation of the data 44 | return PA2ECPublicKey(EcPublicKey(publicKey)) 45 | } 46 | 47 | override fun hashSha256(data: ByteArray): ByteArray { 48 | return CryptoUtils.hashSha256(data) 49 | } 50 | 51 | override fun getRandomData(length: Int): ByteArray { 52 | val bytes = ByteArray(length) 53 | randomGenerator.nextBytes(bytes) 54 | return bytes 55 | } 56 | } 57 | 58 | /** 59 | * An implementation `ECPublicKey` protocol of a public key in EC based cryptography 60 | * done with PowerAuth. PowerAuth is using NIST P-256 curve under the hood. 61 | * 62 | * @param ecPublicKey PowerAuth representation of public key for elliptic curve based cryptography routines. 63 | */ 64 | data class PA2ECPublicKey(val ecPublicKey: EcPublicKey) : ECPublicKey -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/CommonTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import android.util.Base64 20 | import androidx.test.platform.app.InstrumentationRegistry 21 | import com.wultra.android.sslpinning.service.WultraDebug 22 | import org.junit.Before 23 | import java.io.File 24 | import java.net.URL 25 | 26 | /** 27 | * Common instrumentation test setup. 28 | * 29 | * @author Tomas Kypta, tomas.kypta@wultra.com 30 | */ 31 | abstract class CommonTest { 32 | 33 | private lateinit var baseUrl: String 34 | private lateinit var appName: String 35 | protected lateinit var pubKey: ByteArray 36 | protected val serviceUrl by lazy { URL("$baseUrl/app/init?appName=$appName") } 37 | 38 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 39 | 40 | @Before 41 | open fun setUp() { 42 | WultraDebug.loggingLevel = WultraDebug.WultraLoggingLevel.DEBUG 43 | clearStorage() 44 | baseUrl = InstrumentationRegistry.getArguments().getString("test.sslPinning.baseUrl") ?: throw IllegalArgumentException("Missing test.sslPinning.baseUrl parameter for tests") 45 | appName = InstrumentationRegistry.getArguments().getString("test.sslPinning.appName") ?: throw IllegalArgumentException("Missing test.sslPinning.appName parameter for tests") 46 | pubKey = getPublicKeyFromServer() 47 | } 48 | 49 | private data class PublicKeyResponse(var publicKey: String) 50 | 51 | private fun getPublicKeyFromServer(): ByteArray { 52 | val url = URL("$baseUrl/app/init/public-key?appName=$appName") 53 | val responseString = url.readText(Charsets.UTF_8) 54 | val responseObject = GSON.fromJson(responseString, PublicKeyResponse::class.java) 55 | return Base64.decode(responseObject.publicKey, Base64.NO_WRAP) 56 | } 57 | 58 | fun validFingerprintJsonResponse() = """{ "fingerprints": [ ${readAssetFile("valid_github.json")} ] }""".toByteArray() 59 | 60 | private fun readAssetFile(fileName: String): String { 61 | val context = InstrumentationRegistry.getInstrumentation().context 62 | val inputStream = context.assets.open(fileName) 63 | return inputStream.bufferedReader().use { it.readText() } 64 | } 65 | } -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/CertStoreValidateTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import androidx.test.ext.junit.runners.AndroidJUnit4 20 | import com.wultra.android.sslpinning.integration.DefaultCryptoProvider 21 | import com.wultra.android.sslpinning.integration.DefaultSecureDataStore 22 | import com.wultra.android.sslpinning.integration.powerauth.powerAuthCertStore 23 | import com.wultra.android.sslpinning.model.GetFingerprintResponse 24 | import org.junit.Assert 25 | import org.junit.Test 26 | import org.junit.runner.RunWith 27 | import java.util.UUID 28 | 29 | /** 30 | * Test [CertStore] validation. 31 | * 32 | * @author Tomas Kypta, tomas.kypta@wultra.com 33 | */ 34 | @RunWith(AndroidJUnit4::class) 35 | class CertStoreValidateTest : CommonTest() { 36 | 37 | @Test 38 | fun validateWithFallback() { 39 | val fallbackFingerprints = GSON.fromJson(validFingerprintJsonResponse().decodeToString(), GetFingerprintResponse::class.java) 40 | val config = CertStoreConfiguration.Builder(serviceUrl, pubKey) 41 | .useChallenge(true) 42 | .fallbackCertificates(fallbackFingerprints) 43 | .build() 44 | val cert = getCertificateFromUrl("https://github.com") 45 | 46 | arrayOf( 47 | CertStore.powerAuthCertStore(config, appContext), 48 | CertStore(config, DefaultCryptoProvider(), DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 49 | ).forEach { store -> 50 | val result = store.validateCertificate(cert) 51 | Assert.assertEquals(ValidationResult.TRUSTED, result) 52 | } 53 | } 54 | 55 | @Test 56 | fun validateAfterUpdate() { 57 | val config = CertStoreConfiguration.Builder(serviceUrl, pubKey).useChallenge(true).build() 58 | val cert = getCertificateFromUrl("https://github.com") 59 | 60 | arrayOf( 61 | CertStore.powerAuthCertStore(config, appContext), 62 | CertStore(config, DefaultCryptoProvider(), DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 63 | ).forEach { store -> 64 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 65 | val result = store.validateCertificate(cert) 66 | Assert.assertEquals(ValidationResult.TRUSTED, result) 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/util/DistinguishedNameParserTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.util; 18 | 19 | import com.wultra.android.sslpinning.TestUtils; 20 | 21 | import org.junit.Assert; 22 | import org.junit.Ignore; 23 | import org.junit.Test; 24 | 25 | import java.io.IOException; 26 | import java.security.cert.X509Certificate; 27 | 28 | import javax.security.auth.x500.X500Principal; 29 | 30 | /** 31 | * @author Tomas Kypta, tomas.kypta@wultra.com 32 | */ 33 | public class DistinguishedNameParserTest { 34 | 35 | @Test 36 | public void testGithubCNFromUrl() throws Exception { 37 | testCNForURL("github.com", "https://github.com"); 38 | } 39 | 40 | @Test 41 | public void testGoogleCNFromUrl() throws Exception { 42 | testCNForURL("www.google.com", "https://www.google.com"); 43 | } 44 | 45 | @Test 46 | public void testVariousDN() { 47 | testCNForDN("github.com", 48 | "CN=github.com, O=\"GitHub, Inc.\", L=San Francisco, ST=California, C=US, SERIALNUMBER=5157550, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US, OID.2.5.4.15=Private Organization"); 49 | testCNForDN("developer.android.com", 50 | "CN=developer.android.com, O=Google LLC, L=Mountain View, ST=California, C=US"); 51 | testCNForDN("twitter.com", 52 | "CN=twitter.com, OU=tsa_o Point of Presence, O=\"Twitter, Inc.\", L=San Francisco, ST=California, C=US"); 53 | testCNForDN("api.twitter.com", 54 | "CN=api.twitter.com, OU=tsa_o Point of Presence, O=\"Twitter, Inc.\", L=San Francisco, ST=California, C=US"); 55 | } 56 | 57 | private void testCNForURL(String expectedCN, String urlString) throws IOException { 58 | X509Certificate cert = TestUtils.getCertificateFromUrl(urlString); 59 | X500Principal principal = cert.getSubjectX500Principal(); 60 | String cn = new DistinguishedNameParser(principal).findMostSpecific("CN"); 61 | Assert.assertEquals(expectedCN, cn); 62 | } 63 | 64 | private void testCNForDN(String expectedCN, String providedDN) { 65 | X500Principal principal = new X500Principal(providedDN); 66 | String cn = new DistinguishedNameParser(principal).findMostSpecific("CN"); 67 | Assert.assertEquals(expectedCN, cn); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/SecureDataStoreTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import androidx.test.platform.app.InstrumentationRegistry 20 | import androidx.test.ext.junit.runners.AndroidJUnit4 21 | import com.wultra.android.sslpinning.integration.DefaultSecureDataStore 22 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthSecureDataStore 23 | import com.wultra.android.sslpinning.interfaces.SecureDataStore 24 | import org.junit.Test 25 | 26 | import org.junit.Assert.* 27 | import org.junit.Before 28 | import org.junit.runner.RunWith 29 | import java.util.UUID 30 | 31 | /** 32 | * Test [PowerAuthSecureDataStore] methods. 33 | */ 34 | @RunWith(AndroidJUnit4::class) 35 | class SecureDataStoreTest { 36 | 37 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 38 | 39 | lateinit var dataStores: Array 40 | 41 | companion object { 42 | private const val key = "a-key" 43 | } 44 | @Before 45 | fun setUp() { 46 | dataStores = arrayOf( 47 | PowerAuthSecureDataStore(appContext, UUID.randomUUID().toString()), 48 | DefaultSecureDataStore(appContext, UUID.randomUUID().toString()) 49 | ) 50 | dataStores.forEach { it.remove(key) } 51 | } 52 | 53 | @Test 54 | fun saveAndLoad() { 55 | dataStores.forEach { store -> 56 | val loadedBoforeSave = store.load(key) 57 | assertNull(loadedBoforeSave) 58 | 59 | val data = "hello".toByteArray() 60 | store.save(data, key) 61 | 62 | val loadedData = store.load(key) 63 | assertArrayEquals(data, loadedData) 64 | 65 | val data2 = "world".toByteArray() 66 | store.save(data2, key) 67 | 68 | val loadedData2 = store.load(key) 69 | assertArrayEquals(data2, loadedData2) 70 | } 71 | } 72 | 73 | @Test 74 | fun remove() { 75 | dataStores.forEach { store -> 76 | val key = "a-key" 77 | store.remove(key) 78 | val loaded = store.load(key) 79 | assertNull(loaded) 80 | 81 | val data = "lorem ipsum".toByteArray() 82 | store.save(data, key) 83 | val loadedAfterSave = store.load(key) 84 | assertArrayEquals(data, loadedAfterSave) 85 | 86 | store.remove(key) 87 | val loadedAfterRemoval = store.load(key) 88 | assertNull(loadedAfterRemoval) 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/model/CachedDataTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.model 18 | 19 | import android.util.Base64 20 | import com.wultra.android.sslpinning.CertStore 21 | import io.mockk.every 22 | import io.mockk.mockkStatic 23 | import io.mockk.unmockkAll 24 | import org.junit.After 25 | import org.junit.Assert 26 | import org.junit.Before 27 | import org.junit.Test 28 | import java.util.Date 29 | 30 | /** 31 | * 32 | */ 33 | internal class CachedDataTest { 34 | @Before 35 | fun setUp() { 36 | mockkStatic(Base64::class) 37 | every { Base64.decode(any(), any()) } answers { 38 | java.util.Base64.getDecoder().decode(it.invocation.args[0] as String) 39 | } 40 | } 41 | 42 | @After 43 | fun tearDown() { 44 | unmockkAll() 45 | } 46 | 47 | @Test 48 | fun testEntries() { 49 | // the 1st item has different signature, otherwise the data are the same 50 | val jsonData = listOf("""{"fingerprints": [ 51 | { 52 | "name" : "github.com", 53 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 54 | "expires" : 1710460799, 55 | "signature" : "MEQCIElYrNRc/RnIJTFM9Or90Op+5YfEc+OA0JCOzEdewx07AiAm/xAKMkhu9k9mXNFNyUSB/A1FbnqKEegpEpsugY5Z/Q==" 56 | } 57 | ]}""", """{"fingerprints": [ 58 | { 59 | "name" : "github.com", 60 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 61 | "expires" : 1710460799, 62 | "signature" : "MEUCIQDGSbss+QVvF5juP3y7/DkUPYIWopabdHrZETGqYMctLgIgX7aKQ8+22AIlmuWczXZKze4w20ycsKzaps4reobjikA=" 63 | } 64 | ]}""") 65 | 66 | val responses = jsonData.map { 67 | CertStore.GSON.fromJson(it, GetFingerprintResponse::class.java) 68 | } 69 | responses.forEach { 70 | Assert.assertEquals(1, it.fingerprints.size) 71 | } 72 | 73 | val date = Date() 74 | val cachedDatas = responses.map { 75 | val certInfos = it.fingerprints.map { entry -> CertificateInfo(entry) }.toTypedArray() 76 | Assert.assertEquals(1, certInfos.size) 77 | CachedData(certInfos, date) 78 | } 79 | 80 | Assert.assertEquals(2, cachedDatas.size) 81 | Assert.assertEquals(cachedDatas[0], cachedDatas[1]) 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/powerauth/PowerAuthIntegration.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration.powerauth 18 | 19 | import android.content.Context 20 | import com.wultra.android.sslpinning.CertStore 21 | import com.wultra.android.sslpinning.CertStoreConfiguration 22 | 23 | /** 24 | * Creates a new instance of [CertStore] preconfigured with 25 | * [com.wultra.android.sslpinning.interfaces.CryptoProvider] 26 | * and [com.wultra.android.sslpinning.interfaces.SecureDataStore] 27 | * implemented on top of PowerAuthSDK. 28 | * 29 | * @param configuration Configuration for [CertStore] 30 | * @param context Application context 31 | * @param keychainIdentifier Identifier for the data store. Used to distinguish multiple instances. 32 | * @return New instance of [CertStore]. 33 | * 34 | * @author Tomas Kypta, tomas.kypta@wultra.com 35 | */ 36 | fun CertStore.Companion.powerAuthCertStore(configuration: CertStoreConfiguration, 37 | context: Context, 38 | keychainIdentifier: String? = null): CertStore { 39 | val secureDataStore = if (keychainIdentifier == null) { 40 | PowerAuthSecureDataStore(context) 41 | } else { 42 | PowerAuthSecureDataStore(context, keychainIdentifier) 43 | } 44 | return CertStore(configuration = configuration, 45 | cryptoProvider = PowerAuthCryptoProvider(), 46 | secureDataStore = secureDataStore) 47 | } 48 | 49 | /** 50 | * Compatibility API mainly for usage from Java. 51 | * 52 | * Allows calling `CertStore.powerAuthCertStore()` with `PowerAuthCertStore.createInstance()`. 53 | */ 54 | class PowerAuthCertStore { 55 | companion object { 56 | 57 | /** 58 | * Creates a new instance of [CertStore] preconfigured with 59 | * [com.wultra.android.sslpinning.interfaces.CryptoProvider] 60 | * and [com.wultra.android.sslpinning.interfaces.SecureDataStore] 61 | * implemented on top of PowerAuthSDK. 62 | * 63 | * @param configuration Configuration for [CertStore] 64 | * @param context Application context 65 | * @param keychainIdentifier Identifier for the data store. Used to distinguish multiple instances. 66 | * @return New instance of [CertStore]. 67 | */ 68 | @JvmStatic 69 | @JvmOverloads 70 | fun createInstance(configuration: CertStoreConfiguration, 71 | context: Context, 72 | keychainIdentifier: String? = null): CertStore { 73 | return CertStore.powerAuthCertStore(configuration, context, keychainIdentifier) 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/integration/powerauth/PowerAuthIntegrationTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration.powerauth; 18 | 19 | import com.wultra.android.sslpinning.CertStore; 20 | import com.wultra.android.sslpinning.CertStoreConfiguration; 21 | import com.wultra.android.sslpinning.CommonKotlinTest; 22 | import com.wultra.android.sslpinning.TestUtils; 23 | 24 | import org.junit.Assert; 25 | import org.junit.Test; 26 | 27 | import java.net.URL; 28 | 29 | /** 30 | * Testing format of Java compatible APIs. 31 | * 32 | * @author Tomas Kypta, tomas.kypta@wultra.com 33 | */ 34 | public class PowerAuthIntegrationTest extends CommonKotlinTest { 35 | 36 | @Test 37 | public void testPowerAuthCertStoreApis() throws Exception { 38 | URL url = new URL("https://gist.githubusercontent.com/hvge/7c5a3f9ac50332a52aa974d90ea2408c/raw/c5b021db0fcd40b1262ab513bf375e4641834925/ssl-pinning-signatures.json"); 39 | String publicKey = "BC3kV9OIDnMuVoCdDR9nEA/JidJLTTDLuSA2TSZsGgODSshfbZg31MS90WC/HdbU/A5WL5GmyDkE/iks6INv+XE="; 40 | byte[] publicKeyBytes = java.util.Base64.getDecoder().decode(publicKey); 41 | 42 | CertStoreConfiguration configuration = new CertStoreConfiguration.Builder(url, publicKeyBytes) 43 | .build(); 44 | CertStore store1 = PowerAuthCertStore.Companion.createInstance(configuration, context, null); 45 | TestUtils.assignHandler(store1, handler); 46 | Assert.assertNotNull(store1); 47 | CertStore store2 = PowerAuthCertStore.createInstance(configuration, context, null); 48 | TestUtils.assignHandler(store2, handler); 49 | Assert.assertNotNull(store2); 50 | CertStore store3 = PowerAuthCertStore.createInstance(configuration, context); 51 | TestUtils.assignHandler(store3, handler); 52 | Assert.assertNotNull(store3); 53 | 54 | // Kotlin API inconvenient for calling from Java 55 | CertStore store4 = PowerAuthIntegrationKt.powerAuthCertStore(CertStore.Companion, configuration, context, ""); 56 | TestUtils.assignHandler(store4, handler); 57 | Assert.assertNotNull(store4); 58 | } 59 | 60 | @Test 61 | public void testPowerAuthSecureDataStoreApis() { 62 | PowerAuthSecureDataStore secureDataStore1 = new PowerAuthSecureDataStore(context, ""); 63 | Assert.assertNotNull(secureDataStore1); 64 | PowerAuthSecureDataStore secureDataStore2 = new PowerAuthSecureDataStore(context); 65 | Assert.assertNotNull(secureDataStore2); 66 | } 67 | 68 | @Test(expected = NullPointerException.class) 69 | public void testPowerAuthSecureDataStoreApisCrash() { 70 | new PowerAuthSecureDataStore(context, null); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/model/CertificateInfoTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.model 18 | 19 | import android.util.Base64 20 | import com.wultra.android.sslpinning.CertStore 21 | import io.mockk.every 22 | import io.mockk.mockkStatic 23 | import io.mockk.unmockkAll 24 | import junit.framework.TestCase.assertTrue 25 | import org.junit.After 26 | import org.junit.Assert 27 | import org.junit.Before 28 | import org.junit.Test 29 | 30 | 31 | /** 32 | * 33 | */ 34 | internal class CertificateInfoTest { 35 | 36 | @Before 37 | fun setUp() { 38 | mockkStatic(Base64::class) 39 | every { Base64.decode(any(), any()) } answers { 40 | java.util.Base64.getDecoder().decode(it.invocation.args[0] as String) 41 | } 42 | } 43 | 44 | @After 45 | fun tearDown() { 46 | unmockkAll() 47 | } 48 | 49 | @Test 50 | fun testIndexOf() { 51 | val certList = mutableListOf() 52 | 53 | // different signatures of the same fingerprints 54 | // simulates updates with different data 55 | val jsonData = listOf("""{"fingerprints": [ 56 | { 57 | "name" : "github.com", 58 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 59 | "expires" : 1710460799, 60 | "signature" : "MEQCIElYrNRc/RnIJTFM9Or90Op+5YfEc+OA0JCOzEdewx07AiAm/xAKMkhu9k9mXNFNyUSB/A1FbnqKEegpEpsugY5Z/Q==" 61 | } 62 | ]}""", """{"fingerprints": [ 63 | { 64 | "name" : "github.com", 65 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 66 | "expires" : 1710460799, 67 | "signature" : "MEUCIQDGSbss+QVvF5juP3y7/DkUPYIWopabdHrZETGqYMctLgIgX7aKQ8+22AIlmuWczXZKze4w20ycsKzaps4reobjikA=" 68 | } 69 | ]}""") 70 | 71 | // we add the first one 72 | val response = CertStore.GSON.fromJson(jsonData[0], GetFingerprintResponse::class.java) 73 | Assert.assertEquals(1, response.fingerprints.size) 74 | for (entry in response.fingerprints) { 75 | certList.add(CertificateInfo(entry)) 76 | } 77 | 78 | for (json in jsonData) { 79 | val resp = CertStore.GSON.fromJson(json, GetFingerprintResponse::class.java) 80 | Assert.assertEquals(1, resp.fingerprints.size) 81 | val ci = CertificateInfo(resp.fingerprints[0]) 82 | // the data should pre already present 83 | val idx = certList.indexOf(ci) 84 | assertTrue(idx != -1) 85 | assertTrue(certList.contains(ci)) 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/DefaultCryptoProvider.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025, Wultra s.r.o. (www.wultra.com). 3 | * 4 | * All rights reserved. This source code can be used only for purposes specified 5 | * by the given license contract signed by the rightful deputy of Wultra s.r.o. 6 | * This source code can be used only by the owner of the license. 7 | * 8 | * Any disputes arising in respect of this agreement (license) shall be brought 9 | * before the Municipal Court of Prague. 10 | */ 11 | 12 | package com.wultra.android.sslpinning.integration 13 | 14 | import com.wultra.android.sslpinning.interfaces.CryptoProvider 15 | import com.wultra.android.sslpinning.interfaces.ECPublicKey 16 | import com.wultra.android.sslpinning.interfaces.SignedData 17 | import com.wultra.android.sslpinning.service.WultraDebug 18 | import java.math.BigInteger 19 | import java.security.* 20 | import java.security.spec.* 21 | 22 | class DefaultCryptoProvider: CryptoProvider { 23 | 24 | private val randomGenerator = SecureRandom() 25 | 26 | override fun ecdsaValidateSignature(signedData: SignedData, publicKey: ECPublicKey): Boolean { 27 | val exKey = publicKey as? DefaultProviderPublicKey ?: throw IllegalArgumentException("Invalid ECPublicKey object.") 28 | return try { 29 | val signature = Signature.getInstance("SHA256withECDSA") 30 | signature.initVerify(exKey.publicKey) 31 | signature.update(signedData.data) 32 | signature.verify(signedData.signature) 33 | } catch (e: Exception) { 34 | e.printStackTrace() 35 | false 36 | } 37 | } 38 | 39 | override fun importECPublicKey(publicKey: ByteArray): ECPublicKey? { 40 | return try { 41 | val x509key = convertX963ToX509(publicKey) // we expect X9.63 key 42 | val keyFactory = KeyFactory.getInstance("EC") 43 | val publicKeySpec = X509EncodedKeySpec(x509key) 44 | DefaultProviderPublicKey(keyFactory.generatePublic(publicKeySpec)) 45 | } catch (e: Exception) { 46 | WultraDebug.error("Failed to import EC public key: $e") 47 | null 48 | } 49 | } 50 | 51 | @Throws 52 | fun convertX963ToX509(x963Key: ByteArray, curveName: String = "secp256r1"): ByteArray { 53 | 54 | require(x963Key.isNotEmpty() && x963Key[0] == 0x04.toByte()) { "Invalid X9.63 key format" } 55 | 56 | val keySize = (x963Key.size - 1) / 2 57 | val x = BigInteger(1, x963Key.copyOfRange(1, 1 + keySize)) 58 | val y = BigInteger(1, x963Key.copyOfRange(1 + keySize, x963Key.size)) 59 | 60 | // Load EC curve parameters 61 | val params = AlgorithmParameters.getInstance("EC").apply { 62 | init(ECGenParameterSpec(curveName)) 63 | } 64 | val ecSpec = params.getParameterSpec(ECParameterSpec::class.java) 65 | 66 | // Create EC public key from X and Y coordinates 67 | val ecPoint = ECPoint(x, y) 68 | val publicKeySpec = ECPublicKeySpec(ecPoint, ecSpec) 69 | val keyFactory = KeyFactory.getInstance("EC") 70 | val publicKey = keyFactory.generatePublic(publicKeySpec) 71 | 72 | return publicKey.encoded // X.509 SPKI format 73 | } 74 | 75 | override fun hashSha256(data: ByteArray): ByteArray { 76 | val digest = MessageDigest.getInstance("SHA-256") 77 | return digest.digest(data) 78 | } 79 | 80 | override fun getRandomData(length: Int): ByteArray { 81 | val bytes = ByteArray(length) 82 | randomGenerator.nextBytes(bytes) 83 | return bytes 84 | } 85 | } 86 | 87 | private data class DefaultProviderPublicKey(val publicKey: PublicKey) : ECPublicKey 88 | -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/CertStoreUpdateTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import androidx.test.ext.junit.runners.AndroidJUnit4 20 | import android.util.Base64 21 | import com.wultra.android.sslpinning.integration.DefaultCryptoProvider 22 | import com.wultra.android.sslpinning.integration.DefaultSecureDataStore 23 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthCryptoProvider 24 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthSecureDataStore 25 | import com.wultra.android.sslpinning.integration.powerauth.powerAuthCertStore 26 | import org.junit.Test 27 | import org.junit.runner.RunWith 28 | import java.util.UUID 29 | 30 | /** 31 | * Instrumentation test for update signature validation on a device/emulator. 32 | * 33 | * @author Tomas Kypta, tomas.kypta@wultra.com 34 | */ 35 | @RunWith(AndroidJUnit4::class) 36 | class CertStoreUpdateTest : CommonTest() { 37 | 38 | private lateinit var certStores: Array 39 | 40 | override fun setUp() { 41 | super.setUp() 42 | val config = CertStoreConfiguration.Builder(serviceUrl, pubKey).useChallenge(true).build() 43 | certStores = arrayOf( 44 | CertStore.powerAuthCertStore(config, appContext, UUID.randomUUID().toString()), 45 | CertStore(config, DefaultCryptoProvider(), DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 46 | ) 47 | } 48 | 49 | @Test 50 | fun testLocalUpdateSignatureGithub() { 51 | certStores.forEach { store -> 52 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 53 | } 54 | } 55 | 56 | @Test 57 | fun testRemoteUpdateSignatureGithub() { 58 | certStores.forEach { store -> 59 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK, UpdateType.DIRECT) 60 | } 61 | } 62 | 63 | @Test 64 | fun testRemoteUpdateSignatureGithubDefault() { 65 | certStores.forEach { store -> 66 | updateAndCheck(store, UpdateMode.DEFAULT, UpdateResult.OK, UpdateType.DIRECT) 67 | updateAndCheck(store, UpdateMode.DEFAULT, UpdateResult.OK, UpdateType.NO_UPDATE) 68 | } 69 | } 70 | 71 | @Test 72 | fun testRemoteUpdateSignatureGithub_InvalidSignature() { 73 | // intentionally different signature 74 | val publicKey = "BEG6g28LNWRcmdFzexSNTKPBYZnDtKrCyiExFKbktttfKAF7wG4Cx1Nycr5PwCoICG1dRseLyuDxUilAmppPxAo=" 75 | val publicKeyBytes = Base64.decode(publicKey, Base64.NO_WRAP) 76 | 77 | val config = CertStoreConfiguration.Builder(serviceUrl, publicKeyBytes).useChallenge(true).build() 78 | val customCertStores = arrayOf( 79 | CertStore.powerAuthCertStore(config, appContext, UUID.randomUUID().toString()), 80 | CertStore(config, DefaultCryptoProvider(), DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 81 | ) 82 | 83 | customCertStores.forEach { store -> 84 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.INVALID_SIGNATURE) 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/service/UpdateScheduler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.service 18 | 19 | import com.wultra.android.sslpinning.model.CertificateInfo 20 | import java.util.* 21 | import java.util.concurrent.TimeUnit 22 | 23 | /** 24 | * Helper class for calculating date of the next update. 25 | * 26 | * @property periodicUpdateIntervalMillis Defines interval between checks for update of certificate fingerprints 27 | * @property expirationUpdateThresholdMillis Define time window in milliseconds before a certificate expires 28 | * @property thresholdMultiplier A constant for calculating closer date when a certificate is going to expire soon. 29 | * Should be smaller than 1. 30 | * 31 | * @author Tomas Kypta, tomas.kypta@wultra.com 32 | */ 33 | internal class UpdateScheduler(private val periodicUpdateIntervalMillis: Long, 34 | private val expirationUpdateThresholdMillis: Long, 35 | private val thresholdMultiplier: Double) { 36 | 37 | 38 | /** 39 | * Calculates the next date for silent update. 40 | * 41 | * @param certificates List of certificates from which the next update is calculated. 42 | * @param currentDate Date from which to calculate the next update. 43 | */ 44 | fun scheduleNextUpdate(certificates: Array, 45 | currentDate: Date): Date { 46 | 47 | // At first, we will look for expired certificate with closest expiration date. 48 | // We will also ignore older entries for the same common name. We don't need to update frequently 49 | // once the replacement certificate is in database. 50 | val processedCommonNames = mutableSetOf() 51 | 52 | // set nextExpired to approximately +10 years 53 | var nextExpired = currentDate.time + TimeUnit.DAYS.toMillis(10L * 365) 54 | for (certificateInfo in certificates) { 55 | if (processedCommonNames.contains(certificateInfo.commonName)) { 56 | continue 57 | } 58 | processedCommonNames.add(certificateInfo.commonName) 59 | nextExpired = Math.min(nextExpired, certificateInfo.expires.time) 60 | } 61 | 62 | var nextExpiredIntervalMillis = nextExpired - currentDate.time 63 | if (nextExpiredIntervalMillis > 0) { 64 | if (nextExpiredIntervalMillis < expirationUpdateThresholdMillis) { 65 | // if we're below the threshold, don't wait for certificate expiration 66 | // ask server more often for update 67 | nextExpiredIntervalMillis = Math.round(nextExpiredIntervalMillis * thresholdMultiplier) 68 | } 69 | } else { 70 | // looks like the newest is already expired 71 | // set the scheduled date to current 72 | nextExpiredIntervalMillis = 0 73 | } 74 | 75 | // finally, choose between periodic update 76 | nextExpiredIntervalMillis = Math.min(nextExpiredIntervalMillis, periodicUpdateIntervalMillis) 77 | return Date(currentDate.time + nextExpiredIntervalMillis) 78 | } 79 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/model/GetFingerprintResponse.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.model 18 | 19 | import android.util.Base64 20 | import com.wultra.android.sslpinning.interfaces.SignedData 21 | import java.util.* 22 | import java.util.concurrent.TimeUnit 23 | 24 | /** 25 | * Data class for JSON response received from the server. 26 | * 27 | * @property fingerprints List of entry objects 28 | * @author Tomas Kypta, tomas.kypta@wultra.com 29 | */ 30 | data class GetFingerprintResponse(val fingerprints: Array) { 31 | 32 | /** 33 | * Data class for an item in JSON response received from the server. 34 | * 35 | * @property name Common name 36 | * @property fingerprint Fingerprint data 37 | * @property expires Expiration date 38 | * @property signature ECDSA signature, optional for servers that supports challenge in request 39 | * and provides signature for the whole response. 40 | */ 41 | data class Entry(val name: String, 42 | val fingerprint: ByteArray, 43 | val expires: Date, 44 | val signature: ByteArray?) { 45 | 46 | /** 47 | * Get normalized data which can be used for the signature validation. 48 | */ 49 | internal fun dataForSignature(): SignedData? { 50 | if (signature == null) { 51 | return null 52 | } 53 | val expirationTimestampInSeconds = TimeUnit.MILLISECONDS.toSeconds(expires.time) 54 | val fingerprintPart = String(Base64.encode(fingerprint, Base64.NO_WRAP)) 55 | val signedString = "${name}&${fingerprintPart}&${expirationTimestampInSeconds}" 56 | return SignedData(data = signedString.toByteArray(Charsets.UTF_8), signature = signature) 57 | } 58 | 59 | override fun equals(other: Any?): Boolean { 60 | if (this === other) return true 61 | if (javaClass != other?.javaClass) return false 62 | 63 | other as Entry 64 | 65 | if (name != other.name) return false 66 | if (!fingerprint.contentEquals(other.fingerprint)) return false 67 | if (expires != other.expires) return false 68 | if (signature != null) { 69 | if (other.signature == null) return false 70 | if (!signature.contentEquals(other.signature)) return false 71 | } else if (other.signature != null) return false 72 | 73 | return true 74 | } 75 | 76 | override fun hashCode(): Int { 77 | var result = name.hashCode() 78 | result = 31 * result + fingerprint.contentHashCode() 79 | result = 31 * result + expires.hashCode() 80 | result = 31 * result + (signature?.contentHashCode() ?: 0) 81 | return result 82 | } 83 | } 84 | 85 | override fun equals(other: Any?): Boolean { 86 | if (this === other) return true 87 | if (javaClass != other?.javaClass) return false 88 | 89 | other as GetFingerprintResponse 90 | 91 | if (!fingerprints.contentEquals(other.fingerprints)) return false 92 | 93 | return true 94 | } 95 | 96 | override fun hashCode(): Int { 97 | return fingerprints.contentHashCode() 98 | } 99 | } -------------------------------------------------------------------------------- /scripts/prepare-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | TOP=$(dirname $0) 6 | PROJECT_FOLDER="${TOP}/.." 7 | 8 | # required parameters 9 | URL="" 10 | APPNAME="" 11 | MUS_USERNAME="" 12 | MUS_PASSWORD="" 13 | 14 | TEST_CREDS_FILE="${PROJECT_FOLDER}/configs/private-integration-tests.properties" 15 | 16 | while [[ $# -gt 0 ]]; do 17 | opt="$1" 18 | case "$opt" in 19 | --url) 20 | URL=${2} 21 | shift 22 | ;; 23 | --app) 24 | APPNAME=${2} 25 | shift 26 | ;; 27 | --username) 28 | MUS_USERNAME=${2} 29 | shift 30 | ;; 31 | --password) 32 | MUS_PASSWORD=${2} 33 | shift 34 | ;; 35 | *) 36 | echo "Unknown parameter ${1}" 37 | USAGE 1 38 | ;; 39 | esac 40 | shift 41 | done 42 | 43 | if [[ "${URL}" == "" || "${APPNAME}" == "" ]]; then 44 | echo "Missing parameter. Parameters '--url MUS_URL --app APP_NAME are required to provide." 45 | if [[ -f "${TEST_CREDS_FILE}" ]]; then 46 | echo "but test file ${TEST_CREDS_FILE} exists, so let's continue..." 47 | else 48 | exit 1 49 | fi 50 | else 51 | # create test file 52 | echo -e "test.sslPinning.baseUrl=${URL}\ntest.sslPinning.appName=${APPNAME}" > "${TEST_CREDS_FILE}" 53 | fi 54 | 55 | if [[ "${MUS_USERNAME}" == "" || "${MUS_PASSWORD}" == "" ]]; then 56 | echo "" 57 | echo "Missing parameter. Parameters '--username ADMIN_USERNAME --password ADMIN_PASSWORD' are needed to autoupdate the github.com certificate on the server - tests might fail." 58 | echo "" 59 | else 60 | # update github cert on the MUS server 61 | RENEW_CERT_URL="${URL}/admin/apps/${APPNAME}/certificates/auto" 62 | AUTH_TOKEN=$( echo -n "${MUS_USERNAME}:${MUS_PASSWORD}" | base64 ) 63 | JSON_BODY="{\"domain\":\"github.com\"}" 64 | 65 | echo "Calling ${RENEW_CERT_URL} url with ${JSON_BODY}" 66 | 67 | # not that even if the update failes on server logic (non-existing app for example), it will return 200 so the script will continue 68 | curl -X POST "${RENEW_CERT_URL}" \ 69 | -H "Content-Type: application/json" \ 70 | -H "Authorization: Basic ${AUTH_TOKEN}" \ 71 | -d "${JSON_BODY}" 72 | fi 73 | 74 | # create temp folder amd move into it 75 | FOLDER="${PROJECT_FOLDER}/pinningtool" 76 | mkdir -p "${FOLDER}" 77 | pushd "${FOLDER}" 78 | 79 | # set variables needed 80 | TOOL="ssl-pinning-tool.jar" 81 | KEYPAIR="keypair.pem" 82 | PASSWORD="password" 83 | CERT="cert.pem" 84 | OUTPUT="output.json" 85 | PUBKEY="pub.key" 86 | TEMPPUBKEY="temppub.key" 87 | 88 | # output for unit tests 89 | TESTFOLDER="../library/src/test/resources" 90 | TARGETJSONFILE="${TESTFOLDER}/valid_github.json" 91 | TARGETPUBKEYFILE="${TESTFOLDER}/pub.key" 92 | TARGETCERTFILE="${TESTFOLDER}/cert.pem" 93 | 94 | # output for android tests 95 | ANDROIDTESTFOLDER="../library/src/androidTest/assets" 96 | ANDROIDTARGETJSONFILE="${ANDROIDTESTFOLDER}/valid_github.json" 97 | 98 | # download pinning tool 99 | if ! [ -f "${TOOL}" ]; then 100 | curl -L -o "${TOOL}" "https://github.com/wultra/ssl-pinning-tool/releases/download/1.9.0/ssl-pinning-tool.jar" 101 | fi 102 | 103 | # download github certificate 104 | openssl s_client -showcerts -connect github.com:443 -servername github.com < /dev/null | openssl x509 -outform PEM > "${CERT}" 105 | 106 | # generate signing key pair 107 | java -jar "${TOOL}" keygen -o "${KEYPAIR}" -p "${PASSWORD}" 108 | 109 | # generate github signature JSON 110 | java -jar "${TOOL}" sign -k "${KEYPAIR}" -c "${CERT}" -o "${OUTPUT}" -p "${PASSWORD}" 111 | 112 | # generate public key 113 | java -jar "${TOOL}" export -k "${KEYPAIR}" -p "${PASSWORD}" 2> "${TEMPPUBKEY}" && awk -F' - ' '{print $2}' < "${TEMPPUBKEY}" > "${PUBKEY}" && rm "${TEMPPUBKEY}" 114 | 115 | # copy files to unit tests 116 | mkdir -p "${TESTFOLDER}" 117 | cp "${OUTPUT}" "${TARGETJSONFILE}" 118 | cp "${PUBKEY}" "${TARGETPUBKEYFILE}" 119 | cp "${CERT}" "${TARGETCERTFILE}" 120 | 121 | # copy files to android tests 122 | mkdir -p "${ANDROIDTESTFOLDER}" 123 | cp "${OUTPUT}" "${ANDROIDTARGETJSONFILE}" 124 | 125 | popd -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/ValidationObserverTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import com.wultra.android.sslpinning.TestUtils.Companion.assignHandler 20 | import com.wultra.android.sslpinning.service.RemoteDataProvider 21 | import com.wultra.android.sslpinning.service.RemoteDataResponse 22 | import io.mockk.every 23 | import io.mockk.just 24 | import io.mockk.mockk 25 | import io.mockk.runs 26 | import io.mockk.verify 27 | import org.junit.Assert.assertEquals 28 | import org.junit.Before 29 | import org.junit.Test 30 | import java.net.URL 31 | import java.util.* 32 | 33 | /** 34 | * Test global validation observers. 35 | * 36 | * @author Tomas Kypta, tomas.kypta@wultra.com 37 | */ 38 | class ValidationObserverTest : CommonKotlinTest() { 39 | 40 | @Test 41 | fun testValidationObservers() { 42 | 43 | val cert = TestUtils.githubCert() 44 | val certGoogle = TestUtils.getCertificateFromUrl("https://google.com") 45 | 46 | val publicKeyBytes = Base64.getDecoder().decode(TestUtils.testPubKey()) 47 | 48 | val remoteDataProvider: RemoteDataProvider = mockk() 49 | every { remoteDataProvider.getFingerprints(any()) } answers { 50 | RemoteDataResponse( 51 | 200, 52 | emptyMap(), 53 | TestUtils.validFingerprintJsonResponse() 54 | ) 55 | } 56 | 57 | val config = TestUtils.getCertStoreConfiguration( 58 | Date(), 59 | arrayOf("github.com"), 60 | URL("https://test"), 61 | publicKeyBytes, 62 | null 63 | ) 64 | 65 | val store = CertStore(config, cryptoProvider, secureDataStore, remoteDataProvider) 66 | assignHandler(store, handler) 67 | 68 | var observer: ValidationObserver = mockkValidationObserver() 69 | store.addValidationObserver(observer) 70 | val result = store.validateCertificate(cert) 71 | assertEquals(ValidationResult.EMPTY, result) 72 | verify(exactly = 0) { 73 | observer.onValidationUntrusted(any()) 74 | observer.onValidationTrusted(any()) 75 | } 76 | verify(exactly = 1) { 77 | observer.onValidationEmpty(any()) 78 | } 79 | store.removeValidationObserver(observer) 80 | 81 | TestUtils.updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 82 | 83 | observer = mockkValidationObserver() 84 | store.addValidationObserver(observer) 85 | val result2 = store.validateCertificate(cert) 86 | assertEquals(ValidationResult.TRUSTED, result2) 87 | verify(exactly = 0) { 88 | observer.onValidationUntrusted(any()) 89 | observer.onValidationEmpty(any()) 90 | } 91 | verify(exactly = 1) { 92 | observer.onValidationTrusted(any()) 93 | } 94 | store.removeAllValidationObservers() 95 | 96 | observer = mockkValidationObserver() 97 | store.addValidationObserver(observer) 98 | val result3 = store.validateCertificate(certGoogle) 99 | assertEquals(ValidationResult.UNTRUSTED, result3) 100 | verify(exactly = 0) { 101 | observer.onValidationEmpty(any()) 102 | observer.onValidationTrusted(any()) 103 | } 104 | verify(exactly = 1) { 105 | observer.onValidationUntrusted(any()) 106 | } 107 | store.removeAllValidationObservers() 108 | } 109 | 110 | private fun mockkValidationObserver(): ValidationObserver { 111 | val observer: ValidationObserver = mockk() 112 | every { observer.onValidationEmpty(any()) } just runs 113 | every { observer.onValidationTrusted(any()) } just runs 114 | every { observer.onValidationUntrusted(any()) } just runs 115 | return observer 116 | } 117 | } -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/CertStoreLoadSaveTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import androidx.test.ext.junit.runners.AndroidJUnit4 20 | import com.wultra.android.sslpinning.integration.DefaultCryptoProvider 21 | import com.wultra.android.sslpinning.integration.DefaultSecureDataStore 22 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthCryptoProvider 23 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthSecureDataStore 24 | import com.wultra.android.sslpinning.integration.powerauth.powerAuthCertStore 25 | import com.wultra.android.sslpinning.model.CachedData 26 | import com.wultra.android.sslpinning.model.CertificateInfo 27 | import org.junit.Assert 28 | import org.junit.Test 29 | import org.junit.runner.RunWith 30 | import java.util.* 31 | 32 | /** 33 | * Test saving and loading of caches. 34 | * 35 | * @author Tomas Kypta, tomas.kypta@wultra.com 36 | */ 37 | @RunWith(AndroidJUnit4::class) 38 | class CertStoreLoadSaveTest : CommonTest() { 39 | 40 | lateinit var config: CertStoreConfiguration 41 | 42 | override fun setUp() { 43 | super.setUp() 44 | config = CertStoreConfiguration.Builder(serviceUrl, pubKey).useChallenge(true).build() 45 | } 46 | 47 | @Test 48 | fun testLoadingPreviouslySavedFingerprints() { 49 | 50 | // random UUID to ensure the stores are empty by default 51 | val uuid = UUID.randomUUID().toString() 52 | // we need to create the store several times with the same ID -> prepare "factories" 53 | val factories = arrayOf( 54 | { CertStore(config, DefaultCryptoProvider(), DefaultSecureDataStore(appContext, "${uuid}_default")) }, 55 | { CertStore(config, PowerAuthCryptoProvider(), PowerAuthSecureDataStore(appContext, "${uuid}_pa")) } 56 | ) 57 | 58 | factories.forEach { factory -> 59 | 60 | // create an new empty store 61 | val store = factory() 62 | 63 | val cert = getCertificateFromUrl("https://github.com") 64 | val result = store.validateCertificate(cert) 65 | 66 | // first test when store is empty 67 | Assert.assertEquals(ValidationResult.EMPTY, result) 68 | 69 | // update the store 70 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 71 | 72 | // test after update (we expect that the latest github cert is on the server) 73 | val result2 = store.validateCertificate(cert) 74 | Assert.assertEquals(ValidationResult.TRUSTED, result2) 75 | 76 | // create new store that loads saved data 77 | val store2 = factory() 78 | 79 | // new store with the same ID should work on the previously stored data and thus the cert. should be trusted 80 | val result3 = store2.validateCertificate(cert) 81 | Assert.assertEquals(ValidationResult.TRUSTED, result3) 82 | } 83 | } 84 | 85 | @Test 86 | fun testSaveAndLoad() { 87 | 88 | arrayOf( 89 | CertStore.powerAuthCertStore(config, appContext, UUID.randomUUID().toString()), 90 | CertStore(config, DefaultCryptoProvider(), DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 91 | ).forEach { store -> 92 | 93 | Assert.assertNull(store.loadCachedData()) 94 | 95 | val date = Date() 96 | val nextUpdate = Date(date.time + 10000) 97 | val certInfos = arrayOf( 98 | CertificateInfo("github.com", "aaa".toByteArray(), date), 99 | CertificateInfo("wultra.com", "bbb".toByteArray(), date) 100 | ) 101 | val data = CachedData(certInfos, nextUpdate) 102 | store.saveDataToCache(data) 103 | 104 | val loadedData = store.loadCachedData() 105 | Assert.assertNotNull(loadedData) 106 | Assert.assertEquals((nextUpdate.time / 1000) * 1000, loadedData!!.nextUpdate.time) 107 | Assert.assertEquals(2, loadedData.certificates.size) 108 | val ci = loadedData.certificates[0] 109 | Assert.assertEquals("github.com", ci.commonName) 110 | Assert.assertEquals("aaa", String(ci.fingerprint)) 111 | Assert.assertEquals((date.time / 1000) * 1000, ci.expires.time) 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/model/GetFingerprintResponseTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.model 18 | 19 | import android.util.Base64 20 | import com.wultra.android.sslpinning.CertStore 21 | import io.mockk.every 22 | import io.mockk.mockkStatic 23 | import io.mockk.unmockkAll 24 | import org.junit.After 25 | import org.junit.Assert 26 | import org.junit.Before 27 | import org.junit.Test 28 | 29 | 30 | /** 31 | * 32 | */ 33 | internal class GetFingerprintResponseTest { 34 | @Before 35 | fun setUp() { 36 | mockkStatic(Base64::class) 37 | every { Base64.decode(any(), any()) } answers { 38 | java.util.Base64.getDecoder().decode(it.invocation.args[0] as String) 39 | } 40 | } 41 | 42 | @After 43 | fun tearDown() { 44 | unmockkAll() 45 | } 46 | 47 | @Test 48 | fun testEntries() { 49 | // the 1st item has different signature, otherwise the data are the same 50 | val jsonData = """{"fingerprints": [ 51 | { 52 | "name" : "github.com", 53 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 54 | "expires" : 1710460799, 55 | "signature" : "MEQCIElYrNRc/RnIJTFM9Or90Op+5YfEc+OA0JCOzEdewx07AiAm/xAKMkhu9k9mXNFNyUSB/A1FbnqKEegpEpsugY5Z/Q==" 56 | },{ 57 | "name" : "github.com", 58 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 59 | "expires" : 1710460799, 60 | "signature" : "MEUCIQDGSbss+QVvF5juP3y7/DkUPYIWopabdHrZETGqYMctLgIgX7aKQ8+22AIlmuWczXZKze4w20ycsKzaps4reobjikA=" 61 | },{ 62 | "name" : "github.com", 63 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 64 | "expires" : 1710460799, 65 | "signature" : "MEUCIQDGSbss+QVvF5juP3y7/DkUPYIWopabdHrZETGqYMctLgIgX7aKQ8+22AIlmuWczXZKze4w20ycsKzaps4reobjikA=" 66 | } 67 | ]}""" 68 | 69 | // we should parse 3 items from it 70 | val response = CertStore.GSON.fromJson(jsonData, GetFingerprintResponse::class.java) 71 | Assert.assertEquals(3, response.fingerprints.size) 72 | 73 | // in set there should be just 2 entries 74 | val set = response.fingerprints.toSet() 75 | Assert.assertEquals(2, set.size) 76 | 77 | // but the CertificateInfo is just 1 78 | val certSet = mutableSetOf() 79 | for (entry in response.fingerprints) { 80 | certSet.add(CertificateInfo(entry)) 81 | } 82 | // now there should be just 1 item - one fingerprint 83 | Assert.assertEquals(1, certSet.size) 84 | } 85 | 86 | @Test 87 | fun testParse() { 88 | // the 1st item has different signature, otherwise the data are the same 89 | val jsonData = """{"fingerprints": [ 90 | { 91 | "name" : "github.com", 92 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 93 | "expires" : 1710460799, 94 | "signature" : "MEQCIElYrNRc/RnIJTFM9Or90Op+5YfEc+OA0JCOzEdewx07AiAm/xAKMkhu9k9mXNFNyUSB/A1FbnqKEegpEpsugY5Z/Q==" 95 | },{ 96 | "name" : "github.com", 97 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 98 | "expires" : 1710460799, 99 | "signature" : "MEUCIQDGSbss+QVvF5juP3y7/DkUPYIWopabdHrZETGqYMctLgIgX7aKQ8+22AIlmuWczXZKze4w20ycsKzaps4reobjikA=" 100 | },{ 101 | "name" : "github.com", 102 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 103 | "expires" : 1710460799, 104 | "signature" : "MEUCIQDGSbss+QVvF5juP3y7/DkUPYIWopabdHrZETGqYMctLgIgX7aKQ8+22AIlmuWczXZKze4w20ycsKzaps4reobjikA=" 105 | } 106 | ]}""" 107 | 108 | val response1 = CertStore.GSON.fromJson(jsonData, GetFingerprintResponse::class.java) 109 | val response2 = CertStore.GSON.fromJson(jsonData, GetFingerprintResponse::class.java) 110 | Assert.assertEquals(response1, response2) 111 | } 112 | } -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/TestUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import android.content.Context 20 | import android.util.Base64 21 | import androidx.test.platform.app.InstrumentationRegistry 22 | import com.google.gson.GsonBuilder 23 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthSecureDataStore 24 | import com.wultra.android.sslpinning.service.RemoteDataProvider 25 | import com.wultra.android.sslpinning.service.RemoteDataRequest 26 | import com.wultra.android.sslpinning.service.RemoteDataResponse 27 | import com.wultra.android.sslpinning.util.ByteArrayTypeAdapter 28 | import com.wultra.android.sslpinning.util.DateTypeAdapter 29 | import org.junit.Assert.assertEquals 30 | import org.junit.Assert.assertTrue 31 | import java.net.HttpURLConnection 32 | import java.net.URL 33 | import java.security.cert.X509Certificate 34 | import java.util.* 35 | import java.util.concurrent.CountDownLatch 36 | import java.util.concurrent.TimeUnit 37 | import javax.net.ssl.HttpsURLConnection 38 | 39 | /** 40 | * Utility properties and functions for instrumentation tests. 41 | * 42 | * @author Tomas Kypta, tomas.kypta@wultra.com 43 | */ 44 | val remoteDataProvider = getRemoteDataProvider() 45 | fun getRemoteDataProvider(json: String = jsonData): RemoteDataProvider { 46 | return object : RemoteDataProvider { 47 | override fun getFingerprints(request: RemoteDataRequest): RemoteDataResponse { 48 | return RemoteDataResponse(200, emptyMap(), json.toByteArray(Charsets.UTF_8)) 49 | } 50 | } 51 | } 52 | 53 | const val jsonData = """ 54 | { 55 | "fingerprints": [ 56 | { 57 | "name" : "github.com", 58 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 59 | "expires" : 1710460799, 60 | "signature" : "MEUCICB69UpMPOdtrsR6XcJqHEh2L2RO4oSJ3SZ7BYnTBJbGAiEAnZ7rEWdMVGwa59Wx5QbAorEFxXH89Iu0CnqWa96Eda0=" 61 | } 62 | ] 63 | } 64 | """ 65 | 66 | const val jsonDataFingerprintsEmpty = "{\"fingerprints\":[]}" 67 | const val jsonDataAllEmpty = "{}" 68 | 69 | const val publicKey = "BC3kV9OIDnMuVoCdDR9nEA/JidJLTTDLuSA2TSZsGgODSshfbZg31MS90WC/HdbU/A5WL5GmyDkE/iks6INv+XE=" 70 | fun getPublicKeyBytes(): ByteArray { 71 | return Base64.decode(publicKey, Base64.NO_WRAP) 72 | } 73 | 74 | val GSON = GsonBuilder() 75 | .registerTypeAdapter(ByteArray::class.java, ByteArrayTypeAdapter()) 76 | .registerTypeAdapter(Date::class.java, DateTypeAdapter()) 77 | .create()!! 78 | 79 | fun getCertificateFromUrl(urlString: String): X509Certificate { 80 | val url = URL(urlString) 81 | val httpURLConnection = url.openConnection() as HttpURLConnection 82 | val connection = httpURLConnection as HttpsURLConnection 83 | try { 84 | connection.connect() 85 | val certificates = connection.serverCertificates 86 | return certificates[0] as X509Certificate 87 | } finally { 88 | connection.disconnect() 89 | } 90 | } 91 | 92 | @Throws(Exception::class) 93 | @JvmOverloads 94 | fun updateAndCheck(store: CertStore, updateMode: UpdateMode, expectedUpdateResult: UpdateResult, expectedUpdateType: UpdateType? = null) { 95 | val initLatch = CountDownLatch(1) 96 | val latch = CountDownLatch(1) 97 | val updateResultWrapper = UpdateWrapperInstr() 98 | store.update(updateMode, object : UpdateObserver { 99 | override fun onUpdateStarted(type: UpdateType) { 100 | updateResultWrapper.updateType = type 101 | initLatch.countDown() 102 | } 103 | 104 | override fun onUpdateFinished(type: UpdateType, result: UpdateResult) { 105 | updateResultWrapper.updateResult = result 106 | latch.countDown() 107 | } 108 | }) 109 | initLatch.await(500, TimeUnit.MILLISECONDS) 110 | updateResultWrapper.updateType?.isPerformingUpdate?.let { 111 | assertTrue(latch.await(30, TimeUnit.SECONDS)) 112 | } 113 | assertEquals(expectedUpdateResult, updateResultWrapper.updateResult) 114 | expectedUpdateType?.let { 115 | assertEquals(it, updateResultWrapper.updateType) 116 | } 117 | } 118 | 119 | fun clearStorage() { 120 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 121 | appContext.getSharedPreferences(PowerAuthSecureDataStore.defaultKeychainIdentifier, Context.MODE_PRIVATE) 122 | .edit() 123 | .clear() 124 | .commit() 125 | } -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/CommonKotlinTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import android.content.Context 20 | import android.content.SharedPreferences 21 | import android.os.Handler 22 | import android.os.Looper 23 | import android.util.Base64 24 | import android.util.Log 25 | import com.wultra.android.sslpinning.interfaces.CryptoProvider 26 | import com.wultra.android.sslpinning.interfaces.SecureDataStore 27 | import com.wultra.android.sslpinning.interfaces.SignedData 28 | import io.getlime.security.powerauth.crypto.lib.util.KeyConvertor 29 | import io.getlime.security.powerauth.crypto.lib.util.SignatureUtils 30 | import io.mockk.MockKAnnotations 31 | import io.mockk.every 32 | import io.mockk.impl.annotations.MockK 33 | import io.mockk.mockkStatic 34 | import io.mockk.unmockkAll 35 | import org.bouncycastle.jce.provider.BouncyCastleProvider 36 | import org.junit.After 37 | import org.junit.Before 38 | import org.junit.BeforeClass 39 | import java.security.MessageDigest 40 | import java.security.Security 41 | 42 | /** 43 | * Common setup for Kotlin-based tests. 44 | * 45 | * @author Tomas Kypta, tomas.kypta@wultra.com 46 | */ 47 | open class CommonKotlinTest { 48 | 49 | @MockK 50 | lateinit var cryptoProvider: CryptoProvider 51 | 52 | @MockK 53 | lateinit var secureDataStore: SecureDataStore 54 | 55 | @MockK 56 | lateinit var handler: Handler 57 | 58 | @MockK 59 | lateinit var context: Context 60 | 61 | @MockK 62 | lateinit var sharedPrefs: SharedPreferences 63 | 64 | companion object { 65 | 66 | @BeforeClass 67 | @JvmStatic 68 | fun setUpClass() { 69 | Security.addProvider(BouncyCastleProvider()) 70 | } 71 | } 72 | 73 | @Before 74 | fun setUp() { 75 | MockKAnnotations.init(this, relaxUnitFun = true) 76 | 77 | mockkStatic(Base64::class) 78 | every { Base64.encodeToString(any(), any()) } answers { 79 | String(java.util.Base64.getEncoder().encode(it.invocation.args[0] as ByteArray)) 80 | } 81 | every { Base64.encode(any(), any()) } answers { 82 | java.util.Base64.getEncoder().encode(it.invocation.args[0] as ByteArray) 83 | } 84 | every { Base64.decode(any(), any()) } answers { 85 | java.util.Base64.getDecoder().decode(it.invocation.args[0] as String) 86 | } 87 | 88 | mockkStatic(Log::class) 89 | every { Log.e(any(), any()) } answers { 90 | println("error: ${it.invocation.args[1] as String}") 91 | 0 92 | } 93 | every { Log.w(any(), any()) } answers { 94 | println("warning: ${it.invocation.args[1] as String}") 95 | 0 96 | } 97 | 98 | every { cryptoProvider.hashSha256(any()) } answers { 99 | val digest = MessageDigest.getInstance("SHA-256") 100 | digest.digest(it.invocation.args[0] as ByteArray) 101 | } 102 | 103 | every { cryptoProvider.importECPublicKey(any()) } answers { 104 | TestPA2ECPublicKey(it.invocation.args[0] as ByteArray) 105 | } 106 | 107 | every { cryptoProvider.ecdsaValidateSignature(any(), any()) } answers { 108 | val utils = SignatureUtils() 109 | val signedData: SignedData = it.invocation.args[0] as SignedData 110 | val pubKey: TestPA2ECPublicKey = it.invocation.args[1] as TestPA2ECPublicKey 111 | val keyConvertor = KeyConvertor() 112 | utils.validateECDSASignature(signedData.data, 113 | signedData.signature, 114 | keyConvertor.convertBytesToPublicKey(pubKey.data)) 115 | } 116 | 117 | every { secureDataStore.load(any()) } returns null 118 | every { secureDataStore.save(any(), any()) } returns false 119 | 120 | mockkStatic(Looper::class) 121 | every { Looper.getMainLooper() } returns null 122 | 123 | every { handler.post(any()) } answers { 124 | val runnable = it.invocation.args[0] as Runnable 125 | runnable.run() 126 | true 127 | } 128 | 129 | every { context.applicationContext } returns context 130 | every { context.getSharedPreferences(any(), any()) } returns sharedPrefs 131 | 132 | every { sharedPrefs.getInt(any(), any()) } returns 0 133 | } 134 | 135 | @After 136 | fun tearDown() { 137 | unmockkAll() 138 | } 139 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/SslValidationStrategy.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import android.annotation.SuppressLint 20 | import java.security.KeyManagementException 21 | import java.security.NoSuchAlgorithmException 22 | import java.security.cert.X509Certificate 23 | import javax.net.ssl.* 24 | 25 | /** 26 | * Provides validation strategy how HTTPS requests initiated from this library should be handled. 27 | */ 28 | abstract class SslValidationStrategy { 29 | 30 | companion object { 31 | /** 32 | * All secure connections will be trusted. 33 | * 34 | * Be aware, that using this option will lead to use an unsafe implementation of `HostnameVerifier` 35 | * and `X509TrustManager` SSL client validation. This is useful for debug/testing purposes only, e.g. 36 | * when untrusted self-signed SSL certificate is used on server side. 37 | * 38 | * It's strictly recommended to use this option only in debug flavours of your application. 39 | * Deploying to production may cause "Security alert" in Google Developer Console. Please see 40 | * [this](https://support.google.com/faqs/answer/7188426) and 41 | * [this](https://support.google.com/faqs/answer/6346016) Google Help Center articles for more details. 42 | * Beginning 1 March 2017, Google Play will block publishing of any new apps or updates that use such 43 | * unsafe implementation of `HostnameVerifier`. 44 | * 45 | * How to solve this problem for debug/production flavours in gradle build script: 46 | * 47 | * 1. Define boolean type `buildConfigField` in flavour configuration. 48 | * ``` 49 | * productFlavors { 50 | * production { 51 | * buildConfigField 'boolean', 'TRUST_ALL_SSL_HOSTS', 'false' 52 | * } 53 | * debug { 54 | * buildConfigField 'boolean', 'TRUST_ALL_SSL_HOSTS', 'true' 55 | * } 56 | * } 57 | * ``` 58 | * 59 | * 2. In code use this conditional initialization for [CertStoreConfiguration.Builder]: 60 | * ```kotlin 61 | * val publicKey = Base64.decode("BMne....kdh2ak=", Base64.NO_WRAP) 62 | * val builder = CertStoreConfiguration.Builder( 63 | * serviceUrl = URL("https://localhost/..."), 64 | * publicKey = publicKey) 65 | * if (BuildConfig.TRUST_ALL_SSL_HOSTS) { 66 | * builder.sslValidationStrategy(SslValidationStrategy.noValidation()) 67 | * } 68 | * val configuration = builder.build() 69 | * ``` 70 | * 71 | * 3. Set `minifyEnabled` to `true` for release buildType to enable code shrinking with ProGuard. 72 | */ 73 | @JvmStatic 74 | fun noValidation(): SslValidationStrategy = NoSslValidationStrategy() 75 | } 76 | 77 | internal abstract fun sslSocketFactory(): SSLSocketFactory? 78 | internal abstract fun hostnameVerifier(): HostnameVerifier? 79 | } 80 | 81 | /** 82 | * Implements SSL validation strategy that trust any server certificate. 83 | * See [SslValidationStrategy.noValidation] for more details. 84 | */ 85 | @Suppress("CustomX509TrustManager") 86 | internal class NoSslValidationStrategy: SslValidationStrategy() { 87 | override fun sslSocketFactory(): SSLSocketFactory? { 88 | val trustAllCerts = Array(1) { object : X509TrustManager { 89 | @SuppressLint("TrustAllX509TrustManager") 90 | override fun checkClientTrusted(chain: Array?, authType: String?) { 91 | // Empty 92 | } 93 | 94 | @SuppressLint("TrustAllX509TrustManager") 95 | override fun checkServerTrusted(chain: Array?, authType: String?) { 96 | // Empty 97 | } 98 | 99 | override fun getAcceptedIssuers(): Array { 100 | return emptyArray() 101 | } 102 | }} 103 | return try { 104 | val context = SSLContext.getInstance("TLS") 105 | context.init(null, trustAllCerts, null) 106 | context.socketFactory 107 | } catch (e: NoSuchAlgorithmException) { 108 | null 109 | } catch (e: KeyManagementException) { 110 | null 111 | } 112 | } 113 | override fun hostnameVerifier(): HostnameVerifier? { 114 | return HostnameVerifier { _, _ -> true } 115 | } 116 | } -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/integration/powerauth/PowerAuthSslPinningValidationStrategyTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | package com.wultra.android.sslpinning.integration.powerauth 17 | 18 | import com.wultra.android.sslpinning.CertStore 19 | import com.wultra.android.sslpinning.CommonKotlinTest 20 | import com.wultra.android.sslpinning.TestUtils 21 | import com.wultra.android.sslpinning.UpdateMode 22 | import com.wultra.android.sslpinning.UpdateResult 23 | import com.wultra.android.sslpinning.service.RemoteDataProvider 24 | import com.wultra.android.sslpinning.service.RemoteDataResponse 25 | import io.getlime.security.powerauth.networking.ssl.HttpClientValidationStrategy 26 | import io.mockk.every 27 | import io.mockk.mockk 28 | import io.mockk.verify 29 | import org.junit.Assert 30 | import org.junit.Test 31 | import java.net.URL 32 | import java.util.Base64 33 | import java.util.Date 34 | import javax.net.ssl.HttpsURLConnection 35 | import javax.net.ssl.SSLHandshakeException 36 | 37 | /** 38 | * Unit test for PowerAuthSslPinningValidationStrategy. 39 | * 40 | * @author Tomas Kypta, tomas.kypta@wultra.com 41 | */ 42 | class PowerAuthSslPinningValidationStrategyTest : CommonKotlinTest() { 43 | @Test 44 | @Throws(Exception::class) 45 | fun testPowerAuthSslPinningValidationStrategyOnGithubSuccess() { 46 | val publicKeyBytes = Base64.getDecoder().decode(TestUtils.testPubKey()) 47 | val remoteDataProvider: RemoteDataProvider = mockk() 48 | every { remoteDataProvider.getFingerprints(any()) } answers { 49 | RemoteDataResponse( 50 | 200, 51 | emptyMap(), 52 | TestUtils.validFingerprintJsonResponse() 53 | ) 54 | } 55 | 56 | val config = TestUtils.getCertStoreConfiguration( 57 | Date(), 58 | arrayOf("github.com"), 59 | URL("https://test"), 60 | publicKeyBytes, 61 | null 62 | ) 63 | val store = CertStore(config, cryptoProvider, secureDataStore, remoteDataProvider) 64 | TestUtils.assignHandler(store, handler) 65 | TestUtils.updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 66 | val strategy: HttpClientValidationStrategy = PowerAuthSslPinningValidationStrategy(store) 67 | val url = URL("https://github.com") 68 | val urlConnection = url.openConnection() 69 | val sslConnection = urlConnection as HttpsURLConnection 70 | val sslSocketFactory = strategy.sslSocketFactory 71 | if (sslSocketFactory != null) { 72 | sslConnection.sslSocketFactory = sslSocketFactory 73 | } 74 | val hostnameVerifier = strategy.hostnameVerifier 75 | if (hostnameVerifier != null) { 76 | sslConnection.hostnameVerifier = hostnameVerifier 77 | } 78 | verify(exactly = 0) { cryptoProvider.hashSha256(any()) } 79 | sslConnection.connect() 80 | val response = sslConnection.responseCode 81 | Assert.assertEquals(2, (response / 100).toLong()) 82 | sslConnection.disconnect() 83 | verify(exactly = 1) { cryptoProvider.hashSha256(any()) } 84 | verify { secureDataStore.load(any()) } 85 | } 86 | 87 | @Test(expected = SSLHandshakeException::class) 88 | @Throws(Exception::class) 89 | fun testPowerAuthSslPinningValidationStrategyOnGithubFailure() { 90 | val publicKey = 91 | "BC3kV9OIDnMuVoCdDR9nEA/JidJLTTDLuSA2TSZsGgODSshfbZg31MS90WC/HdbU/A5WL5GmyDkE/iks6INv+XE=" 92 | val publicKeyBytes = Base64.getDecoder().decode(publicKey) 93 | val config = TestUtils.getCertStoreConfiguration( 94 | Date(), arrayOf("github.com"), 95 | URL("https://test.wultra.com"), 96 | publicKeyBytes, 97 | null 98 | ) 99 | val store = CertStore(config, cryptoProvider, secureDataStore) 100 | TestUtils.assignHandler(store, handler) 101 | val strategy: HttpClientValidationStrategy = PowerAuthSslPinningValidationStrategy(store) 102 | val url = URL("https://github.com") 103 | val urlConnection = url.openConnection() 104 | val sslConnection = urlConnection as HttpsURLConnection 105 | val sslSocketFactory = strategy.sslSocketFactory 106 | if (sslSocketFactory != null) { 107 | sslConnection.sslSocketFactory = sslSocketFactory 108 | } 109 | val hostnameVerifier = strategy.hostnameVerifier 110 | if (hostnameVerifier != null) { 111 | sslConnection.hostnameVerifier = hostnameVerifier 112 | } 113 | verify(exactly = 0) { cryptoProvider.hashSha256(any()) } 114 | sslConnection.connect() 115 | } 116 | } -------------------------------------------------------------------------------- /library/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.DefaultConfig 2 | import com.android.build.gradle.BaseExtension 3 | import java.io.FileInputStream 4 | import java.util.Properties 5 | 6 | /* 7 | * Copyright 2018 Wultra s.r.o. 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * http://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions 19 | * and limitations under the License. 20 | */ 21 | 22 | plugins { 23 | id("com.android.library") 24 | id("org.jetbrains.kotlin.android") 25 | id("org.jetbrains.dokka") 26 | id("maven-publish") 27 | id("signing") 28 | } 29 | 30 | android { 31 | namespace = "com.wultra.android.sslpinning" 32 | testNamespace = "com.wultra.android.sslpinning.test" 33 | compileSdk = Constants.Android.compileSdkVersion 34 | 35 | defaultConfig { 36 | minSdk = Constants.Android.minSdkVersion 37 | @Suppress("DEPRECATION") 38 | targetSdk = Constants.Android.targetSdkVersion 39 | 40 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 41 | testInstrumentationRunnerArguments["clearPackageData"] = "true" 42 | loadInstrumentationTestConfigProperties(project, this) 43 | } 44 | 45 | buildTypes { 46 | debug { 47 | enableUnitTestCoverage = true 48 | enableAndroidTestCoverage = true 49 | } 50 | release { 51 | isMinifyEnabled = false 52 | consumerProguardFiles("proguard-rules.pro") 53 | } 54 | } 55 | 56 | compileOptions { 57 | sourceCompatibility = Constants.Java.sourceCompatibility 58 | targetCompatibility = Constants.Java.targetCompatibility 59 | } 60 | 61 | kotlinOptions { 62 | jvmTarget = Constants.Java.kotlinJvmTarget 63 | } 64 | 65 | // avoids a gradle warning, otherwise unused due to custom config in android-release-aar.gradle 66 | publishing { 67 | singleVariant("release") { 68 | withSourcesJar() 69 | withJavadocJar() 70 | } 71 | } 72 | 73 | lint { 74 | // to handle warning coming from a transitive dependency 75 | // - obsolete 'androidx.fragment' through 'powerauth-sdk' 76 | disable.add("ObsoleteLintCustomCheck") 77 | } 78 | } 79 | 80 | dependencies { 81 | compileOnly("com.wultra.android.powerauth:powerauth-sdk:${Constants.Dependencies.powerAuthSdkVersion}") 82 | 83 | implementation("org.jetbrains.kotlin:kotlin-stdlib:${Constants.BuildScript.kotlinVersion}") 84 | implementation("com.google.code.gson:gson:2.10.1") 85 | implementation("androidx.annotation:annotation:1.7.1") 86 | implementation("androidx.security:security-crypto:1.0.0") 87 | 88 | testImplementation("com.wultra.android.powerauth:powerauth-sdk:${Constants.Dependencies.powerAuthSdkVersion}") 89 | testImplementation("junit:junit:4.13.2") 90 | testImplementation("io.mockk:mockk:1.13.5") 91 | testImplementation("org.bouncycastle:bcprov-jdk15on:1.70") 92 | testImplementation("io.getlime.security:powerauth-java-crypto:1.4.0") 93 | 94 | androidTestImplementation("androidx.test:runner:1.5.2") 95 | androidTestImplementation("androidx.test:rules:1.5.0") 96 | androidTestImplementation("androidx.test.ext:junit:1.1.5") 97 | androidTestImplementation("com.wultra.android.powerauth:powerauth-sdk:${Constants.Dependencies.powerAuthSdkVersion}") 98 | androidTestImplementation("com.squareup.okhttp3:okhttp:4.10.0") 99 | 100 | constraints { 101 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${Constants.BuildScript.kotlinVersion}") { 102 | because("Avoids conflicts with 'kotlin-stdlib'") 103 | } 104 | } 105 | } 106 | 107 | apply("android-release-aar.gradle") 108 | 109 | // Load properties for instrumentation tests. 110 | fun loadInstrumentationTestConfigProperties(project: Project, defaultConfig: DefaultConfig) { 111 | val configsRoot = File("${project.rootProject.projectDir}/configs") 112 | val defaultConfigFile = File(configsRoot, "integration-tests.properties") 113 | val privateConfigFile = File(configsRoot, "private-integration-tests.properties") 114 | val configPropertiesFile = if (privateConfigFile.canRead()) { 115 | privateConfigFile 116 | } else { 117 | defaultConfigFile 118 | } 119 | val instrumentationArguments = arrayOf( 120 | "test.sslPinning.baseUrl", 121 | "test.sslPinning.appName" 122 | ) 123 | 124 | project.logger.info("LOADING_PROPERTIES Reading $configPropertiesFile") 125 | if (configPropertiesFile.canRead()) { 126 | val props = Properties() 127 | props.load(FileInputStream(configPropertiesFile)) 128 | 129 | for (key in instrumentationArguments) { 130 | defaultConfig.testInstrumentationRunnerArguments[key] = "${props[key]}" 131 | } 132 | } else { 133 | project.logger.warn("Loading properties error: Missing $configPropertiesFile") 134 | } 135 | } -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/TestUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | package com.wultra.android.sslpinning 17 | 18 | import android.os.Handler 19 | import com.google.gson.Gson 20 | import com.wultra.android.sslpinning.model.GetFingerprintResponse 21 | import org.junit.Assert 22 | import java.io.ByteArrayInputStream 23 | import java.io.File 24 | import java.io.IOException 25 | import java.io.InputStream 26 | import java.net.HttpURLConnection 27 | import java.net.URL 28 | import java.security.cert.CertificateFactory 29 | import java.security.cert.X509Certificate 30 | import java.util.Base64 31 | import java.util.Date 32 | import java.util.concurrent.CountDownLatch 33 | import java.util.concurrent.TimeUnit 34 | import javax.net.ssl.HttpsURLConnection 35 | 36 | /** 37 | * @author Tomas Kypta, tomas.kypta@wultra.com 38 | */ 39 | class TestUtils { 40 | 41 | companion object { 42 | 43 | @Throws(Exception::class) 44 | fun githubCert(): X509Certificate { 45 | return getCertificateFromBase64Data(readFile("cert.pem")) 46 | } 47 | 48 | fun testPubKey() = readFile("pub.key").decodeToString().replace("\n", "") 49 | 50 | fun validFingerprintJsonResponse() = """{ "fingerprints": [ ${readFile("valid_github.json").decodeToString()} ] }""".toByteArray() 51 | 52 | fun readFile(fileName: String): ByteArray { 53 | val jsonFile = File(TestUtils::class.java.classLoader!!.getResource(fileName).toURI()) 54 | return jsonFile.readBytes() 55 | } 56 | 57 | @Throws(Exception::class) 58 | fun getCertificateFromBase64Data(data: ByteArray): X509Certificate { 59 | val certFactory = CertificateFactory.getInstance("X.509") 60 | val inputStream = ByteArrayInputStream(data) 61 | return certFactory.generateCertificate(inputStream) as X509Certificate 62 | } 63 | 64 | @JvmStatic 65 | @Throws(IOException::class) 66 | fun getCertificateFromUrl(urlString: String?): X509Certificate { 67 | val url = URL(urlString) 68 | val httpURLConnection = url.openConnection() as HttpURLConnection 69 | val connection = httpURLConnection as HttpsURLConnection 70 | try { 71 | connection.connect() 72 | val certificates = connection.serverCertificates 73 | return certificates[0] as X509Certificate 74 | } finally { 75 | connection.disconnect() 76 | } 77 | } 78 | 79 | @JvmStatic 80 | fun getCertStoreConfiguration( 81 | expiration: Date, 82 | expectedCommonNames: Array?, 83 | serviceUrl: URL, 84 | publicKey: ByteArray, 85 | fallback: GetFingerprintResponse? 86 | ): CertStoreConfiguration { 87 | val builder = CertStoreConfiguration.Builder( 88 | serviceUrl, publicKey 89 | ) 90 | .identifier(null) 91 | .expectedCommonNames(expectedCommonNames) 92 | .fallbackCertificates(fallback) 93 | return builder.build() 94 | } 95 | 96 | @JvmStatic 97 | @Throws(Exception::class) 98 | fun assignHandler(certStore: CertStore?, handler: Handler?) { 99 | val handlerField = CertStore::class.java.getDeclaredField("mainThreadHandler") 100 | handlerField.isAccessible = true 101 | handlerField[certStore] = handler 102 | } 103 | 104 | @Throws(Exception::class) 105 | fun updateAndCheck( 106 | store: CertStore, 107 | updateMode: UpdateMode, 108 | expectedUpdateResult: UpdateResult? 109 | ): UpdateResult { 110 | val initLatch = CountDownLatch(1) 111 | val latch = CountDownLatch(1) 112 | val updateWrapper = UpdateWrapper() 113 | store.update(updateMode, object : UpdateObserver { 114 | override fun onUpdateStarted(type: UpdateType) { 115 | updateWrapper.updateType = type 116 | initLatch.countDown() 117 | } 118 | 119 | override fun onUpdateFinished(type: UpdateType, result: UpdateResult) { 120 | updateWrapper.updateResult = result 121 | latch.countDown() 122 | } 123 | }) 124 | initLatch.await(500, TimeUnit.MILLISECONDS) 125 | Assert.assertNotNull(updateWrapper.updateType) 126 | if (updateWrapper.updateType!!.isPerformingUpdate) { 127 | Assert.assertTrue(latch.await(15, TimeUnit.SECONDS)) 128 | } 129 | if (expectedUpdateResult != null) { 130 | Assert.assertEquals(expectedUpdateResult, updateWrapper.updateResult) 131 | } 132 | return updateWrapper.updateResult!! 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/integration/SSLPinningIntegration.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.integration 18 | 19 | import android.os.Build 20 | import android.util.Log 21 | import com.wultra.android.sslpinning.CertStore 22 | import java.lang.Exception 23 | import java.net.InetAddress 24 | import java.net.Socket 25 | import java.security.KeyManagementException 26 | import java.security.KeyStore 27 | import java.security.NoSuchAlgorithmException 28 | import javax.net.ssl.SSLContext 29 | import javax.net.ssl.SSLSocket 30 | import javax.net.ssl.SSLSocketFactory 31 | import javax.net.ssl.TrustManagerFactory 32 | 33 | /** 34 | * Integration class for creating [SSLSocketFactory] for handling WultraSSLPinning. 35 | * 36 | * @author Tomas Kypta, tomas.kypta@wultra.com 37 | */ 38 | class SSLPinningIntegration { 39 | 40 | companion object { 41 | 42 | /** 43 | * Creates [SSLSocketFactory] for handling WultraSSLPinning. 44 | * The factory first tests SSL Pinning then if that is ok fallbacks on standard 45 | * certificate validation. 46 | * 47 | * @param certStore CertStore to base the SSL pinning on. 48 | * @return SSLSocketFactory capable of handling WultraSSLPinning. 49 | */ 50 | @JvmStatic 51 | fun createSSLPinningSocketFactory(certStore: CertStore): SSLSocketFactory { 52 | return createSSLPinningSocketFactory(SSLPinningX509TrustManager(certStore)) 53 | } 54 | 55 | /** 56 | * Creates [SSLSocketFactory] for handling WultraSSLPinning. 57 | * The factory first tests SSL Pinning then if that is ok fallbacks on standard 58 | * certificate validation. 59 | * 60 | * Note: On devices prior to Android API 21, you'll probably need to use ProviderInstaller.installIfNeeded method 61 | * to ensure, that the device is capable of TLS 1.2 handling. 62 | * 63 | * @param sslPinningTrustManager Trust manager capable of handling WultraSSLPinning 64 | * that makes basis for the [SSLSocketFactory]. 65 | * @return SSLSocketFactory capable of handling WultraSSLPinning. 66 | */ 67 | @JvmStatic 68 | fun createSSLPinningSocketFactory(sslPinningTrustManager: SSLPinningX509TrustManager): SSLSocketFactory { 69 | // obtain default trust managers 70 | val originalTrustManagerFactory = TrustManagerFactory.getInstance("X509") 71 | val keyStore: KeyStore? = null 72 | originalTrustManagerFactory.init(keyStore) 73 | val originalTrustManagers = originalTrustManagerFactory.trustManagers 74 | 75 | // use all trust managers after the provided (or our [SSLPinningX509TrustManager]) 76 | val trustSslPinningCerts = arrayOf(sslPinningTrustManager, *originalTrustManagers) 77 | try { 78 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { 79 | try { 80 | val sc = SSLContext.getInstance(Tls12SocketFactory.TLS12NAME) 81 | sc.init(null, trustSslPinningCerts, null) 82 | return Tls12SocketFactory(sc.socketFactory) 83 | } catch (e: Exception) { 84 | Log.e("TLS12Factory", e.message ?: "") 85 | } 86 | } 87 | 88 | val sc = SSLContext.getInstance("TLS") 89 | sc.init(null, trustSslPinningCerts, null) 90 | return sc.socketFactory 91 | } catch (e: NoSuchAlgorithmException) { 92 | throw RuntimeException(e) 93 | } catch (e: KeyManagementException) { 94 | throw RuntimeException(e) 95 | } 96 | } 97 | } 98 | } 99 | 100 | private class Tls12SocketFactory(private val base: SSLSocketFactory) : SSLSocketFactory() { 101 | 102 | companion object { 103 | const val TLS12NAME = "TLSv1.2" 104 | } 105 | 106 | override fun getDefaultCipherSuites(): Array = base.defaultCipherSuites 107 | 108 | override fun getSupportedCipherSuites(): Array = base.supportedCipherSuites 109 | 110 | override fun createSocket(p0: Socket?, p1: String?, p2: Int, p3: Boolean) = base.createSocket(p0, p1, p2, p3).patch() 111 | 112 | override fun createSocket(p0: String?, p1: Int) = base.createSocket(p0, p1).patch() 113 | 114 | override fun createSocket(p0: String?, p1: Int, p2: InetAddress?, p3: Int) = base.createSocket(p0, p1, p2, p3).patch() 115 | 116 | override fun createSocket(p0: InetAddress?, p1: Int) = base.createSocket(p0, p1).patch() 117 | 118 | override fun createSocket(p0: InetAddress?, p1: Int, p2: InetAddress?, p3: Int) = base.createSocket(p0, p1, p2, p3).patch() 119 | 120 | private fun Socket.patch(): Socket { 121 | return (this as? SSLSocket)?.apply { 122 | enabledProtocols += TLS12NAME 123 | } ?: this 124 | } 125 | } -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/CertStoreUpdateTestJava.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning; 18 | 19 | import android.content.Context; 20 | 21 | import com.wultra.android.sslpinning.integration.DefaultCryptoProvider; 22 | import com.wultra.android.sslpinning.integration.DefaultSecureDataStore; 23 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthCertStore; 24 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthCryptoProvider; 25 | import com.wultra.android.sslpinning.integration.powerauth.PowerAuthSecureDataStore; 26 | import com.wultra.android.sslpinning.service.RemoteDataProvider; 27 | 28 | import org.junit.Test; 29 | 30 | import java.net.URL; 31 | import java.util.ArrayList; 32 | import java.util.UUID; 33 | 34 | import static com.wultra.android.sslpinning.TestUtilsKt.getPublicKeyBytes; 35 | import static com.wultra.android.sslpinning.TestUtilsKt.getRemoteDataProvider; 36 | import static com.wultra.android.sslpinning.TestUtilsKt.jsonDataAllEmpty; 37 | import static com.wultra.android.sslpinning.TestUtilsKt.jsonDataFingerprintsEmpty; 38 | import static com.wultra.android.sslpinning.TestUtilsKt.updateAndCheck; 39 | 40 | /** 41 | * Instrumentation test for update signature validation on a device/emulator. 42 | * Written in Java to validate APIs. 43 | * 44 | * @author Tomas Kypta, tomas.kypta@wultra.com 45 | */ 46 | public class CertStoreUpdateTestJava extends CommonTest { 47 | 48 | @Test 49 | public void testLocalUpdateSignatureGithub_InvalidUrlUpdate() throws Exception { 50 | Context appContext = getAppContext(); 51 | // empty 52 | URL url = new URL("https://gist.githubusercontent.com/TomasKypta/ae4fa795a8c1ffa1ed0144c49b95e63c/raw/761483b6c1fa3039f0b9d7b05c5d43532fc1556a/ssl-pinning-signatures_invalid_url.json"); 53 | CertStoreConfiguration config = new CertStoreConfiguration.Builder(url, getPublicKeyBytes()).build(); 54 | CertStore[] stores = { 55 | PowerAuthCertStore.createInstance(config, appContext), 56 | new CertStore(config, appContext) 57 | }; 58 | for (CertStore store : stores) { 59 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.NETWORK_ERROR); 60 | } 61 | } 62 | 63 | @Test 64 | public void testLocalUpdateSignatureGithub_EmptyUpdate() throws Exception { 65 | Context appContext = getAppContext(); 66 | // empty 67 | URL url = new URL("https://gist.githubusercontent.com/TomasKypta/ae4fa795a8c1ffa1ed0144c49b95e63c/raw/761483b6c1fa3039f0b9d7b05c5d43532fc1556a/ssl-pinning-signatures_empty.json"); 68 | CertStoreConfiguration config = new CertStoreConfiguration.Builder(url, getPublicKeyBytes()).build(); 69 | RemoteDataProvider provider = getRemoteDataProvider(jsonDataAllEmpty); 70 | CertStore[] stores = { 71 | new CertStore(config, new PowerAuthCryptoProvider(), new PowerAuthSecureDataStore(appContext), provider), 72 | new CertStore(config, new DefaultCryptoProvider(), new DefaultSecureDataStore(appContext), provider), 73 | }; 74 | for (CertStore store : stores) { 75 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.INVALID_DATA); 76 | } 77 | } 78 | 79 | @Test 80 | public void testLocalUpdateSignatureGithub_EmptyFingerprintUpdate() throws Exception { 81 | Context appContext = getAppContext(); 82 | // empty 83 | URL url = new URL("https://gist.githubusercontent.com/TomasKypta/ae4fa795a8c1ffa1ed0144c49b95e63c/raw/761483b6c1fa3039f0b9d7b05c5d43532fc1556a/ssl-pinning-signatures_empty.json"); 84 | CertStoreConfiguration config = new CertStoreConfiguration.Builder(url, getPublicKeyBytes()).build(); 85 | RemoteDataProvider provider = getRemoteDataProvider(jsonDataFingerprintsEmpty); 86 | CertStore[] stores = { 87 | new CertStore(config, new PowerAuthCryptoProvider(), new PowerAuthSecureDataStore(appContext), provider), 88 | new CertStore(config, new DefaultCryptoProvider(), new DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 89 | }; 90 | for (CertStore store : stores) { 91 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.STORE_IS_EMPTY); 92 | } 93 | } 94 | 95 | @Test 96 | public void testRemoteUpdateSignatureGithub_EmptyUpdate() throws Exception { 97 | Context appContext = getAppContext(); 98 | // empty 99 | URL url = new URL("https://gist.githubusercontent.com/TomasKypta/ae4fa795a8c1ffa1ed0144c49b95e63c/raw/761483b6c1fa3039f0b9d7b05c5d43532fc1556a/ssl-pinning-signatures_empty.json"); 100 | CertStoreConfiguration config = new CertStoreConfiguration.Builder(url, getPublicKeyBytes()).build(); 101 | CertStore[] stores = { 102 | PowerAuthCertStore.createInstance(config, appContext), 103 | new CertStore(config, new DefaultCryptoProvider(), new DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 104 | }; 105 | for (CertStore store : stores) { 106 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.STORE_IS_EMPTY); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /library/src/androidTest/java/com/wultra/android/sslpinning/SSLPinningIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import androidx.test.ext.junit.runners.AndroidJUnit4 20 | import com.wultra.android.sslpinning.integration.DefaultCryptoProvider 21 | import com.wultra.android.sslpinning.integration.DefaultSecureDataStore 22 | import com.wultra.android.sslpinning.integration.SSLPinningIntegration 23 | import com.wultra.android.sslpinning.integration.SSLPinningX509TrustManager 24 | import com.wultra.android.sslpinning.integration.powerauth.powerAuthCertStore 25 | import okhttp3.OkHttpClient 26 | import okhttp3.Request 27 | import org.junit.Assert 28 | import org.junit.Test 29 | import org.junit.runner.RunWith 30 | import java.net.HttpURLConnection 31 | import java.net.URL 32 | import java.util.UUID 33 | import javax.net.ssl.HttpsURLConnection 34 | import javax.net.ssl.SSLHandshakeException 35 | 36 | /** 37 | * Integration tests for [SSLPinningIntegration] and [SSLPinningX509TrustManager]. 38 | * 39 | * @author Tomas Kypta, tomas.kypta@wultra.com 40 | */ 41 | @RunWith(AndroidJUnit4::class) 42 | class SSLPinningIntegrationTest : CommonTest() { 43 | 44 | private lateinit var certStores: Array 45 | 46 | override fun setUp() { 47 | super.setUp() 48 | val config = CertStoreConfiguration.Builder(serviceUrl, pubKey).useChallenge(true).build() 49 | certStores = arrayOf( 50 | CertStore.powerAuthCertStore(config, appContext, UUID.randomUUID().toString()), 51 | CertStore(config, DefaultCryptoProvider(), DefaultSecureDataStore(appContext, UUID.randomUUID().toString())) 52 | ) 53 | } 54 | 55 | @Test 56 | fun testValidationwithHttpsUrlConnection_ValidCert() { 57 | certStores.forEach { store -> 58 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 59 | 60 | val url = URL("https://github.com") 61 | 62 | val httpURLConnection = url.openConnection() as HttpURLConnection 63 | val connection = httpURLConnection as HttpsURLConnection 64 | 65 | connection.sslSocketFactory = SSLPinningIntegration.createSSLPinningSocketFactory(store) 66 | try { 67 | connection.connect() 68 | } catch (e: SSLHandshakeException) { 69 | Assert.fail() 70 | } finally { 71 | connection.disconnect() 72 | } 73 | } 74 | } 75 | 76 | @Test(expected = SSLHandshakeException::class) 77 | fun testValidationwithHttpsUrlConnection_InvalidCert() { 78 | certStores.forEach { store -> 79 | 80 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 81 | 82 | val url = URL("https://google.com") 83 | 84 | val httpURLConnection = url.openConnection() as HttpURLConnection 85 | val connection = httpURLConnection as HttpsURLConnection 86 | 87 | connection.sslSocketFactory = SSLPinningIntegration.createSSLPinningSocketFactory(store) 88 | try { 89 | connection.connect() 90 | // should not reach here, SSLHandshakeException because validation returns ValidationResult.EMPTY 91 | Assert.fail() 92 | } finally { 93 | connection.disconnect() 94 | } 95 | } 96 | } 97 | 98 | @Test 99 | fun testValidationwithOkHttp_ValidCert() { 100 | certStores.forEach { store -> 101 | 102 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 103 | 104 | val url = URL("https://github.com") 105 | 106 | val trustManager = SSLPinningX509TrustManager(store) 107 | val sslSocketFactory = SSLPinningIntegration.createSSLPinningSocketFactory(trustManager) 108 | 109 | val okhttpClient = OkHttpClient.Builder() 110 | .sslSocketFactory(sslSocketFactory, trustManager) 111 | .build() 112 | 113 | val request = Request.Builder() 114 | .url(url) 115 | .build() 116 | 117 | try { 118 | okhttpClient.newCall(request).execute() 119 | } catch (e: SSLHandshakeException) { 120 | Assert.fail() 121 | } 122 | } 123 | } 124 | 125 | @Test(expected = SSLHandshakeException::class) 126 | fun testValidationwithOkHttp_InvalidCert() { 127 | certStores.forEach { store -> 128 | 129 | updateAndCheck(store, UpdateMode.FORCED, UpdateResult.OK) 130 | 131 | val url = URL("https://google.com") 132 | 133 | val trustManager = SSLPinningX509TrustManager(store) 134 | val sslSocketFactory = SSLPinningIntegration.createSSLPinningSocketFactory(trustManager) 135 | 136 | val okhttpClient = OkHttpClient.Builder() 137 | .sslSocketFactory(sslSocketFactory, trustManager) 138 | .build() 139 | 140 | val request = Request.Builder() 141 | .url(url) 142 | .build() 143 | 144 | okhttpClient.newCall(request).execute() 145 | // should not reach here, SSLHandshakeException because validation returns ValidationResult.EMPTY 146 | Assert.fail() 147 | } 148 | } 149 | } -------------------------------------------------------------------------------- /library/src/main/java/com/wultra/android/sslpinning/service/RestApi.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning.service 18 | 19 | import androidx.annotation.WorkerThread 20 | import com.wultra.android.sslpinning.SslValidationStrategy 21 | import java.io.IOException 22 | import java.lang.Exception 23 | import java.net.HttpURLConnection 24 | import java.net.URL 25 | import java.nio.charset.Charset 26 | import java.util.* 27 | import javax.net.ssl.HttpsURLConnection 28 | 29 | /** 30 | * Handling of network communication with the server. 31 | * Used internally in [com.wultra.android.sslpinning.CertStore]. 32 | * 33 | * @property baseUrl URL of the remote server. 34 | * @author Tomas Kypta, tomas.kypta@wultra.com 35 | */ 36 | class RestApi( 37 | private val baseUrl: URL, 38 | private val sslValidationStrategy: SslValidationStrategy?) : RemoteDataProvider { 39 | 40 | companion object { 41 | const val CONTENT_TYPE = "application/json" 42 | } 43 | 44 | init { 45 | 46 | } 47 | 48 | /** 49 | * Exception denoting that the server request failed. 50 | */ 51 | class NetworkException(val response: RemoteDataResponse) : Exception() 52 | 53 | /** 54 | * Perform REST request to get fingerprints from the remote server. 55 | * 56 | * @return Bytes as received from the remote server. Typically containing data in JSON format. 57 | */ 58 | @WorkerThread 59 | override fun getFingerprints(request: RemoteDataRequest): RemoteDataResponse { 60 | val connection = baseUrl.openConnection() as HttpURLConnection 61 | connection.requestMethod = "GET" 62 | connection.addRequestProperty("Accept", CONTENT_TYPE) 63 | request.requestHeaders.forEach { header -> 64 | connection.addRequestProperty(header.key, header.value) 65 | } 66 | if (sslValidationStrategy != null) { 67 | val secureConnection = connection as? HttpsURLConnection 68 | if (secureConnection != null) { 69 | val socketFactory = sslValidationStrategy.sslSocketFactory() 70 | val hosntameVerifier = sslValidationStrategy.hostnameVerifier() 71 | if (socketFactory != null) { 72 | secureConnection.sslSocketFactory = socketFactory 73 | } 74 | if (hosntameVerifier != null) { 75 | secureConnection.hostnameVerifier = hosntameVerifier 76 | } 77 | } 78 | } 79 | logRequest(connection) 80 | try { 81 | connection.connect() 82 | val responseCode = connection.responseCode 83 | val responseOk = responseCode / 100 == 2 84 | val inputStream = if (responseOk) connection.inputStream else connection.errorStream 85 | val data = inputStream.use { it.readBytes() } 86 | val headers = mutableMapOf() 87 | connection.headerFields.keys.forEach { headerName -> 88 | if (headerName != null) { 89 | val headerValue = connection.getHeaderField(headerName) 90 | if (headerValue != null) { 91 | headers[headerName.lowercase(Locale.getDefault())] = headerValue 92 | } 93 | } 94 | } 95 | val responseData = RemoteDataResponse(responseCode, headers, data) 96 | if (!responseOk) { 97 | throw NetworkException(responseData) 98 | } 99 | logResponse(connection, data, null) 100 | return responseData 101 | } catch (e: NetworkException) { 102 | logResponse(connection, e.response.data, null) 103 | WultraDebug.warning("RestAPI: HTTP request failed with response code ${e.response.responseCode}") 104 | throw e 105 | } catch (t: Throwable) { 106 | logResponse(connection, null, t) 107 | WultraDebug.warning("RestAPI: HTTP request failed with error: $t") 108 | throw t 109 | } finally { 110 | connection.disconnect() 111 | } 112 | } 113 | 114 | /** 115 | * Dump request data into debug log. 116 | * 117 | * @param connection Connection object. 118 | */ 119 | private fun logRequest(connection: HttpURLConnection) { 120 | if (WultraDebug.loggingLevel == WultraDebug.WultraLoggingLevel.DEBUG) { 121 | val url = connection.url 122 | val method = connection.requestMethod 123 | var message = "HTTP ${method} request: -> ${url}" 124 | if (connection.requestProperties != null) { 125 | message += "\n- Headers: ${connection.requestProperties}" 126 | } 127 | WultraDebug.info(message) 128 | } 129 | } 130 | 131 | /** 132 | * Dump response data into debug log. 133 | * 134 | * @param connection Connection object. 135 | * @param data Data received from the server. 136 | * @param error Error produced during the connection. 137 | */ 138 | private fun logResponse(connection: HttpURLConnection, data: ByteArray?, error: Throwable?) { 139 | if (WultraDebug.loggingLevel == WultraDebug.WultraLoggingLevel.DEBUG) { 140 | val url = connection.url 141 | val method = connection.requestMethod 142 | val responseCode = try { 143 | connection.responseCode 144 | } catch (e: IOException) { 145 | 0 146 | } 147 | var message = "HTTP ${method} response: ${responseCode} <- ${url}" 148 | if (connection.headerFields != null) { 149 | message += "\n- Headers: ${connection.headerFields}" 150 | } 151 | if (data != null) { 152 | val dataString = data.toString(Charsets.UTF_8) 153 | message += "\n- Data: ${dataString}" 154 | } 155 | if (error != null) { 156 | message += "\n- Error: ${error}" 157 | } 158 | WultraDebug.info(message) 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/CertStoreValidationTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning 18 | 19 | import com.wultra.android.sslpinning.model.GetFingerprintResponse 20 | import com.wultra.android.sslpinning.service.RemoteDataProvider 21 | import com.wultra.android.sslpinning.service.RemoteDataResponse 22 | import io.mockk.every 23 | import io.mockk.mockk 24 | 25 | import org.junit.Test 26 | 27 | import java.net.URL 28 | import java.util.Base64 29 | import java.util.Date 30 | import java.util.concurrent.TimeUnit 31 | 32 | import org.junit.Assert.assertEquals 33 | 34 | /** 35 | * Tests for validation with [CertStore]. 36 | * 37 | * @author Tomas Kypta, tomas.kypta@wultra.com 38 | */ 39 | class CertStoreValidationTest : CommonKotlinTest() { 40 | 41 | @Test 42 | @Throws(Exception::class) 43 | fun testValidationGithubFallbackInvalid() { 44 | val fingerprintBase64 = "trmmrz6GbL4OajB+fdoXOzcrLTrD8GrxX5dxh3OEgAg=" 45 | val expectedResult = ValidationResult.UNTRUSTED 46 | val publicKey = "BC3kV9OIDnMuVoCdDR9nEA/JidJLTTDLuSA2TSZsGgODSshfbZg31MS90WC/HdbU/A5WL5GmyDkE/iks6INv+XE=" 47 | val publicKeyBytes = Base64.getDecoder().decode(publicKey) 48 | 49 | val signatureBase64 = "MEUCICB69UpMPOdtrsR6XcJqHEh2L2RO4oSJ3SZ7BYnTBJbGAiEAnZ7rEWdMVGwa59Wx5QbAorEFxXH89Iu0CnqWa96Eda0=" 50 | val signatureBytes = Base64.getDecoder().decode(signatureBase64) 51 | val fingerprintBytes = Base64.getDecoder().decode(fingerprintBase64) 52 | 53 | val fallbackEntry = GetFingerprintResponse.Entry( 54 | "github.com", 55 | fingerprintBytes, 56 | Date(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1)), 57 | signatureBytes) 58 | val fallback = GetFingerprintResponse(arrayOf(fallbackEntry)) 59 | 60 | val config = TestUtils.getCertStoreConfiguration( 61 | Date(), 62 | arrayOf("github.com"), 63 | URL("https://github.com"), 64 | publicKeyBytes, 65 | fallback) 66 | val store = CertStore(config, cryptoProvider, secureDataStore) 67 | TestUtils.assignHandler(store, handler) 68 | val result = store.validateCertificate(TestUtils.githubCert()) 69 | assertEquals(expectedResult, result) 70 | } 71 | 72 | @Test 73 | @Throws(Exception::class) 74 | fun testValidationGithubUpdateWithOutdatedData() { 75 | 76 | val remoteDataProvider: RemoteDataProvider = mockk() 77 | every { remoteDataProvider.getFingerprints(any()) } answers { 78 | RemoteDataResponse( 79 | 200, 80 | emptyMap(), 81 | """ 82 | { 83 | "fingerprints": [ 84 | { 85 | "name" : "github.com", 86 | "fingerprint" : "trmmrz6GbL4OajB+fdoXOzcrLTrD8GrxX5dxh3OEgAg=", 87 | "expires" : 1652184000, 88 | "signature" : "MEUCIQCs1y/nyrKh4+2DIuX/PufUYiaVUdt2FBZQg6rBeZ/r4QIgNlT4owBwJ1ThrDsE0SwGipTNI74vP1vNyLNEwuXY4lE=" 89 | } 90 | ] 91 | } 92 | """.toByteArray() 93 | ) 94 | } 95 | 96 | // json with outdated data, correct public key 97 | validateGithubWithUpdateJsonOnly(remoteDataProvider, UpdateResult.STORE_IS_EMPTY, ValidationResult.EMPTY) 98 | } 99 | 100 | @Test 101 | @Throws(Exception::class) 102 | fun testValidationGithubUpdateWithInvalidSignature() { 103 | 104 | val remoteDataProvider: RemoteDataProvider = mockk() 105 | every { remoteDataProvider.getFingerprints(any()) } answers { 106 | RemoteDataResponse( 107 | 200, 108 | emptyMap(), 109 | """ 110 | { 111 | "fingerprints": [{ 112 | "name" : "github.com", 113 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 114 | "expires" : 2212460799, 115 | "signature" : "MEQCIAiq/O5IbZ8K2SsZtDbpsvHWecxu4eLOSS7oOXjDk2KeAiBKkI53ESVByK+wKwaLA5LsEu8oonUHiYVM2zQtWf46DA==" 116 | }] 117 | } 118 | """.toByteArray() 119 | ) 120 | } 121 | 122 | // json with current data, different public key 123 | validateGithubWithUpdateJsonOnly(remoteDataProvider, UpdateResult.INVALID_SIGNATURE, ValidationResult.EMPTY) 124 | } 125 | 126 | @Test 127 | @Throws(Exception::class) 128 | fun testValidationGithubUpdateWithValidData() { 129 | 130 | val remoteDataProvider: RemoteDataProvider = mockk() 131 | every { remoteDataProvider.getFingerprints(any()) } answers { 132 | RemoteDataResponse( 133 | 200, 134 | emptyMap(), 135 | TestUtils.validFingerprintJsonResponse() 136 | ) 137 | } 138 | 139 | // json with current data, correct public key 140 | validateGithubWithUpdateJsonOnly(remoteDataProvider, UpdateResult.OK, ValidationResult.TRUSTED) 141 | } 142 | 143 | @Throws(Exception::class) 144 | fun validateGithubWithUpdateJsonOnly( 145 | provider: RemoteDataProvider, 146 | expectedUpdateResult: UpdateResult, 147 | expectedValidationResult: ValidationResult 148 | ) { 149 | val publicKeyBytes = Base64.getDecoder().decode(TestUtils.testPubKey()) 150 | val config = TestUtils.getCertStoreConfiguration( 151 | Date(), 152 | arrayOf("github.com"), 153 | URL("https://test"), 154 | publicKeyBytes, 155 | null 156 | ) 157 | 158 | val store = CertStore(config, cryptoProvider, secureDataStore, provider) 159 | 160 | TestUtils.assignHandler(store, handler) 161 | TestUtils.updateAndCheck(store, UpdateMode.FORCED, expectedUpdateResult) 162 | 163 | val result = store.validateCertificate(TestUtils.githubCert()) 164 | assertEquals(expectedValidationResult, result) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /library/android-release-aar.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | /* 18 | * External properties required for proper SDK publishing: 19 | * 20 | * Credentials to nexus: 21 | * nexus.user - login / User Token to nexus 22 | * nexus.password - password to nexus 23 | * nexus.stagingProfileId - staging profile ID 24 | * (click on your staging profile and copy ID from browser's URL) 25 | * 26 | * Signing options: 27 | * signing.gnupg.keyName - GPG key identifier 28 | * signing.gnupg.passphrase - GPG key passphrase 29 | * 30 | * If gpg2 executable is not available, then also set 31 | * signing.gnupg.executable=gpg 32 | */ 33 | 34 | ext { 35 | // Group, artifact & version 36 | GROUP_ID = project.GROUP_ID 37 | ARTIFACT_ID = project.ARTIFACT_ID 38 | VERSION_NAME = project.VERSION_NAME 39 | 40 | // Nexus credentials 41 | NEXUS_USERNAME = findProperty('nexus.user') 42 | NEXUS_PASSWORD = findProperty('nexus.password') 43 | //NEXUS_STAGING_PROFILE = findProperty('nexus.stagingProfileId') 44 | 45 | // POM content 46 | POM_LIBRARY_NAME = "Dynamic SSL pinning" 47 | POM_DESCRIPTION = "Android library implementing dynamic SSL pinning" 48 | POM_PACKAGING = "aar" 49 | POM_SITE_URL = "https://github.com/wultra/ssl-pinning-android" 50 | POM_SCM_CONNECTION_URL = "scm:git:github.com/wultra/ssl-pinning-android.git" 51 | POM_SCM_URL = "https://github.com/wultra/ssl-pinning-android" 52 | POM_DEVELOPER_ID =" wultra" 53 | POM_DEVELOPER_NAME = "Wultra s.r.o." 54 | POM_DEVELOPER_EMAIL = "support@wultra.com" 55 | POM_LICENSE_NAME = "Apache License Software License, Version 2.0" 56 | POM_LICENSE_URL = "https://www.apache.org/licenses/LICENSE-2.0.txt" 57 | } 58 | 59 | 60 | task androidJavadocs(type: Javadoc) { 61 | enabled = false // we have to disable because there are no java sources, only kotlin 62 | excludes = ['**/*.kt'] // exclude kotlin, Javadoc task can't handle that 63 | source = android.sourceSets.main.java.srcDirs 64 | classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) 65 | // adds libraries to classpath 66 | android.libraryVariants.all { variant -> 67 | if (variant.name == 'release') { 68 | owner.classpath += variant.getJavaCompileProvider().get().classpath 69 | } 70 | } 71 | options.addStringOption('encoding', 'UTF-8') 72 | } 73 | 74 | task("androidJavadocsJar", type: Jar, dependsOn: [androidJavadocs, dokkaJavadoc]) { 75 | archiveClassifier = 'javadoc' 76 | from androidJavadocs.destinationDir 77 | from dokkaJavadoc.outputDirectory 78 | } 79 | 80 | task androidSourcesJar(type: Jar) { 81 | archiveClassifier = 'sources' 82 | from android.sourceSets.main.java.srcDirs 83 | } 84 | 85 | artifacts { 86 | archives androidSourcesJar 87 | archives androidJavadocsJar 88 | } 89 | 90 | publishing { 91 | publications { 92 | release(MavenPublication) { 93 | // POM group, artifact & version 94 | groupId GROUP_ID 95 | artifactId = ARTIFACT_ID 96 | version = VERSION_NAME 97 | 98 | // Include artifacts, the `aar` and the sources 99 | artifact("$buildDir/outputs/aar/${project.getName()}-release.aar") 100 | artifact androidSourcesJar 101 | artifact androidJavadocsJar 102 | 103 | // Define POM 104 | pom { 105 | packaging = POM_PACKAGING 106 | name = POM_LIBRARY_NAME 107 | description = POM_DESCRIPTION 108 | url = POM_SITE_URL 109 | licenses { 110 | license { 111 | name = POM_LICENSE_NAME 112 | url = POM_LICENSE_URL 113 | } 114 | } 115 | developers { 116 | developer { 117 | id = POM_DEVELOPER_ID 118 | name = POM_DEVELOPER_NAME 119 | email = POM_DEVELOPER_EMAIL 120 | } 121 | } 122 | scm { 123 | connection = POM_SCM_CONNECTION_URL 124 | developerConnection = POM_SCM_CONNECTION_URL 125 | url = POM_SCM_URL 126 | } 127 | 128 | // A slightly hacky fix so that your POM will include any transitive dependencies 129 | // that your library builds upon 130 | withXml { 131 | def dependenciesNode = asNode().appendNode('dependencies') 132 | 133 | project.configurations.implementation.allDependencies.each { 134 | if (it.name == 'unspecified') return 135 | def dependencyNode = dependenciesNode.appendNode('dependency') 136 | dependencyNode.appendNode('groupId', it.group) 137 | dependencyNode.appendNode('artifactId', it.name) 138 | dependencyNode.appendNode('version', it.version) 139 | } 140 | } 141 | } 142 | } 143 | } 144 | // The repository to publish to, Sonatype/MavenCentral 145 | repositories { 146 | maven { 147 | name = 'sonatype' 148 | 149 | def releasesRepoUrl = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" 150 | def snapshotsRepoUrl = "https://s01.oss.sonatype.org/content/repositories/snapshots/" 151 | url = isReleaseBuild() ? releasesRepoUrl : snapshotsRepoUrl 152 | 153 | credentials { 154 | username NEXUS_USERNAME 155 | password NEXUS_PASSWORD 156 | } 157 | } 158 | } 159 | } 160 | 161 | signing { 162 | required { shouldSignArtifacts() } 163 | if (shouldSignArtifacts()) { 164 | // This must be also conditional, otherwise developers with no signing capabilities will 165 | // not be able to publish library to the local maven. 166 | useGpgCmd() 167 | } 168 | sign publishing.publications 169 | } 170 | 171 | // Helper functions 172 | 173 | def isReleaseBuild() { 174 | return !VERSION_NAME.contains("SNAPSHOT") 175 | } 176 | 177 | def shouldSignArtifacts() { 178 | def keyId = findProperty('signing.gnupg.keyName') 179 | def keyPass = findProperty('signing.gnupg.passphrase') 180 | return keyId != null && keyPass != null 181 | } 182 | 183 | afterEvaluate { 184 | signReleasePublication.dependsOn bundleReleaseAar 185 | publishReleasePublicationToSonatypeRepository.dependsOn bundleReleaseAar 186 | publishReleasePublicationToMavenLocal.dependsOn bundleReleaseAar 187 | } 188 | -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/CertStoreConfigurationTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | 17 | package com.wultra.android.sslpinning; 18 | 19 | import static org.junit.Assert.assertEquals; 20 | import static org.junit.Assert.assertNotNull; 21 | import static org.junit.Assert.assertNull; 22 | 23 | import com.wultra.android.sslpinning.model.GetFingerprintResponse; 24 | 25 | import org.junit.Test; 26 | 27 | import java.net.MalformedURLException; 28 | import java.net.URL; 29 | import java.util.Arrays; 30 | import java.util.Date; 31 | import java.util.concurrent.TimeUnit; 32 | 33 | /** 34 | * @author Tomas Kypta, tomas.kypta@wultra.com 35 | */ 36 | public class CertStoreConfigurationTest extends CommonKotlinTest { 37 | 38 | @Test 39 | public void testBasicConfiguration() throws Exception { 40 | String serviceUrl = "https://test.wultra.com"; 41 | CertStoreConfiguration.Builder builder = new CertStoreConfiguration.Builder( 42 | new URL(serviceUrl), "aaa".getBytes()) 43 | .expectedCommonNames(new String[]{"Test", "Wultra"}); 44 | CertStoreConfiguration config = builder.build(); 45 | assertEquals(serviceUrl, config.getServiceUrl().toString()); 46 | assertEquals("aaa", new String(config.getPublicKey())); 47 | } 48 | 49 | @Test 50 | public void testConfiguration() throws Exception { 51 | CertStoreConfiguration config = configuration(new Date()); 52 | assertNull(config.getFallbackCertificates()); 53 | CertStore store = new CertStore(config, cryptoProvider, secureDataStore); 54 | TestUtils.assignHandler(store, handler); 55 | 56 | byte[] fingerprint = new byte[32]; 57 | Arrays.fill(fingerprint, (byte)0xff); 58 | ValidationResult result = store.validateFingerprint("api.fallback.org", fingerprint); 59 | assertEquals(ValidationResult.EMPTY, result); 60 | } 61 | 62 | @Test 63 | public void testConfigurationWithFallbackCertificate() throws Exception { 64 | CertStoreConfiguration config = configurationWithFallback(null, null); 65 | assertNotNull(config.getFallbackCertificates()); 66 | CertStore store = new CertStore(config, cryptoProvider, secureDataStore); 67 | TestUtils.assignHandler(store, handler); 68 | 69 | byte[] fingerprint = new byte[32]; 70 | Arrays.fill(fingerprint, (byte)0xff); 71 | ValidationResult result = store.validateFingerprint("api.fallback.org", fingerprint); 72 | assertEquals(ValidationResult.TRUSTED, result); 73 | } 74 | 75 | @Test 76 | public void testConfigurationWithFallbackCertificateExpired() throws Exception { 77 | Date expired = new Date(new Date().getTime() - TimeUnit.SECONDS.toMillis(1)); 78 | CertStoreConfiguration config = configurationWithFallback(expired, null); 79 | assertNotNull(config.getFallbackCertificates()); 80 | CertStore store = new CertStore(config, cryptoProvider, secureDataStore); 81 | TestUtils.assignHandler(store, handler); 82 | 83 | byte[] fingerprint = new byte[32]; 84 | Arrays.fill(fingerprint, (byte)0xff); 85 | ValidationResult result = store.validateFingerprint("api.fallback.org", fingerprint); 86 | assertEquals(ValidationResult.EMPTY, result); 87 | } 88 | 89 | @Test 90 | public void testConfigurationWithNonMatchingExpectedCommonNames() throws Exception { 91 | CertStoreConfiguration config = configurationWithFallback(null, new String[]{"www.wultra.com"}); 92 | CertStore store = new CertStore(config, cryptoProvider, secureDataStore); 93 | TestUtils.assignHandler(store, handler); 94 | 95 | byte[] fingerprint = new byte[32]; 96 | Arrays.fill(fingerprint, (byte)0xff); 97 | ValidationResult result = store.validateFingerprint("api.fallback.org", fingerprint); 98 | assertEquals("Validate non-matching common name", ValidationResult.UNTRUSTED, result); 99 | 100 | result = store.validateFingerprint("www.wultra.com", fingerprint); 101 | assertEquals("Validate matching common name, no matching fingerprint", ValidationResult.EMPTY, result); 102 | } 103 | 104 | @Test 105 | public void testConfigurationWithMatchingExpectedCommonNames() throws Exception { 106 | CertStoreConfiguration config = configurationWithFallback(null, new String[]{"api.fallback.org"}); 107 | CertStore store = new CertStore(config, cryptoProvider, secureDataStore); 108 | TestUtils.assignHandler(store, handler); 109 | 110 | byte[] fingerprint = new byte[32]; 111 | Arrays.fill(fingerprint, (byte)0xff); 112 | ValidationResult result = store.validateFingerprint("api.fallback.org", fingerprint); 113 | assertEquals("Validate matching common name", ValidationResult.TRUSTED, result); 114 | 115 | result = store.validateFingerprint("www.wultra.com", fingerprint); 116 | assertEquals("Validate non-matching common name", ValidationResult.UNTRUSTED, result); 117 | } 118 | 119 | private CertStoreConfiguration configuration(Date expiration) throws MalformedURLException { 120 | if (expiration == null) { 121 | // create valid date 122 | expiration = new Date(new Date().getTime() + TimeUnit.SECONDS.toMillis(10)); 123 | } 124 | URL serviceUrl = new URL("https://foo.wultra.com"); 125 | String publicKey = "BEG6g28LNWRcmdFzexSNTKPBYZnDtKrCyiExFKbktttfKAF7wG4Cx1Nycr5PwCoICG1dRseLyuDxUilAmppPxAo="; 126 | byte[] publicKeyBytes = java.util.Base64.getDecoder().decode(publicKey); 127 | return TestUtils.getCertStoreConfiguration(expiration, null, serviceUrl, publicKeyBytes, null); 128 | } 129 | 130 | private CertStoreConfiguration configurationWithFallback(Date expiration, String[] expectedCommonNames) throws MalformedURLException { 131 | if (expiration == null) { 132 | // create valid date 133 | expiration = new Date(new Date().getTime() + TimeUnit.SECONDS.toMillis(10)); 134 | } 135 | URL serviceUrl = new URL("https://foo.wultra.com"); 136 | String publicKey = "BEG6g28LNWRcmdFzexSNTKPBYZnDtKrCyiExFKbktttfKAF7wG4Cx1Nycr5PwCoICG1dRseLyuDxUilAmppPxAo="; 137 | byte[] publicKeyBytes = java.util.Base64.getDecoder().decode(publicKey); 138 | 139 | byte[] fingerprint = new byte[32]; 140 | Arrays.fill(fingerprint, (byte)0xff); 141 | byte[] signature = new byte[64]; 142 | Arrays.fill(signature, (byte)0xfe); 143 | 144 | GetFingerprintResponse.Entry[] fallbackList = new GetFingerprintResponse.Entry[] { new GetFingerprintResponse.Entry("api.fallback.org", fingerprint, expiration, signature) }; 145 | GetFingerprintResponse fallbackData = new GetFingerprintResponse(fallbackList); 146 | 147 | return TestUtils.getCertStoreConfiguration(expiration, expectedCommonNames, serviceUrl, publicKeyBytes, fallbackData); 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /library/src/test/java/com/wultra/android/sslpinning/CertStoreUpdateTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Wultra s.r.o. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions 14 | * and limitations under the License. 15 | */ 16 | package com.wultra.android.sslpinning 17 | 18 | import com.wultra.android.sslpinning.integration.DefaultUpdateObserver 19 | import com.wultra.android.sslpinning.service.RemoteDataProvider 20 | import com.wultra.android.sslpinning.service.RemoteDataResponse 21 | import io.mockk.every 22 | import io.mockk.mockk 23 | import org.junit.Assert 24 | import org.junit.Test 25 | import java.net.URL 26 | import java.util.Base64 27 | import java.util.Date 28 | import java.util.concurrent.CountDownLatch 29 | import java.util.concurrent.TimeUnit 30 | 31 | /** 32 | * Unit tests for [CertStore] updates. 33 | * 34 | * @author Tomas Kypta, tomas.kypta@wultra.com 35 | */ 36 | class CertStoreUpdateTest : CommonKotlinTest() { 37 | 38 | @Test 39 | @Throws(Exception::class) 40 | fun testCorrectUpdate() { 41 | 42 | val remoteDataProvider: RemoteDataProvider = mockk() 43 | every { remoteDataProvider.getFingerprints(any()) } answers { 44 | RemoteDataResponse( 45 | 200, 46 | emptyMap(), 47 | """ 48 | { 49 | "fingerprints": [ 50 | { 51 | "name" : "github.com", 52 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 53 | "expires" : 2212460799, 54 | "signature" : "MEUCICB69UpMPOdtrsR6XcJqHEh2L2RO4oSJ3SZ7BYnTBJbGAiEAnZ7rEWdMVGwa59Wx5QbAorEFxXH89Iu0CnqWa96Eda0=" 55 | } 56 | ] 57 | } 58 | """.toByteArray() 59 | ) 60 | } 61 | 62 | every { cryptoProvider.ecdsaValidateSignature(any(), any()) } returns true 63 | 64 | val updateResult = performForcedUpdate(remoteDataProvider) 65 | Assert.assertEquals(UpdateResult.OK, updateResult) 66 | } 67 | 68 | @Test 69 | @Throws(Exception::class) 70 | fun testInvalidSignatureUpdate() { 71 | 72 | val remoteDataProvider: RemoteDataProvider = mockk() 73 | every { remoteDataProvider.getFingerprints(any()) } answers { 74 | RemoteDataResponse( 75 | 200, 76 | emptyMap(), 77 | """ 78 | { 79 | "fingerprints": [{ 80 | "name" : "github.com", 81 | "fingerprint" : "kqN/vV4hpTqVxxbhFE9EL1grlND6/Gc+tnF6TrUaiKc=", 82 | "expires" : 2212460799, 83 | "signature" : "MEQCIAiq/O5IbZ8K2SsZtDbpsvHWecxu4eLOSS7oOXjDk2KeAiBKkI53ESVByK+wKwaLA5LsEu8oonUHiYVM2zQtWf46DA==" 84 | }] 85 | } 86 | """.toByteArray() 87 | ) 88 | } 89 | 90 | every { cryptoProvider.ecdsaValidateSignature(any(), any()) } returns false 91 | val updateResult = performForcedUpdate(remoteDataProvider) 92 | Assert.assertEquals(UpdateResult.INVALID_SIGNATURE, updateResult) 93 | } 94 | 95 | @Test 96 | @Throws(Exception::class) 97 | fun testExpiredUpdate() { 98 | 99 | val remoteDataProvider: RemoteDataProvider = mockk() 100 | every { remoteDataProvider.getFingerprints(any()) } answers { 101 | RemoteDataResponse( 102 | 200, 103 | emptyMap(), 104 | """ 105 | { 106 | "fingerprints": [ 107 | { 108 | "name" : "github.com", 109 | "fingerprint" : "MRFQDEpmASza4zPsP8ocnd5FyVREDn7kE3Fr/zZjwHQ=", 110 | "expires" : 1531185600, 111 | "signature" : "MEUCIQD8nGyux9GM8u3XCrRiuJj/N2eEuB0oiHzTEpGyy2gE9gIgYIRfyed6ykDzZbK1ougq1SoRW8UBe5q3VmWihHuL2JY=" 112 | } 113 | ] 114 | } 115 | """.toByteArray() 116 | ) 117 | } 118 | 119 | every { cryptoProvider.ecdsaValidateSignature(any(), any()) } returns false 120 | 121 | val updateResult = performForcedUpdate(remoteDataProvider) 122 | Assert.assertEquals(UpdateResult.STORE_IS_EMPTY, updateResult) 123 | } 124 | 125 | @Test 126 | @Throws(Exception::class) 127 | fun testUpdateWithNoUpdateObserver() { 128 | val remoteDataProvider: RemoteDataProvider = mockk() 129 | val latch = CountDownLatch(1) 130 | every { remoteDataProvider.getFingerprints(any()) } answers { 131 | val bytes = """{ 132 | "fingerprints": [ 133 | { 134 | "name" : "github.com", 135 | "fingerprint" : "trmmrz6GbL4OajB+fdoXOzcrLTrD8GrxX5dxh3OEgAg=", 136 | "expires" : 1652184000, 137 | "signature" : "MEUCIQCs1y/nyrKh4+2DIuX/PufUYiaVUdt2FBZQg6rBeZ/r4QIgNlT4owBwJ1ThrDsE0SwGipTNI74vP1vNyLNEwuXY4lE=" 138 | } 139 | ] 140 | }""".toByteArray() 141 | latch.countDown() 142 | RemoteDataResponse(200, emptyMap(), bytes) 143 | } 144 | val store = getCertStore(remoteDataProvider) 145 | TestUtils.assignHandler(store, handler) 146 | store.update(UpdateMode.FORCED, object : DefaultUpdateObserver() { 147 | override fun onUpdateStarted(type: UpdateType) { 148 | Assert.assertEquals(UpdateType.DIRECT, type) 149 | super.onUpdateStarted(type) 150 | } 151 | 152 | override fun onUpdateFinished(type: UpdateType, result: UpdateResult) { 153 | Assert.assertEquals(UpdateType.DIRECT, type) 154 | super.onUpdateFinished(type, result) 155 | } 156 | 157 | override fun handleFailedUpdate(type: UpdateType, result: UpdateResult) { 158 | Assert.fail() 159 | } 160 | 161 | override fun continueExecution() {} 162 | }) 163 | Assert.assertTrue(latch.await(2, TimeUnit.SECONDS)) 164 | } 165 | 166 | @Throws(Exception::class) 167 | private fun performForcedUpdate(remoteDataProvider: RemoteDataProvider): UpdateResult { 168 | val store = getCertStore(remoteDataProvider) 169 | TestUtils.assignHandler(store, handler) 170 | return TestUtils.updateAndCheck(store, UpdateMode.FORCED, null) 171 | } 172 | 173 | private fun getCertStore(remoteDataProvider: RemoteDataProvider): CertStore { 174 | val publicKeyBytes = Base64.getDecoder().decode("BC3kV9OIDnMuVoCdDR9nEA/JidJLTTDLuSA2TSZsGgODSshfbZg31MS90WC/HdbU/A5WL5GmyDkE/iks6INv+XE=") 175 | val config = TestUtils.getCertStoreConfiguration( 176 | Date(), 177 | arrayOf("github.com"), 178 | URL("https://test"), 179 | publicKeyBytes, 180 | null 181 | ) 182 | return CertStore(config, cryptoProvider, secureDataStore, remoteDataProvider) 183 | } 184 | } --------------------------------------------------------------------------------