+82
.sqlx/query-17da8b6f6b46eae067bd8842a369a406699888f689122d2bae8bef13b532bcd2.json
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
}