TokenEntropyTest.php 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Auth;
  4. use App\Domain\Auth\TokenIssuer;
  5. use App\Domain\Auth\TokenKind;
  6. use PHPUnit\Framework\TestCase;
  7. use ReflectionClass;
  8. /**
  9. * SPEC §M14.3: prove tokens carry the entropy the SPEC promises (160 bits
  10. * from a CSPRNG), and that the wire format is exactly
  11. * `irdb_<kind3>_<32 base32 chars>`.
  12. *
  13. * The "1000 distinct tokens" check is a probabilistic floor — at 160 bits,
  14. * the collision probability for 1000 samples is negligible (~1e-43), so
  15. * any failure here means something is very wrong upstream of `random_bytes`.
  16. */
  17. final class TokenEntropyTest extends TestCase
  18. {
  19. private const FORMAT = '/^irdb_(rep|con|adm|svc)_[A-Z2-7]{32}$/';
  20. public function testThousandTokensAllDistinct(): void
  21. {
  22. $issuer = new TokenIssuer();
  23. $set = [];
  24. for ($i = 0; $i < 1000; ++$i) {
  25. $set[$issuer->issue(TokenKind::Admin)] = true;
  26. }
  27. self::assertCount(1000, $set, 'expected 1000 distinct tokens; collision detected');
  28. }
  29. public function testEveryTokenMatchesPublishedFormat(): void
  30. {
  31. $issuer = new TokenIssuer();
  32. foreach (TokenKind::cases() as $kind) {
  33. for ($i = 0; $i < 50; ++$i) {
  34. $raw = $issuer->issue($kind);
  35. self::assertSame(
  36. 1,
  37. preg_match(self::FORMAT, $raw),
  38. "format mismatch for {$kind->value}: {$raw}"
  39. );
  40. self::assertStringStartsWith('irdb_' . $kind->code() . '_', $raw);
  41. }
  42. }
  43. }
  44. public function testIssuerSourcesEntropyFromRandomBytes(): void
  45. {
  46. // Static inspection: confirm `random_bytes` is the single source.
  47. // If a refactor swaps in `mt_rand`, `rand`, `microtime`, or anything
  48. // else not listed in PHP's CSPRNG API, this test fails loudly.
  49. $reflection = new ReflectionClass(TokenIssuer::class);
  50. $file = (string) $reflection->getFileName();
  51. self::assertNotEmpty($file);
  52. $source = (string) file_get_contents($file);
  53. self::assertStringContainsString('random_bytes(', $source, 'TokenIssuer must source entropy from random_bytes (CSPRNG)');
  54. $forbidden = ['mt_rand(', 'rand(', 'uniqid(', 'microtime(', 'srand('];
  55. foreach ($forbidden as $needle) {
  56. self::assertStringNotContainsString($needle, $source, "TokenIssuer must not use non-CSPRNG source: {$needle}");
  57. }
  58. }
  59. public function testRandomByteCountIsTwentyForOneSixtyBits(): void
  60. {
  61. // Re-derive 160 bits / 8 = 20 bytes and confirm the issuer asks
  62. // for that many. Catches future "let's use 16 bytes" regressions.
  63. $reflection = new ReflectionClass(TokenIssuer::class);
  64. $source = (string) file_get_contents((string) $reflection->getFileName());
  65. self::assertSame(
  66. 1,
  67. preg_match('/random_bytes\(\s*20\s*\)/', $source),
  68. 'TokenIssuer must request 20 bytes (160 bits) of entropy'
  69. );
  70. }
  71. }