Skip to content

Repository files navigation

Infocyph OTP

Security & Standards Packagist Downloads License: MIT Packagist Version Packagist PHP Version GitHub Code Size Documentation

Framework-agnostic PHP 8.4 primitives for Generic OTP, HOTP (RFC 4226), TOTP (RFC 6238), OCRA (RFC 6287), AOTP asymmetric challenge-response, GridOTP dynamic grid authentication, legacy Mobile-OTP/mOTP, optional WebAuthn passkeys, recovery codes, provisioning URIs, SVG QR codes, secret rotation planning, and CacheLayer-backed replay boundaries.

AOTP and GridOTP are Infocyph-defined protocol primitives. MobileOTP implements the established legacy mOTP wire calculation. Passkey delegates WebAuthn cryptography and ceremony validation to web-auth/webauthn-lib. None of these four uses otpauth:// provisioning.

Requirements

  • PHP ^8.4 on a 64-bit build
  • ext-ctype
  • CacheLayer ^3.3
  • Composer
composer require infocyph/otp

Optional integrations

AOTP requires ext-sodium for Ed25519. OTP keeps sodium under Composer require-dev + suggest, so applications that do not use AOTP do not need it. Use AOTP::isAvailable() before exposing AOTP when the production PHP image may not include sodium.

Passkey/WebAuthn requires the upstream WebAuthn implementation:

composer require web-auth/webauthn-lib:^5.3

OTP keeps web-auth/webauthn-lib under require-dev + suggest as well. Passkey::isAvailable() reports whether it is loaded. OTP's Passkey integration does not require ext-sodium.

Quickstart

Full usage guides, API references, security guidance, and deployment scenarios are available in the documentation.

TOTP

The default SHA-1/6-digit/30-second configuration has the broadest authenticator compatibility.

use Infocyph\OTP\TOTP;

$totp = new TOTP(TOTP::generateSecret());
$uri = $totp->getProvisioningUri('alice@example.com', 'Example App');
$valid = $totp->verify($submittedCode);

The boolean path is stateless. For single-use acceptance, pass a configured CacheLayer authentication-state cache and a generation-specific factor ID:

$result = $totp->verifyWithWindow(
    otp: $submittedCode,
    cache: $stateCache,
    factorId: 'user-42:totp:secret-v1',
);

$stateCache must be fail-closed, payload-integrity protected, and authoritative. CacheLayer 3.3 native atomics are preferred when the backend exposes them; otherwise TOTP/HOTP/OCRA use the cache's coordinated lock fallback. A selected atomic backend failure propagates and is never retried through locks. factorId must identify one factor and secret/moving-factor generation, not merely a user. See TOTP documentation.

HOTP

use Infocyph\OTP\HOTP;

$hotp = new HOTP(HOTP::generateSecret(), digits: 6, algorithm: 'sha1');
$code = $hotp->generate(counter: 10);
$result = $hotp->verifyWithResult($code, counter: 10);

$matched = $result->matchedCounter; // 10
$persist = $result->nextCounter;    // 11

Persist nextCounter, not matchedCounter. Supported counters are 0..PHP_INT_MAX; HOTP/TOTP use 6..9 digits and require at least 128-bit decoded secrets. Replay-aware HOTP stores the greatest accepted counter in the configured CacheLayer backend with no TTL. See HOTP documentation.

Generic OTP

use Infocyph\OTP\GenericOtp;

$otp = new GenericOtp(
    cache: $stateCache,
    key: $purposeSpecificApplicationKey,
    digits: 6,
    ttlSeconds: 300,
    maxAttempts: 3,
);

$code = $otp->generate('login-challenge-123');
$valid = $otp->verify('login-challenge-123', $submittedCode);

Generic OTP deliberately remains lock-based because issue/replace, consumption, failed-attempt decrement, expiry, and deletion form one multi-field state machine. A successful verification consumes the challenge; a mismatch decrements attempts without extending expiry. See Generic OTP documentation.

OCRA

OCRA operation inputs are explicit and suite-driven; an input is rejected when the suite does not authenticate it.

use Infocyph\OTP\OCRA;

$ocra = new OCRA(
    'OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1',
    '12345678901234567890123456789012',
);

$code = $ocra->generate(
    challenge: '12345678',
    counter: 4,
    pin: '1234',
);

Use fromBase32() for enrolled Base32 secrets. Counter OCRA replay state is monotonic; non-counter replay protection requires an explicit replay TTL. otpauth://ocra is a library/client convention rather than an RFC-standardized provisioning format. See OCRA documentation.

AOTP

AOTP is an optional Ed25519 challenge-response primitive. The verifier keeps the public key while the client/device keeps the private key.

use Infocyph\OTP\AOTP;

// Enrollment: generate on the client/device where possible.
$keys = AOTP::generateKeyPair();
$clientPrivateKey = $keys->privateKey;
$storedPublicKey = $keys->publicKey;

// Verifier side.
$aotp = new AOTP($storedPublicKey, 'login.example.com');
$flowId = bin2hex(random_bytes(16));
$context = 'login:web:' . $flowId;

$challenge = $aotp->issue(
    cache: $stateCache,
    factorId: 'user-42:aotp:key-v1',
    context: $context,
);

// Client side: this value comes from trusted local flow state established when
// the login was initiated, never by copying challenge->context.
$locallyKnownFlowId = loadLocallyInitiatedFlowId();

$response = AOTP::respond(
    privateKey: $clientPrivateKey,
    challenge: $challenge,
    expectedAudience: 'login.example.com',
    expectedContext: 'login:web:' . $locallyKnownFlowId,
);

// Verifier side.
$result = $aotp->verifyWithResult(
    cache: $stateCache,
    factorId: 'user-42:aotp:key-v1',
    challenge: $challenge,
    response: $response,
);

