Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 4a8f94d3 authored by Marvin W.'s avatar Marvin W. 🐿️
Browse files

Fido: Prepare for USB support

parent a7413def
Loading
Loading
Loading
Loading
+13 −2
Original line number Diff line number Diff line
@@ -8,8 +8,6 @@

package com.google.android.gms.fido.fido2.api.common;

import android.util.Base64;

import com.google.android.gms.fido.common.Transport;

import org.microg.gms.common.PublicApi;
@@ -32,6 +30,19 @@ public class PublicKeyCredentialDescriptor extends AutoSafeParcelable {
    @Field(4)
    private List<Transport> transports;

    private PublicKeyCredentialDescriptor() {
    }

    public PublicKeyCredentialDescriptor(String type, byte[] id, List<Transport> transports) throws UnsupportedPubKeyCredDescriptorException {
        try {
            this.type = PublicKeyCredentialType.fromString(type);
        } catch (PublicKeyCredentialType.UnsupportedPublicKeyCredTypeException e) {
            throw new UnsupportedPubKeyCredDescriptorException(e.getMessage(), e);
        }
        this.id = id;
        this.transports = transports;
    }

    public byte[] getId() {
        return id;
    }
+3 −0
Original line number Diff line number Diff line
@@ -5,10 +5,13 @@
  -->

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="org.microg.gms.fido.core">

    <uses-permission android:name="android.permission.USE_BIOMETRIC" />
    <uses-permission android:name="android.permission.USE_FINGERPRINT" />
    <uses-permission android:name="android.permission.MANAGE_USB"
        tools:ignore="ProtectedPermissions" />

    <application>
        <service
+59 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2022 microG Project Team
 * SPDX-License-Identifier: Apache-2.0
 */

package org.microg.gms.fido.core

import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.getLongOrNull

class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VERSION) {

    fun isPrivileged(packageName: String, signatureDigest: String): Boolean = readableDatabase.use {
        it.count(TABLE_PRIVILEGED_APPS, "$COLUMN_PACKAGE_NAME = ? AND $COLUMN_SIGNATURE_DIGEST = ?", packageName, signatureDigest) > 0
    }

    fun insertPrivileged(packageName: String, signatureDigest: String) = writableDatabase.use {
        it.insertWithOnConflict(TABLE_PRIVILEGED_APPS, null, ContentValues().apply {
            put(COLUMN_PACKAGE_NAME, packageName)
            put(COLUMN_SIGNATURE_DIGEST, signatureDigest)
            put(COLUMN_TIMESTAMP, System.currentTimeMillis())
        }, CONFLICT_IGNORE)
    }

    override fun onCreate(db: SQLiteDatabase) {
        onUpgrade(db, 0, VERSION)
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        if (oldVersion < 1) {
            db.execSQL("CREATE TABLE $TABLE_PRIVILEGED_APPS($COLUMN_PACKAGE_NAME TEXT, $COLUMN_SIGNATURE_DIGEST TEXT, $COLUMN_TIMESTAMP INT, UNIQUE($COLUMN_PACKAGE_NAME, $COLUMN_SIGNATURE_DIGEST) ON CONFLICT REPLACE);")
        }
    }

    companion object {
        const val VERSION = 1
        private const val TABLE_PRIVILEGED_APPS = "privileged_apps"
        private const val COLUMN_PACKAGE_NAME = "package_name"
        private const val COLUMN_SIGNATURE_DIGEST = "signature_digest"
        private const val COLUMN_TIMESTAMP = "timestamp"
    }
}

fun SQLiteDatabase.count(table: String, selection: String? = null, vararg selectionArgs: String) =
    if (selection == null) {
        rawQuery("SELECT COUNT(*) FROM $table", null)
    } else {
        rawQuery("SELECT COUNT(*) FROM $table WHERE $selection", selectionArgs)
    }.use {
        if (it.moveToFirst()) {
            it.getLongOrNull(0) ?: 0
        } else {
            0
        }
    }
