Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<file>./tests/FileTest.php</file>
<file>./tests/ValidatorTest.php</file>
<file>./tests/UrlTest.php</file>
<file>./tests/HashTest.php</file>
<file>./tests/JwtTest.php</file>
<file>./tests/SessionTest.php</file>

Expand Down
55 changes: 38 additions & 17 deletions src/Hash.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
* - Hash comparison (timing-safe)
*
* @package Webrium
* @version 2.0.0
*/
class Hash
{
Expand Down Expand Up @@ -195,7 +194,10 @@ public static function digest(string $data, string $algorithm = 'sha256', bool $
}

/**
* Create an MD5 hash
* Create an MD5 hash.
*
* For checksums and non-security identifiers only. MD5 is cryptographically
* broken; never use it for passwords (use make()) or signatures (use hmac()).
*
* @param string $data The data to hash
* @param bool $binary Return binary output
Expand All @@ -207,7 +209,11 @@ public static function md5(string $data, bool $binary = false): string
}

/**
* Create a SHA-1 hash
* Create a SHA-1 hash.
*
* For checksums and non-security identifiers only. SHA-1 is no longer
* collision-resistant; never use it for passwords (use make()) or
* signatures (use hmac()).
*
* @param string $data The data to hash
* @param bool $binary Return binary output
Expand Down Expand Up @@ -284,17 +290,25 @@ public static function equals(string $known, string $user): bool
}

/**
* Generate a random hash (useful for tokens)
* Generate a random hexadecimal string of the requested length.
*
* The result is cryptographically secure and exactly $length characters
* long for any positive length, independent of any hash algorithm.
*
* @param int $length The desired length of the hash
* @param string $algorithm The hash algorithm to use
* @return string A random hash
* @param int $length The desired length of the output string (in characters)
* @return string A random hexadecimal string
* @throws \InvalidArgumentException If $length is not positive
*/
public static function random(int $length = 32, string $algorithm = 'sha256'): string
public static function random(int $length = 32): string
{
$bytes = random_bytes(max(16, ceil($length / 2)));
$hash = hash($algorithm, $bytes);
return substr($hash, 0, $length);
if ($length < 1) {
throw new \InvalidArgumentException('Length must be a positive integer.');
}

// Two hex characters per byte; request enough bytes to cover odd lengths.
$bytes = random_bytes((int) ceil($length / 2));

return substr(bin2hex($bytes), 0, $length);
}

/**
Expand Down Expand Up @@ -404,15 +418,18 @@ public static function argon2id(
}

/**
* Generate a secure token (URL-safe)
* Generate a secure random token as a hexadecimal string.
*
* The output is cryptographically secure, URL-safe (hex characters only),
* and exactly $length characters long.
*
* @param int $length The desired length of the token
* @param int $length The desired length of the token (in characters)
* @return string A secure random token
* @throws \InvalidArgumentException If $length is not positive
*/
public static function token(int $length = 32): string
{
$bytes = random_bytes(max(16, $length));
return substr(bin2hex($bytes), 0, $length);
return self::random($length);
}

/**
Expand All @@ -433,7 +450,11 @@ public static function uuid(): string
}

/**
* Create a hash with salt
* Create a salted hash.
*
* Uses HMAC with the salt as the key, which avoids length-extension
* weaknesses and salt/data boundary ambiguity that plain concatenation
* would introduce.
*
* @param string $data The data to hash
* @param string $salt The salt to add
Expand All @@ -442,7 +463,7 @@ public static function uuid(): string
*/
public static function salted(string $data, string $salt, string $algorithm = 'sha256'): string
{
return hash($algorithm, $data . $salt);
return self::hmac($data, $salt, $algorithm);
}

/**
Expand Down
258 changes: 258 additions & 0 deletions tests/HashTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
<?php

declare(strict_types=1);

namespace Tests;

use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Webrium\Hash;

/**
* Unit Tests for Webrium\Hash
*
* Coverage:
* - Password hashing and verification (make / check)
* - Rehashing helpers (needsRehash / checkAndRehash)
* - Algorithm-specific hashing (bcrypt / argon2i / argon2id)
* - Digest helpers (md5 / sha1 / sha256 / sha512 / digest)
* - HMAC generation and verification
* - Timing-safe comparison (equals)
* - Random token generation (random / token) including exact length and entropy
* - UUID v4 format
* - Salted (HMAC-based) and peppered hashing
* - Checksums
*/
class HashTest extends TestCase
{
// =========================================================================
// 1. Password hashing
// =========================================================================

public function testMakeProducesVerifiableHash(): void
{
$hash = Hash::make('correct-horse-battery-staple');

$this->assertNotSame('correct-horse-battery-staple', $hash);
$this->assertTrue(Hash::check('correct-horse-battery-staple', $hash));
}

public function testCheckRejectsWrongPassword(): void
{
$hash = Hash::make('secret');
$this->assertFalse(Hash::check('not-secret', $hash));
}

public function testCheckRejectsEmptyHash(): void
{
$this->assertFalse(Hash::check('secret', ''));
}

public function testMakeProducesDistinctHashesForSamePassword(): void
{
// Salting means two hashes of the same password must differ.
$this->assertNotSame(Hash::make('secret'), Hash::make('secret'));
}

public function testBcryptHashIsValid(): void
{
$hash = Hash::bcrypt('secret', 5);
$this->assertTrue(Hash::check('secret', $hash));
$this->assertSame('bcrypt', Hash::getAlgorithm($hash));
}

// =========================================================================
// 2. Rehashing
// =========================================================================

public function testNeedsRehashIsTrueWhenCostIncreases(): void
{
$hash = Hash::bcrypt('secret', 4);
// A hash made at cost 4 should need rehashing for a higher target cost.
$this->assertTrue(Hash::needsRehash($hash, PASSWORD_BCRYPT, ['cost' => 12]));
}

public function testCheckAndRehashReturnsNewHashWhenSettingsChange(): void
{
$hash = Hash::bcrypt('secret', 4);
$result = Hash::checkAndRehash('secret', $hash, PASSWORD_BCRYPT, ['cost' => 12]);

$this->assertTrue($result['verified']);
$this->assertNotNull($result['hash']);
$this->assertTrue(Hash::check('secret', $result['hash']));
}

public function testCheckAndRehashReturnsNullHashWhenInvalid(): void
{
$hash = Hash::bcrypt('secret', 4);
$result = Hash::checkAndRehash('wrong', $hash);

$this->assertFalse($result['verified']);
$this->assertNull($result['hash']);
}

// =========================================================================
// 3. Digests
// =========================================================================

public function testDigestLengthsAreCorrect(): void
{
$this->assertSame(32, strlen(Hash::md5('x')));
$this->assertSame(40, strlen(Hash::sha1('x')));
$this->assertSame(64, strlen(Hash::sha256('x')));
$this->assertSame(128, strlen(Hash::sha512('x')));
}

public function testDigestIsDeterministic(): void
{
$this->assertSame(Hash::sha256('payload'), Hash::sha256('payload'));
}

public function testIsAlgorithmSupported(): void
{
$this->assertTrue(Hash::isAlgorithmSupported('sha256'));
$this->assertFalse(Hash::isAlgorithmSupported('definitely-not-an-algo'));
}

// =========================================================================
// 4. HMAC and comparison
// =========================================================================

public function testHmacVerifies(): void
{
$sig = Hash::hmac('payload', 'secret-key');
$this->assertTrue(Hash::verifyHmac('payload', $sig, 'secret-key'));
}

public function testHmacFailsWithWrongKey(): void
{
$sig = Hash::hmac('payload', 'secret-key');
$this->assertFalse(Hash::verifyHmac('payload', $sig, 'other-key'));
}

public function testEqualsComparesContent(): void
{
$this->assertTrue(Hash::equals('abc', 'abc'));
$this->assertFalse(Hash::equals('abc', 'abd'));
$this->assertFalse(Hash::equals('abc', 'abcd'));
}

// =========================================================================
// 5. Random / token (regression cover for the random() length bug)
// =========================================================================

/**
* @dataProvider lengthProvider
*/
public function testRandomReturnsExactLength(int $length): void
{
$this->assertSame($length, strlen(Hash::random($length)));
}

/**
* @dataProvider lengthProvider
*/
public function testTokenReturnsExactLength(int $length): void
{
$this->assertSame($length, strlen(Hash::token($length)));
}

public static function lengthProvider(): array
{
// Includes odd lengths and lengths beyond a hash digest's hex size,
// which previously triggered a TypeError or silent truncation.
return [
'one' => [1],
'odd small' => [15],
'odd 31' => [31],
'even 32' => [32],
'odd 63' => [63],
'beyond sha256' => [100],
'large 128' => [128],
'large odd 201' => [201],
];
}

public function testRandomIsHexadecimal(): void
{
$this->assertMatchesRegularExpression('/^[0-9a-f]+$/', Hash::random(64));
}

public function testRandomValuesAreUnique(): void
{
$this->assertNotSame(Hash::random(64), Hash::random(64));
}

public function testRandomRejectsNonPositiveLength(): void
{
$this->expectException(InvalidArgumentException::class);
Hash::random(0);
}

public function testTokenRejectsNonPositiveLength(): void
{
$this->expectException(InvalidArgumentException::class);
Hash::token(-5);
}

public function testUniqueValuesDiffer(): void
{
$this->assertNotSame(Hash::unique('user_'), Hash::unique('user_'));
}

// =========================================================================
// 6. UUID
// =========================================================================

public function testUuidHasValidV4Format(): void
{
$uuid = Hash::uuid();

$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
$uuid
);
}

public function testUuidValuesAreUnique(): void
{
$this->assertNotSame(Hash::uuid(), Hash::uuid());
}

// =========================================================================
// 7. Salted / peppered (regression cover for HMAC-based salting)
// =========================================================================

public function testSaltedUsesHmacNotPlainConcatenation(): void
{
// The hardened salted() must not equal a naive hash of data . salt.
$this->assertSame(Hash::hmac('data', 'salt'), Hash::salted('data', 'salt'));
$this->assertNotSame(hash('sha256', 'datasalt'), Hash::salted('data', 'salt'));
}

public function testSaltedDependsOnSalt(): void
{
$this->assertNotSame(Hash::salted('data', 'salt-a'), Hash::salted('data', 'salt-b'));
}

public function testPepperedMatchesHmac(): void
{
$this->assertSame(Hash::hmac('data', 'pepper'), Hash::peppered('data', 'pepper'));
}

// =========================================================================
// 8. Checksums
// =========================================================================

public function testChecksumVerifies(): void
{
$sum = Hash::checksum('important-data');
$this->assertTrue(Hash::verifyChecksum('important-data', $sum));
}

public function testChecksumDetectsModification(): void
{
$sum = Hash::checksum('important-data');
$this->assertFalse(Hash::verifyChecksum('tampered-data', $sum));
}
}