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

465 views Asked by At

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.

0

There are 0 answers