Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PAS-531 | Generate Facet ID for Android #70

Merged
merged 5 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 3 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,20 @@ Apache Maven
<dependency>
<groupId>com.bitwarden</groupId>
<artifactId>passwordless-android</artifactId>
<version>1.0.4</version>
<version>1.1.0</version>
</dependency>
```

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
Expand All @@ -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,

Expand Down Expand Up @@ -87,54 +84,6 @@ In your application's `res/xml/assetlinks.xml`, you will then need to add the fo
</resources>
```

#### 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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
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
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@ 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://<Relying Party ID>/.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:<Hash value>" , Example "android:apk-key-hash:NX7853gQH6KKGF4iT7WmpEtBDw7njd75WuaAFKzyW44"
* @property YOUR_BACKEND_URL This is where your backend is hosted.
*/
class DemoPasswordlessOptions {
companion object {
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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ class FragmentModule {
val options = PasswordlessOptions(
DemoPasswordlessOptions.API_KEY,
DemoPasswordlessOptions.RP_ID,
DemoPasswordlessOptions.ORIGIN,
DemoPasswordlessOptions.API_URL
)

Expand Down
2 changes: 1 addition & 1 deletion passwordless/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -82,6 +84,7 @@ class PasswordlessClient(
throw IllegalStateException("Context cannot be set more than once")
}
_context = context
_signatureService = SignatureService(_context)
credentialManager = CredentialManager.create(_context)
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
)

Expand Down Expand Up @@ -164,7 +167,7 @@ class PasswordlessClient(
val beginInputModel = RegisterBeginRequest(
token = token,
rpId = _options.rpId,
origin = _options.origin
origin = _signatureService.getFacetId()
)

val beginResponse =
Expand All @@ -183,7 +186,7 @@ class PasswordlessClient(
session = beginResult.session,
response = response,
nickname = nickname,
origin = _options.origin,
origin = _signatureService.getFacetId(),
rpId = _options.rpId
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<appname>:public:<uuid-without-dashes>'" }
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 {
Expand All @@ -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:"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Signature> = 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
}
}
}
Loading