Skip to content

Commit fb82b81

Browse files
authored
Merge pull request #24 from infocyph/feature/enhancement
Feature/enhancement
2 parents 0be6017 + 1f1dd1e commit fb82b81

5 files changed

Lines changed: 128 additions & 60 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
* @abmmhasan @infocyph/core
1+
* @abmmhasan

src/HOTP.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace Infocyph\OTP;
44

5+
use Infocyph\OTP\Traits\Common;
6+
57
final class HOTP
68
{
79
use Common;

src/OCRA.php

Lines changed: 63 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
use DateTimeInterface;
66
use Exception;
77
use Infocyph\OTP\Exceptions\OCRAException;
8+
use Infocyph\OTP\Traits\Common;
89

910
final class OCRA
1011
{
11-
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]))))))?/';
12-
12+
use Common;
13+
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]))))))*$/';
1314
private array $ocraSuite;
14-
private string $pin;
15-
private string $session;
16-
private string $time;
15+
private ?string $pin = null;
16+
private ?string $session = null;
17+
private ?string $time = null;
1718

1819
/**
1920
* Constructor for the class.
@@ -30,13 +31,17 @@ public function __construct(string $ocraSuite, private readonly string $sharedKe
3031
/**
3132
* Sets the pin for the OCRA instance.
3233
*
33-
* Required if the suite supports session.
34+
* Required if the suite supports PIN.
3435
*
3536
* @param string $pin The pin to set.
3637
* @return OCRA
38+
* @throws OCRAException
3739
*/
3840
public function setPin(string $pin): OCRA
3941
{
42+
if (empty($pin)) {
43+
throw new OCRAException('PIN cannot be empty.');
44+
}
4045
$this->pin = $pin;
4146
return $this;
4247
}
@@ -48,9 +53,13 @@ public function setPin(string $pin): OCRA
4853
*
4954
* @param string $session The session to set.
5055
* @return OCRA
56+
* @throws OCRAException
5157
*/
5258
public function setSession(string $session): OCRA
5359
{
60+
if (empty($session)) {
61+
throw new OCRAException('Session cannot be empty.');
62+
}
5463
$this->session = $session;
5564
return $this;
5665
}
@@ -87,11 +96,11 @@ public function generate(string $challenge, int $counter = 0): string
8796

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

