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