diff --git a/README.md b/README.md index 6451b98..b1b4aa7 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,20 @@ Apache Maven com.bitwarden passwordless-android - 1.0.4 + 1.1.0 ``` Gradle Kotlin DSL ```kotlin -implementation("com.bitwarden:passwordless-android:1.0.4") +implementation("com.bitwarden:passwordless-android:1.1.0") ``` Gradle Groovy DSL ```groovy -implementation 'com.bitwarden:passwordless-android:1.0.4' +implementation 'com.bitwarden:passwordless-android:1.1.0' ``` ### Permissions @@ -57,9 +57,6 @@ data class PasswordlessOptions( // Identifier for your server, for example 'example.com' if your backend is hosted at https://example.com. val rpId: String, - // This is where your Facet ID goes - val origin: String, - // Where your backend is hosted val backendUrl:String, @@ -87,54 +84,6 @@ In your application's `res/xml/assetlinks.xml`, you will then need to add the fo ``` -#### Facet ID - -The `Facet ID` will be used at a later point in this guide to use as the `origin`. - -To obtain the Facet ID continue the steps below, the facet id typically looks like: - -`android:apk-key-hash:POIplOLeHuvl-XAQckH0DwY4Yb1ydnnKcmhn-jibZbk` - -1. Execute the following command in your terminal: - - - MacOS & Linux: - ```bash - # Linux, Mac OS, Git Bash, ... - keytool -list -v -keystore ~/.android/debug.keystore | grep "SHA256: " | cut -d " " -f 3 | xxd -r -p | openssl base64 | sed 's/=//g' - ``` - - Windows: - - ```powershell - # Run keytool command and extract SHA256 hash - $keytoolOutput = keytool -list -v -keystore $HOME\.android\debug.keystore - $sha256Hash = ($keytoolOutput | Select-String "SHA256: ").ToString().Split(" ")[2] - - # Remove any non-hex characters from the hash - $hexHash = $sha256Hash -replace "[^0-9A-Fa-f]" - - # Convert the hexadecimal string to a byte array - $byteArray = [byte[]]@() - for ($i = 0; $i -lt $hexHash.Length; $i += 2) { - $byteArray += [byte]([Convert]::ToUInt32($hexHash.Substring($i, 2), 16)) - } - - # Convert the byte array to a base64 string - $base64String = [Convert]::ToBase64String($byteArray) - - Write-Output $base64String - ``` - -2. The default password for the debug keystore is `android`. For your production keystore, enter your chosen password. - -3. This command will output BASE64: - `POIplOLeHuvl+XAQckH0DwY4Yb1ydnnKcmhn+jibZbk` - -4. You need to convert this to BASE64URL format: - `POIplOLeHuvl-XAQckH0DwY4Yb1ydnnKcmhn-jibZbk` - -5. Now append it to `android:apk-key-hash:` to get the Facet ID: - `android:apk-key-hash:POIplOLeHuvl-XAQckH0DwY4Yb1ydnnKcmhn-jibZbk` - ### Configuration (Your back-end) #### Generating SHA-256 Certificate Fingerprints diff --git a/app/src/main/java/dev/passwordless/sampleapp/LoginFragment.kt b/app/src/main/java/dev/passwordless/sampleapp/LoginFragment.kt index 8796aa2..31451cb 100644 --- a/app/src/main/java/dev/passwordless/sampleapp/LoginFragment.kt +++ b/app/src/main/java/dev/passwordless/sampleapp/LoginFragment.kt @@ -1,8 +1,6 @@ package dev.passwordless.sampleapp -import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -10,11 +8,9 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcher import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceManager import com.auth0.android.jwt.JWT import dagger.hilt.android.AndroidEntryPoint import dev.passwordless.android.PasswordlessClient diff --git a/app/src/main/java/dev/passwordless/sampleapp/config/DemoPasswordlessOptions.kt b/app/src/main/java/dev/passwordless/sampleapp/config/DemoPasswordlessOptions.kt index af0fa29..4be9458 100644 --- a/app/src/main/java/dev/passwordless/sampleapp/config/DemoPasswordlessOptions.kt +++ b/app/src/main/java/dev/passwordless/sampleapp/config/DemoPasswordlessOptions.kt @@ -4,8 +4,6 @@ package dev.passwordless.sampleapp.config * @property API_KEY Your public API key. * @property RP_ID This stands for “relying party”; it can be considered as describing the organization responsible for registering and authenticating the user. * Set this as base url for your backend. So, https:///.well-known/assetlinks.json is accessible - * @property ORIGIN This is your generated key for your app, refer readme on how to generate this. - * String Format: "android:apk-key-hash:" , Example "android:apk-key-hash:NX7853gQH6KKGF4iT7WmpEtBDw7njd75WuaAFKzyW44" * @property YOUR_BACKEND_URL This is where your backend is hosted. */ class DemoPasswordlessOptions { @@ -13,7 +11,6 @@ class DemoPasswordlessOptions { const val API_KEY = "pwdemo:public:5aec1f24f65343239bf4e1c9a852e871" const val RP_ID = "demo.passwordless.dev" const val YOUR_BACKEND_URL = "https://demo.passwordless.dev" - const val ORIGIN = "android:apk-key-hash:oSQ_L7vpI6fdhEtKK6QKxy7A1o2GkJTK569M3toUIWU" const val API_URL = "https://v4.passwordless.dev" } } diff --git a/app/src/main/java/dev/passwordless/sampleapp/di/FragmentModule.kt b/app/src/main/java/dev/passwordless/sampleapp/di/FragmentModule.kt index 3653aa9..3fe774e 100644 --- a/app/src/main/java/dev/passwordless/sampleapp/di/FragmentModule.kt +++ b/app/src/main/java/dev/passwordless/sampleapp/di/FragmentModule.kt @@ -34,7 +34,6 @@ class FragmentModule { val options = PasswordlessOptions( DemoPasswordlessOptions.API_KEY, DemoPasswordlessOptions.RP_ID, - DemoPasswordlessOptions.ORIGIN, DemoPasswordlessOptions.API_URL ) diff --git a/passwordless/build.gradle.kts b/passwordless/build.gradle.kts index 12d2a21..b0f7102 100644 --- a/passwordless/build.gradle.kts +++ b/passwordless/build.gradle.kts @@ -12,7 +12,7 @@ android { compileSdk = 34 defaultConfig { - version = "1.0.4" + version = "1.1.0" minSdk = 28 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") diff --git a/passwordless/src/main/java/dev/passwordless/android/PasswordlessClient.kt b/passwordless/src/main/java/dev/passwordless/android/PasswordlessClient.kt index 5cb29bb..623a601 100644 --- a/passwordless/src/main/java/dev/passwordless/android/PasswordlessClient.kt +++ b/passwordless/src/main/java/dev/passwordless/android/PasswordlessClient.kt @@ -19,6 +19,7 @@ import dev.passwordless.android.rest.contracts.register.RegisterCompleteRequest import dev.passwordless.android.rest.contracts.register.RegisterCompleteResponse import dev.passwordless.android.rest.exceptions.PasswordlessApiException import dev.passwordless.android.rest.exceptions.ProblemDetails +import dev.passwordless.android.utils.SignatureService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -38,6 +39,7 @@ class PasswordlessClient( private lateinit var _coroutineScope: CoroutineScope private lateinit var credentialManager: CredentialManager private lateinit var _context: Context + private lateinit var _signatureService: SignatureService /** * Manages the communication with the Credential Manager API and performs registration operations for the Passwordless authentication flow. @@ -82,6 +84,7 @@ class PasswordlessClient( throw IllegalStateException("Context cannot be set more than once") } _context = context + _signatureService = SignatureService(_context) credentialManager = CredentialManager.create(_context) } @@ -109,7 +112,7 @@ class PasswordlessClient( val beginInputModel = LoginBeginRequest( alias = alias, rpId = _options.rpId, - origin = _options.origin + origin = _signatureService.getFacetId() ) val beginResponse = _httpClient .loginBegin(beginInputModel) @@ -129,7 +132,7 @@ class PasswordlessClient( val completeInputModel = LoginCompleteRequest( session = beginResponseData.session, response = credentialResponse.credential as PublicKeyCredential, - origin = _options.origin, + origin = _signatureService.getFacetId(), rpId = _options.rpId ) @@ -164,7 +167,7 @@ class PasswordlessClient( val beginInputModel = RegisterBeginRequest( token = token, rpId = _options.rpId, - origin = _options.origin + origin = _signatureService.getFacetId() ) val beginResponse = @@ -183,7 +186,7 @@ class PasswordlessClient( session = beginResult.session, response = response, nickname = nickname, - origin = _options.origin, + origin = _signatureService.getFacetId(), rpId = _options.rpId ) diff --git a/passwordless/src/main/java/dev/passwordless/android/rest/PasswordlessOptions.kt b/passwordless/src/main/java/dev/passwordless/android/rest/PasswordlessOptions.kt index 652eedf..25417c6 100644 --- a/passwordless/src/main/java/dev/passwordless/android/rest/PasswordlessOptions.kt +++ b/passwordless/src/main/java/dev/passwordless/android/rest/PasswordlessOptions.kt @@ -6,19 +6,15 @@ import dev.passwordless.android.rest.converters.Base64UrlConverter * @property apiUrl The Passwordless.dev server url. * @property apiKey Your public API key. * @property rpId This stands for “relying party”; it can be considered as describing the organization responsible for registering and authenticating the user. - * @property origin This is where your backend is hosted. */ data class PasswordlessOptions( val apiKey: String, val rpId: String, - val origin: String, val apiUrl: String = "https://v4.passwordless.dev" ) {init { require(apiKey.isNotBlank()) { "apiKey must not be blank" } require(isValidApiKey(apiKey)) { "apiKey must be a valid API key ':public:'" } require(rpId.isNotBlank()) { "rpId must not be blank" } - require(isFacetId(origin)) { "origin must be a facet id 'android:apk-key-hash:base64url'" } - require(isValidOrigin(origin)) { "origin must be a valid URL" } } private fun isValidApiKey(apiKey: String): Boolean { @@ -43,12 +39,4 @@ data class PasswordlessOptions( // If all checks pass, the apiKey is valid return true } - - private fun isFacetId(origin: String): Boolean { - return origin.startsWith("android:apk-key-hash:") - } - - private fun isValidOrigin(origin: String): Boolean { - return Base64UrlConverter.isValid(origin.substringAfter("android:apk-key-hash:")) - } } diff --git a/passwordless/src/main/java/dev/passwordless/android/rest/converters/Base64UrlConverter.kt b/passwordless/src/main/java/dev/passwordless/android/rest/converters/Base64UrlConverter.kt index 1a2fa30..8e105d5 100644 --- a/passwordless/src/main/java/dev/passwordless/android/rest/converters/Base64UrlConverter.kt +++ b/passwordless/src/main/java/dev/passwordless/android/rest/converters/Base64UrlConverter.kt @@ -3,18 +3,13 @@ package dev.passwordless.android.rest.converters import android.util.Base64 object Base64UrlConverter { - private val regex = Regex("^[A-Za-z0-9_-]*$") + private const val flags: Int = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP fun convert(input: String): ByteArray { - return Base64.decode(input, Base64.URL_SAFE) + return Base64.decode(input, flags) } - /** - * Checks if the input is a valid Base64 URL-encoded string. - * @param input The input to check. - * @return True if the input is a valid Base64 URL-encoded string, false otherwise. - */ - fun isValid(input: String): Boolean { - return regex.matches(input) + fun convert(input: ByteArray): String { + return Base64.encodeToString(input, flags) } } \ No newline at end of file diff --git a/passwordless/src/main/java/dev/passwordless/android/utils/SignatureService.kt b/passwordless/src/main/java/dev/passwordless/android/utils/SignatureService.kt new file mode 100644 index 0000000..60c4644 --- /dev/null +++ b/passwordless/src/main/java/dev/passwordless/android/utils/SignatureService.kt @@ -0,0 +1,43 @@ +package dev.passwordless.android.utils + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import dev.passwordless.android.rest.converters.Base64UrlConverter +import java.security.MessageDigest + +class SignatureService { + private val context: Context + private var facetId: String? = null + + constructor(context: Context) { + this.context = context; + } + + fun getFacetId(): String { + if (facetId != null) { + return facetId!! + } + val packageManager = context.packageManager + val packageName = context.packageName + try { + val packageInfo: PackageInfo = packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNING_CERTIFICATES) + + val signatures: Array = packageInfo.signingInfo.apkContentsSigners + + val signature: ByteArray = signatures[0].toByteArray() + val md: MessageDigest = MessageDigest.getInstance("SHA-256") + val hash: ByteArray = md.digest(signature) + + val shortFacetId = Base64UrlConverter.convert(hash) + this.facetId = "android:apk-key-hash:$shortFacetId" + return facetId!! + } catch (e: Exception) { + e.printStackTrace() + throw e + } + } +} \ No newline at end of file diff --git a/passwordless/src/test/java/dev/passwordless/android/rest/PasswordlessOptionsTests.kt b/passwordless/src/test/java/dev/passwordless/android/rest/PasswordlessOptionsTests.kt index 71a2797..8d8e713 100644 --- a/passwordless/src/test/java/dev/passwordless/android/rest/PasswordlessOptionsTests.kt +++ b/passwordless/src/test/java/dev/passwordless/android/rest/PasswordlessOptionsTests.kt @@ -12,16 +12,14 @@ class PasswordlessOptionsTests { // arrange val apiKey = "jonasandroid:public:ab2e4350d43946f7b4c93d98fa2c765e" val rpId = "example.com" - val origin = "android:apk-key-hash:POIplOLeHuvl-XAQckH0DwY4Yb1ydnnKcmhn-jibZbk" val apiUrl = "https://v4.passwordless.dev" // act - sut = PasswordlessOptions(apiKey, rpId, origin, apiUrl) + sut = PasswordlessOptions(apiKey, rpId, apiUrl) // assert assertEquals(apiKey, sut.apiKey) assertEquals(rpId, sut.rpId) - assertEquals(origin, sut.origin) assertEquals(apiUrl, sut.apiUrl) } @@ -30,11 +28,10 @@ class PasswordlessOptionsTests { // arrange val apiKey = "jonasandroid:public:ab2e4350d43946f7b4c93d98fa2c765e" val rpId = "example.com" - val origin = "android:apk-key-hash:POIplOLeHuvl-XAQckH0DwY4Yb1ydnnKcmhn-jibZbk" val apiUrl = "https://v4.passwordless.dev" // act - sut = PasswordlessOptions(apiKey, rpId, origin) + sut = PasswordlessOptions(apiKey, rpId) // assert assertEquals(apiUrl, sut.apiUrl) @@ -45,12 +42,11 @@ class PasswordlessOptionsTests { // arrange val apiKey = "jonasandroid:public:ab2e4350d43946f7b4c93d98fa2c765e" val rpId = "" - val origin = "android:apk-key-hash:POIplOLeHuvl-XAQckH0DwY4Yb1ydnnKcmhn-jibZbk" val apiUrl = "https://v4.passwordless.dev" // act assertThrows("rpId must not be blank", IllegalArgumentException::class.java) { - sut = PasswordlessOptions(apiKey, rpId, origin, apiUrl) + sut = PasswordlessOptions(apiKey, rpId, apiUrl) } } @@ -59,12 +55,11 @@ class PasswordlessOptionsTests { // arrange val apiKey = "badkey" val rpId = "yourexample.com" - val origin = "android:apk-key-hash:POIplOLeHuvl-XAQckH0DwY4Yb1ydnnKcmhn-jibZbk" val apiUrl = "https://v4.passwordless.dev" // act assertThrows("apiKey must be a valid API key ':public:'", IllegalArgumentException::class.java) { - sut = PasswordlessOptions(apiKey, rpId, origin, apiUrl) + sut = PasswordlessOptions(apiKey, rpId, apiUrl) } } @@ -73,40 +68,11 @@ class PasswordlessOptionsTests { // arrange val apiKey = "" val rpId = "example.com" - val origin = "android:apk-key-hash:POIplOLeHuvl-XAQckH0DwY4Yb1ydnnKcmhn-jibZbk" val apiUrl = "https://v4.passwordless.dev" // act assertThrows("apiKey must not be blank", IllegalArgumentException::class.java) { - sut = PasswordlessOptions(apiKey, rpId, origin, apiUrl) - } - } - - @Test - fun origin_throws_illegalArgumentException_whenIncorrectBase64Url() { - // arrange - val apiKey = "jonasandroid:public:ab2e4350d43946f7b4c93d98fa2c765e" - val rpId = "example.com" - val origin = "android:apk-key-hash:POIplOLeHuvl+XAQckH0DwY4Yb1ydnnKcmhn+jibZbk" - val apiUrl = "https://v4.passwordless.dev" - - // act - assertThrows("origin must be a valid URL", IllegalArgumentException::class.java) { - sut = PasswordlessOptions(apiKey, rpId, origin, apiUrl) - } - } - - @Test - fun origin_throws_illegalArgumentException_whenNotFacetId() { - // arrange - val apiKey = "jonasandroid:public:ab2e4350d43946f7b4c93d98fa2c765e" - val rpId = "example.com" - val origin = "https://yourbackend.com" - val apiUrl = "https://v4.passwordless.dev" - - // act - assertThrows("origin must be a facet id 'android:apk-key-hash:base64url'", IllegalArgumentException::class.java) { - sut = PasswordlessOptions(apiKey, rpId, origin, apiUrl) + sut = PasswordlessOptions(apiKey, rpId, apiUrl) } } }