caden 0.7.0

Server-side WebAuthn/Passkey library implementing the W3C WebAuthn Level 2 specification
Documentation

CI Rust License

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 webauthn::{RelyingParty, AuthenticatorAttestationResponse,
               AuthenticatorAssertionResponse, Challenge};

// 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 = RelyingParty::new("example.com", "https://example.com", "My Service");
// — 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 = 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 209+ unit + integration + doc tests
cargo test --features serde       # +5 serde round-trip 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 one of the origins in allowed_origins. This defeats cross-origin replays (a credential from bank.com cannot be used at evil.com). RelyingParty::new accepts a single origin; RelyingParty::with_origins accepts a list for multi-environment deployments.

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

  • User verification (opt-in) — when require_user_verification(true) is set on the RelyingParty, 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 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 (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



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.