Understanding Passkeys

NOTE: These are the notes I put together while reading https://medium.com/webauthnworks/introduction-to-webauthn-api-5fd1fb46c285. It is my digest of the content there so I can reuse it later. I do not take credit for the original research Ackermann Yuriy put into this. Thank you!

omg.lol is the one site I’ve used with Passkeys and it is pretty simple once you understand that you have to log in first, then create a passkey

I think that passkeys can be used without an account by assigning users a random user ID, letting them create a passkey for that, and then letting them log in as a limited user with that credential until they decide if they want to have something tied to their real identity (e-mail address, etc).

WebAuthn is an interface to talk to FIDO authenticators. Give it a challenge and get an assertion back. The most important operations are MakeCredential AKA webauthn.create and GetAssertion AKA webauthn.get.

MakeCredential will create a credential on the client. It needs a challenge from the server, the name of the relying party, a user ID generated by the server randomly, a user name to be used as a human readable account name (e-mail address, etc), a display name that can be the user’s real name or whatever they want, and a list of the signing algorithms that the server supports.

authenticatorSelection.authenticatorAttachment allows you to specify “platform” or “cross-platform” authenticator support. Platform is for built-in authenticators. Cross-platform is for external keys.

authenticatorSelection.requireResidentKey allows you to create discoverable credentials (no username required) if set to true.

authenticatorSelection.userVerification can be required, preferred, or discouraged. Required means biometrics, a PIN, or some other second factor is required. Preferred means it will be used if available but can default to TUP (Test of User Presence, like a YubiKey). Discouraged means it will only require TUP.

When you call MakeCredential you get an “attestation response” which can be sent to a server to register the new credential.

An attestation response looks like this:

{
    "rawId": "Aad50Szy7ZFb8f7wdfMmFO2dUdQB8StMrYBbhJprTCJIKVdbIiMs9dAATKOvUpoKfmyh662ZsO1J5PQUsi9yKNumDR-ZD4wevDYZnwprytGf5rn6ydyxQQtBYPSwS8u23FdVBxBqHa8",
    "id": "Aad50Szy7ZFb8f7wdfMmFO2dUdQB8StMrYBbhJprTCJIKVdbIiMs9dAATKOvUpoKfmyh662ZsO1J5PQUsi9yKNumDR-ZD4wevDYZnwprytGf5rn6ydyxQQtBYPSwS8u23FdVBxBqHa8",
    "response": {
        "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjszHUM-fXe8fPTc7IQdAU8xhonRmZeDznRqJqecdVRcUNFYfOzo63OAAI1vMYKZIsLJfHwVQMAaAGnedEs8u2RW_H-8HXzJhTtnVHUAfErTK2AW4Saa0wiSClXWyIjLPXQAEyjr1KaCn5soeutmbDtSeT0FLIvcijbpg0fmQ-MHrw2GZ8Ka8rRn-a5-sncsUELQWD0sEvLttxXVQcQah2vpQECAyYgASFYIMG7Y3fOeGecLpfn7XF_sV4OTc41tsbEPSECGfCiK480IlggH9-qVehm6Gj25SyZau17mB5c0YoTWBZ8ngdEka4EqOY",
        "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoib0dvd2lrQVZHcnZ4Y01uck50ODlCY0dsWnIwVVUwVWxfSm82U0R5RXJrTSIsIm9yaWdpbiI6Imh0dHBzOi8vd2ViYXV0aG53b3Jrcy5naXRodWIuaW8iLCJjcm9zc09yaWdpbiI6ZmFsc2V9"
    },
    "getClientExtensionResults": {},
    "type": "public-key"
}

The attestationObject field, after Base64 URL and CBOR decoding, looks like this:

{
    "fmt": "fido-u2f",
    "authData": "9569088f1ecee3232954035dbd10d7cae391305a2751b559bb8fd7cbb229bdd441000000000000000000000000000000000000000000401d18ae399289eb236706b4a7a1c9c5b8682c8dc019202978fb4b840c04070885e7716402918501bef533650a2faf199e07d426db78668fa3e7cd6ca8896b6df3a5010203262001215820878e2a88b54c088461d0ac6fc5c8027707f4aa3d12e1a45c4ba0002232d665742258207de7e0b3b640e219f3e57dc24baede51680c012673a702875726e8861a692860",
    "attStmt": {
        "sig": "3046022100db3162cfa7b5dbd78c46864e5f93f757e6a124020b32c4997a73c2e22a4abcd4022100daf9f1fdc3f80a4f404abb99ecd742b472a57827ddd0dc021dcbf670c8f5ef92",
        "x5c": [
            "3082013c3081e4a003020102020a39518789387852645409300a06082a8648ce3d0403023017311530130603550403130c4654204649444f2030313030301e170d3134303831343138323933325a170d3234303831343138323933325a3031312f302d0603550403132650696c6f74476e756262792d302e342e312d33393531383738393338373835323634353430393059301306072a8648ce3d020106082a8648ce3d03010703420004878e2a88b54c088461d0ac6fc5c8027707f4aa3d12e1a45c4ba0002232d665747de7e0b3b640e219f3e57dc24baede51680c012673a702875726e8861a692860300a06082a8648ce3d04030203470030440220e81b88a4b3f13ffc0f3623896498aa28a2a50540760a00b031889b4a3922e52f0220e3c50b1f98d492b16c9ee70ecf7a3ecbe30dc45fa729f3becc031bf57a56e2b4"
        ]
    }
}

The clientDataJSON object looks like this after decoding:

