1use anyhow::{Context, Result, bail, ensure};
12use aws_lc_rs::signature::{ECDSA_P256_SHA256_ASN1, ED25519, UnparsedPublicKey};
13use base64::Engine as _;
14use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
15use ciborium::Value as Cbor;
16use serde_json::{Value, json};
17use sha2::{Digest, Sha256};
18
19use tokio_postgres::GenericClient;
20
21use crate::db::Db;
22
23pub const ES256: i64 = -7;
24pub const EDDSA: i64 = -8;
25
26const CHALLENGE_SECS: i64 = 300;
28
29#[derive(Clone, Debug)]
31pub struct Rp {
32 pub id: String,
34 pub origin: String,
36}
37
38impl Rp {
39 pub fn from_public_url(url: &str) -> Result<Self> {
40 let u = url::Url::parse(url).context("public_url isn't a URL")?;
41 let id = u.host_str().context("public_url has no host")?.to_owned();
42 Ok(Self { id, origin: u.origin().ascii_serialization() })
43 }
44}
45
46pub fn user_handle(player: i64) -> Vec<u8> {
48 player.to_be_bytes().to_vec()
49}
50
51pub fn player_of_handle(h: &[u8]) -> Option<i64> {
52 Some(i64::from_be_bytes(h.try_into().ok()?))
53}
54
55pub fn creation_options(rp: &Rp, handle: &[u8], name: &str, challenge: &[u8], exclude: &[Vec<u8>]) -> Value {
57 json!({
58 "challenge": B64.encode(challenge),
59 "rp": {"id": rp.id, "name": "open-chess"},
60 "user": {"id": B64.encode(handle), "name": name, "displayName": name},
61 "pubKeyCredParams": [{"type": "public-key", "alg": ES256}, {"type": "public-key", "alg": EDDSA}],
62 "timeout": CHALLENGE_SECS * 1000,
63 "attestation": "none",
64 "authenticatorSelection": {"residentKey": "required", "requireResidentKey": true, "userVerification": "required"},
65 "excludeCredentials": exclude.iter().map(|id| json!({"type": "public-key", "id": B64.encode(id)})).collect::<Vec<_>>(),
66 })
67}
68
69pub fn request_options(rp: &Rp, challenge: &[u8], allow: &[Vec<u8>]) -> Value {
72 json!({
73 "challenge": B64.encode(challenge),
74 "rpId": rp.id,
75 "timeout": CHALLENGE_SECS * 1000,
76 "userVerification": "required",
77 "allowCredentials": allow.iter().map(|id| json!({"type": "public-key", "id": B64.encode(id)})).collect::<Vec<_>>(),
78 })
79}
80
81#[derive(Debug, PartialEq, Eq)]
83pub struct NewCredential {
84 pub id: Vec<u8>,
85 pub alg: i64,
86 pub public_key: Vec<u8>,
88 pub sign_count: u32,
89}
90
91fn check_client_data(rp: &Rp, kind: &str, challenge: &[u8], client_data_json: &[u8]) -> Result<()> {
92 let c: Value = serde_json::from_slice(client_data_json).context("clientDataJSON")?;
93 ensure!(c["type"] == kind, "not a {kind} response");
94 let got = B64.decode(c["challenge"].as_str().unwrap_or_default()).context("challenge")?;
95 ensure!(got == challenge, "challenge doesn't match");
96 ensure!(c["origin"] == rp.origin.as_str(), "made for another site ({})", c["origin"]);
97 ensure!(c["crossOrigin"] != true, "cross-origin use isn't allowed");
98 Ok(())
99}
100
101struct AuthData<'a> {
103 flags: u8,
104 sign_count: u32,
105 rest: &'a [u8],
107}
108
109const FLAG_UP: u8 = 0x01;
110const FLAG_UV: u8 = 0x04;
111const FLAG_AT: u8 = 0x40;
112
113fn parse_auth_data<'a>(rp: &Rp, d: &'a [u8]) -> Result<AuthData<'a>> {
114 ensure!(d.len() >= 37, "authenticator data too short");
115 ensure!(d[..32] == Sha256::digest(rp.id.as_bytes())[..], "for another site");
116 let flags = d[32];
117 ensure!(flags & FLAG_UP != 0, "user not present");
118 ensure!(flags & FLAG_UV != 0, "user not verified (PIN, fingerprint or face needed)");
119 let sign_count = u32::from_be_bytes(d[33..37].try_into()?);
120 Ok(AuthData { flags, sign_count, rest: &d[37..] })
121}
122
123fn cbor_int(v: &Cbor) -> Option<i64> {
124 v.as_integer().and_then(|i| i64::try_from(i).ok())
125}
126
127fn cose_get(map: &[(Cbor, Cbor)], key: i64) -> Option<&Cbor> {
128 map.iter().find(|(k, _)| cbor_int(k) == Some(key)).map(|(_, v)| v)
129}
130
131fn parse_cose_key(map: &[(Cbor, Cbor)]) -> Result<(i64, Vec<u8>)> {
133 let kty = cose_get(map, 1).and_then(cbor_int).context("key type")?;
134 let alg = cose_get(map, 3).and_then(cbor_int).context("algorithm")?;
135 let bytes = |k| cose_get(map, k).and_then(Cbor::as_bytes).cloned();
136 match (kty, alg) {
137 (2, ES256) => {
138 ensure!(cose_get(map, -1).and_then(cbor_int) == Some(1), "not a P-256 key");
139 let (x, y) = (bytes(-2).context("x")?, bytes(-3).context("y")?);
140 ensure!(x.len() == 32 && y.len() == 32, "bad P-256 key");
141 Ok((ES256, [&[4u8][..], &x, &y].concat()))
142 }
143 (1, EDDSA) => {
144 ensure!(cose_get(map, -1).and_then(cbor_int) == Some(6), "not an Ed25519 key");
145 let x = bytes(-2).context("x")?;
146 ensure!(x.len() == 32, "bad Ed25519 key");
147 Ok((EDDSA, x))
148 }
149 _ => bail!("unsupported key (type {kty}, algorithm {alg}): use a passkey with ES256 or Ed25519"),
150 }
151}
152
153pub fn verify_registration(
155 rp: &Rp,
156 challenge: &[u8],
157 client_data_json: &[u8],
158 attestation_object: &[u8],
159) -> Result<NewCredential> {
160 check_client_data(rp, "webauthn.create", challenge, client_data_json)?;
161 let att: Cbor = ciborium::from_reader(attestation_object).context("attestationObject")?;
162 let att = att.as_map().context("attestationObject isn't a map")?;
163 let auth_data = att
164 .iter()
165 .find(|(k, _)| k.as_text() == Some("authData"))
166 .and_then(|(_, v)| v.as_bytes())
167 .context("no authData")?;
168 let a = parse_auth_data(rp, auth_data)?;
169 ensure!(a.flags & FLAG_AT != 0, "no credential in the response");
170 let r = a.rest;
171 ensure!(r.len() >= 18, "credential data too short");
172 let id_len = usize::from(u16::from_be_bytes([r[16], r[17]]));
173 ensure!(r.len() >= 18 + id_len && id_len > 0 && id_len <= 1023, "bad credential id");
174 let id = r[18..18 + id_len].to_vec();
175 let key: Cbor = ciborium::from_reader(&r[18 + id_len..]).context("credential public key")?;
176 let (alg, public_key) = parse_cose_key(key.as_map().context("public key isn't a map")?)?;
177 Ok(NewCredential { id, alg, public_key, sign_count: a.sign_count })
178}
179
180pub struct Assertion<'a> {
184 pub client_data_json: &'a [u8],
185 pub authenticator_data: &'a [u8],
186 pub signature: &'a [u8],
187}
188
189pub fn verify_assertion(
190 rp: &Rp,
191 challenge: &[u8],
192 a: &Assertion<'_>,
193 alg: i64,
194 public_key: &[u8],
195 stored_count: u32,
196) -> Result<u32> {
197 check_client_data(rp, "webauthn.get", challenge, a.client_data_json)?;
198 let signed = [a.authenticator_data, &Sha256::digest(a.client_data_json)[..]].concat();
199 let signature = a.signature;
200 let a = parse_auth_data(rp, a.authenticator_data)?;
201 let algorithm: &dyn aws_lc_rs::signature::VerificationAlgorithm = match alg {
202 ES256 => &ECDSA_P256_SHA256_ASN1,
203 EDDSA => &ED25519,
204 _ => bail!("unsupported algorithm {alg}"),
205 };
206 UnparsedPublicKey::new(algorithm, public_key)
207 .verify(&signed, signature)
208 .map_err(|_| anyhow::anyhow!("passkey signature doesn't match"))?;
209 if (a.sign_count != 0 || stored_count != 0) && a.sign_count <= stored_count {
211 bail!("this passkey's counter went backwards: it may have been copied");
212 }
213 Ok(a.sign_count)
214}
215
216pub struct StoredKey {
220 pub id: i64,
221 pub player: i64,
222 pub handle: Vec<u8>,
223 pub alg: i64,
224 pub public_key: Vec<u8>,
225 pub sign_count: u32,
226}
227
228pub const REGISTER: &str = "register";
230pub const SIGNIN: &str = "signin";
231pub const SIGNUP: &str = "signup";
232pub const STAFF: &str = "staff";
233
234pub async fn new_challenge(
236 db: &Db,
237 c: &impl GenericClient,
238 purpose: &str,
239 player: Option<i64>,
240 data: Option<&Value>,
241) -> Result<(i64, Vec<u8>)> {
242 let challenge: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
243 c.execute(
244 &db.sql("DELETE FROM chess.webauthn_challenges WHERE created_at < now() - interval '1 hour'"),
245 &[],
246 )
247 .await?;
248 let id: i64 = c
249 .query_one(
250 &db.sql(
251 "INSERT INTO chess.webauthn_challenges (challenge, purpose, player_id, data)
252 VALUES ($1, $2, $3, $4) RETURNING id",
253 ),
254 &[&challenge, &purpose, &player, &data],
255 )
256 .await?
257 .get(0);
258 Ok((id, challenge))
259}
260
261pub struct Taken {
263 pub challenge: Vec<u8>,
264 pub player: Option<i64>,
265 pub data: Option<Value>,
266}
267
268pub async fn take_challenge(db: &Db, c: &impl GenericClient, id: i64, purpose: &str) -> Result<Taken> {
269 let r = c
270 .query_opt(
271 &db.sql(&format!(
272 "DELETE FROM chess.webauthn_challenges WHERE id = $1 AND purpose = $2
273 AND created_at > now() - interval '{CHALLENGE_SECS} seconds'
274 RETURNING challenge, player_id, data"
275 )),
276 &[&id, &purpose],
277 )
278 .await?
279 .context("that passkey request has expired: try again")?;
280 Ok(Taken { challenge: r.get(0), player: r.get(1), data: r.get(2) })
281}
282
283pub async fn add(
284 db: &Db,
285 c: &impl GenericClient,
286 player: i64,
287 handle: &[u8],
288 name: &str,
289 k: &NewCredential,
290) -> Result<i64> {
291 let r = c
292 .query_one(
293 &db.sql(
294 "INSERT INTO chess.passkeys (player_id, user_handle, credential_id, public_key, alg, sign_count, name)
295 VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id",
296 ),
297 &[&player, &handle, &k.id, &k.public_key, &(k.alg as i32), &i64::from(k.sign_count), &name],
298 )
299 .await
300 .map_err(|e| {
301 if e.code() == Some(&tokio_postgres::error::SqlState::UNIQUE_VIOLATION) {
302 anyhow::anyhow!("that passkey is already registered")
303 } else {
304 anyhow::Error::new(e)
305 }
306 })?;
307 Ok(r.get(0))
308}
309
310pub async fn ids(db: &Db, c: &impl GenericClient, player: i64) -> Result<Vec<Vec<u8>>> {
312 Ok(c.query(&db.sql("SELECT credential_id FROM chess.passkeys WHERE player_id = $1"), &[&player])
313 .await?
314 .iter()
315 .map(|r| r.get(0))
316 .collect())
317}
318
319pub async fn by_credential(db: &Db, c: &impl GenericClient, credential: &[u8]) -> Result<Option<StoredKey>> {
320 Ok(c.query_opt(
321 &db.sql(
322 "SELECT k.id, k.player_id, k.user_handle, k.alg, k.public_key, k.sign_count
323 FROM chess.passkeys k JOIN chess.players p ON p.id = k.player_id
324 WHERE k.credential_id = $1 AND p.disabled_at IS NULL",
325 ),
326 &[&credential],
327 )
328 .await?
329 .map(|r| StoredKey {
330 id: r.get(0),
331 player: r.get(1),
332 handle: r.get(2),
333 alg: i64::from(r.get::<_, i32>(3)),
334 public_key: r.get(4),
335 sign_count: u32::try_from(r.get::<_, i64>(5)).unwrap_or(u32::MAX),
336 }))
337}
338
339pub async fn used(db: &Db, c: &impl GenericClient, id: i64, count: u32) -> Result<()> {
340 c.execute(
341 &db.sql("UPDATE chess.passkeys SET sign_count = $2, last_used_at = now() WHERE id = $1"),
342 &[&id, &i64::from(count)],
343 )
344 .await?;
345 Ok(())
346}
347
348#[derive(Debug, serde::Deserialize)]
350pub struct AssertionJson {
351 pub challenge_id: i64,
352 pub id: String,
353 pub client_data_json: String,
354 pub authenticator_data: String,
355 pub signature: String,
356 #[serde(default)]
357 pub user_handle: Option<String>,
358}
359
360#[derive(Debug, serde::Deserialize)]
362pub struct AttestationJson {
363 pub challenge_id: i64,
364 pub client_data_json: String,
365 pub attestation_object: String,
366 #[serde(default)]
367 pub name: Option<String>,
368}
369
370pub fn b64(what: &str, s: &str) -> Result<Vec<u8>> {
371 B64.decode(s.trim()).with_context(|| format!("{what} isn't base64url"))
372}
373
374pub async fn check_assertion(
377 db: &Db,
378 c: &impl GenericClient,
379 rp: &Rp,
380 purpose: &str,
381 a: &AssertionJson,
382 want: Option<i64>,
383) -> Result<(i64, i64)> {
384 let taken = take_challenge(db, c, a.challenge_id, purpose).await?;
385 let cred = b64("id", &a.id)?;
386 let key = by_credential(db, c, &cred).await?.context("that passkey isn't registered here")?;
387 if let Some(p) = want.or(taken.player) {
388 ensure!(key.player == p, "that passkey belongs to someone else");
389 }
390 if let Some(h) = &a.user_handle
391 && !h.is_empty()
392 {
393 ensure!(b64("user_handle", h)? == key.handle, "that passkey belongs to someone else");
394 }
395 let count = verify_assertion(
396 rp,
397 &taken.challenge,
398 &Assertion {
399 client_data_json: &b64("client_data_json", &a.client_data_json)?,
400 authenticator_data: &b64("authenticator_data", &a.authenticator_data)?,
401 signature: &b64("signature", &a.signature)?,
402 },
403 key.alg,
404 &key.public_key,
405 key.sign_count,
406 )?;
407 used(db, c, key.id, count).await?;
408 Ok((key.player, key.id))
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use aws_lc_rs::rand::SystemRandom;
415 use aws_lc_rs::signature::{ECDSA_P256_SHA256_ASN1_SIGNING, EcdsaKeyPair, Ed25519KeyPair, KeyPair as _};
416
417 fn rp() -> Rp {
418 Rp::from_public_url("https://esports.example.com").unwrap()
419 }
420
421 fn client_data(kind: &str, challenge: &[u8], origin: &str) -> Vec<u8> {
422 json!({"type": kind, "challenge": B64.encode(challenge), "origin": origin}).to_string().into_bytes()
423 }
424
425 fn auth_data(rp_id: &str, flags: u8, count: u32, attested: &[u8]) -> Vec<u8> {
426 [&Sha256::digest(rp_id.as_bytes())[..], &[flags], &count.to_be_bytes(), attested].concat()
427 }
428
429 fn cbor(v: &Cbor) -> Vec<u8> {
430 let mut out = Vec::new();
431 ciborium::into_writer(v, &mut out).unwrap();
432 out
433 }
434
435 fn i(n: i64) -> Cbor {
436 Cbor::Integer(n.into())
437 }
438
439 fn register_es256(rp: &Rp, challenge: &[u8]) -> (EcdsaKeyPair, Vec<u8>, Vec<u8>, Vec<u8>) {
441 let rng = SystemRandom::new();
442 let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap();
443 let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref()).unwrap();
444 let point = kp.public_key().as_ref().to_vec(); let cose = Cbor::Map(vec![
446 (i(1), i(2)),
447 (i(3), i(ES256)),
448 (i(-1), i(1)),
449 (i(-2), Cbor::Bytes(point[1..33].to_vec())),
450 (i(-3), Cbor::Bytes(point[33..].to_vec())),
451 ]);
452 let cred_id = b"cred-1".to_vec();
453 let attested =
454 [&[0u8; 16][..], &(cred_id.len() as u16).to_be_bytes(), &cred_id, &cbor(&cose)].concat();
455 let ad = auth_data(&rp.id, FLAG_UP | FLAG_UV | FLAG_AT, 0, &attested);
456 let att = cbor(&Cbor::Map(vec![
457 (Cbor::Text("fmt".into()), Cbor::Text("none".into())),
458 (Cbor::Text("attStmt".into()), Cbor::Map(vec![])),
459 (Cbor::Text("authData".into()), Cbor::Bytes(ad)),
460 ]));
461 (kp, cred_id, client_data("webauthn.create", challenge, &rp.origin), att)
462 }
463
464 #[test]
465 fn es256_register_and_sign_in() {
466 let rp = rp();
467 let (kp, cred_id, cd, att) = register_es256(&rp, b"reg-challenge");
468 let k = verify_registration(&rp, b"reg-challenge", &cd, &att).unwrap();
469 assert_eq!((k.id.clone(), k.alg, k.public_key.len()), (cred_id, ES256, 65));
470 assert!(verify_registration(&rp, b"other", &cd, &att).is_err());
472 let evil = Rp::from_public_url("https://evil.example").unwrap();
473 assert!(verify_registration(&evil, b"reg-challenge", &cd, &att).is_err());
474
475 let rng = SystemRandom::new();
476 let ad = auth_data(&rp.id, FLAG_UP | FLAG_UV, 5, &[]);
477 let cd = client_data("webauthn.get", b"login", &rp.origin);
478 let sig = kp.sign(&rng, &[&ad[..], &Sha256::digest(&cd)[..]].concat()).unwrap();
479 assert_eq!(
480 verify_assertion(
481 &rp,
482 b"login",
483 &Assertion { client_data_json: &cd, authenticator_data: &ad, signature: sig.as_ref() },
484 ES256,
485 &k.public_key,
486 4
487 )
488 .unwrap(),
489 5
490 );
491 assert!(
493 verify_assertion(
494 &rp,
495 b"login",
496 &Assertion { client_data_json: &cd, authenticator_data: &ad, signature: sig.as_ref() },
497 ES256,
498 &k.public_key,
499 5
500 )
501 .is_err()
502 );
503 let mut bad = ad.clone();
504 bad[36] ^= 1;
505 assert!(
506 verify_assertion(
507 &rp,
508 b"login",
509 &Assertion { client_data_json: &cd, authenticator_data: &bad, signature: sig.as_ref() },
510 ES256,
511 &k.public_key,
512 0
513 )
514 .is_err()
515 );
516 let up_only = auth_data(&rp.id, FLAG_UP, 6, &[]);
517 let sig2 = kp.sign(&rng, &[&up_only[..], &Sha256::digest(&cd)[..]].concat()).unwrap();
518 assert!(
519 verify_assertion(
520 &rp,
521 b"login",
522 &Assertion { client_data_json: &cd, authenticator_data: &up_only, signature: sig2.as_ref() },
523 ES256,
524 &k.public_key,
525 0
526 )
527 .is_err()
528 );
529 }
530
531 #[test]
532 fn eddsa_sign_in_with_synced_counter() {
533 let rp = Rp::from_public_url("http://localhost:8090").unwrap();
534 let rng = SystemRandom::new();
535 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
536 let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
537 let cose = Cbor::Map(vec![
538 (i(1), i(1)),
539 (i(3), i(EDDSA)),
540 (i(-1), i(6)),
541 (i(-2), Cbor::Bytes(kp.public_key().as_ref().to_vec())),
542 ]);
543 let (alg, pk) = parse_cose_key(cose.as_map().unwrap()).unwrap();
544 let ad = auth_data("localhost", FLAG_UP | FLAG_UV, 0, &[]);
545 let cd = client_data("webauthn.get", b"c", "http://localhost:8090");
546 let sig = kp.sign(&[&ad[..], &Sha256::digest(&cd)[..]].concat());
547 assert_eq!(
549 verify_assertion(
550 &rp,
551 b"c",
552 &Assertion { client_data_json: &cd, authenticator_data: &ad, signature: sig.as_ref() },
553 alg,
554 &pk,
555 0
556 )
557 .unwrap(),
558 0
559 );
560 assert_eq!(player_of_handle(&user_handle(42)), Some(42));
561 }
562}