diff --git a/tests/Feature/TotpServiceTest.php b/tests/Feature/TotpServiceTest.php new file mode 100644 index 0000000..530310d --- /dev/null +++ b/tests/Feature/TotpServiceTest.php @@ -0,0 +1,405 @@ +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); + }); + }); +});