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.