Skip to content

Instantly share code, notes, and snippets.

@philholden
Last active March 21, 2024 05:49
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save philholden/50120652bfe0498958fd5926694ba354 to your computer and use it in GitHub Desktop.
Save philholden/50120652bfe0498958fd5926694ba354 to your computer and use it in GitHub Desktop.
// paste in console of any https site to run (e.g. this page)
// sample arguments for registration
// https://fidoalliance.org/specs/fido-u2f-v1.1-id-20160915/fido-u2f-raw-message-formats-v1.1-id-20160915.html#authentication-response-message-success
var createCredentialDefaultArgs = {
publicKey: {
// Relying Party (a.k.a. - Service):
rp: {
name: "Acme"
},
// User:
user: {
id: new Uint8Array(16),
name: "john.p.smith@example.com",
displayName: "John P. Smith"
},
pubKeyCredParams: [{
type: "public-key",
alg: -7
}],
attestation: "direct",
timeout: 60000,
challenge: new Uint8Array([ // must be a cryptographically random number sent from a server
0x8C, 0x0A, 0x26, 0xFF, 0x22, 0x91, 0xC1, 0xE9, 0xB9, 0x4E, 0x2E, 0x17, 0x1A, 0x98, 0x6A, 0x73,
0x71, 0x9D, 0x43, 0x48, 0xD5, 0xA7, 0x6A, 0x15, 0x7E, 0x38, 0x94, 0x52, 0x77, 0x97, 0x0F, 0xEF
]).buffer
}
};
// sample arguments for login
var getCredentialDefaultArgs = {
publicKey: {
timeout: 60000,
// allowCredentials: [newCredential] // see below
challenge: new Uint8Array([ // must be a cryptographically random number sent from a server
0x79, 0x50, 0x68, 0x71, 0xDA, 0xEE, 0xEE, 0xB9, 0x94, 0xC3, 0xC2, 0x15, 0x67, 0x65, 0x26, 0x22,
0xE3, 0xF3, 0xAB, 0x3B, 0x78, 0x2E, 0xD5, 0x6F, 0x81, 0x26, 0xE2, 0xA6, 0x01, 0x7D, 0x74, 0x50
]).buffer
},
};
// register / create a new credential
var cred = await navigator.credentials.create(createCredentialDefaultArgs)
console.log("NEW CREDENTIAL", cred);
// normally the credential IDs available for an account would come from a server
// but we can just copy them from above...
var idList = [{
id: cred.rawId,
transports: ["usb", "nfc", "ble"],
type: "public-key"
}];
getCredentialDefaultArgs.publicKey.allowCredentials = idList;
var assertation = await navigator.credentials.get(getCredentialDefaultArgs);
console.log("ASSERTION", assertation);
// verify signature on server
var signature = await assertation.response.signature;
console.log("SIGNATURE", signature)
var clientDataJSON = await assertation.response.clientDataJSON;
console.log("clientDataJSON", clientDataJSON)
var authenticatorData = new Uint8Array(await assertation.response.authenticatorData);
console.log("authenticatorData", authenticatorData)
var clientDataHash = new Uint8Array(await crypto.subtle.digest("SHA-256", clientDataJSON));
console.log("clientDataHash", clientDataHash)
// concat authenticatorData and clientDataHash
var signedData = new Uint8Array(authenticatorData.length + clientDataHash.length);
signedData.set(authenticatorData);
signedData.set(clientDataHash, authenticatorData.length);
console.log("signedData", signedData)
// import key
var key = await crypto.subtle.importKey(
// The getPublicKey() operation thus returns the credential public key as a SubjectPublicKeyInfo. See:
//
// https://w3c.github.io/webauthn/#sctn-public-key-easy
//
// crypto.subtle can import the spki format:
//
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey
"spki", // "spki" Simple Public Key Infrastructure rfc2692
cred.response.getPublicKey(),
{
// these are the algorithm options
// await cred.response.getPublicKeyAlgorithm() // returns -7
// -7 is ES256 with P-256 // search -7 in https://w3c.github.io/webauthn
// the W3C webcrypto docs:
//
// https://www.w3.org/TR/WebCryptoAPI/#informative-references (scroll down a bit)
//
// ES256 corrisponds with the following AlgorithmIdentifier:
name: "ECDSA",
namedCurve: "P-256",
hash: { name: "SHA-256" }
},
false, //whether the key is extractable (i.e. can be used in exportKey)
["verify"] //"verify" for public key import, "sign" for private key imports
);
// check signature with public key and signed data
var verified = await crypto.subtle.verify(
{ name: "ECDSA", namedCurve: "P-256", hash: { name: "SHA-256" } },
key,
signature,
signedData.buffer
);
// verified is false I want it to be true
console.log('verified', verified)
@emlun
Copy link

