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


  • Baselineβ„’ Browser Support
    • Brave, Chrome, Edge, Firefox, Safari,
    • Android Browser, UC Browser, WebViews

  • Universal OS / Cloud Support
    • Android, Chrome OS, iOS, Linux, macOS, Windows
    • iCloud, Google Sync, Microsoft... thingy (?)

Product Support

  • Android thingy (?)
  • Face ID
  • Touch ID
  • YubiKey
  • Windows Hello

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

  1. let challengeLen = 64
  2. let challengeBytes = new Uint8Array(challengeLen); 
  3. globalThis.crypto.randomValues(challengeBytes); 
  4.  
  5. let challenge = Bytes.bufferToBase64(challengeBytes, Bytes.URL_BASE64); 
  6.  
  7. DB.Nonces.set(challenge, { 
  8. /* ... */ 
  9. }); 
  10.  

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

  1. let relyingParty = { 
  2. id: location.hostname, // cookie rules 
  3. name: "My Brand", 
  4. }; 
  5.  
  6. let displayName = "..."; 
  7. let email = "..."; 
  8.  
  9. let challenge = await App.getChallengeFor(email); 
  10. let credentialIds = await App.getCredIdsFor(email); 
  11.  
  12. let userSecretLen = 64
  13. let userSecretBytes = new Uint8Array(userSecretLen); 
  14. globalThis.crypto.randomValues(userSecretBytes); 
  15.  
  16. if (!autofillCtrl) { 
  17. autofillCtrl = new AbortController(); 
  18.  

  1. let registrationOpts = { 
  2. mediation: "optional", // on button click 
  3. signal: autofillCtrl.signal, 
  4. publicKey: { 
  5. attestation: "direct", 
  6. authenticatorSelection: { 
  7. residentKey: "required", 
  8. userVerification: "preferred", 
  9. }, 
  10. challenge: challenge
  11. excludeCredentials: credentialIds, // IMPORTANT (authcs) 
  12. pubKeyCredParams: [ 
  13. { type: "public-key", alg: -7 }, // ECDSA P-256 
  14. ], 
  15. rp: relyingParty
  16. timeout: 180 * 1000
  17. user: { 
  18. name: email
  19. displayName: displayName
  20. id: userSecretBytes, // NOT an ID 
  21. }, 
  22. }, 
  23. }; 
  24.  

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

  1. autofillCtrl.abort(); 
  2. let credential = await navigator.credentials.create(registrationOpts); 
  3.  
  4. console.log(credential); // 😱 
  5.  

  1. function registrationToJSON(cred) { 
  2. let authenticatorData = cred.response.getAuthenticatorData(); 
  3. let asn1Pubkey = cred.response.getPublicKey(); // ArrayBuffer 
  4. let coseKeyType = cred.response.getPublicKeyAlgorithm(); // -7 
  5.  
  6. let jsonCred = { 
  7. authenticatorAttachment: cred.authenticatorAttachment, // "platform" 
  8. id: cred.id, 
  9. rawId: Bytes.bufferToBase64(cred.rawId), // same as cred.id 
  10. response: { 
  11. attestationObject: Bytes.bufferToBase64(cred.response.attestationObject), 
  12. authenticatorData: Bytes.bufferToBase64(authenticatorData), 
  13. clientDataJSON: Bytes.bufferToBase64(cred.response.clientDataJSON), 
  14. publicKey: Bytes.bufferToBase64(asn1Pubkey), 
  15. publicKeyAlgorithm: coseKeyType
  16. // ["usb", "ble", "nfc", "internal"] 
  17. transports: cred.response.getTransports(), 
  18. }, 
  19. type: cred.type, // "webauthn.create" 
  20. }; 
  21.  
  22. return jsonCred
  23.  



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)

  1. let challengeLen = 64
  2. let challengeBytes = new Uint8Array(challengeLen); 
  3. globalThis.crypto.randomValues(challengeBytes); 
  4.  
  5. let challenge = Bytes.bufferToBase64(challengeBytes, Bytes.URL_BASE64); 
  6.  
  7. DB.Nonces.set(challenge, { 
  8. /* ... */ 
  9. }); 
  10.  

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

  • Stronger Authentication for You
  • More Convenient for Your Customers
  • Single Factor
  • One-to-Many
  • A Common* Subset

*

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:

  • passive / background / silent authentication
  • cross-origin authentication

unlikely that all vendors will agree

Good Key Types:

  • βœ… -7 ECDSA P-256 (a.k.a. ES256, prime256v1)
  • ⚠️ -8 EdDSA ed25519 (caniuse)

Bad Key Types

  • ❌ RSA
  • ❌ ECDSA P-256k (a.k.a. secp256k1)
  • ❌ the other 200+ 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)

(this slide unintentionally left blank)