-
-
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: "[email protected]", | |
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) |
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.
@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.
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.
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.
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. :)
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.
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==
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
?
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:
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 }
]
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).
@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.
It wasn't the OS after all, sorry. I'm not sure why, but I now managed to produce the same through Chrome's virtual authenticator. Thanks anyway 🙂
I've put everything into a couple of highly-specific low-level libraries as well as a brief demo (without all the irrelevant boilerplate and cruft that the other ones I found carry).
I hope they can be helpful to others too. Any feedback is appreciated.
https://github.com/tjconcept/webauthn-tools
https://github.com/tjconcept/webauthn-json
https://github.com/tjconcept/webauthn-demo
Thanks a lot for sharing this gist – it perfectly illustrates the “crypto.subtle.verify
always false” issue we‘ve been chasing these weeks.
We finally traced the root cause to the signature encoding accepted by WebCrypto:
- WebAuthn authenticators return an ECDSA-P256 DER signature.
- Chromium / WebKit’s
crypto.subtle.verify
silently applies extra DER-level checks (low-S, no leading 0x00, etc.). - If any check fails,
verify
returnsfalse
, even though the signature is mathematically correct.
The workaround is surprisingly simple: feed raw (r‖s, 64 bytes) instead of DER – all browsers accept it and the verification passes.
import { p256 } from '@noble/curves/p256';
// message = authenticatorData || SHA-256(clientDataJSON)
const key = await crypto.subtle.importKey(
'raw', // 0x04 | X | Y (65 B)
uncompressedPubKey,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify'],
);
const sigRaw = p256.Signature.fromDER(sigDER).toCompactRawBytes(); // DER → raw (64 B)
const ok = await crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
sigRaw, // ← raw, **not** DER
message,
);
console.log('WebCrypto.verify(raw) =>', ok); // true
A minimal repro (HTML + JS) and a full write-up (public-key & signature formats, crypto.subtle quirks, debug checklist) are here:
- demo page: https://github.com/nuwa-protocol/nuwa/blob/4f788f6b784cbc57ae6f4465751dd1bb26e8b90a/nuwa-services/cadop-service/typescript/packages/cadop-web/docs/webauthn_debug.html
- doc: https://github.com/nuwa-protocol/nuwa/blob/4f788f6b784cbc57ae6f4465751dd1bb26e8b90a/nuwa-services/cadop-service/typescript/packages/cadop-web/docs/webauthn_debug.md
Hope this saves others a few hours of hair-pulling.
If anyone hits the same problem on other curves (e.g., Ed25519, RSASSA-PSS) please ping me – happy to compare notes.
@jolestar Pay attention, how you encode the signature must depend on the algorithm! (crazy huh!) I'd like to post an old reply of mine:
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.
@dagnelies Thank you
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. ;)
Unlike ECDSA, EdDSA defines a single canonical signature encoding (which consists of the concatenation of two fixed-length byte strings), and symmetrically the EdDSA verification procedure accepts only this encoding. So in EdDSA there is no ambiguity in how signatures are represented.
I'm having issues supporting Passkeys created by Dashlane. I hope someone here is able to crack this one.
publicKeyAlgorithm: -7
transports: [internal, hybrid]
authenticatorAttachment: platform
type: public-key
crypto.subtle.importKey(
'spki',
// base64url(attestation.response.getPublicKey())
decodeBase64Url('pQECAyYgASFYIKWn5SwwD4LmJy3JHe0f396dpUyLo1RYu73ByigzTmViIlggB_TICGayY6pKSA322an0cYMK3_oaGX9p6_6ENG0p9j8'),
{name: 'ECDSA', namedCurve: 'P-256'},
false,
['verify'],
)
// ASN.1 error: unexpected ASN.1 DER tag: expected SEQUENCE, got CONTEXT-SPECIFIC [5] (constructed)
When decoding the attestation object and comparing the raw key bits with those of .getPublicKey()
, they are identical. I assume it is because both are COSE-encoded (CBOR).
Am I correct that this is apparently a bug in either Dashlane or Chrome as this property should be DER-encoded?
Given this, which is unlikely to change anytime soon, what's the best way to work around it? Is the AAGUID a good way to identify Dashlane? Would it be problematic to simply "cut and paste" the x and y components from the bytes of the COSE key onto a static SPKI header (also naively assuming same key size)?
Parsing the signature with that library works correctly and identically to
readAsn1IntegerSequence
, however, yourparseSignature
function does not deal with two's complement and padding the two integers before concatenation.Specifically, you need to add:
(code from: https://www.criipto.com/blog/webauthn-ecdsa-signature)