emlun commented May 16, 2021

I solved it. It's the signature format.

What is interesting is if I generate a key pair with the above settings and then sign some data the signature is always 64 bytes where as the signature from the authenticator is 70 bytes:

Well spotted - this was the key! It seems like SubtleCrypto emits and expects signatures in "raw" format - in the case of ECDSA signatures, that means just the r and s parts of the signature as two simply concatenated bare 32-byte integers. FIDO authenticators, however, emit signatures in an ASN.1 encoded format: an ASN.1 sequence containing two ASN.1 integers, which incurs between 6 and 8 bytes of overhead (depending on leading zeroes). You can use this function to hex-encode byte arrays and put them into this online ASN.1 decoder to see for yourself:

function btoh(bytes /* Uint8Array */) {
  return [...bytes].map(b => b.toString(16).padStart(2, "0")).join("");
}

For example, try it with this signature value:

// 3045022100e714b457530bd6698902ad9b82d5ef1fbc517e561e83506d86d567657de8b78002207cdf875cce11e6b446551d91203e842ad57c77c82105453c93f5d614b4a5ff02
new Uint8Array([48, 69, 2, 33, 0, 231, 20, 180, 87, 83, 11, 214, 105, 137, 2, 173, 155, 130, 213, 239, 31, 188, 81, 126, 86, 30, 131, 80, 109, 134, 213, 103, 101, 125, 232, 183, 128, 2, 32, 124, 223, 135, 92, 206, 17, 230, 180, 70, 85, 29, 145, 32, 62, 132, 42, 213, 124, 119, 200, 33, 5, 69, 60, 147, 245, 214, 20, 180, 165, 255, 2])

It's easy enough to "unpack" special cases like this and pack them into the "raw" form. The main pitfall here is that a leading zero is added if the most significant bit happens to be 1:

var usignature = new Uint8Array(signature);
var rStart = usignature[4] === 0 ? 5 : 4;
var rEnd = rStart + 32;
var sStart = usignature[rEnd + 2] === 0 ? rEnd + 3 : rEnd + 2;
var r = usignature.slice(rStart, rEnd);
var s = usignature.slice(sStart);
var rawSignature = new Uint8Array([...r, ...s]);

So if we expand the original example with this, we get:

// paste in console of any https site to run (e.g. this page)
// sample arguments for registration
// https://fidoalliance.org/specs/fido-u2f-v1.1-id-20160915/fido-u2f-raw-message-formats-v1.1-id-20160915.html#authentication-response-message-success

var createCredentialDefaultArgs = {
    publicKey: {
        // Relying Party (a.k.a. - Service):
        rp: {
            name: "Acme"
        },

        // User:
        user: {
            id: new Uint8Array(16),
            name: "john.p.smith@example.com",
            displayName: "John P. Smith"
        },

        pubKeyCredParams: [{
            type: "public-key",
            alg: -7
        }],

        attestation: "direct",

        timeout: 60000,

        challenge: new Uint8Array([ // must be a cryptographically random number sent from a server
            0x8C, 0x0A, 0x26, 0xFF, 0x22, 0x91, 0xC1, 0xE9, 0xB9, 0x4E, 0x2E, 0x17, 0x1A, 0x98, 0x6A, 0x73,
            0x71, 0x9D, 0x43, 0x48, 0xD5, 0xA7, 0x6A, 0x15, 0x7E, 0x38, 0x94, 0x52, 0x77, 0x97, 0x0F, 0xEF
        ]).buffer
    }
};

