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.
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).
| 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 |
"none" attestation format |
✅ Implemented |
| Packed self-attestation (no x5c) | ✅ Implemented — signature fully verified |
| Packed basic attestation (x5c present) | |
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 |
| 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 |
| EdDSA / Ed25519 | ✅ Implemented |
| 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.
use webauthn::{RelyingParty, AuthenticatorAttestationResponse,
AuthenticatorAssertionResponse, Challenge};
// 1. Configure the relying party once, at startup.
let rp = RelyingParty::new("example.com", "https://example.com", "My Service");
// ── Registration ──────────────────────────────────────────
// 2. Generate a challenge and send it to the browser.
let reg_challenge = Challenge::new()?;
// 3. Browser calls navigator.credentials.create() and returns:
let reg_response = AuthenticatorAttestationResponse {
client_data_json: todo!("raw bytes from browser response"),
attestation_object: todo!("raw bytes from browser response"),
};
// 4. Verify and store the credential.
let result = rp.verify_registration(®_challenge, ®_response, b"user-id-42")?;
let mut stored = result.credential; // persist this in your database
// ── Authentication ────────────────────────────────────────
// 5. Issue a new challenge (never reuse challenges).
let auth_challenge = Challenge::new()?;
// 6. Browser calls navigator.credentials.get() and returns:
let auth_response = AuthenticatorAssertionResponse {
client_data_json: todo!("raw bytes from browser response"),
authenticator_data: todo!("raw bytes from browser response"),
signature: todo!("raw bytes from browser response"),
user_handle: None,
};
// 7. Verify, then update the stored sign count.
let auth_result = rp.verify_authentication(&stored, &auth_challenge, &auth_response)?;
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.
Simulates full ES256 and RS256 registration → authentication → replay attack rejection entirely in software (no browser, no server):
cargo run --example demoExpected: the final line is All checks passed.
Runs a real Axum HTTP server on port 3000 with all WebAuthn endpoints:
cargo run --example serverTest it with curl:
# Health check
curl http://localhost:3000/health
# Registration begin
curl -s -X POST http://localhost:3000/register/begin \
-H 'Content-Type: application/json' \
-d '{"user_id":"dXNlcjE","username":"alice"}' | jq .
# Registration complete (paste session_id and challenge from begin response,
# then build clientDataJSON/attestationObject from your authenticator)
# Authentication begin
curl -s -X POST http://localhost:3000/authenticate/begin \
-H 'Content-Type: application/json' \
-d '{"credential_id":"<base64url-credential-id>"}' | jq .cargo test # all 168+ unit + integration + doc tests
cargo clippy -- -D warnings # lint (zero-warning policy)
cargo fmt --check # formatting
cargo doc --no-deps # API docs (zero warnings)
cargo package --dry-run # crates.io readiness check-
Origin binding —
clientDataJSON.originmust exactly equalexpected_origin. This defeats cross-origin replays (a credential frombank.comcannot be used atevil.com). -
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.
-
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.
| 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 |
- 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.
#![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.
#![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.
| 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 |
base64 0.22 |
URL-safe base64 encoding/decoding |
thiserror 1 |
Structured, descriptive error types |
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.
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.
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.
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.
- 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
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.