Last active
March 14, 2024 15:27
-
-
Save nsfmc/d74993d49126fdfc5f8c51a012126675 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Rolling your own passkey auth in js, both on the web and in node. | |
// passkeys are pretty well supported in the browser, but there's a lot | |
// of simple crypto and bit twiddling that you need to do. it all adds up | |
// to a lot, but it's all quite straightforward in nature. | |
// this can be a good resource to pair with imperialviolet's passkey article. | |
// except for browser js/node js instead of typescript/python. | |
// Some utility methods here to convert from b64 encoded strings to Uint8Arrays and back | |
const atou8 = (b64ascii) => | |
Uint8Array.from(atob(b64ascii), (c) => c.charCodeAt(0)); | |
const u8toa = (u8arr) => | |
btoa(Array.from(u8arr, (c) => String.fromCharCode(c)).join('')); | |
/* | |
// in node, the recommended way of writing the above utilities is | |
function atou8(b64ascii) { | |
return Uint8Array.from(Buffer.from(b64ascii, 'base64')); | |
} | |
function u8toa(u8arr) { | |
return Buffer.from(u8arr).toString('base64'); | |
} | |
*/ | |
// authData here is a Uint8Array | |
/* | |
* authData is a binaryish data structure you get back from the browser when calling | |
* navigator.credentials.create(createOptions). This call returns a credential object | |
* with two methods: getCredentialData() and getPublicKey() | |
* Being able to parse this gives you the | |
* ability to determine information about the passkey being generated, namely | |
* - is the auth data synced (i.e. might it be safe to suggest disabling passwords) | |
* - what is the encryption being used for the credential (i.e. does it match what you expect) | |
* - the credential id (effectively a uid in your passkey table) | |
* - the credential public key data (in COSE_Key format, redundant because we have it above ^^) | |
*/ | |
function parseAuthData(authData) { | |
// see https://w3c.github.io/webauthn/#sctn-authenticator-data | |
const rpiHash = new Uint8Array(authData.buffer, 0, 32); | |
const flags = authData[32]; | |
const [ | |
userPresent, | |
rfu1, // reserved for future use 1 | |
userVerified, | |
backupEligible, | |
backupState, | |
rfu2, // reserved for future use 2 | |
atPresent, | |
edPresent, | |
] = [ | |
flags & 1, | |
(flags >> 1) & 1, | |
(flags >> 2) & 1, | |
(flags >> 3) & 1, | |
(flags >> 4) & 1, | |
(flags >> 5) & 1, | |
(flags >> 6) & 1, | |
(flags >> 7) & 1, | |
]; | |
// attested credential data starts at offset 33 | |
// https://w3c.github.io/webauthn/#sctn-attested-credential-data | |
const signCount = new DataView( | |
new Uint8Array(authData.buffer, 33, 4).buffer, | |
).getUint32(); | |
// the attested credential data follows, its length is variable, so | |
// credentialId depends on the fixed credentialIdLength slice. | |
// for the purposes of this function, we don't care about the public key | |
// because it is also included in the sidecar of the initial | |
// navigator.credentials.create call | |
const aaguid = new Uint8Array(authData.buffer, 37, 16); | |
const credentialIdLength = new DataView(authData.buffer, 53, 2).getUint16(); | |
const credentialId = new Uint8Array(authData.buffer, 55, credentialIdLength); | |
const credentialIdB64 = u8toa(credentialId) | |
return { | |
userPresent, | |
userVerified, | |
backupEligible, | |
backupState, | |
atPresent, | |
edPresent, // this doesn't get parsed here, oh well | |
signCount, | |
aaguid, | |
credentialId, // Uint8Array | |
credentialIdB64, | |
}; | |
} | |
const regForm = new FormData(); | |
//now you can call in any browser that supports passkeys or whatever | |
const cred = await navigator.credentials.create(createOptions) | |
/* createOptions beyond the scope of this, see | |
https://w3c.github.io/webauthn/#sctn-sample-registration | |
or | |
https://www.imperialviolet.org/2022/09/22/passkeys.html | |
cred, however, because this is webauthn, is a PublicKeyCredential | |
you want to parse data out of its response. | |
All suggest using alg: -257 (RS256) & alg: -7 (ES256, preferred) | |
both of which advise how to parse this using java, go, or .net | |
https://w3c.github.io/webauthn/#sctn-public-key-easy | |
but in node, to parse this, you need to use subtle which requires you | |
to be running 18+. More on this below. | |
*/ | |
const authData = new Uint8Array(cred.response.getAuthenticatorData()); | |
const pubKey = new Uint8Array(cred.response.getPublicKey()); | |
/* there are lots of names for all these types of public keys, for instance | |
searching for "RS256 import node" or "ES256 import nodejs subtle" | |
is not super helpful because you need to... keep reading the spec | |
to find https://www.iana.org/assignments/cose/cose.xhtml#algorithms | |
which explains that RS256 is actually "RSASSA-PKCS1-v1_5 using SHA-256" and | |
ES256 is "ECDSA w/ SHA-256" the sha-256 here is a signature. More to follow. | |
*/ | |
// probably you want to do this to save these to your db | |
regForm.append('auth_data', u8toa(authData)); | |
regForm.append('pub_key', u8toa(pubKey)); | |
// i'm not going to tell you how to do serde, but in my experience using the | |
// combo of npm sqlite + npm sqlite3 you can absolutely do something like | |
// let parsedAuthData = parseAuthData(atou8(req.body.auth_data)); | |
// db.run(`insert (id, username, pk_spki, backed_up) values ($id, $username, $pkSpki, $bak)`, { | |
// $id: parsedAuthData.credentialId, | |
// $username: 'eva', | |
// $pkSpki: atou8(req.body.pub_key), | |
// $bak: parsedAuthData.backupState, | |
// }); | |
// because node-sqlite3 absolutely supports inserting Uint8Arrays as blobs correctly | |
// and because 1/0s are translated to bools correctly | |
// and now you can do some checks on the authData you get back and the pubkey | |
console.log(parseAuthData(authData)); | |
// some things to note here is that you want to probably notify the user if | |
// backupState == 0 or backupEligible == 0. | |
// general notes (although, honestly, the spec is reasonable here, but very terse at times) | |
// each time you make a key and intend to save it, the credentialId will be different | |
// as will the pubKey, but you need to stash the pubKey alongside the id you got when | |
// you created it, as these are related. | |
/* | |
lastly here, because the google-search juice for "how do i import an spki key in nodejs" is bad, | |
if you're doing this, recall that pubkey is either an ecdsa or RSASSA-PKCS1-v1_5 key w/ a sha-256 sig. | |
the rfc https://www.rfc-editor.org/rfc/rfc9053.html#name-ecdsa for cose suggests using p-256 for a | |
sha-256 signed key. | |
from this you can do something like | |
*/ | |
// this returns a promise that includes a webcrypto CryptoKey | |
// assuming the spki key you stashed in your db is good. | |
async function importSpki(spki_buff /* Buffer, from your db */) { | |
let key; | |
try { | |
key = crypto.subtle.importKey('spki', pk, {name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256'}, false, ['verify']) | |
} catch (err) { | |
// maybe we didn't get an ecdsa key :( | |
key = crypto.subtle.importKey('spki', pk, {name: 'RSASSA-PKCS1-v1_5', hash:'SHA-256'}, false, ['verify']) | |
} | |
if (!key) { | |
throw new Error("Couldn't import a key! oops"); | |
} | |
return key | |
} | |
/* | |
but the key isn't going to be both, you just get one, and because of the order you specified things, it's | |
most likely (at least given the state of chrome and safari) that you're getting the ecdsa key for now, in 2023. | |
Having the public key is only half the battle, however, because you will now need to use this key to _verify_ the | |
signature you get back during attestation (e.g. when somebody whacks their thumb against their keyboard) | |
when you call `await navigator.credentials.get(getOptions)` during authentication, you get back a | |
`PublicKeyCredential` that contains a `.rawId` (ArrayBuffer) and a `.response` `AuthenticatorAssertionResponse` | |
and within the response, you also get three other ArrayBuffers: `{authenticatorData, clientDataJSON, signature}`. | |
The principle operation here that concerns you however is actually connecting the `signature` with the `key` and | |
the `authenticatorData` and `clientDataJSON`. There are lots of legitimate validations you need to do using this, | |
but the principal one is "does the key i stored earlier validate the signature. | |
*/ | |
// just assume what follows, since it's all principly handled on the server, is in node | |
// some node crypto utilities here. You can use the classic crypto interface like so | |
const {createHash, createVerify} = require('node:crypto'); | |
function hash256(something) { | |
const hash = createHash('sha256'); | |
hash.update(something); | |
return hash.digest(); | |
} | |
// or you can use subtle, which is likely good enough (it's async though) | |
function h256(something) { | |
return crypto.subtle.digest('SHA-256', something); | |
} | |
/* | |
"What gets signed" is surprisingly clear, it's a | |
`Buffer.concat([authenticatorData, sha256(clientDataJSON)])` which is described in | |
https://www.w3.org/TR/webauthn-3/#fig-signature as | |
> "the concatenation authenticatorData || hash" | |
where || is not 'or' but instead the "now kiss" operator | |
let's make a dumb verify function | |
*/ | |
async function simpleVerify( | |
spki_key, // a buffer, from your db | |
signature, // a buffer, from the browser | |
authenticatorData, // a buffer, also from the browser | |
clientDataJSON, // another buffer, that's a utf8 string, also from the browser | |
) { | |
const key = await importSpki(spki_key); | |
const jsonSig = await h256(clientDataJSON) | |
const dataToVerify = Buffer.concat([authenticatorData, jsonSig]); | |
const verifier = createVerify('SHA256').update(dataToVerify); | |
return verifier.verify(key, signature); | |
} | |
/* | |
this simple verifier works because the node crypto library knows to ingest the signature | |
as a DER/ASN.1 encoded signature. Depending on how tired you are, this is either no big | |
or it feels like a lot of effort. Actually, wait, how did we know it was a DER encoded | |
signature? | |
in the spec, https://www.w3.org/TR/webauthn-3/#sctn-signature-attestation-types | |
the attestation signature structure is defined and you'll notice that | |
> For COSEAlgorithmIdentifier -7 (ES256), and other ECDSA-based algorithms, | |
> the sig value MUST be encoded as an ASN.1 DER Ecdsa-Sig-Value, as defined | |
> in [RFC3279] section 2.2.3. | |
but thankfully, the spec also includes a small diagram to sorta explain how DER ASN.1 | |
encoding kinda works. if not, this https://stackoverflow.com/a/39575576/470756 post | |
also does a good job of explaining how the data is structured. | |
The only problem is that the webcrypto spec https://www.w3.org/TR/WebCryptoAPI/#ecdsa-operations | |
says that ecdsa signatures are _not_ DER encoded, they are just the result of | |
whacking `r || s` together (see above for how || is defined). | |
why this matters is that if you naively try to use crypto.subtle.verify, even using | |
the easily decoded test vectors from that imperialviolet post, you'll notice that | |
even though the simple node verifier works, the subtle one disagrees. | |
In order to use subtle for verification, you need to parse out the DER encoded signature | |
and use the `r || s` version of the signature. this isn't the best code for this | |
but it more makes the point of how you extract this from a rudimentary knowledge of | |
how asn.1/der works | |
*/ | |
// return a subtle-compatible verify signature, in node | |
function parseDerSignature(buff) { | |
// DER ASN.1 keys begin with 0x30 or "ME" in base64 | |
if (buff[0] !== 48) { | |
// probably the correct kind already! | |
return buff; | |
} else { | |
let totalLen = buff[1]; | |
if (buff.length != totalLen + 2) { | |
throw new Error( | |
`bad der encoded signature, expected len: ${totalLen + 2} but got ${ | |
buff.length | |
}`, | |
); | |
} | |
let parts = []; | |
for (let i = 2; i <= totalLen; i += 1) { | |
let curr = buff[i]; | |
// '2' means "new integer" | |
if (curr === 2) { | |
// if the leftmost bit is set, the segment length is | |
// defined using 'length octets' | |
let segmentLength; | |
if (((buff[i + 1] >> 7) & 1) === 1) { | |
// this could happen, but it's not happening yet | |
// and probably not for current key lengths for a while | |
throw new Error('Need to parse indefinite r,s segments'); | |
} else { | |
segmentLength = buff[i + 1]; | |
} | |
let start = i + 2; | |
const end = i + 2 + segmentLength; | |
// trim leading 0x00s from buff by advancing the start | |
// index. these 0s cause sig validation to fail otherwise. | |
while (start < end && buff[start] === 0) { | |
start += 1; | |
} | |
const slice = new Uint8Array(buff.slice(start, end)) | |
parts.push(slice) | |
// move to next segment | |
i += segmentLength; | |
continue; | |
} | |
} | |
return Buffer.concat(parts); | |
} | |
} | |
/* | |
with this in hand, we can now use subtle to verify a signature | |
*/ | |
async function verifyAuth( | |
key, // a CryptoKey, | |
signature, // a Buffer in DER/ASN.1 format (mandatory if coming from attestation), | |
authenticatorData, // also a Buffer, | |
clientDataJson, // a Buffer, but also a utf8 string | |
) { | |
const jsonSig = await crypto.subtle.digest('SHA-256', something); | |
const data = Buffer.concat([authenticatorData, Buffer.from(jsonSig)]); | |
return crypto.subtle.verify( | |
{ // this object is an EcdsaParams object, the name and sha are reqired | |
// but the namedCurve seems to be not strictly necssary. | |
// if you want to support rsa keys, you need to check the key and adjust | |
// these params here as necessary | |
name: 'ECDSA', | |
namedCurve: 'P-256', | |
hash: 'SHA-256', | |
}, | |
key, | |
parseDerSignature(signature), | |
data, | |
); | |
} | |
/* | |
the above function generally works, but it's quite fragile, as you can see we had | |
to do our own DER parsing which is probably not super robust. even though this does | |
work for most cases on macos/ios, i'm not sure i'd recommend this, it's probably | |
better to use the standard crypto module's verifier appreach which is both shorter | |
and also takes care of parsing out everything for you. | |
still, if you're curious, like i was, why attestations are failing but only for subtle | |
or maybe failing inconsistently, i hope this clears things up. | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment