mirror of
https://github.com/android-password-store/Android-Password-Store.git
synced 2026-04-10 16:25:52 +02:00
feat: add code to select passkeys
Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>
This commit is contained in:
parent
c21681747a
commit
5d31c65a59
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue