Passkeys are Kinda Great


AJ ONeal

@_beyondcode
twitch.tv/coolaj86
github.com/therootcompany


Deep-Learner

Dangerous Wrong Thinker


Gun for Hire


🐹 Go πŸ“¦ Node 🦎 Zig

πŸ›œ Net πŸ” Sec 🐧 POSIX


aj@therootcompany.com


Act I: The Happy Pathβ„’


Brought to you in part by


What are Passkeys?


  1. A WebAuthn subset that's implemented* and works.
    (WebAuthn rebranded)

  2. The Password Manager, Reloadedβ„’.
    (High-entropy, push-button)

Platform Support



Product Support


Too New, Too Fancy?






(true progressive enhancement)


Demo


https://passkeys.js.org


The Magicβ„’ (Three Modes)

  1. Register (webauthn.create)
    autofillCtrl.abort();
    let credRegistration = await navigator.credentials.create(registrationOpts);
    
  2. Login (webauthn.get)
    autofillCtrl.abort();
    let credAttestation = await navigator.credentials.get(loginOpts);
    
  3. Autofill (webauthn.get, mediation = "conditional")
    let autofillCtrl = new AbortController();
    void navigator.credentials.get(autofillOpts).then(onAutofill);
    

Registration

  1. 🌎 Generate Challenge
  2. 🌎 Get Credential IDs (i.e. by email)
  3. πŸ‘¨β€πŸ’» Register Credential (webauthn.create)
  4. 🌎 Store Credential (one-to-many)

Assertion

  1. 🌎 Generate Challenge
  2. 🌎 Get Credential IDs (i.e. by email)
  3. πŸ‘¨β€πŸ’» Authenticate Credential (webauthn.get)
  4. 🌎 Verify Message & Signature
  5. 🌎 (optional) Update Counter




Can you use Passkeys without a Library?



No.

(well, maybe)


Closer to the Metal

Maybe you're better off learning than off-loading.


Flat is better than nested.
- Zen of Python

A little copying is better than a little dependency.
- Go Proverbs

Favor reading code over writing code.
- Zen of Zig


... but not the Metal

Use abstractions to avoid confusion or intense tedium.






(some assembly required, and... nuance)

Auth Vocabulary

Term Meaning
Authc Password, Magic Link,
Passkey, OTP,
"Sign in with ...", etc
Authn authentication
(who has access)
Authz authorization
(what can be accessed)

Registration 1 of 4: Challenge Vocabulary

Term Meaning
Registration Add a Passkey to Profile (new or updated)
Challenge a server-side nonce
Nonce arbitrary single-use data, such as a salt
Salt random bytes, to make a hash unpredictable
Base64 -
URL Base64 Base64 but ( + / = ) ➑️ ( - _ ␑ )

Registration 1 of 4: 🌎 Challenge

let challengeLen = 64;
let challengeBytes = new Uint8Array(challengeLen);
globalThis.crypto.randomValues(challengeBytes);

let challenge = Bytes.bufferToBase64(challengeBytes, Bytes.URL_BASE64);

DB.Nonces.set(challenge, {
  /* ... */
});

Registration 3 of 4: 🌎 Credential IDs

app.get(`/api/passkeys-credential-ids`, async function (req, res) {
  let credentialIds = await DB.Customers.getCredentialIds(req.body.email);

  res.json({
    credentialIds: credentialIds,
  });
});

Registration 3 of 4: Credential Vocabulary

Term Meaning
Authenticator Authenticator, the Device or Platform
Platform the browser, OS, plugin, or service
Credential Public Key (P-256 or ed25519)
Credential ID Authenticator's Pairwise ID
Pairwise Tied to two (or more) things
Relying Party domain name (the OG origin)
Attestation details about the passkey device or service

* note: Passkey isn't on the list


Registration 3 of 4: πŸ‘¨β€πŸ’» Credential

let relyingParty = {
  id: location.hostname, // cookie rules
  name: "My Brand",
};

let displayName = "...";
let email = "...";

let challenge = await App.getChallengeFor(email);
let credentialIds = await App.getCredIdsFor(email);

let userSecretLen = 64;
let userSecretBytes = new Uint8Array(userSecretLen);
globalThis.crypto.randomValues(userSecretBytes);

if (!autofillCtrl) {
  autofillCtrl = new AbortController();
}

