diff --git a/authenticator/src/main/java/me/henneke/wearauthn/Utils.kt b/authenticator/src/main/java/me/henneke/wearauthn/Utils.kt index 77a159c..93713c3 100644 --- a/authenticator/src/main/java/me/henneke/wearauthn/Utils.kt +++ b/authenticator/src/main/java/me/henneke/wearauthn/Utils.kt @@ -74,3 +74,19 @@ fun String.escapeHtml(): String = TextUtils.htmlEncode(this) fun String.truncate(targetLength: Int): String = if (length > targetLength) this.take(targetLength - 1) + "…" else this + +fun String.breakAt(lineBreaks: List): String? { + if (this == "") + return "" + val lineRanges = (listOf(0) + lineBreaks + listOf(length)).zipWithNext() + if (!lineRanges.all { (start, end) -> start < end }) + return null + + return lineRanges.map { (start, end) -> + val line = substring(start until end) + if (line.last() == '\n' || end == length) + line + else + line + '\n' + }.joinToString(separator = "") +} diff --git a/authenticator/src/main/java/me/henneke/wearauthn/fido/context/AuthenticatorContext.kt b/authenticator/src/main/java/me/henneke/wearauthn/fido/context/AuthenticatorContext.kt index 8919c1b..30dd015 100644 --- a/authenticator/src/main/java/me/henneke/wearauthn/fido/context/AuthenticatorContext.kt +++ b/authenticator/src/main/java/me/henneke/wearauthn/fido/context/AuthenticatorContext.kt @@ -18,7 +18,9 @@ import kotlinx.coroutines.* import me.henneke.wearauthn.base64 import me.henneke.wearauthn.escapeHtml import me.henneke.wearauthn.fido.context.AuthenticatorAction.* -import me.henneke.wearauthn.fido.ctap2.* +import me.henneke.wearauthn.fido.ctap2.AttestationType +import me.henneke.wearauthn.fido.ctap2.CTAP_ERR +import me.henneke.wearauthn.fido.ctap2.CborValue import me.henneke.wearauthn.fido.ctap2.CtapError.OperationDenied import me.henneke.wearauthn.fido.ctap2.CtapError.Other import me.henneke.wearauthn.fido.u2f.resolveAppIdHash @@ -187,7 +189,8 @@ enum class AuthenticatorSpecialStatus { abstract class AuthenticatorContext(val isHidTransport: Boolean) { abstract fun notifyUser(info: RequestInfo) abstract fun handleSpecialStatus(specialStatus: AuthenticatorSpecialStatus) - abstract suspend fun confirmWithUser(info: RequestInfo): Boolean + abstract suspend fun confirmRequestWithUser(info: RequestInfo): Boolean + abstract suspend fun confirmTransactionWithUser(rpId: String, prompt: String): String? // We use cached credentials only over NFC, where low latency responses are very important private val useCachedCredential = !isHidTransport diff --git a/authenticator/src/main/java/me/henneke/wearauthn/fido/ctap2/Authenticator.kt b/authenticator/src/main/java/me/henneke/wearauthn/fido/ctap2/Authenticator.kt index d8342af..72f565b 100644 --- a/authenticator/src/main/java/me/henneke/wearauthn/fido/ctap2/Authenticator.kt +++ b/authenticator/src/main/java/me/henneke/wearauthn/fido/ctap2/Authenticator.kt @@ -1,6 +1,7 @@ package me.henneke.wearauthn.fido.ctap2 import android.util.Log +import kotlinx.coroutines.delay import me.henneke.wearauthn.* import me.henneke.wearauthn.fido.context.* import me.henneke.wearauthn.fido.context.AuthenticatorAction.* @@ -112,7 +113,7 @@ object Authenticator { "Received a Chrome GetTouchRequest, replying with dummy response after confirmation" ) val requestInfo = Ctap2RequestInfo(REQUIREMENTS_NOT_MET_CHROME, rpId) - val showErrorInBrowser = context.confirmWithUser(requestInfo) + val showErrorInBrowser = context.confirmRequestWithUser(requestInfo) if (showErrorInBrowser) return DUMMY_MAKE_CREDENTIAL_RESPONSE else @@ -130,7 +131,7 @@ object Authenticator { rpId = rpId, rpName = rpName ) - val revealRegistration = context.confirmWithUser(requestInfo) + val revealRegistration = context.confirmRequestWithUser(requestInfo) if (revealRegistration) CTAP_ERR(CredentialExcluded) else @@ -166,7 +167,11 @@ object Authenticator { // Step 4 // We only validate the extension inputs here, the actual processing is done later. - val activeExtensions = parseExtensionInputs(extensions, REGISTER) + val activeExtensions = parseExtensionInputs( + extensions = extensions, + action = REGISTER, + canUseDisplay = context.isHidTransport + ) // Step 7 // We do not support any PIN protocols @@ -184,7 +189,7 @@ object Authenticator { requiresUserVerification = requireUserVerification ) - if (!context.confirmWithUser(requestInfo)) + if (!context.confirmRequestWithUser(requestInfo)) CTAP_ERR(OperationDenied) if (requireUserVerification && !context.verifyUser()) @@ -316,12 +321,20 @@ object Authenticator { // Step 6 // We only validate the extension inputs here, the actual processing is done later. - val activeExtensions = parseExtensionInputs(extensions, AUTHENTICATE) + val activeExtensions = parseExtensionInputs( + extensions = extensions, + action = AUTHENTICATE, + canUseDisplay = context.isHidTransport + ).toMutableMap() // hmac-secret requires user presence, but the spec is not clear on whether this has to be // obtained separately if (activeExtensions.containsKey(Extension.HmacSecret)) requireUserPresence = true + if (activeExtensions.containsKey(Extension.TxAuthSimple)) { + if (!requireUserPresence && !requireUserVerification) + requireUserPresence = true + } // Step 1 val useResidentKey = allowList == null @@ -361,6 +374,24 @@ object Authenticator { // Step 7 + // txAuthSimple leads to a prompt that the user has to confirm. This prompt has to be shown + // before the usual user presence check as per spec, but we omit it if there are no + // applicable credentials. + if (activeExtensions.containsKey(Extension.TxAuthSimple) && numberOfCredentials > 0) { + val txAuthSimpleInput = + activeExtensions[Extension.TxAuthSimple] as TxAuthSimpleAuthenticateInput + // The actual prompt confirmed by the user differs from the requested prompt only by + // potentially containing additional newlines. We simply replace the extension input + // with the prompt that was actually shown to keep extension handling simple. + val actualPrompt = context.confirmTransactionWithUser(rpId, txAuthSimpleInput.prompt) + ?: CTAP_ERR(OperationDenied) + activeExtensions[Extension.TxAuthSimple] = TxAuthSimpleAuthenticateInput(actualPrompt) + // Introduce a small delay between the transaction confirmation and the usual + // GetAssertion confirmation, otherwise the user may inadvertently confirm both with one + // tap. + delay(500) + } + // Since requests requiring user verification may ask the user to confirm their device // credentials, we upgrade them to also require user presence. if (requireUserVerification) @@ -383,7 +414,7 @@ object Authenticator { // We have not found any credentials, ask the user for permission to reveal this fact. Ctap2RequestInfo(AUTHENTICATE_NO_CREDENTIALS, rpId) } - if (requireUserPresence && !context.confirmWithUser(requestInfo)) + if (requireUserPresence && !context.confirmRequestWithUser(requestInfo)) CTAP_ERR(OperationDenied) if (!requireUserPresence) Log.i(TAG, "Processing silent GetAssertion request") @@ -492,7 +523,11 @@ object Authenticator { CborTextString("U2F_V2") ) ), - GET_INFO_RESPONSE_EXTENSIONS to Extension.identifiersAsCbor, + GET_INFO_RESPONSE_EXTENSIONS to + if (context.isHidTransport) + Extension.identifiersAsCbor + else + Extension.noDisplayIdentifiersAsCbor, GET_INFO_RESPONSE_AAGUID to CborByteString(WEARAUTHN_AAGUID), GET_INFO_RESPONSE_OPTIONS to CborTextStringMap(optionsMap), GET_INFO_RESPONSE_MAX_MSG_SIZE to CborLong(MAX_CBOR_MSG_SIZE), @@ -568,7 +603,8 @@ object Authenticator { private fun parseExtensionInputs( extensions: Map?, - action: AuthenticatorAction + action: AuthenticatorAction, + canUseDisplay: Boolean ): Map { if (extensions == null) return mapOf() @@ -577,7 +613,7 @@ object Authenticator { }.map { val extension = Extension.fromIdentifier(it.key) // parseInput throws an appropriate exception if the input is not of the correct form. - Pair(extension, extension.parseInput(it.value, action)) + Pair(extension, extension.parseInput(it.value, action, canUseDisplay)) }.toMap() } @@ -652,6 +688,13 @@ object Authenticator { require(input is NoInput) Extension.identifiersAsCbor } + Extension.TxAuthSimple -> { + require(action == AUTHENTICATE) + require(input is TxAuthSimpleAuthenticateInput) + // At this point, either we have returned an OperationDenied error or the user has + // confirmed the prompt (with added line breaks). + CborTextString(input.prompt) + } Extension.UserVerificationMethod -> { require(input is NoInput) val keyProtectionType = diff --git a/authenticator/src/main/java/me/henneke/wearauthn/fido/ctap2/Messages.kt b/authenticator/src/main/java/me/henneke/wearauthn/fido/ctap2/Messages.kt index 288de08..349b181 100644 --- a/authenticator/src/main/java/me/henneke/wearauthn/fido/ctap2/Messages.kt +++ b/authenticator/src/main/java/me/henneke/wearauthn/fido/ctap2/Messages.kt @@ -160,13 +160,20 @@ data class HmacSecretAuthenticateInput( val saltAuth: ByteArray ) : ExtensionInput +data class TxAuthSimpleAuthenticateInput(val prompt: String) : ExtensionInput + enum class Extension(val identifier: String) { HmacSecret("hmac-secret"), SupportedExtensions("exts"), + TxAuthSimple("txAuthSimple"), UserVerificationMethod("uvm"); @ExperimentalUnsignedTypes - fun parseInput(input: CborValue, action: AuthenticatorAction): ExtensionInput { + fun parseInput( + input: CborValue, + action: AuthenticatorAction, + canUseDisplay: Boolean + ): ExtensionInput { require(action == AUTHENTICATE || action == REGISTER) return when (this) { HmacSecret -> { @@ -232,6 +239,18 @@ enum class Extension(val identifier: String) { CTAP_ERR(InvalidParameter, "Input was not 'true' for exts") NoInput } + TxAuthSimple -> { + if (action != AUTHENTICATE) + CTAP_ERR(InvalidParameter, "txAuthSimple not supported during MakeCredential") + // txAuthSimple requires interactive confirmation and therefore requires a transport + // that can use the display. + if (!canUseDisplay) + CTAP_ERR(UnsupportedExtension) + val prompt = input.unbox() + if (prompt.isBlank()) + CTAP_ERR(InvalidParameter, "Input was only whitespace for txAuthSimple") + TxAuthSimpleAuthenticateInput(prompt) + } UserVerificationMethod -> { if (!input.unbox()) CTAP_ERR(InvalidParameter, "Input was not 'true' for uvm") @@ -242,9 +261,13 @@ enum class Extension(val identifier: String) { companion object { val identifiers = values().map { it.identifier } + val noDisplayIdentifiers = values().filter { it != TxAuthSimple }.map { it.identifier } @ExperimentalUnsignedTypes val identifiersAsCbor = CborArray(identifiers.map { CborTextString(it) }.toTypedArray()) + @ExperimentalUnsignedTypes + val noDisplayIdentifiersAsCbor = + CborArray(noDisplayIdentifiers.map { CborTextString(it) }.toTypedArray()) fun fromIdentifier(identifier: String) = requireNotNull(values().associateBy(Extension::identifier)[identifier]) diff --git a/authenticator/src/main/java/me/henneke/wearauthn/fido/hid/Framing.kt b/authenticator/src/main/java/me/henneke/wearauthn/fido/hid/Framing.kt index 29721ce..ca6c204 100644 --- a/authenticator/src/main/java/me/henneke/wearauthn/fido/hid/Framing.kt +++ b/authenticator/src/main/java/me/henneke/wearauthn/fido/hid/Framing.kt @@ -415,7 +415,7 @@ class TransactionManager(private val authenticatorContext: AuthenticatorContext) // CONDITIONS_NOT_SATISFIED while waiting. activeU2fConfirmation = U2fContinuation( message, - async { authenticatorContext.confirmWithUser(requestInfo) }, + async { authenticatorContext.confirmRequestWithUser(requestInfo) }, cont ) rearmU2fRetryTimeout() diff --git a/authenticator/src/main/java/me/henneke/wearauthn/fido/nfc/NfcAuthenticatorService.kt b/authenticator/src/main/java/me/henneke/wearauthn/fido/nfc/NfcAuthenticatorService.kt index 6ae5c85..a09562f 100644 --- a/authenticator/src/main/java/me/henneke/wearauthn/fido/nfc/NfcAuthenticatorService.kt +++ b/authenticator/src/main/java/me/henneke/wearauthn/fido/nfc/NfcAuthenticatorService.kt @@ -15,11 +15,9 @@ import me.henneke.wearauthn.fido.ApduException import me.henneke.wearauthn.fido.CommandApdu import me.henneke.wearauthn.fido.ResponseApdu import me.henneke.wearauthn.fido.StatusWord -import me.henneke.wearauthn.fido.context.AuthenticatorContext -import me.henneke.wearauthn.fido.context.AuthenticatorSpecialStatus +import me.henneke.wearauthn.fido.context.* import me.henneke.wearauthn.fido.context.AuthenticatorSpecialStatus.RESET import me.henneke.wearauthn.fido.context.AuthenticatorSpecialStatus.USER_NOT_AUTHENTICATED -import me.henneke.wearauthn.fido.context.RequestInfo import me.henneke.wearauthn.fido.ctap2.Authenticator import me.henneke.wearauthn.ui.isDoNotDisturbEnabled import me.henneke.wearauthn.ui.showToast @@ -218,10 +216,14 @@ class NfcAuthenticatorService : HostApduService(), CoroutineScope { } } - override suspend fun confirmWithUser(info: RequestInfo): Boolean { + override suspend fun confirmRequestWithUser(info: RequestInfo): Boolean { // User presence is always certain with NFC transport. return true } + + override suspend fun confirmTransactionWithUser(rpId: String, prompt: String): String? { + throw IllegalStateException("Transaction confirmation not possible via NFC") + } } companion object { diff --git a/authenticator/src/main/java/me/henneke/wearauthn/ui/TimedAcceptDenyDialog.kt b/authenticator/src/main/java/me/henneke/wearauthn/ui/TimedAcceptDenyDialog.kt index 9eef415..da3d47c 100644 --- a/authenticator/src/main/java/me/henneke/wearauthn/ui/TimedAcceptDenyDialog.kt +++ b/authenticator/src/main/java/me/henneke/wearauthn/ui/TimedAcceptDenyDialog.kt @@ -24,7 +24,9 @@ import android.content.DialogInterface import android.os.PowerManager import android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP import android.os.PowerManager.FULL_WAKE_LOCK +import android.util.Log import android.view.View +import android.view.ViewTreeObserver import kotlinx.android.synthetic.main.timed_accept_deny_dialog.* import me.henneke.wearauthn.R @@ -35,6 +37,9 @@ private const val DEFAULT_TIMEOUT = 5_000L class TimedAcceptDenyDialog(context: Context) : Dialog(context) { + var messageLineBreaks: List? = null + private set + private var wakeOnShow = false private var vibrateOnShow = false private var positiveButtonListener: DialogInterface.OnClickListener? = null @@ -66,6 +71,19 @@ class TimedAcceptDenyDialog(context: Context) : Dialog(context) { negativeButton.setOnClickListener(actionHandler) negativeTimeout.setOnTimerFinishedListener(actionHandler) positiveButton.setOnClickListener(actionHandler) + // The txAuthSimple extension requires us to record the line breaks inserted into the + // message by the text rendering engine. This information can only be extracted reliably + // right before a draw. Since we do not expect the message to change after the dialog is + // shown, we remove the listener after the text layout has been obtained once. + messageView.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + if (messageView.layout != null) { + computeMessageLineBreaks() + messageView.viewTreeObserver.removeOnPreDrawListener(this) + } + return true + } + }) } override fun onStart() { @@ -158,4 +176,10 @@ class TimedAcceptDenyDialog(context: Context) : Dialog(context) { fun setWakeOnShow(wakeOnShow: Boolean) { this.wakeOnShow = wakeOnShow } + + private fun computeMessageLineBreaks() { + val layout = messageView.layout + if (layout != null) + messageLineBreaks = (0 until layout.lineCount - 1).map { layout.getLineEnd(it) } + } } \ No newline at end of file diff --git a/authenticator/src/main/java/me/henneke/wearauthn/ui/main/AuthenticatorAttachedActivity.kt b/authenticator/src/main/java/me/henneke/wearauthn/ui/main/AuthenticatorAttachedActivity.kt index e3e1b9f..a3f235d 100644 --- a/authenticator/src/main/java/me/henneke/wearauthn/ui/main/AuthenticatorAttachedActivity.kt +++ b/authenticator/src/main/java/me/henneke/wearauthn/ui/main/AuthenticatorAttachedActivity.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import me.henneke.wearauthn.R +import me.henneke.wearauthn.breakAt import me.henneke.wearauthn.bthid.* import me.henneke.wearauthn.complication.ShortcutComplicationProviderService import me.henneke.wearauthn.fido.context.AuthenticatorContext @@ -208,8 +209,7 @@ class AuthenticatorAttachedActivity : WearableActivity() { // a special status. } - override suspend fun confirmWithUser(info: RequestInfo): Boolean { - val message = info.confirmationPrompt + override suspend fun confirmRequestWithUser(info: RequestInfo): Boolean { return try { status = AuthenticatorStatus.WAITING_FOR_UP withContext(Dispatchers.Main) { @@ -217,7 +217,7 @@ class AuthenticatorAttachedActivity : WearableActivity() { TimedAcceptDenyDialog(this@AuthenticatorAttachedActivity) .apply { setIcon(R.drawable.ic_launcher_outline) - setMessage(message) + setMessage(info.confirmationPrompt) setTimeout(HID_USER_PRESENCE_TIMEOUT_MS) setVibrateOnShow(true) setWakeOnShow(true) @@ -240,6 +240,43 @@ class AuthenticatorAttachedActivity : WearableActivity() { status = AuthenticatorStatus.PROCESSING } } + + override suspend fun confirmTransactionWithUser(rpId: String, prompt: String): String? { + return try { + status = AuthenticatorStatus.WAITING_FOR_UP + withContext(Dispatchers.Main) { + val dialog = + TimedAcceptDenyDialog(this@AuthenticatorAttachedActivity) + .apply { + setIcon(R.drawable.ic_launcher_outline) + setTitle(rpId) + setMessage(prompt) + setTimeout(HID_USER_PRESENCE_TIMEOUT_MS) + setVibrateOnShow(true) + setWakeOnShow(true) + } + suspendCancellableCoroutine { continuation -> + dialog.apply { + setPositiveButton(DialogInterface.OnClickListener { _, _ -> + val lineBreaks = messageLineBreaks + if (lineBreaks == null) + continuation.resume(null) + else + continuation.resume(prompt.breakAt(lineBreaks)) + }) + setNegativeButton(DialogInterface.OnClickListener { _, _ -> + continuation.resume(null) + }) + }.show() + continuation.invokeOnCancellation { + dialog.dismiss() + } + } + } + } finally { + status = AuthenticatorStatus.PROCESSING + } + } } companion object { diff --git a/authenticator/src/test/java/me/henneke/wearauthn/UtilsTest.kt b/authenticator/src/test/java/me/henneke/wearauthn/UtilsTest.kt new file mode 100644 index 0000000..3196ab7 --- /dev/null +++ b/authenticator/src/test/java/me/henneke/wearauthn/UtilsTest.kt @@ -0,0 +1,36 @@ +package me.henneke.wearauthn + +import io.kotlintest.shouldBe +import io.kotlintest.specs.StringSpec + +@ExperimentalUnsignedTypes +class UtilsTest : StringSpec({ + + "breakAt should detect error cases" { + val prompt = "Confirm transaction?" + prompt.breakAt(listOf(prompt.length)) shouldBe null + prompt.breakAt(listOf(5, 1)) shouldBe null + prompt.breakAt(listOf(5, 5)) shouldBe null + } + + "breakAt should insert breaks at the correct positions" { + "".breakAt(listOf()) shouldBe "" + "a".breakAt(listOf()) shouldBe "a" + "Send $1,000 to John Doe at Evil Corp?".breakAt( + listOf(11, 23) + ) shouldBe "Send $1,000\n to John Doe\n at Evil Corp?" + } + + "breakAt should work correctly if the string already contains newlines" { + "\n".breakAt(listOf()) shouldBe "\n" + "\n\n".breakAt(listOf()) shouldBe "\n\n" + "\n\n\n".breakAt(listOf(1)) shouldBe "\n\n\n" + "Send $1,000\nto John Doe\nat Evil Corp?".breakAt( + listOf(12, 24) + ) shouldBe "Send $1,000\nto John Doe\nat Evil Corp?" + "Send $1,000\n\nto John Doe\nat Evil Corp?".breakAt( + listOf(12, 27) + ) shouldBe "Send $1,000\n\nto John Doe\nat\n Evil Corp?" + } +}) + diff --git a/metadata/WearAuthn FIDO2 Authenticator.json b/metadata/WearAuthn FIDO2 Authenticator.json index 45f6a88..91885b5 100644 --- a/metadata/WearAuthn FIDO2 Authenticator.json +++ b/metadata/WearAuthn FIDO2 Authenticator.json @@ -34,7 +34,8 @@ "matcherProtection": 1, "attachmentHint": 30, "isSecondFactorOnly": false, - "tcDisplay": 0, + "tcDisplay": 17, + "tcDisplayContentType": "text/plain", "supportedExtensions": [{ "id": "uvm", "fail_if_unknown": false @@ -46,6 +47,10 @@ { "id": "hmac-secret", "fail_if_unknown": false + }, + { + "id": "txAuthSimple", + "fail_if_unknown": false } ], "attestationRootCertificates": [