node.jsswiftswiftuiwebauthnpasskey

User verification failed when signing in to an iOS app with a passkey


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. The registration of a passkey from the swiftUI app now also works fine, I am able to create a passkey from the iOS app and use it on the web (both directly from the iPhone or by scaning the QRcode on a mac (with a different iCloud)). But when I try to signin in the iOS app, the signature provided by Apple cannot be verified on the server-side.

In handleAuthorizationResult func I get an ASAuthorizationResult which then leads to a .passkeyAssertion case. I then reconstruct the following object to send it back to the server for verification:

{
    "id": Base64URL.encode(passkeyAssertion.credentialID),
    "rawId": Base64URL.encode(passkeyAssertion.credentialID),
    "type": "public-key",
    "response": {
        "clientDataJSON": base64URLEncodedClientDataJSON, // Challenge is decoded from passkeyAssertion.rawClientDataJSON then Base64URL decoded, then everything is re-encoded in Base64URL
        "authenticatorData": Base64URL.encode(passkeyAssertion.rawAuthenticatorData!),
        "signature": Base64URL.encode(passkeyAssertion.signature!),
        "userHandle": Base64URL.encode(passkeyAssertion.userID!)
    }
}

After testing and comparing it to the web response, I can tell that the signature is what is causing the problem. When calling @simplewebauthn function verifyAuthenticationResponse:

const { verified, authenticationInfo } = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin,
    expectedRPID,
    authenticator,
    requireUserVerification: false,
});

which returns verified===false.

So I tried to look into this fuction to narrow down the problem, by calling directly the functions where the problem happens:

const authenticator = {
    credentialPublicKey: isoBase64URL.toBuffer(cred.publicKey),
    credentialID: isoBase64URL.toBuffer(cred.id),
    transports: cred.transports,
};
const authDataBuffer = isoBase64URL.toBuffer(
    response.response.authenticatorData,
);
const clientDataHash = await toHash(
    isoBase64URL.toBuffer(response.response.clientDataJSON),
);

const signatureBase = isoUint8Array.concat([authDataBuffer, clientDataHash]);
const signature = isoBase64URL.toBuffer(response.response.signature);

try {
    const testVerify = await verifySignature({
        signature,
        data: signatureBase,
        credentialPublicKey: authenticator.credentialPublicKey,
    })
    console.log("\nVerify Signature test : " + testVerify)
} catch (e) {
    console.log("\n\nVerif error : " + e)
}

let cosePublicKey = decodeCredentialPublicKey(authenticator.credentialPublicKey);

try {
    const deeperTestVerify = await isoCrypto.verify({
        cosePublicKey,
        signature,
        data: signatureBase,
     })
    console.log("\nVerify Signature deeper test : " + deeperTestVerify)
} catch (e) {
    console.log("\n\nDeeper Verif error : " + e)
}

These two functions return false (which make sense as one calls the other) but they do not throw any error. So I believe the problem happens here :

 export function verify(opts: {
     cosePublicKey: COSEPublicKey;
     signature: Uint8Array;
     data: Uint8Array;
     shaHashOverride?: COSEALG;
   }): Promise<boolean> {
     const { cosePublicKey, signature, data, shaHashOverride } = opts;

     if (isCOSEPublicKeyEC2(cosePublicKey)) {
       const unwrappedSignature = unwrapEC2Signature(signature);
       return verifyEC2({
         cosePublicKey,
         signature: unwrappedSignature,
         data,
         shaHashOverride,
       });
     } else if (isCOSEPublicKeyRSA(cosePublicKey)) {
       return verifyRSA({ cosePublicKey, signature, data, shaHashOverride });
     } else if (isCOSEPublicKeyOKP(cosePublicKey)) {
       return verifyOKP({ cosePublicKey, signature, data });
     }

     const kty = cosePublicKey.get(COSEKEYS.kty);
     throw new Error(
       `Signature verification with public key of kty ${kty} is not supported by this method`,
     );
   }

But I am not sure of the type of not sure of the type of the publickey which is assigned as follows:

export function decodeCredentialPublicKey(
  publicKey: Uint8Array,
): COSEPublicKey {
  return _decodeCredentialPublicKeyInternals.stubThis(
    isoCBOR.decodeFirst<COSEPublicKey>(publicKey),
  );
}

when calling decodeCredentialPublicKey (cf above code snippet).

My guess is that it would be an RSA publicKey which would mean that the verification error happens here:

