Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ $otp = new OTP(
digitCount: 6,
validUpto: 60,
retry: 3,
hashAlgorithm: 'xxh128',
hashAlgorithm: 'sha256',
cacheAdapter: $cachePool,
hashKey: $applicationOtpKey,
);

$code = $otp->generate('signup:alice@example.com');
Expand All @@ -169,6 +170,8 @@ Notes:
- Codes are strings, not integers
- Leading zeroes are preserved
- Digit count must be between `4` and `10`
- Stored OTP digests use SHA-256 or SHA-512; non-cryptographic hashes are rejected
- For production, use a purpose-specific `hashKey` of at least 16 random bytes and keep it outside the OTP cache

### OCRA

Expand Down Expand Up @@ -257,8 +260,11 @@ $parsed->ocraSuite;
The package ships with contracts plus an in-memory store for testing and lightweight use:

- `Infocyph\OTP\Contracts\ReplayStoreInterface`
- `Infocyph\OTP\Contracts\AtomicReplayStoreInterface`
- `Infocyph\OTP\Stores\InMemoryReplayStore`

Use `AtomicReplayStoreInterface` for production stores so consume-once and monotonic-counter updates remain safe under concurrent requests. Legacy `ReplayStoreInterface` implementations remain supported but cannot provide that atomic guarantee.

Recommended usage:

