DEV Community

Cover image for Implementing Apple's Device Check App Attest Protocol
Matt Nelson-White
Matt Nelson-White

Posted on

Implementing Apple's Device Check App Attest Protocol

When creating a back-end for your App, you may want to secure your API so that you can ensure that the App cannot be used by agents other than legitimate APP installs running on legitimate devices. This lowers the API attack surface by limiting exposure to only legitimate APP installs.

As developers, APIs are often protected by user authentication and authorisation; but in instances where there is no user authentication your API is left unprotected.

Suggestions on many forums, that APIs can be protected by configuring your APP with a secret "API Key", are sub-optimal since it is essentially "security by obfuscation" (the secret can be retrieved by extracting it from your APP package).

The answer to both, "how can I secure my API from illegitimate clients? ", and "how should I protect unauthenticated clients?", is "App Attestation"! 🎉🥳

Apple and Android have solutions for App Attestation, but this article will be focused on Apple's Device Check App Attest.

Basic Flow

The figure taken from the App Attest "Establishing Your App's Integrity" document shows there are three high-level components involved in App Attest;

  • your app,
  • your server,
  • and the app attest service.

There is a little more involved than that, and a little more that is useful to know about the high-level components.

High-Level App Attest Architecture

Critically, the "App Attest" service, as it is shown in the figure above, is an internet-exposed service, which may fail or timeout from time to time.

Furthermore, the Secure Enclave on the device is used to prevent "Your App" from direct access to the private key generated for the use of App Attest.

Basic Protocol Flow

  1. Your server delivers a string of random bytes which represents a challenge to your app.
  2. Your app creates a cryptographic key-pair using the device check API. The key resides in the device's "Secure Enclave" and the operation responds with a reference to that public/private key pair with an identifier string (the key ID string is a SHA256 hash of the public key).
  3. A hash of the challenge data along with the key identifier is sent to the Apple App Attest service over the internet. The service responds with the attestation data as a string of bytes.
  4. The attestation data is provided to your server, and the server validates the attestation data.
  5. Subsequence requests to your server are accompanied by assertion data which are generated on the device with the key identifier and the request body.
  6. The assertion data are then validated and the request is processed.

Let's break down how each of these things work. Then iterate on the process to try and take into account HTTP standards for authentication and security practices.

Warning: Check that App Attest is Available

A "step zero" not mentioned above is that you should check your App's platform supports the App Attest service before continuing the process.

let service = DCAppAttestService.shared
if service.isSupported {
    // Perform key generation and attestation.
}
// Continue with server access.
Enter fullscreen mode Exit fullscreen mode

The annoying fact about DCAppAttestService is that it doesn't work on the simulator. 😣

The documentation states it is available on the following platform versions:

iOS iPadOS macOS tvOS watchOS
14.0+ 14.0+ 11.0+. 15.0+ 9.0+

Generating the Challenge (.NET)

In response to some request from your app, your server responds with a string of random bytes, which are referred to as the "challenge".

In cases of security and cryptography, random is just not just any PRNG, it should be cryptographically random so that the sequence cannot be easily anticipated by an attacker.

In .NET, we can use the RandomNumberGenerator found in the System.Security.Cryptography namespace and we simply call GetBytes(length) on the static instance of the class to get a cryptographically random sequence of bytes at the desired length.

Generating a Public/Private Key Pair and ID (Swift)

The Apple docs give detail on this, you will want to persist the key identifier response in a local cache or storage so it may be used later in the process.

The generated key pair and identifier are unique for each user account on each device running your app. So you only need to store one key identifier in your code.

The basic code for generating the key pair is:

service.generateKey { keyId, error in
    guard error == nil else { /* Handle the error. */ }

    // Cache keyId for subsequent operations.
}
Enter fullscreen mode Exit fullscreen mode

I prefer to use async/await rather than callbacks, so here it is refactored:

