aboutsummaryrefslogtreecommitdiff
path: root/passkeys
diff options
context:
space:
mode:
Diffstat (limited to 'passkeys')
-rw-r--r--passkeys/build.gradle.kts1
-rw-r--r--passkeys/src/main/AndroidManifest.xml21
-rw-r--r--passkeys/src/main/java/app/passwordstore/passkeys/APSCredentialProviderService.kt96
-rw-r--r--passkeys/src/main/java/app/passwordstore/passkeys/CreatePasskeyActivity.kt149
-rw-r--r--passkeys/src/main/java/app/passwordstore/passkeys/GetPasskeyActivity.kt138
5 files changed, 391 insertions, 14 deletions
diff --git a/passkeys/build.gradle.kts b/passkeys/build.gradle.kts
index 7a69dadb..ca6b365e 100644
--- a/passkeys/build.gradle.kts
+++ b/passkeys/build.gradle.kts
@@ -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)
diff --git a/passkeys/src/main/AndroidManifest.xml b/passkeys/src/main/AndroidManifest.xml
index 0436b903..141b2027 100644
--- a/passkeys/src/main/AndroidManifest.xml
+++ b/passkeys/src/main/AndroidManifest.xml
@@ -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> \ No newline at end of file
diff --git a/passkeys/src/main/java/app/passwordstore/passkeys/APSCredentialProviderService.kt b/passkeys/src/main/java/app/passwordstore/passkeys/APSCredentialProviderService.kt
index b9711a40..bf84524f 100644
--- a/passkeys/src/main/java/app/passwordstore/passkeys/APSCredentialProviderService.kt
+++ b/passkeys/src/main/java/app/passwordstore/passkeys/APSCredentialProviderService.kt
@@ -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"
}
}
diff --git a/passkeys/src/main/java/app/passwordstore/passkeys/CreatePasskeyActivity.kt b/passkeys/src/main/java/app/passwordstore/passkeys/CreatePasskeyActivity.kt
new file mode 100644
index 00000000..c14097e4
--- /dev/null
+++ b/passkeys/src/main/java/app/passwordstore/passkeys/CreatePasskeyActivity.kt
@@ -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)}"
+ }
+}
diff --git a/passkeys/src/main/java/app/passwordstore/passkeys/GetPasskeyActivity.kt b/passkeys/src/main/java/app/passwordstore/passkeys/GetPasskeyActivity.kt
new file mode 100644
index 00000000..672c5ed9
--- /dev/null
+++ b/passkeys/src/main/java/app/passwordstore/passkeys/GetPasskeyActivity.kt
@@ -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)}"
+ }
+}