PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.

did:web support, including our own internal kind

+34
.sqlx/query-9bd55935253b57b1b7e2d2bf69509e571af810234fa61368f58dd72e1d111cc5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "migrated_to_pds", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + true 31 + ] 32 + }, 33 + "hash": "9bd55935253b57b1b7e2d2bf69509e571af810234fa61368f58dd72e1d111cc5" 34 + }
-28
.sqlx/query-b2c53e6a278c4549c99a5b98cc7ca77fc1e9cd39a591c1d8ec1ca41adfffa3a6.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, did FROM users WHERE handle = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "b2c53e6a278c4549c99a5b98cc7ca77fc1e9cd39a591c1d8ec1ca41adfffa3a6" 28 - }
+10
Cargo.lock
··· 930 930 ] 931 931 932 932 [[package]] 933 + name = "bs58" 934 + version = "0.5.1" 935 + source = "registry+https://github.com/rust-lang/crates.io-index" 936 + checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" 937 + dependencies = [ 938 + "tinyvec", 939 + ] 940 + 941 + [[package]] 933 942 name = "btree-range-map" 934 943 version = "0.7.2" 935 944 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6175 6184 "base32", 6176 6185 "base64 0.22.1", 6177 6186 "bcrypt", 6187 + "bs58", 6178 6188 "bytes", 6179 6189 "chrono", 6180 6190 "cid",
+1
Cargo.toml
··· 55 55 hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } 56 56 metrics = "0.24" 57 57 metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] } 58 + bs58 = "0.5.1" 58 59 [features] 59 60 external-infra = [] 60 61 [dev-dependencies]
+30 -14
TODO.md
··· 9 9 - [ ] Unique "brand" style both unauthed and authed 10 10 - [ ] Better documentation on how to sub out the entire frontend for whatever the users want 11 11 12 + ### Passkeys and 2FA 13 + Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. 14 + 15 + - [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 16 + - [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used) 17 + - [ ] WebAuthn registration challenge generation and attestation verification 18 + - [ ] TOTP secret generation with QR code setup flow 19 + - [ ] Backup codes (hashed, one-time use) with recovery flow 20 + - [ ] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) 21 + - [ ] Passkey-only account creation (no password) 22 + - [ ] Settings UI for managing passkeys, TOTP, backup codes 23 + - [ ] Trusted devices option (remember this browser) 24 + - [ ] Rate limit 2FA attempts 25 + - [ ] Re-auth for sensitive actions (email change, adding new auth methods) 26 + 12 27 ### Delegated accounts 13 28 Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. 14 29 ··· 26 41 - [ ] Log all actions with both actor DID and controller DID 27 42 - [ ] Audit log view for delegated account owners 28 43 29 - ### Passkeys and 2FA 30 - Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. 44 + ### Migration tool 45 + Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states. 31 46 32 - - [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 33 - - [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used) 34 - - [ ] WebAuthn registration challenge generation and attestation verification 35 - - [ ] TOTP secret generation with QR code setup flow 36 - - [ ] Backup codes (hashed, one-time use) with recovery flow 37 - - [ ] OAuth authorize flow: password → 2FA (if enabled) → passkey (as alternative) 38 - - [ ] Passkey-only account creation (no password) 39 - - [ ] Settings UI for managing passkeys, TOTP, backup codes 40 - - [ ] Trusted devices option (remember this browser) 41 - - [ ] Rate limit 2FA attempts 42 - - [ ] Re-auth for sensitive actions (email change, adding new auth methods) 47 + - [ ] Add `migratingTo` parameter to `deactivateAccount` endpoint 48 + - [ ] For self-hosted did:web users: set `migrated_to_pds`, update DID doc serviceEndpoint 49 + - [ ] "Migrated" account state for self-hosted did:web: can authenticate but no repo operations 50 + - [ ] Migrated did:web user UI: minimal dashboard with "update forwarding PDS" setting, or full migration wizard to handle PDS 2 -> PDS 3 moves automatically 51 + - [ ] Outbound UI wizard: new PDS URL -> export repo -> guide account creation -> complete migration 52 + - [ ] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow 53 + - [ ] Support `createAccount` with existing DID + service auth token 54 + - [ ] Progress tracking with resume capability 55 + - [ ] Scheduled automatic backups (CAR export) 56 + - [ ] One-click restore from backup 43 57 44 58 ### Plugin system 45 59 Extensible architecture allowing third-party plugins to add functionality, like minecraft mods or browser extensions. ··· 74 88 75 89 ## Completed 76 90 77 - Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, did:web, full admin API, moderation reports. 91 + Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, full admin API, moderation reports. 92 + 93 + did:web support: Self-hosted did:web (subdomain format `did:web:handle.pds.com`), external/BYOD did:web, DID document serving via `/.well-known/did.json`, migration tracking for did:web users who leave (serviceEndpoint redirect), clear registration warnings about did:web trade-offs vs did:plc. 78 94 79 95 OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification. 80 96
+6
frontend/src/lib/api.ts
··· 71 71 72 72 export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal' 73 73 74 + export type DidType = 'plc' | 'web' | 'web-external' 75 + 74 76 export interface CreateAccountParams { 75 77 handle: string 76 78 email: string 77 79 password: string 78 80 inviteCode?: string 81 + didType?: DidType 82 + did?: string 79 83 verificationChannel?: VerificationChannel 80 84 discordId?: string 81 85 telegramUsername?: string ··· 109 113 email: params.email, 110 114 password: params.password, 111 115 inviteCode: params.inviteCode, 116 + didType: params.didType, 117 + did: params.did, 112 118 verificationChannel: params.verificationChannel, 113 119 discordId: params.discordId, 114 120 telegramUsername: params.telegramUsername,
+141 -1
frontend/src/routes/Register.svelte
··· 1 1 <script lang="ts"> 2 2 import { register, getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 - import { api, ApiError, type VerificationChannel } from '../lib/api' 4 + import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 5 5 6 6 const STORAGE_KEY = 'tranquil_pds_pending_verification' 7 7 ··· 14 14 let discordId = $state('') 15 15 let telegramUsername = $state('') 16 16 let signalNumber = $state('') 17 + let didType = $state<DidType>('plc') 18 + let externalDid = $state('') 17 19 let submitting = $state(false) 18 20 let error = $state<string | null>(null) 19 21 let serverInfo = $state<{ ··· 56 58 if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) { 57 59 return 'Invite code is required' 58 60 } 61 + if (didType === 'web-external') { 62 + if (!externalDid.trim()) return 'External did:web is required' 63 + if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:' 64 + } 59 65 switch (verificationChannel) { 60 66 case 'email': 61 67 if (!email.trim()) return 'Email is required for email verification' ··· 88 94 email: email.trim(), 89 95 password, 90 96 inviteCode: inviteCode.trim() || undefined, 97 + didType, 98 + did: didType === 'web-external' ? externalDid.trim() : undefined, 91 99 verificationChannel, 92 100 discordId: discordId.trim() || undefined, 93 101 telegramUsername: telegramUsername.trim() || undefined, ··· 171 179 required 172 180 /> 173 181 </div> 182 + <fieldset class="identity-section"> 183 + <legend>Identity Type</legend> 184 + <p class="section-hint">Choose how your decentralized identity will be managed.</p> 185 + <div class="radio-group"> 186 + <label class="radio-label"> 187 + <input 188 + type="radio" 189 + name="didType" 190 + value="plc" 191 + bind:group={didType} 192 + disabled={submitting} 193 + /> 194 + <span class="radio-content"> 195 + <strong>did:plc</strong> (Recommended) 196 + <span class="radio-hint">Portable identity managed by PLC Directory</span> 197 + </span> 198 + </label> 199 + <label class="radio-label"> 200 + <input 201 + type="radio" 202 + name="didType" 203 + value="web" 204 + bind:group={didType} 205 + disabled={submitting} 206 + /> 207 + <span class="radio-content"> 208 + <strong>did:web</strong> 209 + <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 210 + </span> 211 + </label> 212 + <label class="radio-label"> 213 + <input 214 + type="radio" 215 + name="didType" 216 + value="web-external" 217 + bind:group={didType} 218 + disabled={submitting} 219 + /> 220 + <span class="radio-content"> 221 + <strong>did:web (BYOD)</strong> 222 + <span class="radio-hint">Bring your own domain</span> 223 + </span> 224 + </label> 225 + </div> 226 + {#if didType === 'web'} 227 + <div class="did-web-warning"> 228 + <strong>Important: Understand the trade-offs</strong> 229 + <ul> 230 + <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>. Even if you migrate to another PDS later, this server must continue hosting your DID document.</li> 231 + <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.</li> 232 + <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.</li> 233 + <li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li> 234 + </ul> 235 + </div> 236 + {/if} 237 + {#if didType === 'web-external'} 238 + <div class="field"> 239 + <label for="external-did">Your did:web</label> 240 + <input 241 + id="external-did" 242 + type="text" 243 + bind:value={externalDid} 244 + placeholder="did:web:yourdomain.com" 245 + disabled={submitting} 246 + required 247 + /> 248 + <p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p> 249 + </div> 250 + {/if} 251 + </fieldset> 174 252 <fieldset class="verification-section"> 175 253 <legend>Contact Method</legend> 176 254 <p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p> ··· 323 401 padding: 0 0.5rem; 324 402 color: var(--text-primary); 325 403 } 404 + .identity-section { 405 + border: 1px solid var(--border-color-light); 406 + border-radius: 6px; 407 + padding: 1rem; 408 + margin: 0.5rem 0; 409 + } 410 + .identity-section legend { 411 + font-weight: 600; 412 + padding: 0 0.5rem; 413 + color: var(--text-primary); 414 + } 415 + .radio-group { 416 + display: flex; 417 + flex-direction: column; 418 + gap: 0.75rem; 419 + } 420 + .radio-label { 421 + display: flex; 422 + align-items: flex-start; 423 + gap: 0.5rem; 424 + cursor: pointer; 425 + } 426 + .radio-label input[type="radio"] { 427 + margin-top: 0.25rem; 428 + } 429 + .radio-content { 430 + display: flex; 431 + flex-direction: column; 432 + gap: 0.125rem; 433 + } 434 + .radio-hint { 435 + font-size: 0.75rem; 436 + color: var(--text-secondary); 437 + } 326 438 .section-hint { 327 439 font-size: 0.8rem; 328 440 color: var(--text-secondary); 329 441 margin: 0 0 1rem 0; 442 + } 443 + .did-web-warning { 444 + margin-top: 1rem; 445 + padding: 1rem; 446 + background: var(--warning-bg, #fff3cd); 447 + border: 1px solid var(--warning-border, #ffc107); 448 + border-radius: 6px; 449 + font-size: 0.875rem; 450 + } 451 + .did-web-warning strong { 452 + color: var(--warning-text, #856404); 453 + } 454 + .did-web-warning ul { 455 + margin: 0.75rem 0 0 0; 456 + padding-left: 1.25rem; 457 + } 458 + .did-web-warning li { 459 + margin-bottom: 0.5rem; 460 + line-height: 1.4; 461 + } 462 + .did-web-warning li:last-child { 463 + margin-bottom: 0; 464 + } 465 + .did-web-warning code { 466 + background: rgba(0, 0, 0, 0.1); 467 + padding: 0.125rem 0.25rem; 468 + border-radius: 3px; 469 + font-size: 0.8rem; 330 470 } 331 471 button { 332 472 padding: 0.75rem;
+2
migrations/20251222_add_did_web_migration_tracking.sql
··· 1 + ALTER TABLE users ADD COLUMN migrated_to_pds TEXT; 2 + ALTER TABLE users ADD COLUMN migrated_at TIMESTAMPTZ;
+115 -71
src/api/identity/account.rs
··· 41 41 pub password: String, 42 42 pub invite_code: Option<String>, 43 43 pub did: Option<String>, 44 + pub did_type: Option<String>, 44 45 pub signing_key: Option<String>, 45 46 pub verification_channel: Option<String>, 46 47 pub discord_id: Option<String>, ··· 268 269 .into_response(); 269 270 } 270 271 }; 271 - let did = if let Some(d) = &input.did { 272 - if d.trim().is_empty() { 273 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 274 - .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); 275 - let genesis_result = match create_genesis_operation( 276 - &signing_key, 277 - &rotation_key, 278 - &full_handle, 279 - &pds_endpoint, 280 - ) { 281 - Ok(r) => r, 282 - Err(e) => { 283 - error!("Error creating PLC genesis operation: {:?}", e); 272 + let did_type = input.did_type.as_deref().unwrap_or("plc"); 273 + let did = match did_type { 274 + "web" => { 275 + let subdomain_host = format!("{}.{}", input.handle, hostname); 276 + let encoded_subdomain = subdomain_host.replace(':', "%3A"); 277 + let self_hosted_did = format!("did:web:{}", encoded_subdomain); 278 + info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)"); 279 + self_hosted_did 280 + } 281 + "web-external" => { 282 + let d = match &input.did { 283 + Some(d) if !d.trim().is_empty() => d, 284 + _ => { 284 285 return ( 285 - StatusCode::INTERNAL_SERVER_ERROR, 286 - Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), 286 + StatusCode::BAD_REQUEST, 287 + Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})), 287 288 ) 288 289 .into_response(); 289 290 } 290 291 }; 291 - let plc_client = PlcClient::new(None); 292 - if let Err(e) = plc_client 293 - .send_operation(&genesis_result.did, &genesis_result.signed_operation) 294 - .await 295 - { 296 - error!("Failed to submit PLC genesis operation: {:?}", e); 292 + if !d.starts_with("did:web:") { 297 293 return ( 298 - StatusCode::BAD_GATEWAY, 299 - Json(json!({ 300 - "error": "UpstreamError", 301 - "message": format!("Failed to register DID with PLC directory: {}", e) 302 - })), 294 + StatusCode::BAD_REQUEST, 295 + Json( 296 + json!({"error": "InvalidDid", "message": "External DID must be a did:web"}), 297 + ), 303 298 ) 304 299 .into_response(); 305 300 } 306 - info!(did = %genesis_result.did, "Successfully registered DID with PLC directory"); 307 - genesis_result.did 308 - } else if d.starts_with("did:web:") { 309 301 if let Err(e) = verify_did_web(d, &hostname, &input.handle).await { 310 302 return ( 311 303 StatusCode::BAD_REQUEST, ··· 313 305 ) 314 306 .into_response(); 315 307 } 316 - d.clone() 317 - } else if d.starts_with("did:plc:") && is_migration { 308 + info!(did = %d, "Creating external did:web account"); 318 309 d.clone() 319 - } else { 320 - return ( 321 - StatusCode::BAD_REQUEST, 322 - 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."})), 323 - ) 324 - .into_response(); 325 310 } 326 - } else { 327 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 328 - .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); 329 - let genesis_result = match create_genesis_operation( 330 - &signing_key, 331 - &rotation_key, 332 - &full_handle, 333 - &pds_endpoint, 334 - ) { 335 - Ok(r) => r, 336 - Err(e) => { 337 - error!("Error creating PLC genesis operation: {:?}", e); 338 - return ( 339 - StatusCode::INTERNAL_SERVER_ERROR, 340 - Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), 341 - ) 342 - .into_response(); 311 + _ => { 312 + if let Some(d) = &input.did { 313 + if d.starts_with("did:plc:") && is_migration { 314 + info!(did = %d, "Migration with existing did:plc"); 315 + d.clone() 316 + } else if d.starts_with("did:web:") { 317 + if let Err(e) = verify_did_web(d, &hostname, &input.handle).await { 318 + return ( 319 + StatusCode::BAD_REQUEST, 320 + Json(json!({"error": "InvalidDid", "message": e})), 321 + ) 322 + .into_response(); 323 + } 324 + d.clone() 325 + } else if !d.trim().is_empty() { 326 + return ( 327 + StatusCode::BAD_REQUEST, 328 + 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."})), 329 + ) 330 + .into_response(); 331 + } else { 332 + let rotation_key = std::env::var("PLC_ROTATION_KEY") 333 + .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); 334 + let genesis_result = match create_genesis_operation( 335 + &signing_key, 336 + &rotation_key, 337 + &full_handle, 338 + &pds_endpoint, 339 + ) { 340 + Ok(r) => r, 341 + Err(e) => { 342 + error!("Error creating PLC genesis operation: {:?}", e); 343 + return ( 344 + StatusCode::INTERNAL_SERVER_ERROR, 345 + Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), 346 + ) 347 + .into_response(); 348 + } 349 + }; 350 + let plc_client = PlcClient::new(None); 351 + if let Err(e) = plc_client 352 + .send_operation(&genesis_result.did, &genesis_result.signed_operation) 353 + .await 354 + { 355 + error!("Failed to submit PLC genesis operation: {:?}", e); 356 + return ( 357 + StatusCode::BAD_GATEWAY, 358 + Json(json!({ 359 + "error": "UpstreamError", 360 + "message": format!("Failed to register DID with PLC directory: {}", e) 361 + })), 362 + ) 363 + .into_response(); 364 + } 365 + info!(did = %genesis_result.did, "Successfully registered DID with PLC directory"); 366 + genesis_result.did 367 + } 368 + } else { 369 + let rotation_key = std::env::var("PLC_ROTATION_KEY") 370 + .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); 371 + let genesis_result = match create_genesis_operation( 372 + &signing_key, 373 + &rotation_key, 374 + &full_handle, 375 + &pds_endpoint, 376 + ) { 377 + Ok(r) => r, 378 + Err(e) => { 379 + error!("Error creating PLC genesis operation: {:?}", e); 380 + return ( 381 + StatusCode::INTERNAL_SERVER_ERROR, 382 + Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), 383 + ) 384 + .into_response(); 385 + } 386 + }; 387 + let plc_client = PlcClient::new(None); 388 + if let Err(e) = plc_client 389 + .send_operation(&genesis_result.did, &genesis_result.signed_operation) 390 + .await 391 + { 392 + error!("Failed to submit PLC genesis operation: {:?}", e); 393 + return ( 394 + StatusCode::BAD_GATEWAY, 395 + Json(json!({ 396 + "error": "UpstreamError", 397 + "message": format!("Failed to register DID with PLC directory: {}", e) 398 + })), 399 + ) 400 + .into_response(); 401 + } 402 + info!(did = %genesis_result.did, "Successfully registered DID with PLC directory"); 403 + genesis_result.did 343 404 } 344 - }; 345 - let plc_client = PlcClient::new(None); 346 - if let Err(e) = plc_client 347 - .send_operation(&genesis_result.did, &genesis_result.signed_operation) 348 - .await 349 - { 350 - error!("Failed to submit PLC genesis operation: {:?}", e); 351 - return ( 352 - StatusCode::BAD_GATEWAY, 353 - Json(json!({ 354 - "error": "UpstreamError", 355 - "message": format!("Failed to register DID with PLC directory: {}", e) 356 - })), 357 - ) 358 - .into_response(); 359 405 } 360 - info!(did = %genesis_result.did, "Successfully registered DID with PLC directory"); 361 - genesis_result.did 362 406 }; 363 407 let mut tx = match state.db.begin().await { 364 408 Ok(tx) => tx,
+231 -68
src/api/identity/did.rs
··· 95 95 })) 96 96 } 97 97 98 - pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse { 98 + pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str> { 99 + let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?; 100 + let public_key = secret_key.public_key(); 101 + let compressed = public_key.to_encoded_point(true); 102 + let compressed_bytes = compressed.as_bytes(); 103 + let mut multicodec_key = vec![0xe7, 0x01]; 104 + multicodec_key.extend_from_slice(compressed_bytes); 105 + Ok(format!("z{}", bs58::encode(&multicodec_key).into_string())) 106 + } 107 + 108 + pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response { 99 109 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 100 - // Kinda for local dev, encode hostname if it contains port 110 + let host_header = headers 111 + .get("host") 112 + .and_then(|h| h.to_str().ok()) 113 + .unwrap_or(&hostname); 114 + let host_without_port = host_header.split(':').next().unwrap_or(host_header); 115 + let hostname_without_port = hostname.split(':').next().unwrap_or(&hostname); 116 + if host_without_port != hostname_without_port 117 + && host_without_port.ends_with(&format!(".{}", hostname_without_port)) 118 + { 119 + let handle = host_without_port 120 + .strip_suffix(&format!(".{}", hostname_without_port)) 121 + .unwrap_or(host_without_port); 122 + return serve_subdomain_did_doc(&state, handle, &hostname).await; 123 + } 101 124 let did = if hostname.contains(':') { 102 125 format!("did:web:{}", hostname.replace(':', "%3A")) 103 126 } else { ··· 112 135 "serviceEndpoint": format!("https://{}", hostname) 113 136 }] 114 137 })) 138 + .into_response() 139 + } 140 + 141 + async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response { 142 + let user = sqlx::query!( 143 + "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 144 + handle 145 + ) 146 + .fetch_optional(&state.db) 147 + .await; 148 + let (user_id, did, migrated_to_pds) = match user { 149 + Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds), 150 + Ok(None) => { 151 + return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response(); 152 + } 153 + Err(e) => { 154 + error!("DB Error: {:?}", e); 155 + return ( 156 + StatusCode::INTERNAL_SERVER_ERROR, 157 + Json(json!({"error": "InternalError"})), 158 + ) 159 + .into_response(); 160 + } 161 + }; 162 + if !did.starts_with("did:web:") { 163 + return ( 164 + StatusCode::NOT_FOUND, 165 + Json(json!({"error": "NotFound", "message": "User is not did:web"})), 166 + ) 167 + .into_response(); 168 + } 169 + let subdomain_host = format!("{}.{}", handle, hostname); 170 + let encoded_subdomain = subdomain_host.replace(':', "%3A"); 171 + let expected_self_hosted = format!("did:web:{}", encoded_subdomain); 172 + if did != expected_self_hosted { 173 + return ( 174 + StatusCode::NOT_FOUND, 175 + Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})), 176 + ) 177 + .into_response(); 178 + } 179 + let key_row = sqlx::query!( 180 + "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", 181 + user_id 182 + ) 183 + .fetch_optional(&state.db) 184 + .await; 185 + let key_bytes: Vec<u8> = match key_row { 186 + Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 187 + Ok(k) => k, 188 + Err(_) => { 189 + return ( 190 + StatusCode::INTERNAL_SERVER_ERROR, 191 + Json(json!({"error": "InternalError"})), 192 + ) 193 + .into_response(); 194 + } 195 + }, 196 + _ => { 197 + return ( 198 + StatusCode::INTERNAL_SERVER_ERROR, 199 + Json(json!({"error": "InternalError"})), 200 + ) 201 + .into_response(); 202 + } 203 + }; 204 + let public_key_multibase = match get_public_key_multibase(&key_bytes) { 205 + Ok(pk) => pk, 206 + Err(e) => { 207 + tracing::error!("Failed to generate public key multibase: {}", e); 208 + return ( 209 + StatusCode::INTERNAL_SERVER_ERROR, 210 + Json(json!({"error": "InternalError"})), 211 + ) 212 + .into_response(); 213 + } 214 + }; 215 + let full_handle = if handle.contains('.') { 216 + handle.to_string() 217 + } else { 218 + format!("{}.{}", handle, hostname) 219 + }; 220 + let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 221 + Json(json!({ 222 + "@context": [ 223 + "https://www.w3.org/ns/did/v1", 224 + "https://w3id.org/security/multikey/v1", 225 + "https://w3id.org/security/suites/secp256k1-2019/v1" 226 + ], 227 + "id": did, 228 + "alsoKnownAs": [format!("at://{}", full_handle)], 229 + "verificationMethod": [{ 230 + "id": format!("{}#atproto", did), 231 + "type": "Multikey", 232 + "controller": did, 233 + "publicKeyMultibase": public_key_multibase 234 + }], 235 + "service": [{ 236 + "id": "#atproto_pds", 237 + "type": "AtprotoPersonalDataServer", 238 + "serviceEndpoint": service_endpoint 239 + }] 240 + })) 241 + .into_response() 115 242 } 116 243 117 244 pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response { 118 245 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 119 - let user = sqlx::query!("SELECT id, did FROM users WHERE handle = $1", handle) 120 - .fetch_optional(&state.db) 121 - .await; 122 - let (user_id, did) = match user { 123 - Ok(Some(row)) => (row.id, row.did), 246 + let user = sqlx::query!( 247 + "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 248 + handle 249 + ) 250 + .fetch_optional(&state.db) 251 + .await; 252 + let (user_id, did, migrated_to_pds) = match user { 253 + Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds), 124 254 Ok(None) => { 125 255 return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response(); 126 256 } ··· 140 270 ) 141 271 .into_response(); 142 272 } 273 + let encoded_hostname = hostname.replace(':', "%3A"); 274 + let old_path_format = format!("did:web:{}:u:{}", encoded_hostname, handle); 275 + let subdomain_host = format!("{}.{}", handle, hostname); 276 + let encoded_subdomain = subdomain_host.replace(':', "%3A"); 277 + let new_subdomain_format = format!("did:web:{}", encoded_subdomain); 278 + if did != old_path_format && did != new_subdomain_format { 279 + return ( 280 + StatusCode::NOT_FOUND, 281 + Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})), 282 + ) 283 + .into_response(); 284 + } 143 285 let key_row = sqlx::query!( 144 286 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", 145 287 user_id ··· 165 307 .into_response(); 166 308 } 167 309 }; 168 - let jwk = match get_jwk(&key_bytes) { 169 - Ok(j) => j, 310 + let public_key_multibase = match get_public_key_multibase(&key_bytes) { 311 + Ok(pk) => pk, 170 312 Err(e) => { 171 - tracing::error!("Failed to generate JWK: {}", e); 313 + tracing::error!("Failed to generate public key multibase: {}", e); 172 314 return ( 173 315 StatusCode::INTERNAL_SERVER_ERROR, 174 316 Json(json!({"error": "InternalError"})), ··· 176 318 .into_response(); 177 319 } 178 320 }; 321 + let full_handle = if handle.contains('.') { 322 + handle.clone() 323 + } else { 324 + format!("{}.{}", handle, hostname) 325 + }; 326 + let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 179 327 Json(json!({ 180 - "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"], 328 + "@context": [ 329 + "https://www.w3.org/ns/did/v1", 330 + "https://w3id.org/security/multikey/v1", 331 + "https://w3id.org/security/suites/secp256k1-2019/v1" 332 + ], 181 333 "id": did, 182 - "alsoKnownAs": [format!("at://{}", handle)], 334 + "alsoKnownAs": [format!("at://{}", full_handle)], 183 335 "verificationMethod": [{ 184 336 "id": format!("{}#atproto", did), 185 - "type": "JsonWebKey2020", 337 + "type": "Multikey", 186 338 "controller": did, 187 - "publicKeyJwk": jwk 339 + "publicKeyMultibase": public_key_multibase 188 340 }], 189 341 "service": [{ 190 342 "id": "#atproto_pds", 191 343 "type": "AtprotoPersonalDataServer", 192 - "serviceEndpoint": format!("https://{}", hostname) 344 + "serviceEndpoint": service_endpoint 193 345 }] 194 - })).into_response() 346 + })) 347 + .into_response() 195 348 } 196 349 197 350 pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> { 351 + let subdomain_host = format!("{}.{}", handle, hostname); 352 + let encoded_subdomain = subdomain_host.replace(':', "%3A"); 353 + let expected_subdomain_did = format!("did:web:{}", encoded_subdomain); 354 + if did == expected_subdomain_did { 355 + return Ok(()); 356 + } 198 357 let expected_prefix = if hostname.contains(':') { 199 358 format!("did:web:{}", hostname.replace(':', "%3A")) 200 359 } else { ··· 204 363 let suffix = &did[expected_prefix.len()..]; 205 364 let expected_suffix = format!(":u:{}", handle); 206 365 if suffix == expected_suffix { 207 - Ok(()) 366 + return Ok(()); 208 367 } else { 209 - Err(format!( 368 + return Err(format!( 210 369 "Invalid DID path for this PDS. Expected {}", 211 370 expected_suffix 212 - )) 371 + )); 213 372 } 373 + } 374 + let parts: Vec<&str> = did.split(':').collect(); 375 + if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" { 376 + return Err("Invalid did:web format".into()); 377 + } 378 + let domain_segment = parts[2]; 379 + let domain = domain_segment.replace("%3A", ":"); 380 + let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") { 381 + "http" 214 382 } else { 215 - let parts: Vec<&str> = did.split(':').collect(); 216 - if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" { 217 - return Err("Invalid did:web format".into()); 218 - } 219 - let domain_segment = parts[2]; 220 - let domain = domain_segment.replace("%3A", ":"); 221 - let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") { 222 - "http" 223 - } else { 224 - "https" 225 - }; 226 - let url = if parts.len() == 3 { 227 - format!("{}://{}/.well-known/did.json", scheme, domain) 228 - } else { 229 - let path = parts[3..].join("/"); 230 - format!("{}://{}/{}/did.json", scheme, domain, path) 231 - }; 232 - let client = reqwest::Client::builder() 233 - .timeout(std::time::Duration::from_secs(5)) 234 - .build() 235 - .map_err(|e| format!("Failed to create client: {}", e))?; 236 - let resp = client 237 - .get(&url) 238 - .send() 239 - .await 240 - .map_err(|e| format!("Failed to fetch DID doc: {}", e))?; 241 - if !resp.status().is_success() { 242 - return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status())); 243 - } 244 - let doc: serde_json::Value = resp 245 - .json() 246 - .await 247 - .map_err(|e| format!("Failed to parse DID doc: {}", e))?; 248 - let services = doc["service"] 249 - .as_array() 250 - .ok_or("No services found in DID doc")?; 251 - let pds_endpoint = format!("https://{}", hostname); 252 - let has_valid_service = services.iter().any(|s| { 253 - s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint 254 - }); 255 - if has_valid_service { 256 - Ok(()) 257 - } else { 258 - Err(format!( 259 - "DID document does not list this PDS ({}) as AtprotoPersonalDataServer", 260 - pds_endpoint 261 - )) 262 - } 383 + "https" 384 + }; 385 + let url = if parts.len() == 3 { 386 + format!("{}://{}/.well-known/did.json", scheme, domain) 387 + } else { 388 + let path = parts[3..].join("/"); 389 + format!("{}://{}/{}/did.json", scheme, domain, path) 390 + }; 391 + let client = reqwest::Client::builder() 392 + .timeout(std::time::Duration::from_secs(5)) 393 + .build() 394 + .map_err(|e| format!("Failed to create client: {}", e))?; 395 + let resp = client 396 + .get(&url) 397 + .send() 398 + .await 399 + .map_err(|e| format!("Failed to fetch DID doc: {}", e))?; 400 + if !resp.status().is_success() { 401 + return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status())); 402 + } 403 + let doc: serde_json::Value = resp 404 + .json() 405 + .await 406 + .map_err(|e| format!("Failed to parse DID doc: {}", e))?; 407 + let services = doc["service"] 408 + .as_array() 409 + .ok_or("No services found in DID doc")?; 410 + let pds_endpoint = format!("https://{}", hostname); 411 + let has_valid_service = services 412 + .iter() 413 + .any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint); 414 + if has_valid_service { 415 + Ok(()) 416 + } else { 417 + Err(format!( 418 + "DID document does not list this PDS ({}) as AtprotoPersonalDataServer", 419 + pds_endpoint 420 + )) 263 421 } 264 422 } 265 423 ··· 344 502 Err(_) => return ApiError::InternalError.into_response(), 345 503 }; 346 504 let did_key = signing_key_to_did_key(&signing_key); 505 + let rotation_keys = if auth_user.did.starts_with("did:web:") { 506 + vec![] 507 + } else { 508 + vec![did_key.clone()] 509 + }; 347 510 ( 348 511 StatusCode::OK, 349 512 Json(GetRecommendedDidCredentialsOutput { 350 - rotation_keys: vec![did_key.clone()], 513 + rotation_keys, 351 514 also_known_as: vec![format!("at://{}", full_handle)], 352 515 verification_methods: VerificationMethods { atproto: did_key }, 353 516 services: Services {
+6
src/api/identity/plc/sign.rs
··· 63 63 return e; 64 64 } 65 65 let did = &auth_user.did; 66 + if did.starts_with("did:web:") { 67 + return ApiError::InvalidRequest( 68 + "PLC operations are only valid for did:plc identities".into(), 69 + ) 70 + .into_response(); 71 + } 66 72 let token = match &input.token { 67 73 Some(t) => t, 68 74 None => {
+6
src/api/identity/plc/submit.rs
··· 42 42 return e; 43 43 } 44 44 let did = &auth_user.did; 45 + if did.starts_with("did:web:") { 46 + return ApiError::InvalidRequest( 47 + "PLC operations are only valid for did:plc identities".into(), 48 + ) 49 + .into_response(); 50 + } 45 51 if let Err(e) = validate_plc_operation(&input.operation) { 46 52 return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response(); 47 53 }
+324
tests/did_web.rs
··· 1 + mod common; 2 + use common::*; 3 + use reqwest::StatusCode; 4 + use serde_json::{Value, json}; 5 + use wiremock::matchers::{method, path}; 6 + use wiremock::{Mock, MockServer, ResponseTemplate}; 7 + 8 + #[tokio::test] 9 + async fn test_create_self_hosted_did_web() { 10 + let client = client(); 11 + let handle = format!("selfweb_{}", uuid::Uuid::new_v4()); 12 + let payload = json!({ 13 + "handle": handle, 14 + "email": format!("{}@example.com", handle), 15 + "password": "password", 16 + "didType": "web" 17 + }); 18 + let res = client 19 + .post(format!( 20 + "{}/xrpc/com.atproto.server.createAccount", 21 + base_url().await 22 + )) 23 + .json(&payload) 24 + .send() 25 + .await 26 + .expect("Failed to send request"); 27 + if res.status() != StatusCode::OK { 28 + let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"})); 29 + panic!("createAccount failed: {:?}", body); 30 + } 31 + let body: Value = res.json().await.expect("Response was not JSON"); 32 + let did = body["did"].as_str().expect("No DID in response"); 33 + assert!( 34 + did.starts_with("did:web:"), 35 + "DID should start with did:web:, got: {}", 36 + did 37 + ); 38 + assert!( 39 + did.contains(&handle), 40 + "DID should contain handle {}, got: {}", 41 + handle, 42 + did 43 + ); 44 + assert!( 45 + !did.contains(":u:"), 46 + "Self-hosted did:web should use subdomain format (no :u:), got: {}", 47 + did 48 + ); 49 + let jwt = verify_new_account(&client, did).await; 50 + let res = client 51 + .get(format!("{}/u/{}/did.json", base_url().await, handle)) 52 + .send() 53 + .await 54 + .expect("Failed to fetch DID doc via path"); 55 + assert_eq!( 56 + res.status(), 57 + StatusCode::OK, 58 + "Self-hosted did:web should have DID doc served by PDS (via path for backwards compat)" 59 + ); 60 + let doc: Value = res.json().await.expect("DID doc was not JSON"); 61 + assert_eq!(doc["id"], did); 62 + assert!( 63 + doc["verificationMethod"][0]["publicKeyMultibase"].is_string(), 64 + "DID doc should have publicKeyMultibase" 65 + ); 66 + let res = client 67 + .post(format!( 68 + "{}/xrpc/com.atproto.repo.createRecord", 69 + base_url().await 70 + )) 71 + .bearer_auth(&jwt) 72 + .json(&json!({ 73 + "repo": did, 74 + "collection": "app.bsky.feed.post", 75 + "record": { 76 + "$type": "app.bsky.feed.post", 77 + "text": "Hello from did:web!", 78 + "createdAt": chrono::Utc::now().to_rfc3339() 79 + } 80 + })) 81 + .send() 82 + .await 83 + .expect("Failed to create post"); 84 + assert_eq!( 85 + res.status(), 86 + StatusCode::OK, 87 + "Self-hosted did:web account should be able to create records" 88 + ); 89 + } 90 + 91 + #[tokio::test] 92 + async fn test_external_did_web_no_local_doc() { 93 + let client = client(); 94 + let mock_server = MockServer::start().await; 95 + let mock_uri = mock_server.uri(); 96 + let mock_addr = mock_uri.trim_start_matches("http://"); 97 + let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); 98 + let handle = format!("extweb_{}", uuid::Uuid::new_v4()); 99 + let pds_endpoint = base_url().await.replace("http://", "https://"); 100 + let did_doc = json!({ 101 + "@context": ["https://www.w3.org/ns/did/v1"], 102 + "id": did, 103 + "service": [{ 104 + "id": "#atproto_pds", 105 + "type": "AtprotoPersonalDataServer", 106 + "serviceEndpoint": pds_endpoint 107 + }] 108 + }); 109 + Mock::given(method("GET")) 110 + .and(path("/.well-known/did.json")) 111 + .respond_with(ResponseTemplate::new(200).set_body_json(did_doc)) 112 + .mount(&mock_server) 113 + .await; 114 + let payload = json!({ 115 + "handle": handle, 116 + "email": format!("{}@example.com", handle), 117 + "password": "password", 118 + "didType": "web-external", 119 + "did": did 120 + }); 121 + let res = client 122 + .post(format!( 123 + "{}/xrpc/com.atproto.server.createAccount", 124 + base_url().await 125 + )) 126 + .json(&payload) 127 + .send() 128 + .await 129 + .expect("Failed to send request"); 130 + if res.status() != StatusCode::OK { 131 + let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"})); 132 + panic!("createAccount failed: {:?}", body); 133 + } 134 + let res = client 135 + .get(format!("{}/u/{}/did.json", base_url().await, handle)) 136 + .send() 137 + .await 138 + .expect("Failed to fetch DID doc"); 139 + assert_eq!( 140 + res.status(), 141 + StatusCode::NOT_FOUND, 142 + "External did:web should NOT have DID doc served by PDS" 143 + ); 144 + let body: Value = res.json().await.expect("Response was not JSON"); 145 + assert!( 146 + body["message"].as_str().unwrap_or("").contains("External"), 147 + "Error message should indicate external did:web" 148 + ); 149 + } 150 + 151 + #[tokio::test] 152 + async fn test_plc_operations_blocked_for_did_web() { 153 + let client = client(); 154 + let handle = format!("plcblock_{}", uuid::Uuid::new_v4()); 155 + let payload = json!({ 156 + "handle": handle, 157 + "email": format!("{}@example.com", handle), 158 + "password": "password", 159 + "didType": "web" 160 + }); 161 + let res = client 162 + .post(format!( 163 + "{}/xrpc/com.atproto.server.createAccount", 164 + base_url().await 165 + )) 166 + .json(&payload) 167 + .send() 168 + .await 169 + .expect("Failed to send request"); 170 + assert_eq!(res.status(), StatusCode::OK); 171 + let body: Value = res.json().await.expect("Response was not JSON"); 172 + let did = body["did"].as_str().expect("No DID").to_string(); 173 + let jwt = verify_new_account(&client, &did).await; 174 + let res = client 175 + .post(format!( 176 + "{}/xrpc/com.atproto.identity.signPlcOperation", 177 + base_url().await 178 + )) 179 + .bearer_auth(&jwt) 180 + .json(&json!({ 181 + "token": "fake-token" 182 + })) 183 + .send() 184 + .await 185 + .expect("Failed to send request"); 186 + assert_eq!( 187 + res.status(), 188 + StatusCode::BAD_REQUEST, 189 + "signPlcOperation should be blocked for did:web users" 190 + ); 191 + let body: Value = res.json().await.expect("Response was not JSON"); 192 + assert!( 193 + body["message"].as_str().unwrap_or("").contains("did:plc"), 194 + "Error should mention did:plc: {:?}", 195 + body 196 + ); 197 + let res = client 198 + .post(format!( 199 + "{}/xrpc/com.atproto.identity.submitPlcOperation", 200 + base_url().await 201 + )) 202 + .bearer_auth(&jwt) 203 + .json(&json!({ 204 + "operation": {} 205 + })) 206 + .send() 207 + .await 208 + .expect("Failed to send request"); 209 + assert_eq!( 210 + res.status(), 211 + StatusCode::BAD_REQUEST, 212 + "submitPlcOperation should be blocked for did:web users" 213 + ); 214 + } 215 + 216 + #[tokio::test] 217 + async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() { 218 + let client = client(); 219 + let handle = format!("creds_{}", uuid::Uuid::new_v4()); 220 + let payload = json!({ 221 + "handle": handle, 222 + "email": format!("{}@example.com", handle), 223 + "password": "password", 224 + "didType": "web" 225 + }); 226 + let res = client 227 + .post(format!( 228 + "{}/xrpc/com.atproto.server.createAccount", 229 + base_url().await 230 + )) 231 + .json(&payload) 232 + .send() 233 + .await 234 + .expect("Failed to send request"); 235 + assert_eq!(res.status(), StatusCode::OK); 236 + let body: Value = res.json().await.expect("Response was not JSON"); 237 + let did = body["did"].as_str().expect("No DID").to_string(); 238 + let jwt = verify_new_account(&client, &did).await; 239 + let res = client 240 + .get(format!( 241 + "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials", 242 + base_url().await 243 + )) 244 + .bearer_auth(&jwt) 245 + .send() 246 + .await 247 + .expect("Failed to send request"); 248 + assert_eq!(res.status(), StatusCode::OK); 249 + let body: Value = res.json().await.expect("Response was not JSON"); 250 + let rotation_keys = body["rotationKeys"] 251 + .as_array() 252 + .expect("rotationKeys should be an array"); 253 + assert!( 254 + rotation_keys.is_empty(), 255 + "did:web should have no rotation keys, got: {:?}", 256 + rotation_keys 257 + ); 258 + assert!( 259 + body["verificationMethods"].is_object(), 260 + "verificationMethods should be present" 261 + ); 262 + assert!(body["services"].is_object(), "services should be present"); 263 + } 264 + 265 + #[tokio::test] 266 + async fn test_did_plc_still_works_with_did_type_param() { 267 + let client = client(); 268 + let handle = format!("plctype_{}", uuid::Uuid::new_v4()); 269 + let payload = json!({ 270 + "handle": handle, 271 + "email": format!("{}@example.com", handle), 272 + "password": "password", 273 + "didType": "plc" 274 + }); 275 + let res = client 276 + .post(format!( 277 + "{}/xrpc/com.atproto.server.createAccount", 278 + base_url().await 279 + )) 280 + .json(&payload) 281 + .send() 282 + .await 283 + .expect("Failed to send request"); 284 + assert_eq!(res.status(), StatusCode::OK); 285 + let body: Value = res.json().await.expect("Response was not JSON"); 286 + let did = body["did"].as_str().expect("No DID").to_string(); 287 + assert!( 288 + did.starts_with("did:plc:"), 289 + "DID with didType=plc should be did:plc:, got: {}", 290 + did 291 + ); 292 + } 293 + 294 + #[tokio::test] 295 + async fn test_external_did_web_requires_did_field() { 296 + let client = client(); 297 + let handle = format!("nodid_{}", uuid::Uuid::new_v4()); 298 + let payload = json!({ 299 + "handle": handle, 300 + "email": format!("{}@example.com", handle), 301 + "password": "password", 302 + "didType": "web-external" 303 + }); 304 + let res = client 305 + .post(format!( 306 + "{}/xrpc/com.atproto.server.createAccount", 307 + base_url().await 308 + )) 309 + .json(&payload) 310 + .send() 311 + .await 312 + .expect("Failed to send request"); 313 + assert_eq!( 314 + res.status(), 315 + StatusCode::BAD_REQUEST, 316 + "web-external without did should fail" 317 + ); 318 + let body: Value = res.json().await.expect("Response was not JSON"); 319 + assert!( 320 + body["message"].as_str().unwrap_or("").contains("did"), 321 + "Error should mention did field is required: {:?}", 322 + body 323 + ); 324 + }
+5 -6
tests/identity.rs
··· 143 143 .send() 144 144 .await 145 145 .expect("Failed to fetch DID doc"); 146 - assert_eq!(res.status(), StatusCode::OK); 147 - let doc: Value = res.json().await.expect("DID doc was not JSON"); 148 - assert_eq!(doc["id"], did); 149 - assert_eq!(doc["alsoKnownAs"][0], format!("at://{}", handle)); 150 - assert_eq!(doc["verificationMethod"][0]["controller"], did); 151 - assert!(doc["verificationMethod"][0]["publicKeyJwk"].is_object()); 146 + assert_eq!( 147 + res.status(), 148 + StatusCode::NOT_FOUND, 149 + "External did:web should not have DID doc served by PDS (user hosts their own)" 150 + ); 152 151 } 153 152 154 153 #[tokio::test]