All Downloads are FREE. Search and download functionalities are using the official Maven repository.

arden.2.1.3.source-code.AttestationService.kt Maven / Gradle / Ivy

The newest version!
package at.asitplus.attestation

import at.asitplus.KmmResult
import at.asitplus.attestation.AttestationException
import at.asitplus.attestation.IOSAttestationConfiguration.AppData
import at.asitplus.attestation.android.*
import at.asitplus.attestation.android.exceptions.AttestationValueException
import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.fromJcaPublicKey
import ch.veehait.devicecheck.appattest.assertion.Assertion
import ch.veehait.devicecheck.appattest.attestation.ValidatedAttestation
import com.google.android.attestation.AttestationApplicationId
import com.google.android.attestation.ParsedAttestationRecord
import net.swiftzer.semver.SemVer
import org.slf4j.LoggerFactory
import java.security.PublicKey
import java.security.cert.X509Certificate
import java.util.*
import kotlin.jvm.optionals.getOrNull
import at.asitplus.attestation.AttestationException as AttException

/**
 * Configuration class for Apple App Attestation
 */
data class IOSAttestationConfiguration @JvmOverloads constructor(

    /**
     * List of applications that can be attested
     */
    val applications: List,

    /**
     * Optional parameter. If present the iOS version of the attested app must be greater or equal to this parameter
     * Uses [SemVer](https://semver.org/) syntax. Can be overridden vor individual apps.
     *
     * @see AppData.iosVersionOverride
     */
    val iosVersion: OsVersions? = null,

    ) {


    @JvmOverloads
    constructor(singleApp: AppData, iosVersion: OsVersions? = null) : this(listOf(singleApp), iosVersion)

    init {
        if (applications.isEmpty())
            throw AttestationException.Configuration(Platform.IOS, "No apps configured", IllegalArgumentException())
    }

    /**
     * Container class for iOS versions. Necessary, iOS versions used to always be encoded into attestation statements using
     * [SemVer](https://semver.org/) syntax. Newer iPhones, however, use a hex string representation of the build number instead.
     * Since it makes rarely sense to only check for SemVer not for a hex-encoded build number (i.e only accept older iPhones),
     * encapsulating both variants into a dedicated type ensures that either both or neither are set.
     */
    data class OsVersions(
        /**
         * [SemVer](https://semver.org/)-formatted iOS version number.
         * This property is a simple string, because it plays nicely when externalising configuration to files, since
         * it doesn't require a custom deserializer/decoder.
         */
        private val semVer: String,

        /**
         * String representation of an iOS build number. As per [TidBITS.com](https://tidbits.com/2020/07/08/how-to-decode-apple-version-and-build-numbers/):
         * @see BuildNumber
         */
        private val buildNumber: String,

        ) : Comparable {

        /**
         * Parsed and normalised iOS build number. As per [TidBITS.com](https://tidbits.com/2020/07/08/how-to-decode-apple-version-and-build-numbers/):
         * @see BuildNumber
         */
        val normalisedBuildNumber: BuildNumber = runCatching { BuildNumber(buildNumber) }.getOrElse { ex ->
            throw AttestationException.Configuration(
                Platform.IOS,
                "Illegal iOS build number $buildNumber",
                ex
            )
        }

        /**
         * [SemVer](https://semver.org/)-formatted iOS version number.
         */
        val semVerParsed: SemVer =
            runCatching { SemVer.parse(semVer) }.getOrElse { ex ->
                throw AttestationException.Configuration(
                    Platform.IOS,
                    "Illegal iOS version number $semVer",
                    ex
                )
            }

        override fun toString(): String =
            "iOS Versions (semVer=$semVerParsed, buildNumber: $normalisedBuildNumber)"

        override fun compareTo(other: Any): Int {
            return when (other) {
                is BuildNumber -> normalisedBuildNumber.compareTo(other)
                is SemVer -> semVerParsed.compareTo(other)
                is Pair<*, *> -> {
                    if ((other.first is SemVer || other.first is SemVer?) && (other.second is BuildNumber || other.second is BuildNumber?)) {
                        other.first?.let { return semVerParsed.compareTo(it as SemVer) }
                            ?: other.second?.let { normalisedBuildNumber.compareTo(it as BuildNumber) }
                            ?: throw UnsupportedOperationException("No Parsed iOS Version present.")
                    } else throw UnsupportedOperationException("Cannot compare OsVersions to ${other::class.simpleName}")
                }

                else -> throw UnsupportedOperationException("Cannot compare OsVersions to ${other::class.simpleName}")
            }
        }
    }


    /**
     * Specifies a to-be attested app
     */
    data class AppData @JvmOverloads constructor(
        /**
         * Nomen est omen
         */
        val teamIdentifier: String,

        /**
         * Nomen est omen
         */
        val bundleIdentifier: String,

        /**
         * Specifies whether the to-be-attested app targets a production or sandbox environment
         */
        val sandbox: Boolean = false,

        /**
         * Optional parameter. If present, overrides the globally configured iOS version for this app.
         */
        val iosVersionOverride: OsVersions? = null,

        ) {

        /**
         * Builder for more Java-friendliness
         * @param teamIdentifier nomen est omen
         * @param bundleIdentifier nomen est omen
         */
        @Suppress("UNUSED")
        class Builder(private val teamIdentifier: String, private val bundleIdentifier: String) {
            private var sandbox = false
            private var iosVersionOverride: OsVersions? = null

            /**
             * @see AppData.sandbox
             */
            fun sandbox(sandbox: Boolean) = apply { this.sandbox = sandbox }

            /**
             * @see AppData.iosVersionOverride
             */
            fun overrideIosVersion(version: OsVersions) = apply { iosVersionOverride = version }

            fun build() = AppData(teamIdentifier, bundleIdentifier, sandbox, iosVersionOverride)
        }
    }

}

