How to decode an iOS assertion object?

Viewed 523

I generate an assertion object using DCAppAttestService and send it to my Cloud Functions server - to verify that in app purchases come from legitimate instances of my app.

Here is my Swift code that, when an in-app purchase has been made:

  1. Retrieves a one-time challenge (string) from my server
  2. Using that string, along with client data, to generate a hash
  3. Use that hash, and the device's keyID, to call generateAssertion()
  4. Send a base64-encoded string of the assertion object and client data object

Swift:

func callSensitiveFunction(name: String, data: [String:String], completion: @escaping (HTTPSCallableResult?, Error?)->()){
    var functions = Functions.functions()
    var adjustedData: [String:String] = data
    guard let keyID = KeychainWrapper.standard.string(forKey: "attestKeyID") else {
        print("no attest ID, return")
        return
    }
    let service = DCAppAttestService.shared
    // Retrieve the challenge string
    functions.httpsCallable("getChallengeString").call(data){ (result, error) in
        if let challengeString = result?.data as? String {
            // Retrieve the keyID (public key) which was saved at first launch
            guard let attestKeyID = KeychainWrapper.standard.string(forKey: "attestKeyID") else { return print("No attestKeyID found") }
            // Add challenge string to request
            adjustedData["challenge"] = challengeString
            // Hash client data (which includes challenge string)
            guard let clientData = try? JSONEncoder().encode(adjustedData) else { return }
            let clientDataHash = Data(SHA256.hash(data: clientData))
            service.generateAssertion(attestKeyID, clientDataHash: clientDataHash) { assertion, err in
                guard error == nil else { return print("generateAssertion() error: \(error)") }
                let assertionString = assertion?.base64EncodedString() ?? ""
                let clientDataString = clientData.base64EncodedString()
                adjustedData["assertionString"] = assertionString
                adjustedData["clientData"] = clientDataString
                functions.httpsCallable(name).call(adjustedData){ (result, error) in
                    completion(result, error)
                    print("function \(name) completed")
                }
            }
        }
    }
}

However when I receive the assertionString in the backend (Node.js) - I'm not how how to decode it. Using this tutorial here i've gotten this far:

const cbor = require("cbor");
const base64url = require('base64url');

let verifyAppleAnonymousAttestation = (assertionString) => {
    let assertionBuffer = base64url.toBuffer(assertionString);
    console.log(`assertionBuffer: ${assertionBuffer}`) // assertionBuffer: �isignatureXG0E!���56��r��@��@J�r+X������ڳ%� D��c! 
    let assertionStruct = cbor.decodeAllSync(assertionBuffer)[0];
    console.log(`assertionStruct[0]: ${assertionStruct[0]}`) // assertionStruct: [object Object]

But I'm not sure where to go from here to verify the assertion string. Apple says after the assertion is decoded it looks like this:

{
  signature: <Buffer 30 45 02 20 ... >,
  authenticatorData: <Buffer 21 c9 9e 00 ... >
}

Any idea how I can extract all of the validating data from the assertionString?

1 Answers

You are missing service.generateKey (you should NOT try to generate the keyId yourself without Apple) and service.attestKey on the device. This is how Apple designed the API and you can see this from the docs. You also should use your Keychain to store the keyId for future requests only after a successful chain of requests. Don't store invalid keyIds in the Keychain that you generated without the DCAppAttestService otherwise, you will not get stable behavior.

The general page for DCAppAttestService says you should certify the key after you generate it using it's opaque framework methods since it doesn't want to expose sensitive device information for security. You are fighting the framework. :-)

Follow the steps in the order provided here.

  • Create a cryptographic key in the Secure Enclave by calling the generateKeyWithCompletionHandler: method.
  • Ask Apple to certify the key by calling the attestKey:clientDataHash:completionHandler: method.
  • Prepare an assertion of your app’s integrity to accompany any or all server requests using the generateAssertion:clientDataHash:completionHandler: method.

Example code:

var keyId: String? // Make this a property on self.
var attestationObject: Data? // Make this a property on self.

// Catch the errors this throws out when you call it.
func callSensitiveFunction(name: String, data: [String:String], completion: @escaping (HTTPSCallableResult?, Error?)->()) throws {    
// Check if keyId exists in Keychain, if it doesn't start the request flow.
// Get clientData from first request (challenge) as you are.
let clientDataHash = Data(SHA256.hash(data: clientData))
let service = DCAppAttestService.shared
guard service.isSupported else { return } 
let group = DispatchGroup()
group.enter()
service.generateKey { keyId, error in
  // First check for errors in every step, obviously you can handle the errors differently (this is just a guide).
  guard !error else {
    group.leave()
    throw ValidationError(error.localizedDescription) // I assume we have defined a new enum Error type with associated values here. 
    return
  }
  self.keyId = keyId
  group.leave()
}
group.wait()
guard let keyId = self.keyId {
  throw ValidationError("Missing keyId")
  return
}
group.enter()
service.attestKey(keyId, clientDataHash: clientDataHash) { attestationObject, error in
  guard !error else {
    group.leave()
    throw ValidationError(error.localizedDescription)
    return
  }
  self.attestationObject = attestationObject
  group.leave()
}
// Now you need to send `attestationObject` to the server directly to validate server-side.
group.wait()
guard let attestationObject = self.attestationObject {
  throw ValidationError("Missing attestationObject")
  return
}
group.enter()
functions.httpsCallable(name).call(attestationObject) { (result, error) in
  guard !error else {
    group.leave()
    throw ValidationError(error.localizedDescription)
    return
  }
  group.leave()
}
// Now you only generate the assertion if the key was valid.
group.wait()
service.generateAssertion(keyId, clientDataHash: clientDataHash) { data, error in
  guard !error else {
    throw ValidationError(error.localizedDescription)
    return
  }
  // NOW store in the Keychain.
  completion(result, error)
  print("function \(name) completed")
}
}

I will limit my answer to iOS device code but the background and guidelines for verifying the key server-side can be found here.

I can also suggest using DispatchGroup instead of nested completion blocks - also make sure you call callSensitiveFunction on a background queue. You get the main reasons for your issue - the HTTPS request order needs to be precise, as well as using Apple's API guidelines. Otherwise, you will be inspecting the wrong object. :) I'm not sure if you want to throw errors, and I haven't tested this code (I don't have an API handy), but the rough idea is there.

Related