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 aValidationExceptionsaying 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) -> stringDescription#
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#
- Passkeys — sign in with the device, no password and no code — passkeys in every app on the UserAccounts kit
- The UserAccounts kit — sign-in done properly, once — the kit
- Totp.NewSecret, Totp.Uri, Totp.Verify, Totp.Code — the authenticator-app second step
- Security.* — hashing, verifying, tickets, random ids — hashing, random ids and the rest of the credential surface