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