+8
-8
TODO.md
+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
+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
+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
+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
-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
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
-3
migrations/20251215_add_identity_account_fields.sql
-12
migrations/20251216_add_channel_verification.sql
-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
-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
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
-6
migrations/20251219_rename_email_confirmed.sql
-27
migrations/20251220_rename_notifications_to_comms.sql
-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
-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
-2
migrations/20251222_add_did_web_migration_tracking.sql
-42
migrations/20251223_add_passkeys_totp.sql
-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
+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
+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
+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
+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
-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
+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
+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
+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
+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"));
+30
-29
tests/email_update.rs
+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
+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",