@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

When accepting a TOTP response, require it respond explicitly to a specific challenge

Summary:
Depends on D19890. Ref T13222. See PHI873. Currently, we only validate TOTP responses against the current (realtime) timestep. Instead, also validate them against a specific challenge.

This mostly just moves us toward more specifically preventing responses from being reused, and supporting flows which must look more like this (SMS/push).

One rough edge here is that during the T+3 and T+4 windows (you request a prompt, then wait 60-120 seconds to respond) only past responses actually work (the current code on your device won't). For example:

- At T+0, you request MFA. We issue a T+0 challenge that accepts codes T-2, T-1, T+0, T+1, and T+2. The challenge locks out T+3 and T+4 to prevent the window from overlapping with the next challenge we may issue (see D19890).
- If you wait 60 seconds until T+3 to actually submit a code, the realtime valid responses are T+1, T+2, T+3, T+4, T+5. The challenge valid responses are T-2, T-1, T+0, T+1, and T+2. Only T+1 and T+2 are in the intersection. Your device is showing T+3 if the clock is right, so if you type in what's shown on your device it won't be accepted.
- This //may// get refined in future changes, but, in the worst case, it's probably fine if it doesn't. Beyond 120s you'll get a new challenge and a full [-2, ..., +2] window to respond, so this lockout is temporary even if you manage to hit it.
- If this //doesn't// get refined, I'll change the UI to say "This factor recently issued a challenge which has expired, wait N seconds." to smooth this over a bit.

Test Plan:
- Went through MFA.
- Added a new TOTP factor.
- Hit some error cases on purpose.
- Tried to use an old code a moment after it expired, got rejected.
- Waited 60+ seconds, tried to use the current displayed factor, got rejected (this isn't great, but currently expected).

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13222

Differential Revision: https://secure.phabricator.com/D19893

+69 -31
+69 -31
src/applications/auth/factor/PhabricatorTOTPAuthFactor.php
··· 77 77 78 78 $e_code = true; 79 79 if ($request->getExists('totp')) { 80 - $okay = $this->verifyTOTPCode( 81 - $user, 80 + $okay = (bool)$this->getTimestepAtWhichResponseIsValid( 81 + $this->getAllowedTimesteps($this->getCurrentTimestep()), 82 82 new PhutilOpaqueEnvelope($key), 83 - $code); 83 + (string)$code); 84 84 85 85 if ($okay) { 86 86 $config = $this->newConfigForUser($user) ··· 240 240 $engine = $config->getSessionEngine(); 241 241 $workflow_key = $engine->getWorkflowKey(); 242 242 243 + $current_timestep = $this->getCurrentTimestep(); 244 + 243 245 foreach ($challenges as $challenge) { 244 246 $challenge_timestep = (int)$challenge->getChallengeKey(); 245 247 $wait_duration = ($challenge->getChallengeTTL() - $now) + 1; ··· 265 267 'again.', 266 268 new PhutilNumber($wait_duration))); 267 269 } 270 + 271 + // If the current realtime timestep isn't a valid response to the current 272 + // challenge but the challenge hasn't expired yet, we're locking out 273 + // the factor to prevent challenge windows from overlapping. Let the user 274 + // know that they should wait for a new challenge. 275 + $challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep); 276 + if (!isset($challenge_timesteps[$current_timestep])) { 277 + return $this->newResult() 278 + ->setIsWait(true) 279 + ->setErrorMessage( 280 + pht( 281 + 'This factor recently issued a challenge which has expired. '. 282 + 'A new challenge can not be issued yet. Wait %s second(s) for '. 283 + 'the code to cycle, then try again.', 284 + new PhutilNumber($wait_duration))); 285 + } 268 286 } 269 287 270 288 return null; ··· 277 295 array $challenges) { 278 296 279 297 $code = $request->getStr($this->getParameterName($config, 'totpcode')); 280 - $key = new PhutilOpaqueEnvelope($config->getFactorSecret()); 281 298 282 299 $result = $this->newResult() 283 300 ->setValue($code); 284 301 285 - if ($this->verifyTOTPCode($viewer, $key, (string)$code)) { 302 + // We expect to reach TOTP validation with exactly one valid challenge. 303 + if (count($challenges) !== 1) { 304 + throw new Exception( 305 + pht( 306 + 'Reached TOTP challenge validation with an unexpected number of '. 307 + 'unexpired challenges (%d), expected exactly one.', 308 + phutil_count($challenges))); 309 + } 310 + 311 + $challenge = head($challenges); 312 + 313 + $challenge_timestep = (int)$challenge->getChallengeKey(); 314 + $current_timestep = $this->getCurrentTimestep(); 315 + 316 + $challenge_timesteps = $this->getAllowedTimesteps($challenge_timestep); 317 + $current_timesteps = $this->getAllowedTimesteps($current_timestep); 318 + 319 + // We require responses be both valid for the challenge and valid for the 320 + // current timestep. A longer challenge TTL doesn't let you use older 321 + // codes for a longer period of time. 322 + $valid_timestep = $this->getTimestepAtWhichResponseIsValid( 323 + array_intersect_key($challenge_timesteps, $current_timesteps), 324 + new PhutilOpaqueEnvelope($config->getFactorSecret()), 325 + (string)$code); 326 + 327 + if ($valid_timestep) { 286 328 $result->setIsValid(true); 287 329 } else { 288 330 if (strlen($code)) { ··· 299 341 public static function generateNewTOTPKey() { 300 342 return strtoupper(Filesystem::readRandomCharacters(32)); 301 343 } 302 - 303 - private function verifyTOTPCode( 304 - PhabricatorUser $user, 305 - PhutilOpaqueEnvelope $key, 306 - $code) { 307 - 308 - $now = (int)(time() / 30); 309 - 310 - // Allow the user to enter a code a few minutes away on either side, in 311 - // case the server or client has some clock skew. 312 - for ($offset = -2; $offset <= 2; $offset++) { 313 - $real = self::getTOTPCode($key, $now + $offset); 314 - if (phutil_hashes_are_identical($real, $code)) { 315 - return true; 316 - } 317 - } 318 - 319 - // TODO: After validating a code, this should mark it as used and prevent 320 - // it from being reused. 321 - 322 - return false; 323 - } 324 - 325 344 326 345 public static function base32Decode($buf) { 327 346 $buf = strtoupper($buf); ··· 424 443 return (int)(PhabricatorTime::getNow() / $duration); 425 444 } 426 445 427 - private function getAllowedTimesteps() { 428 - $current_step = $this->getCurrentTimestep(); 446 + private function getAllowedTimesteps($at_timestep) { 429 447 $window = $this->getTimestepWindowSize(); 430 - return range($current_step - $window, $current_step + $window); 448 + $range = range($at_timestep - $window, $at_timestep + $window); 449 + return array_fuse($range); 431 450 } 432 451 433 452 private function getTimestepWindowSize() { 453 + // The user is allowed to provide a code from the recent past or the 454 + // near future to account for minor clock skew between the client 455 + // and server, and the time it takes to actually enter a code. 434 456 return 2; 435 457 } 458 + 459 + private function getTimestepAtWhichResponseIsValid( 460 + array $timesteps, 461 + PhutilOpaqueEnvelope $key, 462 + $code) { 463 + 464 + foreach ($timesteps as $timestep) { 465 + $expect_code = self::getTOTPCode($key, $timestep); 466 + if (phutil_hashes_are_identical($code, $expect_code)) { 467 + return $timestep; 468 + } 469 + } 470 + 471 + return null; 472 + } 473 + 436 474 437 475 438 476 }