Caden
A WebAuthn relying-party library written in Rust.
This library implements the server-side ceremony verification logic for both WebAuthn flows — registration and authentication — following the W3C WebAuthn Level 3 specification. It is built as a portfolio project demonstrating practical applied cryptography, correct protocol implementation, and idiomatic Rust.
What are WebAuthn and Passkeys?
WebAuthn (Web Authentication) is a W3C standard that lets users authenticate to websites using public-key cryptography instead of passwords. When you register, the authenticator (your phone, laptop, or a hardware key) generates a keypair. The private key never leaves the device; the public key goes to the server. When you log in, the authenticator signs a server-issued challenge with the private key, and the server verifies the signature. An attacker who steals the server's database gets only public keys — useless without the corresponding private keys.
Passkeys are the consumer-facing name for WebAuthn credentials that sync across devices via platform ecosystems (iCloud Keychain, Google Password Manager, etc.). Technically, a passkey is a WebAuthn credential stored in a platform authenticator. The underlying cryptography is identical.
Both eliminate the biggest password risks: phishing (the credential is cryptographically bound to the origin), credential stuffing (public keys are worthless without private keys), and password reuse (each site gets a unique keypair).
What this library implements
| Feature | Status |
|---|---|
| Registration ceremony (W3C WebAuthn §7.1) | ✅ Implemented |
| Authentication ceremony (W3C WebAuthn §7.2) | ✅ Implemented |
| ES256 (ECDSA P-256 + SHA-256, COSE -7) | ✅ Implemented |
| RS256 (RSA PKCS#1 v1.5 + SHA-256, COSE -257) | ✅ Implemented |
| EdDSA / Ed25519 (COSE -8) | ✅ Implemented |
Multiple allowed origins (RelyingParty::with_origins) |
✅ Implemented |
"none" attestation format |
✅ Implemented |
| Packed self-attestation (no x5c) | ✅ Implemented — signature fully verified |
| Packed basic attestation (x5c present) | ⚠️ Detected, certificate chain not verified |
FIDO U2F attestation ("fido-u2f") |
✅ Implemented — signature verified; cert chain requires FIDO MDS |
Android Key attestation ("android-key") |
✅ Implemented — signature + key-match verified; cert chain requires FIDO MDS |
Apple attestation ("apple") |
✅ Implemented — nonce extension + key-match verified; cert chain requires Apple MDS |
UV flag enforcement (require_user_verification) |
✅ Implemented — opt-in via builder; off by default |
Algorithm allowlist (allowed_algorithms) |
✅ Implemented — opt-in via builder; empty = accept all |
| Sign-count replay attack detection | ✅ Implemented |
| Challenge generation (32-byte CSPRNG) | ✅ Implemented |
#![forbid(unsafe_code)] |
✅ Enforced at compile time |
| No-panic guarantee on adversarial input | ✅ #![deny(clippy::unwrap_used)] |
| Fixed test vectors (registration + authentication) | ✅ Implemented |
serde feature — Serialize/Deserialize on public types |
✅ Opt-in via features = ["serde"] |
| TPM attestation | ❌ Not implemented |
| Token binding | ❌ Not implemented |
| FIDO Metadata Service (MDS) lookup | ❌ Not implemented |
This scope is intentional. The library demonstrates mastery of the core protocol and cryptographic operations without adding surface area that obscures the design.
Quick start
use ;
// 1. Configure the relying party once, at startup.
// Use new() for a single origin, or with_origins() to accept multiple
// (e.g. production + localhost for local development).
let rp = new;
// — or —
// let rp = RelyingParty::with_origins(
// "example.com",
// ["https://example.com", "http://localhost:8080"],
// "My Service",
// );
// ── Registration ──────────────────────────────────────────
// 2. Generate a challenge and send it to the browser.
let reg_challenge = new?;
// 3. Browser calls navigator.credentials.create() and returns:
let reg_response = AuthenticatorAttestationResponse ;
// 4. Verify and store the credential.
let result = rp.verify_registration?;
let mut stored = result.credential; // persist this in your database
// ── Authentication ────────────────────────────────────────
// 5. Issue a new challenge (never reuse challenges).
let auth_challenge = new?;
// 6. Browser calls navigator.credentials.get() and returns:
let auth_response = AuthenticatorAssertionResponse ;
// 7. Verify, then update the stored sign count.
let auth_result = rp.verify_authentication?;
stored.sign_count = auth_result.new_sign_count;
Run the self-contained demo to see full registration → authentication → replay-attack sequences for both ES256 and RS256 without a browser:
cargo run --example demo
Expected output ends with: All checks passed.
Running the examples
End-to-end demo
Simulates full ES256 and RS256 registration → authentication → replay attack rejection entirely in software (no browser, no server):
Expected: the final line is All checks passed.
HTTP server
Runs a real Axum HTTP server on port 3000 with all WebAuthn endpoints:
Test it with curl:
# Health check
# Registration begin
|
# Registration complete (paste session_id and challenge from begin response,
# then build clientDataJSON/attestationObject from your authenticator)
# Authentication begin
|
Running tests
Security considerations
What the library verifies
-
Origin binding —
clientDataJSON.originmust exactly equal one of the origins inallowed_origins. This defeats cross-origin replays (a credential frombank.comcannot be used atevil.com).RelyingParty::newaccepts a single origin;RelyingParty::with_originsaccepts a list for multi-environment deployments. -
RP ID binding — the authenticator data's
rpIdHashis verified to equalSHA-256(rp_id). This binds the credential to the relying party identifier. -
Challenge freshness — the challenge in
clientDataJSONmust match the server-issued challenge byte-for-byte. Single-use enforcement is the caller's responsibility. -
User presence — the UP flag in authenticator data must be set. The authenticator confirmed that a human was physically present.
-
User verification (opt-in) — when
require_user_verification(true)is set on theRelyingParty, the UV flag must also be set. This enforces that the authenticator verified the user's identity (PIN, biometric) before signing. Disabled by default; enable for sensitive flows (payment authorization, privileged settings). -
Cryptographic signature — the signature over
authData || SHA-256(clientDataJSON)is verified usingring:- ES256: ECDSA P-256 with SHA-256 (
ring::signature::ECDSA_P256_SHA256_ASN1) - RS256: RSA PKCS#1 v1.5 with SHA-256 (
ring::signature::RSA_PKCS1_2048_8192_SHA256, minimum 2048-bit key enforced)
- ES256: ECDSA P-256 with SHA-256 (
-
Sign count — a non-zero received count must be strictly greater than the stored count. A violation (including wrap-around from u32::MAX → 0) indicates a possible cloned authenticator.
What the caller must provide
| Responsibility | Notes |
|---|---|
| Credential storage | A durable, indexed key-value store keyed by credential ID |
| Single-use challenges | Invalidate each challenge after it is used or expires |
| Challenge expiry | webauthn::challenge::is_expired() checks a 5-minute window |
| HTTPS | WebAuthn requires a secure context; enforce TLS at the transport layer |
| Sign-count update | After successful auth, write auth_result.new_sign_count back |
| User enumeration prevention | Return the same error for unknown vs. invalid credential |
What this library does NOT protect against
- Full attestation chain —
"none","packed"(self-attestation),"fido-u2f", and"android-key"are verified (signature and key-match checks). Certificate chain validation against the FIDO Metadata Service is not implemented for any format, so device provenance — distinguishing genuine hardware from a software emulator — cannot be fully confirmed. - Token binding —
tokenBindinginclientDataJSONis ignored. - Cloned authenticators with zero counters — if
sign_count == 0the spec allows accepting the assertion (the authenticator simply doesn't count). Clone detection is unavailable in this case. - Side-channel attacks —
ring's verifiers provide constant-time signature comparison, but this library does not claim constant-time credential lookups or error responses.
No-panic guarantee
#![deny(clippy::unwrap_used)] is enforced across all library code. .unwrap() is
a compile error; every code path on malformed or adversarial input returns a typed
WebAuthnError rather than panicking. Two fuzz-style tests
(no_panic_on_random_registration_input, no_panic_on_random_authentication_input)
pass 100 randomly-constructed inputs through each ceremony and assert no panic occurs.
No-unsafe guarantee
#![forbid(unsafe_code)] is active. No unsafe block can exist anywhere in this
crate's source. All memory-safety-sensitive work — key parsing, signature verification,
SHA-256 hashing, and random number generation — is handled inside ring's audited
boundary.
Tech stack
| Crate | Purpose |
|---|---|
ring 0.17 |
ECDSA P-256 + RSA PKCS#1 v1.5 signature verification, SHA-256, CSPRNG |
ciborium 0.2 |
CBOR decoding for authenticator data and attestation objects |
serde + serde_json 1 |
clientDataJSON parsing (always required) |
serde_bytes 0.11 |
Efficient Vec<u8> serialization (optional, enabled by features = ["serde"]) |
base64 0.22 |
URL-safe base64 encoding/decoding |
thiserror 1 |
Structured, descriptive error types |
Design decisions
ring over RustCrypto
ring descends from BoringSSL and has a longer audit lineage than the RustCrypto
family. Its API is intentionally narrow — you cannot accidentally use an insecure mode
that a more flexible library might expose. rustls and many production TLS stacks use
it, which gives confidence in its deployment history.
Hand-rolled DER over an ASN.1 library
RSA public keys must be presented to ring in DER format
(SEQUENCE { INTEGER n, INTEGER e }). The full DER encoding is 15 bytes of structure
around the key components. A dedicated ASN.1 library would add a dependency for work
that is simpler, clearer, and more auditable as a 30-line function in src/der.rs.
ciborium for CBOR
WebAuthn uses CBOR for the attestation object and COSE public keys. ciborium decodes
into a Value enum (like serde_json::Value), which the library navigates explicitly.
This keeps parsing code readable and maps one-to-one with the CBOR structures in the
spec. Serde-derive-based CBOR would hide the wire structure behind opaque attributes,
making it harder to audit against the spec.
#![forbid(unsafe_code)]
A security library that handles authentication credentials has a higher bar for memory
safety than average code. The attribute makes the guarantee machine-checked: no future
contributor can accidentally introduce unsafe code, and reviewers don't need to hunt for
it. The ring crate manages its own unsafe code inside a safe API boundary.
References
- W3C Web Authentication Level 3
- RFC 9052 — CBOR Object Signing and Encryption (COSE)
- RFC 3447 — RSA Cryptography Specifications (PKCS#1)
- FIDO Alliance CTAP2 specification
- NIST SP 800-63B — Digital Identity Guidelines
- passkeys.dev — developer documentation
Disclaimer
This is a portfolio project demonstrating a correct implementation of the WebAuthn
core ceremonies. It has not been security audited and is missing features required
for production use: full attestation certificate chain validation, FIDO Metadata
Service integration, and token binding. For production deployments, consider
webauthn-rs.