WebAuthn For Crypto

See the Github Repo for lots of code

Passwords

Roman soldiers used watchwords to identify friend from foe. Prohibition-era speakeasys required passwords to enter. Early multi-user computer systems also required a password to log on, or sign in. But passwords are a bit like democracy, the worst form of identification, except for all the others. Passwords have a litany of well-documented shortcomings and are extremely user-hostile. But in the Year of Our Lord 2023, things are about to change.

Introducing Passkeys

Upgrading the password experience is not a technology problem, we have developed better techniques ages ago. But it is, as most things are, a political problem. Convincing thousands of people (developers, cryptographers, government bureaucrats), to all get on the same page is quite the undertaking.

In the last few years, the Holy (Browser) Trinity has decided to bless us with Webauthn, a set of in-browser cryptographic primitives that are easy to use, phish-resistent, and can be protected with biometrics and secure enclaves. The legacy Web2 industry is busily building Passkeys on top of these primitives, to make signing in to web apps safer and more convenient.

Modern browsers now have these two functions:

navigator.credentials.create and navigator.credentials.get

If you dig into the specs deep enough, you realize that at their core, these two functions enable you to create a public/private keypair, and then sign a message with that keypair. Wait! That sounds suspiciously close to how a crypto transaction works, eh? The excitement fades, however, when you learn that Webauthn uses the secp256r1 elliptic curve, while the rest of crypto uses secp256k1. Bitcoin originally used the k1 curve, and Ethereum followed. But the Webauthn spec writers chose to use a different curve, the r1 curve, which has been anointed by NIST as the P256 curve, and has been dogged by rumors of “NSA tampering”. (Go down that rabbit hole here.)

Unfortunately, this small detail puts a huge crimp in the works, and prevents crypto wallets from using webauthn to directly sign crypto transactions. (I could go on a tangent about Big Tech and Big Govt colluding to stomp on personal liberty, but let’s continue…)

The push for Account Abstraction in the EVM-blockchain world seeks to address this shortcoming, by implementing secp256r1 signature verification either in a precompile or directly in Solidity (ga$$$ yikes) as part of the AA flow. But someone, somehow, still needs to sign a tx with a secp256k1 key, so there are off-chain actors in AA schemes to perform this critical function.

Avalanche HyperSDK FTW

In the Avalanche world, while the C-chain is necessarily “stuck” on the (legacy) EVM/secp256k1 elliptic curve, with the Subnet architecture we are free to explore new design spaces for blockchain systems using fantastic accelerants like the HyperSDK.

So, our “hackathon” idea was, what if we could build a new blockchain that used secp256r1 for signature verification at the base level? By simply choosing a different elliptic curve, this would give billions of people access to a hyper-secure hardware wallet, built-in to their standard web browser, and be able to sign crypto transactions with biometric security.

Could it be done? How hard would it be? How many rough edges are there? (Spoiler alert: a lot!)

So buckle up, and let’s take a journey deep into the weeds…

The Weeds

Creating a new keypair using Javascript in a modern browser is easy:

// Paste this code into your browser's console to see it in action...
const opts = {
  publicKey: {
    pubKeyCredParams: [{ type: "public-key", alg: -7 }], // -7 means secp256r1 aka P256
    challenge: Uint8Array.from("notnecessary", (c) => c.charCodeAt(0)),
    authenticatorSelection: {
      authenticatorAttachment: "platform", // "cross-platform" for mobile QR code UI
      requireResidentKey: true,
      residentKey: "required",
      userVerification: "required",
    },
    rp: {
      // rp means RelyingParty -- your app/website
      // if 'id' is not specified, defaults to domain of whatever page you are on
      // id: "localhost",
      name: "YourAppName",
    },
    user: {
      id: Uint8Array.from("u@x.com", (c) => c.charCodeAt(0)), // can be anything
      name: "u@x.com",
      displayName: "u@x.com",
    },
  },
};
var credential = await navigator.credentials.create(opts);
console.log(credential);

This will cause the browser to pop up a UI asking the user if they would like to create the new key.

Once a key has been created, you can see it in Chrome by going to chrome://settings/passkeys or on Safari iOS/MacOS Settings->Passwords.

The credential variable will contain a JSON object with the standard Webauthn fields.

{
  "id": "npI26b9am4rtzr7-Pza1PAhoAWdrBA3tOR37ZuM_t-E",
  "type": "public-key",
  "authenticatorAttachment": "platform",
  "response": AuthenticatorAttestationResponse,
}

One trick we learned, was that in order to get the public key for later use, you can call credential.response.getPublicKey() and then be sure to store the result somewhere for later use. Also store the id. You will not be able to access this data again, which is a DX fail, but more on that later. (In Passkeys-land, this data is sent to and stored on the server and linked to your user login account.)

