Made the CSRF protection less needlessly complex.
This commit is contained in:
parent
f3535117ce
commit
828dc867a1
5 changed files with 109 additions and 300 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
0.2307.102242
|
||||
0.2307.112158
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue