use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::Value;
use tokio::sync::Mutex;
use tracing::info;
use trust_tasks_rs::specs::auth::passkey::{
enroll::finish::v0_2 as enroll_finish, enroll::start::v0_2 as enroll_start,
revoke::finish::v0_1 as revoke_finish, revoke::start::v0_1 as revoke_start,
};
use uuid::Uuid;
use vti_common::audit::{AdminPasskeyData, AuditEvent, AuditWriter};
use vti_common::auth::passkey::store::{
get_passkey_user_by_did, store_auth_state, store_credential_mapping, store_passkey_user,
store_registration_state, take_auth_state, take_registration_state,
};
use vti_common::error::AppError;
use webauthn_rs::prelude::{Passkey, PublicKeyCredential, RegisterPublicKeyCredential, Webauthn};
use crate::acl::admin::{AdminEntry, RegisteredPasskey, get_admin_entry, store_admin_entry};
use crate::error::TaskError;
use crate::server::AppState;
use crate::step_up_passkey::{webauthn_options, webauthn_result};
use crate::webauthn::{finish_passkey_registration, start_passkey_registration};
static ADMIN_PASSKEY_LOCK: Mutex<()> = Mutex::const_new(());
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
#[schema(as = PasskeyListResponse)]
pub struct ListResponse {
pub credentials: Vec<RegisteredCredential>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RegisteredCredential {
pub credential_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_label: Option<String>,
pub transports: Vec<String>,
pub registered_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<DateTime<Utc>>,
}
impl From<&RegisteredPasskey> for RegisteredCredential {
fn from(p: &RegisteredPasskey) -> Self {
Self {
credential_id: p.credential_id.clone(),
device_label: (!p.label.is_empty()).then(|| p.label.clone()),
transports: p.transports.clone(),
registered_at: p.registered_at,
last_used_at: p.last_used_at,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RegisterStartResponse {
pub enrollment_id: String,
#[schema(value_type = Object)]
pub options: Value,
#[schema(value_type = Object)]
pub uv_options: Value,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RegisterFinishResponse {
pub credential_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_label: Option<String>,
pub subject: String,
pub registered_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RevokeStartResponse {
pub revocation_id: String,
#[schema(value_type = Object)]
pub uv_options: Value,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RevokeFinishResponse {
pub credential_id: String,
pub revoked_at: DateTime<Utc>,
pub remaining: usize,
}
fn revoke_target_key(revocation_id: &str) -> Vec<u8> {
format!("revoke_target:{revocation_id}").into_bytes()
}
pub(crate) async fn list(state: &AppState, did: &str) -> Result<ListResponse, TaskError> {
let entry = get_admin_entry(&state.passkey_ks, did).await?;
Ok(ListResponse {
credentials: entry
.iter()
.flat_map(|e| e.passkeys.iter())
.map(Into::into)
.collect(),
})
}
pub(crate) async fn register_start(
state: &AppState,
did: &str,
_payload: &enroll_start::Payload,
) -> Result<RegisterStartResponse, TaskError> {
let webauthn = require_webauthn(state).map_err(|e| {
TaskError::declared(enroll_start::error_codes::ENROLLMENT_NOT_SUPPORTED.code, e)
})?;
let pk_user = get_passkey_user_by_did(&state.passkey_ks, did)
.await?
.filter(|u| !u.credentials.is_empty())
.ok_or_else(|| {
TaskError::declared(
enroll_start::error_codes::REAUTH_UNAVAILABLE.code,
AppError::NotFound("no passkey to re-authenticate with".into()),
)
})?;
let (uv_options, uv_state) = webauthn
.start_passkey_authentication(&pk_user.credentials)
.map_err(|e| AppError::Internal(format!("webauthn UV start failed: {e}")))?;
let exclude: Vec<_> = pk_user
.credentials
.iter()
.map(|p| p.cred_id().clone())
.collect();
let user_uuid = pk_user.user_uuid;
let (register_options, reg_state) =
start_passkey_registration(webauthn, user_uuid, did, did, Some(exclude))?;
let registration_id = Uuid::new_v4().to_string();
store_registration_state(&state.passkey_ks, ®istration_id, ®_state).await?;
store_auth_state(&state.passkey_ks, ®istration_id, &uv_state).await?;
info!(%did, %registration_id, "passkey register ceremony started");
Ok(RegisterStartResponse {
enrollment_id: registration_id,
options: webauthn_options(®ister_options.public_key)?,
uv_options: webauthn_options(&uv_options.public_key)?,
})
}
pub(crate) async fn register_finish(
state: &AppState,
did: &str,
payload: &enroll_finish::Payload,
) -> Result<RegisterFinishResponse, TaskError> {
let webauthn = require_webauthn(state)?;
let audit_writer = require_audit_writer(state)?;
let enrollment_id = payload.enrollment_id.as_str();
let uv_response: PublicKeyCredential = payload
.uv_credential
.as_ref()
.ok_or_else(|| AppError::Unauthorized("a step-up UV assertion is required".into()))
.and_then(|uv| {
webauthn_result(uv)
.ok_or_else(|| AppError::Unauthorized("the UV assertion does not parse".into()))
})?;
let register_response: RegisterPublicKeyCredential = webauthn_result(&payload.credential)
.ok_or_else(|| {
TaskError::declared(
enroll_finish::error_codes::ATTESTATION_INVALID.code,
AppError::Validation("the new credential does not parse".into()),
)
})?;
let label = payload
.device_label
.as_ref()
.map(|l| l.to_string())
.unwrap_or_default();
let transports = payload.credential.response.transports.clone();
let _guard = ADMIN_PASSKEY_LOCK.lock().await;
let uv_state = take_auth_state(&state.passkey_ks, enrollment_id)
.await?
.ok_or_else(|| {
TaskError::declared(
enroll_finish::error_codes::ENROLLMENT_NOT_FOUND.code,
AppError::NotFound("no enrollment in progress with this id".into()),
)
})?;
webauthn
.finish_passkey_authentication(&uv_response, &uv_state)
.map_err(|_| AppError::Unauthorized("step-up UV failed".into()))?;
let reg_state = take_registration_state(&state.passkey_ks, enrollment_id)
.await?
.ok_or_else(|| {
TaskError::declared(
enroll_finish::error_codes::ENROLLMENT_NOT_FOUND.code,
AppError::NotFound("no enrollment in progress with this id".into()),
)
})?;
let new_passkey = finish_passkey_registration(webauthn, ®ister_response, ®_state)
.map_err(|e| {
TaskError::declared(enroll_finish::error_codes::ATTESTATION_INVALID.code, e)
})?;
let new_cred_id_hex = passkey_cred_id_hex(&new_passkey);
let mut pk_user = get_passkey_user_by_did(&state.passkey_ks, did)
.await?
.ok_or_else(|| AppError::Internal("PasskeyUser missing for admin".into()))?;
pk_user.credentials.push(new_passkey);
store_passkey_user(&state.passkey_ks, &pk_user).await?;
store_credential_mapping(&state.passkey_ks, &new_cred_id_hex, pk_user.user_uuid).await?;
let mut admin_entry = get_admin_entry(&state.passkey_ks, did)
.await?
.unwrap_or_else(|| AdminEntry::new(did.to_string()));
let registered_at = Utc::now();
admin_entry.passkeys.push(RegisteredPasskey {
credential_id: new_cred_id_hex.clone(),
label: label.clone(),
transports: transports.clone(),
registered_at,
last_used_at: None,
});
store_admin_entry(&state.passkey_ks, &admin_entry).await?;
audit_writer
.write(
did,
None,
AuditEvent::AdminPasskeyRegistered(AdminPasskeyData {
credential_id_hex: new_cred_id_hex.clone(),
label: label.clone(),
transports,
}),
)
.await?;
info!(%did, credential_id = %new_cred_id_hex, "passkey registered");
Ok(RegisterFinishResponse {
credential_id: new_cred_id_hex,
device_label: (!label.is_empty()).then_some(label),
subject: did.to_string(),
registered_at,
})
}
pub(crate) async fn revoke_start(
state: &AppState,
did: &str,
payload: &revoke_start::Payload,
) -> Result<RevokeStartResponse, TaskError> {
let webauthn = require_webauthn(state)?;
let credential_id = payload.credential_id.to_string();
let not_found = || {
TaskError::declared(
revoke_start::error_codes::CREDENTIAL_NOT_FOUND.code,
AppError::NotFound("credentialId is not registered for this admin".into()),
)
};
let admin_entry = get_admin_entry(&state.passkey_ks, did)
.await?
.ok_or_else(not_found)?;
if !admin_entry
.passkeys
.iter()
.any(|p| p.credential_id == credential_id)
{
return Err(not_found());
}
let pk_user = get_passkey_user_by_did(&state.passkey_ks, did)
.await?
.ok_or_else(|| AppError::Internal("PasskeyUser missing for admin".into()))?;
let (uv_options, uv_state) = webauthn
.start_passkey_authentication(&pk_user.credentials)
.map_err(|e| AppError::Internal(format!("webauthn UV start failed: {e}")))?;
let revocation_id = Uuid::new_v4().to_string();
store_auth_state(&state.passkey_ks, &revocation_id, &uv_state).await?;
state
.passkey_ks
.insert_raw(
revoke_target_key(&revocation_id),
credential_id.into_bytes(),
)
.await?;
info!(%did, %revocation_id, "passkey revoke ceremony started");
Ok(RevokeStartResponse {
revocation_id,
uv_options: webauthn_options(&uv_options.public_key)?,
})
}
pub(crate) async fn revoke_finish(
state: &AppState,
did: &str,
payload: &revoke_finish::Payload,
) -> Result<RevokeFinishResponse, TaskError> {
use revoke_finish::error_codes as codes;
let webauthn = require_webauthn(state)?;
let audit_writer = require_audit_writer(state)?;
let revocation_id = payload.revocation_id.as_str();
let uv_failed = || {
TaskError::declared(
codes::USER_VERIFICATION_FAILED.code,
AppError::Unauthorized("step-up UV failed".into()),
)
};
let uv_response: PublicKeyCredential =
webauthn_result(&payload.uv_credential).ok_or_else(uv_failed)?;
let _guard = ADMIN_PASSKEY_LOCK.lock().await;
let uv_state = take_auth_state(&state.passkey_ks, revocation_id)
.await?
.ok_or_else(|| {
TaskError::declared(
codes::REVOCATION_NOT_FOUND.code,
AppError::NotFound("no revocation in progress with this id".into()),
)
})?;
webauthn
.finish_passkey_authentication(&uv_response, &uv_state)
.map_err(|_| uv_failed())?;
let target_bytes = state
.passkey_ks
.get_raw(revoke_target_key(revocation_id))
.await?
.ok_or_else(|| AppError::Internal("revocation target missing".into()))?;
let target_cred_id = String::from_utf8(target_bytes)
.map_err(|_| AppError::Internal("revocation target malformed".into()))?;
state
.passkey_ks
.remove(revoke_target_key(revocation_id))
.await?;
let mut admin_entry = get_admin_entry(&state.passkey_ks, did)
.await?
.ok_or_else(|| AppError::NotFound("no admin entry for caller".into()))?;
let before = admin_entry.passkeys.len();
let revoked = admin_entry
.passkeys
.iter()
.find(|p| p.credential_id == target_cred_id)
.cloned()
.ok_or_else(|| {
AppError::NotFound("credentialId is not registered for this admin".into())
})?;
if before <= 1 {
return Err(TaskError::declared(
codes::LAST_CREDENTIAL.code,
AppError::Conflict(
"LastPasskeyProtected: refusing to leave admin with zero passkeys".into(),
),
));
}
admin_entry
.passkeys
.retain(|p| p.credential_id != target_cred_id);
store_admin_entry(&state.passkey_ks, &admin_entry).await?;
let mut pk_user = get_passkey_user_by_did(&state.passkey_ks, did)
.await?
.ok_or_else(|| AppError::Internal("PasskeyUser missing for admin".into()))?;
pk_user
.credentials
.retain(|p| passkey_cred_id_hex(p) != target_cred_id);
store_passkey_user(&state.passkey_ks, &pk_user).await?;
state
.passkey_ks
.remove(format!("pk_cred:{target_cred_id}").into_bytes())
.await?;
audit_writer
.write(
did,
None,
AuditEvent::AdminPasskeyRevoked(AdminPasskeyData {
credential_id_hex: target_cred_id.clone(),
label: revoked.label,
transports: revoked.transports,
}),
)
.await?;
info!(%did, credential_id = %target_cred_id, "passkey revoked");
Ok(RevokeFinishResponse {
credential_id: target_cred_id,
revoked_at: Utc::now(),
remaining: admin_entry.passkeys.len(),
})
}
fn require_webauthn(state: &AppState) -> Result<&Webauthn, AppError> {
state
.webauthn
.as_deref()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "WebAuthn not configured (public_url required)".into(),
})
}
fn require_audit_writer(state: &AppState) -> Result<&AuditWriter, AppError> {
state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "audit writer not configured".into(),
})
}
fn passkey_cred_id_hex(p: &Passkey) -> String {
hex::encode(<_ as AsRef<[u8]>>::as_ref(p.cred_id()))
}