// sample arguments for login
var getCredentialDefaultArgs = {
    publicKey: {
        timeout: 60000,
        // allowCredentials: [newCredential] // see below
        challenge: new Uint8Array([ // must be a cryptographically random number sent from a server
            0x79, 0x50, 0x68, 0x71, 0xDA, 0xEE, 0xEE, 0xB9, 0x94, 0xC3, 0xC2, 0x15, 0x67, 0x65, 0x26, 0x22,
            0xE3, 0xF3, 0xAB, 0x3B, 0x78, 0x2E, 0xD5, 0x6F, 0x81, 0x26, 0xE2, 0xA6, 0x01, 0x7D, 0x74, 0x50
        ]).buffer
    },
};

// register / create a new credential
var cred = await navigator.credentials.create(createCredentialDefaultArgs)
console.log("NEW CREDENTIAL", cred);

// normally the credential IDs available for an account would come from a server
// but we can just copy them from above...
var idList = [{
    id: cred.rawId,
    transports: ["usb", "nfc", "ble"],
    type: "public-key"
}];
getCredentialDefaultArgs.publicKey.allowCredentials = idList;

var assertation = await navigator.credentials.get(getCredentialDefaultArgs);
console.log("ASSERTION", assertation);

// verify signature on server
var signature = await assertation.response.signature;
console.log("SIGNATURE", signature)

var clientDataJSON = await assertation.response.clientDataJSON;
console.log("clientDataJSON", clientDataJSON)

var authenticatorData = new Uint8Array(await assertation.response.authenticatorData);
console.log("authenticatorData", authenticatorData)

var clientDataHash = new Uint8Array(await crypto.subtle.digest("SHA-256", clientDataJSON));
console.log("clientDataHash", clientDataHash)

// concat authenticatorData and clientDataHash
var signedData = new Uint8Array(authenticatorData.length + clientDataHash.length);
signedData.set(authenticatorData);
signedData.set(clientDataHash, authenticatorData.length);
console.log("signedData", signedData)

// import key
var key = await crypto.subtle.importKey(
// The getPublicKey() operation thus returns the credential public key as a SubjectPublicKeyInfo. See:
// 
// https://w3c.github.io/webauthn/#sctn-public-key-easy
//
// crypto.subtle can import the spki format:
// 
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey
  "spki", // "spki" Simple Public Key Infrastructure rfc2692

  cred.response.getPublicKey(),
  {
    // these are the algorithm options
    // await cred.response.getPublicKeyAlgorithm() // returns -7
    // -7 is ES256 with P-256 // search -7 in https://w3c.github.io/webauthn
    // the W3C webcrypto docs:
    //
    // https://www.w3.org/TR/WebCryptoAPI/#informative-references (scroll down a bit)
    //
    // ES256 corrisponds with the following AlgorithmIdentifier:
    name: "ECDSA",
    namedCurve: "P-256",
    hash: { name: "SHA-256" }
  },
  false, //whether the key is extractable (i.e. can be used in exportKey)
  ["verify"] //"verify" for public key import, "sign" for private key imports
);

// Convert signature from ASN.1 sequence to "raw" format
var usignature = new Uint8Array(signature);
var rStart = usignature[4] === 0 ? 5 : 4;
var rEnd = rStart + 32;
var sStart = usignature[rEnd + 2] === 0 ? rEnd + 3 : rEnd + 2;
var r = usignature.slice(rStart, rEnd);
var s = usignature.slice(sStart);
var rawSignature = new Uint8Array([...r, ...s]);

// check signature with public key and signed data 
var verified = await crypto.subtle.verify(
  { name: "ECDSA", namedCurve: "P-256", hash: { name: "SHA-256" } },
  key,
  rawSignature,
  signedData.buffer
);
// verified is now true!
console.log('verified', verified)

which has successfully verified every time I tried it. But you should probably get a real ASN.1 decoder if you need to actually do transformations like this.

@philholden
Copy link
Author

Amazing thanks so much! It was huge puzzle and I could not stop thinking about it. So great to see:

verified true

I was beginning to think I never would.

Passwordless auth should be prevalent by now but fells like we turned up to the party a few hours early. I was surprised to see my Galaxy S9 can do Webauthn using the touch sensor for test of presence. This makes my me think a large number of people without a Yubikey could login to a desktop site by scanning a QR with the challenge.

Look forward to playing with the UX for this.

Thanks again for the help and your dedication.

@rithvikvibhu
Copy link

Oh my god I've spent hours on this finally found this gist, reading this and "I have the exact same problem!" till the end.
The last bit about raw signatures worked for me too, just wanted to say really appreciate the time taken to figure this out. Thanks @philholden, @emlun and @MasterKale!

btw, looking this up, also found https://crypto.stackexchange.com/questions/1795/how-can-i-convert-a-der-ecdsa-signature-to-asn-1 explaining the same what you discussed.

@enricobottazzi
Copy link

Thanks guys! Amazing discussion and help are provided here. I felt your pain reading through it :)

@nikosft
Copy link

nikosft commented Nov 16, 2022

@emlun You are a hero. @philholden Thanks for pushing this forward.

@dagnelies
Copy link

dagnelies commented Nov 23, 2022

🎉 Poah, that was a tough nut to crack!

Moreover, I would like to emphasise that whether the signature is ASN.1 wrapped or not depends on the algorithm used according to the spec https://w3c.github.io/webauthn/#sctn-signature-attestation-types

6.5.6. Signature Formats for Packed Attestation, FIDO U2F Attestation, and Assertion Signatures

[...] For COSEAlgorithmIdentifier -7 (ES256) [...] the sig value MUST be encoded as an ASN.1 [...]
[...] For COSEAlgorithmIdentifier -257 (RS256) [...] The signature is not ASN.1 wrapped.
[...] For COSEAlgorithmIdentifier -37 (PS256) [...] The signature is not ASN.1 wrapped.

What about the -8 algo that is also recommended? ASN.1 wrapped or not? I guess that's simply missing in the specs right now. ;)

@emlun
Copy link

emlun commented Nov 23, 2022 via email

@dagnelies
Copy link

Sure @emlun , here you go: w3c/webauthn#1829

@dagnelies
Copy link

And one more thing, I made a tool to verify / validate webauthn signatures: https://webauthn.passwordless.id/demos/playground.html
Just scroll at the bottom and you can input your base64 encoded raw values. Maybe this is helpful for someone.

@mmv08
Copy link

mmv08 commented Jan 10, 2024

Thanks! It took me ages to figure out how to decode the public key from getPublicKey(). You're truly a legend.

@sarvagnakadiya
Copy link

Big shoutout to @philholden for bringing up the topic! 🙌
And a massive thanks to @emlun for cracking the code like a boss! 🚀
You guys rock!

@sarvagnakadiya
Copy link

sarvagnakadiya commented Jan 31, 2024

Have you guys checked this webauthn' verification

const signatureIsValid = storedCredential.publicKey.verify(
    signature, signedData);

I tried it, but it was saying verify function not found.

if you guys know how to use this function properly will be a great help!

I tried like this

//setting up public key onto useState after creating
 const credential = await navigator.credentials.create({
        publicKey: publicKeyCredentialCreationOptions,
 });
setPubKeyObj(credential);

and now verifying:

