this repo has no description

Inbound migrations work

lewis 920994c7 89377201

+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 }