90-
if ($this->ocraSuite['optional']) {
99+
if (!empty($this->ocraSuite['optionals'])) {
91100
$msg .= $this->calculateOptionals();
92101
}
93102

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

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

125134
/**
126-
* Calculates the optional value based on the format specified in the OCRA suite.
135+
* Calculates the optional values based on the formats specified in the OCRA suite.
127136
*
128-
* @return string The calculated optional value.
137+
* @return string The concatenated calculated optional values.
129138
* @throws OCRAException
130139
*/
131140
private function calculateOptionals(): string
132141
{
133-
return match ($this->ocraSuite['optional']['format']) {
134-
'p' => hash(
135-
(string) $this->ocraSuite['optional']['value'],
136-
$this->pin ?? throw new OCRAException('Missing PIN'),
137-
true
138-
),
139-
's' => str_pad(
140-
pack('H*', $this->session ?? throw new OCRAException('Missing Session')),
141-
$this->ocraSuite['optional']['value'],
142-
"\0",
143-
STR_PAD_LEFT
144-
),
145-
't' => [
146-
$time = (int)floor(($this->time ?? time()) / $this->ocraSuite['optional']['value']),
147-
pack('NN', ($time >> 32) & 0xffffffff, $time & 0xffffffff)
148-
][1]
149-
};
142+
$optionals = '';
143+
foreach ($this->ocraSuite['optionals'] as $optional) {
144+
$optionals .= match ($optional['format']) {
145+
'p' => hash(
146+
(string)$optional['value'],
147+
$this->pin ?? throw new OCRAException('Missing PIN'),
148+
true
149+
),
150+
's' => str_pad(
151+
pack('H*', $this->session ?? throw new OCRAException('Missing Session')),
152+
$optional['value'],
153+
"\0",
154+
STR_PAD_LEFT
155+
),
156+
't' => [
157+
$time = (int)floor(($this->time ?? time()) / $optional['value']),
158+
pack('NN', ($time >> 32) & 0xffffffff, $time & 0xffffffff)
159+
][1],
160+
default => throw new OCRAException('Invalid optional part format')
161+
};
162+
}
163+
return $optionals;
150164
}
151165

152166
/**
@@ -157,7 +171,7 @@ private function calculateOptionals(): string
157171
*/
158172
private function validateAndParse(string $ocraSuite): void
159173
{
160-
if (!preg_match($this->ocraRegex, $ocraSuite, $matches)) {
174+
if (!preg_match(self::OCRA_REGEX, $ocraSuite, $matches)) {
161175
throw new OCRAException('Invalid OCRA Suite!');
162176
}
163177

@@ -178,37 +192,42 @@ private function validateAndParse(string $ocraSuite): void
178192
*
179193
* @param array $parts The array of parts to prepare the conditional parts from.
180194
* @return array The prepared conditional parts.
195+
* @throws OCRAException
181196
*/
182197
private function prepareConditionalParts(array $parts): array
183198
{
184199
$conditionalParts = (
185200
$parts[5] === 'c'
186-
? ['c' => true, 'q' => substr((string) $parts[6], 1), 'optional' => $parts[7] ?? null]
187-
: ['c' => false, 'q' => substr((string) $parts[5], 1), 'optional' => $parts[6] ?? null]
201+
? ['c' => true, 'q' => substr((string)$parts[6], 1), 'optionals' => array_slice($parts, 7)]
202+
: ['c' => false, 'q' => substr((string)$parts[5], 1), 'optionals' => array_slice($parts, 6)]
188203
);
189204

190205
$conditionalParts['q'] = [
191206
'format' => $conditionalParts['q'][0],
192207
'value' => (int)($conditionalParts['q'][1] . $conditionalParts['q'][2]),
193208
];
194209

195-
if (!$conditionalParts['optional']) {
210+
if (empty($conditionalParts['optionals'])) {
196211
return $conditionalParts;
197212
}
198213

199-
$conditionalParts['optional'] = [
200-
'format' => $conditionalParts['optional'][0],
201-
'value' => substr((string) $conditionalParts['optional'], 1)
202-
];
203-
$conditionalParts['optional']['value'] = match ($conditionalParts['optional']['format']) {
204-
's' => (int)$conditionalParts['optional']['value'],
205-
'p' => $conditionalParts['optional']['value'],
206-
't' => match (substr($conditionalParts['optional']['value'], -1)) {
207-
's' => (int)rtrim($conditionalParts['optional']['value'], 's'),
208-
'm' => (int)rtrim($conditionalParts['optional']['value'], 'm') * 60,
209-
'h' => (int)rtrim($conditionalParts['optional']['value'], 'h') * 3600
210-
}
211-
};
214+
$conditionalParts['optionals'] = array_map(fn ($optional) => [
215+
'format' => $optional[0],
216+
'value' => substr((string)$optional, 1)
217+
], $conditionalParts['optionals']);
218+
219+
foreach ($conditionalParts['optionals'] as &$optional) {
220+
$optional['value'] = match ($optional['format']) {
221+
's' => (int)$optional['value'],
222+
'p' => $optional['value'],
223+
't' => match (substr($optional['value'], -1)) {
224+
's' => (int)rtrim($optional['value'], 's'),
225+
'm' => (int)rtrim($optional['value'], 'm') * 60,
226+
'h' => (int)rtrim($optional['value'], 'h') * 3600,
227+
default => throw new OCRAException('Invalid time format')
228+
}
229+
};
230+
}
212231

213232
return $conditionalParts;
214233
}

src/TOTP.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace Infocyph\OTP;
44

5+
use Infocyph\OTP\Traits\Common;
6+
57
final class TOTP
68
{
79
use Common;

src/Common.php renamed to src/Traits/Common.php

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
namespace Infocyph\OTP;
3+
namespace Infocyph\OTP\Traits;
44

55
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
66
use BaconQrCode\Renderer\ImageRenderer;
@@ -20,7 +20,7 @@ trait Common
2020
private string $algorithm = 'sha1';
2121
private string $type = 'totp';
2222
private ?string $label = null;
23-
23+
private ?string $ocraSuiteString = null;
2424

2525
/**
2626
* Generates a secret string
@@ -45,6 +45,41 @@ public function setAlgorithm(string $algorithm): static
4545
return $this;
4646
}
4747

48+
/**
49+
* Set the OCRA suite for the OTP generation.
50+
*
51+
* @param string $ocraSuiteString The OCRA suite to set.
52+
* @return static
53+
*/
54+
public function setOcraSuite(string $ocraSuiteString): static
55+
{
56+
$this->ocraSuiteString = $ocraSuiteString;
57+
$this->type = 'ocra';
58+
return $this;
59+
}
60+
61+
/**
62+
* Get the algorithm from the OCRA suite.
63+
*
64+
* @return string The algorithm.
65+
*/
66+
private function getAlgorithmFromSuite(): string
67+
{
68+
preg_match('/HOTP-(SHA\d+)/', $this->ocraSuiteString, $matches);
69+
return $matches[1] ?? 'SHA1';
70+
}
71+
72+
/**
73+
* Get the number of digits from the OCRA suite.
74+
*
75+
* @return int The number of digits.
76+
*/
77+
private function getDigitsFromSuite(): int
78+
{
79+
preg_match('/-(\d{1,2}):/', $this->ocraSuiteString, $matches);
80+
return (int)($matches[1] ?? 6);
81+
}
82+
4883
/**
4984
* Generates the provisioning URI for the given label, issuer, and optional parameters.
5085
*
@@ -59,25 +94,33 @@ public function getProvisioningUri(
5994
array $include = ['algorithm', 'digits', 'period', 'counter']
6095
): string {
6196
$include = array_flip($include);
62-
$period = null;
63-
if ($this->type !== 'hotp' && isset($include['period'])) {
64-
$period = $this->period;
65-
}
66-
$query = http_build_query(
67-
array_filter([
68-
'secret' => $this->secret,
69-
'issuer' => $issuer,
97+
$query = [
98+
'secret' => $this->secret,
99+
'issuer' => $issuer,
100+
];
101+
102+
$query += match ($this->type) {
103+
'ocra' => [
104+
'ocraSuite' => $this->ocraSuiteString,
105+
'algorithm' => isset($include['algorithm']) ? strtoupper($this->getAlgorithmFromSuite()) : null,
106+
'digits' => isset($include['digits']) ? $this->getDigitsFromSuite() : null
107+
],
108+
default => [
70109
'algorithm' => isset($include['algorithm']) ? $this->algorithm : null,
71110
'digits' => isset($include['digits']) ? $this->digitCount : null,
72-
'period' => $period,
111+
'period' => $this->type === 'totp' && isset($include['period']) ? $this->period : null,
73112
'counter' => isset($include['counter']) ? $this->counter : null
74-
]),
113+
]
114+
};
115+
116+
$queryString = http_build_query(
117+
array_filter($query),
75118
encoding_type: PHP_QUERY_RFC3986
76119
);
77120

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

80-
return "otpauth://$this->type/$label?$query";
123+
return "otpauth://$this->type/$label?$queryString";
81124
}
82125

83126
/**
@@ -86,16 +129,18 @@ public function getProvisioningUri(
86129
* @param string $label The label for the provisioning QR code.
87130
* @param string $issuer The issuer for the provisioning QR code.
88131
* @param array $include An array of optional parameters to include in the provisioning QR code. Default is ['algorithm', 'digits', 'period', 'counter'].
132+
* @param int $imageSize The size of the QR code image.
89133
* @return string The provisioning QR code as SVG string.
90134
*/
91135
public function getProvisioningUriQR(
92136
string $label,
93137
string $issuer,
94-
array $include = ['algorithm', 'digits', 'period', 'counter']
138+
array $include = ['algorithm', 'digits', 'period', 'counter'],
139+
int $imageSize = 200
95140
): string {
96141
$writer = new Writer(
97142
new ImageRenderer(
98-
new RendererStyle(200),
143+
new RendererStyle($imageSize),
99144
new SvgImageBackEnd()
100145
)
101146
);

0 commit comments

Comments
 (0)