Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a1a3178
add Android attestation + key import classes
JesusMcCloud Feb 10, 2025
1a0d6ef
refactor
JesusMcCloud Feb 11, 2025
9599908
parsing fixes
JesusMcCloud Mar 26, 2025
3c5acf1
add attestation data for tests
JesusMcCloud Mar 26, 2025
9f7c88e
add warden-roboto for compatiblity tests
JesusMcCloud Mar 27, 2025
1f0192a
fix android instrumented tests
JesusMcCloud Mar 27, 2025
aa247af
compare more attestation properties
JesusMcCloud Mar 27, 2025
d02a515
more comparisons
JesusMcCloud Mar 27, 2025
52547ce
add sefSigned, relocked bootloader attestation statement
JesusMcCloud Mar 31, 2025
214623a
Claude-generated comparison of authorizationList
JesusMcCloud Mar 31, 2025
13065f0
add toString to AttestationData
thecyberfred Apr 2, 2025
08cb83a
more uniform code in BasicParsingTests + AttestationData
thecyberfred Apr 11, 2025
902ebd4
add Android attestation + key import classes
JesusMcCloud Feb 10, 2025
54e6cc5
parsing fixes
JesusMcCloud Mar 26, 2025
7e679c4
add attestation data for tests
JesusMcCloud Mar 26, 2025
275fcc0
add warden-roboto for compatiblity tests
JesusMcCloud Mar 27, 2025
be5ebdf
fix android instrumented tests
JesusMcCloud Mar 27, 2025
62e87b5
compare more attestation properties
JesusMcCloud Mar 27, 2025
736336b
more comparisons
JesusMcCloud Mar 27, 2025
5ae30d7
add sefSigned, relocked bootloader attestation statement
JesusMcCloud Mar 31, 2025
86065eb
Claude-generated comparison of authorizationList
JesusMcCloud Mar 31, 2025
daece1b
update kotest
JesusMcCloud Mar 31, 2025
734bc87
add toString to AttestationData
thecyberfred Apr 2, 2025
731f2ae
Add attribute "allApplications" (available until version 4)
thecyberfred May 13, 2025
e98ed2b
AttestationExtension alias for for backwards compatibility for attest…
thecyberfred May 13, 2025
0967096
refactor AuthorizationList: Enums and old attributes and old attributes
thecyberfred May 15, 2025
f08a80b
some linebreaks removed
thecyberfred May 15, 2025
0d8f531
add linebreaks
thecyberfred May 15, 2025
aaf5751
format
thecyberfred May 15, 2025
6d417ed
refactor AuthorizationList
thecyberfred May 16, 2025
fc452ef
refactoring continued
thecyberfred May 16, 2025
11cedff
refactor authorizationlist
thecyberfred May 19, 2025
22cab53
refactor: KmmResult wrapper - in progress
thecyberfred May 20, 2025
46ba49c
refactor BasicParsingTests
thecyberfred May 21, 2025
859b745
refactor AuthorizationList
thecyberfred May 21, 2025
8ada1b9
refactor
thecyberfred May 21, 2025
7735541
cleanup merge remnants
JesusMcCloud Aug 4, 2025
ec74fcf
Backport cleaner attestation error handling (#300)
JesusMcCloud Aug 4, 2025
c8dd591
attestation version now added to auth list
thecyberfred Aug 26, 2025
cb6ab28
google test added
thecyberfred Aug 27, 2025
37219e0
tests for json files from https://github.com/android/keyattestation/t…
thecyberfred Sep 1, 2025
b0c1361
AndroidKeyAttestationTests polished
thecyberfred Sep 2, 2025
3a378f2
fix tests
JesusMcCloud Sep 4, 2025
6c18875
move attestation tests to warden
thecyberfred Sep 10, 2025
bae97fb
towards SignumAttestationEngine
thecyberfred Sep 16, 2025
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
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ plugins {
id("io.kotest") version kotestVer
kotlin("multiplatform") version kotlinVer apply false
kotlin("plugin.serialization") version kotlinVer apply false
id("com.android.library") version libs.versions.agp.get() apply (false)
id("com.android.library") version libs.versions.agp.get() apply false
id("com.google.devtools.ksp") version kspVer
}
group = "at.asitplus.signum"
Expand Down
4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
kotlin = "2.2.0"
ksp = "2.0.2"
agp = "8.10.0"
kotest= "6.0.0.M6"
core = "1.5.0"
kotlinxIoCore = "0.7.0"
multibase = "1.2.2"
Expand All @@ -12,9 +13,10 @@ jose = "9.31"
kotlinpoet = "2.2.0"
runner = "1.5.2"
kotlincryptoRandom = "0.5.0"
kotest= "6.0.0.M6"
warden = "1.7.2"

[libraries]
warden = { group = "at.asitplus", name = "warden-roboto", version.ref = "warden"}
bignum = { group = "com.ionspin.kotlin", name = "bignum", version.ref = "bignum" }
core = { module = "androidx.test:core", version.ref = "core" }
kotest-extensions-android = { module = "br.com.colman:kotest-extensions-android", version.ref = "kotestExtensionsAndroid" }
Expand Down
1 change: 1 addition & 0 deletions indispensable-asn1/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ kotlin {
api(kmmresult())
api(serialization("json"))
api(datetime())
api(libs.bignum)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ sealed class Asn1Element(

}

/**
* performs a deep copy of this element, including all its children
*/
abstract fun copy(): Asn1Element

companion object {
/**
* Convenience method to directly parse a HEX-string representation of DER-encoded data.
Expand Down Expand Up @@ -819,6 +824,8 @@ internal constructor(tag: ULong, children: List<Asn1Element>) :
*/
fun verifyTagOrNull(explicitTag: Tag) = catchingUnwrapped { verifyTag(explicitTag) }.getOrNull()

override fun copy(): Asn1ExplicitlyTagged = Asn1ExplicitlyTagged(tag.tagValue, children.map { it.copy() })

override fun prettyPrintHeader(indent: Int) = (" " * indent) + "Tagged" + super.prettyPrintHeader(indent)
}

Expand All @@ -829,6 +836,8 @@ internal constructor(tag: ULong, children: List<Asn1Element>) :
class Asn1Sequence internal constructor(children: List<Asn1Element>) :
Asn1Structure(Tag.SEQUENCE, children, sortChildren = false, shouldBeSorted = false) {

override fun copy(): Asn1Sequence = Asn1Sequence(children.map { it.copy() })

init {
if (!tag.isConstructed) throw IllegalArgumentException("An ASN.1 Structure must have a CONSTRUCTED tag")

Expand Down Expand Up @@ -890,6 +899,9 @@ class Asn1CustomStructure private constructor(
else null
}

override fun copy(): Asn1CustomStructure =
Asn1CustomStructure(tag, children.map { it.copy() }, sortChildren = false, shouldBeSorted)

override fun prettyPrintHeader(indent: Int) =
(" " * indent) + tag.tagClass +
" ${tag.tagValue}" +
Expand Down Expand Up @@ -940,6 +952,8 @@ class Asn1EncapsulatingOctetString(children: List<Asn1Element>) :
return super.equals(other)
}

override fun copy(): Asn1EncapsulatingOctetString = Asn1EncapsulatingOctetString(children.map { it.copy() })

override fun hashCode(): Int = content.contentHashCode()

override fun prettyPrintHeader(indent: Int) =
Expand All @@ -962,6 +976,8 @@ class Asn1PrimitiveOctetString(content: ByteArray) : Asn1Primitive(Tag.OCTET_STR
return super.equals(other)
}

override fun copy(): Asn1PrimitiveOctetString = Asn1PrimitiveOctetString(content.copyOf())

override fun hashCode(): Int = content.contentHashCode()

override fun prettyPrintHeader(indent: Int) = (" " * indent) + "OCTET STRING " + super.prettyPrintHeader(0)
Expand All @@ -979,6 +995,8 @@ open class Asn1Set private constructor(children: List<Asn1Element>, dontSort: Bo
*/
internal constructor(children: List<Asn1Element>) : this(children, false)

override fun copy(): Asn1Set = Asn1Set(children.map { it.copy() }, dontSort = true)

init {
if (!tag.isConstructed) throw IllegalArgumentException("An ASN.1 Structure must have a CONSTRUCTED tag")
}
Expand All @@ -991,7 +1009,7 @@ open class Asn1Set private constructor(children: List<Asn1Element>, dontSort: Bo
* Explicitly discard DER requirements and DON'T sort children. Useful when parsing Structures which might not
* conform to DER
*/
internal fun fromPresorted(children: List<Asn1Element>) = Asn1Set(children, true)
fun fromPresorted(children: List<Asn1Element>) = Asn1Set(children, true)
}
}

Expand Down Expand Up @@ -1073,6 +1091,8 @@ open class Asn1Primitive(

return true
}

override fun copy(): Asn1Primitive = Asn1Primitive(tag.tagValue, content.copyOf())
}


Expand All @@ -1095,6 +1115,11 @@ sealed interface Asn1OctetString {
*/
val content: ByteArray

/**
* Creates a deep copy fo this octet string.
*/
fun copy(): Asn1OctetString

companion object {
/** Constructs a new ASN.1 OCTET STRING primitive containing these bytes */
operator fun invoke(bytes: ByteArray) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.experimental.or
import kotlin.jvm.JvmInline
import com.ionspin.kotlin.bignum.integer.BigInteger

private val REGEX_BASE10 = Regex("[0-9]+")
private val REGEX_ZERO = Regex("0*")
Expand Down Expand Up @@ -50,6 +51,8 @@ sealed class Asn1Integer(internal val uint: VarUInt, val sign: Sign): Asn1Encoda
Sign.NEGATIVE -> "-${uint}"
}

fun toBigInteger() = BigInteger.parseString(this.toString())

/** Encodes the [Asn1Integer] to its minimum-size twos-complement encoding. Non-empty. */
abstract fun twosComplement(): ByteArray

Expand Down
7 changes: 7 additions & 0 deletions indispensable/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ kotlin {
api(coroutines("jvm"))
}
}

jvmTest.dependencies {
implementation(libs.warden)
implementation(ktor("client-cio"))
implementation(ktor("client-content-negotiation"))
implementation(ktor("serialization-kotlinx-json"))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package at.asitplus.signum.indispensable.pki.attestation

import at.asitplus.catchingUnwrapped
import at.asitplus.signum.indispensable.asn1.*
import at.asitplus.signum.indispensable.asn1.encoding.Asn1
import at.asitplus.signum.indispensable.asn1.encoding.decodeToEnum
import at.asitplus.signum.indispensable.asn1.encoding.decodeToInt
import at.asitplus.signum.indispensable.asn1.encoding.encodeToAsn1ContentBytes
import at.asitplus.signum.indispensable.pki.X509Certificate

/**
* Attestation certificate extension [used by Google](https://source.android.com/docs/security/features/keystore/attestation#schema).
* While we could use sophisticated sanity checks to ensure
* that only valid extensions that conform to the schema in every aspect,
* the reality is ugly, with device manufacturers being very _creative_ about
* how and what will be encoded into [softwareEnforced] and [hardwareEnforced].
* Hence, we must be able to parse extensions that are structurally valid
* at first glance, even when the actual values inside look like they have been through a meat grinder.
* As long as those values we check for during attestation validation are there and contain the values
* required for a successful assessment, we're golden!
* Hence, barely any sanity checks are enforced.
*/
class AttestationKeyDescription(
val attestationVersion: Int,
val attestationSecurityLevel: SecurityLevel,
val keyMintVersion: Int,
val keyMintSecurityLevel: SecurityLevel,
val attestationChallenge: ByteArray,
val uniqueId: ByteArray,
val softwareEnforced: AuthorizationList,
val hardwareEnforced: AuthorizationList
) : Asn1Encodable<Asn1Sequence>, Identifiable {

/**
alias for [keyMintVersion] for backwards compatibility for attestationVersion<=4
*/
val keymasterVersion: Int get() = keyMintVersion

/**
alias for [keyMintSecurityLevel] for backwards compatibility for attestationVersion<=4
*/
val keymasterSecurityLevel: SecurityLevel get() = keyMintSecurityLevel

init {
versionCheck()
}

fun versionCheck() {
if(attestationVersion < 100)
{
// keyMintVersion was previously named keyMintVersion
// keyMintSecurityLevel was previously named keyMintSecurityLevel
// TODO: only provide getter in right versions?
}
if (attestationVersion < 3) {
require(attestationSecurityLevel != SecurityLevel.STRONGBOX)
require(keyMintSecurityLevel != SecurityLevel.STRONGBOX)
}
}

override fun encodeToTlv() = Asn1.Sequence {
+Asn1.Int(attestationVersion)
+attestationSecurityLevel
+Asn1.Int(keyMintVersion)
+keyMintSecurityLevel
+Asn1.OctetString(attestationChallenge)
+Asn1.OctetString(uniqueId)
+softwareEnforced
+hardwareEnforced
}

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

if (attestationVersion != other.attestationVersion) return false
if (attestationSecurityLevel != other.attestationSecurityLevel) return false
if (keyMintVersion != other.keyMintVersion) return false
if (keyMintSecurityLevel != other.keyMintSecurityLevel) return false
if (!attestationChallenge.contentEquals(other.attestationChallenge)) return false
if (!uniqueId.contentEquals(other.uniqueId)) return false
if (softwareEnforced != other.softwareEnforced) return false
if (hardwareEnforced != other.hardwareEnforced) return false

return true
}

override fun hashCode(): Int {
var result = attestationVersion
result = 31 * result + attestationSecurityLevel.hashCode()
result = 31 * result + keyMintVersion
result = 31 * result + keyMintSecurityLevel.hashCode()
result = 31 * result + attestationChallenge.contentHashCode()
result = 31 * result + uniqueId.contentHashCode()
result = 31 * result + softwareEnforced.hashCode()
result = 31 * result + hardwareEnforced.hashCode()
return result
}

@OptIn(ExperimentalStdlibApi::class)
override fun toString(): String {
return "AttestationKeyDescription(attestationVersion=$attestationVersion, attestationSecurityLevel=$attestationSecurityLevel, keyMintVersion=$keyMintVersion" +
", keyMintSecurityLevel=$keyMintSecurityLevel, attestationChallenge=${attestationChallenge.toHexString()}, uniqueId=${uniqueId.toHexString()}, softwareEnforced=$softwareEnforced, hardwareEnforced=$hardwareEnforced)"
}

override val oid: ObjectIdentifier get() = AttestationKeyDescription.oid

companion object : Identifiable, Asn1Decodable<Asn1Sequence, AttestationKeyDescription> {
override val oid = ObjectIdentifier("1.3.6.1.4.1.11129.2.1.17")
override fun doDecode(src: Asn1Sequence): AttestationKeyDescription = src.iterator().run {
val version = next().asPrimitive().decodeToInt()
val attestationSecurityLevel =
SecurityLevel.decodeFromTlv(next().asPrimitive())
val keyMintVersion = next().asPrimitive().decodeToInt()
val keyMintSecurityLevel = SecurityLevel.decodeFromTlv(next().asPrimitive())
val attestationChallenge = next().asOctetString().content
val uniqueId = next().asOctetString().content
val softwareEnforced = AuthorizationList.decodeFromTlv(next().asSequence()).copy(attestationVersion=version)
val hardwareEnforced = AuthorizationList.decodeFromTlv(next().asSequence()).copy(attestationVersion=version)
//if there's more, we don't are not allowed to care
return AttestationKeyDescription(
version,
attestationSecurityLevel,
keyMintVersion,
keyMintSecurityLevel,
attestationChallenge,
uniqueId,
softwareEnforced,
hardwareEnforced
)
}
}

/**
* Attestation security level [as defined by Google](https://source.android.com/docs/security/features/keystore/attestation#schema).
*/
enum class SecurityLevel(val intValue: Int) : Asn1Encodable<Asn1Primitive> {
SOFTWARE(0),
TRUSTED_ENVIRONMENT(1),
STRONGBOX(2);

override fun encodeToTlv() =
Asn1Primitive(BERTags.ENUMERATED, intValue.encodeToAsn1ContentBytes())

companion object : Asn1Decodable<Asn1Primitive, SecurityLevel> {
/**
* returns the [SecurityLevel] represented by [intValue]
*/
fun valueOf(intValue: Int) = entries.first { it.intValue == intValue }
override fun doDecode(src: Asn1Primitive) = src.decodeToEnum<SecurityLevel>()
}
}
}

/**
* Tries to parse an [AttestationKeyDescription] certificate extension, if present.
* Never throws.
*/
val X509Certificate.androidAttestationExtension: AttestationKeyDescription?
get() = tbsCertificate.extensions?.firstOrNull { it.oid == AttestationKeyDescription.oid }
?.let {
catchingUnwrapped {
val children = it.value.asEncapsulatingOctetString().children
require(children.size == 1)
AttestationKeyDescription.decodeFromTlv(children.first().asSequence())
}.getOrNull()
}
Loading
Loading