webauthn/credential.rs
1//! Core domain types for stored credentials, challenges, and ceremony results.
2
3use std::collections::HashMap;
4use std::time::{Duration, SystemTime};
5
6use ciborium::value::Value;
7
8use ring::rand::{SecureRandom, SystemRandom};
9
10use crate::error::{Result, WebAuthnError};
11
12// ─── Public key ───────────────────────────────────────────────────────────────
13
14/// The public key extracted from a COSE key structure during registration.
15///
16/// Four algorithms are supported:
17/// - **ES256** — ECDSA P-256 with SHA-256 (COSE alg `-7`). Most common.
18/// - **ES384** — ECDSA P-384 with SHA-384 (COSE alg `-35`).
19/// - **EdDSA** — Ed25519 (COSE alg `-8`). Used by newer FIDO2 authenticators.
20/// - **RS256** — RSA PKCS#1 v1.5 with SHA-256 (COSE alg `-257`). Used by
21/// older YubiKey 4-series devices and Windows Hello.
22#[derive(Debug, Clone)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub enum PublicKey {
25 /// P-256 ECDSA public key (COSE alg `-7`, kty `2`).
26 ///
27 /// `x` and `y` are the 32-byte affine coordinates of the public point.
28 /// To obtain the 65-byte uncompressed point for ring, prepend `0x04`:
29 /// `0x04 || x (32 bytes) || y (32 bytes)`.
30 ES256 {
31 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
32 x: Vec<u8>,
33 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
34 y: Vec<u8>,
35 },
36
37 /// P-384 ECDSA public key (COSE alg `-35`, kty `2`).
38 ///
39 /// `x` and `y` are the 48-byte affine coordinates of the public point.
40 /// To obtain the 97-byte uncompressed point for ring, prepend `0x04`:
41 /// `0x04 || x (48 bytes) || y (48 bytes)`.
42 ES384 {
43 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
44 x: Vec<u8>,
45 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
46 y: Vec<u8>,
47 },
48
49 /// Ed25519 EdDSA public key (COSE alg `-8`, kty `1` OKP).
50 ///
51 /// The inner `Vec<u8>` is the raw 32-byte Ed25519 public key,
52 /// as encoded in COSE OKP key parameter `-2` (`x`).
53 EdDSA(#[cfg_attr(feature = "serde", serde(with = "serde_bytes"))] Vec<u8>),
54
55 /// RSA PKCS#1 v1.5 SHA-256 public key (COSE alg `-257`, kty `3`).
56 ///
57 /// `n` is the big-endian modulus (256 bytes for a 2048-bit key).
58 /// `e` is the big-endian public exponent (typically `[0x01, 0x00, 0x01]`).
59 RS256 {
60 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
61 n: Vec<u8>,
62 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
63 e: Vec<u8>,
64 },
65}
66
67impl PublicKey {
68 /// Return the COSE algorithm identifier for this key.
69 pub fn algorithm(&self) -> i64 {
70 match self {
71 PublicKey::ES256 { .. } => crate::algorithm::COSE_ES256,
72 PublicKey::ES384 { .. } => crate::algorithm::COSE_ES384,
73 PublicKey::EdDSA(_) => crate::algorithm::COSE_EDDSA,
74 PublicKey::RS256 { .. } => crate::algorithm::COSE_RS256,
75 }
76 }
77
78 /// Return a human-readable description of the key type.
79 pub fn key_type(&self) -> &'static str {
80 match self {
81 PublicKey::ES256 { .. } => "EC2 P-256",
82 PublicKey::ES384 { .. } => "EC2 P-384",
83 PublicKey::EdDSA(_) => "OKP Ed25519",
84 PublicKey::RS256 { .. } => "RSA 2048",
85 }
86 }
87}
88
89// ─── Stored credential ────────────────────────────────────────────────────────
90
91/// A registered credential persisted on the relying-party side after a
92/// successful registration ceremony.
93///
94/// The caller is responsible for storing this in a durable, server-side store
95/// keyed by `id` (the credential ID) and associated with `user_id`.
96#[derive(Debug, Clone)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98pub struct Credential {
99 /// Opaque byte string that uniquely identifies this credential.
100 /// Produced by the authenticator during registration.
101 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
102 pub id: Vec<u8>,
103
104 /// The authenticator's public key in the format signalled during registration.
105 pub public_key: PublicKey,
106
107 /// Monotonically increasing counter maintained by the authenticator.
108 /// Used to detect cloned authenticators.
109 pub sign_count: u32,
110
111 /// Application-defined identifier for the user this credential belongs to.
112 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
113 pub user_id: Vec<u8>,
114
115 /// Relying party ID (e.g. `"example.com"`).
116 /// Stored so authentication can verify the credential is bound to this RP.
117 pub rp_id: String,
118
119 /// When this credential was first registered.
120 pub created_at: SystemTime,
121
122 /// Whether this credential is eligible for backup to a platform sync
123 /// service (BE flag, §6.1 bit 3).
124 ///
125 /// This value is immutable per spec — the authenticator sets it once at
126 /// registration and it must not change in subsequent ceremonies. A change
127 /// in `backup_eligible` between registration and authentication is treated
128 /// as a credential substitution attempt and rejected with
129 /// [`crate::error::WebAuthnError::BackupEligibilityChanged`].
130 pub backup_eligible: bool,
131
132 /// Whether this credential is currently backed up (BS flag, §6.1 bit 4).
133 ///
134 /// This value may change between ceremonies as the user's backup state
135 /// varies. After a successful authentication ceremony, update this field
136 /// to `AuthenticationResult.backup_state` before persisting the credential.
137 pub backup_state: bool,
138}
139
140// ─── Wire-format input types ──────────────────────────────────────────────────
141
142/// The response produced by the authenticator after `navigator.credentials.create()`.
143///
144/// Both fields carry the **raw decoded bytes** — base64url decoding happens
145/// outside the library before constructing this struct. This matches the
146/// ArrayBuffer values you get after calling `response.clientDataJSON` in JS.
147#[derive(Debug, Clone)]
148pub struct AuthenticatorAttestationResponse {
149 /// Raw UTF-8 bytes of the `clientDataJSON` object.
150 pub client_data_json: Vec<u8>,
151
152 /// Raw CBOR bytes of the `attestationObject`.
153 pub attestation_object: Vec<u8>,
154}
155
156// ─── Challenge ────────────────────────────────────────────────────────────────
157
158/// A single-use challenge issued by the relying party before a ceremony.
159///
160/// **Security contract**: each `Challenge` must be used at most once and must
161/// expire after a short window (typically 60–300 seconds). The caller is
162/// responsible for enforcing both properties.
163#[derive(Debug, Clone)]
164#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
165pub struct Challenge {
166 /// 32 cryptographically random bytes.
167 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
168 pub bytes: Vec<u8>,
169
170 /// When this challenge was generated — used for expiry checks.
171 pub created_at: SystemTime,
172}
173
174impl Challenge {
175 /// Generate a fresh 32-byte challenge using the OS cryptographic RNG.
176 ///
177 /// 32 bytes provides 256 bits of entropy — far beyond any brute-force threat.
178 ///
179 /// # Errors
180 /// Returns [`WebAuthnError::InvalidClientData`] if the system RNG fails
181 /// (extremely unlikely; would indicate a kernel-level failure).
182 pub fn new() -> Result<Self> {
183 let rng = SystemRandom::new();
184 let mut bytes = vec![0u8; 32];
185 rng.fill(&mut bytes).map_err(|_| {
186 WebAuthnError::InvalidClientData(
187 "system random number generator failed to produce bytes".to_string(),
188 )
189 })?;
190 Ok(Self {
191 bytes,
192 created_at: SystemTime::now(),
193 })
194 }
195
196 /// Returns `true` if this challenge is older than `ttl_secs` seconds.
197 ///
198 /// Returns `true` if the system clock has gone backwards since the challenge
199 /// was created — treating an unverifiable age as expired is the safe default.
200 pub fn is_expired(&self, ttl_secs: u64) -> bool {
201 self.created_at
202 .elapsed()
203 .map(|age| age >= Duration::from_secs(ttl_secs))
204 .unwrap_or(true)
205 }
206}
207
208// ─── Ceremony result types ────────────────────────────────────────────────────
209
210/// Successful outcome of a registration ceremony.
211#[derive(Debug)]
212#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
213pub struct RegistrationResult {
214 /// The newly registered credential — persist this in your database.
215 pub credential: Credential,
216
217 /// What kind of attestation the authenticator provided.
218 pub attestation_type: AttestationType,
219
220 /// Whether the registered credential is eligible for backup to a platform
221 /// sync service (BE flag). This value is set by the authenticator and is
222 /// immutable — it will not change in future ceremonies for this credential.
223 pub backup_eligible: bool,
224
225 /// Whether the credential was backed up at the time of registration (BS flag).
226 pub backup_state: bool,
227
228 /// Authenticator extension data from registration (§6.1 / §10.5), or `None` if the
229 /// ED flag was not set. Keys are extension identifiers (e.g. `"credProps"`); values
230 /// are raw CBOR that callers inspect themselves.
231 ///
232 /// Excluded from serde serialization: `ciborium::value::Value` has no portable JSON
233 /// encoding. Extract the values you need and convert them before serializing.
234 #[cfg_attr(feature = "serde", serde(skip))]
235 pub extensions: Option<HashMap<String, Value>>,
236}
237
238/// Successful outcome of an authentication ceremony.
239#[derive(Debug)]
240#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
241pub struct AuthenticationResult {
242 /// The credential ID used to authenticate.
243 #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
244 pub credential_id: Vec<u8>,
245
246 /// The sign count returned by the authenticator this ceremony.
247 /// Update the stored credential's `sign_count` to this value after success.
248 pub new_sign_count: u32,
249
250 /// Whether the User Present (UP) flag was set — the authenticator confirmed
251 /// that a human was at the device (button press, touch, etc.).
252 pub user_present: bool,
253
254 /// Whether the authenticator signalled that the user was verified
255 /// (biometric check, PIN, etc.) — corresponds to the UV flag.
256 pub user_verified: bool,
257
258 /// Whether the credential is eligible for backup (BE flag).
259 /// Should remain constant across authentications for a given credential.
260 pub backup_eligible: bool,
261
262 /// Whether the credential is currently backed up (BS flag).
263 /// May change between ceremonies as backup state varies.
264 pub backup_state: bool,
265
266 /// Authenticator extension data from authentication (§6.1 / §10.5), or `None` if the
267 /// ED flag was not set. Keys are extension identifiers (e.g. `"appid"`); values are
268 /// raw CBOR that callers inspect themselves.
269 ///
270 /// Excluded from serde serialization: `ciborium::value::Value` has no portable JSON
271 /// encoding. Extract the values you need and convert them before serializing.
272 #[cfg_attr(feature = "serde", serde(skip))]
273 pub extensions: Option<HashMap<String, Value>>,
274}
275
276/// The level of attestation the authenticator provided.
277#[derive(Debug, PartialEq, Eq)]
278#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
279pub enum AttestationType {
280 /// The authenticator explicitly provided no attestation (`"fmt": "none"`).
281 /// The credential is still usable, but device provenance cannot be verified.
282 None,
283
284 /// The attestation was signed by the same key used for authentication
285 /// (self-attestation). Proves the credential is fresh but not the device model.
286 SelfAttestation,
287
288 /// The attestation was signed by a separate attestation key with an `x5c`
289 /// certificate chain present. The chain order has been verified (each cert is
290 /// signed by the next), but the root has **not** been checked against a trust
291 /// anchor because none were configured on [`crate::RelyingParty`]. Device
292 /// provenance is structurally plausible but not cryptographically anchored.
293 Basic,
294
295 /// Same as [`Basic`](AttestationType::Basic) but the root certificate was
296 /// additionally verified to be signed by one of the trust anchors configured
297 /// via [`crate::RelyingParty::trust_anchors`]. Device provenance is
298 /// cryptographically anchored to the configured CA set.
299 BasicVerified,
300}
301
302// ─── Serde round-trip tests ───────────────────────────────────────────────────
303
304#[cfg(all(test, feature = "serde"))]
305mod serde_tests {
306 use std::time::{Duration, SystemTime};
307
308 use super::*;
309
310 fn epoch_plus(secs: u64) -> SystemTime {
311 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
312 }
313
314 #[test]
315 fn challenge_round_trips() {
316 let c = Challenge {
317 bytes: vec![0xDE, 0xAD, 0xBE, 0xEF],
318 created_at: epoch_plus(1_700_000_000),
319 };
320 let json = serde_json::to_string(&c).expect("test setup");
321 let back: Challenge = serde_json::from_str(&json).expect("test setup");
322 assert_eq!(back.bytes, c.bytes);
323 assert_eq!(back.created_at, c.created_at);
324 }
325
326 #[test]
327 fn public_key_es256_round_trips() {
328 let key = PublicKey::ES256 {
329 x: vec![0x01u8; 32],
330 y: vec![0x02u8; 32],
331 };
332 let json = serde_json::to_string(&key).expect("test setup");
333 let back: PublicKey = serde_json::from_str(&json).expect("test setup");
334 match back {
335 PublicKey::ES256 { x, y } => {
336 assert_eq!(x, vec![0x01u8; 32]);
337 assert_eq!(y, vec![0x02u8; 32]);
338 }
339 _ => panic!("wrong variant"),
340 }
341 }
342
343 #[test]
344 fn public_key_eddsa_round_trips() {
345 let key = PublicKey::EdDSA(vec![0x03u8; 32]);
346 let json = serde_json::to_string(&key).expect("test setup");
347 let back: PublicKey = serde_json::from_str(&json).expect("test setup");
348 match back {
349 PublicKey::EdDSA(bytes) => assert_eq!(bytes, vec![0x03u8; 32]),
350 _ => panic!("wrong variant"),
351 }
352 }
353
354 #[test]
355 fn public_key_rs256_round_trips() {
356 let key = PublicKey::RS256 {
357 n: vec![0x04u8; 256],
358 e: vec![0x01, 0x00, 0x01],
359 };
360 let json = serde_json::to_string(&key).expect("test setup");
361 let back: PublicKey = serde_json::from_str(&json).expect("test setup");
362 match back {
363 PublicKey::RS256 { n, e } => {
364 assert_eq!(n, vec![0x04u8; 256]);
365 assert_eq!(e, vec![0x01, 0x00, 0x01]);
366 }
367 _ => panic!("wrong variant"),
368 }
369 }
370
371 #[test]
372 fn credential_round_trips() {
373 let cred = Credential {
374 id: vec![0xAAu8; 16],
375 public_key: PublicKey::ES256 {
376 x: vec![0x01u8; 32],
377 y: vec![0x02u8; 32],
378 },
379 sign_count: 42,
380 user_id: vec![0xBBu8; 8],
381 rp_id: "example.com".to_string(),
382 created_at: epoch_plus(1_700_000_000),
383 backup_eligible: true,
384 backup_state: true,
385 };
386 let json = serde_json::to_string(&cred).expect("test setup");
387 let back: Credential = serde_json::from_str(&json).expect("test setup");
388 assert_eq!(back.id, cred.id);
389 assert_eq!(back.sign_count, cred.sign_count);
390 assert_eq!(back.user_id, cred.user_id);
391 assert_eq!(back.rp_id, cred.rp_id);
392 assert_eq!(back.created_at, cred.created_at);
393 assert_eq!(back.backup_eligible, cred.backup_eligible);
394 assert_eq!(back.backup_state, cred.backup_state);
395 }
396}