+17 −324
Original line number Diff line number Diff line
@@ -5,32 +5,18 @@

package org.microg.gms.fido.core

import android.annotation.TargetApi
import android.content.Context
import android.util.Base64
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import com.google.android.gms.fido.fido2.api.common.*
import com.google.android.gms.fido.fido2.api.common.ErrorCode.*
import com.upokecenter.cbor.CBORObject
import kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONObject
import org.microg.gms.fido.core.RequestOptionsType.REGISTER
import org.microg.gms.fido.core.RequestOptionsType.SIGN
import org.microg.gms.utils.getApplicationLabel
import org.microg.gms.utils.getFirstSignatureDigest
import org.microg.gms.utils.toBase64
import java.math.BigInteger
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
import java.security.PublicKey
import java.security.Signature
import java.security.interfaces.ECPublicKey
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.experimental.or

class RequestHandlingException(val errorCode: ErrorCode, message: String? = null) : Exception(message)

@@ -75,7 +61,7 @@ val RequestOptions.rpId: String
        SIGN -> signOptions.rpId
    }

fun RequestOptions.checkIsValid(context: Context, callingPackage: String) {
fun RequestOptions.checkIsValid(context: Context) {
    if (type == REGISTER) {
        if (registerOptions.authenticatorSelection.requireResidentKey == true) {
            throw RequestHandlingException(
@@ -89,25 +75,15 @@ fun RequestOptions.checkIsValid(context: Context, callingPackage: String) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "Request doesn't have a valid list of allowed credentials.")
        }
    }
    if (this is BrowserRequestOptions) {
        // TODO: Check properly if package is allowed to act as browser
        if (callingPackage != "com.android.chrome") {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "Not a browser.")
        }
    }
}

fun RequestOptions.getWebAuthnClientData(callingPackage: String, origin: String? = null): ByteArray {
fun RequestOptions.getWebAuthnClientData(callingPackage: String, origin: String): ByteArray {
    val obj = JSONObject()
        .put("type", webAuthnType)
        .put("challenge", challenge.toBase64(Base64.NO_PADDING, Base64.NO_WRAP, Base64.URL_SAFE))
        .put("androidPackageName", callingPackage)
        .put("tokenBinding", tokenBinding?.toJsonObject())
    if (origin != null) {
        obj.put("origin", origin)
    } else if (this is BrowserRequestOptions) {
        obj.put("origin", this.origin.toString())
    }
        .put("origin", origin)
    return obj.toString().encodeToByteArray()
}

@@ -116,319 +92,36 @@ fun getApplicationName(context: Context, options: RequestOptions, callingPackage
    else -> context.packageManager.getApplicationLabel(callingPackage).toString()
}

fun getFacetId(context: Context, options: RequestOptions, callingPackage: String): String = when {
fun getApkHashOrigin(context: Context, packageName: String): String {
    val digest = context.packageManager.getFirstSignatureDigest(packageName, "SHA-256")
        ?: throw RequestHandlingException(NOT_ALLOWED_ERR, "Unknown package $packageName")
    return "android:apk-key-hash:${digest.toBase64(Base64.NO_PADDING, Base64.NO_WRAP, Base64.URL_SAFE)}"
}

fun getOrigin(context: Context, options: RequestOptions, callingPackage: String): String = when {
    options is BrowserRequestOptions -> {
        if (options.origin.scheme == null || options.origin.authority == null) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "Bad url ${options.origin}")
        }
        "${options.origin.scheme}://${options.origin.authority}"
    }
    else -> {
        val digest = context.packageManager.getFirstSignatureDigest(callingPackage, "SHA-256")
            ?: throw RequestHandlingException(NOT_ALLOWED_ERR, "Unknown package $callingPackage")
        "android:apk-key-hash:${digest.toBase64(Base64.NO_PADDING, Base64.NO_WRAP, Base64.URL_SAFE)}"
    }
}

