Support built-in UV on CTAP 2.0 devices

This commit is contained in:
mimi89999
2026-03-27 13:02:16 +01:00
parent 9a98e79c76
commit cb1da6bfb8
2 changed files with 97 additions and 35 deletions
@@ -115,6 +115,9 @@ data class DeviceInfo(
val supportsBuiltInUv: Boolean
get() = options["uv"] == true
val supportsPinUvAuthToken: Boolean
get() = options["pinUvAuthToken"] == true
}
object CTAP {
@@ -37,6 +37,15 @@ import java.security.MessageDigest
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class CredentialProviderActivity : AppCompatActivity() {
private sealed class UvMode {
/** No user verification requested. */
object None : UvMode()
/** Ask the authenticator to perform UV itself. */
object BuiltIn : UvMode()
/** UV via a pinUvAuth token. */
class WithToken(val protocol: PinProtocol) : UvMode()
}
private data class ClientData(val json: String?, val hash: ByteArray)
private var nfcAdapter: NfcAdapter? = null
@@ -223,7 +232,11 @@ class CredentialProviderActivity : AppCompatActivity() {
bottomSheet?.showBiometricWaiting()
setInstruction(getString(R.string.instruction_waiting_biometric))
showProgress(true)
authenticateWithUvAndExecute(json)
if (deviceInfo?.supportsPinUvAuthToken == true) {
authenticateWithUvAndExecute(json)
} else {
executeWithBuiltInUv(json)
}
} else {
// Key not connected yet — show waiting state
setInstruction(getString(R.string.instruction_connect_key))
@@ -503,6 +516,7 @@ class CredentialProviderActivity : AppCompatActivity() {
val deviceHasPin = deviceInfo?.clientPinSet == true
val alwaysUv = deviceInfo?.options?.get("alwaysUv") == true
deviceSupportsUv = deviceInfo?.supportsBuiltInUv == true
val supportsPinUvAuthToken = deviceInfo?.supportsPinUvAuthToken == true
when {
// We already have PIN from pre-prompt
@@ -515,9 +529,13 @@ class CredentialProviderActivity : AppCompatActivity() {
throw AuthnkeyError.UserVerificationRequiredNoPin()
}
// UV required/preferred, device supports built-in UV but no PIN set
// -> go directly to biometric
// -> go directly to biometric (use token flow if supported, otherwise built-in)
userVerification != UserVerification.DISCOURAGED && deviceSupportsUv && !deviceHasPin -> {
authenticateWithUvAndExecute(json)
if (supportsPinUvAuthToken) {
authenticateWithUvAndExecute(json)
} else {
executeWithBuiltInUv(json)
}
}
// UV required/preferred, device has both PIN and built-in UV
// -> show PIN input with biometric option
@@ -538,7 +556,11 @@ class CredentialProviderActivity : AppCompatActivity() {
userVerification == UserVerification.DISCOURAGED && alwaysUv -> {
if (deviceSupportsUv) {
// Use built-in UV silently since UV is discouraged but alwaysUv forces it
authenticateWithUvAndExecute(json)
if (supportsPinUvAuthToken) {
authenticateWithUvAndExecute(json)
} else {
executeWithBuiltInUv(json)
}
} else if (deviceHasPin) {
val retries = withContext(Dispatchers.IO) { protocol.getPinRetries() }.getOrDefault(8)
showPinDialog(retries, json)
@@ -562,7 +584,7 @@ class CredentialProviderActivity : AppCompatActivity() {
private fun tryExecuteWithoutPin(json: JSONObject) {
scope.launch {
try {
executeRequest(json, null)
executeRequest(json, UvMode.None)
} catch (e: CTAP.Exception) {
// Check if authenticator requires PIN despite UV=discouraged
if (e.error == CTAP.Error.PIN_REQUIRED ||
@@ -642,7 +664,7 @@ class CredentialProviderActivity : AppCompatActivity() {
return@launch
}
executeRequest(requestJson, protocol)
executeRequest(requestJson, UvMode.WithToken(protocol))
} catch (e: Exception) {
Log.e(TAG, "Authentication error", e)
@@ -651,6 +673,34 @@ class CredentialProviderActivity : AppCompatActivity() {
}
}
/**
* For CTAP2.0 authenticators that support built-in UV but not pinUvAuthToken.
* Sets the "uv" option in the CTAP command and lets the authenticator handle
* user verification internally (e.g., on-device fingerprint prompt).
*/
private fun executeWithBuiltInUv(requestJson: JSONObject) {
scope.launch {
try {
runOnUiThread {
bottomSheet?.showBiometricWaiting()
setInstruction(getString(R.string.instruction_waiting_biometric))
showProgress(true)
}
executeRequest(requestJson, UvMode.BuiltIn)
} catch (e: Exception) {
Log.e(TAG, "Built-in UV error", e)
handleError(e)
}
}
}
/**
* For CTAP2.1 authenticators that support pinUvAuthToken.
* Obtains a UV token via the authenticator's built-in verification
* (e.g., fingerprint) and uses it as a pinUvAuth parameter.
*/
private fun authenticateWithUvAndExecute(requestJson: JSONObject) {
scope.launch {
try {
@@ -740,7 +790,7 @@ class CredentialProviderActivity : AppCompatActivity() {
}
// UV succeeded — proceed with the request
executeRequest(requestJson, protocol)
executeRequest(requestJson, UvMode.WithToken(protocol))
} catch (e: Exception) {
Log.e(TAG, "UV authentication error", e)
@@ -766,14 +816,14 @@ class CredentialProviderActivity : AppCompatActivity() {
}
}
private suspend fun executeRequest(requestJson: JSONObject, pinProtocol: PinProtocol?) {
private suspend fun executeRequest(requestJson: JSONObject, uvMode: UvMode) {
try {
val transport = currentTransport ?: throw AuthnkeyError.NotConnected()
if (isCreateRequest) {
executeCreateCredential(transport, requestJson, pinProtocol)
executeCreateCredential(transport, requestJson, uvMode)
} else {
executeGetAssertion(transport, requestJson, pinProtocol)
executeGetAssertion(transport, requestJson, uvMode)
}
} catch (e: Exception) {
@@ -785,7 +835,7 @@ class CredentialProviderActivity : AppCompatActivity() {
private suspend fun executeCreateCredential(
transport: FidoTransport,
requestJson: JSONObject,
pinProtocol: PinProtocol?
uvMode: UvMode
) {
setInstruction(getString(R.string.instruction_creating))
@@ -858,14 +908,18 @@ class CredentialProviderActivity : AppCompatActivity() {
}
}
// Build UV mode
val uvMode = if (pinProtocol != null && pinProtocol.hasPinToken()) {
FidoCommands.UvMode.AuthToken(
pinProtocol.computeAuthParam(clientData.hash),
protocol = 1
)
} else {
FidoCommands.UvMode.None
// Resolve UV mode
val fidoUvMode = when (uvMode) {
is UvMode.None -> FidoCommands.UvMode.None
is UvMode.BuiltIn -> FidoCommands.UvMode.BuiltIn
is UvMode.WithToken -> if (uvMode.protocol.hasPinToken()) {
FidoCommands.UvMode.AuthToken(
uvMode.protocol.computeAuthParam(clientData.hash),
protocol = 1
)
} else {
FidoCommands.UvMode.None
}
}
// Build and send command
@@ -879,7 +933,7 @@ class CredentialProviderActivity : AppCompatActivity() {
pubKeyCredParams = pubKeyCredParams,
excludeList = excludeList.ifEmpty { null },
requireResidentKey = residentKey.requiresResidentKey(),
uvMode = uvMode,
uvMode = fidoUvMode,
extensions = ctapExtensions.ifEmpty { null },
)
@@ -978,7 +1032,7 @@ class CredentialProviderActivity : AppCompatActivity() {
private suspend fun executeGetAssertion(
transport: FidoTransport,
requestJson: JSONObject,
pinProtocol: PinProtocol?
uvMode: UvMode
) {
setInstruction(getString(R.string.instruction_signing_in))
@@ -1010,10 +1064,11 @@ class CredentialProviderActivity : AppCompatActivity() {
// Prepare hmac-secret extension for getAssertion if PRF is requested
var hmacSecretExtensions: CborRaw? = null
var prfHasTwoSalts = false
var effectiveProtocol = pinProtocol
var prfProtocol: PinProtocol? = null
if (prfRequested) {
val protocol = pinProtocol ?: this.pinProtocol
val protocol = (uvMode as? UvMode.WithToken)?.protocol
?: this.pinProtocol
?: throw Exception("PIN protocol not available for PRF")
// Ensure key agreement is initialized (needed for hmac-secret even without PIN)
@@ -1023,7 +1078,7 @@ class CredentialProviderActivity : AppCompatActivity() {
throw Exception("Failed to initialize key agreement for PRF")
}
}
effectiveProtocol = protocol
prfProtocol = protocol
// Extract and convert PRF salts to hmac-secret salts
// PRF salt → hmac-secret salt: SHA-256("WebAuthn PRF" || 0x00 || salt)
@@ -1087,14 +1142,18 @@ class CredentialProviderActivity : AppCompatActivity() {
}
}
// Build UV mode
val uvMode = if (effectiveProtocol != null && effectiveProtocol.hasPinToken()) {
FidoCommands.UvMode.AuthToken(
effectiveProtocol.computeAuthParam(clientData.hash),
protocol = 1
)
} else {
FidoCommands.UvMode.None
// Resolve UV mode
val fidoUvMode = when (uvMode) {
is UvMode.None -> FidoCommands.UvMode.None
is UvMode.BuiltIn -> FidoCommands.UvMode.BuiltIn
is UvMode.WithToken -> if (uvMode.protocol.hasPinToken()) {
FidoCommands.UvMode.AuthToken(
uvMode.protocol.computeAuthParam(clientData.hash),
protocol = 1
)
} else {
FidoCommands.UvMode.None
}
}
// Build and send command
@@ -1102,7 +1161,7 @@ class CredentialProviderActivity : AppCompatActivity() {
rpId = rpId,
clientDataHash = clientData.hash,
allowList = allowList.ifEmpty { null },
uvMode = uvMode,
uvMode = fidoUvMode,
extensions = hmacSecretExtensions,
)
@@ -1145,12 +1204,12 @@ class CredentialProviderActivity : AppCompatActivity() {
// Parse PRF results from authenticator data extensions
var prfResults: JSONObject? = null
if (prfRequested && effectiveProtocol != null) {
if (prfRequested && prfProtocol != null) {
val authData = AuthenticatorData.parse(selectedAssertion.authData)
val hmacSecretOutput = authData?.extensions?.bytes("hmac-secret")
if (hmacSecretOutput != null) {
val decrypted = effectiveProtocol.decryptHmacSecretOutput(hmacSecretOutput)
val decrypted = prfProtocol.decryptHmacSecretOutput(hmacSecretOutput)
if (decrypted != null) {
prfResults = JSONObject().apply {
val first = decrypted.sliceArray(0 until 32)