WebAuthn
Notes around WebAuthn
In short: think of this as username and password auth for SSH vs using public, private keys, the same is here, private key is stored on device and public key is sent to website
From my understanding it should be treat as additional way to authneticate user, aka think of this - when you have installed an mobile app, you have signed up/in and it ask if you want to use faceid for authentication
So far the best links are webauthn.io its description webauthn.guide
And the best one password.id its sources definitelly helped a lot to wireup everything
So here is my attempt and notes around webauthn
How it works
Registration
From cliend side - listen for registration form submit and send its data to server, aka:
document.getElementById('registration-form').addEventListener('submit', async event => {
event.preventDefault()
const payload = {
name: event.target.name.value, // required, at least by Safary
display_name: event.target.display_name.value, // required, by spec, used in login popup
email: event.target.email.value // just for demo, pass any other fields
}
const { user_id, challenge } = await fetch('/registration-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(r => r.json())
})
So nothing special here, it is not related to webauthn yet all all
And even next step on backend is not related as well, here is pseudo code
app.post('/registration-start', (req, res) => {
const { name, display_name, email } = req.body
// pretend we are saved user to database with following id
const user_id = crypto.randomUUID()
const challenge = crypto.randomBytes(32).toString('base64url') // will be used to complete registration process, so save it as well, think of this as something like csrf
res.json({user_id, challenge})
})
As you can see nothing special here, the very first webauthn related stuff happens when we receive this user id and challange on client side
As very first step we are going to create "credentials" (technically speaking under the hood pair of private public keys will be created, and we will receive public key)
const credential = await navigator.credentials.create({
// technically all this may come from server
publicKey: {
challenge: b64url.decode(challenge),
rp: {
name: 'WebAuthn Demo',
// optionally my pass `id` with domain, aka something similar to cookies
},
user: { // https://www.w3.org/TR/webauthn-2/#dictionary-user-credential-params - mentions that only display name is required, but Safari asks for "name" as well
id: b64url.decode(user_id), // should be bytes, not string
name: event.target.name.value, // required, username
displayName: event.target.display_name.value, // required, display name
},
pubKeyCredParams: [ // this one should be returned from server, list of supported algorithms, ordered by preference
{ type: 'public-key', alg: -7 }, // ES256, required by Safari
{ type: 'public-key', alg: -257 }, // RS256
],
attestation: "none" // we won't be restricting used authenticator, aka it may be used for enterprise/goverment when you want to restrict used approaches to lets say ubikey
}
})
At this stage credentials are already stored in our key chain
Credential have some properties the most important for us are credential id and public key - we need to save them on server side as well
const response = await fetch('/complete-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id, // base64url encoded id of credentials
// rawId: b64url.encode(credential.rawId), // bytes representation of credential id
clientDataJSON: b64url.encode(credential.response.clientDataJSON), // the thing we are going to verify, it continas challange and origin
type: credential.type, // 'public-key', at moment hardcoded, in future may be something else, ideally should be transfered and stored, removed for simplicity
// attestationObject: b64url.encode(credential.response.attestationObject), // contains both public key and authenticator details, we are not going to check authenticator, and to simplify things will pass public key directly so no need to decode it with CBOR 3rd party libraries
publicKey: b64url.encode(credential.response.getPublicKey()),
publicKeyAlgorithm: credential.response.getPublicKeyAlgorithm(), // -7
// transports: credential.response.getTransports() // ['internal', 'hybrid']
})
}).then(res => res.json())
From server side check if challange crom client data is same as stored before and save retrieved credential id and public key
There is really nothing special for server side on this stage, pseudo code will be something like:
app.post('/complete-registration', (req, res) => {
const {id, type, clientDataJSON, publicKey, publicKeyAlgorithm} = req.body
const clientData = JSON.parse(Buffer.from(clientDataJSON, 'base64url').toString())
// TODO: check saved challenge with clientData.challenge, check origin, etc
// save publicKey and alg
})
Note that webauthn by itself does not handle nor decide for you how your app should mark user as authenticated, aka will it be cookies or some jwt or something else is up to you
Login
Even so it seems that login should be simpler it actually harder
From client side we need login form with at least something we can identify user on server - usually it will be username (we have stored it as "name" field)
Once the user enters username and submit form we send it to server
Server will look for such user, if user found, retrieve his saved credentials (credential id and public key), create challange (aka csrf) and send them back to client
So this part is trial and does not need any sample, at least this part:
app.post('/login', (req, res) => {
const { name } = req.body
const user = users.findBy(name)
const challenge = crypto.randomBytes(32).toString('base64url')
res.json({
challenge,
ids: user.credentials.map(c => c.id) // we need only credential id, so credential manager can find it, also note that there may be many credentials for user, aka device based
})
})
The next step on a client side is to ask credentials storage to retrieve credentials by given credential id (as well credential will sign challange) here is sample
const credential = await navigator.credentials.get({
publicKey: {
challenge: b64url.decode(challenge),
allowCredentials: ids.map(id => ({
id: b64url.decode(id),
type: 'public-key'
}))
}
}).catch(console.error)
This credential will have little bit more details, especially userHandle
containing base64 url encoded user id, signature
that will be used to verify on server side
Now we can send it to server for verification
const response = await fetch('/complete-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
// rawId: b64url.encode(credential.rawId),
authenticatorData: b64url.encode(credential.response.authenticatorData),
clientDataJSON: b64url.encode(credential.response.clientDataJSON),
signature: b64url.encode(credential.response.signature),
userHandle: b64url.encode(credential.response.userHandle)
})
}).then(res => res.json())
From server side the same way we want to check client data but the most important thing is signature
Here is where webauthn.passwordless.id playground did helped a LOT, especially its verifySignature did helped a lot espe
Here is an reversed code sample to verify
import crypto from 'crypto'
function convertASN1toRaw(signatureBuffer) {
// Convert signature from ASN.1 sequence to "raw" format
const usignature = new Uint8Array(signatureBuffer);
const rStart = usignature[4] === 0 ? 5 : 4;
const rEnd = rStart + 32;
const sStart = usignature[rEnd + 2] === 0 ? rEnd + 3 : rEnd + 2;
const r = usignature.slice(rStart, rEnd);
const s = usignature.slice(sStart);
return new Uint8Array([...r, ...s]);
}
const publicKeyAlgorithm = -7
const publicKey = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwhFLQhv5IevkaUjrLXprzuZxkiqAOO5gqzTJ22wP_OjT24HnGLSgqXlCx1RbTT8szcVkWylwDqWR83jBaHaO_w'
const authenticatorData = 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA'
const clientDataJSON = 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiN3RHeGpZVmRSVHMzVnJ4bndfdlRBLWh4YUdrbmJfU0Z6V1lhTTU4N2ktYyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDAwMCJ9'
const signature = 'MEQCIC4jfKJytjx9dprp4u2PYpPlqzHs3ziStISOHAnkJZ6ZAiBsIvvNGXsSiEYKVnvxZGNzsXRz-rJKIGYW5qnGGZ1V8A'
const algoParams = publicKeyAlgorithm === -7 ? { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256', } : { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
const cryptoKey = await crypto.subtle.importKey('spki', Buffer.from(publicKey, 'base64url'), algoParams, false, ['verify'])
const clientHash = await crypto.subtle.digest('SHA-256', Buffer.from(clientDataJSON, 'base64url'))
const combo = Buffer.concat([Buffer.from(authenticatorData, 'base64url'), Buffer.from(clientHash)])
const valid = await crypto.subtle.verify(algoParams, cryptoKey, convertASN1toRaw(Buffer.from(signature, 'base64url')), combo)
console.log(valid)
Note: for ES256 we need additional convertation for signature which was not obvious and hard to understand why nothing works
So after verifying signature you may treat user as logged in
Here are not cleaned up samples for client and server sides I have so far
const http = require('http')
const fs = require('fs')
const crypto = require('crypto')
const users = []
function convertASN1toRaw(signatureBuffer) {
// Convert signature from ASN.1 sequence to "raw" format
const usignature = new Uint8Array(signatureBuffer);
const rStart = usignature[4] === 0 ? 5 : 4;
const rEnd = rStart + 32;
const sStart = usignature[rEnd + 2] === 0 ? rEnd + 3 : rEnd + 2;
const r = usignature.slice(rStart, rEnd);
const s = usignature.slice(sStart);
return new Uint8Array([...r, ...s]);
}
const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'})
fs.createReadStream('index.html').pipe(res)
} else if (req.method === 'GET' && req.url === '/users') {
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify(users))
} else if (req.method === 'POST' && req.url === '/register') {
let body = ''
req.on('data', chunk => body += chunk)
req.on('end', () => {
const { name, display_name, email } = JSON.parse(body)
// pretend we are storing user in database
const user_id = Buffer.from(name).toString('base64url') // crypto.randomUUID() // for demo purposes we are using username as identifier, will be helpful later to figure out what id, rawId, etc are
const challenge = crypto.randomBytes(32).toString('base64url') // will be used to complete registration process
const user = {user_id, name, display_name, email, challenge}
users.push(user)
console.log('register', user)
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify({user_id, challenge}))
})
} else if (req.method === 'POST' && req.url === '/complete-registration') {
let body = ''
req.on('data', chunk => body += chunk)
req.on('end', () => {
let {id, type, clientDataJSON, publicKey, publicKeyAlgorithm} = JSON.parse(body)
// id - here is credential id, not user id, it is already base64url encoded string
// clientDataJSON - base64url encoded JSON object {type: "webauthn.create", challenge: "yvSzb2RwhZ8lEVY3M4WEmqin08K71_fg3XPN-cnU7mU", origin: "http://localhost:4000"}
// const attestationObjectBuffer = Buffer.from(attestationObject, 'base64url')
const clientData = JSON.parse(Buffer.from(clientDataJSON, 'base64url').toString())
const user = users.find(u => u.challenge === clientData.challenge) // instead we should look for username, also have session, timeouts, etc, here we are oversimplifying just for demo
if (!user) {
res.writeHead(400, {'Content-Type': 'application/json'})
res.end(JSON.stringify({success: false, message: 'Invalid challenge'}))
return
}
// store retrieved credential id
user.credentials = user.credentials || []
user.credentials.push({id, type, publicKey, publicKeyAlgorithm})
console.log('register', {
id, // 'LQ_lnDs....'
clientData // {type: 'webauthn.create', origin: 'http://localhost:4000', challenge: 'PWPmxVoG4....'}
})
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify({success: true}))
})
} else if (req.method === 'POST' && req.url === '/login') {
let body = ''
req.on('data', chunk => body += chunk)
req.on('end', () => {
const { name } = JSON.parse(body)
// pretend we are storing user in database
const user = users.find(u => u.name === name)
const ids = user ? user.credentials.map(c => c.id) : []
const challenge = crypto.randomBytes(32).toString('base64url') // will be used to complete registration process
user.challenge = challenge
console.log('login', {name, ids, challenge})
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify({ids, challenge}))
})
} else if (req.method === 'POST' && req.url === '/complete-login') {
let body = ''
req.on('data', chunk => body += chunk)
req.on('end', async () => {
const { id, clientDataJSON, authenticatorData, signature, userHandle } = JSON.parse(body)
// id - credential id, base64url encoded string
// userHandle - base64url encoded string with user id (in our case login is used)
// signature - base64url encoded string with signature
// clientDataJSON - base64url encoded JSON object {type: "webauthn.get", challenge: "yvSzb2...", origin: "http://localhost:4000"}
const name = Buffer.from(userHandle, 'base64url').toString()
const clientData = JSON.parse(Buffer.from(clientDataJSON, 'base64url').toString())
console.log('login complete', {
credential_id: id,
name,
clientData,
})
const user = users.find(u => u.name === name && u.challenge === clientData.challenge) // instead we should look for username, also have session, timeouts, etc, here we are oversimplifying just for demo
if (!user) {
res.writeHead(400, {'Content-Type': 'application/json'})
res.end(JSON.stringify({success: false, message: 'Invalid challenge'}))
return
}
// TODO: verify incomming signature
const publicKey = Buffer.from(user.credentials.find(c => c.id === id).publicKey, 'base64url')
const publicKeyAlgorithm = user.credentials.find(c => c.id === id).publicKeyAlgorithm
console.log('publicKey', publicKey)
console.log('publicKeyAlgorithm', publicKeyAlgorithm)
// const verifier = crypto.createVerify('RSA-SHA256')
// verifier.update(Buffer.from(authenticatorData, 'base64url'))
// verifier.update(clientDataJSONHashBuffer)
// verifier.end()
// const verified = verifier.verify(publicKey, Buffer.from(signature, 'base64url'))
// if (!verified) {
// throw new Error('Invalid signature')
// } else {
// console.log("VERIFIED")
// }
let algoParams = undefined
if (publicKeyAlgorithm === -7) {
algoParams = {
name: 'ECDSA',
namedCurve: 'P-256',
hash: 'SHA-256',
}
}
if (publicKeyAlgorithm === -257) {
algoParams = {
name: 'RSASSA-PKCS1-v1_5',
hash: 'SHA-256'
}
}
if (!algoParams) {
throw new Error(`Unknown or unsupported crypto algorithm: ${publicKeyAlgorithm}. Only 'RS256' and 'ES256' are supported.`)
}
console.log('algoParams', algoParams)
const cryptoKey = await crypto.subtle.importKey('spki', publicKey, algoParams, false, ['verify'])
let clientHash = await crypto.subtle.digest('SHA-256', Buffer.from(clientDataJSON, 'base64url'));
var buffer1 = Buffer.from(authenticatorData, 'base64url')
var buffer2 = clientHash
var comboBuffer = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
comboBuffer.set(new Uint8Array(buffer1), 0);
comboBuffer.set(new Uint8Array(buffer2), buffer1.byteLength);
const valid = await crypto.subtle.verify(algoParams, cryptoKey, convertASN1toRaw(Buffer.from(signature, 'base64url')), comboBuffer)
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify({success:true, valid}))
})
} else {
res.writeHead(404, {'Content-Type': 'text/plain'})
res.end('Not Found\n')
}
})
server.listen(4000)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebAuthn</title>
</head>
<body>
<h1>WebAuthn</h1>
<fieldset>
<legend>register</legend>
<form id="register">
<table>
<tr>
<th align="right"><font color="red">*</font> name</th>
<td><input type="text" name="name" placeholder="user name aka login, will be used at login form" autocomplete="webauthn" required></td>
</tr>
<tr>
<th align="right"><font color="red">*</font> display name</th>
<td><input type="text" name="display_name" placeholder="display name, will be used at login popup"></td>
</tr>
<tr>
<th align="right">email</th>
<td><input type="email" name="email" placeholder="email, wont be used at all, just for demo"></td>
</tr>
<tr>
<th></th>
<td><input type="submit" value="register"></td>
</tr>
</table>
</form>
</fieldset>
<fieldset>
<legend>login</legend>
<form id="login">
<table>
<tr>
<th align="right"><font color="red">*</font> name</th>
<td><input type="text" name="name" placeholder="login from registration step" autocomplete="webauthn" required></td>
</tr>
<tr>
<th></th>
<td><input type="submit" value="login"></td>
</tr>
</table>
</form>
</fieldset>
<script>
const b64url = {
// https://gist.github.com/themikefuller/c1de46cbbdad02645b9dc006baedf88e
encode: bytes => btoa(Array.from(new Uint8Array(bytes)).map(val => String.fromCharCode(val)).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/\=/g, ''),
decode: str => new Uint8Array(atob(str.replace(/-/g, '+').replace(/_/g, '/')).split('').map(val => val.charCodeAt(0)))
}
document.getElementById('register').addEventListener('submit', async event => {
event.preventDefault()
// step 1: send registration form data to /register endpoint
const {user_id, challenge} = await fetch('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: event.target.name.value,
display_name: event.target.display_name.value,
email: event.target.email.value
})
}).then(res => res.json())
console.log('registration response', {user_id, challenge})
const credential = await navigator.credentials.create({
// technically all this may come from server, leaved here on front, just for autocomplete to help figure out what's availabel
publicKey: {
challenge: b64url.decode(challenge), // convert challenge from base64 to Uint8Array
rp: {
name: 'WebAuthn Demo',
// optionally my pass `id` with domain, aka something similar to cookies
},
user: { // https://www.w3.org/TR/webauthn-2/#dictionary-user-credential-params - mentions that only display name is required, but Safari asks for "name" as well
id: b64url.decode(user_id), // bytes representation of user id, in our demo it is username
name: event.target.name.value, // required, username
displayName: event.target.display_name.value, // required, display name
},
pubKeyCredParams: [ // this one should be returned from server, list of supported algorithms, ordered by preference
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 }, // RS256
],
attestation: "none" // we won't be restricting used authenticator, aka it may be used for enterprise/goverment when you want to restrict used approaches to lets say ubikey
}
})
console.log('credential', credential)
console.log('credential.response.clientDataJSON', JSON.parse(new TextDecoder("utf-8").decode(credential.response.clientDataJSON)))
// {type: "webauthn.create", challenge: "yvSzb2RwhZ8lEVY3M4WEmqin08K71_fg3XPN-cnU7mU", origin: "http://localhost:4000"}
console.log('alg', credential.response.getPublicKeyAlgorithm())
window.created = credential
// step 2: send created credential to complete regstration
const response = await fetch('/complete-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id, // base64url encoded id of credentials not user id
// rawId: b64url.encode(credential.rawId), // bytes representation of credential id
clientDataJSON: b64url.encode(credential.response.clientDataJSON), // the thing we are going to verify, it continas signed challange and origin
type: credential.type, // 'public-key', at moment hardcoded, in future may be something else, ideally should be transfered and stored, removed for simplicity
// attestationObject: b64url.encode(credential.response.attestationObject), // contains both public key and authenticator details, we are not going to check authenticator, and to simplify things will pass public key directly so no need to decode it with CBOR 3rd party libraries
publicKey: b64url.encode(credential.response.getPublicKey()),
publicKeyAlgorithm: credential.response.getPublicKeyAlgorithm(), // -7
// transports: credential.response.getTransports() // ['internal', 'hybrid']
})
}).then(res => res.json())
console.log('response', response)
alert('registration complete')
})
document.getElementById('login').addEventListener('submit', async event => {
event.preventDefault()
// step 1: send login form data to /login endpoint
const {ids, challenge} = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: event.target.name.value
})
}).then(res => res.json())
console.log('login response', {ids, challenge})
const credential = await navigator.credentials.get({
publicKey: {
challenge: b64url.decode(challenge),
allowCredentials: ids.map(id => ({
id: b64url.decode(id),
type: 'public-key'
}))
}
}).catch(console.error)
console.log('credential', credential)
window.retrieved = credential
// credential.authenticatorAttachment - 'platform'
// credential.id - bese64url('user id')
// credential.rawId - bytes
// credential.response.authenticatorData - bytes
// credential.response.clientDataJSON - bytes
// credential.response.signature - bytes
// credential.response.userHandle - bytes
// credential.type - 'public-key'
const response = await fetch('/complete-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
// rawId: b64url.encode(credential.rawId),
authenticatorData: b64url.encode(credential.response.authenticatorData),
clientDataJSON: b64url.encode(credential.response.clientDataJSON),
signature: b64url.encode(credential.response.signature),
userHandle: b64url.encode(credential.response.userHandle)
})
}).then(res => res.json())
console.log('login response', response)
})
</script>
</body>
</html>
Initially idea was to try get it up and running without 3rd party dependencies and libraries, but if it comes to verifying attestations it is encoded in CBOR and even more depending on platform attestation will have different formats, it is definitely not something you want to code your self, so after this point it is probably will be much better to use some libraries
And so far I at least know very good one for node - @passwordless-id/webauthn
PS: fun fact, webauthn is already few years old and seems to be supported out of the box in majority of platforms, did bothered just because Google and GitHub asked me to create passkeys