Determine iOS App Authenticity Using Apple App Attest

In a previous post we discussed the benefits of using Apple’s DeviceCheck API to verify that an HTTP request originated from a legitimate iOS device. Now we’ll go a step further and look at the App Attest service, which can establish trust with an instance of an iOS App using keys issued by Apple.

Let’s quickly touch on a few requirements of using the App Attest API:

  • iOS 14 minimum
  • Physical device (no simulators)
  • Signed app bundle registered to an active developer account

The first thing we’ll have to do is generate a public/private key pair using the App Attest service. The keys are never directly exposed to the client, what we get back is a keyId. This id can be used going forward to access the keys from the hardware storage area known as Secure Enclave. This block of code has been written to generate a key if it does not already exist, and save keyId in the keychain using a utility class called KeychainManager.

static let dcAppAttestService = DCAppAttestService.shared

static func getAppAttestKeyId() -> String? {
    guard let keyId = KeychainManager.get(key: "appAttestKeyId") else {
        generateAppAttestKey()
        return nil
    }

    return keyId
}

static func generateAppAttestKey() {
    // The generateKey method returns an ID associated with the key.  The key itself is stored in the Secure Enclave
    dcAppAttestService.generateKey(completionHandler: { keyId, error in

        guard let keyId = keyId else {
            print("key generate failed: \(error)")
            return
        }

        // Cache the keyId for use at a later time.
        KeychainManager.set(key: "appAttestKeyId", value: keyId)
    })
}

Once a key is generated, we’ll want to communicate with our application server to “attest” this instance of our app using the key. The app should ask the application server for a one time challenge value, any randomized string or UUID will suffice. The server will persist the challenge value before giving it back to the client, and use it again later. The SHA256 of that challenge is passed to the App Attest service, which gives us back an attestation object.

static func certifyAppAttestKey(challenge: Data) {
    guard let keyId = getAppAttestKeyId() else {
        return
    }

    let hashValue = Data(SHA256.hash(data: challenge))

    // This method contacts Apple's server to retrieve an attestation object for the given hash value
    dcAppAttestService.attestKey(keyId, clientDataHash: hashValue) { attestation, error in
        guard error == nil else {
            return
        }

        guard let attestation = attestation else {
            return
        }

        // send to application server to complete attestation
        let url = URL(string: "http://192.168.1.30:5000/attest")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")

        let session = URLSession.shared
        let task = session.uploadTask(with: request, from: attestation) { data, response, error in
            // add success/error handling here
        }
        task.resume()
    }
}

The server side implementation is where we really need to get our hands dirty. Apple’s instructions list a 9 step process to determine the integrity of the attestation object. These steps are clear enough, however picking apart the binary data structure is not entirely trivial. I’ve written some sample code in python that might be a useful reference, just keep in mind this is not fully battle tested for production.

@app.route('/attest', methods=['POST'])
def app_attest():

	# decode request body into a python object
	attestation_bytes = base64.b64decode(request.data)
	attestation_object = cbor2.loads(attestation_bytes)
	
	
	# 1. Verify cert chain in attestation object against Apple trusted root
	# Root ca can be found at https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
	cred_cert = load_certificate(FILETYPE_ASN1, attestation_object['attStmt']['x5c'][0])
	ca_cert = load_certificate(FILETYPE_ASN1, attestation_object['attStmt']['x5c'][1])
	store = X509Store()
	store.add_cert(app_attestation_root_ca)
	store.add_cert(ca_cert)
	store_ctx = X509StoreContext(store, cred_cert)
	try:
		store_ctx.verify_certificate()
	except Exception:
		return 'cert chain validation failed', 400
	
	
	# 2. Append clientDataHash of the one time challenge to authData
	client_data_hash = hashlib.sha256(one_time_challenge).digest()
	composite_item = attestation_object['authData'] + client_data_hash
	
	
	# 3. Hash the composite item to create nonce
	nonce = hashlib.sha256(composite_item).digest()
	
	
	# 4. Extract OID from credCert (requires ASN1 parser) and compare to nonce
	decoder = asn1.Decoder()
	decoder.start(attestation_object['attStmt']['x5c'][0])
	oid_value = find_oid_value(decoder, '1.2.840.113635.100.8.2')[-32:]
	if oid_value != nonce:
		return 'nonce check failed', 400
	
	
	# 5. Check that the sha256 of cert public key matches keyId of the attest key pair
	# The key ID appears in several places in this payload.  Here in the cert, and also 
	# in the authData block.  We will perform this comparison in step #9
	pubkey = ca_cert.get_pubkey()
	pubkey_bytes = dump_publickey(FILETYPE_ASN1, pubkey)
	pubkey_hash = hashlib.sha256(pubkey_bytes).digest()
	
		
	# 6. Get SHA256 of app ID and compare to RPID value in authData buffer
	# The app ID looks like {apple-teamId}.{bundleId}
	auth_data = attestation_object['authData']
	rp_id_hash = auth_data[0:32]
	app_id_hash = hashlib.sha256(app_id).digest()
	if app_id_hash != rp_id_hash:
		return 'app id check failed', 400
		
		
	# advance buffer past the rpIdHash block
	auth_data = auth_data[32:]
	# advance past flags byte
	auth_data = auth_data[1:]
	
		
	# 7. Verify that the authenticator data’s counter field equals 0
	counter_bytes = auth_data[0:4]
	if 0 != int.from_bytes(counter_bytes, byteorder='big', signed=False):
		return 'counter check failed', 400
	auth_data = auth_data[4:]
	
		
	# 8. Verify aaguid field
	aaguid = auth_data[0:16]
	if 'appattestdevelop' != aaguid.decode('utf-8'):
		return 'aaguid check failed', 400
	auth_data = auth_data[16:]
	

	# 9. Verify credentialId field, which is again the keyId generated at the iOS client
	cred_id_len_bytes = auth_data[0:2]
	auth_data = auth_data[2:]
	cred_id_len = int.from_bytes(cred_id_len_bytes, byteorder='big', signed=False)
	cred_id_bytes = auth_data[0:cred_id_len]
	if cred_id_bytes != pubkey_hash:
		return 'credential id check failed', 400
	

	# All checks have passed
	return 'OK'

Let’s review a few important assertions that were covered here:

  • The payload contained a valid certificate (issued by a trusted Apple CA) that contained the expected keyId & public key.
  • Nonce value derived from registered App Store data was also present in the certificate.
  • The one-time challenge shared between client and server was verified.

An expiring one-time challenge and a short validity period on the credCert are tools to prevent replay attacks against this mechanism. Once this verification has passed, the server has established trust with the client and should save the keyId and receipt data. For future transactions that are deemed sensitive, the server can challenge the client to send an assertion payload, which applies similar concepts to any specific API request. See the section titled Verify the Assertion here for further info.

In conclusion, the App Attest service (in tandem with DeviceCheck) provides a solid app security mechanism for detecting fraudulent access to mobile web services. Feel free to contact Restless Labs if you have questions about what type of security might be appropriate for your web applications.

Additional References:

John Rountree Senior Technical Consultant