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
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @abmmhasan @infocyph/core
* @abmmhasan
2 changes: 2 additions & 0 deletions src/HOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Infocyph\OTP;

use Infocyph\OTP\Traits\Common;

final class HOTP
{
use Common;
Expand Down
107 changes: 63 additions & 44 deletions src/OCRA.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
use DateTimeInterface;
use Exception;
use Infocyph\OTP\Exceptions\OCRAException;
use Infocyph\OTP\Traits\Common;

final class OCRA
{
private string $ocraRegex = '/^OCRA-1:HOTP-SHA(1|256|512)-(0|[4-9]|10):(C-)?Q([ANH])(0[4-9]|[1-5]\d|6[0-4])(-(P(SHA1|SHA256|SHA512)|S\d{3}|(T((\d|[1-3]\d|4[0-8])H|(([1-9]|[1-5]\d)([SM]))))))?/';

use Common;
private const OCRA_REGEX = '/^OCRA-1:HOTP-SHA(1|256|512)-(0|[4-9]|10):(C-)?Q([ANH])(0[4-9]|[1-5]\d|6[0-4])(-(P(SHA1|SHA256|SHA512)|S\d{3}|(T((\d|[1-3]\d|4[0-8])H|(([1-9]|[1-5]\d)([SM]))))))*$/';
private array $ocraSuite;
private string $pin;
private string $session;
private string $time;
private ?string $pin = null;
private ?string $session = null;
private ?string $time = null;

/**
* Constructor for the class.
Expand All @@ -30,13 +31,17 @@ public function __construct(string $ocraSuite, private readonly string $sharedKe
/**
* Sets the pin for the OCRA instance.
*
* Required if the suite supports session.
* Required if the suite supports PIN.
*
* @param string $pin The pin to set.
* @return OCRA
* @throws OCRAException
*/
public function setPin(string $pin): OCRA
{
if (empty($pin)) {
throw new OCRAException('PIN cannot be empty.');
}
$this->pin = $pin;
return $this;
}
Expand All @@ -48,9 +53,13 @@ public function setPin(string $pin): OCRA
*
* @param string $session The session to set.
* @return OCRA
* @throws OCRAException
*/
public function setSession(string $session): OCRA
{
if (empty($session)) {
throw new OCRAException('Session cannot be empty.');
}
$this->session = $session;
return $this;
}
Expand Down Expand Up @@ -87,11 +96,11 @@ public function generate(string $challenge, int $counter = 0): string

$msg .= $this->calculateQ($challenge);

if ($this->ocraSuite['optional']) {
if (!empty($this->ocraSuite['optionals'])) {
$msg .= $this->calculateOptionals();
}

$hash = hash_hmac((string) $this->ocraSuite['algo'], $msg, $this->sharedKey, true);
$hash = hash_hmac((string)$this->ocraSuite['algo'], $msg, $this->sharedKey, true);

if (!$this->ocraSuite['length']) {
return $hash;
Expand Down Expand Up @@ -123,30 +132,35 @@ private function calculateQ(string $input): string
}

/**
* Calculates the optional value based on the format specified in the OCRA suite.
* Calculates the optional values based on the formats specified in the OCRA suite.
*
* @return string The calculated optional value.
* @return string The concatenated calculated optional values.
* @throws OCRAException
*/
private function calculateOptionals(): string
{
return match ($this->ocraSuite['optional']['format']) {
'p' => hash(
(string) $this->ocraSuite['optional']['value'],
$this->pin ?? throw new OCRAException('Missing PIN'),
true
),
's' => str_pad(
pack('H*', $this->session ?? throw new OCRAException('Missing Session')),
$this->ocraSuite['optional']['value'],
"\0",
STR_PAD_LEFT
),
't' => [
$time = (int)floor(($this->time ?? time()) / $this->ocraSuite['optional']['value']),
pack('NN', ($time >> 32) & 0xffffffff, $time & 0xffffffff)
][1]
};
$optionals = '';
foreach ($this->ocraSuite['optionals'] as $optional) {
$optionals .= match ($optional['format']) {
'p' => hash(
(string)$optional['value'],
$this->pin ?? throw new OCRAException('Missing PIN'),
true
),
's' => str_pad(
pack('H*', $this->session ?? throw new OCRAException('Missing Session')),
$optional['value'],
"\0",
STR_PAD_LEFT
),
't' => [
$time = (int)floor(($this->time ?? time()) / $optional['value']),
pack('NN', ($time >> 32) & 0xffffffff, $time & 0xffffffff)
][1],
default => throw new OCRAException('Invalid optional part format')
};
}
return $optionals;
}

/**
Expand All @@ -157,7 +171,7 @@ private function calculateOptionals(): string
*/
private function validateAndParse(string $ocraSuite): void
{
if (!preg_match($this->ocraRegex, $ocraSuite, $matches)) {
if (!preg_match(self::OCRA_REGEX, $ocraSuite, $matches)) {
throw new OCRAException('Invalid OCRA Suite!');
}

Expand All @@ -178,37 +192,42 @@ private function validateAndParse(string $ocraSuite): void
*
* @param array $parts The array of parts to prepare the conditional parts from.
* @return array The prepared conditional parts.
* @throws OCRAException
*/
private function prepareConditionalParts(array $parts): array
{
$conditionalParts = (
$parts[5] === 'c'
? ['c' => true, 'q' => substr((string) $parts[6], 1), 'optional' => $parts[7] ?? null]
: ['c' => false, 'q' => substr((string) $parts[5], 1), 'optional' => $parts[6] ?? null]
? ['c' => true, 'q' => substr((string)$parts[6], 1), 'optionals' => array_slice($parts, 7)]
: ['c' => false, 'q' => substr((string)$parts[5], 1), 'optionals' => array_slice($parts, 6)]
);

$conditionalParts['q'] = [
'format' => $conditionalParts['q'][0],
'value' => (int)($conditionalParts['q'][1] . $conditionalParts['q'][2]),
];

if (!$conditionalParts['optional']) {
if (empty($conditionalParts['optionals'])) {
return $conditionalParts;
}

$conditionalParts['optional'] = [
'format' => $conditionalParts['optional'][0],
'value' => substr((string) $conditionalParts['optional'], 1)
];
$conditionalParts['optional']['value'] = match ($conditionalParts['optional']['format']) {
's' => (int)$conditionalParts['optional']['value'],
'p' => $conditionalParts['optional']['value'],
't' => match (substr($conditionalParts['optional']['value'], -1)) {
's' => (int)rtrim($conditionalParts['optional']['value'], 's'),
'm' => (int)rtrim($conditionalParts['optional']['value'], 'm') * 60,
'h' => (int)rtrim($conditionalParts['optional']['value'], 'h') * 3600
}
};
$conditionalParts['optionals'] = array_map(fn ($optional) => [
'format' => $optional[0],
'value' => substr((string)$optional, 1)
], $conditionalParts['optionals']);

foreach ($conditionalParts['optionals'] as &$optional) {
$optional['value'] = match ($optional['format']) {
's' => (int)$optional['value'],
'p' => $optional['value'],
't' => match (substr($optional['value'], -1)) {
's' => (int)rtrim($optional['value'], 's'),
'm' => (int)rtrim($optional['value'], 'm') * 60,
'h' => (int)rtrim($optional['value'], 'h') * 3600,
default => throw new OCRAException('Invalid time format')
}
};
}

return $conditionalParts;
}
Expand Down
2 changes: 2 additions & 0 deletions src/TOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Infocyph\OTP;

use Infocyph\OTP\Traits\Common;

final class TOTP
{
use Common;
Expand Down
75 changes: 60 additions & 15 deletions src/Common.php → src/Traits/Common.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace Infocyph\OTP;
namespace Infocyph\OTP\Traits;

use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
Expand All @@ -20,7 +20,7 @@ trait Common
private string $algorithm = 'sha1';
private string $type = 'totp';
private ?string $label = null;

private ?string $ocraSuiteString = null;

/**
* Generates a secret string
Expand All @@ -45,6 +45,41 @@ public function setAlgorithm(string $algorithm): static
return $this;
}

/**
* Set the OCRA suite for the OTP generation.
*
* @param string $ocraSuiteString The OCRA suite to set.
* @return static
*/
public function setOcraSuite(string $ocraSuiteString): static
{
$this->ocraSuiteString = $ocraSuiteString;
$this->type = 'ocra';
return $this;
}

/**
* Get the algorithm from the OCRA suite.
*
* @return string The algorithm.
*/
private function getAlgorithmFromSuite(): string
{
preg_match('/HOTP-(SHA\d+)/', $this->ocraSuiteString, $matches);
return $matches[1] ?? 'SHA1';
}

/**
* Get the number of digits from the OCRA suite.
*
* @return int The number of digits.
*/
private function getDigitsFromSuite(): int
{
preg_match('/-(\d{1,2}):/', $this->ocraSuiteString, $matches);
return (int)($matches[1] ?? 6);
}

/**
* Generates the provisioning URI for the given label, issuer, and optional parameters.
*
Expand All @@ -59,25 +94,33 @@ public function getProvisioningUri(
array $include = ['algorithm', 'digits', 'period', 'counter']
): string {
$include = array_flip($include);
$period = null;
if ($this->type !== 'hotp' && isset($include['period'])) {
$period = $this->period;
}
$query = http_build_query(
array_filter([
'secret' => $this->secret,
'issuer' => $issuer,
$query = [
'secret' => $this->secret,
'issuer' => $issuer,
];

$query += match ($this->type) {
'ocra' => [
'ocraSuite' => $this->ocraSuiteString,
'algorithm' => isset($include['algorithm']) ? strtoupper($this->getAlgorithmFromSuite()) : null,
'digits' => isset($include['digits']) ? $this->getDigitsFromSuite() : null
],
default => [
'algorithm' => isset($include['algorithm']) ? $this->algorithm : null,
'digits' => isset($include['digits']) ? $this->digitCount : null,
'period' => $period,
'period' => $this->type === 'totp' && isset($include['period']) ? $this->period : null,
'counter' => isset($include['counter']) ? $this->counter : null
]),
]
};

$queryString = http_build_query(
array_filter($query),
encoding_type: PHP_QUERY_RFC3986
);

$label = rawurlencode(($issuer ? $issuer . ':' : '') . $label);

return "otpauth://$this->type/$label?$query";
return "otpauth://$this->type/$label?$queryString";
}

/**
Expand All @@ -86,16 +129,18 @@ public function getProvisioningUri(
* @param string $label The label for the provisioning QR code.
* @param string $issuer The issuer for the provisioning QR code.
* @param array $include An array of optional parameters to include in the provisioning QR code. Default is ['algorithm', 'digits', 'period', 'counter'].
* @param int $imageSize The size of the QR code image.
* @return string The provisioning QR code as SVG string.
*/
public function getProvisioningUriQR(
string $label,
string $issuer,
array $include = ['algorithm', 'digits', 'period', 'counter']
array $include = ['algorithm', 'digits', 'period', 'counter'],
int $imageSize = 200
): string {
$writer = new Writer(
new ImageRenderer(
new RendererStyle(200),
new RendererStyle($imageSize),
new SvgImageBackEnd()
)
);
Expand Down