{
    "type": "webauthn.create",
    "challenge": "oGowikAVGrvxcMnrNt89BcGlZr0UU0Ul_Jo6SDyErkM",
    "origin": "https://webauthnworks.github.io",
    "crossOrigin": false
}

Once you’ve created the credential you can with MakeCredential you can then authenticate with GetAssertion.

GetAssertion requires a public key object that contains the server’s challenge and then has an optional structure called allowCredentials that can specify the type (public key) and ID of a credential we expect. If this is left blank then only discoverable credentials can be used.

When you call GetAssertion you get an “assertion response” which can be sent to a server to authenticate you.

The data returned from GetAssertion looks like this:

{
    "rawId": "Aad50Szy7ZFb8f7wdfMmFO2dUdQB8StMrYBbhJprTCJIKVdbIiMs9dAATKOvUpoKfmyh662ZsO1J5PQUsi9yKNumDR-ZD4wevDYZnwprytGf5rn6ydyxQQtBYPSwS8u23FdVBxBqHa8",
    "id": "Aad50Szy7ZFb8f7wdfMmFO2dUdQB8StMrYBbhJprTCJIKVdbIiMs9dAATKOvUpoKfmyh662ZsO1J5PQUsi9yKNumDR-ZD4wevDYZnwprytGf5rn6ydyxQQtBYPSwS8u23FdVBxBqHa8",
    "response": {
        "authenticatorData": "zHUM-fXe8fPTc7IQdAU8xhonRmZeDznRqJqecdVRcUMFYfOzqg",
        "signature": "MEUCIHxzf1KZNJTb831gqw0oit-6ms8DoSXLaM8zyZ4Q6iyjAiEAwbguOZU2iJae_I8-Q7qlFwR45isZ-XYVMDgU2SkABU8",
        "userHandle": "Kosv9fPtkDoh4Oz7Yq_pVgWHS8HhdlCto5cR0aBoVMw",
        "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiRjVjSmhMRW00OFNpdGN6MzNiVm51NXpBMmEtRk5MYkxGbURfd1UwT1BIUSIsIm9yaWdpbiI6Imh0dHBzOi8vd2ViYXV0aG53b3Jrcy5naXRodWIuaW8iLCJjcm9zc09yaWdpbiI6ZmFsc2V9"
    },
    "getClientExtensionResults": {},
    "type": "public-key"
}

Decoding clientDataJSON looks like this:

{
    "type": "webauthn.get",
    "challenge": "F5cJhLEm48Sitcz33bVnu5zA2a-FNLbLFmD_wU0OPHQ",
    "origin": "https://webauthnworks.github.io",
    "crossOrigin": false
}

How do we verify one of these things? Another article from this author - https://medium.com/webauthnworks/verifying-fido2-responses-4691288c8770 - discusses that.

Notes:

Questions:

  • The sample code below doesn’t work if my system is connected but how do my phone and browser connect? It says “Make sure both devices are connected to the Internet and have Bluetooth turned on”.
    • Answer: If you turn off Bluetooth on your laptop Passkey creation will fail.

Sample code from https://webauthnworks.github.io/FIDO2WebAuthnSeries/WebAuthnIntro/makeCredExample.html which is referenced from the medium post I built my notes from:

<script>
    var makeCredsSample = () => {
        var challenge = new Uint8Array(32);
        window.crypto.getRandomValues(challenge);

        var userID = 'Kosv9fPtkDoh4Oz7Yq/pVgWHS8HhdlCto5cR0aBoVMw='
        var id = Uint8Array.from(window.atob(userID), c=>c.charCodeAt(0))

        var publicKey = {
            'challenge': challenge,

            'rp': {
                'name': 'Example Inc.'
            },

            'user': {
                'id': id,
                'name': 'alice@example.com',
                'displayName': 'Alice von Wunderland'
            },

            'pubKeyCredParams': [
                { 'type': 'public-key', 'alg': -7  },
                { 'type': 'public-key', 'alg': -257 }
            ]
        }

        navigator.credentials.create({ 'publicKey': publicKey })
            .then((newCredentialInfo) => {
                alert('Open your browser console!')
                console.log('SUCCESS', newCredentialInfo)
                console.log('ClientDataJSON: ', bufferToString(newCredentialInfo.response.clientDataJSON))
                let attestationObject = CBOR.decode(newCredentialInfo.response.attestationObject);
                console.log('AttestationObject: ', attestationObject)
                let authData = parseAuthData(attestationObject.authData);
                console.log('AuthData: ', authData);
                console.log('CredID: ', bufToHex(authData.credID));
                console.log('AAGUID: ', bufToHex(authData.aaguid));
                console.log('PublicKey', CBOR.decode(authData.COSEPublicKey.buffer));
            })
            .catch((error) => {
                alert('Open your browser console!')
                console.log('FAIL', error)
            })
    }
</script>
<script src="../lib/base64url-arraybuffer.js"></script>
<script src="../lib/cbor.js"></script>
<script src="../lib/helpers.js"></script>
<script src="../lib/server.sample.js"></script>

Microsoft’s webauthntest repo shows how to do WebAuthn, but is it useful for passkeys?

Medium post about WebAuthn and passkeys

Glossary:

  • WebAuthn - the client API
  • CTAP - “Client to Authenticator Protocols” - the authenticator API
  • CTAP2 - second version of CTAP that uses CBOR, version difference can mostly be ignored since libraries deal with it
  • Platform - operating system and the FIDO client APIs, could be system APIs or a browser
  • Platform authenticators - an authenticator built into the OS that uses system APIs. Available on almost all iOS, Android, MacOS, and Windows devices.

Unknowns:

  • “keypairs might be either platform or cross-platform authenticators” - What does this mean?