Search code examples
node.jsswiftswiftuiwebauthnpasskey

Unexpected RP ID hash when registering a passkey in an iOS app


I am trying to implement a login with passkeys in a SwiftUI app, based on the sample code provided by Apple (food truck app). On the server side, I am using node.js with @simplewebauthn package. As of now, the web version of the signin/ login process works fine. But when I create a passkey from the swiftUI app and send the registration response back to the server the rpIdHash doesn't match the expected rpIdHash.

At the end of the registration process in swift, in handleAuthorizationResult(_ authorizationResult: ASAuthorizationResult, username: String? = nil), I receive an ASAuthorizationResult, which leads to a .passkeyRegistration case (when registering a new passkey). I then recreate a credential object that has the following structure :

{
    id: passkeyRegistration.credentialID,
    rawId: passkeyRegistration.credentialID,
    type: “public-key“,
    authenticatorAttachment: “platform“,
    response: {
        clientDataJSON: passkeyRegistration.rawClientDataJSON,
        attestationObject: passkeyRegistration.rawAttestationObject,
        transports: ["internal","hybrid"]
    }
}

Which I manage to decode properly on the server-side, but the rpIdHash (203,189,6,160,8,217,220,110,195,214,214,218,140,243,77,214,79,53,154,17,152,74,224,35,148,70,128,25,42,14,122,101) of the attestation object from Apple doesn’t match the expected rpIdHash (203,189,6,160,8,217,220,110,195,214,214,218,140,243,77,214,79,53,154,17,152,74,239,227,148,70,190,25,42,14,122,101). But what is weird is that they only differ from three “keys“ :

  • Expected "22":239, "23":227, "26":190
  • Recieved "22":224, "23":35, "26":128

So I tried to sample some different rpIdHash to find out where this error might come from but I couldn’t get any hash that would match the one from Apple’s ASAuthorizationResult.

I have no idea how to fix this as both hashing process do not depend on me, so I would really appreciate any help.

PS: For reference, here is the hashing function used by @simplewebauthn:

/**
 * Returns hash digest of the given data, using the given algorithm when provided. Defaults to using
 * SHA-256.
 */
export function toHash(
  data: Uint8Array | string,
  algorithm: COSEALG = -7,
): Promise<Uint8Array> {
  if (typeof data === 'string') {
    data = isoUint8Array.fromUTF8String(data);
  }

  const digest = isoCrypto.digest(data, algorithm);

  return digest;
}

Solution

  • Ok I think I have found where does the problem comes from. In the .passkeyRegistration case, I tried to get the attestationObject as follows:

    let rawAttestationObject = passkeyRegistration.rawAttestationObject
    let unsafeUnwrap = rawAttestationObject.unsafelyUnwrapped
    
    let uint8Ptr = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: 182)
    let uint8PtrCount = uint8Ptr.count
    
    print("uint8ptr :: \(uint8Ptr)")
    var bytes: [UInt8] = []
    for i in uint8Ptr {
        print("\(i)")
        bytes.append(i)
    }
    

    Which when I then passed into the server directly :

    const bytesFromNSData = new Uint8Array([163,99,102,109,116,100,110,111,110,101,103,97,116,116,83,116,109,116,160,104,97,117,116,104,68,97,116,97,88,152,203,189,6,160,8,217,220,110,195,214,214,218,140,243,77,214,79,53,154,17,152,74,239,227,148,70,190,25,42,14,122,101,93,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,20,143,66,13,222,33,34,220,187,195,26,161,164,71,230,9,96,61,48,165,42,165,1,2,3,38,32,1,33,88,32,188,210,91,139,14,0,82,46,250,20,230,184,210,55,142,180,31,135,163,47,22,232,245,35,39,18,12,137,210,250,44,245,34,88,32,226,27,251,25,117,245,106,27,182,131,130,8,160,141,4,119,6,128,114,13,14,82,185,119,129,26,216,161,160,10,6,63])
    
    const decodedFromNSData = decodeAttestationObject(bytesFromNSData)
    const authNSData = decodedFromNSData.get('authData');
    const parsedAuthNSData = parseAuthenticatorData(authNSData);
    
    const {
       aaguid,
       rpIdHash,
       flags,
       credentialID,
       counter,
       credentialPublicKey,
       extensionsData,
    } = parsedAuthNSData;
    

    And this then gave me the right expected RPid hash!

    So I suppose the issue comes from casting the rawAttestationObject in the following swift struct:

    public struct RawPublicKeyCredential: Encodable {
        public var id: Data = Data("".utf8)
        public var rawId: Data = Data("".utf8)
        public var type: String = "public-key"
        public var authenticatorAttachment: String = "platform"
        public var response: RawPublicKeyResponse = RawPublicKeyResponse()
        public var user_id: String = ""
    }
    
    public struct RawPublicKeyResponse: Encodable {
        public var clientDataJSON: String = ""
        public var attestationObject: Data? = Data("".utf8)
        public var transports: [String] = ["internal","hybrid"]
    }
    

    Which I originally did like that:

    var res = RawPublicKeyCredential()
    res.id = credentialID
    res.rawId = credentialID
    res.response.attestationObject = rawAttestationObject
    

    Hence I then tried to cast it as follows:

    let data = Data(bytes: bytes, count: bytes.count)
    res.response.attestationObject = data
    

    But it still didn't work. My guess would be that the issue come from the Data() type, but I don't know what to replace it with to fix this. So far, I have tried the following:

    let data = Data(bytes: bytes, count: bytes.count)
    let str = NSString(data: data, encoding: NSUTF8StringEncoding)
    let bytestStr = String(bytes: bytes, encoding: .utf8)
    let dataStr = String(data: data, encoding: .utf8)
    

    but none of them worked (bytestStr and dataStr were set to nil and NSString doesn't conform to Encodable).

    So if someone has an idea on how to fix this I would be very gratefull.

    UPDATE: Ok I'm actually an idiot, from the beginning in was a Base64 encoded String when it needed to be Base64URL. Didn't know it could make such a difference. At least I got to understand a bit more of what's happening under the hood during the passkey registration process.

    So if anyone is wondering or happens to have the same problem, the final 2/3 lines I added are:

    let data = Data(bytes: bytes, count: bytes.count)
    let base64URLattestation = Base64URL.encode(data)
    res.response.attestationObject = base64URLattestation
    

    or just:

    let base64URLattestation = Base64URL.encode(rawAttestationObject!)
    res.response.attestationObject = base64URLattestation
    

    Anyway, thank you @agl for the help and hope this post can help if someone runs into the same issue.