synckit: close 2FA login oracle + serialize rotation-complete (ultra-fuzz Run 3 Security #4)
Phase 2 of the SyncKit ultra-fuzz remediation (Security axis A- -> A).
#4 SERIOUS — /api/sync/auth was a lockout-free password-confirmation oracle for
2FA accounts: a wrong password incremented the failed-login counter, but a
correct password on a suspended/locked/2FA account returned 401 without touching
it, so the counter distinguished "correct-but-blocked" from "wrong". sync_auth
now accounts every correct-password-but-denied outcome identically to a wrong
password (increment lockout + 401), collapsing the branches into one uniform
deny path. Closes the oracle for the whole blocked-account class, not just 2FA.
complete_key_rotation — the remaining-entries count that authorizes the key swap
ran on the pool, outside the swap transaction, so it could go stale against a
concurrent rotation_batch. The rotation fetch (now FOR UPDATE), the count, and
the swap+delete are one transaction; the authorizing read is serialized with the
write.
Test: correct password on a 2FA account returns 401 AND increments
failed_login_attempts (indistinguishable from a wrong password). 50/50 synckit
tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 files changed,
+71 insertions,
-28 deletions
| 207 |
207 |
|
user_id: UserId,
|
| 208 |
208 |
|
rotation_id: uuid::Uuid,
|
| 209 |
209 |
|
) -> Result<std::result::Result<i32, i64>> {
|
|
210 |
+ |
// Lock the rotation row and run the remaining-entries check, the key swap,
|
|
211 |
+ |
// and the rotation delete in ONE transaction. The `FOR UPDATE` serializes
|
|
212 |
+ |
// this against a concurrent `rotation_batch` for the same rotation, so the
|
|
213 |
+ |
// count that authorizes the swap can't go stale between the read and the
|
|
214 |
+ |
// write (the prior code counted on the pool, outside the swap tx).
|
|
215 |
+ |
let mut tx = pool.begin().await?;
|
|
216 |
+ |
|
| 210 |
217 |
|
let rotation = sqlx::query_as::<_, crate::db::models::DbSyncKeyRotation>(
|
| 211 |
|
- |
"SELECT * FROM sync_key_rotations WHERE id = $1 AND app_id = $2 AND user_id = $3",
|
|
218 |
+ |
"SELECT * FROM sync_key_rotations WHERE id = $1 AND app_id = $2 AND user_id = $3 FOR UPDATE",
|
| 212 |
219 |
|
)
|
| 213 |
220 |
|
.bind(rotation_id)
|
| 214 |
221 |
|
.bind(app_id)
|
| 215 |
222 |
|
.bind(user_id)
|
| 216 |
|
- |
.fetch_optional(pool)
|
|
223 |
+ |
.fetch_optional(&mut *tx)
|
| 217 |
224 |
|
.await?;
|
| 218 |
225 |
|
|
| 219 |
226 |
|
let Some(rotation) = rotation else {
|
| 235 |
242 |
|
.bind(user_id)
|
| 236 |
243 |
|
.bind(rotation.new_key_id)
|
| 237 |
244 |
|
.bind(rotation.target_seq)
|
| 238 |
|
- |
.fetch_one(pool)
|
|
245 |
+ |
.fetch_one(&mut *tx)
|
| 239 |
246 |
|
.await?;
|
| 240 |
247 |
|
|
| 241 |
248 |
|
if remaining > 0 {
|
| 242 |
249 |
|
return Ok(Err(remaining));
|
| 243 |
250 |
|
}
|
| 244 |
251 |
|
|
| 245 |
|
- |
// Atomically swap key and delete rotation
|
| 246 |
|
- |
let mut tx = pool.begin().await?;
|
| 247 |
|
- |
|
|
252 |
+ |
// Swap key and delete rotation (same transaction as the authorizing count).
|
| 248 |
253 |
|
sqlx::query(
|
| 249 |
254 |
|
r#"
|
| 250 |
255 |
|
UPDATE sync_keys
|
| 83 |
83 |
|
};
|
| 84 |
84 |
|
|
| 85 |
85 |
|
let valid = crate::auth::verify_password(&req.password, &user.password_hash)?;
|
| 86 |
|
- |
if !valid {
|
| 87 |
|
- |
// Track failed attempts for lockout (atomic increment + lock)
|
|
86 |
+ |
|
|
87 |
+ |
// Account-status checks run after verify_password to avoid timing oracles.
|
|
88 |
+ |
// A correct password that still can't complete login here — suspended,
|
|
89 |
+ |
// locked, or 2FA-gated (2FA users must use the OAuth flow) — is accounted
|
|
90 |
+ |
// and answered EXACTLY like a wrong password: increment the failed-login
|
|
91 |
+ |
// counter and return 401. Otherwise the counter is an oracle that confirms
|
|
92 |
+ |
// the password of a 2FA/locked/suspended account (wrong guesses increment,
|
|
93 |
+ |
// a correct-but-blocked guess would not). Returning 401 (not 400) also
|
|
94 |
+ |
// avoids leaking 2FA status. (ultra-fuzz Run 3 SECURITY #4.)
|
|
95 |
+ |
let denied = !valid
|
|
96 |
+ |
|| user.is_suspended()
|
|
97 |
+ |
|| user
|
|
98 |
+ |
.locked_until
|
|
99 |
+ |
.is_some_and(|locked_until| locked_until > chrono::Utc::now())
|
|
100 |
+ |
|| user.totp_enabled;
|
|
101 |
+ |
|
|
102 |
+ |
if denied {
|
| 88 |
103 |
|
db::auth::increment_failed_login(
|
| 89 |
104 |
|
&state.db, user.id,
|
| 90 |
105 |
|
crate::constants::MAX_LOGIN_ATTEMPTS,
|
| 93 |
108 |
|
return Err(AppError::Unauthorized);
|
| 94 |
109 |
|
}
|
| 95 |
110 |
|
|
| 96 |
|
- |
// Password is correct — now check account status.
|
| 97 |
|
- |
// These checks happen after verify_password to avoid timing oracles.
|
| 98 |
|
- |
|
| 99 |
|
- |
if user.is_suspended() {
|
| 100 |
|
- |
return Err(AppError::Unauthorized);
|
| 101 |
|
- |
}
|
| 102 |
|
- |
|
| 103 |
|
- |
if let Some(locked_until) = user.locked_until
|
| 104 |
|
- |
&& locked_until > chrono::Utc::now()
|
| 105 |
|
- |
{
|
| 106 |
|
- |
return Err(AppError::Unauthorized);
|
| 107 |
|
- |
}
|
| 108 |
|
- |
|
| 109 |
|
- |
// Reject accounts with 2FA enabled — they must use the OAuth flow.
|
| 110 |
|
- |
// Returns 401 (not 400) to avoid leaking 2FA status to attackers who
|
| 111 |
|
- |
// guessed the password.
|
| 112 |
|
- |
if user.totp_enabled {
|
| 113 |
|
- |
return Err(AppError::Unauthorized);
|
| 114 |
|
- |
}
|
| 115 |
|
- |
|
| 116 |
111 |
|
// Successful auth — reset failed login counter
|
| 117 |
112 |
|
db::auth::reset_failed_login(&state.db, user.id).await?;
|
| 118 |
113 |
|
|
| 373 |
373 |
|
.await;
|
| 374 |
374 |
|
assert_eq!(resp.status, 400, "Expected 400 for empty changes: {}", resp.text);
|
| 375 |
375 |
|
}
|
|
376 |
+ |
|
|
377 |
+ |
/// A correct password on a 2FA-enabled account must be accounted exactly like a
|
|
378 |
+ |
/// wrong password: 401 AND the failed-login counter increments. Otherwise the
|
|
379 |
+ |
/// counter is an oracle that confirms the password of a 2FA account (a wrong
|
|
380 |
+ |
/// guess increments, a correct-but-2FA guess would not). ultra-fuzz Run 3 #4.
|
|
381 |
+ |
#[tokio::test]
|
|
382 |
+ |
async fn sync_auth_2fa_account_increments_lockout_like_wrong_password() {
|
|
383 |
+ |
let mut h = TestHarness::new().await;
|
|
384 |
+ |
let user_id = h.signup("tfa_user", "tfa@example.com", "Password1!").await;
|
|
385 |
+ |
let (_app_id, api_key) = create_sync_app_for_user(&h.db, user_id).await;
|
|
386 |
+ |
|
|
387 |
+ |
// Enable 2FA on the account.
|
|
388 |
+ |
sqlx::query("UPDATE users SET totp_enabled = TRUE WHERE id = $1")
|
|
389 |
+ |
.bind(user_id)
|
|
390 |
+ |
.execute(&h.db)
|
|
391 |
+ |
.await
|
|
392 |
+ |
.expect("enable totp");
|
|
393 |
+ |
|
|
394 |
+ |
// Correct password, but 2FA-gated → 401, not a token.
|
|
395 |
+ |
let resp = h
|
|
396 |
+ |
.client
|
|
397 |
+ |
.post_json(
|
|
398 |
+ |
"/api/sync/auth",
|
|
399 |
+ |
&json!({
|
|
400 |
+ |
"email": "tfa@example.com",
|
|
401 |
+ |
"password": "Password1!",
|
|
402 |
+ |
"api_key": api_key,
|
|
403 |
+ |
"key": "test-sdk-key",
|
|
404 |
+ |
})
|
|
405 |
+ |
.to_string(),
|
|
406 |
+ |
)
|
|
407 |
+ |
.await;
|
|
408 |
+ |
assert_eq!(resp.status, 401, "2FA account must not get a token: {}", resp.text);
|
|
409 |
+ |
|
|
410 |
+ |
// The lockout counter incremented, exactly as a wrong password would — no
|
|
411 |
+ |
// oracle distinguishing "correct password + 2FA" from "wrong password".
|
|
412 |
+ |
let attempts: i32 = sqlx::query_scalar("SELECT failed_login_attempts FROM users WHERE id = $1")
|
|
413 |
+ |
.bind(user_id)
|
|
414 |
+ |
.fetch_one(&h.db)
|
|
415 |
+ |
.await
|
|
416 |
+ |
.expect("read failed_login_attempts");
|
|
417 |
+ |
assert_eq!(attempts, 1, "correct-password-on-2FA must increment the lockout counter");
|
|
418 |
+ |
}
|