Skip to content

Commit

Permalink
1.1.0: refactor API
Browse files Browse the repository at this point in the history
  • Loading branch information
JesusMcCloud committed Sep 12, 2023
1 parent 4ef1723 commit f6d3d99
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 66 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,7 @@ attestation on Android, please re-read the readme!
- Kotlin 1.9.10!
- Bouncy Castle 1.76
- Android-Attestation 1.0.0

### 1.1.0
- remove `verifyAttestation`
- introduce `verifyKeyAttestation` taking an encoded public key as a byte array
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ no real use case for such a configuration-
1. The general workflow this library caters to assumes a back-end service, sending an attestation challenge to the mobile app. This challenge needs to be kept for future reference
2. The app is assumed to generate a key pair with attestation (passing the received challenge the platforms' respective crypto APIs)
3. The app responds with a platform-dependent attestation proof and the public key just created.
4. On the back-end, a single call to `service.verifyKeyAttestation()` (or `service.verifyAttestation()`) is sufficient to remotely verify
4. On the back-end, a single call to `service.verifyKeyAttestation()` is sufficient to remotely verify
whether the key is indeed stored in HW (and whether the app can be trusted). This call requires the challenge from step 1.

Various flavours of this attestation call from step 4 exist, some of which are platform-dependent
Expand Down
2 changes: 1 addition & 1 deletion attestation-service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ plugins {
}

group = "at.asitplus"
version = "1.0.0"
version = "1.1.0"

sourceSets.test {
kotlin {
Expand Down
72 changes: 31 additions & 41 deletions attestation-service/src/main/kotlin/AttestationService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,47 +87,21 @@ data class IOSAttestationConfiguration @JvmOverloads constructor(

}

interface AttestationService {
abstract class AttestationService {

/**
* Convenience method to verifies both Android Key Attestation or Apple App Attestation
* structures of the client (in [attestationProof]) if the device can be verified and [challenge] matches
* the attestation challenge. On Android, this is simply the certificate chain from the attestation certificate
* (i.e. the certificate corresponding to the key to be attested) up to one of the [Google hardware attestation root
certificates](https://developer.android.com/training/articles/security-key-attestation#root_certificate).
* on iOS this contains at least the [AppAttest attestation statement](https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server#3576643).
* Calls [verifyAttestationAndroid] or [verifyAttestationApple] depending on the kind of attestation proof prpvoded.
*
* For iOS clients it is optionally possible to pass [clientData]. In this case [attestationProof] must also
* contain an [assertion](https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server#3576644)
* at index `1`, which, by default, is verified to match [clientData].
* The signature counter in the attestation must be `0` and the signature counter in the assertion must be `1`.
*
* Passing a public key created in the same app on the iDevice's secure hardware as [clientData] effectively
* emulates Android's key attestation: Attesting such a secondary key through an assertion, proves that
* it was also created within the same app, on the same device, resulting in an attested key, which can then be used
* for general-purpose crypto. **BEWARE if you pass the public key on iOS to be signed as is. iOS uses the ANSI X9.63
* format represent public keys, so conversion is needed**
*
* @see verifyAttestationApple
* @see verifyAttestationAndroid
*
* @return [AttestationResult] indicating whether Android or iOS was successfully attested,
* [AttestationResult.Error] in case attestation failed
*/
fun verifyAttestation(

internal abstract fun verifyAttestation(
attestationProof: List<ByteArray>,
challenge: ByteArray,
clientData: ByteArray? = null
): AttestationResult


/**
* Verifies key attestation for both Android and Apple devices.
*
* Verifies key attestation for both Android and Apple devices. *
*
* Succeeds if attestation data structures of the client (in [attestationProof]) can be verified and [expectedChallenge] matches
* the attestation challenge. For Android clients, this function Makes sure that [keyToBeAttested] matches the key contained in the attestation certificate.
* the attestation challenge. For Android clients, this function makes sure that [keyToBeAttested] matches the key contained in the attestation certificate.
* For iOS this key needs to be specified explicitly anyhow to emulate key attestation
*
* @param attestationProof On Android, this is simply the certificate chain from the attestation certificate
Expand Down Expand Up @@ -185,13 +159,22 @@ interface AttestationService {
is AttestationResult.IOS -> KeyAttestation(keyToBeAttested, firstTry)
}

/** Same as [verifyKeyAttestation], but taking an encoded (either ANSI X9.63 or DER) publix key as a byte array
* @see verifyKeyAttestation
*/
fun verifyKeyAttestation(
attestationProof: List<ByteArray>,
challenge: ByteArray,
encodedPublicKey: ByteArray
): KeyAttestation<PublicKey> =
verifyKeyAttestation(attestationProof, challenge, encodedPublicKey.parseToPublicKey())

/**
* Groups ios-specific API to reduce toplevel clutter.
*
* Exposes iOS-specific functionality in a more expressive, and less confusing manner
*/
val ios: IOS
abstract val ios: IOS

interface IOS {
/**
Expand Down Expand Up @@ -225,7 +208,7 @@ interface AttestationService {
): AttestationResult
}

val android: Android
abstract val android: Android

interface Android {
/**
Expand Down Expand Up @@ -300,7 +283,14 @@ sealed class AttestationResult {
"Verified(keyMaster security level: ${attestationRecord.keymasterSecurityLevel.name}, " +
"attestation security level: ${attestationRecord.attestationSecurityLevel.name}, " +
"${attestationRecord.attestedKey.algorithm} public key: ${attestationRecord.attestedKey.encoded.encodeBase64()}" + attestationRecord.softwareEnforced.attestationApplicationId.getOrNull()
?.let { app -> ", packageInfos: ${app.packageInfos.joinToString(prefix = "[", postfix = "]") { info -> "${info.packageName}:${info.version}" }}" }
?.let { app ->
", packageInfos: ${
app.packageInfos.joinToString(
prefix = "[",
postfix = "]"
) { info -> "${info.packageName}:${info.version}" }
}"
}
}
}

Expand Down Expand Up @@ -370,7 +360,7 @@ data class KeyAttestation<T : PublicKey> internal constructor(
* Do not use in production!
*/

object NoopAttestationService : AttestationService {
object NoopAttestationService : AttestationService() {

private val log = LoggerFactory.getLogger(this.javaClass)
override fun verifyAttestation(
Expand All @@ -381,8 +371,8 @@ object NoopAttestationService : AttestationService {
if (attestationProof.size > 2) AttestationResult.Android.NOOP(attestationProof)
else AttestationResult.IOS.NOOP(clientData)

override val ios: AttestationService.IOS
get() = object : AttestationService.IOS {
override val ios: IOS
get() = object : IOS {
override fun verifyAppAttestation(attestationObject: ByteArray, challenge: ByteArray) =
verifyAttestation(listOf(attestationObject), challenge, clientData = null)

Expand All @@ -395,7 +385,7 @@ object NoopAttestationService : AttestationService {
) = verifyAttestation(listOf(attestationObject, assertionFromDevice), challenge, referenceClientData)

}
override val android: AttestationService.Android
override val android: Android
get() = TODO("Not yet implemented")
}

Expand All @@ -416,7 +406,7 @@ class DefaultAttestationService(
private val iosAttestationConfiguration: IOSAttestationConfiguration,
private val clock: Clock = Clock.System,
private val verificationTimeOffset: Duration = Duration.ZERO
) : AttestationService {
) : AttestationService() {

/**
* Java-friendly constructor with `java.time` types
Expand Down Expand Up @@ -487,7 +477,7 @@ class DefaultAttestationService(
)
}

override val ios = object : AttestationService.IOS {
override val ios = object : IOS {
override fun verifyAppAttestation(attestationObject: ByteArray, challenge: ByteArray) =
verifyAttestationApple(attestationObject, challenge, assertionData = null, counter = 0L)

Expand All @@ -505,7 +495,7 @@ class DefaultAttestationService(
)
}

override val android = object : AttestationService.Android {
override val android = object : Android {
override fun verifyKeyAttestation(
attestationCerts: List<X509Certificate>,
expectedChallenge: ByteArray
Expand Down
25 changes: 25 additions & 0 deletions attestation-service/src/main/kotlin/Extensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@ package at.asitplus.attestation
import kotlinx.datetime.Clock
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toKotlinInstant
import org.bouncycastle.jce.ECNamedCurveTable
import org.bouncycastle.jce.provider.JCEECPublicKey
import org.bouncycastle.util.encoders.Base64
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import java.security.spec.InvalidKeySpecException
import java.security.spec.X509EncodedKeySpec
import java.time.Instant
import java.time.ZoneId
import java.util.*


private val ecKeyFactory = KeyFactory.getInstance("EC")
private val rsaKeyFactory = KeyFactory.getInstance("RSA")

//copied from AppAttest Library
private val certificateFactory = CertificateFactory.getInstance("X.509")
fun ByteArray.parseToCertificate(): X509Certificate? = kotlin.runCatching {
Expand Down Expand Up @@ -54,6 +64,21 @@ fun ECPublicKey.toAnsi() = let {
byteArrayOf(0x04) + xFromBc + yFromBc
}

fun ByteArray.parseToPublicKey(): PublicKey =
try {
(if (size < 1024) ecKeyFactory else rsaKeyFactory).generatePublic(X509EncodedKeySpec(this))
} 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)
}

/**
* Drops or adds zero bytes at the start until the [size] is reached
*/
Expand Down
22 changes: 0 additions & 22 deletions attestation-service/src/test/java/JavaInteropTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,28 +98,6 @@ public static void testAttestationCallsJavaFriendliness() throws NoSuchAlgorithm
"14.1"),
Duration.ZERO);

AttestationResult result = service.verifyAttestation(Collections.emptyList(), new byte[]{}, null);


if (result instanceof AttestationResult.Android) {
((AttestationResult.Android) result).getAttestationCertificate();
((AttestationResult.Android) result).getAttestationRecord();
}

if (result instanceof AttestationResult.IOS) {
((AttestationResult.IOS) result).getClientData();
}

if (result instanceof AttestationResult.Error) {
Throwable cause = ((AttestationResult.Error) result).getCause();
String explanation = ((AttestationResult.Error) result).getExplanation();
Assertions.assertEquals("Attestation proof is empty", explanation);
Assertions.assertNull(cause);
}

Assertions.assertTrue(result instanceof AttestationResult.Error);


KeyAttestation<ECPublicKey> keyAttestationResult = service.verifyKeyAttestation(Collections.emptyList(),
new byte[]{0, 2, 3, 2, 2}, (ECPublicKey) KeyPairGenerator.getInstance("EC").
generateKeyPair().getPublic());
Expand Down
Loading

0 comments on commit f6d3d99

Please sign in to comment.