typealias ParsedVersions = Pair

/**
 * iOS build number. As per [TidBITS.com](https://tidbits.com/2020/07/08/how-to-decode-apple-version-and-build-numbers/):
 *
 * An Apple build number also has three parts:
 *
 * *  Major version: Within Apple, the major version is called the build train.
 * *  Minor version: For iOS and its descendants, the minor version tracks with the minor release; for macOS, it tracks with patch releases.
 * *  Daily build version: The daily build indicates how many times Apple has built the source code for the release since the previous public release.
 * *  Optional mastering counter; only relevant for internal builds an betas
 *
 * While this last bit about the daily build number is phrased somewhat fuzzy, it really is a strictly increasing decimal number.
 */
class BuildNumber private constructor(
    val buildTrain: UInt,
    val minorVersion: String,
    val buildVer: UInt,
    val masteringCounter: String? = null
) : Comparable {


    constructor(buildNumber: String) : this(parseBuildNumber(buildNumber))

    private constructor(boxed: Pair, String?>) : this(
        boxed.first.first,
        boxed.first.second,
        boxed.first.third,
        boxed.second
    )


    /**
     * Integer representation of the build number. Converts [buildTrain] into a hex number, concatenates it with [minorVersion] radix-36-parsed
     * to a hex number and concatenates it with an end-padded hex-representation of [buildVer].
     * This results in a [UInt] whose MSBs are always set for correct and straight-forward comparison of build numbers.
     * The implementation is inefficient but comprehensible.
     */
    val semVerRepresentation: SemVer = SemVer(
        buildTrain.toInt(),
        minor = minorVersion.toInt(36),
        patch = buildVer.toInt(),
        preRelease = masteringCounter
    )

    override fun compareTo(other: BuildNumber): Int = semVerRepresentation.compareTo(other.semVerRepresentation)

    override fun toString() = "$buildTrain$minorVersion$buildVer ($semVerRepresentation)"

    companion object {
        private fun parseBuildNumber(stringRepresentation: String): Pair, String?> {
            val buildTrain = stringRepresentation.takeWhile { it.isDigit() }

            val minorVersion = stringRepresentation.substring(buildTrain.length).takeWhile { it.isLetter() }
            val masteringCounter = stringRepresentation.takeLastWhile { it.isLetter() }
            val buildVer = stringRepresentation.substring(
                buildTrain.length + minorVersion.length,
                stringRepresentation.length - masteringCounter.length
            ).toUInt(10)

            return Triple(
                buildTrain.toUInt(10),
                minorVersion,
                buildVer
            ) to masteringCounter.let { if (it.isEmpty()) null else it }
        }
    }
}


abstract class AttestationService {


    internal abstract fun verifyAttestation(
        attestationProof: List,
        challenge: ByteArray,
        clientData: ByteArray? = null
    ): AttestationResult


