Skip to content

Commit

Permalink
strict temporal validation
Browse files Browse the repository at this point in the history
  • Loading branch information
JesusMcCloud committed Oct 31, 2024
1 parent 75b5811 commit 297a125
Show file tree
Hide file tree
Showing 9 changed files with 202 additions and 42 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## 2.3.0 Behavioural Changes!
- Update to WARDEN-roboto 1.7.0
- Android attestation statements (for SW, HW, but not Hybrid Nougat Attestation) do now verify attestation creation time!
- Refer to the [WARDEN-roboto changelog](https://github.com/a-sit-plus/warden-roboto/blob/main/CHANGELOG.md#170)!
- Change Android verification offset calculation:
It is now the sum of the toplevel offset and the Android-specific offset
- Change the reason for iOS attestation statement temporal invalidity:
- It is now `AttestationException.Content.iOS(cause = IosAttestationException(…, reason = IosAttestationException.Reason.STATEMENT_TIME))`
- **This reason was newly introduced in this release, making it binary and source incompatible!**
- iOS attestations are now also rejected if their validity starts in the future
- The validity time can now be configured in the same way as for Android, using the `attestationStatementValiditySeconds` property
- Any configured `verificationTimeOffset` is **NOT** automatically compensated for any more. **This means if you have previously used a five minutes offset, you now have to manually increase the `attestationStatementValiditySeconds` to `10 * 60`!**

## 2.2.0
- Introduce new attestation format

Expand Down
2 changes: 1 addition & 1 deletion warden-roboto
13 changes: 11 additions & 2 deletions warden/src/main/kotlin/AttestationService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,20 @@ data class IOSAttestationConfiguration @JvmOverloads constructor(
*/
val iosVersion: OsVersions? = null,

) {
/**
* The maximum age an attestation statement is considered valid.
*/
val attestationStatementValiditySeconds: Int = 5 * 60

) {


@JvmOverloads
constructor(singleApp: AppData, iosVersion: OsVersions? = null) : this(listOf(singleApp), iosVersion)
constructor(
singleApp: AppData,
iosVersion: OsVersions? = null,
attestationStatementValiditySeconds: Int = 5 * 60
) : this(listOf(singleApp), iosVersion, attestationStatementValiditySeconds)

init {
if (applications.isEmpty())
Expand Down
5 changes: 5 additions & 0 deletions warden/src/main/kotlin/Throwables.kt
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ class IosAttestationException(msg: String? = null, cause: Throwable? = null, val
*/
OS_VERSION,

/**
* Attestation statement creation time in the future
*/
STATEMENT_TIME,

/**
* Signature counter in the assertion is too high. This could mean either an implementation error on the client, or a compromised client app.
*/
Expand Down
75 changes: 61 additions & 14 deletions warden/src/main/kotlin/Warden.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package at.asitplus.attestation
import at.asitplus.attestation.android.*
import at.asitplus.attestation.android.exceptions.AttestationValueException
import at.asitplus.attestation.android.exceptions.CertificateInvalidException
import at.asitplus.signum.indispensable.*
import at.asitplus.signum.indispensable.AndroidKeystoreAttestation
import at.asitplus.signum.indispensable.Attestation
import at.asitplus.signum.indispensable.IosHomebrewAttestation
import at.asitplus.signum.indispensable.getJcaPublicKey
import ch.veehait.devicecheck.appattest.AppleAppAttest
import ch.veehait.devicecheck.appattest.assertion.Assertion
import ch.veehait.devicecheck.appattest.assertion.AssertionChallengeValidator
Expand Down Expand Up @@ -31,6 +34,7 @@ import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
import kotlin.time.toKotlinDuration

Expand Down Expand Up @@ -79,17 +83,45 @@ class Warden(
private val log = LoggerFactory.getLogger(this.javaClass)

private val androidAttestationCheckers = mutableListOf<AndroidAttestationChecker>().apply {
if (!androidAttestationConfiguration.disableHardwareAttestation) add(

if (verificationTimeOffset.inWholeSeconds > Int.MAX_VALUE) throw AttestationException.Configuration(
Platform.ANDROID,
"Offset too large!",
cause = NumberFormatException()
)
if (verificationTimeOffset.inWholeSeconds < Int.MIN_VALUE) throw AttestationException.Configuration(
Platform.ANDROID,
"Offset too large!",
cause = NumberFormatException()
)

val androidOffset =
(verificationTimeOffset + androidAttestationConfiguration.verificationSecondsOffset.seconds).inWholeSeconds
if (androidOffset > Int.MAX_VALUE) throw AttestationException.Configuration(
Platform.ANDROID,
"Calculated Android offset too large!",
cause = NumberFormatException()
)
if (androidOffset < Int.MIN_VALUE) throw AttestationException.Configuration(
Platform.ANDROID,
"Calculated Android offset too large!",
cause = NumberFormatException()
)

val correctlyOffsetAndroidConfig =
androidAttestationConfiguration.copy(verificationSecondsOffset = androidOffset.toInt())

if (!correctlyOffsetAndroidConfig.disableHardwareAttestation) add(
HardwareAttestationChecker(
androidAttestationConfiguration
correctlyOffsetAndroidConfig
) { expected, actual -> expected contentEquals actual })
if (androidAttestationConfiguration.enableNougatAttestation) add(
if (correctlyOffsetAndroidConfig.enableNougatAttestation) add(
NougatHybridAttestationChecker(
androidAttestationConfiguration
correctlyOffsetAndroidConfig
) { expected, actual -> expected contentEquals actual })
if (androidAttestationConfiguration.enableSoftwareAttestation) add(
if (correctlyOffsetAndroidConfig.enableSoftwareAttestation) add(
SoftwareAttestationChecker(
androidAttestationConfiguration
correctlyOffsetAndroidConfig
) { expected, actual -> expected contentEquals actual })
}

Expand Down Expand Up @@ -117,8 +149,7 @@ class Warden(
clock = appAttestClock,
receiptValidator = app.createReceiptValidator(
clock = appAttestClock,
maxAge = (verificationTimeOffset.absoluteValue * 2).toJavaDuration()
.plus(ReceiptValidator.APPLE_RECOMMENDED_MAX_AGE)
maxAge = iosAttestationConfiguration.attestationStatementValiditySeconds.seconds.toJavaDuration()
)
)
}
Expand Down Expand Up @@ -215,8 +246,12 @@ class Warden(
is AttestationResult.IOS -> KeyAttestation(
attestationProof.parsedClientData.publicKey.getJcaPublicKey().getOrThrow(), it
)

is AttestationResult.Error -> KeyAttestation(null, it)
is AttestationResult.Android -> KeyAttestation(null, AttestationResult.Error("This must never happen!"))
is AttestationResult.Android -> KeyAttestation(
null,
AttestationResult.Error("This must never happen!")
)
}
}
}
Expand All @@ -229,6 +264,7 @@ class Warden(
is AttestationResult.Android -> KeyAttestation(
attestationProof.certificateChain.first().publicKey.getJcaPublicKey().getOrThrow(), it
)

is AttestationResult.Error -> KeyAttestation(null, it)
is AttestationResult.IOS -> KeyAttestation(null, AttestationResult.Error("This must never happen!"))
}
Expand Down Expand Up @@ -267,7 +303,7 @@ class Warden(
runCatching {
it.verifyAttestation(
certificates,
(clock.now() + verificationTimeOffset).toJavaDate(),
(clock.now()).toJavaDate(),
expectedChallenge
)
}
Expand Down Expand Up @@ -366,6 +402,14 @@ class Warden(
results.first { (_, result) -> result.isSuccess }.let { (app, res) -> app to res.getOrNull()!! }


val notBefore =
result.second.receipt.payload.notBefore?.value ?: result.second.receipt.payload.creationTime.value
if (notBefore > appAttestClock.instant())
throw AttestationException.Content.iOS(
message = "Attestation statement created after ${appAttestClock.instant()}: $notBefore",
cause = IosAttestationException(reason = IosAttestationException.Reason.OS_VERSION)
).also { it.printStackTrace() }

val iosVersion =
iosApps.entries.firstOrNull { (_, appAttest) -> appAttest.app == result.first.app }?.key?.iosVersionOverride
?: iosAttestationConfiguration.iosVersion
Expand Down Expand Up @@ -527,9 +571,12 @@ class Warden(
ex = ex.cause
}
if (ex.message?.startsWith("Receipt's creation time is after") == true)
AttestationException.Certificate.Time.iOS(
cause = ex
)
AttestationException.Content.iOS(
cause = IosAttestationException(
cause = ex,
reason = IosAttestationException.Reason.STATEMENT_TIME
),
).also { it.printStackTrace() }
else AttestationException.Content.iOS(
cause = IosAttestationException(
cause = it,
Expand Down
15 changes: 10 additions & 5 deletions warden/src/test/kotlin/FeatureDemonstration.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ class FeatureDemonstration : FreeSpec() {
requireStrongBox = false, //optional
allowBootloaderUnlock = false, //you don't usually want to change this
requireRollbackResistance = false, //depends on device, so leave off
ignoreLeafValidity = false //Hello, Samsung!
ignoreLeafValidity = false, //Hello, Samsung!
verificationSecondsOffset = 15 * 60 - 1 * 60 * 60 + 24 * 60 * 60, //iOS and Android statements were created at different times
attestationStatementValiditySeconds = 10*60 //But we were not that exact in the line above
),
iosAttestationConfiguration = IOSAttestationConfiguration(
applications = listOf(
Expand All @@ -45,7 +47,7 @@ class FeatureDemonstration : FreeSpec() {
buildNumber = "0A0"
) //optional, use SemVer notation and large hex number to ignore build number
),
clock = FixedTimeClock(Instant.parse("2023-04-13T00:00:00Z")), //optional
clock = FixedTimeClock(Instant.parse("2023-04-13T14:03:00Z")), //optional
verificationTimeOffset = Duration.ZERO //optional
)

Expand All @@ -57,7 +59,8 @@ class FeatureDemonstration : FreeSpec() {
.apply {
shouldBeInstanceOf<AttestationResult.Android>().apply {
attestationCertificate.encoded shouldBe nokiaX10KeyMasterGood.attestationProof.first()
attestationRecord.attestationChallenge().toByteArray() shouldBe nokiaX10KeyMasterGood.challenge
attestationRecord.attestationChallenge()
.toByteArray() shouldBe nokiaX10KeyMasterGood.challenge
attestationRecord.attestationSecurityLevel() shouldBeIn listOf(
ParsedAttestationRecord.SecurityLevel.TRUSTED_ENVIRONMENT,
ParsedAttestationRecord.SecurityLevel.STRONG_BOX
Expand All @@ -80,7 +83,8 @@ class FeatureDemonstration : FreeSpec() {
attestedPublicKey!!.encoded shouldBe nokiaX10KeyMasterGood.publicKey!!.encoded
details.shouldBeInstanceOf<AttestationResult.Android>().apply {
attestationCertificate.encoded shouldBe nokiaX10KeyMasterGood.attestationProof.first()
attestationRecord.attestationChallenge().toByteArray() shouldBe nokiaX10KeyMasterGood.challenge
attestationRecord.attestationChallenge()
.toByteArray() shouldBe nokiaX10KeyMasterGood.challenge
attestationRecord.attestationSecurityLevel() shouldBeIn listOf(
ParsedAttestationRecord.SecurityLevel.TRUSTED_ENVIRONMENT,
ParsedAttestationRecord.SecurityLevel.STRONG_BOX
Expand All @@ -100,7 +104,8 @@ class FeatureDemonstration : FreeSpec() {
attestedPublicKey!!.encoded shouldBe nokiaX10KeyMasterGood.publicKey!!.encoded
details.shouldBeInstanceOf<AttestationResult.Android>().apply {
attestationCertificate.encoded shouldBe nokiaX10KeyMasterGood.attestationProof.first()
attestationRecord.attestationChallenge().toByteArray() shouldBe nokiaX10KeyMasterGood.challenge
attestationRecord.attestationChallenge()
.toByteArray() shouldBe nokiaX10KeyMasterGood.challenge
attestationRecord.attestationSecurityLevel() shouldBeIn listOf(
ParsedAttestationRecord.SecurityLevel.TRUSTED_ENVIRONMENT,
ParsedAttestationRecord.SecurityLevel.STRONG_BOX
Expand Down
11 changes: 4 additions & 7 deletions warden/src/test/kotlin/TemporalOffsetTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import io.kotest.core.spec.style.FreeSpec
import io.kotest.datatest.withData
import io.kotest.matchers.types.shouldBeInstanceOf
import io.kotest.matchers.types.shouldNotBeInstanceOf
import java.security.interfaces.ECPublicKey
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds

class TemporalOffsetTest : FreeSpec() {

Expand All @@ -33,6 +32,8 @@ class TemporalOffsetTest : FreeSpec() {
attestationService(
timeSource = FixedTimeClock(it.verificationDate.time),
offset = 1.days,
androidAttestationStatementValidity = 1.days + 1.seconds,
iosAttestationStatementValidity = 1.days + 1.seconds,
).verifyAttestation(
it.attestationProof,
it.challenge,
Expand All @@ -43,7 +44,7 @@ class TemporalOffsetTest : FreeSpec() {
}

"Exact Time of Validity - 1D" - {
withData(exactStartOfValidity) {
withData(mapOf("KeyMint 200" to pixel6KeyMint200Good)) {
attestationService(
timeSource = FixedTimeClock(it.verificationDate.time),
offset = (-1).days,
Expand Down Expand Up @@ -71,12 +72,8 @@ class TemporalOffsetTest : FreeSpec() {
).apply {
shouldBeInstanceOf<AttestationResult.Error>()
.cause.shouldBeInstanceOf<AttestationException.Certificate.Time>()

}
}

}
}
}


Loading

0 comments on commit 297a125

Please sign in to comment.