service = new TotpService; }); describe('TotpService', function () { describe('generateSecretKey()', function () { it('returns a valid base32 string', function () { $secret = $this->service->generateSecretKey(); expect($secret)->toMatch('/^[A-Z2-7]+$/'); }); it('returns a secret of expected length for 160-bit key', function () { $secret = $this->service->generateSecretKey(); // 160 bits / 5 bits per base32 char = 32 chars expect(strlen($secret))->toBe(32); }); it('generates unique secrets on each call', function () { $secrets = []; for ($i = 0; $i < 50; $i++) { $secrets[] = $this->service->generateSecretKey(); } expect(array_unique($secrets))->toHaveCount(50); }); }); describe('qrCodeUrl()', function () { it('produces a valid otpauth URI', function () { $url = $this->service->qrCodeUrl('MyApp', 'user@example.com', 'JBSWY3DPEHPK3PXP'); expect($url)->toStartWith('otpauth://totp/') ->and($url)->toContain('secret=JBSWY3DPEHPK3PXP') ->and($url)->toContain('issuer=MyApp') ->and($url)->toContain('algorithm=SHA1') ->and($url)->toContain('digits=6') ->and($url)->toContain('period=30'); }); it('URL-encodes the application name with special characters', function () { $url = $this->service->qrCodeUrl('My App & Co.', 'user@example.com', 'SECRET'); expect($url)->toContain(rawurlencode('My App & Co.')); }); it('URL-encodes the email address', function () { $url = $this->service->qrCodeUrl('App', 'user+tag@example.com', 'SECRET'); expect($url)->toContain(rawurlencode('user+tag@example.com')); }); it('handles empty application name', function () { $url = $this->service->qrCodeUrl('', 'user@example.com', 'SECRET'); expect($url)->toStartWith('otpauth://totp/'); }); it('handles unicode characters in name and email', function () { $url = $this->service->qrCodeUrl('Ünïcödé', 'umlaut@über.de', 'SECRET'); expect($url)->toStartWith('otpauth://totp/') ->and($url)->toContain(rawurlencode('Ünïcödé')); }); }); describe('verify() — code format edge cases', function () { it('rejects an empty string', function () { expect($this->service->verify('JBSWY3DPEHPK3PXP', ''))->toBeFalse(); }); it('rejects a code that is too short', function () { expect($this->service->verify('JBSWY3DPEHPK3PXP', '12345'))->toBeFalse(); }); it('rejects a code that is too long', function () { expect($this->service->verify('JBSWY3DPEHPK3PXP', '1234567'))->toBeFalse(); }); it('rejects purely alphabetic input', function () { expect($this->service->verify('JBSWY3DPEHPK3PXP', 'abcdef'))->toBeFalse(); }); it('strips spaces from a formatted code before validating length', function () { // '123 456' after stripping non-digits becomes '123456' (6 digits) // It should not be rejected for length — only for incorrect OTP value $result = $this->service->verify('JBSWY3DPEHPK3PXP', '123 456'); // The code is the right length once cleaned, but almost certainly wrong // We just confirm it does not crash and returns a boolean expect($result)->toBeBool(); }); it('strips dashes from a formatted code before validating length', function () { $result = $this->service->verify('JBSWY3DPEHPK3PXP', '123-456'); expect($result)->toBeBool(); }); it('rejects a code that becomes too short after stripping non-digits', function () { // 'abc12' strips to '12' which is too short expect($this->service->verify('JBSWY3DPEHPK3PXP', 'abc12'))->toBeFalse(); }); it('rejects a code consisting solely of whitespace', function () { expect($this->service->verify('JBSWY3DPEHPK3PXP', ' '))->toBeFalse(); }); }); describe('verify() — correct code validation with known secret', function () { /** * Helper: generate a valid TOTP code for the given secret and timestamp * by invoking the protected generateCode() method via reflection. */ beforeEach(function () { $this->generateCodeAt = function (string $secret, int $timestamp): string { $reflection = new ReflectionMethod(TotpService::class, 'generateCode'); $reflection->setAccessible(true); // base32Decode the secret first, same as verify() does $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $secretBytes = $decodeMethod->invoke($this->service, $secret); return $reflection->invoke($this->service, $secretBytes, $timestamp); }; }); it('accepts a code generated for the current time window', function () { $secret = 'JBSWY3DPEHPK3PXP'; $now = time(); $code = ($this->generateCodeAt)($secret, $now); expect($this->service->verify($secret, $code))->toBeTrue(); }); it('rejects a completely wrong code', function () { $secret = 'JBSWY3DPEHPK3PXP'; $now = time(); $validCode = ($this->generateCodeAt)($secret, $now); // Flip every digit so it's definitely wrong $wrongCode = ''; for ($i = 0; $i < strlen($validCode); $i++) { $wrongCode .= ((int) $validCode[$i] + 5) % 10; } expect($this->service->verify($secret, $wrongCode))->toBeFalse(); }); it('accepts a code from the previous time window (clock drift -30s)', function () { $secret = 'JBSWY3DPEHPK3PXP'; $now = time(); $pastCode = ($this->generateCodeAt)($secret, $now - 30); expect($this->service->verify($secret, $pastCode))->toBeTrue(); }); it('accepts a code from the next time window (clock drift +30s)', function () { $secret = 'JBSWY3DPEHPK3PXP'; $now = time(); $futureCode = ($this->generateCodeAt)($secret, $now + 30); expect($this->service->verify($secret, $futureCode))->toBeTrue(); }); it('rejects a code from two time windows ago (beyond drift tolerance)', function () { $secret = 'JBSWY3DPEHPK3PXP'; // Go back far enough that it falls outside the WINDOW=1 tolerance. // Align to the start of a period so the -2 window is definitively out. $now = time(); $periodStart = $now - ($now % 30); // A code from 3 full periods ago is guaranteed out of WINDOW=1 $oldCode = ($this->generateCodeAt)($secret, $periodStart - 90); // Only valid if it happens to collide with the current window, // which is astronomically unlikely expect($this->service->verify($secret, $oldCode))->toBeFalse(); }); it('handles a code with leading zeros', function () { // Use reflection to generate a code, which already zero-pads, // then verify it round-trips correctly $secret = 'JBSWY3DPEHPK3PXP'; $code = ($this->generateCodeAt)($secret, time()); expect(strlen($code))->toBe(6); expect($this->service->verify($secret, $code))->toBeTrue(); }); }); describe('verify() — malformed and invalid base32 secrets', function () { it('handles a lowercase secret by treating it case-insensitively', function () { $secret = 'JBSWY3DPEHPK3PXP'; $now = time(); // Generate code with uppercase secret $reflection = new ReflectionMethod(TotpService::class, 'generateCode'); $reflection->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $secretBytes = $decodeMethod->invoke($this->service, $secret); $code = $reflection->invoke($this->service, $secretBytes, $now); // Verify using lowercase secret — base32Decode uppercases internally expect($this->service->verify(strtolower($secret), $code))->toBeTrue(); }); it('handles a secret with trailing padding characters', function () { $secret = 'JBSWY3DPEHPK3PXP===='; $now = time(); $reflection = new ReflectionMethod(TotpService::class, 'generateCode'); $reflection->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $secretBytes = $decodeMethod->invoke($this->service, $secret); $code = $reflection->invoke($this->service, $secretBytes, $now); expect($this->service->verify($secret, $code))->toBeTrue(); }); it('does not throw on an empty secret', function () { // An empty secret decodes to empty bytes; the HMAC will still compute, // but the code won't match a random guess expect($this->service->verify('', '000000'))->toBeBool(); }); it('silently skips invalid base32 characters (digits 0, 1, 8, 9)', function () { // Characters 0, 1, 8, 9 are not in the base32 alphabet. // base32Decode skips them, so 'JBSWY3DP01890189' decodes // to fewer bytes than expected but should not throw. expect($this->service->verify('JBSWY3DP01890189', '123456'))->toBeBool(); }); it('does not throw on a secret containing only invalid characters', function () { expect($this->service->verify('01890189', '123456'))->toBeBool(); }); }); describe('verify() — replay and reuse scenarios', function () { it('accepts the same valid code twice (no built-in replay protection)', function () { // TotpService itself has no state — replay prevention is the caller's job. // This test documents that behaviour explicitly. $secret = 'JBSWY3DPEHPK3PXP'; $now = time(); $reflection = new ReflectionMethod(TotpService::class, 'generateCode'); $reflection->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $secretBytes = $decodeMethod->invoke($this->service, $secret); $code = $reflection->invoke($this->service, $secretBytes, $now); expect($this->service->verify($secret, $code))->toBeTrue(); expect($this->service->verify($secret, $code))->toBeTrue(); }); it('rejects a code generated for a different secret', function () { $secretA = 'JBSWY3DPEHPK3PXP'; $secretB = 'KRSXG5CTMVRXEZLU'; $now = time(); $reflection = new ReflectionMethod(TotpService::class, 'generateCode'); $reflection->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $secretBytesA = $decodeMethod->invoke($this->service, $secretA); $codeA = $reflection->invoke($this->service, $secretBytesA, $now); expect($this->service->verify($secretB, $codeA))->toBeFalse(); }); }); describe('base32 encode/decode round-trip', function () { it('round-trips arbitrary binary data', function () { $encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode'); $encodeMethod->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); // Test with the exact byte length used for secrets (20 bytes) $original = random_bytes(20); $encoded = $encodeMethod->invoke($this->service, $original); $decoded = $decodeMethod->invoke($this->service, $encoded); expect($decoded)->toBe($original); }); it('round-trips a known test vector', function () { // RFC 4648 test vector: "Hello!" => "JBSWY3DPEE======" $encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode'); $encodeMethod->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $encoded = $encodeMethod->invoke($this->service, 'Hello!'); $decoded = $decodeMethod->invoke($this->service, $encoded); expect($decoded)->toBe('Hello!'); }); it('round-trips empty input', function () { $encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode'); $encodeMethod->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $encoded = $encodeMethod->invoke($this->service, ''); $decoded = $decodeMethod->invoke($this->service, $encoded); expect($decoded)->toBe(''); }); it('round-trips single byte', function () { $encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode'); $encodeMethod->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $encoded = $encodeMethod->invoke($this->service, "\x00"); $decoded = $decodeMethod->invoke($this->service, $encoded); expect($decoded)->toBe("\x00"); }); it('round-trips all-ones byte', function () { $encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode'); $encodeMethod->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $encoded = $encodeMethod->invoke($this->service, "\xFF"); $decoded = $decodeMethod->invoke($this->service, $encoded); expect($decoded)->toBe("\xFF"); }); }); describe('generateCode() — RFC 6238 conformance', function () { it('produces a 6-digit zero-padded string', function () { $reflection = new ReflectionMethod(TotpService::class, 'generateCode'); $reflection->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $secretBytes = $decodeMethod->invoke($this->service, 'JBSWY3DPEHPK3PXP'); // Test across several timestamps foreach ([0, 1, 1000000000, 2000000000] as $ts) { $code = $reflection->invoke($this->service, $secretBytes, $ts); expect(strlen($code))->toBe(6) ->and($code)->toMatch('/^[0-9]{6}$/'); } }); it('generates deterministic output for the same secret and timestamp', function () { $reflection = new ReflectionMethod(TotpService::class, 'generateCode'); $reflection->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $secretBytes = $decodeMethod->invoke($this->service, 'JBSWY3DPEHPK3PXP'); $timestamp = 1234567890; $code1 = $reflection->invoke($this->service, $secretBytes, $timestamp); $code2 = $reflection->invoke($this->service, $secretBytes, $timestamp); expect($code1)->toBe($code2); }); it('produces different codes for different time periods', function () { $reflection = new ReflectionMethod(TotpService::class, 'generateCode'); $reflection->setAccessible(true); $decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode'); $decodeMethod->setAccessible(true); $secretBytes = $decodeMethod->invoke($this->service, 'JBSWY3DPEHPK3PXP'); // Codes for time periods that are far apart should differ // (there is a 1-in-1000000 chance of collision per pair, so we // test several to make a false pass astronomically unlikely) $codes = []; for ($i = 0; $i < 10; $i++) { $codes[] = $reflection->invoke($this->service, $secretBytes, $i * 30); } // At least 9 out of 10 should be unique (allowing for one freak collision) expect(count(array_unique($codes)))->toBeGreaterThanOrEqual(9); }); }); });