feat: add code to select passkeys

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
This commit is contained in:
Aditya Wasan 2024-01-02 00:53:57 -05:00 committed by Harsh Shandilya
parent c21681747a
commit 5d31c65a59
5 changed files with 391 additions and 14 deletions

View file

@ -16,6 +16,7 @@ android {
dependencies {
implementation(libs.androidx.annotation)
implementation(libs.androidx.biometricKtx)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.credentials)
implementation(libs.kotlinx.coroutines.core)

View file

@ -3,6 +3,7 @@
~ SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name=".APSCredentialProviderService"
@ -15,7 +16,25 @@
</intent-filter>
<meta-data
android:name="android.credentials.provider"
android:resource="@xml/provider"/>
android:resource="@xml/provider" />
</service>
<activity
android:name="app.passwordstore.passkeys.CreatePasskeyActivity"
android:exported="true">
<intent-filter>
<action android:name="app.passwordstore.CREATE_PASSKEY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="app.passwordstore.passkeys.GetPasskeyActivity"
android:exported="true">
<intent-filter>
<action android:name="app.passwordstore.GET_PASSKEY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -1,8 +1,10 @@
package app.passwordstore.passkeys
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.annotation.RequiresApi
@ -10,14 +12,21 @@ import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.CredentialEntry
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.PublicKeyCredentialEntry
import androidx.credentials.webauthn.PublicKeyCredentialRequestOptions
import java.io.File
import logcat.logcat
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
public class APSCredentialProviderService : CredentialProviderService() {
@ -38,8 +47,17 @@ public class APSCredentialProviderService : CredentialProviderService() {
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>
) {}
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
) {
try {
val response = processGetCredentialsRequest(request)
callback.onResult(response)
} catch (e: GetCredentialException) {
callback.onError(GetCredentialUnknownException())
}
return
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
@ -64,27 +82,79 @@ public class APSCredentialProviderService : CredentialProviderService() {
@Suppress("UNUSED_PARAMETER") request: BeginCreatePublicKeyCredentialRequest
): BeginCreateCredentialResponse {
val createEntries: MutableList<CreateEntry> = mutableListOf()
println(request.requestJson)
createEntries.add(
CreateEntry(
DEFAULT_ACCOUNT_NAME,
createNewPendingIntent(DEFAULT_ACCOUNT_NAME, CREATE_PASSKEY_INTENT_ACTION)
createNewPendingIntent(CREATE_PASSKEY_INTENT_ACTION, CREATE_REQUEST_CODE),
)
)
return BeginCreateCredentialResponse(createEntries)
}
private fun createNewPendingIntent(accountId: String, action: String): PendingIntent {
private fun processGetCredentialsRequest(
request: BeginGetCredentialRequest
): BeginGetCredentialResponse {
val callingPackage = request.callingAppInfo?.packageName
val credentialEntries: MutableList<CredentialEntry> = mutableListOf()
for (option in request.beginGetCredentialOptions) {
when (option) {
is BeginGetPublicKeyCredentialOption -> {
credentialEntries.addAll(populatePasskeyData(callingPackage, option))
}
else -> {
logcat { "Request not supported" }
}
}
}
return BeginGetCredentialResponse(credentialEntries)
}
@SuppressLint("RestrictedApi")
private fun populatePasskeyData(
callingPackage: String?,
option: BeginGetPublicKeyCredentialOption,
): List<CredentialEntry> {
if (callingPackage.isNullOrEmpty()) return emptyList()
// Get your credentials from database where you saved during creation flow
val passkeysDir = File(filesDir.toString(), "/store/passkeys")
val appDir = File(passkeysDir, callingPackage)
if (!appDir.exists()) return emptyList()
// Get all passkeys for this package
val passkeyEntries: MutableList<CredentialEntry> = mutableListOf()
@Suppress("UNUSED_VARIABLE") val request = PublicKeyCredentialRequestOptions(option.requestJson)
val usernames =
appDir.listFiles()?.filter(File::isDirectory)?.map(File::getName) ?: return emptyList()
for (username in usernames) {
val data = Bundle()
passkeyEntries.add(
PublicKeyCredentialEntry(
context = applicationContext,
username = username,
pendingIntent = createNewPendingIntent(GET_PASSKEY_INTENT_ACTION, GET_REQUEST_CODE, data),
beginGetPublicKeyCredentialOption = option,
)
)
}
return passkeyEntries
}
private fun createNewPendingIntent(
action: String,
requestCode: Int,
extra: Bundle? = null,
): PendingIntent {
val intent = Intent(action).setPackage(packageName)
// Add your local account ID as an extra to the intent, so that when
// user selects this entry, the credential can be saved to this
// account
intent.putExtra(EXTRA_KEY_ACCOUNT_ID, accountId)
extra?.let { intent.putExtra("CREDENTIAL_DATA", extra) }
return PendingIntent.getActivity(
applicationContext,
REQUEST_CODE,
requestCode,
intent,
(PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT),
)
@ -94,10 +164,10 @@ public class APSCredentialProviderService : CredentialProviderService() {
// These intent actions are specified for corresponding activities
// that are to be invoked through the PendingIntent(s)
const val REQUEST_CODE = 1010101
const val EXTRA_KEY_ACCOUNT_ID = "EXTRA_KEY_ACCOUNT_ID"
const val CREATE_REQUEST_CODE = 10001
const val GET_REQUEST_CODE = 10002
const val DEFAULT_ACCOUNT_NAME = "Default Password Store"
const val CREATE_PASSKEY_INTENT_ACTION = "app.passwordstore.CREATE_PASSKEY"
const val GET_PASSKEY_INTENT_ACTION = "PACKAGE_NAME.GET_PASSKEY"
const val GET_PASSKEY_INTENT_ACTION = "app.passwordstore.GET_PASSKEY"
}
}

View file

@ -0,0 +1,149 @@
package app.passwordstore.passkeys
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.provider.CallingAppInfo
import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.webauthn.AuthenticatorAttestationResponse
import androidx.credentials.webauthn.FidoPublicKeyCredential
import androidx.credentials.webauthn.PublicKeyCredentialCreationOptions
import androidx.fragment.app.FragmentActivity
import java.io.File
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.spec.ECGenParameterSpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(34)
public class CreatePasskeyActivity : FragmentActivity() {
@SuppressLint("RestrictedApi")
private fun createAuthenticationCallback(
request: PublicKeyCredentialCreationOptions,
callingAppInfo: CallingAppInfo,
): BiometricPrompt.AuthenticationCallback {
return object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
finish()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
finish()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
// Generate a credentialId
val credentialId = ByteArray(32)
SecureRandom().nextBytes(credentialId)
// Generate a credential key pair
val spec = ECGenParameterSpec("secp256r1")
val keyPairGen = KeyPairGenerator.getInstance("EC")
keyPairGen.initialize(spec)
val keyPair = keyPairGen.genKeyPair()
// Save passkey in your database as per your own implementation
val passkeysDir = File(filesDir.toString(), "/store/passkeys")
if (!passkeysDir.exists()) passkeysDir.mkdirs()
val folderName = callingAppInfo.packageName
val subfolderName = request.user.name
val keyDir = File(passkeysDir, "/$folderName/$subfolderName")
if (!keyDir.exists()) keyDir.mkdirs()
val publicKey = File(keyDir, "public.key")
val privateKey = File(keyDir, "private.key")
publicKey.writeBytes(keyPair.public.encoded)
privateKey.writeBytes(keyPair.private.encoded)
// Create AuthenticatorAttestationResponse object to pass to FidoPublicKeyCredential
val response =
AuthenticatorAttestationResponse(
requestOptions = request,
credentialId = credentialId,
credentialPublicKey = keyPair.public.encoded,
origin = appInfoToOrigin(callingAppInfo),
up = true,
uv = true,
be = true,
bs = true,
packageName = callingAppInfo.packageName,
)
// https://w3c.github.io/webauthn/#enum-attachment
val credential =
FidoPublicKeyCredential(
rawId = credentialId,
response = response,
authenticatorAttachment = "platform",
)
val intent = Intent()
val createPublicKeyCredResponse = CreatePublicKeyCredentialResponse(credential.json())
// Set the CreateCredentialResponse as the result of the Activity
PendingIntentHandler.setCreateCredentialResponse(intent, createPublicKeyCredResponse)
setResult(Activity.RESULT_OK, intent)
finish()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
if (request != null && request.callingRequest is CreatePublicKeyCredentialRequest) {
val publicKeyRequest = request.callingRequest as CreatePublicKeyCredentialRequest
println(publicKeyRequest.requestJson)
createPasskey(
publicKeyRequest.requestJson,
request.callingAppInfo,
publicKeyRequest.clientDataHash,
)
}
}
@SuppressLint("RestrictedApi")
private fun createPasskey(
requestJson: String,
callingAppInfo: CallingAppInfo,
@Suppress("UNUSED_PARAMETER") clientDataHash: ByteArray?,
) {
val request = PublicKeyCredentialCreationOptions(requestJson)
val biometricPrompt =
BiometricPrompt(this, createAuthenticationCallback(request, callingAppInfo))
val promptInfo =
BiometricPrompt.PromptInfo.Builder()
.setTitle("Use your screen lock")
.setSubtitle("Create passkey for ${request.rp.name}")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG
) /* or BiometricManager.Authenticators.DEVICE_CREDENTIAL */
.build()
biometricPrompt.authenticate(promptInfo)
}
@OptIn(ExperimentalEncodingApi::class)
private fun appInfoToOrigin(info: CallingAppInfo): String {
val cert = info.signingInfo.apkContentsSigners[0].toByteArray()
val md = MessageDigest.getInstance("SHA-256")
val certHash = md.digest(cert)
// This is the format for origin
return "android:apk-key-hash:${Base64.encode(certHash)}"
}
}

View file

@ -0,0 +1,138 @@
package app.passwordstore.passkeys
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.provider.CallingAppInfo
import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.provider.ProviderGetCredentialRequest
import androidx.credentials.webauthn.AuthenticatorAttestationResponse
import androidx.credentials.webauthn.FidoPublicKeyCredential
import androidx.credentials.webauthn.PublicKeyCredentialCreationOptions
import androidx.fragment.app.FragmentActivity
import java.io.File
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.spec.ECGenParameterSpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(34)
public class GetPasskeyActivity : FragmentActivity() {
@SuppressLint("RestrictedApi")
private fun createAuthenticationCallback(
request: PublicKeyCredentialCreationOptions,
callingAppInfo: CallingAppInfo,
): BiometricPrompt.AuthenticationCallback {
return object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
finish()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
finish()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
// Generate a credentialId
val credentialId = ByteArray(32)
SecureRandom().nextBytes(credentialId)
// Generate a credential key pair
val spec = ECGenParameterSpec("secp256r1")
val keyPairGen = KeyPairGenerator.getInstance("EC")
keyPairGen.initialize(spec)
val keyPair = keyPairGen.genKeyPair()
// Save passkey in your database as per your own implementation
val passkeysDir = File(filesDir.toString(), "/store/passkeys")
if (!passkeysDir.exists()) passkeysDir.mkdirs()
val folderName = callingAppInfo.packageName
val subfolderName = request.user.name
val keyDir = File(passkeysDir, "/$folderName/$subfolderName")
if (!keyDir.exists()) keyDir.mkdirs()
val publicKey = File(keyDir, "public.key")
val privateKey = File(keyDir, "private.key")
publicKey.writeBytes(keyPair.public.encoded)
privateKey.writeBytes(keyPair.private.encoded)
// Create AuthenticatorAttestationResponse object to pass to FidoPublicKeyCredential
val response =
AuthenticatorAttestationResponse(
requestOptions = request,
credentialId = credentialId,
credentialPublicKey = keyPair.public.encoded,
origin = appInfoToOrigin(callingAppInfo),
up = true,
uv = true,
be = true,
bs = true,
packageName = callingAppInfo.packageName,
)
// https://w3c.github.io/webauthn/#enum-attachment
val credential =
FidoPublicKeyCredential(
rawId = credentialId,
response = response,
authenticatorAttachment = "platform",
)
val intent = Intent()
val createPublicKeyCredResponse = CreatePublicKeyCredentialResponse(credential.json())
// Set the CreateCredentialResponse as the result of the Activity
PendingIntentHandler.setCreateCredentialResponse(intent, createPublicKeyCredResponse)
setResult(Activity.RESULT_OK, intent)
finish()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val getRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
if (getRequest !is ProviderGetCredentialRequest) finish()
// val publicKeyRequest = getRequest!!.credentialOptions as GetPublicKeyCredentialOption
// val requestInfo = intent.getBundleExtra("CREDENTIAL_DATA")
// val credIdEnc = requestInfo.getString("credId")
// Get the saved passkey from your database based on the credential ID
// from the publickeyRequest
// val passkey = <your database>.getPasskey(credIdEnc)
}
@Suppress("unused")
@SuppressLint("RestrictedApi")
private fun createPasskey(
requestJson: String,
callingAppInfo: CallingAppInfo,
@Suppress("UNUSED_PARAMETER") clientDataHash: ByteArray?,
) {
val request = PublicKeyCredentialCreationOptions(requestJson)
val biometricPrompt =
BiometricPrompt(this, createAuthenticationCallback(request, callingAppInfo))
val promptInfo =
BiometricPrompt.PromptInfo.Builder()
.setTitle("Use your screen lock")
.setSubtitle("Create passkey for ${request.rp.name}")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG
) /* or BiometricManager.Authenticators.DEVICE_CREDENTIAL */
.build()
biometricPrompt.authenticate(promptInfo)
}
@OptIn(ExperimentalEncodingApi::class)
private fun appInfoToOrigin(info: CallingAppInfo): String {
val cert = info.signingInfo.apkContentsSigners[0].toByteArray()
val md = MessageDigest.getInstance("SHA-256")
val certHash = md.digest(cert)
// This is the format for origin
return "android:apk-key-hash:${Base64.encode(certHash)}"
}
}