Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Inbound migrations work

+951 -184
+82
.sqlx/query-17da8b6f6b46eae067bd8842a369a406699888f689122d2bae8bef13b532bcd2.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT\n handle, email, email_verified, is_admin, deactivated_at,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "handle", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email_verified", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "is_admin", 24 + "type_info": "Bool" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "deactivated_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "preferred_channel: crate::comms::CommsChannel", 34 + "type_info": { 35 + "Custom": { 36 + "name": "comms_channel", 37 + "kind": { 38 + "Enum": [ 39 + "email", 40 + "discord", 41 + "telegram", 42 + "signal" 43 + ] 44 + } 45 + } 46 + } 47 + }, 48 + { 49 + "ordinal": 6, 50 + "name": "discord_verified", 51 + "type_info": "Bool" 52 + }, 53 + { 54 + "ordinal": 7, 55 + "name": "telegram_verified", 56 + "type_info": "Bool" 57 + }, 58 + { 59 + "ordinal": 8, 60 + "name": "signal_verified", 61 + "type_info": "Bool" 62 + } 63 + ], 64 + "parameters": { 65 + "Left": [ 66 + "Text" 67 + ] 68 + }, 69 + "nullable": [ 70 + false, 71 + true, 72 + false, 73 + false, 74 + true, 75 + false, 76 + false, 77 + false, 78 + false 79 + ] 80 + }, 81 + "hash": "17da8b6f6b46eae067bd8842a369a406699888f689122d2bae8bef13b532bcd2" 82 + }
+28
.sqlx/query-933f6585efdafedc82a8b6ac3c1513f25459bd9ab08e385ebc929469666d7747.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, deactivated_at FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "deactivated_at", 14 + "type_info": "Timestamptz" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "933f6585efdafedc82a8b6ac3c1513f25459bd9ab08e385ebc929469666d7747" 28 + }
+2 -2
.sqlx/query-d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89.json .sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1", 3 + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 72 72 true 73 73 ] 74 74 }, 75 - "hash": "d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89" 75 + "hash": "c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590" 76 76 }
+34
.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + true 31 + ] 32 + }, 33 + "hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7" 34 + }
+6 -6
src/api/actor/preferences.rs
··· 32 32 .into_response(); 33 33 } 34 34 }; 35 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 35 + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { 36 36 Ok(user) => user, 37 37 Err(_) => { 38 38 return ( ··· 109 109 .into_response(); 110 110 } 111 111 }; 112 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 112 + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { 113 113 Ok(user) => user, 114 114 Err(_) => { 115 115 return ( ··· 119 119 .into_response(); 120 120 } 121 121 }; 122 - let user_id: uuid::Uuid = 123 - match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) 122 + let (user_id, is_migration): (uuid::Uuid, bool) = 123 + match sqlx::query!("SELECT id, deactivated_at FROM users WHERE did = $1", auth_user.did) 124 124 .fetch_optional(&state.db) 125 125 .await 126 126 { 127 - Ok(Some(id)) => id, 127 + Ok(Some(row)) => (row.id, row.deactivated_at.is_some()), 128 128 _ => { 129 129 return ( 130 130 StatusCode::INTERNAL_SERVER_ERROR, ··· 166 166 ) 167 167 .into_response(); 168 168 } 169 - if pref_type == "app.bsky.actor.defs#declaredAgePref" { 169 + if pref_type == "app.bsky.actor.defs#declaredAgePref" && !is_migration { 170 170 return ( 171 171 StatusCode::BAD_REQUEST, 172 172 Json(json!({"error": "InvalidRequest", "message": "declaredAgePref is read-only"})),
+215 -88
src/api/identity/account.rs
··· 1 1 use super::did::verify_did_web; 2 + use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; 2 3 use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; 3 4 use crate::state::{AppState, RateLimitKind}; 4 5 use axum::{ ··· 15 16 use serde::{Deserialize, Serialize}; 16 17 use serde_json::json; 17 18 use std::sync::Arc; 18 - use tracing::{error, info, warn}; 19 + use tracing::{debug, error, info, warn}; 19 20 20 21 fn extract_client_ip(headers: &HeaderMap) -> String { 21 22 if let Some(forwarded) = headers.get("x-forwarded-for") ··· 50 51 pub struct CreateAccountOutput { 51 52 pub handle: String, 52 53 pub did: String, 54 + #[serde(skip_serializing_if = "Option::is_none")] 55 + pub access_jwt: Option<String>, 56 + #[serde(skip_serializing_if = "Option::is_none")] 57 + pub refresh_jwt: Option<String>, 53 58 pub verification_required: bool, 54 59 pub verification_channel: String, 55 60 } ··· 75 80 ) 76 81 .into_response(); 77 82 } 83 + 84 + let migration_auth = if let Some(token) = 85 + extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) 86 + { 87 + if is_service_token(&token) { 88 + let verifier = ServiceTokenVerifier::new(); 89 + match verifier 90 + .verify_service_token(&token, Some("com.atproto.server.createAccount")) 91 + .await 92 + { 93 + Ok(claims) => { 94 + debug!("Service token verified for migration: iss={}", claims.iss); 95 + Some(claims.iss) 96 + } 97 + Err(e) => { 98 + error!("Service token verification failed: {:?}", e); 99 + return ( 100 + StatusCode::UNAUTHORIZED, 101 + Json(json!({ 102 + "error": "AuthenticationFailed", 103 + "message": format!("Service token verification failed: {}", e) 104 + })), 105 + ) 106 + .into_response(); 107 + } 108 + } 109 + } else { 110 + None 111 + } 112 + } else { 113 + None 114 + }; 115 + 116 + let is_migration = migration_auth.is_some() 117 + && input.did.as_ref().map(|d| d.starts_with("did:plc:")).unwrap_or(false); 118 + 119 + if is_migration { 120 + let migration_did = input.did.as_ref().unwrap(); 121 + let auth_did = migration_auth.as_ref().unwrap(); 122 + if migration_did != auth_did { 123 + return ( 124 + StatusCode::FORBIDDEN, 125 + Json(json!({ 126 + "error": "AuthorizationError", 127 + "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) 128 + })), 129 + ) 130 + .into_response(); 131 + } 132 + info!(did = %migration_did, "Processing account migration"); 133 + } 134 + 78 135 if input.handle.contains('!') || input.handle.contains('@') { 79 136 return ( 80 137 StatusCode::BAD_REQUEST, ··· 99 156 } 100 157 let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); 101 158 let valid_channels = ["email", "discord", "telegram", "signal"]; 102 - if !valid_channels.contains(&verification_channel) { 159 + if !valid_channels.contains(&verification_channel) && !is_migration { 103 160 return ( 104 161 StatusCode::BAD_REQUEST, 105 162 Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel. Must be one of: email, discord, telegram, signal"})), 106 163 ) 107 164 .into_response(); 108 165 } 109 - let verification_recipient = match verification_channel { 110 - "email" => match &input.email { 111 - Some(email) if !email.trim().is_empty() => email.trim().to_string(), 112 - _ => return ( 113 - StatusCode::BAD_REQUEST, 114 - Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})), 115 - ).into_response(), 116 - }, 117 - "discord" => match &input.discord_id { 118 - Some(id) if !id.trim().is_empty() => id.trim().to_string(), 119 - _ => return ( 120 - StatusCode::BAD_REQUEST, 121 - Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})), 122 - ).into_response(), 123 - }, 124 - "telegram" => match &input.telegram_username { 125 - Some(username) if !username.trim().is_empty() => username.trim().to_string(), 126 - _ => return ( 127 - StatusCode::BAD_REQUEST, 128 - Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})), 129 - ).into_response(), 130 - }, 131 - "signal" => match &input.signal_number { 132 - Some(number) if !number.trim().is_empty() => number.trim().to_string(), 166 + let verification_recipient = if is_migration { 167 + None 168 + } else { 169 + Some(match verification_channel { 170 + "email" => match &input.email { 171 + Some(email) if !email.trim().is_empty() => email.trim().to_string(), 172 + _ => return ( 173 + StatusCode::BAD_REQUEST, 174 + Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})), 175 + ).into_response(), 176 + }, 177 + "discord" => match &input.discord_id { 178 + Some(id) if !id.trim().is_empty() => id.trim().to_string(), 179 + _ => return ( 180 + StatusCode::BAD_REQUEST, 181 + Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})), 182 + ).into_response(), 183 + }, 184 + "telegram" => match &input.telegram_username { 185 + Some(username) if !username.trim().is_empty() => username.trim().to_string(), 186 + _ => return ( 187 + StatusCode::BAD_REQUEST, 188 + Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})), 189 + ).into_response(), 190 + }, 191 + "signal" => match &input.signal_number { 192 + Some(number) if !number.trim().is_empty() => number.trim().to_string(), 193 + _ => return ( 194 + StatusCode::BAD_REQUEST, 195 + Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})), 196 + ).into_response(), 197 + }, 133 198 _ => return ( 134 199 StatusCode::BAD_REQUEST, 135 - Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})), 200 + Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})), 136 201 ).into_response(), 137 - }, 138 - _ => return ( 139 - StatusCode::BAD_REQUEST, 140 - Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})), 141 - ).into_response(), 202 + }) 142 203 }; 143 204 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 144 205 let pds_endpoint = format!("https://{}", hostname); ··· 246 307 .into_response(); 247 308 } 248 309 d.clone() 310 + } else if d.starts_with("did:plc:") && is_migration { 311 + d.clone() 249 312 } else { 250 313 return ( 251 314 StatusCode::BAD_REQUEST, 252 - Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc"})), 315 + Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})), 253 316 ) 254 317 .into_response(); 255 318 } ··· 396 459 .await 397 460 .map(|c| c.unwrap_or(0) == 0) 398 461 .unwrap_or(false); 462 + let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration { 463 + Some(chrono::Utc::now()) 464 + } else { 465 + None 466 + }; 399 467 let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 400 468 r#"INSERT INTO users ( 401 469 handle, email, did, password_hash, 402 470 preferred_comms_channel, 403 471 discord_id, telegram_username, signal_number, 404 - is_admin 405 - ) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9) RETURNING id"#, 472 + is_admin, deactivated_at, email_verified 473 + ) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9, $10, $11) RETURNING id"#, 406 474 ) 407 475 .bind(short_handle) 408 476 .bind(&email) ··· 431 499 .filter(|s| !s.is_empty()), 432 500 ) 433 501 .bind(is_first_user) 502 + .bind(deactivated_at) 503 + .bind(is_migration) 434 504 .fetch_one(&mut *tx) 435 505 .await; 436 506 let user_id = match user_insert { ··· 477 547 } 478 548 }; 479 549 480 - if let Err(e) = sqlx::query!( 481 - "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", 482 - user_id, 483 - verification_code, 484 - email, 485 - code_expires_at 486 - ) 487 - .execute(&mut *tx) 488 - .await { 489 - error!("Error inserting verification code: {:?}", e); 490 - return ( 491 - StatusCode::INTERNAL_SERVER_ERROR, 492 - Json(json!({"error": "InternalError"})), 550 + if !is_migration { 551 + if let Err(e) = sqlx::query!( 552 + "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", 553 + user_id, 554 + verification_code, 555 + email, 556 + code_expires_at 493 557 ) 494 - .into_response(); 558 + .execute(&mut *tx) 559 + .await { 560 + error!("Error inserting verification code: {:?}", e); 561 + return ( 562 + StatusCode::INTERNAL_SERVER_ERROR, 563 + Json(json!({"error": "InternalError"})), 564 + ) 565 + .into_response(); 566 + } 495 567 } 496 568 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 497 569 Ok(enc) => enc, ··· 636 708 ) 637 709 .into_response(); 638 710 } 639 - if let Err(e) = 640 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await 641 - { 642 - warn!("Failed to sequence identity event for {}: {}", did, e); 711 + if !is_migration { 712 + if let Err(e) = 713 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await 714 + { 715 + warn!("Failed to sequence identity event for {}: {}", did, e); 716 + } 717 + if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 718 + { 719 + warn!("Failed to sequence account event for {}: {}", did, e); 720 + } 721 + let profile_record = json!({ 722 + "$type": "app.bsky.actor.profile", 723 + "displayName": input.handle 724 + }); 725 + if let Err(e) = crate::api::repo::record::create_record_internal( 726 + &state, 727 + &did, 728 + "app.bsky.actor.profile", 729 + "self", 730 + &profile_record, 731 + ) 732 + .await 733 + { 734 + warn!("Failed to create default profile for {}: {}", did, e); 735 + } 736 + if let Some(ref recipient) = verification_recipient { 737 + if let Err(e) = crate::comms::enqueue_signup_verification( 738 + &state.db, 739 + user_id, 740 + verification_channel, 741 + recipient, 742 + &verification_code, 743 + ) 744 + .await 745 + { 746 + warn!( 747 + "Failed to enqueue signup verification notification: {:?}", 748 + e 749 + ); 750 + } 751 + } 643 752 } 644 - if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 645 - { 646 - warn!("Failed to sequence account event for {}: {}", did, e); 647 - } 648 - let profile_record = json!({ 649 - "$type": "app.bsky.actor.profile", 650 - "displayName": input.handle 651 - }); 652 - if let Err(e) = crate::api::repo::record::create_record_internal( 653 - &state, 654 - &did, 655 - "app.bsky.actor.profile", 656 - "self", 657 - &profile_record, 658 - ) 659 - .await 660 - { 661 - warn!("Failed to create default profile for {}: {}", did, e); 662 - } 663 - if let Err(e) = crate::comms::enqueue_signup_verification( 664 - &state.db, 665 - user_id, 666 - verification_channel, 667 - &verification_recipient, 668 - &verification_code, 669 - ) 670 - .await 671 - { 672 - warn!( 673 - "Failed to enqueue signup verification notification: {:?}", 674 - e 675 - ); 676 - } 753 + 754 + let (access_jwt, refresh_jwt) = if is_migration { 755 + let access_meta = 756 + match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) { 757 + Ok(m) => m, 758 + Err(e) => { 759 + error!("Error creating access token for migration: {:?}", e); 760 + return ( 761 + StatusCode::INTERNAL_SERVER_ERROR, 762 + Json(json!({"error": "InternalError"})), 763 + ) 764 + .into_response(); 765 + } 766 + }; 767 + let refresh_meta = 768 + match crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) { 769 + Ok(m) => m, 770 + Err(e) => { 771 + error!("Error creating refresh token for migration: {:?}", e); 772 + return ( 773 + StatusCode::INTERNAL_SERVER_ERROR, 774 + Json(json!({"error": "InternalError"})), 775 + ) 776 + .into_response(); 777 + } 778 + }; 779 + if let Err(e) = sqlx::query!( 780 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 781 + did, 782 + access_meta.jti, 783 + refresh_meta.jti, 784 + access_meta.expires_at, 785 + refresh_meta.expires_at 786 + ) 787 + .execute(&state.db) 788 + .await 789 + { 790 + error!("Error creating session for migration: {:?}", e); 791 + return ( 792 + StatusCode::INTERNAL_SERVER_ERROR, 793 + Json(json!({"error": "InternalError"})), 794 + ) 795 + .into_response(); 796 + } 797 + (Some(access_meta.token), Some(refresh_meta.token)) 798 + } else { 799 + (None, None) 800 + }; 801 + 677 802 ( 678 803 StatusCode::OK, 679 804 Json(CreateAccountOutput { 680 - handle: short_handle.to_string(), 805 + handle: full_handle.clone(), 681 806 did, 682 - verification_required: true, 807 + access_jwt, 808 + refresh_jwt, 809 + verification_required: !is_migration, 683 810 verification_channel: verification_channel.to_string(), 684 811 }), 685 812 )
+11 -13
src/api/identity/did.rs
··· 1 1 use crate::api::ApiError; 2 + use crate::plc::signing_key_to_did_key; 2 3 use crate::state::AppState; 3 4 use axum::{ 4 5 Json, ··· 309 310 .into_response(); 310 311 } 311 312 }; 312 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 313 + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { 313 314 Ok(user) => user, 314 315 Err(e) => return ApiError::from(e).into_response(), 315 316 }; ··· 334 335 }; 335 336 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 336 337 let pds_endpoint = format!("https://{}", hostname); 337 - let secret_key = match k256::SecretKey::from_slice(&key_bytes) { 338 + let full_handle = if user.handle.contains('.') { 339 + user.handle.clone() 340 + } else { 341 + format!("{}.{}", user.handle, hostname) 342 + }; 343 + let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) { 338 344 Ok(k) => k, 339 345 Err(_) => return ApiError::InternalError.into_response(), 340 346 }; 341 - let public_key = secret_key.public_key(); 342 - let encoded = public_key.to_encoded_point(true); 343 - let did_key = format!( 344 - "did:key:zQ3sh{}", 345 - multibase::encode(multibase::Base::Base58Btc, encoded.as_bytes()) 346 - .chars() 347 - .skip(1) 348 - .collect::<String>() 349 - ); 347 + let did_key = signing_key_to_did_key(&signing_key); 350 348 ( 351 349 StatusCode::OK, 352 350 Json(GetRecommendedDidCredentialsOutput { 353 351 rotation_keys: vec![did_key.clone()], 354 - also_known_as: vec![format!("at://{}", user.handle)], 352 + also_known_as: vec![format!("at://{}", full_handle)], 355 353 verification_methods: VerificationMethods { atproto: did_key }, 356 354 services: Services { 357 355 atproto_pds: AtprotoPds { ··· 380 378 Some(t) => t, 381 379 None => return ApiError::AuthenticationRequired.into_response(), 382 380 }; 383 - let did = match crate::auth::validate_bearer_token(&state.db, &token).await { 381 + let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { 384 382 Ok(user) => user.did, 385 383 Err(e) => return ApiError::from(e).into_response(), 386 384 };
+1 -1
src/api/identity/plc/request.rs
··· 24 24 Some(t) => t, 25 25 None => return ApiError::AuthenticationRequired.into_response(), 26 26 }; 27 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 27 + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { 28 28 Ok(user) => user, 29 29 Err(e) => return ApiError::from(e).into_response(), 30 30 };
+1 -1
src/api/identity/plc/sign.rs
··· 50 50 Some(t) => t, 51 51 None => return ApiError::AuthenticationRequired.into_response(), 52 52 }; 53 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &bearer).await { 53 + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await { 54 54 Ok(user) => user, 55 55 Err(e) => return ApiError::from(e).into_response(), 56 56 };
+38 -33
src/api/identity/plc/submit.rs
··· 29 29 Some(t) => t, 30 30 None => return ApiError::AuthenticationRequired.into_response(), 31 31 }; 32 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &bearer).await { 32 + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await { 33 33 Ok(user) => user, 34 34 Err(e) => return ApiError::from(e).into_response(), 35 35 }; ··· 40 40 let op = &input.operation; 41 41 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 42 42 let public_url = format!("https://{}", hostname); 43 - let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) 43 + let user = match sqlx::query!("SELECT id, handle, deactivated_at FROM users WHERE did = $1", did) 44 44 .fetch_optional(&state.db) 45 45 .await 46 46 { ··· 53 53 .into_response(); 54 54 } 55 55 }; 56 + let is_migration = user.deactivated_at.is_some(); 56 57 let key_row = match sqlx::query!( 57 58 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", 58 59 user.id ··· 93 94 } 94 95 }; 95 96 let user_did_key = signing_key_to_did_key(&signing_key); 96 - if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { 97 - let server_rotation_key = 98 - std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); 99 - let has_server_key = rotation_keys 100 - .iter() 101 - .any(|k| k.as_str() == Some(&server_rotation_key)); 102 - if !has_server_key { 103 - return ( 104 - StatusCode::BAD_REQUEST, 105 - Json(json!({ 106 - "error": "InvalidRequest", 107 - "message": "Rotation keys do not include server's rotation key" 108 - })), 109 - ) 110 - .into_response(); 97 + if !is_migration { 98 + if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { 99 + let server_rotation_key = 100 + std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); 101 + let has_server_key = rotation_keys 102 + .iter() 103 + .any(|k| k.as_str() == Some(&server_rotation_key)); 104 + if !has_server_key { 105 + return ( 106 + StatusCode::BAD_REQUEST, 107 + Json(json!({ 108 + "error": "InvalidRequest", 109 + "message": "Rotation keys do not include server's rotation key" 110 + })), 111 + ) 112 + .into_response(); 113 + } 111 114 } 112 115 } 113 116 if let Some(services) = op.get("services").and_then(|v| v.as_object()) ··· 135 138 .into_response(); 136 139 } 137 140 } 138 - if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) 139 - && let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) 140 - && atproto_key != user_did_key { 141 + if !is_migration { 142 + if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) 143 + && let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) 144 + && atproto_key != user_did_key { 145 + return ( 146 + StatusCode::BAD_REQUEST, 147 + Json(json!({ 148 + "error": "InvalidRequest", 149 + "message": "Incorrect signing key in verificationMethods" 150 + })), 151 + ) 152 + .into_response(); 153 + } 154 + if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) { 155 + let expected_handle = format!("at://{}", user.handle); 156 + let first_aka = also_known_as.first().and_then(|v| v.as_str()); 157 + if first_aka != Some(&expected_handle) { 141 158 return ( 142 159 StatusCode::BAD_REQUEST, 143 160 Json(json!({ 144 161 "error": "InvalidRequest", 145 - "message": "Incorrect signing key in verificationMethods" 162 + "message": "Incorrect handle in alsoKnownAs" 146 163 })), 147 164 ) 148 165 .into_response(); 149 166 } 150 - if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) { 151 - let expected_handle = format!("at://{}", user.handle); 152 - let first_aka = also_known_as.first().and_then(|v| v.as_str()); 153 - if first_aka != Some(&expected_handle) { 154 - return ( 155 - StatusCode::BAD_REQUEST, 156 - Json(json!({ 157 - "error": "InvalidRequest", 158 - "message": "Incorrect handle in alsoKnownAs" 159 - })), 160 - ) 161 - .into_response(); 162 167 } 163 168 } 164 169 let plc_client = PlcClient::new(None);
+61 -17
src/api/repo/blob.rs
··· 1 + use crate::auth::{ServiceTokenVerifier, is_service_token}; 1 2 use crate::state::AppState; 2 3 use axum::body::Bytes; 3 4 use axum::{ ··· 13 14 use serde_json::json; 14 15 use sha2::{Digest, Sha256}; 15 16 use std::str::FromStr; 16 - use tracing::error; 17 + use tracing::{debug, error}; 17 18 18 19 const MAX_BLOB_SIZE: usize = 1_000_000; 20 + const MAX_VIDEO_BLOB_SIZE: usize = 100_000_000; 19 21 20 22 pub async fn upload_blob( 21 23 State(state): State<AppState>, 22 24 headers: axum::http::HeaderMap, 23 25 body: Bytes, 24 26 ) -> Response { 25 - if body.len() > MAX_BLOB_SIZE { 26 - return ( 27 - StatusCode::PAYLOAD_TOO_LARGE, 28 - Json(json!({"error": "BlobTooLarge", "message": format!("Blob size {} exceeds maximum of {} bytes", body.len(), MAX_BLOB_SIZE)})), 29 - ) 30 - .into_response(); 31 - } 32 27 let token = match crate::auth::extract_bearer_token_from_header( 33 28 headers.get("Authorization").and_then(|h| h.to_str().ok()), 34 29 ) { ··· 41 36 .into_response(); 42 37 } 43 38 }; 44 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 45 - Ok(user) => user, 46 - Err(_) => { 47 - return ( 48 - StatusCode::UNAUTHORIZED, 49 - Json(json!({"error": "AuthenticationFailed"})), 50 - ) 51 - .into_response(); 39 + 40 + let is_service_auth = is_service_token(&token); 41 + 42 + let (did, is_migration) = if is_service_auth { 43 + debug!("Verifying service token for blob upload"); 44 + let verifier = ServiceTokenVerifier::new(); 45 + match verifier 46 + .verify_service_token(&token, Some("com.atproto.repo.uploadBlob")) 47 + .await 48 + { 49 + Ok(claims) => { 50 + debug!("Service token verified for DID: {}", claims.iss); 51 + (claims.iss, false) 52 + } 53 + Err(e) => { 54 + error!("Service token verification failed: {:?}", e); 55 + return ( 56 + StatusCode::UNAUTHORIZED, 57 + Json(json!({"error": "AuthenticationFailed", "message": format!("Service token verification failed: {}", e)})), 58 + ) 59 + .into_response(); 60 + } 52 61 } 62 + } else { 63 + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { 64 + Ok(user) => { 65 + let deactivated = sqlx::query_scalar!( 66 + "SELECT deactivated_at FROM users WHERE did = $1", 67 + user.did 68 + ) 69 + .fetch_optional(&state.db) 70 + .await 71 + .ok() 72 + .flatten() 73 + .flatten(); 74 + (user.did, deactivated.is_some()) 75 + } 76 + Err(_) => { 77 + return ( 78 + StatusCode::UNAUTHORIZED, 79 + Json(json!({"error": "AuthenticationFailed"})), 80 + ) 81 + .into_response(); 82 + } 83 + } 84 + }; 85 + 86 + let max_size = if is_service_auth || is_migration { 87 + MAX_VIDEO_BLOB_SIZE 88 + } else { 89 + MAX_BLOB_SIZE 53 90 }; 54 - let did = auth_user.did; 91 + 92 + if body.len() > max_size { 93 + return ( 94 + StatusCode::PAYLOAD_TOO_LARGE, 95 + Json(json!({"error": "BlobTooLarge", "message": format!("Blob size {} exceeds maximum of {} bytes", body.len(), max_size)})), 96 + ) 97 + .into_response(); 98 + } 55 99 let mime_type = headers 56 100 .get("content-type") 57 101 .and_then(|h| h.to_str().ok())
+53 -14
src/api/repo/import.rs
··· 53 53 Some(t) => t, 54 54 None => return ApiError::AuthenticationRequired.into_response(), 55 55 }; 56 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 56 + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { 57 57 Ok(user) => user, 58 58 Err(e) => return ApiError::from(e).into_response(), 59 59 }; ··· 82 82 .into_response(); 83 83 } 84 84 }; 85 - if user.deactivated_at.is_some() { 86 - return ( 87 - StatusCode::FORBIDDEN, 88 - Json(json!({ 89 - "error": "AccountDeactivated", 90 - "message": "Account is deactivated" 91 - })), 92 - ) 93 - .into_response(); 94 - } 95 85 if user.takedown_ref.is_some() { 96 86 return ( 97 87 StatusCode::FORBIDDEN, ··· 185 175 let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION") 186 176 .map(|v| v == "true" || v == "1") 187 177 .unwrap_or(false); 188 - if !skip_verification { 178 + let is_migration = user.deactivated_at.is_some(); 179 + if skip_verification { 180 + warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)"); 181 + } else if is_migration { 182 + debug!("Verifying CAR file structure for migration (skipping signature verification)"); 183 + let verifier = CarVerifier::new(); 184 + match verifier.verify_car_structure_only(did, &root, &blocks) { 185 + Ok(verified) => { 186 + debug!( 187 + "CAR structure verification successful: rev={}, data_cid={}", 188 + verified.rev, verified.data_cid 189 + ); 190 + } 191 + Err(crate::sync::verify::VerifyError::DidMismatch { 192 + commit_did, 193 + expected_did, 194 + }) => { 195 + return ( 196 + StatusCode::FORBIDDEN, 197 + Json(json!({ 198 + "error": "InvalidRequest", 199 + "message": format!( 200 + "CAR file is for DID {} but you are authenticated as {}", 201 + commit_did, expected_did 202 + ) 203 + })), 204 + ) 205 + .into_response(); 206 + } 207 + Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => { 208 + return ( 209 + StatusCode::BAD_REQUEST, 210 + Json(json!({ 211 + "error": "InvalidRequest", 212 + "message": format!("MST validation failed: {}", msg) 213 + })), 214 + ) 215 + .into_response(); 216 + } 217 + Err(e) => { 218 + error!("CAR structure verification error: {:?}", e); 219 + return ( 220 + StatusCode::BAD_REQUEST, 221 + Json(json!({ 222 + "error": "InvalidRequest", 223 + "message": format!("CAR verification failed: {}", e) 224 + })), 225 + ) 226 + .into_response(); 227 + } 228 + } 229 + } else { 189 230 debug!("Verifying CAR file signature and structure for DID {}", did); 190 231 let verifier = CarVerifier::new(); 191 232 match verifier.verify_car(did, &root, &blocks).await { ··· 264 305 .into_response(); 265 306 } 266 307 } 267 - } else { 268 - warn!("Skipping CAR signature verification for import (SKIP_IMPORT_VERIFICATION=true)"); 269 308 } 270 309 let max_blocks: usize = std::env::var("MAX_IMPORT_BLOCKS") 271 310 .ok()
+7 -5
src/api/server/session.rs
··· 1 1 use crate::api::ApiError; 2 - use crate::auth::BearerAuth; 2 + use crate::auth::{BearerAuth, BearerAuthAllowDeactivated}; 3 3 use crate::state::{AppState, RateLimitKind}; 4 4 use axum::{ 5 5 Json, ··· 88 88 k.key_bytes, k.encryption_version 89 89 FROM users u 90 90 JOIN user_keys k ON u.id = k.user_id 91 - WHERE u.handle = $1 OR u.email = $1"#, 91 + WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, 92 92 normalized_identifier 93 93 ) 94 94 .fetch_optional(&state.db) ··· 189 189 190 190 pub async fn get_session( 191 191 State(state): State<AppState>, 192 - BearerAuth(auth_user): BearerAuth, 192 + BearerAuthAllowDeactivated(auth_user): BearerAuthAllowDeactivated, 193 193 ) -> Response { 194 194 match sqlx::query!( 195 195 r#"SELECT 196 - handle, email, email_verified, is_admin, 196 + handle, email, email_verified, is_admin, deactivated_at, 197 197 preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 198 198 discord_verified, telegram_verified, signal_verified 199 199 FROM users WHERE did = $1"#, ··· 211 211 }; 212 212 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 213 213 let handle = full_handle(&row.handle, &pds_hostname); 214 + let is_active = row.deactivated_at.is_none(); 214 215 Json(json!({ 215 216 "handle": handle, 216 217 "did": auth_user.did, ··· 219 220 "preferredChannel": preferred_channel, 220 221 "preferredChannelVerified": preferred_channel_verified, 221 222 "isAdmin": row.is_admin, 222 - "active": true, 223 + "active": is_active, 224 + "status": if is_active { "active" } else { "deactivated" }, 223 225 "didDoc": {} 224 226 })).into_response() 225 227 }
+2
src/auth/mod.rs
··· 7 7 use crate::cache::Cache; 8 8 9 9 pub mod extractor; 10 + pub mod service; 10 11 pub mod token; 11 12 pub mod verify; 12 13 ··· 23 24 pub use verify::{ 24 25 get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token, verify_token, 25 26 }; 27 + pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; 26 28 27 29 const KEY_CACHE_TTL_SECS: u64 = 300; 28 30 const SESSION_CACHE_TTL_SECS: u64 = 60;
+375
src/auth/service.rs
··· 1 + use anyhow::{Result, anyhow}; 2 + use base64::Engine as _; 3 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 4 + use chrono::Utc; 5 + use k256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; 6 + use reqwest::Client; 7 + use serde::{Deserialize, Serialize}; 8 + use std::time::Duration; 9 + use tracing::debug; 10 + 11 + #[derive(Debug, Clone, Serialize, Deserialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct FullDidDocument { 14 + pub id: String, 15 + #[serde(default)] 16 + pub also_known_as: Vec<String>, 17 + #[serde(default)] 18 + pub verification_method: Vec<VerificationMethod>, 19 + #[serde(default)] 20 + pub service: Vec<DidService>, 21 + } 22 + 23 + #[derive(Debug, Clone, Serialize, Deserialize)] 24 + #[serde(rename_all = "camelCase")] 25 + pub struct VerificationMethod { 26 + pub id: String, 27 + #[serde(rename = "type")] 28 + pub method_type: String, 29 + pub controller: String, 30 + #[serde(default)] 31 + pub public_key_multibase: Option<String>, 32 + } 33 + 34 + #[derive(Debug, Clone, Serialize, Deserialize)] 35 + #[serde(rename_all = "camelCase")] 36 + pub struct DidService { 37 + pub id: String, 38 + #[serde(rename = "type")] 39 + pub service_type: String, 40 + pub service_endpoint: String, 41 + } 42 + 43 + #[derive(Debug, Clone, Serialize, Deserialize)] 44 + pub struct ServiceTokenClaims { 45 + pub iss: String, 46 + #[serde(default)] 47 + pub sub: Option<String>, 48 + pub aud: String, 49 + pub exp: usize, 50 + #[serde(default)] 51 + pub iat: Option<usize>, 52 + #[serde(skip_serializing_if = "Option::is_none")] 53 + pub lxm: Option<String>, 54 + #[serde(default)] 55 + pub jti: Option<String>, 56 + } 57 + 58 + impl ServiceTokenClaims { 59 + pub fn subject(&self) -> &str { 60 + self.sub.as_deref().unwrap_or(&self.iss) 61 + } 62 + } 63 + 64 + #[derive(Debug, Clone, Serialize, Deserialize)] 65 + struct TokenHeader { 66 + pub alg: String, 67 + pub typ: String, 68 + } 69 + 70 + pub struct ServiceTokenVerifier { 71 + client: Client, 72 + plc_directory_url: String, 73 + pds_did: String, 74 + } 75 + 76 + impl ServiceTokenVerifier { 77 + pub fn new() -> Self { 78 + let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") 79 + .unwrap_or_else(|_| "https://plc.directory".to_string()); 80 + 81 + let pds_hostname = 82 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 83 + let pds_did = format!("did:web:{}", pds_hostname); 84 + 85 + let client = Client::builder() 86 + .timeout(Duration::from_secs(10)) 87 + .connect_timeout(Duration::from_secs(5)) 88 + .build() 89 + .unwrap_or_else(|_| Client::new()); 90 + 91 + Self { 92 + client, 93 + plc_directory_url, 94 + pds_did, 95 + } 96 + } 97 + 98 + pub async fn verify_service_token( 99 + &self, 100 + token: &str, 101 + required_lxm: Option<&str>, 102 + ) -> Result<ServiceTokenClaims> { 103 + let parts: Vec<&str> = token.split('.').collect(); 104 + if parts.len() != 3 { 105 + return Err(anyhow!("Invalid token format")); 106 + } 107 + 108 + let header_bytes = URL_SAFE_NO_PAD 109 + .decode(parts[0]) 110 + .map_err(|e| anyhow!("Base64 decode of header failed: {}", e))?; 111 + 112 + let header: TokenHeader = serde_json::from_slice(&header_bytes) 113 + .map_err(|e| anyhow!("JSON decode of header failed: {}", e))?; 114 + 115 + if header.alg != "ES256K" { 116 + return Err(anyhow!("Unsupported algorithm: {}", header.alg)); 117 + } 118 + 119 + let claims_bytes = URL_SAFE_NO_PAD 120 + .decode(parts[1]) 121 + .map_err(|e| anyhow!("Base64 decode of claims failed: {}", e))?; 122 + 123 + let claims: ServiceTokenClaims = serde_json::from_slice(&claims_bytes) 124 + .map_err(|e| anyhow!("JSON decode of claims failed: {}", e))?; 125 + 126 + let now = Utc::now().timestamp() as usize; 127 + if claims.exp < now { 128 + return Err(anyhow!("Token expired")); 129 + } 130 + 131 + if claims.aud != self.pds_did { 132 + return Err(anyhow!( 133 + "Invalid audience: expected {}, got {}", 134 + self.pds_did, 135 + claims.aud 136 + )); 137 + } 138 + 139 + if let Some(required) = required_lxm { 140 + match &claims.lxm { 141 + Some(lxm) if lxm == "*" || lxm == required => {} 142 + Some(lxm) => { 143 + return Err(anyhow!( 144 + "Token lxm '{}' does not permit '{}'", 145 + lxm, 146 + required 147 + )); 148 + } 149 + None => { 150 + return Err(anyhow!("Token missing lxm claim")); 151 + } 152 + } 153 + } 154 + 155 + let did = &claims.iss; 156 + let public_key = self.resolve_signing_key(did).await?; 157 + 158 + let signature_bytes = URL_SAFE_NO_PAD 159 + .decode(parts[2]) 160 + .map_err(|e| anyhow!("Base64 decode of signature failed: {}", e))?; 161 + 162 + let signature = Signature::from_slice(&signature_bytes) 163 + .map_err(|e| anyhow!("Invalid signature format: {}", e))?; 164 + 165 + let message = format!("{}.{}", parts[0], parts[1]); 166 + 167 + public_key 168 + .verify(message.as_bytes(), &signature) 169 + .map_err(|e| anyhow!("Signature verification failed: {}", e))?; 170 + 171 + debug!("Service token verified for DID: {}", did); 172 + 173 + Ok(claims) 174 + } 175 + 176 + async fn resolve_signing_key(&self, did: &str) -> Result<VerifyingKey> { 177 + let did_doc = self.resolve_did_document(did).await?; 178 + 179 + let atproto_key = did_doc 180 + .verification_method 181 + .iter() 182 + .find(|vm| vm.id.ends_with("#atproto") || vm.id == format!("{}#atproto", did)) 183 + .ok_or_else(|| anyhow!("No atproto verification method found in DID document"))?; 184 + 185 + let multibase = atproto_key 186 + .public_key_multibase 187 + .as_ref() 188 + .ok_or_else(|| anyhow!("Verification method missing publicKeyMultibase"))?; 189 + 190 + parse_did_key_multibase(multibase) 191 + } 192 + 193 + async fn resolve_did_document(&self, did: &str) -> Result<FullDidDocument> { 194 + if did.starts_with("did:plc:") { 195 + self.resolve_did_plc(did).await 196 + } else if did.starts_with("did:web:") { 197 + self.resolve_did_web(did).await 198 + } else { 199 + Err(anyhow!("Unsupported DID method: {}", did)) 200 + } 201 + } 202 + 203 + async fn resolve_did_plc(&self, did: &str) -> Result<FullDidDocument> { 204 + let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); 205 + debug!("Resolving did:plc {} via {}", did, url); 206 + 207 + let resp = self 208 + .client 209 + .get(&url) 210 + .send() 211 + .await 212 + .map_err(|e| anyhow!("HTTP request failed: {}", e))?; 213 + 214 + if resp.status() == reqwest::StatusCode::NOT_FOUND { 215 + return Err(anyhow!("DID not found: {}", did)); 216 + } 217 + 218 + if !resp.status().is_success() { 219 + return Err(anyhow!("HTTP {}", resp.status())); 220 + } 221 + 222 + resp.json::<FullDidDocument>() 223 + .await 224 + .map_err(|e| anyhow!("Failed to parse DID document: {}", e)) 225 + } 226 + 227 + async fn resolve_did_web(&self, did: &str) -> Result<FullDidDocument> { 228 + let host = did 229 + .strip_prefix("did:web:") 230 + .ok_or_else(|| anyhow!("Invalid did:web format"))?; 231 + 232 + let decoded_host = host.replace("%3A", ":"); 233 + let (host_part, path_part) = if let Some(idx) = decoded_host.find('/') { 234 + (&decoded_host[..idx], &decoded_host[idx..]) 235 + } else { 236 + (decoded_host.as_str(), "") 237 + }; 238 + 239 + let scheme = if host_part.starts_with("localhost") 240 + || host_part.starts_with("127.0.0.1") 241 + || host_part.contains(':') 242 + { 243 + "http" 244 + } else { 245 + "https" 246 + }; 247 + 248 + let url = if path_part.is_empty() { 249 + format!("{}://{}/.well-known/did.json", scheme, host_part) 250 + } else { 251 + format!("{}://{}{}/did.json", scheme, host_part, path_part) 252 + }; 253 + 254 + debug!("Resolving did:web {} via {}", did, url); 255 + 256 + let resp = self 257 + .client 258 + .get(&url) 259 + .send() 260 + .await 261 + .map_err(|e| anyhow!("HTTP request failed: {}", e))?; 262 + 263 + if !resp.status().is_success() { 264 + return Err(anyhow!("HTTP {}", resp.status())); 265 + } 266 + 267 + resp.json::<FullDidDocument>() 268 + .await 269 + .map_err(|e| anyhow!("Failed to parse DID document: {}", e)) 270 + } 271 + } 272 + 273 + impl Default for ServiceTokenVerifier { 274 + fn default() -> Self { 275 + Self::new() 276 + } 277 + } 278 + 279 + fn parse_did_key_multibase(multibase: &str) -> Result<VerifyingKey> { 280 + if !multibase.starts_with('z') { 281 + return Err(anyhow!("Expected base58btc multibase encoding (starts with 'z')")); 282 + } 283 + 284 + let (_, decoded) = multibase::decode(multibase) 285 + .map_err(|e| anyhow!("Failed to decode multibase: {}", e))?; 286 + 287 + if decoded.len() < 2 { 288 + return Err(anyhow!("Invalid multicodec data")); 289 + } 290 + 291 + let (codec, key_bytes) = if decoded[0] == 0xe7 && decoded[1] == 0x01 { 292 + (0xe701u16, &decoded[2..]) 293 + } else { 294 + return Err(anyhow!( 295 + "Unsupported key type. Expected secp256k1 (0xe701), got {:02x}{:02x}", 296 + decoded[0], 297 + decoded[1] 298 + )); 299 + }; 300 + 301 + if codec != 0xe701 { 302 + return Err(anyhow!("Only secp256k1 keys are supported")); 303 + } 304 + 305 + VerifyingKey::from_sec1_bytes(key_bytes) 306 + .map_err(|e| anyhow!("Invalid public key: {}", e)) 307 + } 308 + 309 + pub fn is_service_token(token: &str) -> bool { 310 + let parts: Vec<&str> = token.split('.').collect(); 311 + if parts.len() != 3 { 312 + return false; 313 + } 314 + 315 + let Ok(claims_bytes) = URL_SAFE_NO_PAD.decode(parts[1]) else { 316 + return false; 317 + }; 318 + 319 + let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&claims_bytes) else { 320 + return false; 321 + }; 322 + 323 + claims.get("lxm").is_some() 324 + } 325 + 326 + #[cfg(test)] 327 + mod tests { 328 + use super::*; 329 + 330 + #[test] 331 + fn test_is_service_token() { 332 + let claims_with_lxm = serde_json::json!({ 333 + "iss": "did:plc:test", 334 + "sub": "did:plc:test", 335 + "aud": "did:web:test.com", 336 + "exp": 9999999999i64, 337 + "iat": 1000000000i64, 338 + "lxm": "com.atproto.repo.uploadBlob", 339 + "jti": "test-jti" 340 + }); 341 + 342 + let claims_without_lxm = serde_json::json!({ 343 + "iss": "did:plc:test", 344 + "sub": "did:plc:test", 345 + "aud": "did:web:test.com", 346 + "exp": 9999999999i64, 347 + "iat": 1000000000i64, 348 + "jti": "test-jti" 349 + }); 350 + 351 + let token_with_lxm = format!( 352 + "{}.{}.{}", 353 + URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K","typ":"jwt"}"#), 354 + URL_SAFE_NO_PAD.encode(claims_with_lxm.to_string()), 355 + URL_SAFE_NO_PAD.encode("fake-sig") 356 + ); 357 + 358 + let token_without_lxm = format!( 359 + "{}.{}.{}", 360 + URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K","typ":"at+jwt"}"#), 361 + URL_SAFE_NO_PAD.encode(claims_without_lxm.to_string()), 362 + URL_SAFE_NO_PAD.encode("fake-sig") 363 + ); 364 + 365 + assert!(is_service_token(&token_with_lxm)); 366 + assert!(!is_service_token(&token_without_lxm)); 367 + } 368 + 369 + #[test] 370 + fn test_parse_did_key_multibase() { 371 + let test_key = "zQ3shcXtVCEBjUvAhzTW3r12DkpFdR2KmA3rHmuEMFx4GMBDB"; 372 + let result = parse_did_key_multibase(test_key); 373 + assert!(result.is_ok(), "Failed to parse valid multibase key"); 374 + } 375 + }
+32
src/sync/verify.rs
··· 86 86 }) 87 87 } 88 88 89 + pub fn verify_car_structure_only( 90 + &self, 91 + expected_did: &str, 92 + root_cid: &Cid, 93 + blocks: &HashMap<Cid, Bytes>, 94 + ) -> Result<VerifiedCar, VerifyError> { 95 + let root_block = blocks 96 + .get(root_cid) 97 + .ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?; 98 + let commit = 99 + Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?; 100 + let commit_did = commit.did().as_str(); 101 + if commit_did != expected_did { 102 + return Err(VerifyError::DidMismatch { 103 + commit_did: commit_did.to_string(), 104 + expected_did: expected_did.to_string(), 105 + }); 106 + } 107 + let data_cid = commit.data(); 108 + self.verify_mst_structure(data_cid, blocks)?; 109 + debug!( 110 + "MST structure verified for DID {} (signature verification skipped for migration)", 111 + commit_did 112 + ); 113 + Ok(VerifiedCar { 114 + did: commit_did.to_string(), 115 + rev: commit.rev().to_string(), 116 + data_cid: *data_cid, 117 + prev: commit.prev().cloned(), 118 + }) 119 + } 120 + 89 121 async fn resolve_did_signing_key(&self, did: &str) -> Result<PublicKey<'static>, VerifyError> { 90 122 let did_doc = self.resolve_did_document(did).await?; 91 123 did_doc
+3 -4
tests/import_verification.rs
··· 192 192 } 193 193 194 194 #[tokio::test] 195 - async fn test_import_deactivated_account_rejected() { 195 + async fn test_import_deactivated_account_allowed_for_migration() { 196 196 let client = client(); 197 197 let (token, did) = create_account_and_login(&client).await; 198 198 let export_res = client ··· 229 229 .await 230 230 .expect("Import failed"); 231 231 assert!( 232 - import_res.status() == StatusCode::FORBIDDEN 233 - || import_res.status() == StatusCode::UNAUTHORIZED, 234 - "Expected FORBIDDEN (403) or UNAUTHORIZED (401), got {}", 232 + import_res.status().is_success(), 233 + "Deactivated accounts should allow import for migration, got {}", 235 234 import_res.status() 236 235 ); 237 236 }