PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1113 lines 37 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, 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(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 (commit_bytes, _sig) = 516 match create_signed_commit(&did, mst_root, rev.as_ref(), None, &secret_key) { 517 Ok(result) => result, 518 Err(e) => { 519 error!("Error creating genesis commit: {:?}", e); 520 return ApiError::InternalError(None).into_response(); 521 } 522 }; 523 let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { 524 Ok(c) => c, 525 Err(e) => { 526 error!("Error saving genesis commit: {:?}", e); 527 return ApiError::InternalError(None).into_response(); 528 } 529 }; 530 let commit_cid_str = commit_cid.to_string(); 531 let rev_str = rev.as_ref().to_string(); 532 if let Err(e) = sqlx::query!( 533 "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)", 534 user_id, 535 commit_cid_str, 536 rev_str 537 ) 538 .execute(&mut *tx) 539 .await 540 { 541 error!("Error inserting repo: {:?}", e); 542 return ApiError::InternalError(None).into_response(); 543 } 544 let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; 545 if let Err(e) = sqlx::query!( 546 r#" 547 INSERT INTO user_blocks (user_id, block_cid) 548 SELECT $1, block_cid FROM UNNEST($2::bytea[]) AS t(block_cid) 549 ON CONFLICT (user_id, block_cid) DO NOTHING 550 "#, 551 user_id, 552 &genesis_block_cids 553 ) 554 .execute(&mut *tx) 555 .await 556 { 557 error!("Error inserting user_blocks: {:?}", e); 558 return ApiError::InternalError(None).into_response(); 559 } 560 561 if let Some(ref code) = input.invite_code { 562 let _ = sqlx::query!( 563 "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 564 code 565 ) 566 .execute(&mut *tx) 567 .await; 568 569 let _ = sqlx::query!( 570 "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 571 code, 572 user_id 573 ) 574 .execute(&mut *tx) 575 .await; 576 } 577 578 if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() { 579 let birthdate_pref = json!({ 580 "$type": "app.bsky.actor.defs#personalDetailsPref", 581 "birthDate": "1998-05-06T00:00:00.000Z" 582 }); 583 if let Err(e) = sqlx::query!( 584 "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) 585 ON CONFLICT (user_id, name) DO NOTHING", 586 user_id, 587 "app.bsky.actor.defs#personalDetailsPref", 588 birthdate_pref 589 ) 590 .execute(&mut *tx) 591 .await 592 { 593 warn!("Failed to set default birthdate preference: {:?}", e); 594 } 595 } 596 597 if let Err(e) = tx.commit().await { 598 error!("Error committing transaction: {:?}", e); 599 return ApiError::InternalError(None).into_response(); 600 } 601 602 if !is_byod_did_web { 603 if let Err(e) = 604 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 605 { 606 warn!("Failed to sequence identity event for {}: {}", did, e); 607 } 608 if let Err(e) = 609 crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 610 { 611 warn!("Failed to sequence account event for {}: {}", did, e); 612 } 613 let profile_record = serde_json::json!({ 614 "$type": "app.bsky.actor.profile", 615 "displayName": handle 616 }); 617 if let Err(e) = crate::api::repo::record::create_record_internal( 618 &state, 619 &did, 620 "app.bsky.actor.profile", 621 "self", 622 &profile_record, 623 ) 624 .await 625 { 626 warn!("Failed to create default profile for {}: {}", did, e); 627 } 628 } 629 630 let verification_token = crate::auth::verification_token::generate_signup_token( 631 &did, 632 verification_channel, 633 &verification_recipient, 634 ); 635 let formatted_token = 636 crate::auth::verification_token::format_token_for_display(&verification_token); 637 if let Err(e) = crate::comms::enqueue_signup_verification( 638 &state.db, 639 user_id, 640 verification_channel, 641 &verification_recipient, 642 &formatted_token, 643 None, 644 ) 645 .await 646 { 647 warn!("Failed to enqueue signup verification: {:?}", e); 648 } 649 650 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); 651 652 let access_jwt = if byod_auth.is_some() { 653 match crate::auth::token::create_access_token_with_metadata(&did, &secret_key_bytes) { 654 Ok(token_meta) => { 655 let refresh_jti = uuid::Uuid::new_v4().to_string(); 656 let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24); 657 let no_scope: Option<String> = None; 658 if let Err(e) = sqlx::query!( 659 "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)", 660 did, 661 token_meta.jti, 662 refresh_jti, 663 token_meta.expires_at, 664 refresh_expires, 665 false, 666 false, 667 no_scope 668 ) 669 .execute(&state.db) 670 .await 671 { 672 warn!(did = %did, "Failed to insert migration session: {:?}", e); 673 } 674 info!(did = %did, "Generated migration access token for BYOD passkey account"); 675 Some(token_meta.token) 676 } 677 Err(e) => { 678 warn!(did = %did, "Failed to generate migration access token: {:?}", e); 679 None 680 } 681 } 682 } else { 683 None 684 }; 685 686 Json(CreatePasskeyAccountResponse { 687 did: did.into(), 688 handle: handle.into(), 689 setup_token, 690 setup_expires_at, 691 access_jwt, 692 }) 693 .into_response() 694} 695 696#[derive(Deserialize)] 697#[serde(rename_all = "camelCase")] 698pub struct CompletePasskeySetupInput { 699 pub did: Did, 700 pub setup_token: String, 701 pub passkey_credential: serde_json::Value, 702 pub passkey_friendly_name: Option<String>, 703} 704 705#[derive(Serialize)] 706#[serde(rename_all = "camelCase")] 707pub struct CompletePasskeySetupResponse { 708 pub did: Did, 709 pub handle: Handle, 710 pub app_password: String, 711 pub app_password_name: String, 712} 713 714pub async fn complete_passkey_setup( 715 State(state): State<AppState>, 716 Json(input): Json<CompletePasskeySetupInput>, 717) -> Response { 718 let user = sqlx::query!( 719 r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required 720 FROM users WHERE did = $1"#, 721 input.did.as_str() 722 ) 723 .fetch_optional(&state.db) 724 .await; 725 726 let user = match user { 727 Ok(Some(u)) => u, 728 Ok(None) => { 729 return ApiError::AccountNotFound.into_response(); 730 } 731 Err(e) => { 732 error!("DB error: {:?}", e); 733 return ApiError::InternalError(None).into_response(); 734 } 735 }; 736 737 if user.password_required { 738 return ApiError::InvalidAccount.into_response(); 739 } 740 741 let token_hash = match &user.recovery_token { 742 Some(h) => h, 743 None => { 744 return ApiError::SetupExpired.into_response(); 745 } 746 }; 747 748 if let Some(expires_at) = user.recovery_token_expires_at 749 && expires_at < Utc::now() 750 { 751 return ApiError::SetupExpired.into_response(); 752 } 753 754 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { 755 return ApiError::InvalidToken(None).into_response(); 756 } 757 758 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 759 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 760 Ok(w) => w, 761 Err(e) => { 762 error!("Failed to create WebAuthn config: {:?}", e); 763 return ApiError::InternalError(None).into_response(); 764 } 765 }; 766 767 let reg_state = 768 match crate::auth::webauthn::load_registration_state(&state.db, &input.did).await { 769 Ok(Some(s)) => s, 770 Ok(None) => { 771 return ApiError::NoChallengeInProgress.into_response(); 772 } 773 Err(e) => { 774 error!("Error loading registration state: {:?}", e); 775 return ApiError::InternalError(None).into_response(); 776 } 777 }; 778 779 let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = 780 match serde_json::from_value(input.passkey_credential) { 781 Ok(c) => c, 782 Err(e) => { 783 warn!("Failed to parse credential: {:?}", e); 784 return ApiError::InvalidCredential.into_response(); 785 } 786 }; 787 788 let security_key = match webauthn.finish_registration(&credential, &reg_state) { 789 Ok(sk) => sk, 790 Err(e) => { 791 warn!("Passkey registration failed: {:?}", e); 792 return ApiError::RegistrationFailed.into_response(); 793 } 794 }; 795 796 if let Err(e) = crate::auth::webauthn::save_passkey( 797 &state.db, 798 &input.did, 799 &security_key, 800 input.passkey_friendly_name.as_deref(), 801 ) 802 .await 803 { 804 error!("Error saving passkey: {:?}", e); 805 return ApiError::InternalError(None).into_response(); 806 } 807 808 let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; 809 810 let app_password = generate_app_password(); 811 let app_password_name = "bsky.app".to_string(); 812 let password_hash = match hash(&app_password, DEFAULT_COST) { 813 Ok(h) => h, 814 Err(e) => { 815 error!("Error hashing app password: {:?}", e); 816 return ApiError::InternalError(None).into_response(); 817 } 818 }; 819 820 if let Err(e) = sqlx::query!( 821 "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", 822 user.id, 823 app_password_name, 824 password_hash 825 ) 826 .execute(&state.db) 827 .await 828 { 829 error!("Error creating app password: {:?}", e); 830 return ApiError::InternalError(None).into_response(); 831 } 832 833 if let Err(e) = sqlx::query!( 834 "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", 835 input.did.as_str() 836 ) 837 .execute(&state.db) 838 .await 839 { 840 error!("Error clearing setup token: {:?}", e); 841 } 842 843 info!(did = %input.did, "Passkey-only account setup completed"); 844 845 Json(CompletePasskeySetupResponse { 846 did: input.did.clone(), 847 handle: user.handle.into(), 848 app_password, 849 app_password_name, 850 }) 851 .into_response() 852} 853 854pub async fn start_passkey_registration_for_setup( 855 State(state): State<AppState>, 856 Json(input): Json<StartPasskeyRegistrationInput>, 857) -> Response { 858 let user = sqlx::query!( 859 r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required 860 FROM users WHERE did = $1"#, 861 input.did.as_str() 862 ) 863 .fetch_optional(&state.db) 864 .await; 865 866 let user = match user { 867 Ok(Some(u)) => u, 868 Ok(None) => { 869 return ApiError::AccountNotFound.into_response(); 870 } 871 Err(e) => { 872 error!("DB error: {:?}", e); 873 return ApiError::InternalError(None).into_response(); 874 } 875 }; 876 877 if user.password_required { 878 return ApiError::InvalidAccount.into_response(); 879 } 880 881 let token_hash = match &user.recovery_token { 882 Some(h) => h, 883 None => { 884 return ApiError::SetupExpired.into_response(); 885 } 886 }; 887 888 if let Some(expires_at) = user.recovery_token_expires_at 889 && expires_at < Utc::now() 890 { 891 return ApiError::SetupExpired.into_response(); 892 } 893 894 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { 895 return ApiError::InvalidToken(None).into_response(); 896 } 897 898 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 899 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 900 Ok(w) => w, 901 Err(e) => { 902 error!("Failed to create WebAuthn config: {:?}", e); 903 return ApiError::InternalError(None).into_response(); 904 } 905 }; 906 907 let existing_passkeys = crate::auth::webauthn::get_passkeys_for_user(&state.db, &input.did) 908 .await 909 .unwrap_or_default(); 910 911 let exclude_credentials: Vec<webauthn_rs::prelude::CredentialID> = existing_passkeys 912 .iter() 913 .map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone())) 914 .collect(); 915 916 let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle); 917 918 let (ccr, reg_state) = match webauthn.start_registration( 919 &input.did, 920 &user.handle, 921 display_name, 922 exclude_credentials, 923 ) { 924 Ok(result) => result, 925 Err(e) => { 926 error!("Failed to start passkey registration: {:?}", e); 927 return ApiError::InternalError(None).into_response(); 928 } 929 }; 930 931 if let Err(e) = 932 crate::auth::webauthn::save_registration_state(&state.db, &input.did, &reg_state).await 933 { 934 error!("Failed to save registration state: {:?}", e); 935 return ApiError::InternalError(None).into_response(); 936 } 937 938 let options = serde_json::to_value(&ccr).unwrap_or(json!({})); 939 Json(json!({"options": options})).into_response() 940} 941 942#[derive(Deserialize)] 943#[serde(rename_all = "camelCase")] 944pub struct StartPasskeyRegistrationInput { 945 pub did: Did, 946 pub setup_token: String, 947 pub friendly_name: Option<String>, 948} 949 950#[derive(Deserialize)] 951#[serde(rename_all = "camelCase")] 952pub struct RequestPasskeyRecoveryInput { 953 #[serde(alias = "identifier")] 954 pub email: String, 955} 956 957pub async fn request_passkey_recovery( 958 State(state): State<AppState>, 959 headers: HeaderMap, 960 Json(input): Json<RequestPasskeyRecoveryInput>, 961) -> Response { 962 let client_ip = extract_client_ip(&headers); 963 if !state 964 .check_rate_limit(RateLimitKind::PasswordReset, &client_ip) 965 .await 966 { 967 return ApiError::RateLimitExceeded(None).into_response(); 968 } 969 970 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 971 let identifier = input.email.trim().to_lowercase(); 972 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); 973 let normalized_handle = if identifier.contains('@') || identifier.contains('.') { 974 identifier.to_string() 975 } else { 976 format!("{}.{}", identifier, pds_hostname) 977 }; 978 979 let user = sqlx::query!( 980 "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2", 981 identifier, 982 normalized_handle 983 ) 984 .fetch_optional(&state.db) 985 .await; 986 987 let user = match user { 988 Ok(Some(u)) if !u.password_required => u, 989 _ => { 990 return SuccessResponse::ok().into_response(); 991 } 992 }; 993 994 let recovery_token = generate_setup_token(); 995 let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) { 996 Ok(h) => h, 997 Err(_) => { 998 return ApiError::InternalError(None).into_response(); 999 } 1000 }; 1001 let expires_at = Utc::now() + Duration::hours(1); 1002 1003 if let Err(e) = sqlx::query!( 1004 "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3", 1005 recovery_token_hash, 1006 expires_at, 1007 &user.did 1008 ) 1009 .execute(&state.db) 1010 .await 1011 { 1012 error!("Error updating recovery token: {:?}", e); 1013 return ApiError::InternalError(None).into_response(); 1014 } 1015 1016 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1017 let recovery_url = format!( 1018 "https://{}/app/recover-passkey?did={}&token={}", 1019 hostname, 1020 urlencoding::encode(&user.did), 1021 urlencoding::encode(&recovery_token) 1022 ); 1023 1024 let _ = 1025 crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await; 1026 1027 info!(did = %user.did, "Passkey recovery requested"); 1028 SuccessResponse::ok().into_response() 1029} 1030 1031#[derive(Deserialize)] 1032#[serde(rename_all = "camelCase")] 1033pub struct RecoverPasskeyAccountInput { 1034 pub did: Did, 1035 pub recovery_token: String, 1036 pub new_password: PlainPassword, 1037} 1038 1039pub async fn recover_passkey_account( 1040 State(state): State<AppState>, 1041 Json(input): Json<RecoverPasskeyAccountInput>, 1042) -> Response { 1043 if let Err(e) = validate_password(&input.new_password) { 1044 return ApiError::InvalidRequest(e.to_string()).into_response(); 1045 } 1046 1047 let user = sqlx::query!( 1048 "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", 1049 input.did.as_str() 1050 ) 1051 .fetch_optional(&state.db) 1052 .await; 1053 1054 let user = match user { 1055 Ok(Some(u)) => u, 1056 _ => { 1057 return ApiError::InvalidRecoveryLink.into_response(); 1058 } 1059 }; 1060 1061 let token_hash = match &user.recovery_token { 1062 Some(h) => h, 1063 None => { 1064 return ApiError::InvalidRecoveryLink.into_response(); 1065 } 1066 }; 1067 1068 if let Some(expires_at) = user.recovery_token_expires_at 1069 && expires_at < Utc::now() 1070 { 1071 return ApiError::RecoveryLinkExpired.into_response(); 1072 } 1073 1074 if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) { 1075 return ApiError::InvalidRecoveryLink.into_response(); 1076 } 1077 1078 let password_hash = match hash(&input.new_password, DEFAULT_COST) { 1079 Ok(h) => h, 1080 Err(_) => { 1081 return ApiError::InternalError(None).into_response(); 1082 } 1083 }; 1084 1085 if let Err(e) = sqlx::query!( 1086 "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", 1087 password_hash, 1088 input.did.as_str() 1089 ) 1090 .execute(&state.db) 1091 .await 1092 { 1093 error!("Error updating password: {:?}", e); 1094 return ApiError::InternalError(None).into_response(); 1095 } 1096 1097 let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str()) 1098 .execute(&state.db) 1099 .await; 1100 match deleted { 1101 Ok(result) => { 1102 if result.rows_affected() > 0 { 1103 info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery"); 1104 } 1105 } 1106 Err(e) => { 1107 warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e); 1108 } 1109 } 1110 1111 info!(did = %input.did, "Passkey-only account recovered with temporary password"); 1112 SuccessResponse::ok().into_response() 1113}