this repo has no description
1use crate::api::SuccessResponse; 2use crate::api::error::ApiError; 3use axum::{ 4 Json, 5 extract::State, 6 http::HeaderMap, 7 response::{IntoResponse, Response}, 8}; 9use bcrypt::{DEFAULT_COST, hash}; 10use chrono::{Duration, Utc}; 11use jacquard::types::{integer::LimitedU32, string::Tid}; 12use jacquard_repo::{mst::Mst, storage::BlockStore}; 13use rand::Rng; 14use serde::{Deserialize, Serialize}; 15use serde_json::json; 16use std::sync::Arc; 17use tracing::{debug, error, info, warn}; 18use uuid::Uuid; 19 20use crate::api::repo::record::utils::create_signed_commit; 21use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; 22use crate::state::{AppState, RateLimitKind}; 23use crate::types::{Did, Handle, PlainPassword}; 24use crate::validation::validate_password; 25 26fn extract_client_ip(headers: &HeaderMap) -> String { 27 if let Some(forwarded) = headers.get("x-forwarded-for") 28 && let Ok(value) = forwarded.to_str() 29 && let Some(first_ip) = value.split(',').next() 30 { 31 return first_ip.trim().to_string(); 32 } 33 if let Some(real_ip) = headers.get("x-real-ip") 34 && let Ok(value) = real_ip.to_str() 35 { 36 return value.trim().to_string(); 37 } 38 "unknown".to_string() 39} 40 41fn generate_setup_token() -> String { 42 let mut rng = rand::thread_rng(); 43 (0..32) 44 .map(|_| { 45 let idx = rng.gen_range(0..36); 46 if idx < 10 { 47 (b'0' + idx) as char 48 } else { 49 (b'a' + idx - 10) as char 50 } 51 }) 52 .collect() 53} 54 55fn generate_app_password() -> String { 56 let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; 57 let mut rng = rand::thread_rng(); 58 let segments: Vec<String> = (0..4) 59 .map(|_| { 60 (0..4) 61 .map(|_| chars[rng.gen_range(0..chars.len())] as char) 62 .collect() 63 }) 64 .collect(); 65 segments.join("-") 66} 67 68#[derive(Deserialize)] 69#[serde(rename_all = "camelCase")] 70pub struct CreatePasskeyAccountInput { 71 pub handle: String, 72 pub email: Option<String>, 73 pub invite_code: Option<String>, 74 pub did: Option<String>, 75 pub did_type: Option<String>, 76 pub signing_key: Option<String>, 77 pub verification_channel: Option<String>, 78 pub discord_id: Option<String>, 79 pub telegram_username: Option<String>, 80 pub signal_number: Option<String>, 81} 82 83#[derive(Serialize)] 84#[serde(rename_all = "camelCase")] 85pub struct CreatePasskeyAccountResponse { 86 pub did: Did, 87 pub handle: Handle, 88 pub setup_token: String, 89 pub setup_expires_at: chrono::DateTime<Utc>, 90 #[serde(skip_serializing_if = "Option::is_none")] 91 pub access_jwt: Option<String>, 92} 93 94pub async fn create_passkey_account( 95 State(state): State<AppState>, 96 headers: HeaderMap, 97 Json(input): Json<CreatePasskeyAccountInput>, 98) -> Response { 99 let client_ip = extract_client_ip(&headers); 100 if !state 101 .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) 102 .await 103 { 104 warn!(ip = %client_ip, "Account creation rate limit exceeded"); 105 return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),)) 106 .into_response(); 107 } 108 109 let byod_auth = if let Some(token) = 110 extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) 111 { 112 if is_service_token(&token) { 113 let verifier = ServiceTokenVerifier::new(); 114 match verifier 115 .verify_service_token(&token, Some("com.atproto.server.createAccount")) 116 .await 117 { 118 Ok(claims) => { 119 debug!( 120 "Service token verified for BYOD did:web: iss={}", 121 claims.iss 122 ); 123 Some(claims.iss) 124 } 125 Err(e) => { 126 error!("Service token verification failed: {:?}", e); 127 return ApiError::AuthenticationFailed(Some(format!( 128 "Service token verification failed: {}", 129 e 130 ))) 131 .into_response(); 132 } 133 } 134 } else { 135 None 136 } 137 } else { 138 None 139 }; 140 141 let is_byod_did_web = byod_auth.is_some() 142 && input 143 .did 144 .as_ref() 145 .map(|d| d.starts_with("did:web:")) 146 .unwrap_or(false); 147 148 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 149 let pds_suffix = format!(".{}", hostname); 150 151 let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 152 let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 153 input 154 .handle 155 .strip_suffix(&pds_suffix) 156 .unwrap_or(&input.handle) 157 } else { 158 &input.handle 159 }; 160 match crate::api::validation::validate_short_handle(handle_to_validate) { 161 Ok(h) => format!("{}.{}", h, hostname), 162 Err(_) => { 163 return ApiError::InvalidHandle(None).into_response(); 164 } 165 } 166 } else { 167 input.handle.to_lowercase() 168 }; 169 170 let email = input 171 .email 172 .as_ref() 173 .map(|e| e.trim().to_string()) 174 .filter(|e| !e.is_empty()); 175 if let Some(ref email) = email 176 && !crate::api::validation::is_valid_email(email) 177 { 178 return ApiError::InvalidEmail.into_response(); 179 } 180 181 if let Some(ref code) = input.invite_code { 182 let valid = sqlx::query_scalar!( 183 "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1", 184 code 185 ) 186 .fetch_optional(&state.db) 187 .await 188 .ok() 189 .flatten() 190 .unwrap_or(Some(false)); 191 192 if valid != Some(true) { 193 return ApiError::InvalidInviteCode.into_response(); 194 } 195 } else { 196 let invite_required = std::env::var("INVITE_CODE_REQUIRED") 197 .map(|v| v == "true" || v == "1") 198 .unwrap_or(false); 199 if invite_required { 200 return ApiError::InviteCodeRequired.into_response(); 201 } 202 } 203 204 let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); 205 let verification_recipient = match verification_channel { 206 "email" => match &email { 207 Some(e) if !e.is_empty() => e.clone(), 208 _ => return ApiError::MissingEmail.into_response(), 209 }, 210 "discord" => match &input.discord_id { 211 Some(id) if !id.trim().is_empty() => id.trim().to_string(), 212 _ => return ApiError::MissingDiscordId.into_response(), 213 }, 214 "telegram" => match &input.telegram_username { 215 Some(username) if !username.trim().is_empty() => username.trim().to_string(), 216 _ => return ApiError::MissingTelegramUsername.into_response(), 217 }, 218 "signal" => match &input.signal_number { 219 Some(number) if !number.trim().is_empty() => number.trim().to_string(), 220 _ => return ApiError::MissingSignalNumber.into_response(), 221 }, 222 _ => return ApiError::InvalidVerificationChannel.into_response(), 223 }; 224 225 use k256::ecdsa::SigningKey; 226 use rand::rngs::OsRng; 227 228 let pds_endpoint = format!("https://{}", hostname); 229 let did_type = input.did_type.as_deref().unwrap_or("plc"); 230 231 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<Uuid>) = 232 if let Some(signing_key_did) = &input.signing_key { 233 let reserved = sqlx::query!( 234 r#" 235 SELECT id, private_key_bytes 236 FROM reserved_signing_keys 237 WHERE public_key_did_key = $1 238 AND used_at IS NULL 239 AND expires_at > NOW() 240 FOR UPDATE 241 "#, 242 signing_key_did 243 ) 244 .fetch_optional(&state.db) 245 .await; 246 match reserved { 247 Ok(Some(row)) => (row.private_key_bytes, Some(row.id)), 248 Ok(None) => { 249 return ApiError::InvalidSigningKey.into_response(); 250 } 251 Err(e) => { 252 error!("Error looking up reserved signing key: {:?}", e); 253 return ApiError::InternalError(None).into_response(); 254 } 255 } 256 } else { 257 let secret_key = k256::SecretKey::random(&mut OsRng); 258 (secret_key.to_bytes().to_vec(), None) 259 }; 260 261 let secret_key = match SigningKey::from_slice(&secret_key_bytes) { 262 Ok(k) => k, 263 Err(e) => { 264 error!("Error creating signing key: {:?}", e); 265 return ApiError::InternalError(None).into_response(); 266 } 267 }; 268 269 let did = match did_type { 270 "web" => { 271 let subdomain_host = format!("{}.{}", input.handle, hostname); 272 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 273 let self_hosted_did = format!("did:web:{}", encoded_subdomain); 274 info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account"); 275 self_hosted_did 276 } 277 "web-external" => { 278 let d = match &input.did { 279 Some(d) if !d.trim().is_empty() => d.trim(), 280 _ => { 281 return ApiError::InvalidRequest( 282 "External did:web requires the 'did' field to be provided".into(), 283 ) 284 .into_response(); 285 } 286 }; 287 if !d.starts_with("did:web:") { 288 return ApiError::InvalidDid("External DID must be a did:web".into()) 289 .into_response(); 290 } 291 if is_byod_did_web { 292 if let Some(ref auth_did) = byod_auth 293 && d != auth_did 294 { 295 return ApiError::AuthorizationError(format!( 296 "Service token issuer {} does not match DID {}", 297 auth_did, d 298 )) 299 .into_response(); 300 } 301 info!(did = %d, "Creating external did:web passkey account (BYOD key)"); 302 } else { 303 if let Err(e) = crate::api::identity::did::verify_did_web( 304 d, 305 &hostname, 306 &input.handle, 307 input.signing_key.as_deref(), 308 ) 309 .await 310 { 311 return ApiError::InvalidDid(e).into_response(); 312 } 313 info!(did = %d, "Creating external did:web passkey account (reserved key)"); 314 } 315 d.to_string() 316 } 317 _ => { 318 if let Some(ref auth_did) = byod_auth { 319 if let Some(ref provided_did) = input.did { 320 if provided_did.starts_with("did:plc:") { 321 if provided_did != auth_did { 322 return ApiError::AuthorizationError(format!( 323 "Service token issuer {} does not match DID {}", 324 auth_did, provided_did 325 )) 326 .into_response(); 327 } 328 info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)"); 329 provided_did.clone() 330 } else { 331 return ApiError::InvalidRequest( 332 "BYOD migration requires a did:plc or did:web DID".into(), 333 ) 334 .into_response(); 335 } 336 } else { 337 return ApiError::InvalidRequest( 338 "BYOD migration requires the 'did' field".into(), 339 ) 340 .into_response(); 341 } 342 } else { 343 let rotation_key = std::env::var("PLC_ROTATION_KEY") 344 .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key)); 345 346 let genesis_result = match crate::plc::create_genesis_operation( 347 &secret_key, 348 &rotation_key, 349 &handle, 350 &pds_endpoint, 351 ) { 352 Ok(r) => r, 353 Err(e) => { 354 error!("Error creating PLC genesis operation: {:?}", e); 355 return ApiError::InternalError(Some("Failed to create PLC operation".into())) 356 .into_response(); 357 } 358 }; 359 360 let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); 361 if let Err(e) = plc_client 362 .send_operation(&genesis_result.did, &genesis_result.signed_operation) 363 .await 364 { 365 error!("Failed to submit PLC genesis operation: {:?}", e); 366 return ApiError::UpstreamErrorMsg(format!( 367 "Failed to register DID with PLC directory: {}", 368 e 369 )) 370 .into_response(); 371 } 372 genesis_result.did 373 } 374 } 375 }; 376 377 info!(did = %did, handle = %handle, "Created DID for passkey-only account"); 378 379 let setup_token = generate_setup_token(); 380 let setup_token_hash = match hash(&setup_token, DEFAULT_COST) { 381 Ok(h) => h, 382 Err(e) => { 383 error!("Error hashing setup token: {:?}", e); 384 return ApiError::InternalError(None).into_response(); 385 } 386 }; 387 let setup_expires_at = Utc::now() + Duration::hours(1); 388 389 let mut tx = match state.db.begin().await { 390 Ok(tx) => tx, 391 Err(e) => { 392 error!("Error starting transaction: {:?}", e); 393 return ApiError::InternalError(None).into_response(); 394 } 395 }; 396 397 let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") 398 .fetch_one(&mut *tx) 399 .await 400 .map(|c| c.unwrap_or(0) == 0) 401 .unwrap_or(false); 402 403 let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web { 404 Some(Utc::now()) 405 } else { 406 None 407 }; 408 409 let user_insert: Result<(Uuid,), _> = sqlx::query_as( 410 r#"INSERT INTO users ( 411 handle, email, did, password_hash, password_required, 412 preferred_comms_channel, 413 discord_id, telegram_username, signal_number, 414 recovery_token, recovery_token_expires_at, 415 is_admin, deactivated_at 416 ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#, 417 ) 418 .bind(&handle) 419 .bind(&email) 420 .bind(&did) 421 .bind(verification_channel) 422 .bind( 423 input 424 .discord_id 425 .as_deref() 426 .map(|s| s.trim()) 427 .filter(|s| !s.is_empty()), 428 ) 429 .bind( 430 input 431 .telegram_username 432 .as_deref() 433 .map(|s| s.trim()) 434 .filter(|s| !s.is_empty()), 435 ) 436 .bind( 437 input 438 .signal_number 439 .as_deref() 440 .map(|s| s.trim()) 441 .filter(|s| !s.is_empty()), 442 ) 443 .bind(&setup_token_hash) 444 .bind(setup_expires_at) 445 .bind(is_first_user) 446 .bind(deactivated_at) 447 .fetch_one(&mut *tx) 448 .await; 449 450 let user_id = match user_insert { 451 Ok((id,)) => id, 452 Err(e) => { 453 if let Some(db_err) = e.as_database_error() 454 && db_err.code().as_deref() == Some("23505") 455 { 456 let constraint = db_err.constraint().unwrap_or(""); 457 if constraint.contains("handle") { 458 return ApiError::HandleNotAvailable(None).into_response(); 459 } else if constraint.contains("email") { 460 return ApiError::EmailTaken.into_response(); 461 } 462 } 463 error!("Error inserting user: {:?}", e); 464 return ApiError::InternalError(None).into_response(); 465 } 466 }; 467 468 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 469 Ok(bytes) => bytes, 470 Err(e) => { 471 error!("Error encrypting signing key: {:?}", e); 472 return ApiError::InternalError(None).into_response(); 473 } 474 }; 475 476 if let Err(e) = sqlx::query!( 477 "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", 478 user_id, 479 &encrypted_key_bytes[..], 480 crate::config::ENCRYPTION_VERSION 481 ) 482 .execute(&mut *tx) 483 .await 484 { 485 error!("Error inserting user key: {:?}", e); 486 return ApiError::InternalError(None).into_response(); 487 } 488 489 if let Some(key_id) = reserved_key_id 490 && let Err(e) = sqlx::query!( 491 "UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1", 492 key_id 493 ) 494 .execute(&mut *tx) 495 .await 496 { 497 error!("Error marking reserved key as used: {:?}", e); 498 return ApiError::InternalError(None).into_response(); 499 } 500 501 let mst = Mst::new(Arc::new(state.block_store.clone())); 502 let mst_root = match mst.persist().await { 503 Ok(c) => c, 504 Err(e) => { 505 error!("Error persisting MST: {:?}", e); 506 return ApiError::InternalError(None).into_response(); 507 } 508 }; 509 let rev = Tid::now(LimitedU32::MIN); 510 let (commit_bytes, _sig) = 511 match create_signed_commit(&did, mst_root, rev.as_ref(), None, &secret_key) { 512 Ok(result) => result, 513 Err(e) => { 514 error!("Error creating genesis commit: {:?}", e); 515 return ApiError::InternalError(None).into_response(); 516 } 517 }; 518 let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { 519 Ok(c) => c, 520 Err(e) => { 521 error!("Error saving genesis commit: {:?}", e); 522 return ApiError::InternalError(None).into_response(); 523 } 524 }; 525 let commit_cid_str = commit_cid.to_string(); 526 let rev_str = rev.as_ref().to_string(); 527 if let Err(e) = sqlx::query!( 528 "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)", 529 user_id, 530 commit_cid_str, 531 rev_str 532 ) 533 .execute(&mut *tx) 534 .await 535 { 536 error!("Error inserting repo: {:?}", e); 537 return ApiError::InternalError(None).into_response(); 538 } 539 let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 540 if let Err(e) = sqlx::query!( 541 r#" 542 INSERT INTO user_blocks (user_id, block_cid) 543 SELECT $1, block_cid FROM UNNEST($2::bytea[]) AS t(block_cid) 544 ON CONFLICT (user_id, block_cid) DO NOTHING 545 "#, 546 user_id, 547 &genesis_block_cids 548 ) 549 .execute(&mut *tx) 550 .await 551 { 552 error!("Error inserting user_blocks: {:?}", e); 553 return ApiError::InternalError(None).into_response(); 554 } 555 556 if let Some(ref code) = input.invite_code { 557 let _ = sqlx::query!( 558 "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 559 code 560 ) 561 .execute(&mut *tx) 562 .await; 563 564 let _ = sqlx::query!( 565 "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 566 code, 567 user_id 568 ) 569 .execute(&mut *tx) 570 .await; 571 } 572 573 if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() { 574 let birthdate_pref = json!({ 575 "$type": "app.bsky.actor.defs#personalDetailsPref", 576 "birthDate": "1998-05-06T00:00:00.000Z" 577 }); 578 if let Err(e) = sqlx::query!( 579 "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) 580 ON CONFLICT (user_id, name) DO NOTHING", 581 user_id, 582 "app.bsky.actor.defs#personalDetailsPref", 583 birthdate_pref 584 ) 585 .execute(&mut *tx) 586 .await 587 { 588 warn!("Failed to set default birthdate preference: {:?}", e); 589 } 590 } 591 592 if let Err(e) = tx.commit().await { 593 error!("Error committing transaction: {:?}", e); 594 return ApiError::InternalError(None).into_response(); 595 } 596 597 if !is_byod_did_web { 598 if let Err(e) = 599 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 600 { 601 warn!("Failed to sequence identity event for {}: {}", did, e); 602 } 603 if let Err(e) = 604 crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 605 { 606 warn!("Failed to sequence account event for {}: {}", did, e); 607 } 608 let profile_record = serde_json::json!({ 609 "$type": "app.bsky.actor.profile", 610 "displayName": handle 611 }); 612 if let Err(e) = crate::api::repo::record::create_record_internal( 613 &state, 614 &did, 615 "app.bsky.actor.profile", 616 "self", 617 &profile_record, 618 ) 619 .await 620 { 621 warn!("Failed to create default profile for {}: {}", did, e); 622 } 623 } 624 625 let verification_token = crate::auth::verification_token::generate_signup_token( 626 &did, 627 verification_channel, 628 &verification_recipient, 629 ); 630 let formatted_token = 631 crate::auth::verification_token::format_token_for_display(&verification_token); 632 if let Err(e) = crate::comms::enqueue_signup_verification( 633 &state.db, 634 user_id, 635 verification_channel, 636 &verification_recipient, 637 &formatted_token, 638 None, 639 ) 640 .await 641 { 642 warn!("Failed to enqueue signup verification: {:?}", e); 643 } 644 645 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); 646 647 let access_jwt = if byod_auth.is_some() { 648 match crate::auth::token::create_access_token_with_metadata(&did, &secret_key_bytes) { 649 Ok(token_meta) => { 650 let refresh_jti = uuid::Uuid::new_v4().to_string(); 651 let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24); 652 let no_scope: Option<String> = None; 653 if let Err(e) = sqlx::query!( 654 "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 655 did, 656 token_meta.jti, 657 refresh_jti, 658 token_meta.expires_at, 659 refresh_expires, 660 false, 661 false, 662 no_scope 663 ) 664 .execute(&state.db) 665 .await 666 { 667 warn!(did = %did, "Failed to insert migration session: {:?}", e); 668 } 669 info!(did = %did, "Generated migration access token for BYOD passkey account"); 670 Some(token_meta.token) 671 } 672 Err(e) => { 673 warn!(did = %did, "Failed to generate migration access token: {:?}", e); 674 None 675 } 676 } 677 } else { 678 None 679 }; 680 681 Json(CreatePasskeyAccountResponse { 682 did: did.into(), 683 handle: handle.into(), 684 setup_token, 685 setup_expires_at, 686 access_jwt, 687 }) 688 .into_response() 689} 690 691#[derive(Deserialize)] 692#[serde(rename_all = "camelCase")] 693pub struct CompletePasskeySetupInput { 694 pub did: Did, 695 pub setup_token: String, 696 pub passkey_credential: serde_json::Value, 697 pub passkey_friendly_name: Option<String>, 698} 699 700#[derive(Serialize)] 701#[serde(rename_all = "camelCase")] 702pub struct CompletePasskeySetupResponse { 703 pub did: Did, 704 pub handle: Handle, 705 pub app_password: String, 706 pub app_password_name: String, 707} 708 709pub async fn complete_passkey_setup( 710 State(state): State<AppState>, 711 Json(input): Json<CompletePasskeySetupInput>, 712) -> Response { 713 let user = sqlx::query!( 714 r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required 715 FROM users WHERE did = $1"#, 716 input.did.as_str() 717 ) 718 .fetch_optional(&state.db) 719 .await; 720 721 let user = match user { 722 Ok(Some(u)) => u, 723 Ok(None) => { 724 return ApiError::AccountNotFound.into_response(); 725 } 726 Err(e) => { 727 error!("DB error: {:?}", e); 728 return ApiError::InternalError(None).into_response(); 729 } 730 }; 731 732 if user.password_required { 733 return ApiError::InvalidAccount.into_response(); 734 } 735 736 let token_hash = match &user.recovery_token { 737 Some(h) => h, 738 None => { 739 return ApiError::SetupExpired.into_response(); 740 } 741 }; 742 743 if let Some(expires_at) = user.recovery_token_expires_at 744 && expires_at < Utc::now() 745 { 746 return ApiError::SetupExpired.into_response(); 747 } 748 749 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { 750 return ApiError::InvalidToken(None).into_response(); 751 } 752 753 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 754 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 755 Ok(w) => w, 756 Err(e) => { 757 error!("Failed to create WebAuthn config: {:?}", e); 758 return ApiError::InternalError(None).into_response(); 759 } 760 }; 761 762 let reg_state = match crate::auth::webauthn::load_registration_state(&state.db, &input.did) 763 .await 764 { 765 Ok(Some(s)) => s, 766 Ok(None) => { 767 return ApiError::NoChallengeInProgress.into_response(); 768 } 769 Err(e) => { 770 error!("Error loading registration state: {:?}", e); 771 return ApiError::InternalError(None).into_response(); 772 } 773 }; 774 775 let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = 776 match serde_json::from_value(input.passkey_credential) { 777 Ok(c) => c, 778 Err(e) => { 779 warn!("Failed to parse credential: {:?}", e); 780 return ApiError::InvalidCredential.into_response(); 781 } 782 }; 783 784 let security_key = match webauthn.finish_registration(&credential, &reg_state) { 785 Ok(sk) => sk, 786 Err(e) => { 787 warn!("Passkey registration failed: {:?}", e); 788 return ApiError::RegistrationFailed.into_response(); 789 } 790 }; 791 792 if let Err(e) = crate::auth::webauthn::save_passkey( 793 &state.db, 794 &input.did, 795 &security_key, 796 input.passkey_friendly_name.as_deref(), 797 ) 798 .await 799 { 800 error!("Error saving passkey: {:?}", e); 801 return ApiError::InternalError(None).into_response(); 802 } 803 804 let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; 805 806 let app_password = generate_app_password(); 807 let app_password_name = "bsky.app".to_string(); 808 let password_hash = match hash(&app_password, DEFAULT_COST) { 809 Ok(h) => h, 810 Err(e) => { 811 error!("Error hashing app password: {:?}", e); 812 return ApiError::InternalError(None).into_response(); 813 } 814 }; 815 816 if let Err(e) = sqlx::query!( 817 "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", 818 user.id, 819 app_password_name, 820 password_hash 821 ) 822 .execute(&state.db) 823 .await 824 { 825 error!("Error creating app password: {:?}", e); 826 return ApiError::InternalError(None).into_response(); 827 } 828 829 if let Err(e) = sqlx::query!( 830 "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", 831 input.did.as_str() 832 ) 833 .execute(&state.db) 834 .await 835 { 836 error!("Error clearing setup token: {:?}", e); 837 } 838 839 info!(did = %input.did, "Passkey-only account setup completed"); 840 841 Json(CompletePasskeySetupResponse { 842 did: input.did.clone(), 843 handle: user.handle.into(), 844 app_password, 845 app_password_name, 846 }) 847 .into_response() 848} 849 850pub async fn start_passkey_registration_for_setup( 851 State(state): State<AppState>, 852 Json(input): Json<StartPasskeyRegistrationInput>, 853) -> Response { 854 let user = sqlx::query!( 855 r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required 856 FROM users WHERE did = $1"#, 857 input.did.as_str() 858 ) 859 .fetch_optional(&state.db) 860 .await; 861 862 let user = match user { 863 Ok(Some(u)) => u, 864 Ok(None) => { 865 return ApiError::AccountNotFound.into_response(); 866 } 867 Err(e) => { 868 error!("DB error: {:?}", e); 869 return ApiError::InternalError(None).into_response(); 870 } 871 }; 872 873 if user.password_required { 874 return ApiError::InvalidAccount.into_response(); 875 } 876 877 let token_hash = match &user.recovery_token { 878 Some(h) => h, 879 None => { 880 return ApiError::SetupExpired.into_response(); 881 } 882 }; 883 884 if let Some(expires_at) = user.recovery_token_expires_at 885 && expires_at < Utc::now() 886 { 887 return ApiError::SetupExpired.into_response(); 888 } 889 890 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { 891 return ApiError::InvalidToken(None).into_response(); 892 } 893 894 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 895 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 896 Ok(w) => w, 897 Err(e) => { 898 error!("Failed to create WebAuthn config: {:?}", e); 899 return ApiError::InternalError(None).into_response(); 900 } 901 }; 902 903 let existing_passkeys = crate::auth::webauthn::get_passkeys_for_user(&state.db, &input.did) 904 .await 905 .unwrap_or_default(); 906 907 let exclude_credentials: Vec<webauthn_rs::prelude::CredentialID> = existing_passkeys 908 .iter() 909 .map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone())) 910 .collect(); 911 912 let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle); 913 914 let (ccr, reg_state) = match webauthn.start_registration( 915 &input.did, 916 &user.handle, 917 display_name, 918 exclude_credentials, 919 ) { 920 Ok(result) => result, 921 Err(e) => { 922 error!("Failed to start passkey registration: {:?}", e); 923 return ApiError::InternalError(None).into_response(); 924 } 925 }; 926 927 if let Err(e) = 928 crate::auth::webauthn::save_registration_state(&state.db, &input.did, &reg_state).await 929 { 930 error!("Failed to save registration state: {:?}", e); 931 return ApiError::InternalError(None).into_response(); 932 } 933 934 let options = serde_json::to_value(&ccr).unwrap_or(json!({})); 935 Json(json!({"options": options})).into_response() 936} 937 938#[derive(Deserialize)] 939#[serde(rename_all = "camelCase")] 940pub struct StartPasskeyRegistrationInput { 941 pub did: Did, 942 pub setup_token: String, 943 pub friendly_name: Option<String>, 944} 945 946#[derive(Deserialize)] 947#[serde(rename_all = "camelCase")] 948pub struct RequestPasskeyRecoveryInput { 949 #[serde(alias = "identifier")] 950 pub email: String, 951} 952 953pub async fn request_passkey_recovery( 954 State(state): State<AppState>, 955 headers: HeaderMap, 956 Json(input): Json<RequestPasskeyRecoveryInput>, 957) -> Response { 958 let client_ip = extract_client_ip(&headers); 959 if !state 960 .check_rate_limit(RateLimitKind::PasswordReset, &client_ip) 961 .await 962 { 963 return ApiError::RateLimitExceeded(None).into_response(); 964 } 965 966 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 967 let identifier = input.email.trim().to_lowercase(); 968 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); 969 let normalized_handle = if identifier.contains('@') || identifier.contains('.') { 970 identifier.to_string() 971 } else { 972 format!("{}.{}", identifier, pds_hostname) 973 }; 974 975 let user = sqlx::query!( 976 "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2", 977 identifier, 978 normalized_handle 979 ) 980 .fetch_optional(&state.db) 981 .await; 982 983 let user = match user { 984 Ok(Some(u)) if !u.password_required => u, 985 _ => { 986 return SuccessResponse::ok().into_response(); 987 } 988 }; 989 990 let recovery_token = generate_setup_token(); 991 let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) { 992 Ok(h) => h, 993 Err(_) => { 994 return ApiError::InternalError(None).into_response(); 995 } 996 }; 997 let expires_at = Utc::now() + Duration::hours(1); 998 999 if let Err(e) = sqlx::query!( 1000 "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3", 1001 recovery_token_hash, 1002 expires_at, 1003 &user.did 1004 ) 1005 .execute(&state.db) 1006 .await 1007 { 1008 error!("Error updating recovery token: {:?}", e); 1009 return ApiError::InternalError(None).into_response(); 1010 } 1011 1012 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1013 let recovery_url = format!( 1014 "https://{}/app/recover-passkey?did={}&token={}", 1015 hostname, 1016 urlencoding::encode(&user.did), 1017 urlencoding::encode(&recovery_token) 1018 ); 1019 1020 let _ = 1021 crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await; 1022 1023 info!(did = %user.did, "Passkey recovery requested"); 1024 SuccessResponse::ok().into_response() 1025} 1026 1027#[derive(Deserialize)] 1028#[serde(rename_all = "camelCase")] 1029pub struct RecoverPasskeyAccountInput { 1030 pub did: Did, 1031 pub recovery_token: String, 1032 pub new_password: PlainPassword, 1033} 1034 1035pub async fn recover_passkey_account( 1036 State(state): State<AppState>, 1037 Json(input): Json<RecoverPasskeyAccountInput>, 1038) -> Response { 1039 if let Err(e) = validate_password(&input.new_password) { 1040 return ApiError::InvalidRequest(e.to_string()).into_response(); 1041 } 1042 1043 let user = sqlx::query!( 1044 "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", 1045 input.did.as_str() 1046 ) 1047 .fetch_optional(&state.db) 1048 .await; 1049 1050 let user = match user { 1051 Ok(Some(u)) => u, 1052 _ => { 1053 return ApiError::InvalidRecoveryLink.into_response(); 1054 } 1055 }; 1056 1057 let token_hash = match &user.recovery_token { 1058 Some(h) => h, 1059 None => { 1060 return ApiError::InvalidRecoveryLink.into_response(); 1061 } 1062 }; 1063 1064 if let Some(expires_at) = user.recovery_token_expires_at 1065 && expires_at < Utc::now() 1066 { 1067 return ApiError::RecoveryLinkExpired.into_response(); 1068 } 1069 1070 if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) { 1071 return ApiError::InvalidRecoveryLink.into_response(); 1072 } 1073 1074 let password_hash = match hash(&input.new_password, DEFAULT_COST) { 1075 Ok(h) => h, 1076 Err(_) => { 1077 return ApiError::InternalError(None).into_response(); 1078 } 1079 }; 1080 1081 if let Err(e) = sqlx::query!( 1082 "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", 1083 password_hash, 1084 input.did.as_str() 1085 ) 1086 .execute(&state.db) 1087 .await 1088 { 1089 error!("Error updating password: {:?}", e); 1090 return ApiError::InternalError(None).into_response(); 1091 } 1092 1093 let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str()) 1094 .execute(&state.db) 1095 .await; 1096 match deleted { 1097 Ok(result) => { 1098 if result.rows_affected() > 0 { 1099 info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery"); 1100 } 1101 } 1102 Err(e) => { 1103 warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e); 1104 } 1105 } 1106 1107 info!(did = %input.did, "Passkey-only account recovered with temporary password"); 1108 SuccessResponse::ok().into_response() 1109}