diff --git a/CHANGELOG.md b/CHANGELOG.md index 0863e9e..813ddb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,4 +74,8 @@ In addition, 1.0.0. introduces a new diagnostics tool (a runnable jar), which ta out the attestation record. ### 1.1.0 -- introduce builder for `AppData` \ No newline at end of file +- introduce builder for `AppData` + +### 1.2.0 +- introduce well-defined error codes for every way an attestation can fail +- refactor exception hierarchy as a consequence \ No newline at end of file diff --git a/android-attestation/build.gradle.kts b/android-attestation/build.gradle.kts index 544743b..61c2be3 100644 --- a/android-attestation/build.gradle.kts +++ b/android-attestation/build.gradle.kts @@ -3,7 +3,7 @@ import at.asitplus.gradle.ktor import org.gradle.kotlin.dsl.support.listFilesOrdered group = "at.asitplus" -version = "1.1.0" +version = "1.2.0" plugins { kotlin("jvm") diff --git a/android-attestation/src/main/kotlin/AndroidAttestationChecker.kt b/android-attestation/src/main/kotlin/AndroidAttestationChecker.kt index 6550e8e..b834231 100644 --- a/android-attestation/src/main/kotlin/AndroidAttestationChecker.kt +++ b/android-attestation/src/main/kotlin/AndroidAttestationChecker.kt @@ -1,6 +1,6 @@ package at.asitplus.attestation.android -import at.asitplus.attestation.android.exceptions.AttestationException +import at.asitplus.attestation.android.exceptions.AttestationValueException import at.asitplus.attestation.android.exceptions.CertificateInvalidException import at.asitplus.attestation.android.exceptions.RevocationException import com.google.android.attestation.AuthorizationList @@ -26,6 +26,8 @@ import java.io.InputStream import java.math.BigInteger import java.security.Principal import java.security.PublicKey +import java.security.cert.CertificateExpiredException +import java.security.cert.CertificateNotYetValidException import java.security.cert.X509Certificate import java.util.* @@ -38,9 +40,21 @@ abstract class AndroidAttestationChecker( private fun List.verifyCertificateChain(verificationDate: Date) { runCatching { verifyRootCertificate(verificationDate) } - .onFailure { throw CertificateInvalidException("could not verify root certificate", cause = it) } + .onFailure { + throw if (it is CertificateInvalidException) it else CertificateInvalidException( + "could not verify root certificate", + cause = it, + if ((it is CertificateExpiredException) || (it is CertificateNotYetValidException)) CertificateInvalidException.Reason.TIME else CertificateInvalidException.Reason.TRUST + ) + } val revocationStatusList = runCatching { RevocationList.fromGoogleServer() } - .getOrElse { throw RevocationException("could not download revocation information", it) } + .getOrElse { + throw RevocationException( + "could not download revocation information", + it, + RevocationException.Reason.LIST_UNAVAILABLE + ) + } let { if (attestationConfiguration.ignoreLeafValidity) mapIndexed { i, cert -> if (i == 0) EternalX509Certificate(cert) else cert @@ -61,15 +75,23 @@ abstract class AndroidAttestationChecker( certificate.checkValidity(verificationDate) certificate.verify(parent.publicKey) }.onFailure { - throw CertificateInvalidException(it.message ?: "Certificate invalid", it) + throw CertificateInvalidException( + it.message ?: "Certificate invalid", + it, + if ((it is CertificateExpiredException) || (it is CertificateNotYetValidException)) CertificateInvalidException.Reason.TIME else CertificateInvalidException.Reason.TRUST + ) } runCatching { statusList.isRevoked(certificate.serialNumber) }.onSuccess { - if (it) // getting any status means not trustworthy - throw RevocationException("Certificate revoked") + if (it) + throw RevocationException("Certificate revoked", reason = RevocationException.Reason.REVOKED) }.onFailure { - throw RevocationException("Could not get revocation list", it) + throw RevocationException( + "Could not init revocation list", + it, + RevocationException.Reason.LIST_UNAVAILABLE + ) } } @@ -78,85 +100,124 @@ abstract class AndroidAttestationChecker( root.checkValidity(verificationDate) val matchingTrustAnchor = trustAnchors .firstOrNull { root.publicKey.encoded.contentEquals(it.encoded) } - ?: throw CertificateInvalidException("No matching root certificate") + ?: throw CertificateInvalidException( + "No matching root certificate", + reason = CertificateInvalidException.Reason.TRUST + ) root.verify(matchingTrustAnchor) } protected abstract val trustAnchors: Collection - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) private fun ParsedAttestationRecord.verifyApplication(application: AndroidAttestationConfiguration.AppData) { runCatching { if (softwareEnforced.attestationApplicationId.get().packageInfos.first().packageName != application.packageName) { - throw AttestationException("Invalid Application Package") + throw AttestationValueException( + "Invalid Application Package", + reason = AttestationValueException.Reason.PACKAGE_NAME + ) } application.appVersion?.let { configuredVersion -> if (softwareEnforced.attestationApplicationId.get().packageInfos.first().version < configuredVersion) { - throw AttestationException("Application Version not supported") + throw AttestationValueException( + "Application Version not supported", + reason = AttestationValueException.Reason.APP_VERSION + ) } } if (!softwareEnforced.attestationApplicationId.get().signatureDigests.any { fromAttestation -> application.signatureDigests.any { it.contentEquals(fromAttestation) } }) { - throw AttestationException("Invalid Application Signature Digest") + throw AttestationValueException( + "Invalid Application Signature Digest", + reason = AttestationValueException.Reason.APP_SIGNER_DIGEST + ) } }.onFailure { throw when (it) { - is AttestationException -> it - else -> AttestationException("Could not verify Client Application", it) + is AttestationValueException -> it + else -> AttestationValueException( + "Could not verify Client Application", + it, + reason = AttestationValueException.Reason.APP_UNEXPECTED + ) } } } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) protected abstract fun ParsedAttestationRecord.verifyAndroidVersion( versionOverride: Int? = null, osPatchLevel: Int? ) + protected fun AuthorizationList.verifyAndroidVersion(versionOverride: Int?, patchLevel: Int?) { runCatching { - (versionOverride?: attestationConfiguration.androidVersion)?.let { - if ((osVersion.get()) < it) throw AttestationException("Android version not supported") + (versionOverride ?: attestationConfiguration.androidVersion)?.let { + if ((osVersion.get()) < it) throw AttestationValueException( + "Android version not supported", + reason = AttestationValueException.Reason.OS_VERSION + ) } - (patchLevel?:attestationConfiguration.osPatchLevel)?.let { - if ((osPatchLevel.get()) < it) throw AttestationException("Patch level not supported") + (patchLevel ?: attestationConfiguration.osPatchLevel)?.let { + if ((osPatchLevel.get()) < it) throw AttestationValueException( + "Patch level not supported", + reason = AttestationValueException.Reason.OS_VERSION + ) } }.onFailure { throw when (it) { - is AttestationException -> it - else -> AttestationException("Could not verify Android Version", it) + is AttestationValueException -> it + else -> AttestationValueException( + "Could not verify Android Version", + it, + AttestationValueException.Reason.OS_VERSION + ) } } } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) protected abstract fun ParsedAttestationRecord.verifyBootStateAndSystemImage() - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) protected fun AuthorizationList.verifySystemLocked() { if (attestationConfiguration.allowBootloaderUnlock) return - if (rootOfTrust == null) throw AttestationException("Root of Trust not present") + if (rootOfTrust == null) throw AttestationValueException( + "Root of Trust not present", + reason = AttestationValueException.Reason.SYSTEM_INTEGRITY + ) - if (!rootOfTrust.get().deviceLocked) throw AttestationException("Bootloader not locked") + if (!rootOfTrust.get().deviceLocked) throw AttestationValueException( + "Bootloader not locked", + reason = AttestationValueException.Reason.SYSTEM_INTEGRITY + ) if ((rootOfTrust.get().verifiedBootState ?: RootOfTrust.VerifiedBootState.FAILED) != RootOfTrust.VerifiedBootState.VERIFIED - ) throw AttestationException("System image not verified") + ) throw AttestationValueException( + "System image not verified", + reason = AttestationValueException.Reason.SYSTEM_INTEGRITY + ) } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) protected abstract fun ParsedAttestationRecord.verifyRollbackResistance() - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) protected fun AuthorizationList.verifyRollbackResistance() { if (attestationConfiguration.requireRollbackResistance) - if (!rollbackResistant) throw AttestationException("No rollback resistance") + if (!rollbackResistant) throw AttestationValueException( + "No rollback resistance", + reason = AttestationValueException.Reason.ROLLBACK_RESISTANCE + ) } /** @@ -166,12 +227,12 @@ abstract class AndroidAttestationChecker( * @See [AndroidAttestationConfiguration] for details on what is and is not checked. * * @return [ParsedAttestationRecord] on success - * @throws AttestationException if a property fails to verify according to the current configuration + * @throws AttestationValueException if a property fails to verify according to the current configuration * @throws RevocationException if a certificate has been revoked * @throws CertificateInvalidException if certificates fail to verify * */ - @Throws(AttestationException::class, CertificateInvalidException::class, RevocationException::class) + @Throws(AttestationValueException::class, CertificateInvalidException::class, RevocationException::class) open fun verifyAttestation( certificates: List, verificationDate: Date = Date(), @@ -187,7 +248,10 @@ abstract class AndroidAttestationChecker( expectedChallenge, parsedAttestationRecord.attestationChallenge ) - ) throw AttestationException("verification of attestation challenge failed") + ) throw AttestationValueException( + "verification of attestation challenge failed", + reason = AttestationValueException.Reason.CHALLENGE + ) parsedAttestationRecord.verifySecurityLevel() parsedAttestationRecord.verifyBootStateAndSystemImage() @@ -196,13 +260,14 @@ abstract class AndroidAttestationChecker( val attestedApp = attestationConfiguration.applications.associateWith { app -> runCatching { parsedAttestationRecord.verifyApplication(app) } }.let { - it.entries.firstOrNull { (_, result) -> result.isSuccess } ?: it.values.first().exceptionOrNull()!!.let { throw it } + it.entries.firstOrNull { (_, result) -> result.isSuccess } ?: it.values.first().exceptionOrNull()!! + .let { throw it } }.key parsedAttestationRecord.verifyAndroidVersion(attestedApp.androidVersionOverride, attestedApp.osPatchLevel) return parsedAttestationRecord } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) protected abstract fun ParsedAttestationRecord.verifySecurityLevel() /** diff --git a/android-attestation/src/main/kotlin/AndroidAttestationConfiguration.kt b/android-attestation/src/main/kotlin/AndroidAttestationConfiguration.kt index f81367e..03b0e33 100644 --- a/android-attestation/src/main/kotlin/AndroidAttestationConfiguration.kt +++ b/android-attestation/src/main/kotlin/AndroidAttestationConfiguration.kt @@ -1,6 +1,6 @@ package at.asitplus.attestation.android -import at.asitplus.attestation.android.exceptions.AttestationException +import at.asitplus.attestation.android.exceptions.AndroidAttestationException import com.google.android.attestation.Constants.GOOGLE_ROOT_CA_PUB_KEY import java.security.KeyFactory import java.security.PublicKey @@ -240,7 +240,8 @@ class AndroidAttestationConfiguration @JvmOverloads constructor( ) { init { - if (signatureDigests.isEmpty()) throw AttestationException("No signature digests specified") + if (signatureDigests.isEmpty()) throw object : + AndroidAttestationException("No signature digests specified", null) {} } /** @@ -296,11 +297,13 @@ class AndroidAttestationConfiguration @JvmOverloads constructor( init { if (hardwareAttestationTrustAnchors.isEmpty() && softwareAttestationTrustAnchors.isEmpty()) - throw AttestationException("No trust anchors configured") + throw object : AndroidAttestationException("No trust anchors configured", null) {} - if (applications.isEmpty()) throw AttestationException("No apps configured") + if (applications.isEmpty()) throw object : AndroidAttestationException("No apps configured", null) {} if (disableHardwareAttestation && !enableSoftwareAttestation && !enableNougatAttestation) - throw AttestationException("Neither hardware, nor hybrid, nor software attestation enabled") + throw object : AndroidAttestationException( + "Neither hardware, nor hybrid, nor software attestation enabled", null + ) {} } /** diff --git a/android-attestation/src/main/kotlin/HardwareAttestationChecker.kt b/android-attestation/src/main/kotlin/HardwareAttestationChecker.kt index 0ec070b..881f7a6 100644 --- a/android-attestation/src/main/kotlin/HardwareAttestationChecker.kt +++ b/android-attestation/src/main/kotlin/HardwareAttestationChecker.kt @@ -1,6 +1,7 @@ package at.asitplus.attestation.android -import at.asitplus.attestation.android.exceptions.AttestationException +import at.asitplus.attestation.android.exceptions.AndroidAttestationException +import at.asitplus.attestation.android.exceptions.AttestationValueException import com.google.android.attestation.ParsedAttestationRecord import io.ktor.client.* import io.ktor.client.call.* @@ -18,34 +19,48 @@ class HardwareAttestationChecker @JvmOverloads constructor( ) : AndroidAttestationChecker(attestationConfiguration, verifyChallenge) { init { - if (attestationConfiguration.disableHardwareAttestation) throw AttestationException("Hardware attestation is disabled!") - if (attestationConfiguration.hardwareAttestationTrustAnchors.isEmpty()) throw AttestationException("No hardware attestation trust anchors configured") + if (attestationConfiguration.disableHardwareAttestation) throw object : + AndroidAttestationException("Hardware attestation is disabled!", null) {} + if (attestationConfiguration.hardwareAttestationTrustAnchors.isEmpty()) throw object : + AndroidAttestationException("No hardware attestation trust anchors configured", null) {} } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifySecurityLevel() { if (attestationConfiguration.requireStrongBox) { if (attestationSecurityLevel != ParsedAttestationRecord.SecurityLevel.STRONG_BOX) - throw AttestationException("Attestation security level not StrongBox") + throw AttestationValueException( + "Attestation security level not StrongBox", + reason = AttestationValueException.Reason.SEC_LEVEL + ) if (keymasterSecurityLevel != ParsedAttestationRecord.SecurityLevel.STRONG_BOX) - throw AttestationException("Keymaster security level not StrongBox") + throw AttestationValueException( + "Keymaster security level not StrongBox", + reason = AttestationValueException.Reason.SEC_LEVEL + ) } else { if (attestationSecurityLevel == ParsedAttestationRecord.SecurityLevel.SOFTWARE) - throw AttestationException("Attestation security level software") + throw AttestationValueException( + "Attestation security level software", + reason = AttestationValueException.Reason.SEC_LEVEL + ) if (keymasterSecurityLevel == ParsedAttestationRecord.SecurityLevel.SOFTWARE) - throw AttestationException("Keymaster security level software") + throw AttestationValueException( + "Keymaster security level software", + reason = AttestationValueException.Reason.SEC_LEVEL + ) } } override val trustAnchors = attestationConfiguration.hardwareAttestationTrustAnchors - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyAndroidVersion(versionOverride: Int?, osPatchLevel: Int?) = teeEnforced.verifyAndroidVersion(versionOverride, osPatchLevel) - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyBootStateAndSystemImage() = teeEnforced.verifySystemLocked() - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyRollbackResistance() = teeEnforced.verifyRollbackResistance() } \ No newline at end of file diff --git a/android-attestation/src/main/kotlin/NougatHybridAttestationChecker.kt b/android-attestation/src/main/kotlin/NougatHybridAttestationChecker.kt index b1a3700..a856e74 100644 --- a/android-attestation/src/main/kotlin/NougatHybridAttestationChecker.kt +++ b/android-attestation/src/main/kotlin/NougatHybridAttestationChecker.kt @@ -1,6 +1,7 @@ package at.asitplus.attestation.android -import at.asitplus.attestation.android.exceptions.AttestationException +import at.asitplus.attestation.android.exceptions.AndroidAttestationException +import at.asitplus.attestation.android.exceptions.AttestationValueException import com.google.android.attestation.ParsedAttestationRecord import io.ktor.client.* import io.ktor.client.call.* @@ -18,36 +19,42 @@ class NougatHybridAttestationChecker @JvmOverloads constructor( ) : AndroidAttestationChecker(attestationConfiguration, verifyChallenge) { init { - if (!attestationConfiguration.enableNougatAttestation) throw AttestationException("Hardware attestation is disabled!") - if (attestationConfiguration.hardwareAttestationTrustAnchors.isEmpty()) throw AttestationException("No hardware attestation trust anchors configured") + if (!attestationConfiguration.enableNougatAttestation) throw object : + AndroidAttestationException("Hardware attestation is disabled!", null) {} + if (attestationConfiguration.hardwareAttestationTrustAnchors.isEmpty()) throw object : + AndroidAttestationException("No hardware attestation trust anchors configured", null) {} } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifySecurityLevel() { if (attestationConfiguration.requireStrongBox) { - if (keymasterSecurityLevel != ParsedAttestationRecord.SecurityLevel.STRONG_BOX) - throw AttestationException("Keymaster security level not StrongBox") + if (keymasterSecurityLevel != ParsedAttestationRecord.SecurityLevel.STRONG_BOX) throw AttestationValueException( + "Keymaster security level not StrongBox", reason = AttestationValueException.Reason.SEC_LEVEL + ) } else { - if (keymasterSecurityLevel == ParsedAttestationRecord.SecurityLevel.SOFTWARE) - throw AttestationException("Keymaster security level software") + if (keymasterSecurityLevel == ParsedAttestationRecord.SecurityLevel.SOFTWARE) throw AttestationValueException( + "Keymaster security level software", reason = AttestationValueException.Reason.SEC_LEVEL + ) } if (attestationSecurityLevel != ParsedAttestationRecord.SecurityLevel.SOFTWARE) { - throw AttestationException("Attestation security level not software") + throw AttestationValueException( + "Attestation security level not software", reason = AttestationValueException.Reason.SEC_LEVEL + ) } } override val trustAnchors = attestationConfiguration.softwareAttestationTrustAnchors - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyAndroidVersion(versionOverride: Int?, osPatchLevel: Int?) { //impossible } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyBootStateAndSystemImage() { //impossible } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyRollbackResistance() = teeEnforced.verifyRollbackResistance() } \ No newline at end of file diff --git a/android-attestation/src/main/kotlin/SoftwareAttestationChecker.kt b/android-attestation/src/main/kotlin/SoftwareAttestationChecker.kt index 5258741..05d28ef 100644 --- a/android-attestation/src/main/kotlin/SoftwareAttestationChecker.kt +++ b/android-attestation/src/main/kotlin/SoftwareAttestationChecker.kt @@ -1,6 +1,7 @@ package at.asitplus.attestation.android -import at.asitplus.attestation.android.exceptions.AttestationException +import at.asitplus.attestation.android.exceptions.AndroidAttestationException +import at.asitplus.attestation.android.exceptions.AttestationValueException import com.google.android.attestation.ParsedAttestationRecord import io.ktor.client.* import io.ktor.client.call.* @@ -18,8 +19,10 @@ class SoftwareAttestationChecker @JvmOverloads constructor( verifyChallenge: (expected: ByteArray, actual: ByteArray) -> Boolean = { expected, actual -> expected contentEquals actual } ) : AndroidAttestationChecker(attestationConfiguration, verifyChallenge) { init { - if (!attestationConfiguration.enableSoftwareAttestation) throw AttestationException("Software attestation is disabled!") - if (attestationConfiguration.softwareAttestationTrustAnchors.isEmpty()) throw AttestationException("No software attestation trust anchors configured") + if (!attestationConfiguration.enableSoftwareAttestation) throw object : + AndroidAttestationException("Software attestation is disabled!", null) {} + if (attestationConfiguration.softwareAttestationTrustAnchors.isEmpty()) throw object : + AndroidAttestationException("No software attestation trust anchors configured", null) {} } companion object { @@ -33,25 +36,27 @@ class SoftwareAttestationChecker @JvmOverloads constructor( "+Vfkl5YLCazOkjWFmwIDAQAB" } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifySecurityLevel() { - if (attestationSecurityLevel != ParsedAttestationRecord.SecurityLevel.SOFTWARE) - throw AttestationException("Attestation security level not software") - if (keymasterSecurityLevel != ParsedAttestationRecord.SecurityLevel.SOFTWARE) - throw AttestationException("Keymaster security level not software") + if (attestationSecurityLevel != ParsedAttestationRecord.SecurityLevel.SOFTWARE) throw AttestationValueException( + "Attestation security level not software", reason = AttestationValueException.Reason.SEC_LEVEL + ) + if (keymasterSecurityLevel != ParsedAttestationRecord.SecurityLevel.SOFTWARE) throw AttestationValueException( + "Keymaster security level not software", reason = AttestationValueException.Reason.SEC_LEVEL + ) } override val trustAnchors: Collection = attestationConfiguration.softwareAttestationTrustAnchors - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyAndroidVersion(versionOverride: Int?, osPatchLevel: Int?) = softwareEnforced.verifyAndroidVersion(versionOverride, osPatchLevel) - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyBootStateAndSystemImage() { //impossible } - @Throws(AttestationException::class) + @Throws(AttestationValueException::class) override fun ParsedAttestationRecord.verifyRollbackResistance() = softwareEnforced.verifyRollbackResistance() } \ No newline at end of file diff --git a/android-attestation/src/main/kotlin/exceptions/Throwables.kt b/android-attestation/src/main/kotlin/exceptions/Throwables.kt index 0fc469d..11c2a07 100644 --- a/android-attestation/src/main/kotlin/exceptions/Throwables.kt +++ b/android-attestation/src/main/kotlin/exceptions/Throwables.kt @@ -1,5 +1,128 @@ package at.asitplus.attestation.android.exceptions -class AttestationException(message: String?, cause: Throwable? = null) : Throwable(message, cause) -class CertificateInvalidException(message: String, cause: Throwable? = null) : Throwable(message, cause) -class RevocationException(message: String?, cause: Throwable? = null) : Throwable(message, cause) +import at.asitplus.attestation.android.SoftwareAttestationChecker + +/** + * Base class for all well-defined Android attestation exceptions. + * If this one is thrown, a well-defined error arose. + */ +abstract class AndroidAttestationException(message: String?, cause: Throwable?):Throwable(message, cause) + + +/** + * Indicates an attestation error during App or OS attestation + * + * @param message the error message + * @param cause the underlying exception + * @param reason one of a set of well-defined [Reason]s why the attestation failed + */ +class AttestationValueException(message: String?, cause: Throwable? = null, val reason: Reason) : AndroidAttestationException(message, cause) { + + /** + * Possible reasons an [AttestationValueException] was thrown + */ + enum class Reason { + /** + * Indicates an unlocked bootloader and/or modified system image + */ + SYSTEM_INTEGRITY, + + /** + * Indicates that the app was not signed by the developer (i.e. a repackaged app has been detected9 + */ + APP_SIGNER_DIGEST, + + /** + * Indicates that "this is not the app you are looking for" (i.e. an unauthorized client is connecting to your backend) + */ + PACKAGE_NAME, + + /** + * Indicates an app version mismatch (i.e. the app used is too old) + */ + APP_VERSION, + + /** + * Indicates an unexpected error when trying to attest an app's properties. This should never happen, but a borked + * attestation extension in the leaf certificate coul cause this. + */ + APP_UNEXPECTED, + + /** + * Indicates an unsupported (i.e. outdated) OS or patch level version being used. + */ + OS_VERSION, + + /** + * If you encounter this, you are assumed to know what it is about + */ + ROLLBACK_RESISTANCE, + + /** + * Happens if the challenge in the attestation record does not pass the challenge verification function + * (which, by default, simply checks for equality) + */ + CHALLENGE, + + /** + * Indicates that the security level of the attestation does not match the configured one (i.e. an attestation + * record produced in hardware being validated against a [SoftwareAttestationChecker]. + * + * **Note** that this reason might be shadowed by a [CertificateInvalidException] with [CertificateInvalidException.Reason.TRUST] + * since software and hardware attestation use different trust anchors + */ + SEC_LEVEL + } +} + +/** + * Indicates an error verifying the attestation's underlying certificate chain + * + * @param message the error message + * @param cause the underlying exception + * @param reason one of a set of well-defined [Reason]s why the attestation failed + */ +class CertificateInvalidException(message: String, cause: Throwable? = null, val reason: Reason) : + AndroidAttestationException(message, cause) { + + /** + * Possible reasons a [CertificateInvalidException] was thrown + */ + enum class Reason { + /** + * Indicates either a borked certificate chain, or a mismatching trust anchor + */ + TRUST, + + /** + * Indicates that temporal invalidity of at least one certificate + */ + TIME, + + } +} + +/** + * Indicates an attestation error due to revocation or inability to fetch a revocation list + * @param message the error message + * @param cause the underlying exception + * @param reason one of a set of well-defined [Reason]s why the attestation failed + */ +class RevocationException(message: String?, cause: Throwable? = null, val reason: Reason) : AndroidAttestationException(message, cause) { + + /** + * Possible reasons, a [RevocationException] was thrown + */ + enum class Reason { + + /** + * Indicates an error fetching the revocation list. + */ + LIST_UNAVAILABLE, + + /** + * Indicates that a certificate on the chain was revoked or suspended. + */ + REVOKED + } +} diff --git a/android-attestation/src/test/kotlin/AttestationTests.kt b/android-attestation/src/test/kotlin/AttestationTests.kt index cbe58dd..226431c 100644 --- a/android-attestation/src/test/kotlin/AttestationTests.kt +++ b/android-attestation/src/test/kotlin/AttestationTests.kt @@ -1,6 +1,7 @@ package at.asitplus.attestation.android -import at.asitplus.attestation.android.exceptions.AttestationException +import at.asitplus.attestation.android.exceptions.AndroidAttestationException +import at.asitplus.attestation.android.exceptions.AttestationValueException import at.asitplus.attestation.android.exceptions.CertificateInvalidException import at.asitplus.attestation.data.AttestationData import at.asitplus.attestation.data.attestationCertChain @@ -108,7 +109,7 @@ class AttestationTests : FreeSpec() { verificationDate, challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST } } @@ -126,13 +127,13 @@ class AttestationTests : FreeSpec() { ignoreLeafValidity = true ) ).apply { - shouldThrow { + shouldThrow { verifyAttestation( attestationCertChain, verificationDate, challenge ) - } + }.reason shouldBe AttestationValueException.Reason.SEC_LEVEL } } @@ -195,7 +196,7 @@ class AttestationTests : FreeSpec() { data.verificationDate, data.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST } } @@ -218,7 +219,7 @@ class AttestationTests : FreeSpec() { data.verificationDate, data.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST } } @@ -444,7 +445,7 @@ class AttestationTests : FreeSpec() { recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST } } @@ -466,7 +467,7 @@ class AttestationTests : FreeSpec() { recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST } } @@ -480,31 +481,31 @@ class AttestationTests : FreeSpec() { recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST shouldThrow { service.verifyAttestation( recordedAttestation.attestationCertChain.subList(0, 1), recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST shouldThrow { service.verifyAttestation( recordedAttestation.attestationCertChain.subList(0, 2), recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST } "require StrongBox" { - shouldThrow { + shouldThrow { attestationService(requireStrongBox = true).verifyAttestation( recordedAttestation.attestationCertChain, recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe AttestationValueException.Reason.SEC_LEVEL } "time of verification" - { @@ -518,7 +519,7 @@ class AttestationTests : FreeSpec() { ), recordedAttestation.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TIME } "too late" { @@ -531,22 +532,22 @@ class AttestationTests : FreeSpec() { ), recordedAttestation.challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TIME } } "package name" { - shouldThrow { + shouldThrow { attestationService(androidPackageName = "org.wrong.package.name").verifyAttestation( recordedAttestation.attestationCertChain, recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe AttestationValueException.Reason.PACKAGE_NAME } "wrong signature digests" { - shouldThrow { + shouldThrow { attestationService( androidAppSignatureDigest = listOf( byteArrayOf(0, 32, 55, 29, 120, 22, 0), @@ -558,11 +559,11 @@ class AttestationTests : FreeSpec() { recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe AttestationValueException.Reason.APP_SIGNER_DIGEST } "no signature digests, cannot instantiate" { - shouldThrow { + shouldThrow { attestationService(androidAppSignatureDigest = listOf()) } } @@ -570,33 +571,43 @@ class AttestationTests : FreeSpec() { "app version" { - shouldThrow { + shouldThrow { + attestationService(androidAppVersion = 20).verifyAttestation( + recordedAttestation.attestationCertChain, + recordedAttestation.verificationDate, + recordedAttestation.challenge + ) + }.reason shouldBe AttestationValueException.Reason.APP_VERSION + } + + "OS version" { + shouldThrow { attestationService(androidVersion = 200000).verifyAttestation( recordedAttestation.attestationCertChain, recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe AttestationValueException.Reason.OS_VERSION } "patch level" { - shouldThrow { + shouldThrow { attestationService(androidPatchLevel = PatchLevel(2030, 1)).verifyAttestation( recordedAttestation.attestationCertChain, recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe AttestationValueException.Reason.OS_VERSION } "rollback resistance" { - shouldThrow { + shouldThrow { attestationService(requireRollbackResistance = true).verifyAttestation( recordedAttestation.attestationCertChain, recordedAttestation.verificationDate, recordedAttestation.challenge ) - } + }.reason shouldBe AttestationValueException.Reason.ROLLBACK_RESISTANCE } } } diff --git a/android-attestation/src/test/kotlin/FakeAttestationTests.kt b/android-attestation/src/test/kotlin/FakeAttestationTests.kt index b04596c..73de691 100644 --- a/android-attestation/src/test/kotlin/FakeAttestationTests.kt +++ b/android-attestation/src/test/kotlin/FakeAttestationTests.kt @@ -6,6 +6,7 @@ import at.asitplus.attestation.data.AttestationData import at.asitplus.attestation.data.attestationCertChain import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe import kotlin.random.Random class FakeAttestationTests : FreeSpec({ @@ -116,7 +117,7 @@ class FakeAttestationTests : FreeSpec({ shouldThrow { checker.verifyAttestation(nokia.attestationCertChain, expectedChallenge = nokia.challenge) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST } "and the fake attestation must not verify against the google root key" { @@ -141,7 +142,7 @@ class FakeAttestationTests : FreeSpec({ certificates = attestationProof, expectedChallenge = challenge ) - } + }.reason shouldBe CertificateInvalidException.Reason.TRUST }