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