Signing

Now that we have a keypair, how would we sign a crypto transaction with it? The navigator.credentials.get function will sign a “challenge”, which in Passkeys-land is just a random set of bytes sent from the server. So instead of signing random bytes, we will sign a 32 byte hash of a “transaction”, which can be anything we want since we are building our own blockchain. In this demo project our toy transaction looks like:

const tx = {
  "amount": "42",
  "payer": "15Y5TYLTX1fRuEb5aAPxcMZDnws1ScPeC2",
  "payee": "16Zh4RBhnhxCwcqHmoYYQnBNnkRpcMbBDK"
}

and our “challenge” would be sha256(tx). So lets try signing that:

// credential.id is the id of the key we created earlier, but its in Base64URL and we (annoyingly) need it in a raw ArrayBuffer. 
const base64 = credential.id.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
  bytes[i] = binary.charCodeAt(i);
};
// Whew. Ok, lets continue...
const msg = JSON.stringify(tx);
const msgBuffer = new TextEncoder("utf-8").encode(msg);
const challenge = await window.crypto.subtle.digest("SHA-256", msgBuffer);
const publicKey = {
  challenge: challenge,
  userVerification: "required",
  allowCredentials: [{ type: "public-key", id: buffer }],
};

const response = await navigator.credentials.get({ publicKey });

Once the user signs, we get back a JSON object:

{  
  "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiakd3RGNuSnFmMTdaaEM0aHc1Y1VrNnRMZWJnS2RJTkRNNW1kNDJTWC1mcyIsIm9yaWdpbiI6Imh0dHBzOi8vZ29nby13ZWJhdXRobi5mbHkuZGV2IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ",  
  "authenticatorData": "BqMx8BQZHBJxAPv8fS7-sGLx1GUDAljio7hrFPdDsgUFAAAAAA",
  "signature": "MEQCID_Y89ej3YQu7h3Oe5cN2M4qtl3UsOLoVdGD3X0NAE7iAiACMpOoUzZ2xXJZjs4lJ8H3Y3vzIqlngRdiV_eQdO6YuA",
  "userHandle": "TXkgV2FsbGV0"
}

So it looks like we have a signature, woohoo! The next step is to use the signature, and the hash that was signed, and the public key, and cryptographically verify it. Oh, and we want to do this in Go, since our blockchain will be written in Go using HyperSDK. We will skip ahead past the part where we had to read a bunch of specs and other people’s code on Github to figure out exactly how to do this. And no, ChatGPT was decidedly unhelpful in this regard.

It turns out, that the ArrayBuffer bytes that you get from the browser for the public key (credential.response.getPublicKey()) is a SubjectPublicKeyInfo which can be parsed with crypto/x509.ParsePKIXPublicKey

Our first attempt went like this:

// msgHash is sha256(tx)
pk, _ := x509.ParsePKIXPublicKey(publicKeyBytes)
epk, _ := pk.(*ecdsa.PublicKey)
ok := ecdsa.VerifyASN1(epk, msgHash, signature)

But, alas, this did not work. We thought the browser signed our “challenge” which was sha256(tx), but that’s not quite right. And when it comes to math, you gotta be exactly right.

What the browser actually signs, is reproduced in this Go code:

// Construct the data that `navigator.credentials.get` used to sign
func (w Webauthn) signedDataHash() [32]byte {
	clientDataHash := sha256.Sum256([]byte(string(w.Response.ClientDataJSON)))
	sigData := append(w.Response.AuthenticatorData, clientDataHash[:]...)
	msgHash := sha256.Sum256(sigData)
	return msgHash
}
“Hey Johnny, why did it take you so long to write 3 lines of code?” 🤣

Wait, where is our tx data? Well, remember the navigator.credentials.get call that returned a response object? That has a clientDataJSON field which is a Base64URL encoded JSON that looks something like this:

{
  "type":"webauthn.get",
  "challenge":"jGwDcnJqf17ZhC4hw5cUk6tLebgKdINDM5md42SX-fs",
  "origin":"https://gogo-webauthn.fly.dev",
  "crossOrigin":false
}

So, to tie it all together, our tx data

{
  "amount": "42",
  "payer": "15Y5TYLTX1fRuEb5aAPxcMZDnws1ScPeC2",
  "payee": "16Zh4RBhnhxCwcqHmoYYQnBNnkRpcMbBDK"
}

is sha256-hashed into a 32 byte “challenge”, and then the navigator.credentials.get call Base64URL encodes it into the challenge field of this obj:

{
  "type":"webauthn.get",
  "challenge":"jGwDcnJqf17ZhC4hw5cUk6tLebgKdINDM5md42SX-fs",
  "origin":"https://gogo-webauthn.fly.dev",
  "crossOrigin":false
}

