Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

rework key attestation key comparison #11

Merged
merged 4 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 2.1.1
- Rely on [Signum](https://github.com/a-sit-plus/signum) to transcode public keys
- Add working `hashCode` and `equals` to `AttestationResult` and `KeyAttestation`
- Rework key attestation key comparison
- Try all encodings for public keys
- Throw exception with *very* detailed message when key attestation runs into a logical error

## 2.1.0
- Rebrand to _WARDEN_
- Dependency Updates
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
jdk.version=17
artifactVersion=2.1.0
artifactVersion=2.1.1
androidAttestationVersion=1.6.0
2 changes: 1 addition & 1 deletion warden-roboto
2 changes: 1 addition & 1 deletion warden/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ sourceSets.test {
dependencies {
api("at.asitplus:warden-roboto:$androidAttestationVersion")
api(datetime())
implementation("at.asitplus.signum:indispensable:3.6.0")
implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.14.2")
implementation("net.swiftzer.semver:semver:1.2.0")
implementation(bouncycastle("bcpkix", "jdk18on"))
implementation("org.slf4j:slf4j-api:1.7.36")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2")

Expand Down
145 changes: 111 additions & 34 deletions warden/src/main/kotlin/AttestationService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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
Expand All @@ -12,7 +14,6 @@ import net.swiftzer.semver.SemVer
import org.slf4j.LoggerFactory
import java.security.PublicKey
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import java.util.*
import kotlin.jvm.optionals.getOrNull
import at.asitplus.attestation.AttestationException as AttException
Expand Down Expand Up @@ -256,46 +257,69 @@ abstract class AttestationService {
attestationProof: List<ByteArray>,
expectedChallenge: ByteArray,
keyToBeAttested: T
): KeyAttestation<T> =
when (val firstTry = verifyAttestation(attestationProof, expectedChallenge, keyToBeAttested.encoded)) {
is AttestationResult.Android -> {
if (keyToBeAttested.encoded contentEquals firstTry.attestationCertificate.publicKey.encoded) KeyAttestation(
keyToBeAttested,
firstTry
)
else {
("Android attestation failed: keyToBeAttested (${keyToBeAttested.encoded.encodeBase64()}) does not match " +
"key from attestation certificate: ${firstTry.attestationCertificate.publicKey.encoded.encodeBase64()}").let {
KeyAttestation(
null,
AttestationResult.Error(
explanation = it,
cause = AttException.Content.Android(
it,
AttestationValueException(
it,
cause = null,
reason = AttestationValueException.Reason.APP_UNEXPECTED
)
)
)
)
): KeyAttestation<T> {
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.Error -> when (val secondTry =
kotlin.runCatching {
verifyAttestation(attestationProof, expectedChallenge, (keyToBeAttested as ECPublicKey).toAnsi())
}.getOrElse { return KeyAttestation(null, firstTry) }) {
is AttestationResult.Android -> throw RuntimeException("WTF?")
is AttestationResult.Error -> KeyAttestation(null, firstTry)
is AttestationResult.IOS -> KeyAttestation(keyToBeAttested, secondTry)
}
is AttestationResult.IOS -> return KeyAttestation(keyToBeAttested, firstTry)
}
}

is AttestationResult.IOS -> KeyAttestation(keyToBeAttested, firstTry)
private fun <T : PublicKey> processAndroidAttestationResult(
keyToBeAttested: T,
firstTry: AttestationResult.Android
): KeyAttestation<T> =
if (CryptoPublicKey.fromJcaPublicKey(keyToBeAttested) == CryptoPublicKey.fromJcaPublicKey(
firstTry.attestationCertificate.publicKey
)
) KeyAttestation(keyToBeAttested, firstTry)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would also refactor this, because it's not clear what's compared to what, and the if block has no curly braces, but the else block does, although it's also only one statement

else {
("Android attestation failed: keyToBeAttested (${keyToBeAttested.encoded.encodeBase64()}) does not match " +
"key from attestation certificate: ${firstTry.attestationCertificate.publicKey.encoded.encodeBase64()}").let {
KeyAttestation(
null,
AttestationResult.Error(
explanation = it,
cause = AttException.Content.Android(
it,
AttestationValueException(
it,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much better, but this still seems off, because not every parameter is using the parameter name, and it takes up 14 lines to create the result

cause = null,
reason = AttestationValueException.Reason.APP_UNEXPECTED
)
)
)
)
}
}


/** Same as [verifyKeyAttestation], but taking an encoded (either ANSI X9.63 or DER) publix key as a byte array
* @see verifyKeyAttestation
*/
Expand Down Expand Up @@ -435,6 +459,22 @@ sealed class AttestationResult {
}"
}
}

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
}
}


Expand Down Expand Up @@ -466,6 +506,25 @@ sealed class AttestationResult {
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.
*/
Expand Down Expand Up @@ -499,6 +558,24 @@ data class KeyAttestation<T : PublicKey> internal constructor(
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
}


}

/**
Expand Down
32 changes: 16 additions & 16 deletions warden/src/main/kotlin/Extensions.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package at.asitplus.attestation

import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.fromJcaPublicKey
import at.asitplus.signum.indispensable.getJcaPublicKey
import kotlinx.datetime.Clock
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toKotlinInstant
Expand Down Expand Up @@ -39,6 +42,17 @@ data class AttestationObject(
)
}

internal fun PublicKey.transcodeToAllFormats() = CryptoPublicKey.fromJcaPublicKey(this).getOrThrow().let {
listOf(
it.iosEncoded,
(it as CryptoPublicKey.EC).copy(
it.publicPoint,
preferCompressedRepresentation = !it.preferCompressedRepresentation
).iosEncoded,
it.encodeToDer()
)
}

internal fun String.decodeBase64ToArray() = Base64.decode(this)

internal fun ByteArray.encodeBase64() = Base64.toBase64String(this)
Expand All @@ -58,25 +72,11 @@ internal fun Clock.toJavaClock(): java.time.Clock =

internal fun kotlinx.datetime.Instant.toJavaDate() = Date.from(toJavaInstant())

fun ECPublicKey.toAnsi() = let {
val xFromBc = it.w.affineX.toByteArray().ensureSize(32)
val yFromBc = it.w.affineY.toByteArray().ensureSize(32)
byteArrayOf(0x04) + xFromBc + yFromBc
}

fun ByteArray.parseToPublicKey(): PublicKey =
try {
(if (size < 1024) ecKeyFactory else rsaKeyFactory).generatePublic(X509EncodedKeySpec(this))
CryptoPublicKey.decodeFromDer(this).getJcaPublicKey().getOrThrow()
} catch (e: Throwable) {
if (first() != 0x04.toByte()) throw InvalidKeySpecException("Encoded public key does not start with 0x04")

val parameterSpec = ECNamedCurveTable.getParameterSpec("P-256")
val ecPoint = parameterSpec.curve.createPoint(
BigInteger(1, sliceArray(1..<33)),
BigInteger(1, takeLast(32).toByteArray())
)
val ecPublicKeySpec = org.bouncycastle.jce.spec.ECPublicKeySpec(ecPoint, parameterSpec)
JCEECPublicKey("EC", ecPublicKeySpec)
CryptoPublicKey.fromIosEncoded(this).getJcaPublicKey().getOrThrow()
}

/**
Expand Down
15 changes: 13 additions & 2 deletions warden/src/main/kotlin/Throwables.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import at.asitplus.attestation.AttestationException.Certificate
import at.asitplus.attestation.AttestationException.Content
import at.asitplus.attestation.android.exceptions.AndroidAttestationException
import at.asitplus.attestation.android.exceptions.AttestationValueException
import kotlinx.datetime.Clock
import java.security.PublicKey


/**
Expand Down Expand Up @@ -174,6 +176,15 @@ class IosAttestationException(msg: String? = null, cause: Throwable? = null, val
*/
APP_UNEXPECTED,


}
}
}

