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>&nbsp;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>&nbsp;display&nbsp;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