The signed payload binds challenge ID, nonce, audience, mandatory context, issuance, and expiration. respond() refuses a wrong audience/context and refuses stale or not-yet-valid challenges before signing. Successful verification atomically consumes the issued reservation; a concurrent duplicate from a valid signer is replay.

AOTP provides asymmetric proof-of-possession and replay-resistant challenge authentication. Phishing resistance is an end-to-end property of the consuming client and verifier. The client must independently authenticate the intended verifier and derive expected context from trusted local session/transaction state rather than blindly signing received challenge fields. Audience/context fields alone do not stop a real-time relay. Prefer Passkey/WebAuthn when browser-origin-bound phishing resistance is the goal.

AOTP requires ext-sodium. See AOTP documentation.

GridOTP

GridOTP is a human-computable dynamic-grid knowledge factor. Every challenge regenerates a balanced mapping and requests 6..10 positions from the enrolled secret.

use Infocyph\OTP\GridOTP;

$secret = GridOTP::generateSecret();
$gridOtp = new GridOTP($stateCache, $secret);
$challenge = $gridOtp->issue('user-42:grid:secret-v1');
$response = GridOTP::respond($challenge, $secret);
$result = $gridOtp->verifyWithResult(
    'user-42:grid:secret-v1',
    $challenge,
    $response,
);

The enrolled secret is not submitted during authentication and a captured response cannot be replayed against a new grid. Repeated full observations can still recover secret symbols, so GridOTP is not shoulder-surfing proof and is one knowledge factor rather than MFA. See GridOTP documentation.

MobileOTP

MobileOTP provides strict compatibility with legacy Mobile-OTP/mOTP: a 10-second timestep, 16-hex-character Init-Secret, four-digit PIN, and first six lowercase hexadecimal characters of the protocol's MD5 calculation.

use Infocyph\OTP\MobileOTP;

$mobile = new MobileOTP(
    secret: MobileOTP::generateSecret(),
    pin: '5555',
);

$otp = $mobile->generate();
$result = $mobile->verifyWithWindow(
    otp: $submittedOtp,
    cache: $stateCache,
    factorId: 'user-42:mobile:secret-v1',
);

The default window is zero; legacy tolerance can be configured up to 18 10-second steps per direction. MD5 is used only because it is part of the legacy wire algorithm. Prefer TOTP, AOTP, or Passkey for new deployments. See MobileOTP documentation.

Passkey / WebAuthn

Passkey is an optional ceremony/state wrapper around web-auth/webauthn-lib. The authenticator owns the private key and the application persists the upstream CredentialRecord.

use Infocyph\OTP\Passkey;

$passkey = new Passkey(
    cache: $stateCache,
    rpId: 'example.com',
    allowedOrigins: ['https://example.com'],
);

$ceremony = $passkey->beginRegistration(
    binding: 'user-42:passkey:registration',
    userHandle: $opaqueStableUserHandle,
    username: 'alice@example.com',
    displayName: 'Alice',
);

$result = $passkey->finishRegistration(
    binding: 'user-42:passkey:registration',
    ceremonyId: $submittedCeremonyId,
    credentialJson: $browserCredentialJson,
);

There is no rpName constructor argument; OTP uses the RP ID as the serialized RP display name. Registration requests discoverable credentials and user verification. Authentication supports account-bound and discoverable flows. Persist the updated credentialRecordJson after every successful assertion. Passkey does not require ext-sodium from OTP. See Passkey/WebAuthn documentation.

Recovery codes

use Infocyph\OTP\RecoveryCodes;
use Infocyph\OTP\Stores\InMemoryRecoveryCodeStore;

$recovery = new RecoveryCodes(
    new InMemoryRecoveryCodeStore(),
    $separateRecoveryHmacKey,
);
$batch = $recovery->generate('user-42');
$result = $recovery->consume('user-42', $submittedCode);

Regeneration replaces the entire active batch and consumption is atomic and single-use. Production applications should implement RecoveryCodeStoreInterface with authoritative durable atomic persistence. See Recovery codes documentation.

Security boundary

Correct OTP math is not a complete authentication workflow. Store HOTP/TOTP/OCRA, GridOTP, and MobileOTP secrets encrypted; protect AOTP private keys on the client; persist passkey CredentialRecord data in an authoritative durable store; keep Generic OTP and recovery HMAC keys separate; use TLS; apply rate limits; protect provisioning material; and rotate factor IDs when secrets, keys, or moving-factor generations rotate.

For Generic OTP, GridOTP, AOTP, Passkey ceremonies, MobileOTP replay, and replay-aware HOTP/TOTP/OCRA, configure one shared, fail-closed, payload-integrity protected, authoritative CacheLayer backend. Generic OTP, GridOTP, and Passkey ceremonies additionally require a coordinated lock because their state transitions are multi-field. Recovery-code and passkey credential persistence remain application-owned. Backend/configuration failures propagate and fail closed. See the security guide, storage guide, and replay-protection guide.

Security

Do not disclose suspected vulnerabilities in a public issue, discussion or pull request. Review the security policy, then use GitHub private vulnerability reporting to contact the maintainers confidentially.

OTP is protected by PHPForge, an automated quality and security gate covering tests, static and taint analysis, dependency auditing, architecture checks, and release readiness. Automated controls reduce risk but do not replace responsible disclosure or manual review.


Made with ❤️ for the PHP community
MIT Licensed
DocumentationSecurityCode of ConductContributing
🗂️ BugFeatureDocumentationQuestionCI failure
🔀 GeneralBug fixFeatureRefactorPerformanceSecurity & reliabilityDocumentationMaintenance

About

Simple but Secure Generic OTP, OCRA (RFC6287), TOTP (RFC6238) & HOTP (RFC4226) solution!

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

17 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages