Skip to content

Instantly share code, notes, and snippets.

@philholden
Last active October 17, 2024 21:56
Show Gist options
  • 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)
@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

[EDIT: updated the link below, from a fork to the original project, which is more active compared to the previously linked fork -- and the original project is adding ESM distribution support]

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) https://www.npmjs.com/package/@root/asn1 it's only 4.2k minified (and gzips to ~1.6k), 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

@SpeedyCraftah
Copy link

SpeedyCraftah commented Jul 14, 2024

Have you guys checked this webauthn' verification
but it says:

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

Late reply but in case someone stumbles on this: the example you found is pseudocode!

Example: Verifying the assertion signature on the server (pseudo-code)

It won't actually work as this syntax is just an example, you'll have to implement a similar solution to the above that was talked about.

@SpeedyCraftah
Copy link

Also want to thank everyone in addition for this thread, I was slowly going insane trying to figure this out.
Now that I think of it, this is quite a steep curve if you don't want to use a library that does this for you, but I'd prefer to do it myself.

@tjconcept
Copy link

tjconcept commented Oct 10, 2024

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]);

Maybe not that easy 😅

I recently encountered a signature which did not correctly "unwrap" using this function. I was able to unwrap it with the code from https://www.criipto.com/blog/webauthn-ecdsa-signature though.

For reproducing:

// note Base64 URL encoding
const c = {
  id: 'dcYrMAXFosJ2vNyjsjlCKjL_lSk',
  rawId: 'dcYrMAXFosJ2vNyjsjlCKjL_lSk',
  response: {
    authenticatorData: 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA',
    clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiUWwzVzJuVkxWTlFNNEd3VFJ0U084QSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCJ9',
    signature: 'MEMCHwZ-ACr2CSpvtWwBE8nMdO_rvK5iV-VCWx0R9QGX3TICIFRXY-hO2J4w52BsIZ0aJKROxyOICPitFP8IgxuAvoYm',
    userHandle: 'ZBSnLGMxtip1ZNiryx-I0Q',
  },
  authenticatorAttachment: 'platform',
  clientExtensionResults: {},
  type: 'public-key',
}
// note plain Base64 encoding
const pk = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJX4iup4PhjtjZYjAo44MYoG879m4+foQSsGMv/qqavFPusUdOilEXVWyFlaJQ6Cd0znXdZoXDrCdBkOHmZE/6A=='

@getify
Copy link

getify commented Oct 10, 2024

@tjconcept

I had definitely been worried about corner cases "out in the wild", like the one you report.

Just curious if you might try parsing that signature using the ASN.1 parser I linked to in this comment?

https://gist.github.com/philholden/50120652bfe0498958fd5926694ba354?permalink_comment_id=4979478#gistcomment-4979478

@tjconcept
Copy link

tjconcept commented Oct 10, 2024

Just curious if you might try parsing that signature using the ASN.1 parser I linked to in this comment?

Parsing the signature with that library works correctly and identically to readAsn1IntegerSequence, however, your parseSignature function does not deal with two's complement and padding the two integers before concatenation.

Specifically, you need to add:

let r = der.children[0].value
let s = der.children[1].value

// R and S length is assumed multiple of 128bit.
// If leading is 0 and modulo of length is 1 byte then
// leading 0 is for two's complement and will be removed.
if (r[0] === 0 && r.byteLength % 16 == 1) {
  r = r.slice(1)
}
if (s[0] === 0 && s.byteLength % 16 == 1) {
  s = s.slice(1)
}

// R and S length is assumed multiple of 128bit.
// If missing a byte then it will be padded by 0.
if (r.byteLength % 16 == 15) {
  r = concat([new Uint8Array([0]), r])
}
if (s.byteLength % 16 == 15) {
  s = concat([new Uint8Array([0]), s])
}

return concat([r, s])

(code from: https://www.criipto.com/blog/webauthn-ecdsa-signature)

@tjconcept
Copy link

tjconcept commented Oct 10, 2024

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

@dagnelies , just reading the source, I think your library might have inherited the same bug.

@dagnelies
Copy link

dagnelies commented Oct 11, 2024

@tjconcept thanks for notifying me. I'll update it right away.

Edit: ...hmm ...maybe I'll pull the dependency you mentionned here. It was quite nice to be dependency free but all this low-level byte (un)wrapping is starting to get tedious.

Edit2: I'll rather look for another lib. This one cannot be tree-shaken and uses quite dusty stuff. I'll have a look around.

@getify
Copy link

getify commented Oct 11, 2024

@dagnelies

FWIW, the ASN.1 library I linked gzips to 1.6kb. Compared to the megabytes of JS (and gigabytes of images, fonts, and CSS) we ship in the modern web, I don't even remotely worry about a 1.6kb library that's purpose built for a specialized domain (like parsing a non-trivial file format).

I do wish it was ESM format (and thus tree shakable), and they're actually planning to release an update soon for that exact purpose.

I looked around quite a bit, and found other ASN.1 parsers, that were either bigger, slower, or had unsuitable licenses. I also chose that one because it was small, single file, zero dependency, and had a permissive license.

@dagnelies
Copy link

The one you linked is a project with 0 stars, the last update 4 years ago, that has 2 dependencies, that it basically only wraps, has no ES module, a "MPL" License... I don't find it ideal.

@getify
Copy link

getify commented Oct 11, 2024

@dagnelies

Sorry for the confusion. You're right, the "yoursunny" fork I linked to is not active, but the "therootcompany" original here is still active, and is the one that's likely adding ESM support soon.

I had switched back to that original some time ago, but had forgotten the link here was still pointing to that fork. Updated the link now.

project with 0 stars

I don't care so much about github stars, but the original has ~11k downloads per week on npm, so that's not nothing.

that has 2 dependencies

FWIW, the "2 dependencies" are internal project dependencies, not external dependencies. This matters to me.

Moreover, I don't use complex bundler build processes in my own projects, so it was ideal to me that this library comes with a single distributable file, instead of having dozens of ESM module files to wrangle.

a "MPL" License

AFAICT, the only substantial difference between MPL (copy-left) and MIT is MPL doesn't permit re-licensing to non-MPL. That didn't bother me, because it's not like GPL -- it doesn't infect the rest of my project, so I just make sure the license notice stays included (even with dependency minification/concatenation), and that's it.

I don't find it ideal.

Neither do I. But I didn't find other more ideal options. If you do, I'd definitely appreciate the links. :)

