Skip to main content

open_chess_server/
passkeys.rs

1//! Passkeys (WebAuthn): registering them, signing in (or making an account)
2//! with one instead of a password, and the passkey check that switches on
3//! an admin's or manager's rights.
4//!
5//! The server is the relying party itself. Only what passkeys need: "none"-style trust
6//! (attestation statements aren't checked: the key is trusted because the
7//! signed-in person registered it), user verification required, and the
8//! two algorithms every platform authenticator offers, ES256 (P-256) and
9//! EdDSA (Ed25519). Signatures are checked with aws-lc-rs.
10
11use 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
26/// Challenges are good for this long.
27const CHALLENGE_SECS: i64 = 300;
28
29/// This app as a WebAuthn relying party.
30#[derive(Clone, Debug)]
31pub struct Rp {
32    /// The host name (`esports.example.com`, `localhost`).
33    pub id: String,
34    /// `https://esports.example.com` (or `http://localhost:8090`).
35    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
46/// The user handle passkeys carry: the player id, as 8 bytes.
47pub 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
55/// `navigator.credentials.create` options (binary fields base64url).
56pub 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
69/// `navigator.credentials.get` options. An empty `allow` lets the browser
70/// offer any of this site's passkeys (signing in without typing a name).
71pub 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/// A passkey just registered.
82#[derive(Debug, PartialEq, Eq)]
83pub struct NewCredential {
84    pub id: Vec<u8>,
85    pub alg: i64,
86    /// ES256: the uncompressed point (65 bytes); EdDSA: 32 bytes.
87    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
101/// Parsed authenticator data.
102struct AuthData<'a> {
103    flags: u8,
104    sign_count: u32,
105    /// Attested credential data (registration only).
106    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
131/// A COSE public key → (algorithm, raw key).
132fn 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
153/// Check a registration (`navigator.credentials.create`) response.
154pub 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
180/// Check a sign-in (`navigator.credentials.get`) response against a stored
181/// passkey; returns the new signature counter.
182/// What the browser returns from `navigator.credentials.get`.
183pub 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    // Synced passkeys keep 0; a counter that goes backwards means a copy.
210    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
216// ─────────────────────────── storage ───────────────────────────
217
218/// A stored passkey, for checking a sign-in.
219pub 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
228/// What a challenge was made for.
229pub const REGISTER: &str = "register";
230pub const SIGNIN: &str = "signin";
231pub const SIGNUP: &str = "signup";
232pub const STAFF: &str = "staff";
233
234/// A new challenge: (id, bytes). `data` rides along (a new account's name).
235pub 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
261/// A challenge taken (once, within five minutes): (bytes, player, data).
262pub 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
310/// A player's passkeys' credential ids.
311pub 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/// What the browser sent back from `navigator.credentials.get`, base64url.
349#[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/// What the browser sent back from `navigator.credentials.create`.
361#[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
374/// Check a sign-in against the challenge it answers: the passkey's id and
375/// player. `want` limits it to one player's passkeys (the staff check).
376pub 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    /// A fake platform authenticator with a P-256 key.
440    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(); // 0x04 || x || y
445        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        // Wrong challenge, wrong site.
471        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        // Replayed counter, tampered data, no user verification.
492        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        // Counter 0 stays 0 for synced passkeys.
548        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}