Skip to main content

sofie_identity/passkeys/
mod.rs

1mod cbor;
2mod screen;
3mod sign_in;
4mod webauthn;
5
6use std::time::Duration;
7
8use sofie::core::Instant;
9use sofie::core::encoding::base64::{self, URL_SAFE};
10use sofie::db::Uuid;
11use sofie::http::Sealing;
12use sofie::prelude::*;
13
14pub use screen::routes;
15pub(crate) use sign_in::{
16    options as sign_in_options, path as sign_in_path, routes as sign_in_routes,
17};
18
19use crate::AccountId;
20
21const CHALLENGE: &str = "sofie_identity.passkey_challenge";
22/// Long enough to find the authenticator, short enough that a stale page signs nothing.
23const CHALLENGE_VALID_FOR: Duration = Duration::from_mins(5);
24const CREDENTIAL_INDEX: &str = "passkey_credential";
25
26/// Trusted for the account that registered it; attestation is not asked.
27#[derive(Aggregate)]
28#[aggregate(behaviors(SoftDelete))]
29pub struct Passkey {
30    meta: Meta,
31    #[owner]
32    account: AccountId,
33    credential: String,
34    public_key: String,
35    algorithm: i32,
36    sign_count: i64,
37    /// A ceremony challenged earlier than this one is refused, so none is replayed.
38    challenged_at: i64,
39    last_used_at: Option<Instant>,
40}
41
42/// What the browser answered, each part base64url as `engine.js` sends it; a credential id
43/// is at most 1023 bytes and a ceremony a few kilobytes.
44#[derive(Input)]
45pub struct Ceremony {
46    #[max(16384)]
47    id: String,
48    #[max(16384)]
49    client_data: String,
50    #[max(16384)]
51    attestation_object: Option<String>,
52    #[max(16384)]
53    authenticator_data: Option<String>,
54    #[max(16384)]
55    signature: Option<String>,
56}
57
58struct Challenge {
59    kind: String,
60    account: Option<Uuid>,
61    issued: i64,
62}
63
64impl Challenge {
65    fn read(value: &sofie::serde_json::Value) -> Option<Self> {
66        let account = match &value["account"] {
67            sofie::serde_json::Value::Null => None,
68            account => Some(account.as_str()?.parse().ok()?),
69        };
70        Some(Self {
71            kind: value["kind"].as_str()?.to_owned(),
72            account,
73            issued: value["issued"].as_i64()?,
74        })
75    }
76}
77
78fn refused(reason: &str) -> Error {
79    Error::Rule(format!("The passkey was not accepted: {reason}."))
80}
81
82fn decoded(part: Option<&str>, name: &str) -> Result<Vec<u8>> {
83    part.and_then(|text| base64::decode(&URL_SAFE, text))
84        .ok_or_else(|| refused(&format!("its {name} is missing or not base64url")))
85}
86
87/// Sealed, so the browser carries it and nothing is stored until the ceremony ends.
88fn challenge(
89    sealing: &Sealing,
90    kind: &str,
91    account: Option<AccountId>,
92    now: Instant,
93) -> Result<String> {
94    let text = sofie::serde_json::json!({
95        "kind": kind,
96        "account": account.map(|account| account.uuid().to_string()),
97        "issued": now.timestamp().as_microsecond(),
98    });
99    let sealed = sealing.seal(CHALLENGE, text.to_string().as_bytes())?;
100    Ok(base64::encode(&URL_SAFE, sealed.as_bytes()))
101}
102
103/// The challenge the browser signed, once it proves to be one issued here, recently, for `kind`.
104fn opened(sealing: &Sealing, signed: &[u8], kind: &str, now: Instant) -> Result<Challenge> {
105    let challenge = std::str::from_utf8(signed)
106        .ok()
107        .and_then(|sealed| sealing.open(CHALLENGE, sealed))
108        .and_then(|text| sofie::serde_json::from_slice(&text).ok())
109        .and_then(|value: sofie::serde_json::Value| Challenge::read(&value))
110        .filter(|challenge| challenge.kind == kind)
111        .ok_or_else(|| refused("its challenge was not issued here"))?;
112    let expires = challenge
113        .issued
114        .saturating_add(i64::try_from(CHALLENGE_VALID_FOR.as_micros()).unwrap_or(i64::MAX));
115    if expires <= now.timestamp().as_microsecond() {
116        return Err(refused("its challenge expired; reload the page"));
117    }
118    Ok(challenge)
119}
120
121fn origin(session: &Session) -> Result<(String, String)> {
122    let origin = session
123        .origin()
124        .ok_or_else(|| refused("the request names no origin"))?
125        .to_owned();
126    let host = origin
127        .split_once("://")
128        .map(|(_, rest)| rest)
129        .and_then(|rest| rest.split(':').next())
130        .filter(|host| !host.is_empty())
131        .ok_or_else(|| refused("the request's origin names no host"))?
132        .to_owned();
133    Ok((origin, host))
134}
135
136#[cfg(feature = "account-data")]
137pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
138    let passkeys = Passkey::query()
139        .where_(Passkey::ACCOUNT.eq(account))
140        .all(read)
141        .await?;
142    Ok(sofie::serde_json::json!(
143        passkeys
144            .iter()
145            .map(|passkey| sofie::serde_json::json!({
146                "credential": passkey.credential,
147                "last_used_at": passkey.last_used_at,
148            }))
149            .collect::<Vec<_>>()
150    ))
151}
152
153pub(crate) async fn removing(read: &Read, account: AccountId) -> Result<Vec<Passkey>> {
154    let mut passkeys = Passkey::query()
155        .where_(Passkey::ACCOUNT.eq(account))
156        .all(read)
157        .await?;
158    for passkey in &mut passkeys {
159        passkey.public_key = String::new();
160        passkey.delete();
161    }
162    Ok(passkeys)
163}