- TOTP: store accepted timesteps per user/device binding
Expand Down Expand Up @@ -299,6 +305,8 @@ $result->totalGenerated;
$result->lastUsedAt;
```

For production, pass a purpose-specific secret `hashKey` of at least 16 random bytes and store it outside the recovery-code database.

Notes:

- Recovery codes are stored hashed
Expand Down
52 changes: 49 additions & 3 deletions benchmarks/OtpBench.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Infocyph\OTP\Tests\Support\InMemoryCacheItemPool;
use Infocyph\OTP\TOTP;
use PhpBench\Attributes\BeforeMethods;
use PhpBench\Attributes\Revs;

#[BeforeMethods('setUp')]
final class OtpBench
Expand All @@ -33,6 +34,8 @@ final class OtpBench

private string $totpCode;

private InMemoryReplayStore $totpReplayStore;

public function setUp(): void
{
$this->totp = (new TOTP(
Expand All @@ -53,14 +56,21 @@ public function setUp(): void
digitCount: 6,
validUpto: 60,
retry: 3,
hashAlgorithm: 'xxh128',
hashAlgorithm: 'sha256',
cacheAdapter: new InMemoryCacheItemPool(),
);

$this->totpCode = $this->totp->getOTP(1716532624);
$this->hotpCode = $this->hotp->getOTP(5);
$this->ocraCode = $this->ocra->generate('12345678', 0);
$this->genericCode = $this->genericOtp->generate($this->signature);
$this->totpReplayStore = new InMemoryReplayStore();
$this->totpReplayStore->consumeOnce(
'totp:step',
'bench-user',
(string) $this->totp->getCurrentTimeStep(1716532624),
90,
);
}

public function benchGenericOtpGenerate(): void
Expand All @@ -69,18 +79,24 @@ public function benchGenericOtpGenerate(): void
digitCount: 6,
validUpto: 60,
retry: 3,
hashAlgorithm: 'xxh128',
hashAlgorithm: 'sha256',
cacheAdapter: new InMemoryCacheItemPool(),
);

$otp->generate('bench:another@example.com');
}

#[Revs(1)]
public function benchGenericOtpVerify(): void
{
$this->genericOtp->verify($this->signature, $this->genericCode);
}

public function benchGenericOtpVerifyMalformed(): void
{
$this->genericOtp->verify($this->signature, 'invalid');
}

public function benchHotpGenerate(): void
{
$this->hotp->getOTP(5);
Expand All @@ -91,6 +107,11 @@ public function benchHotpVerify(): void
$this->hotp->verify($this->hotpCode, 5, 3);
}

public function benchHotpVerifyInvalid(): void
{
$this->hotp->verify('000000', 5, 3);
}

public function benchOcraGenerate(): void
{
$this->ocra->generate('12345678', 0);
Expand All @@ -101,6 +122,11 @@ public function benchOcraVerify(): void
$this->ocra->verify($this->ocraCode, '12345678', 0);
}

public function benchOcraVerifyInvalid(): void
{
$this->ocra->verify('00000000', '12345678', 0);
}

public function benchTotpGenerate(): void
{
$this->totp->getOTP(1716532624);
Expand All @@ -111,7 +137,17 @@ public function benchTotpVerify(): void
$this->totp->verify($this->totpCode, 1716532624, 1, 1);
}

public function benchTotpVerifyWithReplayStore(): void
public function benchTotpVerifyInvalid(): void
{
$this->totp->verify('000000', 1716532624, 1, 1);
}

public function benchTotpVerifyMalformed(): void
{
$this->totp->verify('invalid', 1716532624, 1, 1);
}

public function benchTotpVerifyReplayAccepted(): void
{
$store = new InMemoryReplayStore();
$this->totp->verifyWithWindow(
Expand All @@ -121,4 +157,14 @@ public function benchTotpVerifyWithReplayStore(): void
binding: 'bench-user',
);
}

public function benchTotpVerifyReplayRejected(): void
{
$this->totp->verifyWithWindow(
$this->totpCode,
1716532624,
replayStore: $this->totpReplayStore,
binding: 'bench-user',
);
}
}
5 changes: 5 additions & 0 deletions docs/api/contracts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ ReplayStoreInterface

Use this to persist replay state for TOTP, HOTP, or OCRA verification flows.

AtomicReplayStoreInterface
--------------------------

Use this extension for production replay stores. Its ``consumeOnce()`` and ``advance()`` operations must be implemented atomically so concurrent requests cannot accept the same TOTP/OCRA token or move an HOTP counter backwards.

RecoveryCodeStoreInterface
--------------------------

Expand Down
10 changes: 10 additions & 0 deletions docs/getting-started/migration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,39 @@ Generic OTP
- Leading zeroes are preserved.
- The generic OTP class now expects a caller-provided PSR-6 cache pool.
- Digit count is validated as OTP digits, not PHP integer size.
- Generic OTP now accepts only ``sha256`` and ``sha512`` for stored-code digests.
- Signature cache keys now use SHA-256. Outstanding cache entries created with the earlier ``xxh3`` key format are intentionally not reused.
- Validity is bounded to 86400 seconds, retries to 100, and signatures to 4096 bytes.
- An optional final ``hashKey`` constructor argument enables keyed HMAC storage without changing existing positional arguments.

TOTP
----

- The old boolean leeway model has been replaced by configurable past/future windows.
- A simple boolean API remains available through ``verify()``.
- Richer inspection is available through ``verifyWithWindow()`` and ``VerificationResult``.
- Verification windows are bounded to 100 total drift steps and TOTP periods to 86400 seconds.
- Malformed submitted codes return a non-matching result instead of using exceptions for expected verification flow.

HOTP
----

- ``verify()`` supports a look-ahead window.
- ``verifyWithResult()`` returns matched counter and drift information.
- HOTP look-ahead is bounded to 100 counters.

Provisioning
------------

- ``otpauth://`` parsing is now available.
- Enrollment payload helpers expose URI, QR payload, and optional SVG.
- Label and issuer handling is stricter and centralized.
- Parsing rejects duplicate parameters, conflicting label/query issuers, invalid Base32 encodings, and oversized URIs.

Recovery codes and replay protection
------------------------------------

- Recovery code generation and consumption are now first-class features.
- Replay protection is pluggable through interfaces and in-memory stores.
- ``AtomicReplayStoreInterface`` adds atomic consume-once and monotonic advance operations for concurrency-safe replay protection.
- OCRA generated Base32 secrets should be constructed with ``OCRA::fromBase32()``; the constructor continues to accept raw key bytes for RFC test-vector and binary-key compatibility.
2 changes: 1 addition & 1 deletion docs/getting-started/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Generic OTP
digitCount: 6,
validUpto: 60,
retry: 3,
hashAlgorithm: 'xxh128',
hashAlgorithm: 'sha256',
cacheAdapter: $cachePool,
);

Expand Down
5 changes: 5 additions & 0 deletions docs/guides/custom-stores.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The relevant contracts are:

- ``Infocyph\OTP\Contracts\RecoveryCodeStoreInterface``
- ``Infocyph\OTP\Contracts\ReplayStoreInterface``
- ``Infocyph\OTP\Contracts\AtomicReplayStoreInterface``
- ``Infocyph\OTP\Contracts\SecretStoreInterface``

Secret storage guidance
Expand Down Expand Up @@ -84,6 +85,8 @@ That means your system can answer, day to day:
Example PDO store
~~~~~~~~~~~~~~~~~

The following class illustrates the legacy read/write contract. It is suitable for understanding the data model, but separate ``hasConsumed()``/``markConsumed()`` and ``getState()``/``setState()`` calls are not atomic.

.. code-block:: php

<?php
Expand Down Expand Up @@ -395,4 +398,6 @@ Notes
- The SQL above is illustrative. You may need to adapt syntax for PostgreSQL, MySQL, SQLite or SQL Server.
- Recovery code consumption should be atomic to prevent double-use under concurrency.
- Replay stores should apply indexes on namespace, binding and token.
- Production replay stores should implement ``AtomicReplayStoreInterface``. Implement ``consumeOnce()`` as a single unique insert (or equivalent compare-and-set) and ``advance()`` as a conditional atomic update that succeeds only when the new counter is greater.
- Do not implement either atomic method as a read followed by a write; that recreates the race the interface is designed to prevent.
- Recovery code hashes should be treated as sensitive authentication data even though they are hashed.
14 changes: 13 additions & 1 deletion docs/guides/generic-otp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ The generic OTP class requires a PSR-6 cache pool implementation:
digitCount: 6,
validUpto: 60,
retry: 3,
hashAlgorithm: 'xxh128',
hashAlgorithm: 'sha256',
cacheAdapter: $cachePool,
hashKey: $applicationOtpKey,
);

Codes are strings
Expand Down Expand Up @@ -102,3 +103,14 @@ The generic OTP cache payload keeps:
- the expiration moment

Because codes are strings, leading zeroes are preserved correctly.

Security and limits
-------------------

- SHA-256 is the default stored-code digest; SHA-512 is also supported.
- For production, provide a purpose-specific ``hashKey`` of at least 16 random bytes and keep it outside the OTP cache.
- Non-cryptographic hashes are rejected because OTP verification is an authentication decision.
- Signature cache keys use SHA-256 to prevent attacker-controlled non-cryptographic collisions.
- Configuration is validated when the immutable OTP instance is constructed.
- Validity is bounded to 86400 seconds, retries to 100, and signature input to 4096 bytes.
- PSR-6 does not define an atomic consume operation. Use a cache/storage integration with an application-level atomic consume when concurrent verification of the same generic OTP must be prevented.
2 changes: 2 additions & 0 deletions docs/guides/hotp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ HOTP often needs controlled counter resynchronization.

This means the verifier will try the provided counter and then probe forward up to the configured look-ahead window.

The look-ahead is bounded to 100 counters to keep verification work predictable and resistant to resource-exhaustion configuration.

Rich verification result
------------------------

Expand Down
19 changes: 15 additions & 4 deletions docs/guides/ocra.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,16 @@ You can generate one with:

<?php
use Infocyph\OTP\OCRA;
use Infocyph\OTP\Support\SecretUtility;

$sharedKey = OCRA::generateSecret();
$base32Secret = OCRA::generateSecret();
$sharedKey = SecretUtility::decodeBase32($base32Secret);
$ocra = OCRA::fromBase32(
'OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1',
$base32Secret,
);

If your integration needs a specific keying strategy, you can also supply an application-managed shared key when constructing the instance.
``generateSecret()`` returns Base32 for safe storage and provisioning. Use ``fromBase32()`` to decode that representation once. If your integration already manages raw binary key bytes, pass those bytes to the constructor directly.

Creating an OCRA instance
-------------------------
Expand All @@ -83,7 +89,10 @@ Creating an OCRA instance
<?php
use Infocyph\OTP\OCRA;

$ocra = new OCRA('OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1', $sharedKey);
$ocra = OCRA::fromBase32(
'OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1',
$base32Secret,
);
$ocra->setPin('1234');

$code = $ocra->generate('12345678', 0);
Expand Down Expand Up @@ -258,7 +267,7 @@ Notes on optional inputs:

- if the suite includes ``C``, you should provide a counter
- if the suite includes ``PSHA1`` / ``PSHA256`` / ``PSHA512``, you should call ``setPin()``
- if the suite includes ``Snnn``, you should call ``setSession()``
- if the suite includes ``Snnn``, you should call ``setSession()`` with an even-length hexadecimal representation no longer than the configured byte length
- if the suite includes ``T...``, you can optionally call ``setTime()`` to verify or generate for a specific moment

Parsed suite details
Expand Down Expand Up @@ -311,6 +320,8 @@ For many OCRA use cases, you should reject:
- reused counter values where counters are present
- previously accepted challenge and counter combinations

For suites containing ``C``, atomic replay stores enforce a strictly increasing accepted counter. For suites without ``C``, the challenge/counter token is consumed once.

Example:

.. code-block:: php
Expand Down
13 changes: 11 additions & 2 deletions docs/guides/provisioning.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ For TOTP, HOTP, and OCRA, you can generate a new Base32 secret before building p

$totpSecret = TOTP::generateSecret();
$hotpSecret = HOTP::generateSecret();
$ocraSharedKey = OCRA::generateSecret();
$ocraSecret = OCRA::generateSecret();
$ocra = OCRA::fromBase32(
'OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1',
$ocraSecret,
);

Provisioning URIs
-----------------
Expand Down Expand Up @@ -101,7 +105,10 @@ OCRA QR example:
<?php
use Infocyph\OTP\OCRA;

$ocra = new OCRA('OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1', $sharedKey);
$ocra = OCRA::fromBase32(
'OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1',
$ocraSecret,
);
$svg = $ocra->getProvisioningUriQR(
'alice@example.com',
'Example App',
Expand Down Expand Up @@ -176,4 +183,6 @@ The provisioning layer:

- normalizes issuers
- safely formats labels
- rejects duplicate query parameters and conflicting issuer identities while parsing
- validates canonical Base32 secrets and bounds URI/query sizes
- separates URI generation from QR rendering
15 changes: 15 additions & 0 deletions docs/guides/recovery-codes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ Generating codes
$generated->totalGenerated;
$generated->remainingCount;

For production, provide a purpose-specific HMAC key kept outside the recovery-code store:

.. code-block:: php

<?php
$codes = new RecoveryCodes(
$store,
hashAlgorithm: 'sha256',
hashKey: $applicationRecoveryCodeKey,
);

The HMAC key must contain at least 16 bytes. Without a key, the package stores a SHA-256 or SHA-512 digest.

Consuming a code
----------------

Expand All @@ -41,8 +54,10 @@ Behavior

- Codes are displayed in a user-friendly grouped format.
- Stored values are hashed before persistence.
- Hash algorithms are restricted to SHA-256 and SHA-512.
- Generating a new set replaces the old set.
- A consumed code cannot be reused.
- Counts, lengths, grouping, and character sets are bounded and validated before generation.

Persistent tracking
-------------------
Expand Down
Loading