diff options
Diffstat (limited to 'passkeys')
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)}" + } +} |