internal fun <T : PublicKey> logicalError(
keyToBeAttested: T,
attestationProof: List<ByteArray>,
expectedChallenge: ByteArray
) = RuntimeException("Logical Error attesting key ${
keyToBeAttested.encoded.encodeBase64()
} for attestation proof ${
attestationProof.joinToString { it.encodeBase64() }
} with challenge ${expectedChallenge.encodeBase64()} at ${Clock.System.now()}")
7 changes: 4 additions & 3 deletions warden/src/test/kotlin/KeyConversionTests.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import at.asitplus.attestation.decodeBase64ToArray
import at.asitplus.attestation.parseToPublicKey
import at.asitplus.attestation.toAnsi
import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.fromJcaPublicKey
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import java.security.interfaces.ECPublicKey
Expand All @@ -12,9 +13,9 @@ class KeyConversionTests : FreeSpec({
"it should be parsable" - {
val parsedKey = x509Key.parseToPublicKey()
"and encodable to ANSI X9.62" - {
val ansBytes = (parsedKey as ECPublicKey).toAnsi()
val ansiBytes = CryptoPublicKey.EC.fromJcaPublicKey(parsedKey as ECPublicKey).getOrThrow().iosEncoded
"and decodable" - {
val decoded = ansBytes.parseToPublicKey()
val decoded = ansiBytes.parseToPublicKey()
"to match the original X5095-encoded key" {
decoded.encoded shouldBe x509Key
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,13 @@ class WardenTest : FreeSpec() {
sandbox = true
)
), FixedTimeClock(2023u, 9u, 11u)
).verifyKeyAttestation(
iosIDA.attestationProof, iosIDA.challenge, iosIDA.publicKey!!
).apply {
isSuccess.shouldBeTrue()
verifyKeyAttestation(
iosIDA.attestationProof, iosIDA.challenge, iosIDA.publicKey!!
).apply {
isSuccess.shouldBeTrue()
verifyKeyAttestation( iosIDA.attestationProof, iosIDA.challenge, iosIDA.pubKeyB64!!.decodeBase64ToArray()) shouldBe this
}
}
}

Expand Down Expand Up @@ -157,15 +160,20 @@ class WardenTest : FreeSpec() {
}
}
"Key Attestation" {
verifyKeyAttestation(
(verifyKeyAttestation(
recordedAttestation.attestationProof,
recordedAttestation.challenge,
recordedAttestation.publicKey!!
).apply {
also { println(it) }
isSuccess.shouldBeTrue()
attestedPublicKey.shouldNotBeNull()
attestedPublicKey!!.encoded shouldBe recordedAttestation.publicKey?.encoded
) to verifyKeyAttestation(
recordedAttestation.attestationProof,
recordedAttestation.challenge,
recordedAttestation.pubKeyB64!!.decodeBase64ToArray()
)).apply {
also { println(it.first) }
first.isSuccess.shouldBeTrue()
first.attestedPublicKey.shouldNotBeNull()
first.attestedPublicKey!!.encoded shouldBe recordedAttestation.publicKey?.encoded
first shouldBe second
}
}
}
Expand Down
Loading