public func getKeyId() async throws -> String {
    if let keyId = try _keyId.value {
        return keyId
    }

    return try await withCheckedThrowingContinuation { continuation in
        _service.generateKey { keyId, error in
            if let keyIdValue = keyId {
                do {
                    try self._keyId.set(value: keyIdValue)
                    continuation.resume(returning: keyIdValue)
                }
                catch {
                    continuation.resume(throwing: AppAttestError.GetKey(error))
                }
            }
            else {
                continuation.resume(throwing: AppAttestError.GetKey(error))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The above code makes use of an instance of self._keyId to manage the reuse of a generated key pair. The instance is of a type that serves as an interface to the device keychain secret storage, the implementation of that will be shown later.

It also uses AppAttestError, and again, I will share this code later.

  1. Try and get the stored keyId value and return early.
  2. Asynchronously generate the key pair and handle errors with either generation or persisting the key identifier
  3. Capture the key identifier response (or error) with the continuation.

Generate Attestation (Swift)

After retrieving the challenge and obtaining the key identifier we should generate the attestation data. Keep in mind this operation is made against an Apple ran web service; from my experimentation, this service frequently times out or is temporarily unavailable, so make sure to consider this in your design.

When generating the attestation data we need to make use of both the challenge we received earlier and the key identifier.

Apple's instructions for implementing this step are shown under Certify the key pairs as valid.

import CryptoKit

let challenge = <# Data from your server. #>
let hash = Data(SHA256.hash(data: challenge))

service.attestKey(keyId, clientDataHash: hash) { attestation, error in
    guard error == nil else { /* Handle error and return. */ }

    // Send the attestation object to your server for verification.
}
Enter fullscreen mode Exit fullscreen mode

As before, here is a sensible implementation of this function using async.

public func generateAttestation(keyId: String, challenge: Data) async throws -> Data {
    let challengeHash = Data(SHA256.hash(data: challenge))

    return try await withCheckedThrowingContinuation { continuation in
        _service.attestKey(keyId, clientDataHash: challengeHash) { attestation, error in
            if let attestationValue = attestation {
                continuation.resume(returning: attestationValue)
            }
            else if let dcError = error as? DCError, dcError.code == .invalidKey {
                do {
                    try self._keyId.remove()
                    continuation.resume(throwing: AppAttestError.GenerateAttestation(error))
                }
                catch {
                    continuation.resume(throwing: AppAttestError.GenerateAttestation(error))
                }
            }
            else {
                continuation.resume(throwing: AppAttestError.GenerateAttestation(error))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The above code handles the case of the "invalid key" App Attest error. In this scenario, we should invalidate the local key identifier cache so that it will get recreated.

Validating Attestation Statement

The steps to validating the attestation statement are outlined here.

This represents the bulk of the protocol implementation, there are lots of steps and lots of concepts to understand. Let's try and break it down to make it digestible.

High-Level Steps

  1. Decode the attestation data
  2. Verify statement certificates
  3. Verify cryptographic nonce
  4. Verify key identifier against statement certificate
  5. Verify app identifier
  6. Verify the signing counter
  7. Verify development or product environment
  8. Verify key identifier against credential identifier
  9. Store attestation data for assertion data verification

... yikes 😅

I don't expect all of that to make sense at this point, so please continue reading...

The Attestation Object Structure

To decode the attestation statement we need to cover:

... yikes 😅

I think probably the best way to break down this problem is to show the hierarchy of types within the attestation data and then detail their encoding and semantics.

Image description

  • attestation: Represents the CBOR encoded map represented by the attestation data return from the App Attest service.
    • fmt: CBOR encoded string that identifies the statement format. For Apple's Device Check App Attest, it is always "apple-appattest".
    • attStmt: The attestation "statement", encoded as a CBOR map.
    • x5c: CBOR encoded array of byte arrays, each byte array is encoded as an X509 certificate.
      • Index 0: The public certificate of the key pair generated by the app.
      • Index 1: Intermediate signing certificate. The chain is represented by root -> intermediate -> auth cert.
    • receipt: CBOR encoded byte array can be sent to the App App Attest service to get information on the risk metric associated with the attestation.
    • authData: Or "Authenticator Data" is defined within the web authentication spec. For the most part, data in this structure is fixed width, so we can marshal the bytes directly to a struct (mostly 😅).
    • rpidHash: As the name suggests, this is a hash of the RPID, or relying party identifier. Within App Attest, the RP is always your app's "App ID".
    • flags: bitflags to describe the auth data; does it include attest data? Is the user verified? Is there extensions on this auth data? Stuff like that...
    • signCount: A counter for how many times the signature has signed content. When parsing the attestation statement, this figure is zero.
    • attestedCredentialData: Contains the credential data for the corresponding attestation. App Attest uses the key identifier as the credential identifier
      • aaguid: Determines if the app attest environment is development or production.
      • credentialIdLength: A big-endian UInt16 value for the length credentialId.
      • credentialId: The attested credential identifier. For the purposes of app attest, the key identifier and the credential identifier are the same value.
      • credentialPublicKey: COSE key, the encoding of which is a whole thing, but since the apple App Attest protocol does not make use of this field, I won't spend time going into it.

Decoding the Attestation Object

The first level of decoding the attestation object required CBOR decoding. To decode a CBOR encoded object you can pull in a library like Dahomey.Cbor, or write your own. Somewhere in the middle is using what Microsoft provides with System.Formats.Cbor.CborReader. You still need to do a lot of the lifting and write a deserialiser, but the class can help.

To understand CBOR encoding better, take a look at my article here.

Top comments (0)