Skip to content

Commit

Permalink
rework key attestation key comparison
Browse files Browse the repository at this point in the history
  • Loading branch information
JesusMcCloud committed Aug 8, 2024
1 parent 11027a9 commit 86a888c
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 45 deletions.
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
109 changes: 95 additions & 14 deletions warden/src/main/kotlin/AttestationService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ 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 kotlinx.datetime.Clock
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,13 +258,18 @@ abstract class AttestationService {
attestationProof: List<ByteArray>,
expectedChallenge: ByteArray,
keyToBeAttested: T
): KeyAttestation<T> =
when (val firstTry = verifyAttestation(attestationProof, expectedChallenge, keyToBeAttested.encoded)) {
): KeyAttestation<T> {
when (val firstTry = verifyAttestation(
attestationProof,
expectedChallenge,
keyToBeAttested.encoded
)) {
is AttestationResult.Android -> {
if (keyToBeAttested.encoded contentEquals firstTry.attestationCertificate.publicKey.encoded) KeyAttestation(
keyToBeAttested,
firstTry
return if (CryptoPublicKey.fromJcaPublicKey(keyToBeAttested) == CryptoPublicKey.fromJcaPublicKey(
firstTry.attestationCertificate.publicKey
)
)
KeyAttestation(keyToBeAttested, firstTry)
else {
("Android attestation failed: keyToBeAttested (${keyToBeAttested.encoded.encodeBase64()}) does not match " +
"key from attestation certificate: ${firstTry.attestationCertificate.publicKey.encoded.encodeBase64()}").let {
Expand All @@ -284,17 +291,38 @@ abstract class AttestationService {
}
}

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.Error -> {
//try different encodings
val publicKeyEncodings = CryptoPublicKey.fromJcaPublicKey(keyToBeAttested).getOrThrow().let {
listOf(
it.iosEncoded,
(it as CryptoPublicKey.EC).copy(
it.publicPoint,
preferCompressedRepresentation = !it.preferCompressedRepresentation
).iosEncoded,
it.encodeToDer()
)
}

// not the most efficient way, but doing it like this won't involve any guesswork at all
publicKeyEncodings.forEach {
when (val secondTry =
kotlin.runCatching { verifyAttestation(attestationProof, expectedChallenge, it) }
.getOrElse { return KeyAttestation(null, firstTry) }) {
is AttestationResult.Android -> throw RuntimeException("Logical Error attesting key ${keyToBeAttested.encoded.encodeBase64()} for attestation proof ${attestationProof.joinToString { it.encodeBase64() }} with challenge ${expectedChallenge.encodeBase64()} at ${Clock.System.now()}")
is AttestationResult.Error -> {/*try again*/
}
//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 -> KeyAttestation(keyToBeAttested, firstTry)
is AttestationResult.IOS -> return KeyAttestation(keyToBeAttested, firstTry)
}
}

/** 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 +463,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 +510,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 +562,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
20 changes: 4 additions & 16 deletions warden/src/main/kotlin/Extensions.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package at.asitplus.attestation

import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.getJcaPublicKey
import kotlinx.datetime.Clock
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toKotlinInstant
Expand Down Expand Up @@ -58,25 +60,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
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

0 comments on commit 86a888c

Please sign in to comment.