abmmhasan / otp
Simple & Secure Generic OTP, OCRA (RFC6287), TOTP (RFC6238) & HOTP (RFC4226) solution!
5.01
2026-04-19 18:38 UTC
Requires
- php: >=8.4
- bacon/bacon-qr-code: ^3.1.1
- paragonie/constant_time_encoding: ^3.1.3
- psr/cache: ^3.0
Requires (Dev)
- captainhook/captainhook: ^5.29.2
- laravel/pint: ^1.29
- pestphp/pest: ^4.6.2
- pestphp/pest-plugin-drift: ^4.1
- phpbench/phpbench: ^1.6.1
- phpstan/phpstan: ^2.1.50
- rector/rector: ^2.4.2
- squizlabs/php_codesniffer: ^4.0.1
- symfony/var-dumper: ^7.3 || ^8.0.8
- tomasvotruba/cognitive-complexity: ^1.1
- vimeo/psalm: ^6.16.1
README
Standalone OTP and MFA primitives for PHP.
Supports:
- Generic OTP with PSR-6 storage
- TOTP (RFC6238)
- HOTP (RFC4226)
- OCRA (RFC6287)
- Recovery / backup codes
otpauth://generation and parsing- Replay-protection contracts and in-memory stores
Requirements
- PHP 8.4+
Installation
composer require infocyph/otp
Highlights
- Base32 secret generation, normalization, and validation
- Safer provisioning URI and label handling
- SVG QR rendering plus raw payload/URI access
- Rich verification results where needed, simple bool APIs where preferred
- Configurable TOTP drift windows
- HOTP look-ahead resynchronization
- Replay protection contracts for TOTP, HOTP, and OCRA
- One-time recovery codes with hashed storage
Quick Start
TOTP
use Infocyph\OTP\TOTP; $secret = TOTP::generateSecret(); $totp = (new TOTP($secret)) ->setAlgorithm('sha256'); $otp = $totp->getOTP(); $isValid = $totp->verify($otp);
Advanced verification with drift windows:
use Infocyph\OTP\Stores\InMemoryReplayStore; use Infocyph\OTP\ValueObjects\VerificationWindow; $store = new InMemoryReplayStore(); $result = $totp->verifyWithWindow( $otp, timestamp: time(), window: new VerificationWindow(past: 1, future: 1), replayStore: $store, binding: 'user-42', ); $result->matched; $result->matchedTimestep; $result->driftOffset; $result->isExact(); $result->isDrifted(); $result->replayDetected;
Useful helpers:
$totp->getCurrentTimeStep(); $totp->getRemainingSeconds(); $totp->getTimeStepFromTimestamp(1716532624);
HOTP
use Infocyph\OTP\HOTP; $secret = HOTP::generateSecret(); $hotp = (new HOTP($secret)) ->setCounter(3) ->setAlgorithm('sha256'); $otp = $hotp->getOTP(346); $isValid = $hotp->verify($otp, 346);
Look-ahead verification with matched-counter result:
use Infocyph\OTP\Stores\InMemoryReplayStore; $result = $hotp->verifyWithResult( $otp, counter: 340, lookAhead: 10, replayStore: new InMemoryReplayStore(), binding: 'device-1', ); $result->matched; $result->matchedCounter; $result->driftOffset;
Generic OTP
Generic OTP is now string-based and uses a caller-provided PSR-6 cache pool.
use Infocyph\OTP\OTP; use Psr\Cache\CacheItemPoolInterface; /** @var CacheItemPoolInterface $cachePool */ $otp = new OTP( digitCount: 6, validUpto: 60, retry: 3, hashAlgorithm: 'xxh128', cacheAdapter: $cachePool, ); $code = $otp->generate('signup:alice@example.com'); $otp->verify('signup:alice@example.com', $code); $otp->delete('signup:alice@example.com'); $otp->flush();
Notes:
- Codes are strings, not integers
- Leading zeroes are preserved
- Digit count must be between
4and10
OCRA
use Infocyph\OTP\OCRA; $ocra = new OCRA('OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1', '12345678901234567890123456789012'); $ocra->setPin('1234'); $code = $ocra->generate('12345678', 0); $isValid = $ocra->verify($code, '12345678', 0);
Replay-aware verification:
use Infocyph\OTP\Stores\InMemoryReplayStore; $result = $ocra->verifyWithResult( $code, challenge: '12345678', counter: 0, replayStore: new InMemoryReplayStore(), binding: 'user-42', );
Provisioning
Generate otpauth:// URIs
$uri = $totp->getProvisioningUri('alice@example.com', 'Example App');
Render SVG QR
$svg = $totp->getProvisioningUriQR('alice@example.com', 'Example App');
Get enrollment payload
$payload = $totp->getEnrollmentPayload( 'alice@example.com', 'Example App', withQrSvg: true, ); $payload->secret; $payload->uri; $payload->qrPayload; $payload->issuer; $payload->label; $payload->qrSvg;
Parse existing otpauth:// URIs
use Infocyph\OTP\TOTP; $parsed = TOTP::parseProvisioningUri($uri); $parsed->type; $parsed->secret; $parsed->label; $parsed->issuer; $parsed->algorithm; $parsed->digits; $parsed->period; $parsed->counter; $parsed->ocraSuite;
Replay Protection
The package ships with contracts plus an in-memory store for testing and lightweight use:
Infocyph\OTP\Contracts\ReplayStoreInterfaceInfocyph\OTP\Stores\InMemoryReplayStore
Recommended usage:
- TOTP: store accepted timesteps per user/device binding
- HOTP: store last accepted counter
- OCRA: store used challenge/counter combinations where required
Recovery Codes
use Infocyph\OTP\RecoveryCodes; use Infocyph\OTP\Stores\InMemoryRecoveryCodeStore; $codes = new RecoveryCodes(new InMemoryRecoveryCodeStore()); $generated = $codes->generate( binding: 'user-42', count: 10, length: 10, groupSize: 4, ); $generated->plainCodes; $generated->totalGenerated; $generated->remainingCount;
Consume a code:
$result = $codes->consume('user-42', $generated->plainCodes[0]); $result->consumed; $result->reason; $result->remainingCount; $result->totalGenerated; $result->lastUsedAt;
Notes:
- Recovery codes are stored hashed
- Generating a new set replaces the old set
- Display formatting is separate from storage hashing
Secret Utilities
Base32 helpers live in Infocyph\OTP\Support\SecretUtility.
use Infocyph\OTP\Support\SecretUtility; $secret = SecretUtility::generate(64); $normalized = SecretUtility::normalizeBase32('ab cd ef 234==='); $isValid = SecretUtility::isValidBase32($normalized);
Result Objects
For richer flows, use the advanced APIs and inspect:
Infocyph\OTP\Result\VerificationResultInfocyph\OTP\Result\RecoveryCodeGenerationResultInfocyph\OTP\Result\RecoveryCodeConsumptionResult
VerificationResult exposes:
matchedreasonmatchedTimestepmatchedCounterdriftOffsetreplayDetectedverifiedAt
Additional Helpers
Infocyph\OTP\Support\StepUpInfocyph\OTP\ValueObjects\DeviceEnrollment
Example:
use Infocyph\OTP\Support\StepUp; $requiresFreshOtp = StepUp::requiresFreshOtp($verifiedAt, 300);
Storage Guidance
- OTP secrets are reversible secrets. If your application needs to generate OTPs later, hashing alone is not enough.
- Recovery codes should usually be stored hashed.
- Replay state may live in cache or a database depending on durability needs.
- Generic OTP requires a PSR-6 cache pool implementation from the caller.
OCRA Suite Notes
Example suite:
OCRA-1:HOTP-SHA1-6:C-QN08-PSHA1
Supported suite parts include:
- HMAC algorithms:
SHA1,SHA256,SHA512 - Digits:
0,4-10 - Challenge formats: numeric (
QNxx), alphanumeric (QAxx), hexadecimal (QHxx) - Optional counter, PIN, session, and time components
References
- HOTP (RFC4226): https://tools.ietf.org/html/rfc4226
- TOTP (RFC6238): https://tools.ietf.org/html/rfc6238
- OCRA (RFC6287): https://tools.ietf.org/html/rfc6287