class AttestedCredentialData(val aaguid: ByteArray, val id: ByteArray, val publicKey: ByteArray) {
    fun encode() = ByteBuffer.allocate(aaguid.size + 2 + id.size + publicKey.size)
        .put(aaguid)
        .order(ByteOrder.BIG_ENDIAN).putShort(id.size.toShort())
        .put(id)
        .put(publicKey)
        .array()
}


class AuthenticatorData(
    val rpIdHash: ByteArray,
    val userPresent: Boolean,
    val userVerified: Boolean,
    val signCount: Int,
    val attestedCredentialData: AttestedCredentialData? = null,
    val extensions: ByteArray? = null
) {
    fun encode(): ByteArray {
        val attestedCredentialData = attestedCredentialData?.encode() ?: ByteArray(0)
        val extensions = extensions ?: ByteArray(0)
        return ByteBuffer.allocate(rpIdHash.size + 5 + attestedCredentialData.size + extensions.size)
            .put(rpIdHash)
            .put(buildFlags(userPresent, userVerified, attestedCredentialData.isNotEmpty(), extensions.isNotEmpty()))
            .order(ByteOrder.BIG_ENDIAN).putInt(signCount)
            .put(attestedCredentialData)
            .put(extensions)
            .array()
    }

    fun toCBOR(): CBORObject = encode().toCBOR()

    companion object {
        /** User Present **/
        private const val FLAG_UP: Byte = 1

        /** User Verified **/
        private const val FLAG_UV: Byte = 4

        /** Attested credential data included **/
        private const val FLAG_AT: Byte = 64

        /** Extension data included **/
        private const val FLAG_ED: Byte = -128

        private fun buildFlags(up: Boolean, uv: Boolean, at: Boolean, ed: Boolean): Byte =
            (if (up) FLAG_UP else 0) or (if (uv) FLAG_UV else 0) or (if (at) FLAG_AT else 0) or (if (ed) FLAG_ED else 0)
    }
}

fun String.toCBOR() = CBORObject.FromObject(this)
fun ByteArray.toCBOR() = CBORObject.FromObject(this)
fun Int.toCBOR() = CBORObject.FromObject(this)

abstract class AttestationObject(val authData: AuthenticatorData) {
    abstract val fmt: String
    abstract val attStmt: CBORObject

    fun encode(): ByteArray = CBORObject.NewMap().apply {
        set("fmt", fmt.toCBOR())
        set("attStmt", attStmt)
        set("authData", authData.toCBOR())
    }.EncodeToBytes()
}

class NoneAttestationObject(authData: AuthenticatorData) : AttestationObject(authData) {
    override val fmt: String
        get() = "none"
    override val attStmt: CBORObject
        get() = CBORObject.NewMap()
}

class AndroidSafetyNetAttestationObject(authData: AuthenticatorData, val ver: String, val response: ByteArray) :
    AttestationObject(authData) {
    override val fmt: String
        get() = "android-safetynet"
    override val attStmt: CBORObject
        get() = CBORObject.NewMap().apply {
            set("ver", ver.toCBOR())
            set("response", response.toCBOR())
        }
}

class CoseKey(
    val algorithm: Algorithm,
    val x: BigInteger,
    val y: BigInteger,
    val curveId: Int,
    val curvePointSize: Int
) {
    fun encode(): ByteArray = CBORObject.NewMap().apply {
        set(1, 2.toCBOR())
        set(3, algorithm.algoValue.toCBOR())
        set(-1, curveId.toCBOR())
        set(-2, x.toByteArray(curvePointSize).toCBOR())
        set(-3, y.toByteArray(curvePointSize).toCBOR())
    }.EncodeToBytes()

    companion object {
        fun BigInteger.toByteArray(size: Int): ByteArray {
            val res = ByteArray(size)
            val orig = toByteArray()
            if (orig.size > size) {
                System.arraycopy(orig, orig.size - size, res, 0, size)
            } else {
                System.arraycopy(orig, 0, res, size - orig.size, orig.size)
            }
            return res
        }
    }
}