    /**
     * Verifies key attestation for both Android and Apple devices.
     *
     * Succeeds if attestation data structures of the client (in [attestationProof]) can be verified and [expectedChallenge] matches
     * the attestation challenge. For Android clients, this function makes sure that [keyToBeAttested] matches the key contained in the attestation certificate.
     * For iOS this key needs to be specified explicitly anyhow to emulate key attestation
     *
     * @param attestationProof On Android, this is simply the certificate chain from the attestation certificate
     * (i.e. the certificate corresponding to the key to be attested) up to one of the [Google hardware attestation root
     * certificates](https://developer.android.com/training/articles/security-key-attestation#root_certificate).
     * on iOS this must contain the [AppAttest attestation statement](https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server#3576643)
     * at index `0` and an [assertion](https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server#3576644)
     * at index `1`, which, is verified for integrity and to match [keyToBeAttested].
     * The signature counter in the attestation must be `0` and the signature counter in the assertion must be `1`.
     *
     * Passing a public key created in the same app on the iDevice's secure hardware as `clientData` to create an assertion effectively
     * emulates Android's key attestation: Attesting such a secondary key through an assertion, proves that
     * it was also created within the same app, on the same device, resulting in an attested key, which can then be used
     * for general-purpose crypto. **BEWARE: supports only EC key on iOS (either the ANSI X9.63 encoded or DER encoded).
     * The key can be passed in either encoding to the secure enclave for assertion/attestation**
     *
     * @param expectedChallenge
     * @param keyToBeAttested
     *
     * @return [KeyAttestation] containing the attested public key on success or null in case of failure (see [KeyAttestation])
     */
    fun  verifyKeyAttestation(
        attestationProof: List,
        expectedChallenge: ByteArray,
        keyToBeAttested: T
    ): KeyAttestation {
        when (val firstTry = verifyAttestation(
            attestationProof,
            expectedChallenge,
            keyToBeAttested.encoded
        )) {
            is AttestationResult.Android -> return processAndroidAttestationResult(keyToBeAttested, firstTry)

            is AttestationResult.Error -> {

                // try all different key encodings
                // not the most efficient way, but doing it like this won't involve any guesswork at all
                keyToBeAttested.transcodeToAllFormats().forEach {
                    when (val secondTry =
                        kotlin.runCatching { verifyAttestation(attestationProof, expectedChallenge, it) }
                            .getOrElse { return KeyAttestation(null, firstTry) }) {

                        is AttestationResult.Android ->
                            throw logicalError(keyToBeAttested, attestationProof, expectedChallenge)

                        is AttestationResult.Error -> {} //try again, IOS could have encoded it differently

                        //if this works, perfect!
                        is AttestationResult.IOS -> return KeyAttestation(keyToBeAttested, secondTry)
                    }
                }
                //if no encoding works, then it should just fail
                return KeyAttestation(null, firstTry)
            }

            is AttestationResult.IOS -> return KeyAttestation(keyToBeAttested, firstTry)
        }
    }

    private fun  processAndroidAttestationResult(
        keyToBeAttested: T,
        firstTry: AttestationResult.Android
    ): KeyAttestation =
        if (keyToBeAttested.toCryptoPublicKey() == firstTry.attestationCertificate.publicKey.toCryptoPublicKey()) {
            KeyAttestation(keyToBeAttested, firstTry)
        } else {
            val reason = "Android attestation failed: keyToBeAttested (${keyToBeAttested.toLogString()}) does not " +
                    "match key from attestation certificate: ${firstTry.attestationCertificate.publicKey.toLogString()}"
            AttException.Content.Android(
                reason, AttestationValueException(reason, null, AttestationValueException.Reason.APP_UNEXPECTED)
            ).toAttestationError(reason)
        }

    private fun  T.toLogString(): String? = encoded.encodeBase64()

    private fun  AttestationException.Content.toAttestationError(it: String): KeyAttestation =
        KeyAttestation(null, AttestationResult.Error(it, this))

    private fun  T.toCryptoPublicKey(): KmmResult =
        CryptoPublicKey.fromJcaPublicKey(this)


    /** Same as [verifyKeyAttestation], but taking an encoded (either ANSI X9.63 or DER) publix key as a byte array
     * @see verifyKeyAttestation
     */
    fun verifyKeyAttestation(
        attestationProof: List,
        challenge: ByteArray,
        encodedPublicKey: ByteArray
    ): KeyAttestation =
        verifyKeyAttestation(attestationProof, challenge, encodedPublicKey.parseToPublicKey())

    /**
     * Groups iOS-specific API to reduce toplevel clutter.
     *
     * Exposes iOS-specific functionality in a more expressive, and less confusing manner
     */
    abstract val ios: IOS

    interface IOS {
        /**
         * convenience method specific to iOS, which only verifies App Attestation and no assertion
         * @param attestationObject the AppAttest attestation object to verify
         * @param challenge the challenge to verify against
         */
        fun verifyAppAttestation(
            attestationObject: ByteArray,
            challenge: ByteArray,
        ): AttestationResult

