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 )
641 .await
642 {
643 warn!("Failed to enqueue signup verification: {:?}", e);
644 }
645
646 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion");
647
648 Json(CreatePasskeyAccountResponse {
649 did,
650 handle,
651 setup_token,
652 setup_expires_at,
653 })
654 .into_response()
655}
656
657#[derive(Deserialize)]
658#[serde(rename_all = "camelCase")]
659pub struct CompletePasskeySetupInput {
660 pub did: String,
661 pub setup_token: String,
662 pub passkey_credential: serde_json::Value,
663 pub passkey_friendly_name: Option<String>,
664}
665
666#[derive(Serialize)]
667#[serde(rename_all = "camelCase")]
668pub struct CompletePasskeySetupResponse {
669 pub did: String,
670 pub handle: String,
671 pub app_password: String,
672 pub app_password_name: String,
673}
674
675pub async fn complete_passkey_setup(
676 State(state): State<AppState>,
677 Json(input): Json<CompletePasskeySetupInput>,
678) -> Response {
679 let user = sqlx::query!(
680 r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required
681 FROM users WHERE did = $1"#,
682 input.did
683 )
684 .fetch_optional(&state.db)
685 .await;
686
687 let user = match user {
688 Ok(Some(u)) => u,
689 Ok(None) => {
690 return (
691 StatusCode::NOT_FOUND,
692 Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
693 )
694 .into_response();
695 }
696 Err(e) => {
697 error!("DB error: {:?}", e);
698 return (
699 StatusCode::INTERNAL_SERVER_ERROR,
700 Json(json!({"error": "InternalError"})),
701 )
702 .into_response();
703 }
704 };
705
706 if user.password_required {
707 return (
708 StatusCode::BAD_REQUEST,
709 Json(json!({"error": "InvalidAccount", "message": "This account is not a passkey-only account"})),
710 )
711 .into_response();
712 }
713
714 let token_hash = match &user.recovery_token {
715 Some(h) => h,
716 None => {
717 return (
718 StatusCode::BAD_REQUEST,
719 Json(json!({"error": "SetupExpired", "message": "Setup has already been completed or expired"})),
720 )
721 .into_response();
722 }
723 };
724
725 if let Some(expires_at) = user.recovery_token_expires_at
726 && expires_at < Utc::now()
727 {
728 return (
729 StatusCode::BAD_REQUEST,
730 Json(json!({"error": "SetupExpired", "message": "Setup token has expired"})),
731 )
732 .into_response();
733 }
734
735 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
736 return (
737 StatusCode::UNAUTHORIZED,
738 Json(json!({"error": "InvalidToken", "message": "Invalid setup token"})),
739 )
740 .into_response();
741 }
742
743 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
744 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
745 Ok(w) => w,
746 Err(e) => {
747 error!("Failed to create WebAuthn config: {:?}", e);
748 return (
749 StatusCode::INTERNAL_SERVER_ERROR,
750 Json(json!({"error": "InternalError"})),
751 )
752 .into_response();
753 }
754 };
755
756 let reg_state = match crate::auth::webauthn::load_registration_state(&state.db, &input.did)
757 .await
758 {
759 Ok(Some(s)) => s,
760 Ok(None) => {
761 return (
762 StatusCode::BAD_REQUEST,
763 Json(json!({"error": "NoChallengeInProgress", "message": "Please start passkey registration first"})),
764 )
765 .into_response();
766 }
767 Err(e) => {
768 error!("Error loading registration state: {:?}", e);
769 return (
770 StatusCode::INTERNAL_SERVER_ERROR,
771 Json(json!({"error": "InternalError"})),
772 )
773 .into_response();
774 }
775 };
776
777 let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = match serde_json::from_value(
778 input.passkey_credential,
779 ) {
780 Ok(c) => c,
781 Err(e) => {
782 warn!("Failed to parse credential: {:?}", e);
783 return (
784 StatusCode::BAD_REQUEST,
785 Json(
786 json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
787 ),
788 )
789 .into_response();
790 }
791 };
792
793 let security_key = match webauthn.finish_registration(&credential, ®_state) {
794 Ok(sk) => sk,
795 Err(e) => {
796 warn!("Passkey registration failed: {:?}", e);
797 return (
798 StatusCode::BAD_REQUEST,
799 Json(json!({"error": "RegistrationFailed", "message": "Passkey registration failed"})),
800 )
801 .into_response();
802 }
803 };
804
805 if let Err(e) = crate::auth::webauthn::save_passkey(
806 &state.db,
807 &input.did,
808 &security_key,
809 input.passkey_friendly_name.as_deref(),
810 )
811 .await
812 {
813 error!("Error saving passkey: {:?}", e);
814 return (
815 StatusCode::INTERNAL_SERVER_ERROR,
816 Json(json!({"error": "InternalError"})),
817 )
818 .into_response();
819 }
820
821 let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await;
822
823 let app_password = generate_app_password();
824 let app_password_name = "bsky.app".to_string();
825 let password_hash = match hash(&app_password, DEFAULT_COST) {
826 Ok(h) => h,
827 Err(e) => {
828 error!("Error hashing app password: {:?}", e);
829 return (
830 StatusCode::INTERNAL_SERVER_ERROR,
831 Json(json!({"error": "InternalError"})),
832 )
833 .into_response();
834 }
835 };
836
837 if let Err(e) = sqlx::query!(
838 "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)",
839 user.id,
840 app_password_name,
841 password_hash
842 )
843 .execute(&state.db)
844 .await
845 {
846 error!("Error creating app password: {:?}", e);
847 return (
848 StatusCode::INTERNAL_SERVER_ERROR,
849 Json(json!({"error": "InternalError"})),
850 )
851 .into_response();
852 }
853
854 if let Err(e) = sqlx::query!(
855 "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
856 input.did
857 )
858 .execute(&state.db)
859 .await
860 {
861 error!("Error clearing setup token: {:?}", e);
862 }
863
864 info!(did = %input.did, "Passkey-only account setup completed");
865
866 Json(CompletePasskeySetupResponse {
867 did: input.did,
868 handle: user.handle,
869 app_password,
870 app_password_name,
871 })
872 .into_response()
873}
874
875pub async fn start_passkey_registration_for_setup(
876 State(state): State<AppState>,
877 Json(input): Json<StartPasskeyRegistrationInput>,
878) -> Response {
879 let user = sqlx::query!(
880 r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required
881 FROM users WHERE did = $1"#,
882 input.did
883 )
884 .fetch_optional(&state.db)
885 .await;
886
887 let user = match user {
888 Ok(Some(u)) => u,
889 Ok(None) => {
890 return (
891 StatusCode::NOT_FOUND,
892 Json(json!({"error": "AccountNotFound"})),
893 )
894 .into_response();
895 }
896 Err(e) => {
897 error!("DB error: {:?}", e);
898 return (
899 StatusCode::INTERNAL_SERVER_ERROR,
900 Json(json!({"error": "InternalError"})),
901 )
902 .into_response();
903 }
904 };
905
906 if user.password_required {
907 return (
908 StatusCode::BAD_REQUEST,
909 Json(json!({"error": "InvalidAccount"})),
910 )
911 .into_response();
912 }
913
914 let token_hash = match &user.recovery_token {
915 Some(h) => h,
916 None => {
917 return (
918 StatusCode::BAD_REQUEST,
919 Json(json!({"error": "SetupExpired"})),
920 )
921 .into_response();
922 }
923 };
924
925 if let Some(expires_at) = user.recovery_token_expires_at
926 && expires_at < Utc::now()
927 {
928 return (
929 StatusCode::BAD_REQUEST,
930 Json(json!({"error": "SetupExpired"})),
931 )
932 .into_response();
933 }
934
935 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
936 return (
937 StatusCode::UNAUTHORIZED,
938 Json(json!({"error": "InvalidToken"})),
939 )
940 .into_response();
941 }
942
943 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
944 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
945 Ok(w) => w,
946 Err(e) => {
947 error!("Failed to create WebAuthn config: {:?}", e);
948 return (
949 StatusCode::INTERNAL_SERVER_ERROR,
950 Json(json!({"error": "InternalError"})),
951 )
952 .into_response();
953 }
954 };
955
956 let existing_passkeys = crate::auth::webauthn::get_passkeys_for_user(&state.db, &input.did)
957 .await
958 .unwrap_or_default();
959
960 let exclude_credentials: Vec<webauthn_rs::prelude::CredentialID> = existing_passkeys
961 .iter()
962 .map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone()))
963 .collect();
964
965 let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle);
966
967 let (ccr, reg_state) = match webauthn.start_registration(
968 &input.did,
969 &user.handle,
970 display_name,
971 exclude_credentials,
972 ) {
973 Ok(result) => result,
974 Err(e) => {
975 error!("Failed to start passkey registration: {:?}", e);
976 return (
977 StatusCode::INTERNAL_SERVER_ERROR,
978 Json(json!({"error": "InternalError"})),
979 )
980 .into_response();
981 }
982 };
983
984 if let Err(e) =
985 crate::auth::webauthn::save_registration_state(&state.db, &input.did, ®_state).await
986 {
987 error!("Failed to save registration state: {:?}", e);
988 return (
989 StatusCode::INTERNAL_SERVER_ERROR,
990 Json(json!({"error": "InternalError"})),
991 )
992 .into_response();
993 }
994
995 let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
996 Json(json!({"options": options})).into_response()
997}
998
999#[derive(Deserialize)]
1000#[serde(rename_all = "camelCase")]
1001pub struct StartPasskeyRegistrationInput {
1002 pub did: String,
1003 pub setup_token: String,
1004 pub friendly_name: Option<String>,
1005}
1006
1007#[derive(Deserialize)]
1008#[serde(rename_all = "camelCase")]
1009pub struct RequestPasskeyRecoveryInput {
1010 #[serde(alias = "identifier")]
1011 pub email: String,
1012}
1013
1014pub async fn request_passkey_recovery(
1015 State(state): State<AppState>,
1016 headers: HeaderMap,
1017 Json(input): Json<RequestPasskeyRecoveryInput>,
1018) -> Response {
1019 let client_ip = extract_client_ip(&headers);
1020 if !state
1021 .check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
1022 .await
1023 {
1024 return (
1025 StatusCode::TOO_MANY_REQUESTS,
1026 Json(json!({"error": "RateLimitExceeded"})),
1027 )
1028 .into_response();
1029 }
1030
1031 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1032 let identifier = input.email.trim().to_lowercase();
1033 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
1034 let normalized_handle = if identifier.contains('@') || identifier.contains('.') {
1035 identifier.to_string()
1036 } else {
1037 format!("{}.{}", identifier, pds_hostname)
1038 };
1039
1040 let user = sqlx::query!(
1041 "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2",
1042 identifier,
1043 normalized_handle
1044 )
1045 .fetch_optional(&state.db)
1046 .await;
1047
1048 let user = match user {
1049 Ok(Some(u)) if !u.password_required => u,
1050 _ => {
1051 return Json(json!({"success": true})).into_response();
1052 }
1053 };
1054
1055 let recovery_token = generate_setup_token();
1056 let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) {
1057 Ok(h) => h,
1058 Err(_) => {
1059 return (
1060 StatusCode::INTERNAL_SERVER_ERROR,
1061 Json(json!({"error": "InternalError"})),
1062 )
1063 .into_response();
1064 }
1065 };
1066 let expires_at = Utc::now() + Duration::hours(1);
1067
1068 if let Err(e) = sqlx::query!(
1069 "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
1070 recovery_token_hash,
1071 expires_at,
1072 user.did
1073 )
1074 .execute(&state.db)
1075 .await
1076 {
1077 error!("Error updating recovery token: {:?}", e);
1078 return (
1079 StatusCode::INTERNAL_SERVER_ERROR,
1080 Json(json!({"error": "InternalError"})),
1081 )
1082 .into_response();
1083 }
1084
1085 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1086 let recovery_url = format!(
1087 "https://{}/#/recover-passkey?did={}&token={}",
1088 hostname,
1089 urlencoding::encode(&user.did),
1090 urlencoding::encode(&recovery_token)
1091 );
1092
1093 let _ =
1094 crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await;
1095
1096 info!(did = %user.did, "Passkey recovery requested");
1097 Json(json!({"success": true})).into_response()
1098}
1099
1100#[derive(Deserialize)]
1101#[serde(rename_all = "camelCase")]
1102pub struct RecoverPasskeyAccountInput {
1103 pub did: String,
1104 pub recovery_token: String,
1105 pub new_password: String,
1106}
1107
1108pub async fn recover_passkey_account(
1109 State(state): State<AppState>,
1110 Json(input): Json<RecoverPasskeyAccountInput>,
1111) -> Response {
1112 if let Err(e) = validate_password(&input.new_password) {
1113 return (
1114 StatusCode::BAD_REQUEST,
1115 Json(json!({
1116 "error": "InvalidPassword",
1117 "message": e.to_string()
1118 })),
1119 )
1120 .into_response();
1121 }
1122
1123 let user = sqlx::query!(
1124 "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
1125 input.did
1126 )
1127 .fetch_optional(&state.db)
1128 .await;
1129
1130 let user = match user {
1131 Ok(Some(u)) => u,
1132 _ => {
1133 return (
1134 StatusCode::NOT_FOUND,
1135 Json(json!({"error": "InvalidRecoveryLink"})),
1136 )
1137 .into_response();
1138 }
1139 };
1140
1141 let token_hash = match &user.recovery_token {
1142 Some(h) => h,
1143 None => {
1144 return (
1145 StatusCode::BAD_REQUEST,
1146 Json(json!({"error": "InvalidRecoveryLink"})),
1147 )
1148 .into_response();
1149 }
1150 };
1151
1152 if let Some(expires_at) = user.recovery_token_expires_at
1153 && expires_at < Utc::now()
1154 {
1155 return (
1156 StatusCode::BAD_REQUEST,
1157 Json(json!({"error": "RecoveryLinkExpired"})),
1158 )
1159 .into_response();
1160 }
1161
1162 if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) {
1163 return (
1164 StatusCode::UNAUTHORIZED,
1165 Json(json!({"error": "InvalidRecoveryLink"})),
1166 )
1167 .into_response();
1168 }
1169
1170 let password_hash = match hash(&input.new_password, DEFAULT_COST) {
1171 Ok(h) => h,
1172 Err(_) => {
1173 return (
1174 StatusCode::INTERNAL_SERVER_ERROR,
1175 Json(json!({"error": "InternalError"})),
1176 )
1177 .into_response();
1178 }
1179 };
1180
1181 if let Err(e) = sqlx::query!(
1182 "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
1183 password_hash,
1184 input.did
1185 )
1186 .execute(&state.db)
1187 .await
1188 {
1189 error!("Error updating password: {:?}", e);
1190 return (
1191 StatusCode::INTERNAL_SERVER_ERROR,
1192 Json(json!({"error": "InternalError"})),
1193 )
1194 .into_response();
1195 }
1196
1197 let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did)
1198 .execute(&state.db)
1199 .await;
1200 match deleted {
1201 Ok(result) => {
1202 if result.rows_affected() > 0 {
1203 info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery");
1204 }
1205 }
1206 Err(e) => {
1207 warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e);
1208 }
1209 }
1210
1211 info!(did = %input.did, "Passkey-only account recovered with temporary password");
1212 Json(json!({"success": true})).into_response()
1213}