webauthn
= aloli-crystal/webauthn :toc: left :toc-title: Contents
WebAuthn (W3C Level 3) relying-party library for Crystal: passkeys, CBOR and COSE decoding, ES256 and RS256 verification.
== Why
A passkey leaves no shared secret on the server. The private key is held by the user's device — macOS Keychain, Windows Hello, a TPM, a security key — and unlocked by the device itself, by biometrics or the session password. All the server ever stores is a public key: a leak of the credential table yields nothing reusable. And because a signature is bound to the origin it was made for, it cannot be replayed against a phishing site.
An authenticator with user verification required is therefore already multi-factor in a single gesture: possession of the device, plus the thing that unlocks it. Stacking a one-time code on top adds nothing.
This shard implements the relying party — the server side. It does not talk to authenticators; the browser does that. It verifies what comes back.
== What works today
Work in progress. What is implemented and specced:
[cols="1,3",options="header"] |=== |Component |State
|WebAuthn::CBOR |Decoder for the CBOR subset WebAuthn uses (RFC 8949). Strict by design: this parses attacker-reachable input, so indefinite lengths, tags, floats, duplicate map keys, over-long declared lengths and deep nesting are all refused rather than tolerated.
|WebAuthn::COSE |COSE_Key decoding (RFC 8152) for EC2 and RSA, mapped onto https://github.com/aloli-crystal/jose[jose] keys, with signature verification. An algorithm the relying party did not offer is refused, so a caller cannot downgrade the negotiation.
|WebAuthn::AuthenticatorData |The binary record the authenticator signs over (§6.1), including attested credential data and its variable-length COSE key.
|WebAuthn::ClientData |What the browser vouches for (§5.8.1): ceremony type, challenge and origin.
|Registration ceremony |RelyingParty#verify_registration (§7.1). Attestation none only.
|Authentication ceremony |RelyingParty#verify_authentication (§7.2), including the signature counter check. |===
Algorithms offered by default are ES256 and RS256. ES256 covers Apple and Android platform authenticators and most security keys; RS256 covers Windows Hello, which commonly enrols with it. Offering only one shuts out an entire population of users.
Out of scope for now: attestation statement verification beyond none — this shard does not try to prove which make and model of authenticator was used, which is rarely what a relying party actually needs and carries privacy consequences.
== Install
[source,yaml]
dependencies: webauthn: github: aloli-crystal/webauthn version: "~> 0.1"
== Quickstart
[source,crystal]
require "webauthn"
The RP ID is effectively permanent: change it and every passkey already
registered stops being offered. Set it to the registrable domain suffix
shared by every instance that should accept the same key.
rp = WebAuthn::RelyingParty.new( id: "noalyss.example", origins: ["https://client1.noalyss.example"] )
--- Registration -----------------------------------------------------
challenge = WebAuthn.generate_challenge # store it server-side, single use
credential = rp.verify_registration( attestation_object: params["attestationObject"], client_data_json: params["clientDataJSON"], challenge: challenge )
Persist credential.id, its public key, and credential.sign_count.
--- Authentication ---------------------------------------------------
challenge = WebAuthn.generate_challenge
assertion = rp.verify_authentication( credential: stored_credential, authenticator_data: params["authenticatorData"], client_data_json: params["clientDataJSON"], signature: params["signature"], challenge: challenge )
Persist assertion.sign_count — skipping that turns the cloned-authenticator
check into a no-op.
Both ceremonies raise WebAuthn::VerificationError rather than returning a boolean, so a caller cannot forget to check the result. A suspected clone raises WebAuthn::ClonedAuthenticatorError, which is worth distinguishing: the signature was valid, so it is not a forgery — the credential exists in two places, and the user should be told.
User verification is required by default. That is what makes a passkey multi-factor on its own; pass user_verification: to relax it.
== Status
- Version : see https://github.com/aloli-crystal/webauthn/releases[GitHub releases]
- Default branch :
production - License : MIT (see
LICENSE)
== Documentation
- https://aloli-crystal.github.io/webauthn/[Antora doc site] (upcoming)
CHANGELOG.adoc— version historyCHANGELOG.fr.adoc— French version
== ALOLI conventions
This shard follows the https://github.com/aloli-crystal[aloli-crystal/*] ecosystem conventions:
VERSIONconstant read at compile-time fromshard.yml— cannot drift.- CLI accepts
webauthn help [<sub>](UX-standard equivalent of--help+ per-subcommand focus). - Default branch is
production(nevermain). - SemVer 3-digit for original creations, 4-digit for ports of Ruby gems.
To start a new shard with these conventions, see https://github.com/aloli-crystal/shard-template[aloli-crystal/shard-template].
== License
MIT — see LICENSE.
webauthn
- 0
- 0
- 0
- 0
- 2
- about 2 hours ago
- September 26, 2026
MIT License
Sat, 26 Sep 2026 19:52:28 GMT