Detect Fraud Using Apple's DeviceCheck for iOS

Mobile APIs are a common target for attempting to exploit a web server, and there are typically many layers of security built into a web infrastructure. We will discuss how an application server can determine that requests are coming from a legitimate source, in this case a trusted mobile app running on a mobile device.

A server identifies itself during the TLS handshake and establishes trust with the client. The client then offers credentials or tokens to the server in order to obtain access to resources. But what if the credentials become compromised and are in use by a bad actor? A scripted attack can impersonate an iPhone and gain access to data that is now leaked outside of the “mobile sandbox”. How can we determine whether an HTTP request actually originated from a mobile device? Apple’s DeviceCheck API gives us a way to do this.

In this example our iOS client will generate a token using the DeviceCheck library, then pass it along with credentials to a login endpoint.

static func loginWithDeviceCheck() {
    if DCDevice.current.isSupported {
        // A unique token will be generated for every call to this method
        DCDevice.current.generateToken(completionHandler: { token, error in

            guard let token = token else {
                print("error generating token: \(error!)")
                return
            }

            doLogin(username: "user", password: "Password1!", token: token)
        })
    }
}

static func doLogin(username: String, password: String, token: Data) {
    let session = URLSession.shared
    let url = URL(string: "http://192.168.1.30:5000/login")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let dict = [
        "username": username,
        "password": password,
        "token": token.base64EncodedString(),
    ]

    var jsonData: Data?
    do {
        jsonData = try JSONEncoder().encode(dict)
    } catch {
        return
    }

    let task = session.uploadTask(with: request, from: jsonData) { data, response, error in
        // response handling
    }
    task.resume()
}

Our server controller will now call Apple’s server API before executing any business logic. Only if the Apple check succeeds will we proceed with the login. Note: a DeviceCheck private key must be created before the API can be called. More information on that process can be found here.

@app.route('/login', methods=['POST'])
def login():
	validate_device(request.json['token'])
	return login_user(request.json['username'], request.json['password'])

# iOS Device validation
def validate_device(device_token):

	request_body = {
		# Device token provided by the iOS client
		'device_token': device_token,
		
		# A unique transaction id
		'transaction_id': str(uuid.uuid1()),
		
		# Timestamp in milliseconds
		'timestamp': int(time.time()*1000)
	}
	
	# A DeviceCheck key generated under an Apple Developer account
	with open('AuthKey_ZYXW098765.p8', 'r') as file:
		private_key = file.read()
	
	jwt_headers = {
        # Key ID of the private key
		'kid': 'ZYXW098765'
	}
	
	jwt_payload = {
		# 10-character Team ID from an Apple developer account 
		'iss': 'ABCD123456',
		
		# Timestamp in seconds
		'iat': int(time.time())
	}

	# Encode an ES256 JWT for authorization.
	auth_token = jwt.encode(jwt_payload, private_key, algorithm='ES256', headers=jwt_headers)
	
    # Send request to Apple
	result = requests.post('https://api.development.devicecheck.apple.com/v1/validate_device_token',
		headers={'Authorization': 'Bearer {}'.format(auth_token), 'Content-type': 'application/json'},
		json=request_body
	)

    return result.status_code == 200

DeviceCheck also provides 2 bit fields that can be persisted on Apple’s servers. The presence of the bits on the server will indicate that we have sent data for this device before. The state of the bits can be used to flag fraud or business cases that need to be limited to a single use. There is a timestamp for the last time bits were updated, this is also useful for time based restrictions such as promotional offers.

    # Query for device bits
	result = requests.post('https://api.development.devicecheck.apple.com/v1/query_two_bits',
		headers={'Authorization': 'Bearer {}'.format(auth_token), 'Content-type': 'application/json'},
		#data=json.dumps(request_body)
		json=request_body
	)
	
	if result.status_code != 200:
		return "Bad Request"
	
	if result.text == "Failed to find bit state":
		# New device, register the default bit state
		request_body = {
			'device_token': device_token,
			'transaction_id': str(uuid.uuid1()),	
			'timestamp': int(time.time()*1000),
			'bit0': False,
			'bit1': False
		}
		result = requests.post('https://api.development.devicecheck.apple.com/v1/update_two_bits',
			headers={'Authorization': 'Bearer {}'.format(auth_token), 'Content-type': 'application/json'},
			json=request_body
		)
	else:
		# Found device, Check the existing bit state
        response_body = result.json()
		bit0 = response_body['bit0']
		bit1 = response_body['bit1']
        last_update_time = response_body['last_update_time']
        # Evaluate bit state & take appropriate action for this device

Since these device tokens are both temporary and backed by hardware, this mechanism is much safer than embedding our own custom keys and crypto into an iOS application.

You may be thinking an attacker can load a recompiled copy of our application onto a device, which would allow fraudulent access despite the checks we have demonstrated here. This is absolutely a concern, and in an upcoming post we will cover another piece of DeviceCheck, which involves checking integrity of the iOS application sending a request.

John Rountree Senior Technical Consultant