Made the CSRF protection less needlessly complex.

This commit is contained in:
Pachira 2023-07-11 21:59:09 +00:00
parent f3535117ce
commit 828dc867a1
5 changed files with 109 additions and 300 deletions

View file

@ -1 +1 @@
0.2307.102242
0.2307.112158

View file

@ -1,104 +1,104 @@
<?php
// CSRFP.php
// Created: 2021-06-11
// Updated: 2022-02-27
// Updated: 2023-07-11
namespace Index\Security;
use Index\Serialisation\Serialiser;
/**
* Contains info shared between sessions as well as shorthand methods.
* Contains a mechanism for validating requests.
*/
class CSRFP {
private const TOLERANCE = 30 * 60;
private const EPOCH = 1619956800;
private const HASH_ALGO = 'sha1';
private const EPOCH = 1682985600;
private const HASH_ALGO = 'sha3-256';
private const TIMESTAMP_LENGTH = 4;
private const NONCE_LENGTH = 8;
private const HASH_LENGTH = 32;
private string $secretKey;
private string $identity;
private int $tolerance;
private int $epoch;
private string $hashAlgo;
private array $identities = [];
/**
* Creates a new CSRFP instance.
*
* @param string $secretKey Secret key for HMAC hashes.
* @param int $tolerance Amount of time a token should remain valid for.
* @param int $epoch First possible date and time.
* @param string $hashAlgo Hashing algorithm to use for HMAC.
* @param string $identity Unique identity component for these tokens.
* @param int $tolerance Default amount of time a token should remain valid for.
* @return CSRFP New CSRFP instance.
*/
public function __construct(
string $secretKey,
int $tolerance = self::TOLERANCE,
int $epoch = self::EPOCH,
string $hashAlgo = self::HASH_ALGO
string $identity,
int $tolerance = self::TOLERANCE
) {
$this->secretKey = $secretKey;
$this->identity = $identity;
$this->tolerance = $tolerance;
$this->epoch = $epoch;
$this->hashAlgo = $hashAlgo;
}
private static function time(int $time = -1): int {
return ($time < 0 ? time() : $time) - self::EPOCH;
}
private function createHash(int $time, string $nonce): string {
return hash_hmac(self::HASH_ALGO, "{$this->identity}!{$time}!{$nonce}", $this->secretKey, true);
}
/**
* Gets the amount of time a token should remain valid for.
* Creates a token string.
*
* @return int Token expiry tolerance.
*/
public function getTolerance(): int {
return $this->tolerance;
}
/**
* Gets an identity instance.
*
* @param string $identity String that represents the identity.
* @return CSRFPIdentity An identity instance representing the given string.
*/
public function getIdentity(string $identity): CSRFPIdentity {
return $this->identities[$identity] ?? ($this->identities[$identity] = new CSRFPIdentity($this, $identity));
}
/**
* Returns the time with the offset applied.
*
* @return int Current time, with offset.
*/
public function time(): int {
return time() - $this->epoch;
}
/**
* Creates a hash with the given arguments.
*
* @param string $identity Identity string to verify for.
* @param int $timestamp Timestamp to verify.
* @param int $tolerance Tolerance to verify.
* @return string Hash representing the given arguments.
*/
public function createHash(string $identity, int $timestamp, int $tolerance): string {
return hash_hmac($this->hashAlgo, "{$identity}!{$timestamp}!{$tolerance}", $this->secretKey, true);
}
/**
* Creates a token string using a given identity string.
*
* @param string $identity Identity to create token for.
* @param int $time Timestamp to generate the token for, -1 (default) for now.
* @return string Token string.
*/
public function createToken(string $identity): string {
return $this->getIdentity($identity)->createToken();
public function createToken(int $time = -1): string {
$time = self::time($time);
$nonce = random_bytes(self::NONCE_LENGTH);
$hash = $this->createHash($time, $nonce);
return Serialiser::uriBase64()->serialise(
pack('V', $time) . $nonce . $hash
);
}
/**
* Verifies a token string using a given identity string.
* Verifies a token string.
*
* @param string $identity Identity to test the token with.
* @param string $token Token to test.
* @param int $tolerance Amount of seconds for which the token can remain valid, < 0 for whatever the default value is.
* @param int $time Point in time for which to check validity for this time.
* @return bool true if the token is valid, false if not.
*/
public function verifyToken(string $identity, string $token): bool {
return $this->getIdentity($identity)->verifyToken(CSRFPToken::decode($token));
public function verifyToken(string $token, int $tolerance = -1, int $time = -1): bool {
if($tolerance === 0)
return false;
if($tolerance < 0)
$tolerance = $this->tolerance;
$token = Serialiser::uriBase64()->deserialise($token);
if(empty($token))
return false;
$uTime = -1;
extract(unpack('VuTime', $token));
if($uTime < 0)
return false;
$uNonce = substr($token, self::TIMESTAMP_LENGTH, self::NONCE_LENGTH);
if(empty($uNonce))
return false;
$uHash = substr($token, self::TIMESTAMP_LENGTH + self::NONCE_LENGTH, self::HASH_LENGTH);
if(empty($uHash))
return false;
$rTime = self::time($time);
return ($rTime - ($uTime + $tolerance)) < 0
&& hash_equals($this->createHash($uTime, $uNonce), $uHash);
}
}

View file

@ -1,79 +0,0 @@
<?php
// CSRFPIdentity.php
// Created: 2021-06-11
// Updated: 2022-02-02
namespace Index\Security;
/**
* Represents a CSRF prevention identity.
*/
class CSRFPIdentity {
private CSRFP $owner;
private string $identity;
/**
* Construct a new instance of CSRFPIdentity.
*
* @param CSRFP $owner Owner CSRFP instance.
* @param string $identity Identity string.
* @return CSRFPIdentity Instance representing the identity.
*/
public function __construct(CSRFP $owner, string $identity) {
$this->owner = $owner;
$this->identity = $identity;
}
/**
* Gets a reference to the owner CSRFP object.
*
* @return CSRFP Owner object.
*/
public function getOwner(): CSRFP {
return $this->owner;
}
/**
* Gets the string for this identity.
*/
public function getIdentity(): string {
return $this->identity;
}
/**
* Creates a new token using this identity.
*
* @return CSRFPToken Newly created token.
*/
public function createToken(): CSRFPToken {
$timestamp = $this->owner->time();
$tolerance = $this->owner->getTolerance();
$hash = $this->owner->createHash($this->identity, $timestamp, $tolerance);
return new CSRFPToken($timestamp, $tolerance, $hash);
}
/**
* Verifies a token using this identity.
*
* @param CSRFPToken $token Token to verify.
* @return bool true if the token is valid, false if not.
*/
public function verifyToken(CSRFPToken $token): bool {
$timestamp = $token->getTimestamp();
$tolerance = $token->getTolerance();
$tHash = $token->getHash();
// invalid for sure, defaults for decode failure
if($timestamp < 0 || $tolerance < 1 || empty($tHash))
return false;
$currentTime = $this->owner->time();
if($currentTime < $timestamp
|| $currentTime > ($timestamp + $tolerance))
return false;
$rHash = $this->owner->createHash($this->identity, $timestamp, $tolerance);
return hash_equals($rHash, $tHash);
}
}

View file

@ -1,101 +0,0 @@
<?php
// CSRFPToken.php
// Created: 2021-06-11
// Updated: 2022-02-27
namespace Index\Security;
use ErrorException;
use InvalidArgumentException;
use Stringable;
use Index\Serialisation\Serialiser;
/**
* Represents a CSRF prevention token.
*/
class CSRFPToken implements Stringable {
private int $timestamp;
private int $tolerance;
private string $hash;
/**
* Construct a CSRFPToken instance.
*
* @param int $timestamp Timestamp at which the token was generated.
* @param int $tolerance Period for how long this token is valid.
* @param string $hash A HMAC hash to prevent tampering.
* @return CSRFPToken A new instance of CSRFPToken.
*/
public function __construct(
int $timestamp,
int $tolerance,
string $hash
) {
$this->timestamp = $timestamp;
$this->tolerance = $tolerance;
$this->hash = $hash;
}
/**
* Gets the timestamp value of this token.
*
* @return int Timestamp for this token.
*/
public function getTimestamp(): int {
return $this->timestamp;
}
/**
* Gets the tolerance value of this token.
*
* @return int Tolerance for this token.
*/
public function getTolerance(): int {
return $this->tolerance;
}
/**
* Gets the hash of this token.
*
* @return string Hash for this token.
*/
public function getHash(): string {
return $this->hash;
}
/**
* Encodes the CSRFPToken instance as a token string.
*
* @return string CSRF prevention token string.
*/
public function encode(): string {
return Serialiser::uriBase64()->serialise(pack('Vv', $this->timestamp, $this->tolerance) . $this->hash);
}
/**
* Decodes a token string to a CSRFPToken instance.
*
* If an invalid token is provided, no exception will be thrown.
*
* @param string $token Input token string.
* @return CSRFPToken Instance representing the provided token.
*/
public static function decode(string $token): CSRFPToken {
$token = Serialiser::uriBase64()->deserialise($token);
try {
$decode = unpack('Vtimestamp/vtolerance', $token);
} catch(ErrorException $ex) {
$decode = [
'timestamp' => -1,
'tolerance' => 0,
];
}
// arbitrary length
$hash = substr($token, 6, 128);
return new CSRFPToken($decode['timestamp'], $decode['tolerance'], $hash);
}
public function __toString(): string {
return $this->encode();
}
}

View file

@ -1,79 +1,68 @@
<?php
// CSRFPTest.php
// Created: 2021-06-11
// Updated: 2022-02-03
// Updated: 2023-07-11
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use Index\Security\CSRFP;
use Index\Security\CSRFPIdentity;
use Index\Security\CSRFPToken;
/**
* @covers CSRFP
* @covers CSRFPIdentity
* @covers CSRFPToken
*/
final class CSRFPTest extends TestCase {
private const SECRET_1 = 'pli4v2MgGI1DHP66hIBGEXyt6iCahANp';
private const SECRET_2 = 'WRp3MEWpb2vbcNBsx50184PXCg7tfH4D';
private const SECRET_1 = 'oCtBmR7XS1dLOnGVoVi0wXwTLk7Ursn4';
private const SECRET_2 = 'v6H37MPa8NHmxVvL4AzNSxrWLVkiTfPHouyTto1LXOfOyoqSU7EaSorjdM4gXlq3';
private const TIMESTAMP_1 = 1689111761;
private const TIMESTAMP_2 = 1689025361;
private const P3M = 3 * 60;
private const P5M = 5 * 60;
private const P15M = 15 * 60;
private const P30M = 30 * 60;
private const P4M59S = (4 * 60) + 59;
private const P5M30S = (5 * 60) + 30;
private const P29M59S = (29 * 60) + 59;
public function testCSRFP(): void {
$csrfp1 = new CSRFP(self::SECRET_1);
$csrfp2 = new CSRFP(self::SECRET_2);
$csrfp1 = new CSRFP(self::SECRET_1, '8.8.8.8!12345');
$csrfp2 = new CSRFP(self::SECRET_2, '127.0.0.1');
$this->assertEquals(bin2hex($csrfp1->createHash('test', 1234, 12)), '965582c3bd762c22c18f99a5733cf9922166dbd2');
$this->assertEquals(bin2hex($csrfp2->createHash('test', 1234, 12)), '539b4e89e7313b91c66d5d18cc6e9ff6826cc7a2');
$token1 = $csrfp1->createToken(self::TIMESTAMP_1);
$token2 = $csrfp2->createToken(self::TIMESTAMP_1);
$token3 = $csrfp1->createToken(self::TIMESTAMP_2);
$token4 = $csrfp2->createToken(self::TIMESTAMP_2);
$token1 = $csrfp1->createToken('identity');
$token2 = $csrfp2->createToken('identity');
$token3 = $csrfp1->createToken('other');
$token4 = $csrfp2->createToken('other');
$this->assertNotEquals($token1, $token2);
$this->assertNotEquals($token2, $token3);
$this->assertNotEquals($token3, $token4);
$this->assertNotEquals($token4, $token1);
$this->assertTrue($csrfp1->verifyToken('identity', $token1));
$this->assertTrue($csrfp2->verifyToken('identity', $token2));
$this->assertTrue($csrfp1->verifyToken('other', $token3));
$this->assertTrue($csrfp2->verifyToken('other', $token4));
$this->assertTrue($csrfp1->verifyToken($token1, -1, self::TIMESTAMP_1 + self::P15M));
$this->assertTrue($csrfp2->verifyToken($token2, -1, self::TIMESTAMP_1 + self::P29M59S));
$this->assertTrue($csrfp1->verifyToken($token3, -1, self::TIMESTAMP_2 + self::P15M));
$this->assertTrue($csrfp2->verifyToken($token4, -1, self::TIMESTAMP_2 + self::P29M59S));
$this->assertFalse($csrfp2->verifyToken('identity', $token1));
$this->assertFalse($csrfp1->verifyToken('identity', $token2));
$this->assertFalse($csrfp2->verifyToken('other', $token3));
$this->assertFalse($csrfp1->verifyToken('other', $token4));
$this->assertFalse($csrfp2->verifyToken($token1, -1, self::TIMESTAMP_1 + self::P15M));
$this->assertFalse($csrfp1->verifyToken($token2, -1, self::TIMESTAMP_1 + self::P29M59S));
$this->assertFalse($csrfp2->verifyToken($token3, -1, self::TIMESTAMP_2 + self::P15M));
$this->assertFalse($csrfp1->verifyToken($token4, -1, self::TIMESTAMP_2 + self::P29M59S));
$this->assertFalse($csrfp1->verifyToken('other', $token1));
$this->assertFalse($csrfp2->verifyToken('other', $token2));
$this->assertFalse($csrfp1->verifyToken('identity', $token3));
$this->assertFalse($csrfp2->verifyToken('identity', $token4));
}
$this->assertFalse($csrfp1->verifyToken($token1, -1, self::TIMESTAMP_1 + self::P30M));
$this->assertFalse($csrfp2->verifyToken($token2, -1, self::TIMESTAMP_1 + self::P30M));
$this->assertFalse($csrfp1->verifyToken($token3, -1, self::TIMESTAMP_2 + self::P30M));
$this->assertFalse($csrfp2->verifyToken($token4, -1, self::TIMESTAMP_2 + self::P30M));
public function testTokenDecode(): void {
$token1 = CSRFPToken::decode('zCM1AAgHjTdDYLEcRgg5g0NHVsu69PTKurg'); // valid
$token2 = CSRFPToken::decode('AyQ1AAgHirhWJJJnQIwYKhWaF6zfv5NkhQ0'); // valid
$token3 = CSRFPToken::decode('KJFfkd39rrkf9Gs9g90sg90g3fdskfdsk34'); // random characters
$token4 = CSRFPToken::decode('zCM1AAgHjTdDY'); // incomplete data
$token5 = CSRFPToken::decode('AyQ'); // incomplete data
$token6 = CSRFPToken::decode(''); // empty
$this->assertTrue($csrfp1->verifyToken($token1, self::P5M, self::TIMESTAMP_1 + self::P3M));
$this->assertTrue($csrfp2->verifyToken($token2, self::P5M, self::TIMESTAMP_1 + self::P4M59S));
$this->assertTrue($csrfp1->verifyToken($token3, self::P5M, self::TIMESTAMP_2 + self::P3M));
$this->assertTrue($csrfp2->verifyToken($token4, self::P5M, self::TIMESTAMP_2 + self::P4M59S));
$this->assertEquals(bin2hex($token1->getHash()), '8d374360b11c46083983434756cbbaf4f4cabab8');
$this->assertEquals(bin2hex($token2->getHash()), '8ab856249267408c182a159a17acdfbf9364850d');
$this->assertEquals(bin2hex($token3->getHash()), 'aeb91ff46b3d83dd2c83dd20ddf76c91f76c937e');
$this->assertEquals(bin2hex($token4->getHash()), '8d3743'); // data may be incomplete, but there's still something
$this->assertEquals($token5->getHash(), '');
$this->assertEquals($token6->getHash(), '');
$this->assertEquals($token1->getTimestamp(), 3482572);
$this->assertEquals($token2->getTimestamp(), 3482627);
$this->assertEquals($token3->getTimestamp(), 2438959400);
$this->assertEquals($token4->getTimestamp(), 3482572);
$this->assertEquals($token5->getTimestamp(), -1);
$this->assertEquals($token6->getTimestamp(), -1);
$this->assertEquals($token1->getTolerance(), 1800);
$this->assertEquals($token2->getTolerance(), 1800);
$this->assertEquals($token3->getTolerance(), 64989);
$this->assertEquals($token4->getTolerance(), 1800);
$this->assertEquals($token5->getTolerance(), 0);
$this->assertEquals($token6->getTolerance(), 0);
$this->assertFalse($csrfp1->verifyToken($token1, self::P5M, self::TIMESTAMP_1 + self::P5M));
$this->assertFalse($csrfp2->verifyToken($token2, self::P5M, self::TIMESTAMP_1 + self::P5M30S));
$this->assertFalse($csrfp1->verifyToken($token3, self::P5M, self::TIMESTAMP_2 + self::P5M));
$this->assertFalse($csrfp2->verifyToken($token4, self::P5M, self::TIMESTAMP_2 + self::P5M30S));
}
}