try {
      const assertion = await navigator.credentials.get({
        publicKey: {
          challenge: Uint8Array.from("randomStringFromServer", (c) =>
            c.charCodeAt(0)
          ),
          rpId: "localhost",
        },
        mediation: "optional",
      });

      console.log(assertion);

      console.log("authenticator:", assertion.response.authenticatorData);
      console.log("client:", assertion.response.clientDataJSON);

      const authenticatorDataBytes = assertion.response.authenticatorData;

      var hashedClientDataJSON = new Uint8Array(
        await crypto.subtle.digest("SHA-256", assertion.response.clientDataJSON)
      );
      console.log("clientDataHash", hashedClientDataJSON);

      const signedData = authenticatorDataBytes + hashedClientDataJSON;

      const signatureIsValid = pubKeyObj.verify(
        assertion.response.signature,
        signedData
      );

      console.log(signatureIsValid);

      if (signatureIsValid) {
        return "Hooray! User is authenticated! 🎉";
      } else {
        return "Verification failed. 😭";
      }
    } catch (error) {
      console.error("Error during login:", error);
      return "Error during login.";
    } 

but it says:

Error during login: TypeError: pubKeyObj.verify is not a function
    at signIn

@getify
Copy link

getify commented Mar 9, 2024

Thank you for this thread! Was the only way I found to solve the problem I was facing!

I was just a bit nervous about the lower-level code and wanted a bit more assurances from using a quality/compliant ASN.1 parser.

I just wanted to report that I found a reasonably sized, and capable for this task, ASN.1 parser lib (in JS): https://www.npmjs.com/package/@yoursunny/asn1 It's only 7k minified (and gzips to ~2.5k), and comes in UMD/CJS format so it will work in a lot of environments including even somewhat older browsers and older Node instances.

So instead of the above code (adapted into):

function parseSignature(sig) {
   var usignature = new Uint8Array(sig);
   var rStart = usignature[4] === 0 ? 5 : 4;
   var rEnd = rStart + 32;
   var sStart = usignature[rEnd + 2] === 0 ? rEnd + 3 : rEnd + 2;
   var r = usignature.slice(rStart, rEnd);
   var s = usignature.slice(sStart);
   return new Uint8Array([...r, ...s]);
}

I was able to use the library like this:

function parseSignature(sig) {
   var der = ASN1.parseVerbose(new Uint8Array(sig));
   return new Uint8Array([ ...der.children[0].value, ...der.children[1].value, ]);
}

Perhaps that helps others who land here.

@philholden
Copy link
Author

philholden commented Mar 9, 2024 via email

@dagnelies
Copy link

@getify Pay attention that depending on the algorithm used by the credential, the signature may or may not be ASN.1 wrapped!

Moreover, I would like to emphasise that whether the signature is ASN.1 wrapped or not depends on the algorithm used according to the spec https://w3c.github.io/webauthn/#sctn-signature-attestation-types

6.5.6. Signature Formats for Packed Attestation, FIDO U2F Attestation, and Assertion Signatures
[...] For COSEAlgorithmIdentifier -7 (ES256) [...] the sig value MUST be encoded as an ASN.1 [...]
[...] For COSEAlgorithmIdentifier -257 (RS256) [...] The signature is not ASN.1 wrapped.
[...] For COSEAlgorithmIdentifier -37 (PS256) [...] The signature is not ASN.1 wrapped.

What about the -8 algo that is also recommended? ASN.1 wrapped or not? I guess that's simply missing in the specs right now. ;)

I recommend using a lib like mine to avoid such issues: https://webauthn.passwordless.id/demos/playground.html , you can also verify signatures there.

@getify
Copy link

getify commented Mar 10, 2024

the signature may or may not be ASN.1 wrapped!

yep... that's true. the spec basically says that -7 (ECDSA P-256) is the only one that's wrapped, and it says that other algorithms "should not" be wrapped. so I only call that function on that specific algorithm.

@getify
Copy link

getify commented Mar 12, 2024

@dagnelies

I recommend using a lib like mine

Your lib is great, and quite comprehensive. I've been working on a smaller lib for a more narrow purpose (and I link to yours as an alternative option):

https://github.com/mylofi/webauthn-local-client

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment