Skip to content

Commit

Permalink
Allow for on-device resident credential management
Browse files Browse the repository at this point in the history
  • Loading branch information
fmeum committed Jan 19, 2020
1 parent 038f0e9 commit dd4e5cc
Show file tree
Hide file tree
Showing 13 changed files with 285 additions and 77 deletions.
1 change: 1 addition & 0 deletions authenticator/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
<category android:name="android.support.wearable.complications.category.PROVIDER_CONFIG" />
</intent-filter>
</activity>
<activity android:name=".ui.main.ResidentCredentialsList" />

<service android:name="me.henneke.wearauthn.sync.UnlockComplicationListenerService">
<intent-filter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -441,11 +441,30 @@ abstract class AuthenticatorContext(private val context: Context, val isHidTrans
)
}

fun getResidentKeyUserIdsForRpId(rpIdHash: ByteArray): List<ByteArray> {
fun deleteResidentCredential(credential: WebAuthnCredential) {
check(credential.userId != null)
val rpIdHashString = credential.rpIdHash.base64()
val encodedUserId = credential.userId.base64()
val encodedKeyHandle = credential.keyHandle.base64()
val rpPrefs = getResidentKeyPrefsForRpId(credential.rpIdHash)
rpPrefs.edit {
remove("uid+$encodedUserId")
remove("kh+$encodedKeyHandle")
}
if (rpPrefs.all.none { it.key.startsWith("uid+") || it.key.startsWith("kh+") }) {
// This was the last resident credential for this RP, delete its record.
context.deleteSharedPreferences(RESIDENT_KEY_PREFERENCE_FILE_PREFIX + rpIdHashString)
context.sharedPreferences(RESIDENT_KEY_RP_ID_HASHES_FILE).edit {
remove(rpIdHashString)
}
}
}

fun getResidentKeyUserIdsForRpId(rpIdHash: ByteArray): List<String> {
require(rpIdHash.size == 32)
val prefs = getResidentKeyPrefsForRpId(rpIdHash)
return prefs.all.keys.filter { it.startsWith("uid+") }
.mapNotNull { it.substring(4).base64() }.toList()
.map { it.substring(4) }.toList()
}

private fun getResidentKeyPrefsForRpId(rpIdHash: ByteArray): SharedPreferences {
Expand Down Expand Up @@ -544,6 +563,30 @@ abstract class AuthenticatorContext(private val context: Context, val isHidTrans
}
}

fun getAllResidentCredentials(context: Context): Map<String, List<WebAuthnCredential>> {
var unknownSiteCounter = 1
return context.sharedPreferences(RESIDENT_KEY_RP_ID_HASHES_FILE).all.keys
.sorted() // Guarantee deterministic assignment of indices to RPs without stored rpId
.mapNotNull {
it.base64()
}
.map { rpIdHash ->
val rpPrefs =
context.sharedPreferences(RESIDENT_KEY_PREFERENCE_FILE_PREFIX + rpIdHash.base64())
val rpId = rpPrefs.getString("rpId", null)
?: "Unknown site #$unknownSiteCounter".also { unknownSiteCounter++ }
val credentials = rpPrefs.all.keys
.filter { it.startsWith("uid+") }
.mapNotNull {
WebAuthnCredential.deserialize(
rpPrefs.getString(it, null) ?: return@mapNotNull null,
rpIdHash
)
}.sortedByDescending { it.creationDate }
Pair(rpId, credentials)
}.toMap()
}

fun isScreenLockEnabled(context: Context) = context.keyguardManager?.isDeviceSecure == true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import java.security.*
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.*
import javax.crypto.*
Expand Down Expand Up @@ -101,7 +102,10 @@ private val authenticatorKeyAgreementKeyPair: KeyPair by lazy {

@ExperimentalUnsignedTypes
val authenticatorKeyAgreementKey
get() = getCoseRepresentation(authenticatorKeyAgreementKeyPair.public as ECPublicKey, ECAlgorithm.KeyAgreement)
get() = getCoseRepresentation(
authenticatorKeyAgreementKeyPair.public as ECPublicKey,
ECAlgorithm.KeyAgreement
)

val authenticatorKeyAgreementParams: ECParameterSpec
get() = (authenticatorKeyAgreementKeyPair.public as ECPublicKey).params
Expand Down Expand Up @@ -533,8 +537,8 @@ abstract class Credential {
}

@ExperimentalUnsignedTypes
fun delete(context: AuthenticatorContext?) {
context?.deleteCounter(keyAlias)
open fun delete(context: AuthenticatorContext) {
context.deleteCounter(keyAlias)
deleteKey(keyAlias)
}

Expand Down Expand Up @@ -703,6 +707,44 @@ class WebAuthnCredential(
}).toCbor().base64()
}

override fun delete(context: AuthenticatorContext) {
if (isResident)
context.deleteResidentCredential(this)
super.delete(context)
}

fun getTwoLineInfo(simpleCount: Int): Pair<String, String?> {
val userDisplayNameInfo = userDisplayName.takeUnless { it.isNullOrBlank() }
val userNameInfo = userName.takeUnless { it.isNullOrBlank() }
val creationDateInfo =
creationDate?.let { "Created ${SimpleDateFormat.getDateInstance().format(it)}" }
return when {
userDisplayNameInfo != null && userNameInfo != null -> {
Pair(userDisplayNameInfo, userNameInfo)
}
userNameInfo != null -> {
Pair(userNameInfo, creationDateInfo)
}
userDisplayNameInfo != null -> {
Pair(userDisplayNameInfo, creationDateInfo)
}
else -> {
Pair("Account #${simpleCount}", creationDateInfo)
}
}
}

fun getFormattedInfo(): String? {
val userDisplayNameInfo = userDisplayName.takeUnless { it.isNullOrBlank() }
val userNameInfo = userName.takeUnless { it.isNullOrBlank() }
val creationDateInfo =
creationDate?.let { "Created ${SimpleDateFormat.getDateInstance().format(it)}" }
val infoOrBlank = if (userDisplayNameInfo != null) "$userDisplayNameInfo\n" else "" +
if (userNameInfo != null) "$userNameInfo\n" else "" +
if (creationDateInfo != null) "$creationDateInfo" else ""
return infoOrBlank.takeUnless { it.isBlank() }
}

@ExperimentalUnsignedTypes
val androidKeystoreAttestation: CborArray
get() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ object Authenticator {
} else {
// Locate all rk credentials bound to the provided rpId
context.getResidentKeyUserIdsForRpId(rpIdHash).asSequence()
.mapNotNull { userId -> context.getResidentCredential(rpIdHash, userId.base64()) }
.mapNotNull { userId -> context.getResidentCredential(rpIdHash, userId) }
.sortedByDescending { it.creationDate }
}.filter {
// If the hmac-secret extension is requested, we must only offer credentials that were
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,25 +69,10 @@ class CredentialChooserDialog(
val titleView = credentialView.findViewById<TextView>(R.id.title)
val summaryView = credentialView.findViewById<TextView>(R.id.summary)
val credential = getItem(position)
when {
!credential.userDisplayName.isNullOrBlank() -> {
titleView.text = credential.userDisplayName
summaryView.text = credential.userName
}
!credential.userName.isNullOrBlank() -> {
titleView.text = credential.userName
summaryView.text = null
}
else -> {
titleView.text = "Account #${position + 1}"
summaryView.text = if (credential.creationDate != null) {
"Created ${SimpleDateFormat.getDateInstance().format(credential.creationDate)}"
} else {
null
}
}
}
summaryView.visibility = if (summaryView.text.isNullOrBlank()) View.GONE else View.VISIBLE
val credentialInfo = credential.getTwoLineInfo(position + 1)
titleView.text = credentialInfo.first
summaryView.text = credentialInfo.second
summaryView.visibility = if (summaryView.text == null) View.GONE else View.VISIBLE
return credentialView
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import me.henneke.wearauthn.R
import me.henneke.wearauthn.bthid.HidDataSender
import me.henneke.wearauthn.bthid.HidDeviceProfile
import me.henneke.wearauthn.bthid.canUseAuthenticator
import me.henneke.wearauthn.bthid.hasCompatibleBondedDevice
import me.henneke.wearauthn.bthid.*
import me.henneke.wearauthn.fido.context.AuthenticatorContext
import me.henneke.wearauthn.fido.context.armUserVerificationFuse
import me.henneke.wearauthn.fido.context.getUserVerificationState
Expand All @@ -55,6 +52,7 @@ class AuthenticatorMainMenu : PreferenceFragment(), CoroutineScope {
private lateinit var discoverableSwitchPreference: SwitchPreference
private lateinit var nfcSettingsPreference: Preference
private lateinit var singleFactorModeSwitchPreference: SwitchPreference
private lateinit var manageCredentialsPreference: Preference

private val REQUEST_CODE_ENABLE_BLUETOOTH = 1
private val REQUEST_CODE_MAKE_DISCOVERABLE = 2
Expand All @@ -79,6 +77,8 @@ class AuthenticatorMainMenu : PreferenceFragment(), CoroutineScope {
nfcSettingsPreference = findPreference(getString(R.string.preference_nfc_settings))
singleFactorModeSwitchPreference =
findPreference(getString(R.string.preference_single_factor_mode)) as SwitchPreference
manageCredentialsPreference =
findPreference(getString(R.string.preference_credential_management))
}

override fun onResume() {
Expand Down Expand Up @@ -256,8 +256,9 @@ class AuthenticatorMainMenu : PreferenceFragment(), CoroutineScope {
}

private fun updateUserVerificationPreferencesState() {
val userVerificationState = getUserVerificationState(context)
singleFactorModeSwitchPreference.apply {
when (getUserVerificationState(context)) {
when (userVerificationState) {
true -> {
isEnabled = false
isChecked = true
Expand Down Expand Up @@ -316,6 +317,43 @@ class AuthenticatorMainMenu : PreferenceFragment(), CoroutineScope {
}
}
}
manageCredentialsPreference.apply {
if (userVerificationState != false) {
isEnabled = true
setIcon(R.drawable.ic_btn_key)
summary = null
} else {
isEnabled = false
icon = null
summary = getString(R.string.summary_manage_credentials_disabled)
}
setOnPreferenceClickListener {
val intent =
Intent(
context,
ConfirmDeviceCredentialActivity::class.java
).apply {
putExtra(
EXTRA_CONFIRM_DEVICE_CREDENTIAL_RECEIVER,
object : ResultReceiver(Handler()) {
override fun onReceiveResult(
resultCode: Int,
resultData: Bundle?
) {
if (resultCode == Activity.RESULT_OK)
context.startActivity(
Intent(
context,
ResidentCredentialsList::class.java
)
)
}
})
}
context.startActivity(intent)
true
}
}
}

private val hidProfileListener = object : HidDataSender.ProfileListener {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package me.henneke.wearauthn.ui.main

import android.app.AlertDialog
import android.os.Bundle
import android.preference.PreferenceCategory
import android.preference.PreferenceFragment
import android.support.wearable.preference.WearableDialogPreference
import android.support.wearable.preference.WearablePreferenceActivity
import android.support.wearable.view.AcceptDenyDialog
import me.henneke.wearauthn.R
import me.henneke.wearauthn.fido.context.AuthenticatorContext

@ExperimentalUnsignedTypes
class ResidentCredentialsList : WearablePreferenceActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startPreferenceFragment(ResidentCredentialsPreferenceFragment(), false)
}
}

@ExperimentalUnsignedTypes
class ResidentCredentialsPreferenceFragment : PreferenceFragment() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
createCredentialList()
}

private fun createCredentialList() {
preferenceScreen = preferenceManager.createPreferenceScreen(context)
val credentialsPerRp = AuthenticatorContext.getAllResidentCredentials(context)
preferenceScreen.title =
if (credentialsPerRp.isEmpty()) "No single-factor credentials" else "Single-factor credentials"
for ((rpId, credentials) in credentialsPerRp) {
if (credentials.isEmpty())
continue
val rpCategory = PreferenceCategory(context)
// PreferenceCategory has to be added to the PreferenceScreen before customizing it
// https://stackoverflow.com/a/49108303/297261
preferenceScreen.addPreference(rpCategory)
rpCategory.apply {
title = rpId
for ((index, credential) in credentials.withIndex()) {
credential.unlockUserInfoIfNecessary()
val credentialTwoLineInfo = credential.getTwoLineInfo(index + 1)
val credentialFormattedInfo = credential.getFormattedInfo()
addPreference(object : WearableDialogPreference(context) {
init {
isIconSpaceReserved = false
title = credentialTwoLineInfo.first
summary = credentialTwoLineInfo.second
}

override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
super.onPrepareDialogBuilder(builder)
val preference = this
builder.apply {
setTitle(rpId)
setMessage(credentialFormattedInfo)
setPositiveButton(R.string.button_delete) { _, _ ->
AcceptDenyDialog(context).run {
setTitle(rpId)
setMessage("Delete credential for\n${credentialTwoLineInfo.first}\n?")
setPositiveButton { _, _ ->
AuthenticatorContext.deleteResidentCredential(
context,
credential
)
rpCategory.removePreference(preference)
if (rpCategory.preferenceCount == 0)
preferenceScreen.removePreference(rpCategory)
}
setNegativeButton { _, _ -> }
show()
}
}
}
}
})
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@
android:left="@dimen/button_icon_margin"
android:right="@dimen/button_icon_margin"
android:top="@dimen/button_icon_margin"
android:drawable="@drawable/ic_person" />
android:drawable="@drawable/ic_key" />
</layer-list>
27 changes: 27 additions & 0 deletions authenticator/src/main/res/drawable/ic_key.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?><!--
Based on ic_vpn_key_24px.svg from the Material Design Icons,
modified by Fabian Henneke
Original license:
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/>
</vector>
Loading

0 comments on commit dd4e5cc

Please sign in to comment.