webauthn

WebAuthn (W3C Level 3) relying-party library for Crystal: passkeys, CBOR and COSE decoding, ES256 and RS256 verification

= 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

== Documentation

== ALOLI conventions

This shard follows the https://github.com/aloli-crystal[aloli-crystal/*] ecosystem conventions:

  • VERSION constant read at compile-time from shard.yml — cannot drift.
  • CLI accepts webauthn help [<sub>] (UX-standard equivalent of --help + per-subcommand focus).
  • Default branch is production (never main).
  • 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.

Repository

webauthn

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 2
  • about 2 hours ago
  • September 26, 2026
License

MIT License

Links
Synced at

Sat, 26 Sep 2026 19:52:28 GMT

Languages