class CredentialId(val type: Byte, val data: ByteArray, val rpId: String, val publicKey: PublicKey) {
    fun encode(): ByteArray = ByteBuffer.allocate(1 + data.size + 32).apply {
        put(type)
        put(data)
        put((rpId.toByteArray() + publicKey.encoded).digest("SHA-256"))
    }.array()

    companion object {
        fun decodeTypeAndData(bytes: ByteArray): Pair<Byte, ByteArray> {
            val buffer = ByteBuffer.wrap(bytes)
            val type = buffer.get()
            val data = ByteArray(32)
            buffer.get(data)
            return type to data
        }
    }
    else -> getApkHashOrigin(context, callingPackage)
}

fun ByteArray.digest(md: String): ByteArray = MessageDigest.getInstance(md).digest(this)

fun getClientDataAndHash(options: RequestOptions, callingPackage: String): Pair<ByteArray, ByteArray> {
fun getClientDataAndHash(
    context: Context,
    options: RequestOptions,
    callingPackage: String
): Pair<ByteArray, ByteArray> {
    val clientData: ByteArray?
    var clientDataHash = (options as? BrowserPublicKeyCredentialCreationOptions)?.clientDataHash
    if (clientDataHash == null) {
        clientData = options.getWebAuthnClientData(callingPackage)
        clientData = options.getWebAuthnClientData(callingPackage, getOrigin(context, options, callingPackage))
        clientDataHash = clientData.digest("SHA-256")
    } else {
        clientData = "<invalid>".toByteArray()
    }
    return clientData to clientDataHash
}

@TargetApi(23)
suspend fun getActiveSignature(
    activity: FragmentActivity,
    options: RequestOptions,
    callingPackage: String,
    store: InternalCredentialStore,
    keyId: ByteArray
): Signature {
    val signature = store.getSignature(options.rpId, keyId) ?: throw RequestHandlingException(INVALID_STATE_ERR)
    suspendCancellableCoroutine<BiometricPrompt.AuthenticationResult> { continuation ->
        val prompt = BiometricPrompt(activity, object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                continuation.resume(result)
            }

            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                val errorMessage = when (errorCode) {
                    BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON -> "User canceled verification"
                    else -> errString.toString()
                }
                continuation.resumeWithException(RequestHandlingException(NOT_ALLOWED_ERR, errorMessage))
            }
        })
        prompt.authenticate(
            BiometricPrompt.PromptInfo.Builder()
                .setTitle(activity.getString(R.string.fido_biometric_prompt_title))
                .setDescription(
                    activity.getString(
                        R.string.fido_biometric_prompt_body,
                        getApplicationName(activity, options, callingPackage)
                    )
                )
                .setNegativeButtonText(activity.getString(android.R.string.cancel))
                .build(),
            BiometricPrompt.CryptoObject(signature)
        )
        continuation.invokeOnCancellation { prompt.cancelAuthentication() }
    }
    return signature
}

