Skip to content

ivanx000/WebAuthn

Repository files navigation

CI Rust License

WebAuthn

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
"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
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.


Quick start

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(&reg_challenge, &reg_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.


Running the examples

End-to-end demo

Simulates full ES256 and RS256 registration → authentication → replay attack rejection entirely in software (no browser, no server):

cargo run --example demo

Expected: the final line is All checks passed.

HTTP server

Runs a real Axum HTTP server on port 3000 with all WebAuthn endpoints:

cargo run --example server

Test 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 .

Running tests

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

Security considerations

What the library verifies

  • Origin bindingclientDataJSON.origin must exactly equal expected_origin. This defeats cross-origin replays (a credential from bank.com cannot be used at evil.com).

  • RP ID binding — the authenticator data's rpIdHash is verified to equal SHA-256(rp_id). This binds the credential to the relying party identifier.

  • Challenge freshness — the challenge in clientDataJSON must 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 using ring:

    • 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)
  • 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 bindingtokenBinding in clientDataJSON is ignored.
  • Cloned authenticators with zero counters — if sign_count == 0 the spec allows accepting the assertion (the authenticator simply doesn't count). Clone detection is unavailable in this case.
  • Side-channel attacksring'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
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



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.

About

Server-side WebAuthn/Passkey library in Rust. Implements the W3C WebAuthn specification including registration and authentication ceremonies, CBOR parsing, ES256 signature verification, and replay attack prevention.

Resources

License

Unknown, MIT licenses found

Licenses found

Unknown
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors