From 240f24718e1d92a0f94e8edd1d50fcc18fcd8d1b Mon Sep 17 00:00:00 2001 From: lewis Date: Thu, 5 Feb 2026 22:34:56 +0200 Subject: [PATCH] feat: fun handle resolution during migration --- TODO.md | 7 +- crates/tranquil-db/src/postgres/repo.rs | 8 +- .../tranquil-pds/src/api/discord_webhook.rs | 21 +- .../tranquil-pds/src/api/identity/handle.rs | 76 +++++ crates/tranquil-pds/src/api/identity/mod.rs | 2 + crates/tranquil-pds/src/api/validation.rs | 8 +- .../src/auth/verification_token.rs | 2 +- crates/tranquil-pds/src/lib.rs | 4 + .../tranquil-pds/src/rate_limit/extractor.rs | 5 + crates/tranquil-pds/src/rate_limit/mod.rs | 4 + crates/tranquil-pds/src/state.rs | 4 + crates/tranquil-pds/tests/identity.rs | 96 ++++++ crates/tranquil-pds/tests/security_fixes.rs | 27 +- .../components/dashboard/CommsContent.svelte | 25 +- .../migration/ChooseHandleStep.svelte | 285 ++++++++++++++++-- .../components/migration/InboundWizard.svelte | 52 +++- .../migration/OfflineInboundWizard.svelte | 6 + frontend/src/lib/api.ts | 28 +- frontend/src/lib/migration/atproto-client.ts | 10 + frontend/src/lib/migration/flow.svelte.ts | 34 ++- .../src/lib/migration/offline-flow.svelte.ts | 4 + frontend/src/lib/migration/types.ts | 6 + frontend/src/lib/types/api.ts | 2 + frontend/src/locales/en.json | 11 + frontend/src/locales/fi.json | 11 + frontend/src/locales/ja.json | 11 + frontend/src/locales/ko.json | 11 + frontend/src/locales/sv.json | 11 + frontend/src/locales/zh.json | 11 + frontend/src/styles/migration.css | 2 +- .../tests/migration/atproto-client.test.ts | 87 ++++++ .../flow-handle-preservation.test.ts | 206 +++++++++++++ frontend/src/tests/migration/storage.test.ts | 20 ++ frontend/src/tests/setup.ts | 15 + 34 files changed, 1009 insertions(+), 103 deletions(-) create mode 100644 crates/tranquil-pds/src/api/identity/handle.rs create mode 100644 frontend/src/tests/migration/flow-handle-preservation.test.ts diff --git a/TODO.md b/TODO.md index 6b1d845..b6cffab 100644 --- a/TODO.md +++ b/TODO.md @@ -74,11 +74,6 @@ example plugins ### Misc -migration handle preservation -- [ ] allow users to keep their existing handle during migration (eg. lewis.moe instead of forcing lewis.newpds.com) -- [ ] UI option to preserve external handle vs create new pds-subdomain handle -- [ ] handle the DNS verification flow for external handles during migration - cross-pds delegation when a client (eg. tangled.org) tries to log into a delegated account: - [ ] client starts oauth flow to delegated account's pds @@ -162,4 +157,4 @@ App password scopes: Granular permissions for app passwords using the same scope Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices. -Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management. +Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management, handle preservation (keep existing external handle via DNS/HTTP verification or create new PDS-subdomain handle). diff --git a/crates/tranquil-db/src/postgres/repo.rs b/crates/tranquil-db/src/postgres/repo.rs index a86ceeb..f695da9 100644 --- a/crates/tranquil-db/src/postgres/repo.rs +++ b/crates/tranquil-db/src/postgres/repo.rs @@ -1159,10 +1159,10 @@ impl RepoRepository for PostgresRepoRepository { None => return Err(ImportRepoError::RepoNotFound), }; - if let Some(expected) = expected_root_cid { - if repo.repo_root_cid.as_str() != expected.as_str() { - return Err(ImportRepoError::ConcurrentModification); - } + if let Some(expected) = expected_root_cid + && repo.repo_root_cid.as_str() != expected.as_str() + { + return Err(ImportRepoError::ConcurrentModification); } let block_chunks: Vec> = blocks diff --git a/crates/tranquil-pds/src/api/discord_webhook.rs b/crates/tranquil-pds/src/api/discord_webhook.rs index 8fecb99..bf945c8 100644 --- a/crates/tranquil-pds/src/api/discord_webhook.rs +++ b/crates/tranquil-pds/src/api/discord_webhook.rs @@ -108,7 +108,10 @@ pub async fn handle_discord_webhook( 1 => Json(json!({"type": 1})).into_response(), 2 => handle_command(state, interaction).await, other => { - debug!(interaction_type = other, "Received unknown Discord interaction type"); + debug!( + interaction_type = other, + "Received unknown Discord interaction type" + ); StatusCode::OK.into_response() } } @@ -148,14 +151,14 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response { let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref())); - if let Some(ref h) = handle { - if Handle::new(h).is_err() { - return Json(json!({ - "type": 4, - "data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64} - })) - .into_response(); - } + if let Some(ref h) = handle + && Handle::new(h).is_err() + { + return Json(json!({ + "type": 4, + "data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64} + })) + .into_response(); } debug!( diff --git a/crates/tranquil-pds/src/api/identity/handle.rs b/crates/tranquil-pds/src/api/identity/handle.rs new file mode 100644 index 0000000..5a86684 --- /dev/null +++ b/crates/tranquil-pds/src/api/identity/handle.rs @@ -0,0 +1,76 @@ +use crate::api::error::ApiError; +use crate::rate_limit::{HandleVerificationLimit, RateLimited}; +use crate::types::{Did, Handle}; +use axum::{ + Json, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct VerifyHandleOwnershipInput { + pub handle: String, + pub did: Did, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyHandleOwnershipOutput { + pub verified: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub async fn verify_handle_ownership( + _rate_limit: RateLimited, + Json(input): Json, +) -> Response { + let handle: Handle = match input.handle.parse() { + Ok(h) => h, + Err(_) => { + return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response(); + } + }; + + let handle_str = handle.as_str(); + let did_str = input.did.as_str(); + + let dns_mismatch = match crate::handle::resolve_handle_dns(handle_str).await { + Ok(did) if did == did_str => { + return Json(VerifyHandleOwnershipOutput { + verified: true, + method: Some("dns".to_string()), + error: None, + }) + .into_response(); + } + Ok(did) => Some(format!( + "DNS record points to {}, expected {}", + did, did_str + )), + Err(_) => None, + }; + + match crate::handle::resolve_handle_http(handle_str).await { + Ok(did) if did == did_str => Json(VerifyHandleOwnershipOutput { + verified: true, + method: Some("http".to_string()), + error: None, + }) + .into_response(), + Ok(did) => Json(VerifyHandleOwnershipOutput { + verified: false, + method: None, + error: Some(format!("Handle resolves to {}, expected {}", did, did_str)), + }) + .into_response(), + Err(e) => Json(VerifyHandleOwnershipOutput { + verified: false, + method: None, + error: Some(dns_mismatch.unwrap_or_else(|| format!("Handle resolution failed: {}", e))), + }) + .into_response(), + } +} diff --git a/crates/tranquil-pds/src/api/identity/mod.rs b/crates/tranquil-pds/src/api/identity/mod.rs index 1a7b2e1..9e01cfc 100644 --- a/crates/tranquil-pds/src/api/identity/mod.rs +++ b/crates/tranquil-pds/src/api/identity/mod.rs @@ -1,5 +1,6 @@ pub mod account; pub mod did; +pub mod handle; pub mod plc; pub use account::create_account; @@ -7,4 +8,5 @@ pub use did::{ get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, well_known_atproto_did, well_known_did, }; +pub use handle::verify_handle_ownership; pub use plc::{request_plc_operation_signature, sign_plc_operation, submit_plc_operation}; diff --git a/crates/tranquil-pds/src/api/validation.rs b/crates/tranquil-pds/src/api/validation.rs index 015a37c..8eb91df 100644 --- a/crates/tranquil-pds/src/api/validation.rs +++ b/crates/tranquil-pds/src/api/validation.rs @@ -550,7 +550,9 @@ mod tests { assert!(is_valid_discord_username("user.name")); assert!(is_valid_discord_username("user123")); assert!(is_valid_discord_username("a_b.c_d")); - assert!(is_valid_discord_username("12345678901234567890123456789012")); + assert!(is_valid_discord_username( + "12345678901234567890123456789012" + )); } #[test] @@ -564,6 +566,8 @@ mod tests { assert!(!is_valid_discord_username("username.")); assert!(!is_valid_discord_username("user..name")); assert!(!is_valid_discord_username("user name")); - assert!(!is_valid_discord_username("123456789012345678901234567890123")); + assert!(!is_valid_discord_username( + "123456789012345678901234567890123" + )); } } diff --git a/crates/tranquil-pds/src/auth/verification_token.rs b/crates/tranquil-pds/src/auth/verification_token.rs index 08fd2db..5d8b5bf 100644 --- a/crates/tranquil-pds/src/auth/verification_token.rs +++ b/crates/tranquil-pds/src/auth/verification_token.rs @@ -300,7 +300,7 @@ pub fn format_token_for_display(token: &str) -> String { } pub fn normalize_token_input(input: &str) -> String { - input.trim().to_string() + input.chars().filter(|c| !c.is_whitespace()).collect() } #[cfg(test)] diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index 62f3efd..25c699d 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -337,6 +337,10 @@ pub fn app(state: AppState) -> Router { "/com.atproto.identity.submitPlcOperation", post(api::identity::submit_plc_operation), ) + .route( + "/_identity.verifyHandleOwnership", + post(api::identity::verify_handle_ownership), + ) .route("/com.atproto.repo.importRepo", post(api::repo::import_repo)) .route( "/com.atproto.admin.deleteAccount", diff --git a/crates/tranquil-pds/src/rate_limit/extractor.rs b/crates/tranquil-pds/src/rate_limit/extractor.rs index b8c3af2..de895f4 100644 --- a/crates/tranquil-pds/src/rate_limit/extractor.rs +++ b/crates/tranquil-pds/src/rate_limit/extractor.rs @@ -110,6 +110,11 @@ impl RateLimitPolicy for OAuthRegisterCompleteLimit { const KIND: RateLimitKind = RateLimitKind::OAuthRegisterComplete; } +pub struct HandleVerificationLimit; +impl RateLimitPolicy for HandleVerificationLimit { + const KIND: RateLimitKind = RateLimitKind::HandleVerification; +} + pub trait RateLimitRejection: IntoResponse + Send + 'static { fn new() -> Self; } diff --git a/crates/tranquil-pds/src/rate_limit/mod.rs b/crates/tranquil-pds/src/rate_limit/mod.rs index 05c9bad..c72f61b 100644 --- a/crates/tranquil-pds/src/rate_limit/mod.rs +++ b/crates/tranquil-pds/src/rate_limit/mod.rs @@ -33,6 +33,7 @@ pub struct RateLimiters { pub sso_callback: Arc, pub sso_unlink: Arc, pub oauth_register_complete: Arc, + pub handle_verification: Arc, } impl Default for RateLimiters { @@ -109,6 +110,9 @@ impl RateLimiters { .unwrap() .allow_burst(NonZeroU32::new(5).unwrap()), )), + handle_verification: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(10).unwrap(), + ))), } } diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index af7bab0..23bbdc1 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -71,6 +71,7 @@ pub enum RateLimitKind { SsoCallback, SsoUnlink, OAuthRegisterComplete, + HandleVerification, } impl RateLimitKind { @@ -95,6 +96,7 @@ impl RateLimitKind { Self::SsoCallback => "sso_callback", Self::SsoUnlink => "sso_unlink", Self::OAuthRegisterComplete => "oauth_register_complete", + Self::HandleVerification => "handle_verification", } } @@ -119,6 +121,7 @@ impl RateLimitKind { Self::SsoCallback => (30, 60_000), Self::SsoUnlink => (10, 60_000), Self::OAuthRegisterComplete => (5, 300_000), + Self::HandleVerification => (10, 60_000), } } } @@ -271,6 +274,7 @@ impl AppState { RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback, RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink, RateLimitKind::OAuthRegisterComplete => &self.rate_limiters.oauth_register_complete, + RateLimitKind::HandleVerification => &self.rate_limiters.handle_verification, }; let ok = limiter.check_key(&client_ip.to_string()).is_ok(); diff --git a/crates/tranquil-pds/tests/identity.rs b/crates/tranquil-pds/tests/identity.rs index 610d6c9..333d45d 100644 --- a/crates/tranquil-pds/tests/identity.rs +++ b/crates/tranquil-pds/tests/identity.rs @@ -531,3 +531,99 @@ async fn test_update_handle_too_long() { assert_eq!(body["error"], "InvalidHandle"); assert!(body["message"].as_str().unwrap().contains("long")); } + +#[tokio::test] +async fn test_verify_handle_ownership_invalid_did() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/_identity.verifyHandleOwnership", + base_url().await + )) + .json(&json!({ + "handle": "some.handle.test", + "did": "not-a-did" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_verify_handle_ownership_invalid_handle() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/_identity.verifyHandleOwnership", + base_url().await + )) + .json(&json!({ + "handle": "@#$!", + "did": "did:plc:abc123" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "InvalidHandle"); +} + +#[tokio::test] +async fn test_verify_handle_ownership_missing_fields() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/_identity.verifyHandleOwnership", + base_url().await + )) + .json(&json!({})) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_verify_handle_ownership_unresolvable() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/_identity.verifyHandleOwnership", + base_url().await + )) + .json(&json!({ + "handle": "nonexistent.example.com", + "did": "did:plc:abc123def456" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["verified"], false); + assert!(body["error"].as_str().is_some()); +} + +#[tokio::test] +async fn test_verify_handle_ownership_wrong_did() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/_identity.verifyHandleOwnership", + base_url().await + )) + .json(&json!({ + "handle": "nonexistent.example.com", + "did": "did:plc:aaaaaaaaaaaaaaaaaaaaaa" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["verified"], false); + assert!(body["error"].as_str().is_some()); + assert!(body["method"].is_null()); +} diff --git a/crates/tranquil-pds/tests/security_fixes.rs b/crates/tranquil-pds/tests/security_fixes.rs index b3ebe59..a302ae2 100644 --- a/crates/tranquil-pds/tests/security_fixes.rs +++ b/crates/tranquil-pds/tests/security_fixes.rs @@ -1,5 +1,7 @@ mod common; -use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value}; +use tranquil_pds::comms::{ + SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value, +}; use tranquil_pds::image::{ImageError, ImageProcessor}; #[test] @@ -101,15 +103,20 @@ fn test_signal_username_validation() { assert!(!is_valid_signal_username("a".repeat(33).as_str())); - ["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"] - .iter() - .for_each(|malicious| { - assert!( - !is_valid_signal_username(malicious), - "Command injection '{}' should be rejected", - malicious - ); - }); + [ + "alice.01; rm -rf /", + "bob.01 && cat /etc/passwd", + "user.01`id`", + "test.01$(whoami)", + ] + .iter() + .for_each(|malicious| { + assert!( + !is_valid_signal_username(malicious), + "Command injection '{}' should be rejected", + malicious + ); + }); } #[test] diff --git a/frontend/src/components/dashboard/CommsContent.svelte b/frontend/src/components/dashboard/CommsContent.svelte index 6801e10..b0fe840 100644 --- a/frontend/src/components/dashboard/CommsContent.svelte +++ b/frontend/src/components/dashboard/CommsContent.svelte @@ -333,18 +333,14 @@ placeholder={$_('register.signalUsernamePlaceholder')} disabled={saving} /> - {#if signalUsername && signalUsername === savedSignalUsername && !signalVerified} - - {/if} {#if signalInUse}

