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
Act I: The Happy Pathβ’
Brought to you in part by
- PaperOS: https://paperos.com
- Kyle Simpson: https://x.com/@getifyX
What are Passkeys?
- A WebAuthn subset that's implemented* and works.
(WebAuthn rebranded)
- 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?
Demo
The Magicβ’ (Three Modes)
- Register (
webauthn.create
)autofillCtrl.abort(); let credRegistration = await navigator.credentials.create(registrationOpts);
- Login (
webauthn.get
)autofillCtrl.abort(); let credAttestation = await navigator.credentials.get(loginOpts);
- Autofill (
webauthn.get
,mediation = "conditional"
)let autofillCtrl = new AbortController(); void navigator.credentials.get(autofillOpts).then(onAutofill);
Registration
- π Generate Challenge
- π Get Credential IDs (i.e. by email)
- π¨βπ» Register Credential (
webauthn.create
) - π Store Credential (one-to-many)
Assertion
- π Generate Challenge
- π Get Credential IDs (i.e. by email)
- π¨βπ» Authenticate Credential (
webauthn.get
) - π Verify Message & Signature
- π (optional) Update Counter
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.
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;
}
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
- (server) Challenge Generation
- Credential Registration (
webauthn.create
) - (server) Credential Storage
- Credential Authentication (
webauthn.get
)- (also called
assertion
)
- (also called
- (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
- https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create
- https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions
- https://caniuse.com/mdn-api_publickeycredential
- https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions
- https://caniuse.com/mdn-api_credentialscontainer_create_publickey_option_extensions
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)