this repo has no description

Better handles

lewis afa83692 2cddf065

+8 -8
TODO.md
··· 12 12 ### Passkeys and 2FA 13 13 Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. 14 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) 15 + - [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 16 + - [x] user_totp table (did, secret_encrypted, verified, created_at, last_used) 17 + - [x] WebAuthn registration challenge generation and attestation verification 18 + - [x] TOTP secret generation with QR code setup flow 19 + - [x] Backup codes (hashed, one-time use) with recovery flow 20 + - [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) 21 21 - [ ] Passkey-only account creation (no password) 22 - - [ ] Settings UI for managing passkeys, TOTP, backup codes 22 + - [x] Settings UI for managing passkeys, TOTP, backup codes 23 23 - [ ] Trusted devices option (remember this browser) 24 - - [ ] Rate limit 2FA attempts 24 + - [x] Rate limit 2FA attempts 25 25 - [ ] Re-auth for sensitive actions (email change, adding new auth methods) 26 26 27 27 ### Delegated accounts
+6 -4
frontend/src/routes/OAuthLogin.svelte
··· 341 341 /> 342 342 </div> 343 343 344 - {#if securityStatusChecked && passkeySupported} 344 + {#if passkeySupported && username.length >= 3} 345 345 <button 346 346 type="button" 347 347 class="passkey-btn" 348 - class:passkey-unavailable={!hasPasskeys} 348 + class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 349 349 onclick={handlePasskeyLogin} 350 - disabled={submitting || !hasPasskeys || !username} 351 - title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 350 + disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 351 + title={checkingSecurityStatus ? 'Checking passkey status...' : hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 352 352 > 353 353 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 354 354 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> ··· 358 358 <span class="passkey-text"> 359 359 {#if submitting} 360 360 Authenticating... 361 + {:else if checkingSecurityStatus || !securityStatusChecked} 362 + Checking passkey... 361 363 {:else if hasPasskeys} 362 364 Sign in with passkey 363 365 {:else}
+9 -1
frontend/src/routes/Register.svelte
··· 50 50 } 51 51 } 52 52 53 + let handleHasDot = $derived(handle.includes('.')) 54 + 53 55 function validateForm(): string | null { 54 56 if (!handle.trim()) return 'Handle is required' 57 + if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.' 55 58 if (!password) return 'Password is required' 56 59 if (password.length < 8) return 'Password must be at least 8 characters' 57 60 if (password !== confirmPassword) return 'Passwords do not match' ··· 152 155 disabled={submitting} 153 156 required 154 157 /> 155 - {#if fullHandle()} 158 + {#if handleHasDot} 159 + <p class="hint warning">Custom domain handles can be set up after account creation in Settings.</p> 160 + {:else if fullHandle()} 156 161 <p class="hint">Your full handle will be: @{fullHandle()}</p> 157 162 {/if} 158 163 </div> ··· 389 394 font-size: 0.75rem; 390 395 color: var(--text-secondary); 391 396 margin: 0.25rem 0 0 0; 397 + } 398 + .hint.warning { 399 + color: var(--warning-text, #856404); 392 400 } 393 401 .verification-section { 394 402 border: 1px solid var(--border-color-light);
+90 -19
migrations/20251211_initial_schema.sql
··· 1 - CREATE TYPE notification_channel AS ENUM ('email', 'discord', 'telegram', 'signal'); 2 - CREATE TYPE notification_status AS ENUM ('pending', 'processing', 'sent', 'failed'); 3 - CREATE TYPE notification_type AS ENUM ( 1 + CREATE TYPE comms_channel AS ENUM ('email', 'discord', 'telegram', 'signal'); 2 + CREATE TYPE comms_status AS ENUM ('pending', 'processing', 'sent', 'failed'); 3 + CREATE TYPE comms_type AS ENUM ( 4 4 'welcome', 5 5 'email_verification', 6 6 'password_reset', ··· 8 8 'account_deletion', 9 9 'admin_email', 10 10 'plc_operation', 11 - 'two_factor_code' 11 + 'two_factor_code', 12 + 'channel_verification' 12 13 ); 13 14 CREATE TABLE IF NOT EXISTS users ( 14 15 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ··· 21 22 deactivated_at TIMESTAMPTZ, 22 23 invites_disabled BOOLEAN DEFAULT FALSE, 23 24 takedown_ref TEXT, 24 - preferred_notification_channel notification_channel NOT NULL DEFAULT 'email', 25 + preferred_comms_channel comms_channel NOT NULL DEFAULT 'email', 25 26 password_reset_code TEXT, 26 27 password_reset_code_expires_at TIMESTAMPTZ, 27 - email_pending_verification TEXT, 28 - email_confirmation_code TEXT, 29 - email_confirmation_code_expires_at TIMESTAMPTZ, 30 - email_confirmed BOOLEAN NOT NULL DEFAULT FALSE, 28 + email_verified BOOLEAN NOT NULL DEFAULT FALSE, 31 29 two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, 32 30 discord_id TEXT, 33 31 discord_verified BOOLEAN NOT NULL DEFAULT FALSE, 34 32 telegram_username TEXT, 35 33 telegram_verified BOOLEAN NOT NULL DEFAULT FALSE, 36 34 signal_number TEXT, 37 - signal_verified BOOLEAN NOT NULL DEFAULT FALSE 35 + signal_verified BOOLEAN NOT NULL DEFAULT FALSE, 36 + is_admin BOOLEAN NOT NULL DEFAULT FALSE, 37 + migrated_to_pds TEXT, 38 + migrated_at TIMESTAMPTZ 38 39 ); 39 40 CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL; 40 - CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL; 41 41 CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL; 42 42 CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL; 43 43 CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL; 44 + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL; 44 45 CREATE TABLE IF NOT EXISTS invite_codes ( 45 46 code TEXT PRIMARY KEY, 46 47 available_uses INT NOT NULL DEFAULT 1, ··· 48 49 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 49 50 disabled BOOLEAN DEFAULT FALSE 50 51 ); 52 + CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by ON invite_codes(created_by_user); 51 53 CREATE TABLE IF NOT EXISTS invite_code_uses ( 52 54 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 53 55 code TEXT NOT NULL REFERENCES invite_codes(code), ··· 86 88 UNIQUE(repo_id, collection, rkey) 87 89 ); 88 90 CREATE INDEX idx_records_repo_rev ON records(repo_rev); 91 + CREATE INDEX IF NOT EXISTS idx_records_repo_collection ON records(repo_id, collection); 92 + CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created ON records(repo_id, collection, created_at DESC); 89 93 CREATE TABLE IF NOT EXISTS blobs ( 90 94 cid TEXT PRIMARY KEY, 91 95 mime_type TEXT NOT NULL, ··· 95 99 takedown_ref TEXT, 96 100 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 97 101 ); 102 + CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user ON blobs(created_by_user, created_at DESC); 98 103 CREATE TABLE IF NOT EXISTS app_passwords ( 99 104 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 100 105 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, ··· 104 109 privileged BOOLEAN NOT NULL DEFAULT FALSE, 105 110 UNIQUE(user_id, name) 106 111 ); 112 + CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id ON app_passwords(user_id); 107 113 CREATE TABLE reports ( 108 114 id BIGINT PRIMARY KEY, 109 115 reason_type TEXT NOT NULL, ··· 118 124 expires_at TIMESTAMPTZ NOT NULL, 119 125 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 120 126 ); 121 - CREATE TABLE IF NOT EXISTS notification_queue ( 127 + CREATE TABLE IF NOT EXISTS comms_queue ( 122 128 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 123 129 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 124 - channel notification_channel NOT NULL DEFAULT 'email', 125 - notification_type notification_type NOT NULL, 126 - status notification_status NOT NULL DEFAULT 'pending', 130 + channel comms_channel NOT NULL DEFAULT 'email', 131 + comms_type comms_type NOT NULL, 132 + status comms_status NOT NULL DEFAULT 'pending', 127 133 recipient TEXT NOT NULL, 128 134 subject TEXT, 129 135 body TEXT NOT NULL, ··· 136 142 scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(), 137 143 processed_at TIMESTAMPTZ 138 144 ); 139 - CREATE INDEX idx_notification_queue_status_scheduled 140 - ON notification_queue(status, scheduled_for) 145 + CREATE INDEX idx_comms_queue_status_scheduled 146 + ON comms_queue(status, scheduled_for) 141 147 WHERE status = 'pending'; 142 - CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id); 148 + CREATE INDEX idx_comms_queue_user_id ON comms_queue(user_id); 143 149 CREATE TABLE IF NOT EXISTS reserved_signing_keys ( 144 150 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 145 151 did TEXT, ··· 160 166 prev_cid TEXT, 161 167 ops JSONB, 162 168 blobs TEXT[], 163 - blocks_cids TEXT[] 169 + blocks_cids TEXT[], 170 + prev_data_cid TEXT, 171 + handle TEXT, 172 + active BOOLEAN, 173 + status TEXT 164 174 ); 165 175 CREATE INDEX idx_repo_seq_seq ON repo_seq(seq); 166 176 CREATE INDEX idx_repo_seq_did ON repo_seq(did); 177 + CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq ON repo_seq(did, seq DESC); 167 178 CREATE TABLE IF NOT EXISTS session_tokens ( 168 179 id SERIAL PRIMARY KEY, 169 180 did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, ··· 275 286 ); 276 287 CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri); 277 288 CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at); 289 + CREATE TABLE IF NOT EXISTS channel_verifications ( 290 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 291 + channel comms_channel NOT NULL, 292 + code TEXT NOT NULL, 293 + pending_identifier TEXT, 294 + expires_at TIMESTAMPTZ NOT NULL, 295 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 296 + PRIMARY KEY (user_id, channel) 297 + ); 298 + CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at); 299 + CREATE TABLE oauth_scope_preference ( 300 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 301 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 302 + client_id TEXT NOT NULL, 303 + scope TEXT NOT NULL, 304 + granted BOOLEAN NOT NULL DEFAULT TRUE, 305 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 306 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 307 + UNIQUE(did, client_id, scope) 308 + ); 309 + CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id); 310 + CREATE TABLE user_totp ( 311 + did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, 312 + secret_encrypted BYTEA NOT NULL, 313 + encryption_version INTEGER NOT NULL DEFAULT 1, 314 + verified BOOLEAN NOT NULL DEFAULT FALSE, 315 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 316 + last_used TIMESTAMPTZ 317 + ); 318 + CREATE TABLE backup_codes ( 319 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 320 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 321 + code_hash TEXT NOT NULL, 322 + used_at TIMESTAMPTZ, 323 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 324 + ); 325 + CREATE INDEX idx_backup_codes_did ON backup_codes(did); 326 + CREATE TABLE passkeys ( 327 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 328 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 329 + credential_id BYTEA NOT NULL UNIQUE, 330 + public_key BYTEA NOT NULL, 331 + sign_count INTEGER NOT NULL DEFAULT 0, 332 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 333 + last_used TIMESTAMPTZ, 334 + friendly_name TEXT, 335 + aaguid BYTEA, 336 + transports TEXT[] 337 + ); 338 + CREATE INDEX idx_passkeys_did ON passkeys(did); 339 + CREATE TABLE webauthn_challenges ( 340 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 341 + did TEXT NOT NULL, 342 + challenge BYTEA NOT NULL, 343 + challenge_type TEXT NOT NULL, 344 + state_json TEXT NOT NULL, 345 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 346 + expires_at TIMESTAMPTZ NOT NULL 347 + ); 348 + CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
-15
migrations/20251213_performance_indexes.sql
··· 1 - CREATE INDEX IF NOT EXISTS idx_records_repo_collection 2 - ON records(repo_id, collection); 3 - CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created 4 - ON records(repo_id, collection, created_at DESC); 5 - CREATE INDEX IF NOT EXISTS idx_users_email 6 - ON users(email) 7 - WHERE email IS NOT NULL; 8 - CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user 9 - ON blobs(created_by_user, created_at DESC); 10 - CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq 11 - ON repo_seq(did, seq DESC); 12 - CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id 13 - ON app_passwords(user_id); 14 - CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by 15 - ON invite_codes(created_by_user);
-1
migrations/20251214_add_prev_data_cid.sql
··· 1 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS prev_data_cid TEXT;
-3
migrations/20251215_add_identity_account_fields.sql
··· 1 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS handle TEXT; 2 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS active BOOLEAN; 3 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS status TEXT;
-12
migrations/20251216_add_channel_verification.sql
··· 1 - ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'channel_verification'; 2 - 3 - CREATE TABLE IF NOT EXISTS channel_verifications ( 4 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 5 - channel notification_channel NOT NULL, 6 - code TEXT NOT NULL, 7 - expires_at TIMESTAMPTZ NOT NULL, 8 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 - PRIMARY KEY (user_id, channel) 10 - ); 11 - 12 - CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
-11
migrations/20251217_migrate_email_to_channel_verifications.sql
··· 1 - ALTER TABLE channel_verifications ADD COLUMN pending_identifier TEXT; 2 - 3 - INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 4 - SELECT id, 'email', email_confirmation_code, email_pending_verification, email_confirmation_code_expires_at 5 - FROM users 6 - WHERE email_confirmation_code IS NOT NULL AND email_confirmation_code_expires_at IS NOT NULL; 7 - 8 - ALTER TABLE users 9 - DROP COLUMN email_confirmation_code, 10 - DROP COLUMN email_confirmation_code_expires_at, 11 - DROP COLUMN email_pending_verification;
-1
migrations/20251218_add_is_admin.sql
··· 1 - ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-6
migrations/20251219_rename_email_confirmed.sql
··· 1 - DO $$ 2 - BEGIN 3 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'email_confirmed') THEN 4 - ALTER TABLE users RENAME COLUMN email_confirmed TO email_verified; 5 - END IF; 6 - END $$;
-27
migrations/20251220_rename_notifications_to_comms.sql
··· 1 - DO $$ 2 - BEGIN 3 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN 4 - ALTER TYPE notification_channel RENAME TO comms_channel; 5 - END IF; 6 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN 7 - ALTER TYPE notification_status RENAME TO comms_status; 8 - END IF; 9 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN 10 - ALTER TYPE notification_type RENAME TO comms_type; 11 - END IF; 12 - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'notification_queue') THEN 13 - ALTER TABLE notification_queue RENAME TO comms_queue; 14 - END IF; 15 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comms_queue' AND column_name = 'notification_type') THEN 16 - ALTER TABLE comms_queue RENAME COLUMN notification_type TO comms_type; 17 - END IF; 18 - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_status_scheduled') THEN 19 - ALTER INDEX idx_notification_queue_status_scheduled RENAME TO idx_comms_queue_status_scheduled; 20 - END IF; 21 - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_user_id') THEN 22 - ALTER INDEX idx_notification_queue_user_id RENAME TO idx_comms_queue_user_id; 23 - END IF; 24 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'preferred_notification_channel') THEN 25 - ALTER TABLE users RENAME COLUMN preferred_notification_channel TO preferred_comms_channel; 26 - END IF; 27 - END $$;
-12
migrations/20251221_oauth_scope_preferences.sql
··· 1 - CREATE TABLE oauth_scope_preference ( 2 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 4 - client_id TEXT NOT NULL, 5 - scope TEXT NOT NULL, 6 - granted BOOLEAN NOT NULL DEFAULT TRUE, 7 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 8 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 - UNIQUE(did, client_id, scope) 10 - ); 11 - 12 - CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id);
-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;
-42
migrations/20251223_add_passkeys_totp.sql
··· 1 - CREATE TABLE user_totp ( 2 - did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, 3 - secret_encrypted BYTEA NOT NULL, 4 - encryption_version INTEGER NOT NULL DEFAULT 1, 5 - verified BOOLEAN NOT NULL DEFAULT FALSE, 6 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 - last_used TIMESTAMPTZ 8 - ); 9 - 10 - CREATE TABLE backup_codes ( 11 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 12 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 13 - code_hash TEXT NOT NULL, 14 - used_at TIMESTAMPTZ, 15 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 16 - ); 17 - CREATE INDEX idx_backup_codes_did ON backup_codes(did); 18 - 19 - CREATE TABLE passkeys ( 20 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 21 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 22 - credential_id BYTEA NOT NULL UNIQUE, 23 - public_key BYTEA NOT NULL, 24 - sign_count INTEGER NOT NULL DEFAULT 0, 25 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 26 - last_used TIMESTAMPTZ, 27 - friendly_name TEXT, 28 - aaguid BYTEA, 29 - transports TEXT[] 30 - ); 31 - CREATE INDEX idx_passkeys_did ON passkeys(did); 32 - 33 - CREATE TABLE webauthn_challenges ( 34 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 35 - did TEXT NOT NULL, 36 - challenge BYTEA NOT NULL, 37 - challenge_type TEXT NOT NULL, 38 - state_json TEXT NOT NULL, 39 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 40 - expires_at TIMESTAMPTZ NOT NULL 41 - ); 42 - CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
+9 -3
src/api/admin/account/update.rs
··· 67 67 Json(input): Json<UpdateAccountHandleInput>, 68 68 ) -> Response { 69 69 let did = input.did.trim(); 70 - let handle = input.handle.trim(); 71 - if did.is_empty() || handle.is_empty() { 70 + let input_handle = input.handle.trim(); 71 + if did.is_empty() || input_handle.is_empty() { 72 72 return ( 73 73 StatusCode::BAD_REQUEST, 74 74 Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})), 75 75 ) 76 76 .into_response(); 77 77 } 78 - if !handle 78 + if !input_handle 79 79 .chars() 80 80 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') 81 81 { ··· 87 87 ) 88 88 .into_response(); 89 89 } 90 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 91 + let handle = if !input_handle.contains('.') { 92 + format!("{}.{}", input_handle, hostname) 93 + } else { 94 + input_handle.to_string() 95 + }; 90 96 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 91 97 .fetch_optional(&state.db) 92 98 .await
+45 -24
src/api/identity/account.rs
··· 139 139 info!(did = %migration_did, "Processing account migration"); 140 140 } 141 141 142 - if input.handle.contains('!') || input.handle.contains('@') { 143 - return ( 144 - StatusCode::BAD_REQUEST, 145 - Json( 146 - json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}), 147 - ), 148 - ) 149 - .into_response(); 150 - } 142 + let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 143 + let pds_suffix = format!(".{}", hostname_for_validation); 144 + 145 + let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 146 + let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 147 + input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle) 148 + } else { 149 + &input.handle 150 + }; 151 + match crate::api::validation::validate_short_handle(handle_to_validate) { 152 + Ok(h) => h, 153 + Err(e) => { 154 + return ( 155 + StatusCode::BAD_REQUEST, 156 + Json(json!({"error": "InvalidHandle", "message": e.to_string()})), 157 + ) 158 + .into_response(); 159 + } 160 + } 161 + } else { 162 + if input.handle.contains(' ') || input.handle.contains('\t') { 163 + return ( 164 + StatusCode::BAD_REQUEST, 165 + Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})), 166 + ) 167 + .into_response(); 168 + } 169 + input.handle.to_lowercase() 170 + }; 151 171 let email: Option<String> = input 152 172 .email 153 173 .as_ref() ··· 212 232 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 213 233 let pds_endpoint = format!("https://{}", hostname); 214 234 let suffix = format!(".{}", hostname); 215 - let short_handle = if input.handle.ends_with(&suffix) { 216 - input.handle.strip_suffix(&suffix).unwrap_or(&input.handle) 235 + let handle = if input.handle.ends_with(&suffix) { 236 + format!("{}.{}", validated_short_handle, hostname) 237 + } else if input.handle.contains('.') { 238 + validated_short_handle.clone() 217 239 } else { 218 - &input.handle 240 + format!("{}.{}", validated_short_handle, hostname) 219 241 }; 220 - let full_handle = format!("{}.{}", short_handle, hostname); 221 242 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) = 222 243 if let Some(signing_key_did) = &input.signing_key { 223 244 let reserved = sqlx::query!( ··· 298 319 ) 299 320 .into_response(); 300 321 } 301 - if let Err(e) = verify_did_web(d, &hostname, &input.handle).await { 322 + if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { 302 323 return ( 303 324 StatusCode::BAD_REQUEST, 304 325 Json(json!({"error": "InvalidDid", "message": e})), ··· 314 335 info!(did = %d, "Migration with existing did:plc"); 315 336 d.clone() 316 337 } else if d.starts_with("did:web:") { 317 - if let Err(e) = verify_did_web(d, &hostname, &input.handle).await { 338 + if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { 318 339 return ( 319 340 StatusCode::BAD_REQUEST, 320 341 Json(json!({"error": "InvalidDid", "message": e})), ··· 334 355 let genesis_result = match create_genesis_operation( 335 356 &signing_key, 336 357 &rotation_key, 337 - &full_handle, 358 + &handle, 338 359 &pds_endpoint, 339 360 ) { 340 361 Ok(r) => r, ··· 371 392 let genesis_result = match create_genesis_operation( 372 393 &signing_key, 373 394 &rotation_key, 374 - &full_handle, 395 + &handle, 375 396 &pds_endpoint, 376 397 ) { 377 398 Ok(r) => r, ··· 424 445 .unwrap_or(None); 425 446 if let Some((account_id, old_handle, deactivated_at)) = existing_account { 426 447 if deactivated_at.is_some() { 427 - info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration"); 448 + info!(did = %did, old_handle = %old_handle, new_handle = %handle, "Preparing existing account for inbound migration"); 428 449 let update_result: Result<_, sqlx::Error> = 429 450 sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") 430 - .bind(short_handle) 451 + .bind(&handle) 431 452 .bind(account_id) 432 453 .execute(&mut *tx) 433 454 .await; ··· 536 557 return ( 537 558 StatusCode::OK, 538 559 Json(CreateAccountOutput { 539 - handle: full_handle.clone(), 560 + handle: handle.clone(), 540 561 did, 541 562 access_jwt: Some(access_meta.token), 542 563 refresh_jwt: Some(refresh_meta.token), ··· 556 577 } 557 578 let exists_result: Option<(i32,)> = 558 579 sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL") 559 - .bind(short_handle) 580 + .bind(&handle) 560 581 .fetch_optional(&mut *tx) 561 582 .await 562 583 .unwrap_or(None); ··· 660 681 is_admin, deactivated_at, email_verified 661 682 ) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9, $10, $11) RETURNING id"#, 662 683 ) 663 - .bind(short_handle) 684 + .bind(&handle) 664 685 .bind(&email) 665 686 .bind(&did) 666 687 .bind(&password_hash) ··· 898 919 } 899 920 if !is_migration { 900 921 if let Err(e) = 901 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) 922 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) 902 923 .await 903 924 { 904 925 warn!("Failed to sequence identity event for {}: {}", did, e); ··· 991 1012 ( 992 1013 StatusCode::OK, 993 1014 Json(CreateAccountOutput { 994 - handle: full_handle.clone(), 1015 + handle: handle.clone(), 995 1016 did, 996 1017 access_jwt, 997 1018 refresh_jwt,
+64 -56
src/api/identity/did.rs
··· 36 36 if let Some(did) = state.cache.get(&cache_key).await { 37 37 return (StatusCode::OK, Json(json!({ "did": did }))).into_response(); 38 38 } 39 - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 40 - let suffix = format!(".{}", hostname); 41 - let short_handle = if handle.ends_with(&suffix) { 42 - handle.strip_suffix(&suffix).unwrap_or(handle) 43 - } else { 44 - handle 45 - }; 46 - let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle) 39 + let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) 47 40 .fetch_optional(&state.db) 48 41 .await; 49 42 match user { ··· 139 132 } 140 133 141 134 async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response { 135 + let full_handle = format!("{}.{}", handle, hostname); 142 136 let user = sqlx::query!( 143 137 "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 144 - handle 138 + full_handle 145 139 ) 146 140 .fetch_optional(&state.db) 147 141 .await; ··· 212 206 .into_response(); 213 207 } 214 208 }; 215 - let full_handle = if handle.contains('.') { 216 - handle.to_string() 217 - } else { 218 - format!("{}.{}", handle, hostname) 219 - }; 220 209 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 221 210 Json(json!({ 222 211 "@context": [ ··· 225 214 "https://w3id.org/security/suites/secp256k1-2019/v1" 226 215 ], 227 216 "id": did, 228 - "alsoKnownAs": [format!("at://{}", full_handle)], 217 + "alsoKnownAs": [format!("at://{}", handle)], 229 218 "verificationMethod": [{ 230 219 "id": format!("{}#atproto", did), 231 220 "type": "Multikey", ··· 243 232 244 233 pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response { 245 234 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 235 + let full_handle = format!("{}.{}", handle, hostname); 246 236 let user = sqlx::query!( 247 237 "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 248 - handle 238 + full_handle 249 239 ) 250 240 .fetch_optional(&state.db) 251 241 .await; ··· 318 308 .into_response(); 319 309 } 320 310 }; 321 - let full_handle = if handle.contains('.') { 322 - handle.clone() 323 - } else { 324 - format!("{}.{}", handle, hostname) 325 - }; 326 311 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 327 312 Json(json!({ 328 313 "@context": [ ··· 331 316 "https://w3id.org/security/suites/secp256k1-2019/v1" 332 317 ], 333 318 "id": did, 334 - "alsoKnownAs": [format!("at://{}", full_handle)], 319 + "alsoKnownAs": [format!("at://{}", handle)], 335 320 "verificationMethod": [{ 336 321 "id": format!("{}#atproto", did), 337 322 "type": "Multikey", ··· 347 332 .into_response() 348 333 } 349 334 350 - pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> { 335 + pub async fn verify_did_web( 336 + did: &str, 337 + hostname: &str, 338 + handle: &str, 339 + expected_signing_key: Option<&str>, 340 + ) -> Result<(), String> { 351 341 let subdomain_host = format!("{}.{}", handle, hostname); 352 342 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 353 343 let expected_subdomain_did = format!("did:web:{}", encoded_subdomain); ··· 371 361 )); 372 362 } 373 363 } 364 + let expected_signing_key = expected_signing_key.ok_or_else(|| { 365 + "External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount.".to_string() 366 + })?; 374 367 let parts: Vec<&str> = did.split(':').collect(); 375 368 if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" { 376 369 return Err("Invalid did:web format".into()); ··· 411 404 let has_valid_service = services 412 405 .iter() 413 406 .any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint); 414 - if has_valid_service { 415 - Ok(()) 416 - } else { 417 - Err(format!( 407 + if !has_valid_service { 408 + return Err(format!( 418 409 "DID document does not list this PDS ({}) as AtprotoPersonalDataServer", 419 410 pds_endpoint 420 - )) 411 + )); 421 412 } 413 + let verification_methods = doc["verificationMethod"] 414 + .as_array() 415 + .ok_or("No verificationMethod found in DID doc")?; 416 + let expected_multibase = expected_signing_key 417 + .strip_prefix("did:key:") 418 + .ok_or("Invalid signing key format")?; 419 + let has_matching_key = verification_methods.iter().any(|vm| { 420 + vm["publicKeyMultibase"] 421 + .as_str() 422 + .map(|pk| pk == expected_multibase) 423 + .unwrap_or(false) 424 + }); 425 + if !has_matching_key { 426 + return Err(format!( 427 + "DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {}", 428 + expected_multibase 429 + )); 430 + } 431 + Ok(()) 422 432 } 423 433 424 434 #[derive(serde::Serialize)] ··· 492 502 }; 493 503 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 494 504 let pds_endpoint = format!("https://{}", hostname); 495 - let full_handle = if user.handle.contains('.') { 496 - user.handle.clone() 497 - } else { 498 - format!("{}.{}", user.handle, hostname) 499 - }; 500 505 let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) { 501 506 Ok(k) => k, 502 507 Err(_) => return ApiError::InternalError.into_response(), ··· 511 516 StatusCode::OK, 512 517 Json(GetRecommendedDidCredentialsOutput { 513 518 rotation_keys, 514 - also_known_as: vec![format!("at://{}", full_handle)], 519 + also_known_as: vec![format!("at://{}", user.handle)], 515 520 verification_methods: VerificationMethods { atproto: did_key }, 516 521 services: Services { 517 522 atproto_pds: AtprotoPds { ··· 577 582 .into_response(); 578 583 } 579 584 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 585 + let suffix = format!(".{}", hostname); 580 586 let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname); 581 - let (handle_to_store, full_handle) = if is_service_domain { 582 - let suffix = format!(".{}", hostname); 583 - let short_handle = if new_handle.ends_with(&suffix) { 587 + let handle = if is_service_domain { 588 + let short_part = if new_handle.ends_with(&suffix) { 584 589 new_handle.strip_suffix(&suffix).unwrap_or(new_handle) 585 590 } else { 586 591 new_handle 587 592 }; 588 - ( 589 - short_handle.to_string(), 590 - format!("{}.{}", short_handle, hostname), 591 - ) 593 + if short_part.contains('.') { 594 + return ( 595 + StatusCode::BAD_REQUEST, 596 + Json(json!({ 597 + "error": "InvalidHandle", 598 + "message": "Nested subdomains are not allowed. Use a simple handle without dots." 599 + })), 600 + ) 601 + .into_response(); 602 + } 603 + if new_handle.ends_with(&suffix) { 604 + new_handle.to_string() 605 + } else { 606 + format!("{}.{}", new_handle, hostname) 607 + } 592 608 } else { 593 609 match crate::handle::verify_handle_ownership(new_handle, &did).await { 594 610 Ok(()) => {} ··· 625 641 .into_response(); 626 642 } 627 643 } 628 - (new_handle.to_string(), new_handle.to_string()) 644 + new_handle.to_string() 629 645 }; 630 646 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id) 631 647 .fetch_optional(&state.db) ··· 634 650 .flatten(); 635 651 let existing = sqlx::query!( 636 652 "SELECT id FROM users WHERE handle = $1 AND id != $2", 637 - handle_to_store, 653 + handle, 638 654 user_id 639 655 ) 640 656 .fetch_optional(&state.db) ··· 648 664 } 649 665 let result = sqlx::query!( 650 666 "UPDATE users SET handle = $1 WHERE id = $2", 651 - handle_to_store, 667 + handle, 652 668 user_id 653 669 ) 654 670 .execute(&state.db) ··· 660 676 } 661 677 let _ = state 662 678 .cache 663 - .delete(&format!("handle:{}", handle_to_store)) 679 + .delete(&format!("handle:{}", handle)) 664 680 .await; 665 - let _ = state.cache.delete(&format!("handle:{}", full_handle)).await; 666 681 if let Err(e) = 667 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) 682 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) 668 683 .await 669 684 { 670 685 warn!("Failed to sequence identity event for handle update: {}", e); 671 686 } 672 - if let Err(e) = update_plc_handle(&state, &did, &full_handle).await { 687 + if let Err(e) = update_plc_handle(&state, &did, &handle).await { 673 688 warn!("Failed to update PLC handle: {}", e); 674 689 } 675 690 (StatusCode::OK, Json(json!({}))).into_response() ··· 723 738 Some(h) => h, 724 739 None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(), 725 740 }; 726 - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 727 - let suffix = format!(".{}", hostname); 728 741 let handle = host.split(':').next().unwrap_or(host); 729 - let short_handle = if handle.ends_with(&suffix) { 730 - handle.strip_suffix(&suffix).unwrap_or(handle) 731 - } else { 732 - return (StatusCode::NOT_FOUND, "Handle not found").into_response(); 733 - }; 734 - let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle) 742 + let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) 735 743 .fetch_optional(&state.db) 736 744 .await; 737 745 match user {
+2 -2
src/api/repo/blob.rs
··· 16 16 use std::str::FromStr; 17 17 use tracing::{debug, error}; 18 18 19 - const MAX_BLOB_SIZE: usize = 1_000_000; 20 - const MAX_VIDEO_BLOB_SIZE: usize = 100_000_000; 19 + const MAX_BLOB_SIZE: usize = 10_000_000_000; 20 + const MAX_VIDEO_BLOB_SIZE: usize = 10_000_000_000; 21 21 22 22 pub async fn upload_blob( 23 23 State(state): State<AppState>,
-24
src/api/repo/import.rs
··· 318 318 records.len(), 319 319 did 320 320 ); 321 - if is_migration { 322 - if let Err(e) = 323 - sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) 324 - .execute(&state.db) 325 - .await 326 - { 327 - error!("Failed to reactivate account after import: {:?}", e); 328 - } 329 - let _ = state.cache.delete(&format!("handle:{}", user.handle)).await; 330 - if let Err(e) = crate::api::repo::record::sequence_identity_event( 331 - &state, 332 - did, 333 - Some(&user.handle), 334 - ) 335 - .await 336 - { 337 - warn!("Failed to sequence identity event after import: {:?}", e); 338 - } 339 - if let Err(e) = 340 - crate::api::repo::record::sequence_account_event(&state, did, true, None).await 341 - { 342 - warn!("Failed to sequence account event after import: {:?}", e); 343 - } 344 - } 345 321 if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { 346 322 warn!("Failed to sequence import event: {:?}", e); 347 323 }
+7 -1
src/api/repo/meta.rs
··· 17 17 State(state): State<AppState>, 18 18 Query(input): Query<DescribeRepoInput>, 19 19 ) -> Response { 20 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 20 21 let user_row = if input.repo.starts_with("did:") { 21 22 sqlx::query!( 22 23 "SELECT id, handle, did FROM users WHERE did = $1", ··· 26 27 .await 27 28 .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) 28 29 } else { 30 + let handle = if !input.repo.contains('.') { 31 + format!("{}.{}", input.repo, hostname) 32 + } else { 33 + input.repo.clone() 34 + }; 29 35 sqlx::query!( 30 36 "SELECT id, handle, did FROM users WHERE handle = $1", 31 - input.repo 37 + handle 32 38 ) 33 39 .fetch_optional(&state.db) 34 40 .await
+8 -10
src/api/repo/record/read.rs
··· 34 34 .await 35 35 .map(|opt| opt.map(|r| r.id)) 36 36 } else { 37 - let suffix = format!(".{}", hostname); 38 - let short_handle = if input.repo.ends_with(&suffix) { 39 - input.repo.strip_suffix(&suffix).unwrap_or(&input.repo) 37 + let handle = if !input.repo.contains('.') { 38 + format!("{}.{}", input.repo, hostname) 40 39 } else { 41 - &input.repo 40 + input.repo.clone() 42 41 }; 43 - sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle) 42 + sqlx::query!("SELECT id FROM users WHERE handle = $1", handle) 44 43 .fetch_optional(&state.db) 45 44 .await 46 45 .map(|opt| opt.map(|r| r.id)) ··· 212 211 .await 213 212 .map(|opt| opt.map(|r| r.id)) 214 213 } else { 215 - let suffix = format!(".{}", hostname); 216 - let short_handle = if input.repo.ends_with(&suffix) { 217 - input.repo.strip_suffix(&suffix).unwrap_or(&input.repo) 214 + let handle = if !input.repo.contains('.') { 215 + format!("{}.{}", input.repo, hostname) 218 216 } else { 219 - &input.repo 217 + input.repo.clone() 220 218 }; 221 - sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle) 219 + sqlx::query!("SELECT id FROM users WHERE handle = $1", handle) 222 220 .fetch_optional(&state.db) 223 221 .await 224 222 .map(|opt| opt.map(|r| r.id))
+10 -12
src/api/server/session.rs
··· 29 29 } 30 30 31 31 fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { 32 - let suffix = format!(".{}", pds_hostname); 33 - if identifier.ends_with(&suffix) { 34 - identifier[..identifier.len() - suffix.len()].to_string() 32 + let identifier = identifier.trim(); 33 + if identifier.contains('@') || identifier.starts_with("did:") { 34 + identifier.to_string() 35 + } else if !identifier.contains('.') { 36 + format!("{}.{}", identifier.to_lowercase(), pds_hostname) 35 37 } else { 36 - identifier.to_string() 38 + identifier.to_lowercase() 37 39 } 38 40 } 39 41 40 - fn full_handle(stored_handle: &str, pds_hostname: &str) -> String { 41 - let suffix = format!(".{}", pds_hostname); 42 - if stored_handle.ends_with(&suffix) || stored_handle.ends_with(pds_hostname) { 43 - stored_handle.to_string() 44 - } else { 45 - format!("{}.{}", stored_handle, pds_hostname) 46 - } 42 + fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { 43 + stored_handle.to_string() 47 44 } 48 45 49 46 #[derive(Deserialize)] ··· 66 63 headers: HeaderMap, 67 64 Json(input): Json<CreateSessionInput>, 68 65 ) -> Response { 69 - info!("create_session called"); 66 + info!("create_session called with identifier: {}", input.identifier); 70 67 let client_ip = extract_client_ip(&headers); 71 68 if !state 72 69 .check_rate_limit(RateLimitKind::Login, &client_ip) ··· 84 81 } 85 82 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 86 83 let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname); 84 + info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier); 87 85 let row = match sqlx::query!( 88 86 r#"SELECT 89 87 u.id, u.did, u.handle, u.password_hash,
+100
src/api/validation.rs
··· 4 4 pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63; 5 5 const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-"; 6 6 7 + pub const MIN_HANDLE_LENGTH: usize = 3; 8 + pub const MAX_HANDLE_LENGTH: usize = 253; 9 + 10 + #[derive(Debug, PartialEq)] 11 + pub enum HandleValidationError { 12 + Empty, 13 + TooShort, 14 + TooLong, 15 + InvalidCharacters, 16 + StartsWithInvalidChar, 17 + EndsWithInvalidChar, 18 + ContainsSpaces, 19 + } 20 + 21 + impl std::fmt::Display for HandleValidationError { 22 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 + match self { 24 + Self::Empty => write!(f, "Handle cannot be empty"), 25 + Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH), 26 + Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH), 27 + Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"), 28 + Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"), 29 + Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"), 30 + Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), 31 + } 32 + } 33 + } 34 + 35 + pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> { 36 + let handle = handle.trim(); 37 + 38 + if handle.is_empty() { 39 + return Err(HandleValidationError::Empty); 40 + } 41 + 42 + if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { 43 + return Err(HandleValidationError::ContainsSpaces); 44 + } 45 + 46 + if handle.len() < MIN_HANDLE_LENGTH { 47 + return Err(HandleValidationError::TooShort); 48 + } 49 + 50 + if handle.len() > MAX_HANDLE_LENGTH { 51 + return Err(HandleValidationError::TooLong); 52 + } 53 + 54 + let first_char = handle.chars().next().unwrap(); 55 + if first_char == '-' || first_char == '_' { 56 + return Err(HandleValidationError::StartsWithInvalidChar); 57 + } 58 + 59 + let last_char = handle.chars().last().unwrap(); 60 + if last_char == '-' || last_char == '_' { 61 + return Err(HandleValidationError::EndsWithInvalidChar); 62 + } 63 + 64 + for c in handle.chars() { 65 + if !c.is_ascii_alphanumeric() && c != '-' && c != '_' { 66 + return Err(HandleValidationError::InvalidCharacters); 67 + } 68 + } 69 + 70 + Ok(handle.to_lowercase()) 71 + } 72 + 7 73 pub fn is_valid_email(email: &str) -> bool { 8 74 let email = email.trim(); 9 75 if email.is_empty() || email.len() > MAX_EMAIL_LENGTH { ··· 54 120 #[cfg(test)] 55 121 mod tests { 56 122 use super::*; 123 + 124 + #[test] 125 + fn test_valid_handles() { 126 + assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); 127 + assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string())); 128 + assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string())); 129 + assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string())); 130 + assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string())); 131 + assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string())); 132 + assert_eq!(validate_short_handle("abc"), Ok("abc".to_string())); 133 + } 134 + 135 + #[test] 136 + fn test_invalid_handles() { 137 + assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty)); 138 + assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty)); 139 + assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort)); 140 + assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort)); 141 + assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces)); 142 + assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces)); 143 + assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar)); 144 + assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar)); 145 + assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar)); 146 + assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar)); 147 + assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters)); 148 + assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters)); 149 + assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters)); 150 + } 151 + 152 + #[test] 153 + fn test_handle_trimming() { 154 + assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string())); 155 + } 156 + 57 157 #[test] 58 158 fn test_valid_emails() { 59 159 assert!(is_valid_email("user@example.com"));
+15 -17
src/oauth/endpoints/authorize.rs
··· 426 426 let normalized_username = normalized_username 427 427 .strip_prefix('@') 428 428 .unwrap_or(normalized_username); 429 - let normalized_username = if let Some(bare_handle) = 430 - normalized_username.strip_suffix(&format!(".{}", pds_hostname)) 431 - { 432 - bare_handle.to_string() 429 + let normalized_username = if normalized_username.contains('@') { 430 + normalized_username.to_string() 431 + } else if !normalized_username.contains('.') { 432 + format!("{}.{}", normalized_username, pds_hostname) 433 433 } else { 434 434 normalized_username.to_string() 435 435 }; ··· 1585 1585 Query(query): Query<CheckPasskeysQuery>, 1586 1586 ) -> Response { 1587 1587 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1588 - let normalized_identifier = query.identifier.trim(); 1589 - let normalized_identifier = normalized_identifier 1590 - .strip_prefix('@') 1591 - .unwrap_or(normalized_identifier); 1592 - let normalized_identifier = if let Some(bare_handle) = 1593 - normalized_identifier.strip_suffix(&format!(".{}", pds_hostname)) 1594 - { 1595 - bare_handle.to_string() 1588 + let identifier = query.identifier.trim(); 1589 + let identifier = identifier.strip_prefix('@').unwrap_or(identifier); 1590 + let normalized_identifier = if identifier.contains('@') || identifier.starts_with("did:") { 1591 + identifier.to_string() 1592 + } else if !identifier.contains('.') { 1593 + format!("{}.{}", identifier.to_lowercase(), pds_hostname) 1596 1594 } else { 1597 - normalized_identifier.to_string() 1595 + identifier.to_lowercase() 1598 1596 }; 1599 1597 1600 1598 let user = sqlx::query!( ··· 1695 1693 let normalized_username = normalized_username 1696 1694 .strip_prefix('@') 1697 1695 .unwrap_or(normalized_username); 1698 - let normalized_username = if let Some(bare_handle) = 1699 - normalized_username.strip_suffix(&format!(".{}", pds_hostname)) 1700 - { 1701 - bare_handle.to_string() 1696 + let normalized_username = if normalized_username.contains('@') { 1697 + normalized_username.to_string() 1698 + } else if !normalized_username.contains('.') { 1699 + format!("{}.{}", normalized_username, pds_hostname) 1702 1700 } else { 1703 1701 normalized_username.to_string() 1704 1702 };
+30 -29
tests/email_update.rs
··· 17 17 base_url: &str, 18 18 handle: &str, 19 19 email: &str, 20 - ) -> String { 20 + ) -> (String, String) { 21 21 let res = client 22 22 .post(format!( 23 23 "{}/xrpc/com.atproto.server.createAccount", ··· 33 33 .expect("Failed to create account"); 34 34 assert_eq!(res.status(), StatusCode::OK); 35 35 let body: Value = res.json().await.expect("Invalid JSON"); 36 - let did = body["did"].as_str().expect("No did"); 37 - common::verify_new_account(client, did).await 36 + let did = body["did"].as_str().expect("No did").to_string(); 37 + let jwt = common::verify_new_account(client, &did).await; 38 + (jwt, did) 38 39 } 39 40 40 41 #[tokio::test] ··· 44 45 let pool = get_pool().await; 45 46 let handle = format!("emailup_{}", uuid::Uuid::new_v4()); 46 47 let email = format!("{}@example.com", handle); 47 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 48 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 48 49 let new_email = format!("new_{}@example.com", handle); 49 50 let res = client 50 51 .post(format!( ··· 61 62 assert_eq!(body["tokenRequired"], true); 62 63 63 64 let verification = sqlx::query!( 64 - "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 65 - handle 65 + "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 66 + did 66 67 ) 67 68 .fetch_one(&pool) 68 69 .await ··· 84 85 .await 85 86 .expect("Failed to confirm email"); 86 87 assert_eq!(res.status(), StatusCode::OK); 87 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 88 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 88 89 .fetch_one(&pool) 89 90 .await 90 91 .expect("User not found"); 91 92 assert_eq!(user.email, Some(new_email)); 92 93 93 94 let verification = sqlx::query!( 94 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 95 - handle 95 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 96 + did 96 97 ) 97 98 .fetch_optional(&pool) 98 99 .await ··· 106 107 let base_url = common::base_url().await; 107 108 let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4()); 108 109 let email1 = format!("{}@example.com", handle1); 109 - let _ = create_verified_account(&client, &base_url, &handle1, &email1).await; 110 + let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 110 111 let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4()); 111 112 let email2 = format!("{}@example.com", handle2); 112 - let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await; 113 + let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; 113 114 let res = client 114 115 .post(format!( 115 116 "{}/xrpc/com.atproto.server.requestEmailUpdate", ··· 131 132 let base_url = common::base_url().await; 132 133 let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4()); 133 134 let email = format!("{}@example.com", handle); 134 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 135 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 135 136 let new_email = format!("new_{}@example.com", handle); 136 137 let res = client 137 138 .post(format!( ··· 166 167 let pool = get_pool().await; 167 168 let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4()); 168 169 let email = format!("{}@example.com", handle); 169 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 170 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 170 171 let new_email = format!("new_{}@example.com", handle); 171 172 let res = client 172 173 .post(format!( ··· 180 181 .expect("Failed to request email update"); 181 182 assert_eq!(res.status(), StatusCode::OK); 182 183 let verification = sqlx::query!( 183 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 184 - handle 184 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 185 + did 185 186 ) 186 187 .fetch_one(&pool) 187 188 .await ··· 209 210 let pool = get_pool().await; 210 211 let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4()); 211 212 let email = format!("{}@example.com", handle); 212 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 213 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 213 214 let new_email = format!("direct_{}@example.com", handle); 214 215 let res = client 215 216 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) ··· 219 220 .await 220 221 .expect("Failed to update email"); 221 222 assert_eq!(res.status(), StatusCode::OK); 222 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 223 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 223 224 .fetch_one(&pool) 224 225 .await 225 226 .expect("User not found"); ··· 232 233 let base_url = common::base_url().await; 233 234 let handle = format!("emailup_same_{}", uuid::Uuid::new_v4()); 234 235 let email = format!("{}@example.com", handle); 235 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 236 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 236 237 let res = client 237 238 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 238 239 .bearer_auth(&access_jwt) ··· 253 254 let base_url = common::base_url().await; 254 255 let handle = format!("emailup_token_{}", uuid::Uuid::new_v4()); 255 256 let email = format!("{}@example.com", handle); 256 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 257 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 257 258 let new_email = format!("pending_{}@example.com", handle); 258 259 let res = client 259 260 .post(format!( ··· 285 286 let pool = get_pool().await; 286 287 let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4()); 287 288 let email = format!("{}@example.com", handle); 288 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 289 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 289 290 let new_email = format!("valid_{}@example.com", handle); 290 291 let res = client 291 292 .post(format!( ··· 299 300 .expect("Failed to request email update"); 300 301 assert_eq!(res.status(), StatusCode::OK); 301 302 let verification = sqlx::query!( 302 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 303 - handle 303 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 304 + did 304 305 ) 305 306 .fetch_one(&pool) 306 307 .await ··· 317 318 .await 318 319 .expect("Failed to update email"); 319 320 assert_eq!(res.status(), StatusCode::OK); 320 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 321 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 321 322 .fetch_one(&pool) 322 323 .await 323 324 .expect("User not found"); 324 325 assert_eq!(user.email, Some(new_email)); 325 326 let verification = sqlx::query!( 326 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 327 - handle 327 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 328 + did 328 329 ) 329 330 .fetch_optional(&pool) 330 331 .await ··· 338 339 let base_url = common::base_url().await; 339 340 let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4()); 340 341 let email = format!("{}@example.com", handle); 341 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 342 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 342 343 let new_email = format!("badtok_{}@example.com", handle); 343 344 let res = client 344 345 .post(format!( ··· 372 373 let base_url = common::base_url().await; 373 374 let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4()); 374 375 let email1 = format!("{}@example.com", handle1); 375 - let _ = create_verified_account(&client, &base_url, &handle1, &email1).await; 376 + let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 376 377 let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4()); 377 378 let email2 = format!("{}@example.com", handle2); 378 - let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await; 379 + let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; 379 380 let res = client 380 381 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 381 382 .bearer_auth(&access_jwt2) ··· 412 413 let base_url = common::base_url().await; 413 414 let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4()); 414 415 let email = format!("{}@example.com", handle); 415 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 416 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 416 417 let res = client 417 418 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 418 419 .bearer_auth(&access_jwt)
+5 -4
tests/identity.rs
··· 8 8 #[tokio::test] 9 9 async fn test_resolve_handle_success() { 10 10 let client = client(); 11 - let handle = format!("resolvetest_{}", uuid::Uuid::new_v4()); 11 + let short_handle = format!("resolvetest_{}", uuid::Uuid::new_v4()); 12 12 let payload = json!({ 13 - "handle": handle, 14 - "email": format!("{}@example.com", handle), 13 + "handle": short_handle, 14 + "email": format!("{}@example.com", short_handle), 15 15 "password": "password" 16 16 }); 17 17 let res = client ··· 26 26 assert_eq!(res.status(), StatusCode::OK); 27 27 let body: Value = res.json().await.expect("Invalid JSON"); 28 28 let did = body["did"].as_str().expect("No DID").to_string(); 29 - let params = [("handle", handle.as_str())]; 29 + let full_handle = body["handle"].as_str().expect("No handle in response").to_string(); 30 + let params = [("handle", full_handle.as_str())]; 30 31 let res = client 31 32 .get(format!( 32 33 "{}/xrpc/com.atproto.identity.resolveHandle",