@RequiresApi(23)
suspend fun registerInternal(
    activity: FragmentActivity,
    options: RequestOptions,
    callingPackage: String
): AuthenticatorAttestationResponse {
    if (options.type != REGISTER) throw RequestHandlingException(INVALID_STATE_ERR)
    val store = InternalCredentialStore(activity)
    // TODO: Privacy?
    for (descriptor in options.registerOptions.excludeList.orEmpty()) {
        if (store.containsKey(options.rpId, descriptor.id)) {
            throw RequestHandlingException(
                NOT_ALLOWED_ERR,
                "An excluded credential has already been registered with the device"
            )
        }
    }
    val (clientData, clientDataHash) = getClientDataAndHash(options, callingPackage)
    if (options.registerOptions.attestationConveyancePreference in setOf(AttestationConveyancePreference.NONE, null)) {
        // No attestation needed
    } else {
        // TODO: SafetyNet
        throw RequestHandlingException(NOT_SUPPORTED_ERR, "SafetyNet Attestation not yet supported")
    }
    val keyId = store.createKey(options.rpId)
    val publicKey = store.getPublicKey(options.rpId, keyId) ?: throw RequestHandlingException(INVALID_STATE_ERR)

    // We're ignoring the signature object as we don't need it for registration
    getActiveSignature(activity, options, callingPackage, store, keyId)

    val (x, y) = (publicKey as ECPublicKey).w.let { it.affineX to it.affineY }
    val coseKey = CoseKey(EC2Algorithm.ES256, x, y, 1, 32)
    val credentialId = CredentialId(1, keyId, options.rpId, publicKey)

    val credentialData = AttestedCredentialData(
        ByteArray(16), // 0xb93fd961f2e6462fb12282002247de78 for SafetyNet
        credentialId.encode(),
        coseKey.encode()
    )

    val authenticatorData = AuthenticatorData(
        options.rpId.toByteArray().digest("SHA-256"),
        userPresent = true,
        userVerified = true,
        signCount = 0,
        attestedCredentialData = credentialData
    )

    return AuthenticatorAttestationResponse(
        credentialId.encode(),
        clientData,
        NoneAttestationObject(authenticatorData).encode()
    )
}

@RequiresApi(23)
suspend fun signInternal(
    activity: FragmentActivity,
    options: RequestOptions,
    callingPackage: String
): AuthenticatorAssertionResponse {
    if (options.type != SIGN) throw RequestHandlingException(INVALID_STATE_ERR)
    val store = InternalCredentialStore(activity)
    val candidates = mutableListOf<CredentialId>()
    for (descriptor in options.signOptions.allowList) {
        try {
            val (type, data) = CredentialId.decodeTypeAndData(descriptor.id)
            if (type == 1.toByte() && store.containsKey(options.rpId, data)) {
                candidates.add(CredentialId(type, data, options.rpId, store.getPublicKey(options.rpId, data)!!))
            }
        } catch (e: Exception) {
            // Not in store or unknown id
        }
    }
    if (candidates.isEmpty()) {
        // TODO: Privacy
        throw RequestHandlingException(
            NOT_ALLOWED_ERR,
            "Cannot find credential in local KeyStore or database"
        )
    }

    val (clientData, clientDataHash) = getClientDataAndHash(options, callingPackage)

    val credentialId = candidates.first()
    val keyId = credentialId.data

    val (x, y) = (credentialId.publicKey as ECPublicKey).w.let { it.affineX to it.affineY }
    val coseKey = CoseKey(EC2Algorithm.ES256, x, y, 1, 32)

    val credentialData = AttestedCredentialData(
        ByteArray(16), // 0xb93fd961f2e6462fb12282002247de78 for SafetyNet
        credentialId.encode(),
        coseKey.encode()
    )

    val authenticatorData = AuthenticatorData(
        options.rpId.toByteArray().digest("SHA-256"),
        userPresent = true,
        userVerified = true,
        signCount = 0,
        attestedCredentialData = credentialData
    )

    val signature = getActiveSignature(activity, options, callingPackage, store, keyId)

    signature.update(authenticatorData.encode() + clientDataHash)
    val sig = signature.sign()

    return AuthenticatorAssertionResponse(
        credentialId.encode(),
        clientData,
        authenticatorData.encode(),
        sig,
        null
    )
}
+19 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2022 microG Project Team
 * SPDX-License-Identifier: Apache-2.0
 */

package org.microg.gms.fido.core.protocol

import com.upokecenter.cbor.CBORObject

class AndroidSafetyNetAttestationObject(authData: AuthenticatorData, val ver: String, val response: ByteArray) :
    AttestationObject(authData) {
    override val fmt: String
        get() = "android-safetynet"
    override val attStmt: CBORObject
        get() = CBORObject.NewMap().apply {
            set("ver", ver.encodeAsCbor())
            set("response", response.encodeAsCbor())
        }
}
Loading