Skip to content

Instantly share code, notes, and snippets.

@nsfmc
Last active March 14, 2024 15:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nsfmc/d74993d49126fdfc5f8c51a012126675 to your computer and use it in GitHub Desktop.
Save nsfmc/d74993d49126fdfc5f8c51a012126675 to your computer and use it in GitHub Desktop.
// 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