{$_('comms.signalInUseWarning')}

{/if} - {#if verifyingChannel === 'signal'} + {#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
- + -
{/if} @@ -553,20 +549,11 @@ color: var(--text-secondary); } - .verify-btn { - padding: var(--space-2) var(--space-3); - font-size: var(--text-sm); - } .verify-form { display: flex; + flex-direction: column; gap: var(--space-2); - align-items: center; - } - - .verify-form input { - flex: 1; - min-width: 0; } .verify-form button { @@ -574,12 +561,6 @@ font-size: var(--text-sm); } - .verify-form button.cancel { - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); - } - .actions { margin-bottom: var(--space-5); } diff --git a/frontend/src/components/migration/ChooseHandleStep.svelte b/frontend/src/components/migration/ChooseHandleStep.svelte index 8803656..8f35bba 100644 --- a/frontend/src/components/migration/ChooseHandleStep.svelte +++ b/frontend/src/components/migration/ChooseHandleStep.svelte @@ -1,5 +1,5 @@ @@ -69,38 +93,115 @@ {migratingFromValue} -
- -
- onHandleChange((e.target as HTMLInputElement).value)} - onblur={onCheckHandle} - /> - {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} - + {#if isExternalHandle} +
+ {$_('migration.inbound.chooseHandle.handleChoice')} +
+ + +
+
+ {/if} + + {#if handlePreservation === 'existing' && isExternalHandle} +
+ {$_('migration.inbound.chooseHandle.existingHandle')} +
+ @{sourceHandle} + {#if existingHandleVerified} + {$_('migration.inbound.chooseHandle.verified')} + {/if} +
+ + {#if !existingHandleVerified} +
+

{$_('migration.inbound.chooseHandle.verifyInstructions')}

+
+ _atproto.{sourceHandle} TXT "did={sourceDid}" +
+

{$_('migration.inbound.chooseHandle.or')}

+
+ https://{sourceHandle}/.well-known/atproto-did + {$_('migration.inbound.chooseHandle.returning')} {sourceDid} +
+
+ + + + {#if existingHandleError} +

{existingHandleError}

+ {/if} {/if}
+ {:else} +
+ +
+ onHandleChange((e.target as HTMLInputElement).value)} + onblur={onCheckHandle} + /> + {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} + + {/if} +
- {#if handleTooShort} -

{$_('migration.inbound.chooseHandle.handleTooShort')}

- {:else if checkingHandle} -

{$_('migration.inbound.chooseHandle.checkingAvailability')}

- {:else if handleAvailable === true} -

{$_('migration.inbound.chooseHandle.handleAvailable')}

- {:else if handleAvailable === false} -

{$_('migration.inbound.chooseHandle.handleTaken')}

- {:else} -

{$_('migration.inbound.chooseHandle.handleHint')}

- {/if} -
+ {#if handleTooShort} +

{$_('migration.inbound.chooseHandle.handleTooShort')}

+ {:else if checkingHandle} +

{$_('migration.inbound.chooseHandle.checkingAvailability')}

+ {:else if handleAvailable === true} +

{$_('migration.inbound.chooseHandle.handleAvailable')}

+ {:else if handleAvailable === false} +

{$_('migration.inbound.chooseHandle.handleTaken')}

+ {:else} +

{$_('migration.inbound.chooseHandle.handleHint')}

+ {/if} +
+ {/if}
@@ -187,3 +288,123 @@
+ + diff --git a/frontend/src/components/migration/InboundWizard.svelte b/frontend/src/components/migration/InboundWizard.svelte index 58c6a90..80ddc59 100644 --- a/frontend/src/components/migration/InboundWizard.svelte +++ b/frontend/src/components/migration/InboundWizard.svelte @@ -1,6 +1,6 @@