        /**
         * Verifies an App Attestation in conjunction with an assertion for some client data.
         *
         * First, it verifies the app attestation, afterwards it verifies the assertion, checks whether at most [counter] many signatures
         * have been performed using the key bound to the attestation before signing the assertion and verifies whether the client data
         * referenced within the assertion matches [referenceClientData]
         *
         * @param attestationObject the AppAttest attestation object to verify
         * @param assertionFromDevice the assertion data created on the device.
         * @param referenceClientData the expected client data to be contained in [assertionFromDevice]
         * @param counter the highest expected value of the signature counter prior to creating the assertion.
         */
        fun verifyAssertion(
            attestationObject: ByteArray,
            assertionFromDevice: ByteArray,
            referenceClientData: ByteArray,
            challenge: ByteArray,
            counter: Long = 0
        ): AttestationResult
    }

    /**
     * Exposes Android-specific API to reduce toplevel clutter
     */
    abstract val android: Android

    interface Android {
        /**
         * convenience method for [verifyKeyAttestation] specific to Android. Attests the public key contained in the leaf
         * @param attestationCerts attestation certificate chain
         * @param expectedChallenge attestation challenge
         */
        fun verifyKeyAttestation(
            attestationCerts: List,
            expectedChallenge: ByteArray
        ): KeyAttestation
    }
}

/**
 * Pairs an Apple [AppAttest](https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server#3576644)
 * assertion with the referenced `clientData` value
 */
@JvmInline
value class AssertionData private constructor(private val pair: Pair) {

    /**
     * Pairs an Apple AppAttest  assertion with the referenced clientData value
     */
    constructor(assertion: ByteArray, clientData: ByteArray) : this(assertion to clientData)

    val assertion get() = pair.first
    val clientData get() = pair.second
}

/**
 * Attestation result class. Successful results contain attested data. Typically contained within a
 * [KeyAttestation] object.
 */
sealed class AttestationResult {

    override fun toString() = "AttestationResult::$details)"
    protected abstract val details: String

    /**
     * Successful Android Key Attestation result. [attestationCertificateChain] contains the attested certificate.
     *
     * All attested information in [attestationRecord] is available for further processing, should this be desired.
     * Note: this will fail when using the [NoopAttestationService]!
     */
    @Suppress("MemberVisibilityCanBePrivate")
    abstract class Android(val attestationCertificateChain: List) :
        AttestationResult() {

        protected abstract val androidDetails: String
        override val details: String by lazy { "Android::$androidDetails" }
        abstract val attestationRecord: ParsedAttestationRecord

        val attestationCertificate by lazy { attestationCertificateChain.first() }

        internal class NOOP internal constructor(attestationCertificateChain: List) :
            Android(attestationCertificateChain.mapNotNull { it.parseToCertificate() }) {
            override val androidDetails = "NOOP"
            override val attestationRecord: ParsedAttestationRecord by lazy {
                ParsedAttestationRecord.createParsedAttestationRecord(
                    attestationCertificateChain.mapNotNull { it.parseToCertificate() }
                )
            }
        }

        class Verified(attestationCertificateChain: List) : Android(attestationCertificateChain) {

            override val attestationRecord: ParsedAttestationRecord =
                ParsedAttestationRecord.createParsedAttestationRecord(
                    attestationCertificateChain
                )
            override val androidDetails =
                "Verified(keyMaster security level: ${attestationRecord.keymasterSecurityLevel().name}, " +
                        "attestation security level: ${attestationRecord.attestationSecurityLevel().name}, " +
                        "${attestationRecord.attestedKey().algorithm} public key: ${attestationRecord.attestedKey().encoded.encodeBase64()}" + attestationRecord.softwareEnforced()
                    .attestationApplicationId()
                    .getOrNull()
                    ?.let { app ->
                        ", packageInfos: ${
                            app.packageInfos().joinToString(
                                prefix = "[",
                                postfix = "]"
                            ) { info: AttestationApplicationId.AttestationPackageInfo -> "${info.packageName()}:${info.version()}" }
                        }"
                    }
        }

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (other !is Android) return false

            if (attestationCertificateChain.map { it.encoded.encodeBase64() } != other.attestationCertificateChain.map { it.encoded.encodeBase64() }) return false
            if (androidDetails != other.androidDetails) return false

