sofie_identity/passkeys/
mod.rs1mod 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";
22const CHALLENGE_VALID_FOR: Duration = Duration::from_mins(5);
24const CREDENTIAL_INDEX: &str = "passkey_credential";
25
26#[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 challenged_at: i64,
39 last_used_at: Option<Instant>,
40}
41
42#[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
87fn 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
103fn 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}