@tjconcept
Copy link

As the ASN.1 DER encoded algorithms are few and will be replaced eventually, I’ll keep “hacking” it to save that dependency.
I’ll eventually publish a library with small webauthn related helpers (possibly individual ones). It’s at least the to/from JSON ponyfills, parsing the authentication bits and this unwrapping.

Does anyone have actual examples of assertations/attestations and respective public keys for other algorithms than -7? I’m particularly interested in a -8 fixture.

@getify
Copy link

getify commented Oct 15, 2024

@tjconcept

IIUC, is this helpful? I used a virtual-authenticator in Chrome (plus my webauthn-local-client library) to generate this info.


Attestation

origin: http://localhost:8080
rp.id: localhost
user.id (base64): RUdXZWU5dDlna3RvL1E9PQ==
public-key COSE: -8
public-key OID: 2b6570
public-key (base64): Zj4Jsg2QAVqGXZxIjwgYa61CBPE+aKiV8T2YtQLzJBE=
attestation-object (base64): o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViBSZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NFAAAAAQECAwQFBgcIAQIDBAUGBwgAIMfDlCtULLYymHeov2p8ykNyZx4YzgpNxPrTwk0yF7/IpAEBAycgBiFYIGY+CbINkAFahl2cSI8IGGutQgTxPmiolfE9mLUC8yQR
client-data-json (base64): eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoicnplalpydmJ1WTJUbE1zV0g4Q2x2WW1BUjRNIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ==

Assertion

origin: http://localhost:8080
rpId: localhost
challenge (base64): Q5Q9zaVkPyGMXe1SULLvGZhQF4w=
signature (base64): 9AXVFr7Ap3Xy+QsPArglgeOKUijfj8d2YBbzACrdQ9QwrU6vGfXsMEaJ6TA9WhyCsyInxA4UdKBMXIj8J0h3Ag==
authenticator-data (base64): SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAg==
client-data-json (base64): eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiUTVROXphVmtQeUdNWGUxU1VMTHZHWmhRRjR3Iiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ==

@tjconcept
Copy link

Yes! Thanks a lot. I got it working.

Is this the raw output of .create/get? I'm surprised the public key is in raw format.

I wasn't able to generate anything but -7 keys in Chrome... Did you configure it somehow or did it just create one when adding the algorithm to pubKeyCredParams?

@getify
Copy link

getify commented Oct 16, 2024

@tjconcept

Is this the raw output of .create/get?

Some of it is raw, some of it was processed by logic in my webauthn-local-client library, including some parsing, and the base64 encoding (for ease of output above).

I'm surprised the public key is in raw format.

Sorry for the confusion, no it doesn't come back raw.

It always comes back in SPKI format. My library uses this function for parsing the SPKI format out into the raw public-key and its algo's OID.

I should have included the SPKI, sorry. If you need that, I can re-run a new test to get those values. Or maybe someone clever can just re-pack the key and COSE I provided back into an equivalent SPKI. ;-)

Did you configure it somehow

The only "configuration" I did was in setting up the virtual-authenticator like this:

virtual-authenticator

did it just create one when adding the algorithm to pubKeyCredParams?

Yeah, the library passes in four requested algorithms, in preference order of -8 (ed25519), -7 (es256), -37 (rsassa-pss), and -257 (rsassa-pkcs1-v1_5). So basically, the pubKeyCredParams passed to create() is this array:

[
   { type: "public-key", alg: -8 },
   { type: "public-key", alg: -7 },
   { type: "public-key", alg: -37 },
   { type: "public-key", alg: -257 }
]

@tjconcept
Copy link

tjconcept commented Oct 17, 2024

Interesting. What's your OS? I'm on MacOS, and can only produce -7 keys, regardless of the flagged support.

Ideally, I could use the output of toJSON (or similar ponyfill) from both and attestation and an assertion using each algo 😇
I'll then use it as a test fixture for both my ponyfills of toJSON, related fromJSON functions, and verify-functions, in a webauthn stand-alone low-level helpers library (to be published).

@getify
Copy link

getify commented Oct 17, 2024

@tjconcept I'm on windows 11. I'm surprised that OS would change how Chrome's virtual authenticator works, but I suppose it's possible.

In any case, if you'd like to provide a snippet of code (standalone, whatever) that I can run for you, and send you results, I'm happy to help with that.

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