Files
tranquil-pds/crates/tranquil-api/src/server/passkey_account.rs
T
2026-04-12 15:56:17 +03:00

824 lines
26 KiB
Rust

use crate::common;
use axum::{Json, extract::State, http::HeaderMap};
use chrono::{Duration, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, error, info, warn};
use tranquil_db_traits::WebauthnChallengeType;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::{OptionsResponse, SuccessResponse};
use tranquil_pds::auth::NormalizedLoginIdentifier;
use tranquil_pds::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
use tranquil_pds::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::validation::validate_password;
fn generate_setup_token() -> String {
let mut rng = rand::thread_rng();
(0..32)
.map(|_| {
let idx = rng.gen_range(0..36);
if idx < 10 {
(b'0' + idx) as char
} else {
(b'a' + idx - 10) as char
}
})
.collect()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePasskeyAccountInput {
pub handle: String,
pub email: Option<String>,
pub invite_code: Option<String>,
pub did: Option<String>,
pub did_type: Option<String>,
pub signing_key: Option<String>,
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_username: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePasskeyAccountOutput {
pub did: Did,
pub handle: Handle,
pub setup_token: String,
pub setup_expires_at: chrono::DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_jwt: Option<String>,
}
pub async fn create_passkey_account(
State(state): State<AppState>,
_rate_limit: RateLimited<AccountCreationLimit>,
headers: HeaderMap,
Json(input): Json<CreatePasskeyAccountInput>,
) -> Result<Json<CreatePasskeyAccountOutput>, ApiError> {
let byod_auth = if let Some(extracted) = tranquil_pds::auth::extract_auth_token_from_header(
tranquil_pds::util::get_header_str(&headers, http::header::AUTHORIZATION),
) {
let token = extracted.token;
if is_service_token(&token) {
let verifier = ServiceTokenVerifier::new();
match verifier
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
.await
{
Ok(claims) => {
debug!(
"Service token verified for BYOD did:web: iss={}",
claims.iss
);
Some(claims.iss)
}
Err(e) => {
error!("Service token verification failed: {:?}", e);
return Err(ApiError::AuthenticationFailed(Some(format!(
"Service token verification failed: {}",
e
))));
}
}
} else {
None
}
} else {
None
};
let is_byod_did_web = byod_auth.is_some()
&& input
.did
.as_ref()
.map(|d| d.starts_with("did:web:"))
.unwrap_or(false);
let cfg = tranquil_config::get();
let hostname = &cfg.server.hostname;
let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
};
let email = input
.email
.as_ref()
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty());
if let Some(ref email) = email
&& !tranquil_pds::api::validation::is_valid_email(email)
{
return Err(ApiError::InvalidEmail);
}
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
let _validated_invite_code = if is_bootstrap {
match input.invite_code.as_deref() {
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => None,
_ => return Err(ApiError::InvalidInviteCode),
}
} else if let Some(ref code) = input.invite_code {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
} else {
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Err(ApiError::InviteCodeRequired);
}
None
};
let verification_channel = input
.verification_channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let verification_recipient = match common::extract_verification_recipient(
verification_channel,
&common::ChannelInput {
email: email.as_deref(),
discord_username: input.discord_username.as_deref(),
telegram_username: input.telegram_username.as_deref(),
signal_username: input.signal_username.as_deref(),
},
) {
Ok(r) => r,
Err(e) => return Err(e),
};
let pds_endpoint = format!("https://{}", hostname);
let did_type = input.did_type.as_deref().unwrap_or("plc");
let key_result =
match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_deref())
.await
{
Ok(k) => k,
Err(e) => return Err(e),
};
let secret_key_bytes = key_result.secret_key_bytes;
let secret_key = key_result.signing_key;
let reserved_key_id = key_result.reserved_key_id;
let did = match did_type {
"web" => {
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
Ok(d) => d,
Err(e) => return Err(e),
};
info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
self_hosted_did
}
"web-external" => {
let d = match &input.did {
Some(d) if !d.trim().is_empty() => d.trim(),
_ => {
return Err(ApiError::InvalidRequest(
"External did:web requires the 'did' field to be provided".into(),
));
}
};
if !d.starts_with("did:web:") {
return Err(ApiError::InvalidDid(
"External DID must be a did:web".into(),
));
}
if is_byod_did_web {
if let Some(ref auth_did) = byod_auth
&& d != auth_did.as_str()
{
return Err(ApiError::AuthorizationError(format!(
"Service token issuer {} does not match DID {}",
auth_did, d
)));
}
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
} else {
if let Err(e) = crate::identity::did::verify_did_web(
d,
hostname,
&input.handle,
input.signing_key.as_deref(),
)
.await
{
return Err(ApiError::InvalidDid(e.to_string()));
}
info!(did = %d, "Creating external did:web passkey account (reserved key)");
}
d.to_string()
}
_ => {
if let Some(ref auth_did) = byod_auth {
if let Some(ref provided_did) = input.did {
if provided_did.starts_with("did:plc:") {
if provided_did != auth_did.as_str() {
return Err(ApiError::AuthorizationError(format!(
"Service token issuer {} does not match DID {}",
auth_did, provided_did
)));
}
info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)");
provided_did.clone()
} else {
return Err(ApiError::InvalidRequest(
"BYOD migration requires a did:plc or did:web DID".into(),
));
}
} else {
return Err(ApiError::InvalidRequest(
"BYOD migration requires the 'did' field".into(),
));
}
} else {
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&secret_key));
let genesis_result = match tranquil_pds::plc::create_genesis_operation(
&secret_key,
&rotation_key,
&handle,
&pds_endpoint,
) {
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return Err(ApiError::InternalError(Some(
"Failed to create PLC operation".into(),
)));
}
};
let plc_client =
tranquil_pds::plc::PlcClient::with_cache(None, Some(state.cache.clone()));
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return Err(ApiError::UpstreamErrorMsg(format!(
"Failed to register DID with PLC directory: {}",
e
)));
}
genesis_result.did
}
}
};
info!(did = %did, handle = %handle, "Created DID for passkey-only account");
let setup_token = generate_setup_token();
let setup_token_hash = common::hash_or_internal_error(&setup_token)?;
let setup_expires_at = Utc::now() + Duration::hours(1);
let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web {
Some(Utc::now())
} else {
None
};
let did_typed: Did = match did.parse() {
Ok(d) => d,
Err(_) => return Err(ApiError::InternalError(Some("Invalid DID".into()))),
};
let repo = match crate::identity::provision::init_genesis_repo(
&state,
&did_typed,
&secret_key,
&secret_key_bytes,
)
.await
{
Ok(r) => r,
Err(e) => return Err(e),
};
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
Some(json!({
"$type": "app.bsky.actor.defs#personalDetailsPref",
"birthDate": "1998-05-06T00:00:00.000Z"
}))
} else {
None
};
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
};
let repo_for_seq = repo.clone();
let comms = crate::identity::provision::normalize_comms_usernames(
input.discord_username.as_deref(),
input.telegram_username.as_deref(),
input.signal_username.as_deref(),
);
let create_input = tranquil_db_traits::CreatePasskeyAccountInput {
handle: handle_typed.clone(),
email: email.clone().unwrap_or_default(),
did: did_typed.clone(),
preferred_comms_channel: verification_channel,
discord_username: comms.discord,
telegram_username: comms.telegram,
signal_username: comms.signal,
setup_token_hash,
setup_expires_at,
deactivated_at,
encrypted_key_bytes: repo.encrypted_key_bytes,
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
reserved_key_id,
commit_cid: repo.commit_cid.to_string(),
repo_rev: repo.repo_rev.clone(),
genesis_block_cids: repo.genesis_block_cids,
invite_code: if is_bootstrap {
None
} else {
input.invite_code.clone()
},
birthdate_pref,
};
let create_result = match state.repos.user.create_passkey_account(&create_input).await {
Ok(r) => r,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return Err(ApiError::HandleNotAvailable(None));
}
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
return Err(ApiError::EmailTaken);
}
Err(e) => {
error!("Error creating passkey account: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
let user_id = create_result.user_id;
if !is_byod_did_web {
crate::identity::provision::sequence_new_account(
&state,
&did_typed,
&handle_typed,
&repo_for_seq,
&handle,
)
.await;
}
crate::identity::provision::enqueue_signup_verification(
&state,
user_id,
&did_typed,
verification_channel,
&verification_recipient,
)
.await;
info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion");
let access_jwt = if byod_auth.is_some() {
match tranquil_pds::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
Ok(token_meta) => {
let refresh_jti = uuid::Uuid::new_v4().to_string();
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did_typed.clone(),
access_jti: token_meta.jti.clone(),
refresh_jti,
access_expires_at: token_meta.expires_at,
refresh_expires_at: refresh_expires,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some("transition:generic transition:chat.bsky".to_string()),
controller_did: None,
app_password_name: None,
};
if let Err(e) = state.repos.session.create_session(&session_data).await {
warn!(did = %did, "Failed to insert migration session: {:?}", e);
}
info!(did = %did, "Generated migration access token for BYOD passkey account");
Some(token_meta.token)
}
Err(e) => {
warn!(did = %did, "Failed to generate migration access token: {:?}", e);
None
}
}
} else {
None
};
Ok(Json(CreatePasskeyAccountOutput {
did: did.into(),
handle: handle.into(),
setup_token,
setup_expires_at,
access_jwt,
}))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletePasskeySetupInput {
pub did: Did,
pub setup_token: String,
pub passkey_credential: serde_json::Value,
pub passkey_friendly_name: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletePasskeySetupOutput {
pub did: Did,
pub handle: Handle,
pub app_password: String,
pub app_password_name: String,
}
pub async fn complete_passkey_setup(
State(state): State<AppState>,
Json(input): Json<CompletePasskeySetupInput>,
) -> Result<Json<CompletePasskeySetupOutput>, ApiError> {
let user = match state
.repos
.user
.get_user_for_passkey_setup(&input.did)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::AccountNotFound);
}
Err(e) => {
error!("DB error: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
if user.password_required {
return Err(ApiError::InvalidAccount);
}
let token_hash = match &user.recovery_token {
Some(h) => h,
None => {
return Err(ApiError::SetupExpired);
}
};
common::validate_token_hash(
user.recovery_token_expires_at,
token_hash,
&input.setup_token,
ApiError::SetupExpired,
ApiError::InvalidToken(None),
)?;
let webauthn = &state.webauthn_config;
let reg_state = match state
.repos
.user
.load_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
.await
{
Ok(Some(json)) => match serde_json::from_str(&json) {
Ok(s) => s,
Err(e) => {
error!("Error deserializing registration state: {:?}", e);
return Err(ApiError::InternalError(None));
}
},
Ok(None) => {
return Err(ApiError::NoChallengeInProgress);
}
Err(e) => {
error!("Error loading registration state: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
match serde_json::from_value(input.passkey_credential) {
Ok(c) => c,
Err(e) => {
warn!("Failed to parse credential: {:?}", e);
return Err(ApiError::InvalidCredential);
}
};
let security_key = match webauthn.finish_registration(&credential, &reg_state) {
Ok(sk) => sk,
Err(e) => {
warn!("Passkey registration failed: {:?}", e);
return Err(ApiError::RegistrationFailed);
}
};
let credential_id = security_key.cred_id().to_vec();
let public_key = match serde_json::to_vec(&security_key) {
Ok(pk) => pk,
Err(e) => {
error!("Error serializing security key: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
if let Err(e) = state
.repos
.user
.save_passkey(
&input.did,
&credential_id,
&public_key,
input.passkey_friendly_name.as_deref(),
)
.await
{
error!("Error saving passkey: {:?}", e);
return Err(ApiError::InternalError(None));
}
let app_password = generate_app_password();
let app_password_name = "bsky.app".to_string();
let password_hash = common::hash_or_internal_error(&app_password)?;
let setup_input = tranquil_db_traits::CompletePasskeySetupInput {
user_id: user.id,
did: input.did.clone(),
app_password_name: app_password_name.clone(),
app_password_hash: password_hash,
};
if let Err(e) = state.repos.user.complete_passkey_setup(&setup_input).await {
error!("Error completing passkey setup: {:?}", e);
return Err(ApiError::InternalError(None));
}
let _ = state
.repos
.user
.delete_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
.await;
info!(did = %input.did, "Passkey-only account setup completed");
Ok(Json(CompletePasskeySetupOutput {
did: input.did.clone(),
handle: user.handle,
app_password,
app_password_name,
}))
}
pub async fn start_passkey_registration_for_setup(
State(state): State<AppState>,
Json(input): Json<StartPasskeyRegistrationInput>,
) -> Result<Json<OptionsResponse<serde_json::Value>>, ApiError> {
let user = match state
.repos
.user
.get_user_for_passkey_setup(&input.did)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::AccountNotFound);
}
Err(e) => {
error!("DB error: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
if user.password_required {
return Err(ApiError::InvalidAccount);
}
let token_hash = match &user.recovery_token {
Some(h) => h,
None => {
return Err(ApiError::SetupExpired);
}
};
common::validate_token_hash(
user.recovery_token_expires_at,
token_hash,
&input.setup_token,
ApiError::SetupExpired,
ApiError::InvalidToken(None),
)?;
let webauthn = &state.webauthn_config;
let existing_passkeys = state
.repos
.user
.get_passkeys_for_user(&input.did)
.await
.unwrap_or_default();
let exclude_credentials: Vec<webauthn_rs::prelude::CredentialID> = existing_passkeys
.iter()
.map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone()))
.collect();
let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle);
let (ccr, reg_state) = match webauthn.start_registration(
&input.did,
&user.handle,
display_name,
exclude_credentials,
) {
Ok(result) => result,
Err(e) => {
error!("Failed to start passkey registration: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
let state_json = match serde_json::to_string(&reg_state) {
Ok(json) => json,
Err(e) => {
error!("Failed to serialize registration state: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
if let Err(e) = state
.repos
.user
.save_webauthn_challenge(&input.did, WebauthnChallengeType::Registration, &state_json)
.await
{
error!("Failed to save registration state: {:?}", e);
return Err(ApiError::InternalError(None));
}
let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
Ok(OptionsResponse::new(options))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartPasskeyRegistrationInput {
pub did: Did,
pub setup_token: String,
pub friendly_name: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestPasskeyRecoveryInput {
#[serde(alias = "identifier")]
pub email: String,
}
pub async fn request_passkey_recovery(
State(state): State<AppState>,
_rate_limit: RateLimited<PasswordResetLimit>,
Json(input): Json<RequestPasskeyRecoveryInput>,
) -> Result<Json<SuccessResponse>, ApiError> {
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let identifier = input.email.trim().to_lowercase();
let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
let normalized_handle =
NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles);
let user = match state
.repos
.user
.get_user_for_passkey_recovery(identifier, normalized_handle.as_str())
.await
{
Ok(Some(u)) if !u.password_required => u,
_ => {
return Ok(Json(SuccessResponse { success: true }));
}
};
let recovery_token = generate_setup_token();
let recovery_token_hash = common::hash_or_internal_error(&recovery_token)?;
let expires_at = Utc::now() + Duration::hours(1);
if let Err(e) = state
.repos
.user
.set_recovery_token(&user.did, &recovery_token_hash, expires_at)
.await
{
error!("Error updating recovery token: {:?}", e);
return Err(ApiError::InternalError(None));
}
let hostname = &tranquil_config::get().server.hostname;
let recovery_url = format!(
"https://{}/app/recover-passkey?did={}&token={}",
hostname,
urlencoding::encode(&user.did),
urlencoding::encode(&recovery_token)
);
let _ = tranquil_pds::comms::comms_repo::enqueue_passkey_recovery(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user.id,
&recovery_url,
hostname,
)
.await;
info!(did = %user.did, "Passkey recovery requested");
Ok(Json(SuccessResponse { success: true }))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecoverPasskeyAccountInput {
pub did: Did,
pub recovery_token: String,
pub new_password: PlainPassword,
}
pub async fn recover_passkey_account(
State(state): State<AppState>,
Json(input): Json<RecoverPasskeyAccountInput>,
) -> Result<Json<SuccessResponse>, ApiError> {
if let Err(e) = validate_password(&input.new_password) {
return Err(ApiError::InvalidRequest(e.to_string()));
}
let user = match state.repos.user.get_user_for_recovery(&input.did).await {
Ok(Some(u)) => u,
_ => {
return Err(ApiError::InvalidRecoveryLink);
}
};
let token_hash = match &user.recovery_token {
Some(h) => h,
None => {
return Err(ApiError::InvalidRecoveryLink);
}
};
common::validate_token_hash(
user.recovery_token_expires_at,
token_hash,
&input.recovery_token,
ApiError::RecoveryLinkExpired,
ApiError::InvalidRecoveryLink,
)?;
let password_hash = common::hash_or_internal_error(&input.new_password)?;
let recover_input = tranquil_db_traits::RecoverPasskeyAccountInput {
did: input.did.clone(),
password_hash,
};
let result = match state
.repos
.user
.recover_passkey_account(&recover_input)
.await
{
Ok(r) => r,
Err(e) => {
error!("Error recovering passkey account: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
if result.passkeys_deleted > 0 {
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
}
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user.id).await {
let actual_channel =
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
if let Err(e) = state
.repos
.user
.set_channel_verified(&input.did, actual_channel)
.await
{
warn!(
"Failed to implicitly verify channel on passkey recovery: {:?}",
e
);
}
}
info!(did = %input.did, "Passkey-only account recovered with temporary password");
Ok(Json(SuccessResponse { success: true }))
}