let registrationOpts = {
  mediation: "optional", // on button click
  signal: autofillCtrl.signal,
  publicKey: {
    attestation: "direct",
    authenticatorSelection: {
      residentKey: "required",
      userVerification: "preferred",
    },
    challenge: challenge,
    excludeCredentials: credentialIds, // IMPORTANT (authcs)
    pubKeyCredParams: [
      { type: "public-key", alg: -7 }, // ECDSA P-256
    ],
    rp: relyingParty,
    timeout: 180 * 1000,
    user: {
      name: email,
      displayName: displayName,
      id: userSecretBytes, // NOT an ID
    },
  },
};

Registration 3 of 4: πŸ‘¨β€πŸ’» Credential

autofillCtrl.abort();
let credential = await navigator.credentials.create(registrationOpts);

console.log(credential); // 😱

function registrationToJSON(cred) {
  let authenticatorData = cred.response.getAuthenticatorData();
  let asn1Pubkey = cred.response.getPublicKey(); // ArrayBuffer
  let coseKeyType = cred.response.getPublicKeyAlgorithm(); // -7

  let jsonCred = {
    authenticatorAttachment: cred.authenticatorAttachment, // "platform"
    id: cred.id,
    rawId: Bytes.bufferToBase64(cred.rawId), // same as cred.id
    response: {
      attestationObject: Bytes.bufferToBase64(cred.response.attestationObject),
      authenticatorData: Bytes.bufferToBase64(authenticatorData),
      clientDataJSON: Bytes.bufferToBase64(cred.response.clientDataJSON),
      publicKey: Bytes.bufferToBase64(asn1Pubkey),
      publicKeyAlgorithm: coseKeyType,
      // ["usb", "ble", "nfc", "internal"]
      transports: cred.response.getTransports(),
    },
    type: cred.type, // "webauthn.create"
  };

  return jsonCred;
}




Fully Expanded Passkey Objects + JSDoc types
https://github.com/BeyondCodeBootcamp/passkeys/issues/6

Registration 4 of 4: 🌎 Store

// πŸ‘¨β€πŸ’»
await Customer.register(emailToVerify, credential);

// or (authenticated)
await Customer.addPasskey(verifiedEmail, credential);
// 🌎
app.post(`/api/customers`, async function (req, res) {
  mustMostlyValidatePasskey(req.body.passkey);
  DB.Passkeys.store(req.customer.email, req.body.passkey);
});

Registration 4 of 4: 🌎 Store

CREATE TABLE "customer" (
  "id" UUID PRIMARY KEY,
  "username" VARCHAR(100) DEFAULT NULL,
  "given_name" VARCHAR(100) DEFAULT NULL,
  "family_name" VARCHAR(100) DEFAULT NULL,
  "zoneinfo" VARCHAR(255) NOT NULL DEFAULT '',
  "locale" VARCHAR(255) NOT NULL DEFAULT '',
  "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
);

* note the absence of 'email', 'phone', 'password'


Registration 4 of 4: 🌎 Store

CREATE TABLE "authenticator" (
  "id" UUID NOT NULL,
  "customer_id" UUID NOT NULL,
  "priority" BIGINT NOT NULL CHECK ("priority" > 0),
  "type" VARCHAR(16) NOT NULL,
  "value" VARCHAR(255) NOT NULL,
  "details" JSON NOT NULL DEFAULT '{}',
  "verified_at" TIMESTAMP DEFAULT NULL,
  "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  "revoked_at" TIMESTAMP DEFAULT NULL,
  PRIMARY KEY ("id"),
  UNIQUE ("id", "priority"),
  UNIQUE ("id", "type", "value"),
  CONSTRAINT "authenticator_customer_id_foreign"
    FOREIGN KEY ("customer_id") REFERENCES "customer" ("id")
);

Assertion 1 of 5: 🌎 Challenge (same)

let challengeLen = 64;
let challengeBytes = new Uint8Array(challengeLen);
globalThis.crypto.randomValues(challengeBytes);

let challenge = Bytes.bufferToBase64(challengeBytes, Bytes.URL_BASE64);

DB.Nonces.set(challenge, {
  /* ... */
});

Assertion 2 of 5: 🌎 Credential IDs (same)

app.get(`/api/passkeys-credential-ids`, async function (req, res) {
  let credentialIds = await DB.Customers.getCredentialIds(req.body.email);

  res.json({
    credentialIds: credentialIds,
  });
});

Assertion 2 of 5: 🌎 Credential

const AUTOFILL = "conditional";
const BUTTON = "optional";

let assertionOpts = {
  mediation: BUTTON,
  signal: autofillCtrl.signal,
  publicKey: {
    challenge: challenge,
    rpId: relyingParty.id,
    timeout: 180 * 1000,
    userVerification: "preferred",
  },
};

