use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, Request, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Extension;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use openssl::symm::{decrypt_aead, encrypt_aead, Cipher};
use recall_wire::devices::displayable;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::OffsetDateTime;
use webauthn_rs::prelude::{
DiscoverableAuthentication, DiscoverableKey, Passkey, PasskeyRegistration, PublicKeyCredential,
RegisterPublicKeyCredential, Url, Uuid, Webauthn, WebauthnBuilder, WebauthnError,
};
use super::admin::{
cleared_cookie, csrf_token, no_store, require_recent_sign_in, session_cookie,
session_token_sha256, OwnerSession, PasskeyStatus, SessionView, SESSION_IDLE, SESSION_LIMIT,
};
use super::auth::Caller;
use super::respond::{error, internal, json, Refusal};
use super::AppState;
use crate::audit::leaf;
use crate::format_timestamp;
use crate::store::{
AddedCredential, AdminCredential, BootstrapCode, FirstPasskey, NewAdminCredential,
RemovedCredential,
};
const CEREMONY_TTL: Duration = Duration::from_secs(5 * 60);
const CEREMONY_AAD: &[u8] = b"recall admin ceremony v1";
const NONCE_BYTES: usize = 12;
const TAG_BYTES: usize = 16;
const MAX_NAME_CHARS: usize = 64;
const USER_NAME: &str = "owner";
const USER_DISPLAY_NAME: &str = "Recall owner";
#[derive(Serialize, Deserialize)]
enum Pending {
Bootstrap {
registration: PasskeyRegistration,
user_handle: Uuid,
code_sha256: String,
},
Add {
registration: PasskeyRegistration,
user_handle: Uuid,
session: String,
},
SignIn(DiscoverableAuthentication),
}
#[derive(Serialize, Deserialize)]
struct Ceremony {
id: String,
expires: i64,
pending: Pending,
}
struct Site {
webauthn: Webauthn,
origin: String,
}
pub(super) struct Passkeys {
site: Result<Site, String>,
key: [u8; 32],
finished: Mutex<HashMap<String, i64>>,
}
impl Passkeys {
pub(super) fn new(public_url: &str) -> Self {
let mut key = [0u8; 32];
let site = if public_url.is_empty() {
Err(
"RECALL_PUBLIC_URL is not set on the server, so passkey sign-in is off. \
Set it to the address this page is served from, such as \
https://recall.example.com, and restart the server."
.to_string(),
)
} else if let Err(e) = getrandom::fill(&mut key) {
Err(format!(
"the server has no randomness to seal ceremonies with ({e})"
))
} else {
site(public_url).map_err(|why| {
let why = format!(
"RECALL_PUBLIC_URL is {public_url:?}, which cannot be used for passkeys: \
{why}. Passkey sign-in is off until it is fixed."
);
eprintln!("{why}");
why
})
};
if let (Ok(_), Some(warning)) = (&site, local_only(public_url)) {
eprintln!("{warning}");
}
Self {
site,
key,
finished: Mutex::new(HashMap::new()),
}
}
pub(super) fn status(&self) -> PasskeyStatus {
match &self.site {
Ok(site) => PasskeyStatus {
enabled: true,
origin: Some(site.origin.clone()),
reason: None,
},
Err(why) => PasskeyStatus {
enabled: false,
origin: None,
reason: Some(why.clone()),
},
}
}
fn site(&self) -> Result<&Site, Refusal> {
self.site.as_ref().map_err(|why| {
Refusal::new(
StatusCode::SERVICE_UNAVAILABLE,
format!("passkey sign-in is off: {why}"),
)
})
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, i64>> {
self.finished.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(super) fn prune(&self, now: i64) -> usize {
let mut finished = self.lock();
let before = finished.len();
finished.retain(|_, expires| *expires > now);
before - finished.len()
}
fn begin(&self, pending: Pending, now: i64) -> Result<String, Refusal> {
let sealing = |e: &dyn std::fmt::Display| {
Refusal::internal(anyhow::anyhow!("sealing a ceremony: {e}"))
};
let ceremony = Ceremony {
id: URL_SAFE_NO_PAD.encode(random::<16>()?),
expires: now + CEREMONY_TTL.as_secs() as i64,
pending,
};
let plain = serde_json::to_vec(&ceremony).map_err(|e| sealing(&e))?;
let nonce = random::<NONCE_BYTES>()?;
let mut tag = [0u8; TAG_BYTES];
let sealed = encrypt_aead(
Cipher::aes_256_gcm(),
&self.key,
Some(&nonce),
CEREMONY_AAD,
&plain,
&mut tag,
)
.map_err(|e| sealing(&e))?;
let mut out = Vec::with_capacity(NONCE_BYTES + sealed.len() + TAG_BYTES);
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
out.extend_from_slice(&tag);
Ok(format!("cer_{}", URL_SAFE_NO_PAD.encode(out)))
}
fn open(&self, ceremony_id: &str, now: i64) -> Result<Ceremony, Refusal> {
let raw = ceremony_id
.strip_prefix("cer_")
.and_then(|b64| URL_SAFE_NO_PAD.decode(b64).ok())
.filter(|raw| raw.len() > NONCE_BYTES + TAG_BYTES)
.ok_or_else(unusable)?;
let (nonce, rest) = raw.split_at(NONCE_BYTES);
let (sealed, tag) = rest.split_at(rest.len() - TAG_BYTES);
let plain = decrypt_aead(
Cipher::aes_256_gcm(),
&self.key,
Some(nonce),
CEREMONY_AAD,
sealed,
tag,
)
.map_err(|_| unusable())?;
let ceremony: Ceremony = serde_json::from_slice(&plain).map_err(|_| unusable())?;
if ceremony.expires <= now || self.lock().contains_key(&ceremony.id) {
return Err(unusable());
}
Ok(ceremony)
}
fn finish(&self, ceremony: &Ceremony, now: i64) -> Result<(), Refusal> {
let mut finished = self.lock();
finished.retain(|_, expires| *expires > now);
if finished
.insert(ceremony.id.clone(), ceremony.expires)
.is_some()
{
return Err(unusable());
}
Ok(())
}
}
fn unusable() -> Refusal {
Refusal::new(
StatusCode::BAD_REQUEST,
"this ceremony has expired or was already used; start again",
)
}
pub(super) async fn json_only(req: Request, next: Next) -> Response {
if is_json(req.headers()) {
next.run(req).await
} else {
error(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"this needs Content-Type: application/json",
)
}
}
fn is_json(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(';').next())
.is_some_and(|media| media.trim().eq_ignore_ascii_case("application/json"))
}
fn site(public_url: &str) -> Result<Site, String> {
let url = Url::parse(public_url).map_err(|e| format!("it is not a URL ({e})"))?;
let host = url
.host_str()
.filter(|_| url.domain().is_some())
.ok_or("it needs a domain name; passkeys cannot be bound to an IP address")?;
if host.ends_with('.') {
return Err("its host ends with a dot; leave the dot off".to_string());
}
if !host.contains('.') && host != "localhost" {
return Err(format!(
"{host:?} is not a full domain name; use the one people reach the server at, \
such as recall.example.com"
));
}
match url.scheme() {
"https" => {}
"http" if host == "localhost" => {}
_ => return Err("it must start with https://".to_string()),
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err("it must be an origin only, with no path, such as \
https://recall.example.com"
.to_string());
}
if !url.username().is_empty() || url.password().is_some() {
return Err("it must not carry a user name or password".to_string());
}
let webauthn = WebauthnBuilder::new(host, &url)
.and_then(|b| b.rp_name("Recall").timeout(CEREMONY_TTL).build())
.map_err(|e| format!("webauthn-rs refused it ({e})"))?;
Ok(Site {
webauthn,
origin: url.origin().ascii_serialization(),
})
}
fn local_only(public_url: &str) -> Option<String> {
let url = Url::parse(public_url).ok()?;
(url.scheme() == "http").then(|| {
format!(
"RECALL_PUBLIC_URL is {public_url}: passkeys will work only from a browser on this \
machine. For a server reached from anywhere else, set it to its https:// address."
)
})
}
fn random<const N: usize>() -> Result<[u8; N], Refusal> {
let mut buf = [0u8; N];
getrandom::fill(&mut buf)
.map_err(|e| Refusal::internal(anyhow::anyhow!("no randomness: {e}")))?;
Ok(buf)
}
#[derive(Debug, Serialize)]
struct PasskeyView {
id: String,
name: String,
created_at: String,
last_used_at: Option<String>,
current: bool,
}
impl PasskeyView {
fn of(c: AdminCredential, current: Option<&str>) -> Self {
Self {
current: current == Some(c.id.as_str()),
id: c.id,
name: c.name,
created_at: c.created_at,
last_used_at: c.last_used_at,
}
}
}
#[derive(Debug, Serialize)]
struct PasskeyList {
passkeys: Vec<PasskeyView>,
}
#[derive(Debug, Serialize)]
struct Started {
ceremony_id: String,
options: Value,
}
#[derive(Debug, Deserialize)]
struct StartBootstrap {
#[serde(default)]
bootstrap_code: String,
}
#[derive(Debug, Deserialize)]
struct FinishRegistration {
ceremony_id: String,
#[serde(default)]
name: String,
credential: RegisterPublicKeyCredential,
}
#[derive(Debug, Deserialize)]
struct FinishSignIn {
ceremony_id: String,
credential: PublicKeyCredential,
}
fn parse<T: serde::de::DeserializeOwned>(bytes: &Bytes) -> Result<T, Refusal> {
serde_json::from_slice(bytes)
.map_err(|e| Refusal::new(StatusCode::BAD_REQUEST, format!("invalid json body: {e}")))
}
fn started(ceremony_id: String, options: Value) -> Response {
no_store(json(
StatusCode::OK,
&Started {
ceremony_id,
options,
},
))
}
fn now_at(state: &AppState) -> (OffsetDateTime, String) {
let at = state.clock();
(at, format_timestamp(at))
}
fn registration_options(
site: &Site,
user_handle: Uuid,
exclude: Vec<Passkey>,
) -> Result<(Value, PasskeyRegistration), Refusal> {
let exclude =
(!exclude.is_empty()).then(|| exclude.iter().map(|p| p.cred_id().clone()).collect());
let (challenge, registration) = site
.webauthn
.start_passkey_registration(user_handle, USER_NAME, USER_DISPLAY_NAME, exclude)
.map_err(|e| Refusal::internal(anyhow::anyhow!("starting a registration: {e}")))?;
let mut options =
serde_json::to_value(challenge).map_err(|e| Refusal::internal(anyhow::anyhow!("{e}")))?;
if let Some(selection) = options.pointer_mut("/publicKey/authenticatorSelection") {
if let Some(selection) = selection.as_object_mut() {
selection.insert("residentKey".into(), Value::from("required"));
selection.insert("requireResidentKey".into(), Value::from(true));
}
}
Ok((options, registration))
}
fn stored_passkeys(state: &AppState) -> Result<Vec<(AdminCredential, Passkey)>, Refusal> {
let credentials = state.store.admin_credentials().map_err(Refusal::internal)?;
credentials
.into_iter()
.map(|c| {
let passkey = serde_json::from_str(&c.passkey).map_err(|e| {
Refusal::internal(anyhow::anyhow!("passkey {} does not parse: {e}", c.id))
})?;
Ok((c, passkey))
})
.collect()
}
fn credential_id(passkey: &Passkey) -> String {
URL_SAFE_NO_PAD.encode(passkey.cred_id().as_ref() as &[u8])
}
fn registered_counter(passkey_json: &Value) -> Result<u32, Refusal> {
passkey_json
.pointer("/cred/counter")
.and_then(Value::as_u64)
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| {
Refusal::internal(anyhow::anyhow!(
"a registered passkey's JSON has no /cred/counter; has webauthn-rs changed its \
format?"
))
})
}
fn asserted_counter(credential: &PublicKeyCredential) -> Option<u32> {
let data: &[u8] = credential.response.authenticator_data.as_ref();
Some(u32::from_be_bytes(data.get(33..37)?.try_into().ok()?))
}
fn passkey_name(name: &str) -> Result<String, Refusal> {
let name = name.trim();
if !displayable(name, MAX_NAME_CHARS) {
return Err(Refusal::new(
StatusCode::BAD_REQUEST,
"name must be at most 64 characters, with no control, format or invisible characters",
));
}
Ok(if name.is_empty() {
"passkey".to_string()
} else {
name.to_string()
})
}
fn not_registered(e: WebauthnError) -> Refusal {
Refusal::new(
StatusCode::BAD_REQUEST,
format!("the passkey's answer did not verify: {e}"),
)
}
fn register(
state: &AppState,
ceremony: &Ceremony,
registration: &PasskeyRegistration,
user_handle: Uuid,
finish: &FinishRegistration,
first: Option<&str>,
actor: &leaf::Actor<'_>,
) -> Result<AdminCredential, Refusal> {
let site = state.passkeys.site()?;
let name = passkey_name(&finish.name)?;
let kept = finish
.credential
.extensions
.cred_props
.as_ref()
.and_then(|p| p.rk);
if kept == Some(false) {
return Err(Refusal::new(
StatusCode::BAD_REQUEST,
"this authenticator did not keep the passkey on itself (the browser says credProps.rk \
is false), and signing in here needs one that does; use a phone, or a password \
manager that saves passkeys",
));
}
let passkey = site
.webauthn
.finish_passkey_registration(&finish.credential, registration)
.map_err(not_registered)?;
state.passkeys.finish(ceremony, state.now())?;
let passkey_json =
serde_json::to_value(&passkey).map_err(|e| Refusal::internal(anyhow::anyhow!("{e}")))?;
let sign_count = registered_counter(&passkey_json)?;
let id = credential_id(&passkey);
let (_, now) = now_at(state);
let added = state
.store
.add_admin_credential_audited(
&NewAdminCredential {
id: &id,
user_handle: &user_handle.to_string(),
name: &name,
passkey: &passkey_json.to_string(),
sign_count,
created_at: &now,
},
first.map(|code_sha256| FirstPasskey {
code_sha256,
now: &now,
}),
|seq, at| {
leaf::encode(
seq,
at,
leaf::action::PASSKEY_ADD,
actor,
leaf::subject_passkey(&id, &name, Some(first.is_some())),
None,
)
},
)
.map_err(Refusal::internal)?;
match added {
AddedCredential::Added => {}
AddedCredential::NotFirst => return Err(already_bootstrapped()),
AddedCredential::Code(refused) => return Err(code_refused(refused)),
AddedCredential::Duplicate => {
return Err(Refusal::new(
StatusCode::CONFLICT,
"that passkey is registered already",
))
}
}
state
.store
.admin_credential(&id)
.map_err(Refusal::internal)?
.ok_or_else(|| Refusal::internal(anyhow::anyhow!("passkey {id} vanished")))
}
fn already_bootstrapped() -> Refusal {
Refusal::new(
StatusCode::FORBIDDEN,
"forbidden: a passkey is registered already, and RECALL_TOKEN cannot register \
another; sign in with the passkey to add more",
)
}
fn code_refused(why: BootstrapCode) -> Refusal {
Refusal::new(
StatusCode::FORBIDDEN,
match why {
BootstrapCode::Expired => {
"forbidden: that bootstrap code has expired; restart the server, or run \
recall-server reset-passkeys where it runs, for a new one"
}
_ => {
"forbidden: registering the first passkey needs the one-time bootstrap code the \
server printed where it runs, as well as RECALL_TOKEN"
}
},
)
}
fn bootstrap_allowed(state: &AppState, caller: &Caller) -> Result<(), Refusal> {
if *caller != Caller::Operator {
return Err(Refusal::new(
StatusCode::FORBIDDEN,
"forbidden: registering the first passkey needs RECALL_TOKEN",
));
}
match state.store.has_admin_credentials() {
Ok(false) => Ok(()),
Ok(true) => Err(already_bootstrapped()),
Err(e) => Err(Refusal::internal(e)),
}
}
pub(super) async fn handle_bootstrap_start(
State(state): State<Arc<AppState>>,
Extension(caller): Extension<Caller>,
bytes: Bytes,
) -> Response {
let run = || -> Result<Response, Refusal> {
bootstrap_allowed(&state, &caller)?;
let site = state.passkeys.site()?;
let typed = if bytes.is_empty() {
String::new()
} else {
parse::<StartBootstrap>(&bytes)?.bootstrap_code
};
let code_sha256 =
crate::bootstrap::sha256(&typed).ok_or_else(|| code_refused(BootstrapCode::Wrong))?;
let (_, now) = now_at(&state);
match state
.store
.check_bootstrap_code(&code_sha256, &now)
.map_err(Refusal::internal)?
{
BootstrapCode::Valid => {}
refused => return Err(code_refused(refused)),
}
let user_handle = Uuid::from_bytes(random::<16>()?);
let (options, registration) = registration_options(site, user_handle, Vec::new())?;
let id = state.passkeys.begin(
Pending::Bootstrap {
registration,
user_handle,
code_sha256,
},
state.now(),
)?;
Ok(started(id, options))
};
run().unwrap_or_else(IntoResponse::into_response)
}
pub(super) async fn handle_bootstrap_finish(
State(state): State<Arc<AppState>>,
Extension(caller): Extension<Caller>,
bytes: Bytes,
) -> Response {
let run = || -> Result<Response, Refusal> {
bootstrap_allowed(&state, &caller)?;
let finish: FinishRegistration = parse(&bytes)?;
let ceremony = state.passkeys.open(&finish.ceremony_id, state.now())?;
let Pending::Bootstrap {
registration,
user_handle,
code_sha256,
} = &ceremony.pending
else {
return Err(wrong_ceremony());
};
let credential = register(
&state,
&ceremony,
registration,
*user_handle,
&finish,
Some(code_sha256),
&leaf::Actor::Operator,
)?;
Ok(json(StatusCode::OK, &PasskeyView::of(credential, None)))
};
run().unwrap_or_else(IntoResponse::into_response)
}
fn wrong_ceremony() -> Refusal {
Refusal::new(
StatusCode::BAD_REQUEST,
"that ceremony is not one this route finishes; start again",
)
}
pub(super) async fn handle_sign_in_start(State(state): State<Arc<AppState>>) -> Response {
let run = || -> Result<Response, Refusal> {
let site = state.passkeys.site()?;
if !state
.store
.has_admin_credentials()
.map_err(Refusal::internal)?
{
return Err(Refusal::new(
StatusCode::CONFLICT,
"no passkey is registered yet; register the first with RECALL_TOKEN",
));
}
let (mut challenge, authentication) = site
.webauthn
.start_discoverable_authentication()
.map_err(|e| Refusal::internal(anyhow::anyhow!("starting a sign-in: {e}")))?;
challenge.mediation = None;
let options = serde_json::to_value(challenge)
.map_err(|e| Refusal::internal(anyhow::anyhow!("{e}")))?;
let id = state
.passkeys
.begin(Pending::SignIn(authentication), state.now())?;
Ok(started(id, options))
};
run().unwrap_or_else(IntoResponse::into_response)
}
fn refused_sign_in(why: &str) -> Refusal {
Refusal::new(StatusCode::UNAUTHORIZED, format!("unauthorized: {why}"))
}
const COUNTER_WENT_BACK: &str = "this passkey's signature counter did not move forward, which \
can mean a copy of it exists; sign-in refused";
fn counter_went_back(stored: &AdminCredential, credential: &PublicKeyCredential) -> Refusal {
let asserted = asserted_counter(credential)
.map(|n| n.to_string())
.unwrap_or_else(|| "unreadable".to_string());
eprintln!(
"admin sign-in refused: the passkey {:?} ({}) answered with signature counter {asserted}, \
which is not past the {} already seen. A copy of it may exist.",
stored.name, stored.id, stored.sign_count
);
refused_sign_in(COUNTER_WENT_BACK)
}
fn sign_in(state: &AppState, finish: &FinishSignIn) -> Result<(String, SessionView), Refusal> {
let site = state.passkeys.site()?;
let ceremony = state.passkeys.open(&finish.ceremony_id, state.now())?;
let Pending::SignIn(authentication) = &ceremony.pending else {
return Err(wrong_ceremony());
};
let (user_handle, raw_id) = site
.webauthn
.identify_discoverable_authentication(&finish.credential)
.map_err(|_| refused_sign_in("the passkey did not say which account it is for"))?;
let id = URL_SAFE_NO_PAD.encode(raw_id);
let stored = state
.store
.admin_credential(&id)
.map_err(Refusal::internal)?
.ok_or_else(|| refused_sign_in("that passkey is not registered here"))?;
if stored.user_handle != user_handle.to_string() {
return Err(refused_sign_in("that passkey is not registered here"));
}
let mut passkey: Passkey = serde_json::from_str(&stored.passkey)
.map_err(|e| Refusal::internal(anyhow::anyhow!("passkey {id} does not parse: {e}")))?;
let result = site
.webauthn
.finish_discoverable_authentication(
&finish.credential,
authentication.clone(),
&[DiscoverableKey::from(&passkey)],
)
.map_err(|e| match e {
WebauthnError::CredentialPossibleCompromise => {
counter_went_back(&stored, &finish.credential)
}
e => refused_sign_in(&format!("the passkey's answer did not verify: {e}")),
})?;
state.passkeys.finish(&ceremony, state.now())?;
passkey.update_credential(&result);
let passkey_json =
serde_json::to_string(&passkey).map_err(|e| Refusal::internal(anyhow::anyhow!("{e}")))?;
let (at, now) = now_at(state);
let recorded = state
.store
.record_admin_sign_in(&id, result.counter(), &passkey_json, &now)
.map_err(Refusal::internal)?;
if !recorded {
return Err(counter_went_back(&stored, &finish.credential));
}
let token = URL_SAFE_NO_PAD.encode(random::<32>()?);
let expires_at = format_timestamp(at + SESSION_LIMIT);
let created = state
.store
.create_admin_session(&recall_wire::content_sha256(&token), &id, &now, &expires_at)
.map_err(Refusal::internal)?;
if !created {
return Err(refused_sign_in("that passkey was removed as it signed in"));
}
Ok((
token.clone(),
SessionView {
csrf_token: csrf_token(&token),
expires_at,
idle_expires_at: format_timestamp(at + SESSION_IDLE),
},
))
}
pub(super) async fn handle_sign_in_finish(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
bytes: Bytes,
) -> Response {
let finish: FinishSignIn = match parse(&bytes) {
Ok(finish) => finish,
Err(refused) => return refused.into_response(),
};
match sign_in(&state, &finish) {
Ok((token, view)) => {
if let Some(previous) = session_token_sha256(&headers) {
if let Err(e) = state.store.delete_admin_session(&previous) {
eprintln!("ending the session a sign-in replaced: {e:#}");
}
}
let mut resp = no_store(json(StatusCode::OK, &view));
resp.headers_mut()
.insert(header::SET_COOKIE, session_cookie(&token, SESSION_LIMIT));
resp
}
Err(refused) => refused.into_response(),
}
}
pub(super) async fn handle_sign_out(
State(state): State<Arc<AppState>>,
Extension(session): Extension<OwnerSession>,
) -> Response {
if let Err(e) = state.store.delete_admin_session(&session.token_sha256) {
return internal(e);
}
let mut resp = json(StatusCode::OK, &serde_json::json!({ "signed_out": true }));
resp.headers_mut()
.insert(header::SET_COOKIE, cleared_cookie());
resp
}
pub(super) async fn handle_sign_out_others(
State(state): State<Arc<AppState>>,
Extension(session): Extension<OwnerSession>,
) -> Response {
if let Err(refused) = require_recent_sign_in(&state, &session) {
return refused.into_response();
}
let ended =
state
.store
.delete_other_admin_sessions_audited(&session.token_sha256, |seq, at, ended| {
leaf::encode(
seq,
at,
leaf::action::SESSIONS_END,
&leaf::Actor::Session {
credential_id: &session.credential_id,
},
leaf::subject_sessions_end(ended),
None,
)
});
match ended {
Ok(n) => json(
StatusCode::OK,
&serde_json::json!({ "other_sessions_ended": n }),
),
Err(e) => internal(e),
}
}
pub(super) async fn handle_list_passkeys(
State(state): State<Arc<AppState>>,
Extension(session): Extension<OwnerSession>,
) -> Response {
match state.store.admin_credentials() {
Ok(credentials) => json(
StatusCode::OK,
&PasskeyList {
passkeys: credentials
.into_iter()
.map(|c| PasskeyView::of(c, Some(&session.credential_id)))
.collect(),
},
),
Err(e) => internal(e),
}
}
pub(super) async fn handle_add_start(
State(state): State<Arc<AppState>>,
Extension(session): Extension<OwnerSession>,
) -> Response {
let run = || -> Result<Response, Refusal> {
require_recent_sign_in(&state, &session)?;
let site = state.passkeys.site()?;
let existing = stored_passkeys(&state)?;
let user_handle = existing
.first()
.and_then(|(c, _)| Uuid::parse_str(&c.user_handle).ok())
.ok_or_else(|| Refusal::internal(anyhow::anyhow!("no passkey to add to")))?;
let exclude = existing.into_iter().map(|(_, p)| p).collect();
let (options, registration) = registration_options(site, user_handle, exclude)?;
let id = state.passkeys.begin(
Pending::Add {
registration,
user_handle,
session: session.token_sha256.clone(),
},
state.now(),
)?;
Ok(started(id, options))
};
run().unwrap_or_else(IntoResponse::into_response)
}
pub(super) async fn handle_add_finish(
State(state): State<Arc<AppState>>,
Extension(session): Extension<OwnerSession>,
bytes: Bytes,
) -> Response {
let run = || -> Result<Response, Refusal> {
let finish: FinishRegistration = parse(&bytes)?;
let ceremony = state.passkeys.open(&finish.ceremony_id, state.now())?;
let Pending::Add {
registration,
user_handle,
session: started_by,
} = &ceremony.pending
else {
return Err(wrong_ceremony());
};
if *started_by != session.token_sha256 {
return Err(wrong_ceremony());
}
let credential = register(
&state,
&ceremony,
registration,
*user_handle,
&finish,
None,
&leaf::Actor::Session {
credential_id: &session.credential_id,
},
)?;
Ok(json(
StatusCode::OK,
&PasskeyView::of(credential, Some(&session.credential_id)),
))
};
run().unwrap_or_else(IntoResponse::into_response)
}
pub(super) async fn handle_remove(
State(state): State<Arc<AppState>>,
Extension(session): Extension<OwnerSession>,
Path(id): Path<String>,
) -> Response {
if let Err(refused) = require_recent_sign_in(&state, &session) {
return refused.into_response();
}
let removed = state
.store
.remove_admin_credential_audited(&id, |seq, at, credential| {
leaf::encode(
seq,
at,
leaf::action::PASSKEY_REMOVE,
&leaf::Actor::Session {
credential_id: &session.credential_id,
},
leaf::subject_passkey(&credential.id, &credential.name, None),
None,
)
});
match removed {
Ok(RemovedCredential::Removed(credential)) => {
let own = credential.id == session.credential_id;
let mut resp = json(
StatusCode::OK,
&PasskeyView::of(credential, Some(&session.credential_id)),
);
if own {
resp.headers_mut()
.insert(header::SET_COOKIE, cleared_cookie());
}
resp
}
Ok(RemovedCredential::Last) => error(
StatusCode::CONFLICT,
"that is the only passkey; add another before removing it",
),
Ok(RemovedCredential::NotFound) => error(StatusCode::NOT_FOUND, "no passkey has that id"),
Err(e) => internal(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_public_url_must_be_an_https_origin() {
let ok = site("https://recall.example.com").ok().unwrap();
assert_eq!(ok.origin, "https://recall.example.com");
assert_eq!(
site("https://recall.example.com:8443/")
.ok()
.unwrap()
.origin,
"https://recall.example.com:8443"
);
assert!(site("http://localhost:8787").is_ok(), "trying it locally");
assert!(site("https://localhost").is_ok());
for bad in [
"recall.example.com",
"http://recall.example.com",
"https://recall.example.com/admin",
"https://recall.example.com/?x=1",
"https://203.0.113.9",
"https://user:pw@recall.example.com",
"ftp://recall.example.com",
"https://recall.example.com.",
"http://localhost.:8787",
"https://recall",
"https://intranet:8443",
] {
assert!(site(bad).is_err(), "{bad}");
}
assert!(site("https://recall.example.com.")
.err()
.unwrap()
.contains("ends with a dot"));
assert!(site("https://recall")
.err()
.unwrap()
.contains("not a full domain name"));
}
#[test]
fn plain_http_on_localhost_works_with_a_warning() {
assert!(local_only("http://localhost:8787")
.unwrap()
.contains("only from a browser on this machine"));
assert_eq!(local_only("https://recall.example.com"), None);
}
#[test]
fn off_says_why() {
let off = Passkeys::new("");
let status = off.status();
assert!(!status.enabled);
assert!(status
.reason
.unwrap()
.contains("RECALL_PUBLIC_URL is not set"));
let bad = Passkeys::new("http://recall.example.com");
assert!(bad.status().reason.unwrap().contains("https://"));
assert!(Passkeys::new("https://recall.example.com").status().enabled);
}
#[test]
fn a_sealed_ceremony_opens_once_here_and_nowhere_else() {
let p = Passkeys::new("https://recall.example.com");
let site = p.site().ok().unwrap();
let (_, auth) = site.webauthn.start_discoverable_authentication().unwrap();
let now = 1_000_000;
let id = p.begin(Pending::SignIn(auth), now).ok().unwrap();
assert!(id.starts_with("cer_"));
let ceremony = p.open(&id, now).ok().unwrap();
assert!(matches!(ceremony.pending, Pending::SignIn(_)));
assert!(p.open(&id, now + 299).is_ok(), "within five minutes");
assert!(p.open(&id, now + 300).is_err(), "expired");
let mut bytes = URL_SAFE_NO_PAD.decode(&id[4..]).unwrap();
bytes[NONCE_BYTES + 3] ^= 1;
let tampered = format!("cer_{}", URL_SAFE_NO_PAD.encode(&bytes));
assert!(p.open(&tampered, now).is_err());
let other = Passkeys::new("https://recall.example.com");
assert!(other.open(&id, now).is_err());
for junk in ["", "cer_", "cer_nope", "nope", &id[4..]] {
assert!(p.open(junk, now).is_err(), "{junk:?}");
}
assert!(p.finish(&ceremony, now).is_ok());
assert!(p.finish(&ceremony, now).is_err());
assert!(p.open(&id, now).is_err());
assert_eq!(p.prune(now), 0, "not expired yet");
assert_eq!(p.prune(now + 300), 1);
}
#[test]
fn starting_a_ceremony_holds_nothing() {
let p = Passkeys::new("https://recall.example.com");
let site = p.site().ok().unwrap();
for _ in 0..100 {
let (_, auth) = site.webauthn.start_discoverable_authentication().unwrap();
p.begin(Pending::SignIn(auth), 0).ok().unwrap();
}
assert!(p.lock().is_empty());
}
#[test]
fn only_json_is_json() {
let with = |ct: &str| {
let mut h = HeaderMap::new();
h.insert(header::CONTENT_TYPE, ct.parse().unwrap());
is_json(&h)
};
assert!(with("application/json"));
assert!(with("Application/JSON; charset=utf-8"));
for no in [
"text/plain",
"application/x-www-form-urlencoded",
"multipart/form-data",
"application/jsonx",
"text/plain; application/json",
] {
assert!(!with(no), "{no}");
}
assert!(!is_json(&HeaderMap::new()));
}
#[test]
fn the_counter_is_read_where_webauthn_rs_keeps_it() {
let json = serde_json::json!({ "cred": { "counter": 7 } });
assert_eq!(registered_counter(&json).ok(), Some(7));
assert!(registered_counter(&serde_json::json!({})).is_err());
}
}