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