autofillCtrl.abort(); //let autofillCtrl = new AbortController();
let assertion = await navigator.credentials.get(assertionOpts);

Assertion 2 of 5: 🌎 Credential

function assertionToJSON(assert) {
  let jsonCred = {
    authenticatorAttachment: assert.authenticatorAttachment, // "platform"
    id: assert.id,
    rawId: Bytes.bufferToBase64(assert.rawId), // same as assert.id
    response: {
      authenticatorData: Bytes.bufferToBase64(authenticatorData),
      clientDataJSON: Bytes.bufferToBase64(assert.response.clientDataJSON),
      signature: Bytes.bufferToBase64(assert.response.signature)
      publicKey: Bytes.bufferToBase64(asn1Pubkey),
      publicKeyAlgorithm: coseKeyType,
      getUserSecret: function () {
        return assert.response.userHandle;
      }
    },
    type: assert.type, // "webauthn.get"
  };

  return jsonCred;
}

signature

FIN


Registration 2 of 3: πŸ‘¨β€πŸ’» Credential

Term Meaning
JSON -
Private Key x in f(x) => y, random bytes
Public Key y in f(x) => y, the result of the random bytes
Hash lossy matrix math (tables) on a sequence of bytes
Signature key + salt + matrix math + curve (alt public key)

Passkeys Vocabulary (cont. 3)

SKIP (we'll circle back)

Term Meaning
Assertion signed server-side challenge (salt)
CBOR binary JSON
COSE binary JOSE (JWT)
AttToBeSigned a bespoke hash of the bespoke data
ASN.1 / DER binary XML
P1363 WebCrypto Signatures
(bespoke) one-off, proprietary to WebAuthn
FooCredential non-implemented WebAuthn stuff (we don't care)

Registration: Challenge


List, Delete?

No.


On to the Implementation


Platform & Autofill Support

let passkeyCredential = globalThis.PublicKeyCredential;

let hasPlatformSupport =
  await passkeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable();

let hasAutofillSupport =
  await passkeyCredential?.isConditionalMediationAvailable();
let passkeySupport = hasPlatformSupport && hasAutofillSupport;

No Platform, No Autofill, No Service

Just Say No πŸ™…β€β™€οΈ

diminishing returns on stuff your mom will never know


Process

  1. (server) Challenge Generation
  2. Credential Registration (webauthn.create)
  3. (server) Credential Storage
  4. Credential Authentication (webauthn.get)
    • (also called assertion)
  5. (server) Credential Verification

2.b

let credAuthOpts = {
  mediation: "optional", // on button click
  signal: autofillCtrl.signal,
  publicKey: {
    attestation: "direct",
    authenticatorSelection: {
      residentKey: "required",
      userVerification: "preferred",
    },
    challenge: challenge,
    excludeCredentials: credentialIds, // IMPORTANT (authcs)
    pubKeyCredParams: [
      { type: "public-key", alg: -7 }, // ECDSA P-256
    ],
    rp: relyingParty,
    timeout: 180 * 1000,
    user: {
      name: email,
      displayName: displayName,
      id: userSecretBytes, // NOT an ID
    },
  },
};

1.d Registration

References


Caveats


Context of Passkeys

*


Act II: The Quagmireβ„’


The subset of WebAuthn that's implemented* and works.

*: some tedious but critical assembly required

also *: most of WebAuthn will never be implemented


Unsupportable Features

These will probably never be supported:

unlikely that all vendors will agree


Good Key Types:


Bad Key Types


Diving Deep

0.b Challenge

Bytes.RFC_BASE64 = true;
Bytes.URL_BASE64 = false;

/**
 * @param {Uint8Array|ArrayBuffer?} buffer
 * @param {Boolean} [rfc]
 */
Bytes.bufferToBase64 = function (buffer, rfc) {
  let bytes = new Uint8Array(buffer);
  let binstr = String.fromCharCode.apply(null, bytes);
  let rfcBase64 = btoa(binstr);
  if (rfc) {
    return rfcBase64;
  }

  Bytes.rfcBase64ToUrlBase64(rfcBase64);
};

0.c Challenge

/**
 * @param {String} rfcBase64
 */
Bytes.rfcBase64ToUrlBase64 = function (rfcBase64) {
  let urlBase64 = rfcBase64.replace(/=+$/g, "");
  urlBase64 = urlBase64.replace(/[/]/g, "_");
  urlBase64 = urlBase64.replace(/[+]/g, "-");

  return urlBase64;
};

What's WebAuthn?

A suite of specifications and standards related to browser-based authentication.

FIDO rebranded.

(enterprise, government, lots of backwards compatibility)