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