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