which is then Base64URL encoded into the clientDataJSON field of the response object:

{
  "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiakd3RGNuSnFmMTdaaEM0aHc1Y1VrNnRMZWJnS2RJTkRNNW1kNDJTWC1mcyIsIm9yaWdpbiI6Imh0dHBzOi8vZ29nby13ZWJhdXRobi5mbHkuZGV2IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ",
  "authenticatorData": "BqMx8BQZHBJxAPv8fS7-sGLx1GUDAljio7hrFPdDsgUFAAAAAA",
  "signature": "MEQCID_Y89ej3YQu7h3Oe5cN2M4qtl3UsOLoVdGD3X0NAE7iAiACMpOoUzZ2xXJZjs4lJ8H3Y3vzIqlngRdiV_eQdO6YuA",
  "userHandle": "TXkgV2FsbGV0"
}

So now back to our signedDataHash() Go function above, it has to calculate some more sha256 hashes of various fields to finally get the hash that the browser signed. And with that, we can now verify the signature against the hash and public key.

msgHash := signedDataHash()
pk, _ := x509.ParsePKIXPublicKey(publicKeyBytes)
epk, _ := pk.(*ecdsa.PublicKey)
ok := ecdsa.VerifyASN1(epk, msgHash, signature)
// 🎉

This works, and we have verified the signature. Whew. This will now allow us to put all the pieces together into a blockchain system that has these awesome features:

  • Unparalleled new user on-boarding experience
  • Crypto wallet with no seed phrase or password to remember
  • Private keys protected via biometrics and secure enclaves
  • Can access wallet and sign txs from phone using any desktop browser

TODOs

While we have proved out the ability to do what we wanted, there are still many rough edges that developers will need to file down in order to make this a great experience for users.

Since Webauthn was developed mainly in service of the Passkeys use case, there are going to be impedance mismatches with crypto. For example, when “signing” a transaction via biometrics, the Safari UI (which as a dev you are unable to change) asks you to “sign in”

Another example is that when a key is created, the browser locks it to the current web site domain. The browser then prevents the key from being used on any other web site. This is the phish-resistance feature in action. But how will this work for a crypto wallet? A browser extension might work. But then we get into the particulars of how each browser implements extensions – Chrome has a fixed domain it uses for an extension, but Safari uses a dynamically generated domain.

Or maybe, a better approach is to actually generate a new key for each Dapp you use, that can then only be used on that page, and thus take advantage of the anti-phish feature. But then how do you “register” each new key with your blockchain account?

ecrecover

When sending a tx to the EVM, we usually send the tx, and a signature, but we do not need to also send our public key, because the EVM itself “recovers” our public key by using the ecrecover function. What is this ecrecover thing? Well, we should all give thanks to the unsung heroes of Go-lang crypto, the big brains at Decred. They are responsible for most of the core crypto Go code used in many projects (Avalanche included). As far as I can tell, Owain G. Ainsworth committed the first version of the function in 2014 and this is the current cannonical version.

That function, however, was written to only work on the secp256k1 curve, and we need that function for the secp256r1 curve. I tried asking ChatGPT to write it for me, and wow that was a waste of time and tokens. And my Google-fu turned up nothing either. The Hyperledger folks have a native C implementation that makes it seem like the same code can recover either curve here so I’m sure this is nothing that a quiet weekend and a case of White Monster can’t fix.

At any rate, for our demo project, this means we had to send along the public key as well, so that we could verify it against the signature and hash. Which sucks, since now we have to save the public key somewhere, because once it is created we cannot access it again, we can only tell the browser to sign things with it.

Secure Enclave on Apple Devices

The Secure Enclave is able to securely store secrets on your device, that cannot be exported in any way. When you create a Webauthn key on Chrome on an Apple device, it will store the private key in this way. The upside is the key is extremely secure. The downside is it cannot be backed up. If you lose the device, you also lose the key (and potentially your crypto!). This can be addressed by building out an “account” system on your blockchain that could have several private keys registered, or something like that. The Flow blockchain has an interesting approach to accounts.

But, it seems that Apple feels like this ability to lose a key is just bad UX for their users, so on Safari, the private key is stored in the Secure Enclave, but also backed up into iCloud. The exact details of how this is accomplished are theorized about here. The upshot of this is that the exact disposition of the Webauthn key you create is highly dependent on the browser and operating system you created it on. The ability for the user to view and manage keys is also seriously lacking at this point.

Now What

Overall, it is going to be a UX challenge for builders as we try and fit the Webauthn square peg into the Crypto round hole. But the end result will be overwhelmingly better than the current state-of-the-art, and will pave the way for actually on-boarding the next billion users to crypto.

Financial sovereignty for every member of the human race is our goal.

[Erik Voorhees](https://twitter.com/permissionless/status/1702054516458156126) says it best.