            return true
        }

        override fun hashCode(): Int {
            var result = attestationCertificateChain.map { it.encoded.contentHashCode() }.hashCode()
            result = 31 * result + androidDetails.hashCode()
            return result
        }
    }


    /**
     * Successful iOS attestation. If [AttestationService.verifyKeyAttestation] returned this, [clientData] contains the
     * encoded attested public key.
     * The [Warden], returns [IOS.Verified], also setting [IOS.Verified.attestation].
     * The [NoopAttestationService] returns [IOS.NOOP] (which is useful to as it enables skipping any
     * and all attestation checks for unit testing, when used with dependency injection, for example).
     */
    @Suppress("MemberVisibilityCanBePrivate")
    abstract class IOS(val clientData: ByteArray?) : AttestationResult() {

        abstract val iosDetails: String
        override val details: String by lazy { "iOS::$iosDetails" }

        class Verified(
            val attestation: ValidatedAttestation,
            val iosVersion: ParsedVersions,
            val assertedClientData: Pair?
        ) :
            IOS(assertedClientData?.first) {
            override val iosDetails =
                "Verified(${attestation.certificate.publicKey.algorithm} public key: ${attestation.certificate.publicKey.encoded.encodeBase64()}, " +
                        "iOS version: (semVer=${iosVersion.first}, buildNumber=[${iosVersion.second}]), app: ${attestation.receipt.payload.appId}"
        }

        class NOOP(clientData: ByteArray?) : IOS(clientData) {
            override val iosDetails = "NOOP"
        }

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (other !is IOS) return false

            if (clientData != null) {
                if (other.clientData == null) return false
                if (!clientData.contentEquals(other.clientData)) return false
            } else if (other.clientData != null) return false
            if (iosDetails != other.iosDetails) return false

            return true
        }

        override fun hashCode(): Int {
            var result = clientData?.contentHashCode() ?: 0
            result = 31 * result + iosDetails.hashCode()
            return result
        }

        /**
         * Represents an attestation verification failure. Always contains an  [explanation] about what went wrong.
         */
    }

    class Error(val explanation: String, val cause: AttException? = null) : AttestationResult() {
        override val details = "Error($explanation" + cause?.let { ", Cause: ${cause::class.qualifiedName}" }
    }
}

/**
 * Result type returned by [AttestationService.verifyKeyAttestation].
 * [attestedPublicKey] contains attested public key if attestation was successful (null otherwise)
 * [details] contains the detailed attestation result (see [AttestationResult] for more details)
 *
 */
data class KeyAttestation internal constructor(
    val attestedPublicKey: T?,
    val details: AttestationResult
) {
    val isSuccess get() = attestedPublicKey != null

    override fun toString() = "Key$details"

    @Suppress("UNUSED")
    inline fun  fold(
        onError: (AttestationResult.Error) -> R,
        onSuccess: (T, AttestationResult) -> R
    ): R =
        if (isSuccess) onSuccess(attestedPublicKey!!, details)
        else {
            onError(details as AttestationResult.Error)
        }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is KeyAttestation<*>) return false

        if (!attestedPublicKey?.encoded.contentEquals(other.attestedPublicKey?.encoded)) return false
        if (details != other.details) return false

        return true
    }

    override fun hashCode(): Int {
        var result = attestedPublicKey?.encoded?.contentHashCode() ?: 0
        result = 31 * result + details.hashCode()
        return result
    }


}

/**
 * NOOP attestation service. Useful during unit tests for disabling attestation integrated into service endpoints.
 * Simply forwards inputs but performs no attestation whatsoever.
 *
 * Do not use in production!
 */

object NoopAttestationService : AttestationService() {

    private val log = LoggerFactory.getLogger(this.javaClass)
    override fun verifyAttestation(
        attestationProof: List,
        challenge: ByteArray,
        clientData: ByteArray?
    ): AttestationResult =
        if (attestationProof.size > 2) AttestationResult.Android.NOOP(attestationProof)
        else AttestationResult.IOS.NOOP(clientData)

    override val ios: IOS
        get() = object : IOS {
            override fun verifyAppAttestation(attestationObject: ByteArray, challenge: ByteArray) =
                verifyAttestation(listOf(attestationObject), challenge, clientData = null)

            override fun verifyAssertion(
                attestationObject: ByteArray,
                assertionFromDevice: ByteArray,
                referenceClientData: ByteArray,
                challenge: ByteArray,
                counter: Long
            ) = verifyAttestation(listOf(attestationObject, assertionFromDevice), challenge, referenceClientData)

        }
    override val android: Android
        get() = TODO("Not yet implemented")
}





© 2015 - 2024 Weber Informatics LLC | Privacy Policy