Opaque, Interoperable Passkey Records (and a Go API)
Hacker News

Opaque, Interoperable Passkey Records (and a Go API)

Comments

Opaque, Interoperable Passkey Records (and a Go API)

Passkeys are the most important thing happening in information security right now because they are the only principled solution to the overwhelming effectiveness of phishing attacks. Just like memory safety is the only principled solution to memory corruption attacks. Unfortunately, implementing them on the server side can appear more complex than using password hashes. Part of this is unavoidable because passkeys require interaction with the browser to get their phishing resistance properties. Part of it, however, could be abstracted away a little more effectively by defining interoperable passkey record encodings.

The Problem

The WebAuthn specification defines a credential record as an abstract concept with a number of components such as type, id, publicKey, backupState, transports, and other flags. Google recommends a database table with a Credential ID primary key, and public_key, backed_up, and transports columns. Adam Langley’s stellar Tour of WebAuthn similarly recommends a cred_id primary key, and separate public_key_spki and backed_up columns.

All guidance recommends using libraries to handle WebAuthn authentication, but that still leaves applications with a potentially non-interoperable database schema. It might also be impractical to defer the whole authentication flow and database interaction to a library or framework. Interoperable, well-specified passkey records that the application can handle as opaque strings, like password hashes, can be a middle-ground abstraction layer.

Proposed Solution: PHC-style Passkey Records

c2sp.org/passkey-record is a specification proposal which borrows the syntax of Password Hashing Competition (PHC) Strings and reuses the existing authenticator data encoding for the bulk of the work. A record looks like this:

$webauthn$v=1$transports=hybrid+internal$

The payload is the authenticator data, a CTAP2 CBOR encoding of most of the credential record fields that is already specified by WebAuthn and included in the JSON encoding of an AuthenticatorAttestationResponse (which is the return type of navigator.credentials.create() even if attestation is not in use). Transports are the only missing field, and they are stored as PHC parameters.

The application is then only in charge of keeping track of what passkey records are associated with a user account, which is a task that is familiar to web developers because it is not dissimilar to implementing password authentication (except there are multiple passkeys per account). These opaque strings can be passed to a library to verify the login assertion (or to generate a registration request with the appropriate excludedCredentials). With a well-specified interoperable storage format, it will hopefully be possible to switch passkey library (or even backend language) while preserving a credentials database.

Other Fields to Store

Along with the passkey record, applications might still wish to store metadata fields like a user-selected nickname and creation and last use timestamps, to provide pretty passkey management UIs. None of these require any special treatment by the WebAuthn library.

The one exception is the backed up state flag. Passkeys report to the server whether they are backed up (e.g. to iCloud Keychain or a Google account), and servers can use that signal to suggest removing a password from an account. This flag can change across logins, while the passkey record is immutable, so it would have to be stored separately and updated on every login. I think this logic is overrated for the average website, which will keep supporting email password resets anyway.

A Potential crypto/passkey Go API

Building on top of these passkey records, I drafted a potential crypto/passkey stateless Go package API.

Registration flow

  1. Call RelyingParty.NewRegistration with the logged-in (or otherwise identified) user details and any existing passkey records.
  2. Pass the returned JSON to parseCreationOptionsFromJSON() and then to navigator.credentials.create().
  3. Pass the returned JSON-encoded PublicKeyCredential to RelyingParty.Register.
  4. Store the returned passkey record in the database.

Login flow

  1. Call RelyingParty.NewLogin while generating the log-in page.
  2. Store the returned request in a key-value cache with a short TTL, under RequestID(request), and pass the returned JSON to parseRequestOptionsFromJSON() and then to navigator.credentials.get().
  3. Pass the returned JSON-encoded PublicKeyCredential to Inspect, and use the returned requestID to retrieve the request from the key-value cache and use the returned userID to retrieve the passkey records from the database.
  4. Pass the JSON PublicKeyCredential, the request, and the passkey records to RelyingParty.Login.

Application responsibilities

  • Associating an opaque, permanent, privacy-preserving user ID with each user;
  • Storing passkey records associated with a user; and
  • Caching request challenges produced by RelyingParty.NewLogin.

The library provides JSON values that can be passed straight to parseCreationOptionsFromJSON() and parseRequestOptionsFromJSON(), and accepts JSON values returned by calling JSON.stringify() on a PublicKeyCredential. This is optimized for the discoverable credential flow (a.k.a. passkeys, where the authenticator stores and provides to the server the user ID) but the RelyingParty.NewLoginForUser method can also be used for second-factor flows or re-authentication prompts.

The API works for both modal and conditional UI (autofill) flows. There are a few helpers to extract information from the passkey record (AAGUID, BackedUp) and from the JSON-encoded PublicKeyCredential (ResponseBackedUp). Currently, there is no implementation; I would like to get feedback on the passkey record format and on the Go API, before potentially making a proposal for Go 1.28.

On Duplicate Credential IDs

One thing you can’t do with this storage model is ensure that different accounts don’t share passkeys with the same Credential ID, which the spec says you SHOULD do. The reason for that check is avoiding attacks where you look up a credential by its ID and land at the wrong public key or user ID because the attacker intentionally injected a colliding Credential ID through their own account.

This attack simply can’t happen if you don’t have a Credential ID index in the first place! The index is only needed to mitigate an attack introduced by the existence of the index. Login attempts carry the user ID, and if you use that to look up the user’s passkey records to verify the login against, it doesn’t matter if some other user has a passkey with the same Credential ID, just like it doesn’t matter if two users share a password. Don’t let the attacker dictate your PRIMARY KEY and you won’t have PRIMARY KEY collision attacks.

For more Go API previews, follow me on Bluesky at @filippo.abyssdomain.expert or on Mastodon at @filippo@abyssdomain.expert.

The picture

More from this year’s CENTOPASSI (a GPS-tracked motorcycle competition involving careful planning, 100 coordinates, and 1700 km of secondary roads over three and a half days). Here’s a glimpse of Castel del Monte (AQ), after climbing down from a deserted and still snowy Campo Imperatore.

Support and Acknowledgments

My work is made possible by Geomys, an organization of professional Go maintainers, which is funded by Ava Labs, Teleport, Datadog, Tailscale, and Sentry. Through our retainer contracts they ensure the sustainability and reliability of our open source maintenance work and get a direct line to my expertise and that of the other Geomys maintainers. (Learn more in the Geomys announcement.)

Here are a few words from some of them!

Teleport - For the past five years, attacks and compromises have been shifting from traditional malware and security breaches to identifying and compromising valid user accounts and credentials with social engineering, credential theft, or phishing. Teleport Identity is designed to eliminate weak access patterns through access monitoring, minimize attack surface with access requests, and purge unused permissions via mandatory access reviews.

Ava Labs - We at Ava Labs, maintainer of AvalancheGo (the most widely used client for interacting with the Avalanche Network), believe the sustainable maintenance and development of open source cryptographic protocols is critical to the broad adoption of blockchain technology. We are proud to support this necessary and impactful work through our ongoing sponsorship of Filippo and his team.

Comments

No comments yet. Start the discussion.