/**
* Verify a signature using an RSA public key
*/
export async function verifyRSA(opts: {
 cosePublicKey: COSEPublicKeyRSA;
 signature: Uint8Array;
 data: Uint8Array;
 shaHashOverride?: COSEALG;
}): Promise<boolean> {
 const { cosePublicKey, signature, data, shaHashOverride } = opts;

 const WebCrypto = await getWebCrypto();

 const alg = cosePublicKey.get(COSEKEYS.alg);
 const n = cosePublicKey.get(COSEKEYS.n);
 const e = cosePublicKey.get(COSEKEYS.e);

 if (!alg) {
   throw new Error('Public key was missing alg (RSA)');
 }

 if (!isCOSEAlg(alg)) {
   throw new Error(`Public key had invalid alg ${alg} (RSA)`);
 }

 if (!n) {
   throw new Error('Public key was missing n (RSA)');
 }

 if (!e) {
   throw new Error('Public key was missing e (RSA)');
 }

 const keyData: JsonWebKey = {
   kty: 'RSA',
   alg: '',
   n: isoBase64URL.fromBuffer(n),
   e: isoBase64URL.fromBuffer(e),
   ext: false,
 };

 const keyAlgorithm = {
   name: mapCoseAlgToWebCryptoKeyAlgName(alg),
   hash: { name: mapCoseAlgToWebCryptoAlg(alg) },
 };

 const verifyAlgorithm: AlgorithmIdentifier | RsaPssParams = {
   name: mapCoseAlgToWebCryptoKeyAlgName(alg),
 };

 if (shaHashOverride) {
   keyAlgorithm.hash.name = mapCoseAlgToWebCryptoAlg(shaHashOverride);
 }

 if (keyAlgorithm.name === 'RSASSA-PKCS1-v1_5') {
   if (keyAlgorithm.hash.name === 'SHA-256') {
     keyData.alg = 'RS256';
   } else if (keyAlgorithm.hash.name === 'SHA-384') {
     keyData.alg = 'RS384';
   } else if (keyAlgorithm.hash.name === 'SHA-512') {
     keyData.alg = 'RS512';
   } else if (keyAlgorithm.hash.name === 'SHA-1') {
     keyData.alg = 'RS1';
   }
 } else if (keyAlgorithm.name === 'RSA-PSS') {
   /**
    * salt length. The default value is 20 but the convention is to use hLen, the length of the
    * output of the hash function in bytes. A salt length of zero is permitted and will result in
    * a deterministic signature value. The actual salt length used can be determined from the
    * signature value.
    *
    * From https://www.cryptosys.net/pki/manpki/pki_rsaschemes.html
    */
   let saltLength = 0;

   if (keyAlgorithm.hash.name === 'SHA-256') {
     keyData.alg = 'PS256';
     saltLength = 32; // 256 bits => 32 bytes
   } else if (keyAlgorithm.hash.name === 'SHA-384') {
     keyData.alg = 'PS384';
     saltLength = 48; // 384 bits => 48 bytes
   } else if (keyAlgorithm.hash.name === 'SHA-512') {
     keyData.alg = 'PS512';
     saltLength = 64; // 512 bits => 64 bytes
   }

   (verifyAlgorithm as RsaPssParams).saltLength = saltLength;
 } else {
   throw new Error(
     `Unexpected RSA key algorithm ${alg} (${keyAlgorithm.name})`,
   );
 }

 const key = await importKey({
   keyData,
   algorithm: keyAlgorithm,
 });

 return WebCrypto.subtle.verify(verifyAlgorithm, key, signature, data);
}

Which, if I am not mistaking means the error happens at this last line:

return WebCrypto.subtle.verify(verifyAlgorithm, key, signature, data);

Sorry I know it is a lot of talk for not a lot of progress but I am trying to narrow down the problem as much as possible. Although for now, my only conclusion is that there is a problem with the signature coming from swift. Everyting else works fine, I have also tried to send it to the server without Base64URL encoding or by casting the bytes into a string as such:

let unsafeUnwrap = signature.unsafelyUnwrapped
let uint8Ptr = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: unsafeUnwrap.count)
let _ = uint8Ptr.initialize(from: unsafeUnwrap)
let uint8PtrCount = uint8Ptr.count

var bytes: [UInt8] = []
for i in uint8Ptr {
    bytes.append(i)
}
print(bytes)
let data = Data(bytes: bytes, count: bytes.count)
let str = String(decoding: bytes, as: UTF8.self)

let base64URLSignature = str
                        .replacingOccurrences(of: "+", with: "-")
                        .replacingOccurrences(of: "/", with: "_")
                        .replacingOccurrences(of: "=", with: "")

res.response.signature = Base64URL.encode(data)
// or
res.response.signature = base64URLSignature
// or just
res.response.signature = Base64URL.encode(signature!)

But the result was always the same, the string was fine but the User Verification failed. So I am kind of runing out of ideas on how to fix this and would really appreciate any help. Thank you very much in advance and apologise for the length of this question.


Solution

  • After on year on this problem I finally found the answer to it!

    Turns out I have actually made a big (stupid) mistake. When receiving the challenge on the iOS client, I forgot to Base64 decode it, which led ASAuthorizationPlatformPublicKeyCredentialProvider.createCredentialAssertionRequest() to Base64 encode it a second time. When receiving the rawClientDataJSON from the ASAuthorizationResult, in order to send the appropriate challenge back to the server, I was Base64 decoding it from the rawClientDataJSON to then send it in the expected format (removed the second Base64 encoding) to the server.

    This was a trick I used for the .passkeyRegistration case (as I also forgot to Base64 decode the challenge in the first place there as well), which happened to work as there is no signature produced from the clientDataHash, hence just decoding the challenge afterwards was enough. But for the .passkeyAssertion case, as the signature is produced from the clientDataHash, this obviously doesn't work anymore. When finding this previous "solution", I thought this was just some Base64/Base64URL conversion mistake, but it was actually much more concerning and it led me straight into this error.

    I guess what could have saved me from this mistake is if both createCredentialAssertionRequest() and createCredentialRegistrationRequest() were doing a type check on the challenge received, to make sure it is of type BufferSource (and not Base64URL encoded) as both navigator.credentials.get() and .create() do and return a type error in case of such mistake.

    Hence in order to fix this behavior, one should do:

    let challenge = Base64URL.decode(response.challenge)
    

    Before passing it to

    ASAuthorizationPlatformPublicKeyCredentialProvider().createCredentialAssertionRequest(challenge: challenge)
    

    I hope this can help if someone ever runs into the same issue as I did.