Osy#betaRun it on Osyrin: osyrin.com

Reference / Function

WebAuthn.* and Passkey.* — the two halves of a passkey

WebAuthn.CreationOptions(…) · WebAuthn.RequestOptions(challenge, rpId, allow) · WebAuthn.Register(response, challenge, origin, rpId) → string · WebAuthn.CredentialId(response) → string · WebAuthn.Verify(response, publicKey, storedSignCount, challenge, origin, rpId, userHandle) → long · Passkey.Create(options) → string · Passkey.Get(options) → string · Passkey.Supported() → bool

A passkey (W3C Web Authentication) has a server half and a browser half. `WebAuthn.*` runs on the server: it makes the options a browser is handed and checks what comes back — CBOR, COSE keys, ES256/RS256/EdDSA signatures, the origin, the relying party, user verification and the signature counter. `Passkey.Create`/`Passkey.Get` run in the browser and ask the person's device. `WebAuthn.NewTestKey`/`TestCreate`/`TestGet` are the device's half, for tests.

preview1 example compiled by CIfunctionstdlibsecurityauthentication

Summary#

If what you want is passkey sign-in, you do not need these directly — the UserAccounts kit has it, pages and all. These are what it is built on, for a sign-in of your own:

  • WebAuthn.CreationOptions(…) / WebAuthn.RequestOptions(…) — the options for the browser, carrying your challenge.
  • Passkey.Create(options) / Passkey.Get(options) — the browser asks the device; the answer is WebAuthn's own JSON, or "" when the person closed the prompt.
  • WebAuthn.Register(…) — check a new passkey and answer what to keep; WebAuthn.Verify(…) — check a sign-in and answer the new counter. Both throw a ValidationException saying what failed.

Signature#

WebAuthn.CreationOptions(<string> challenge, <string> rpId, <string> rpName, <string> userHandle, <string> userName,
                         <string> displayName, <string[]> exclude) -> string
WebAuthn.RequestOptions(<string> challenge, <string> rpId, <string[]> allow) -> string
WebAuthn.Register(<string> response, <string> challenge, <string> origin, <string> rpId) -> string   // JSON
WebAuthn.CredentialId(<string> response) -> string
WebAuthn.Verify(<string> response, <string> publicKey, <long> storedSignCount, <string> challenge, <string> origin,
                <string> rpId, <string> userHandle) -> long
Passkey.Create(<string> options) -> string      // in the browser
Passkey.Get(<string> options) -> string         // in the browser
Passkey.Supported() -> bool                     // in the browser
WebAuthn.NewTestKey() -> string                 // the device's half, for tests
WebAuthn.TestCredentialId(<string> key) -> string
WebAuthn.TestCreate(<string> key, <string> options, <string> origin) -> string
WebAuthn.TestGet(<string> key, <string> options, <string> origin, <string> userHandle, <long> signCount,
                 <bool> userVerified) -> string

Description#

What do the options ask for?#

CreationOptions asks for a discoverable credential (it signs in with no address typed), user verification required, ES256, EdDSA or RS256, no attestation, and excludes the exclude credential ids so one device is not registered twice. RequestOptions with an empty allow lets the browser offer every passkey it holds for the app; with an account's ids it asks for one of those. The challenge you pass is what the browser signs — mint it per ceremony, keep its hash, and spend it on the first answer.

What does Register answer?#

JSON — { "CredentialId", "PublicKey", "SignCount", "BackedUp", "Algorithm" } — which you deserialize into a class of your own and keep: the public key is not a secret; the credential id is how a sign-in finds it. Attestation is not checked: there is no trust anchor to check it against when any make of authenticator is welcome.

What does Verify check?#

That the client data is a webauthn.get for THIS challenge from THIS origin, not framed by another site; that the authenticator data is for THIS relying party with the person present AND verified; that the user handle, when there is one, is userHandle; that the signature verifies under the stored key; and that the counter moved forward when either it or the stored one is not zero (a synced passkey reports zero every time). It answers the new counter to store.

origin is the page's scheme, host and port (https://acme.osyrin.app); rpId is the host alone. Read both off the app's own address — new Uri(App.BaseUrl) — never off anything the browser sends.

Why does a closed prompt answer ""?#

The browser reports a dismissed prompt and a timed-out one alike, on purpose, and neither is a fault: nothing happened. Every other refusal — a page not on the app's own address, a device already holding a passkey for the account — is a DeviceUnavailableException with the browser's reason.

How does a test play the device?#

WebAuthn.NewTestKey() makes a P-256 key held as text; TestCreate(key, options, origin) and TestGet(key, options, origin, userHandle, signCount, userVerified) answer exactly what a browser's create and get would — real CBOR, real signatures — so the same Register and Verify check them. Pass another origin, a counter that goes back, or userVerified: false to watch each refusal. TestCredentialId(key) is the id TestCreate gives the passkey.

Where does each run?#

WebAuthn.* is server-only: a page that checked its own authenticator's answer would prove nothing to anybody. Passkey.* is browser-only: the device is reached through navigator.credentials.

Examples#

entity Key {
  [Required, Unique, MaxLength(1400)] string CredentialId;
  [Required, MaxLength(2000)] string PublicKey;
  long SignCount = 0;
  security { allow read, create, update when IsAuthenticated; }
}

class Kept { public string CredentialId = ""; public string PublicKey = ""; public long SignCount = 0; }

string Origin() { var u = new Uri(App.BaseUrl); return u.Scheme + "://" + u.Authority; }
string Site() { return new Uri(App.BaseUrl).Host; }

/// Keep the passkey `answer` registered for `challenge`.
void Keep(string answer, string challenge) {
  var kept = JsonSerializer.Deserialize<Kept>(WebAuthn.Register(answer, challenge, Origin(), Site()));
  new Key { CredentialId = kept.CredentialId, PublicKey = kept.PublicKey, SignCount = kept.SignCount };
}

/// Is `answer` a good sign-in for `challenge` by the account whose handle is `handle`?
bool Check(string answer, string challenge, string handle) {
  var id = WebAuthn.CredentialId(answer);
  var key = Key.FirstOrDefault(k => k.CredentialId == id);
  if (key == null) { return false; }
  key.SignCount = WebAuthn.Verify(answer, key.PublicKey, key.SignCount, challenge, Origin(), Site(), handle);
  return true;
}

See also#

Related

Passkeys — sign in with the device, no password and no code

Every app on the UserAccounts kit signs people in with passkeys: a key pair their phone, laptop or security key makes…

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…

Totp.NewSecret, Totp.Uri, Totp.Verify, Totp.Code

Authenticator-app codes (RFC 6238 — the six-digit, thirty-second codes every authenticator app shows), which is what a…

Security.* — hashing, verifying, tickets, random ids

The calls an authentication flow needs: `HashPassword` (salted, one-way), `VerifyPassword` (constant-work comparison…