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, ®_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, ®_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}