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