diff --git a/composer.json b/composer.json index f65e008..1cc87a5 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,14 @@ "psr/clock": "1.0.0", "chillerlan/php-qrcode": "5.0.1", "paragonie/constant_time_encoding": "2.6.3" - } + }, + "require-dev": { + "phpunit/phpunit": "^11" + }, + "autoload": { + "psr-4": { + "BradyMcD\\TAATP\\": "src/" + } + } } diff --git a/src/URI/otpauth.php b/src/URI/Otpauth.php similarity index 57% rename from src/URI/otpauth.php rename to src/URI/Otpauth.php index 659191f..6e65012 100644 --- a/src/URI/otpauth.php +++ b/src/URI/Otpauth.php @@ -7,12 +7,12 @@ use InvalidArgumentException; class Otpauth { public function __construct( - public readonly string $issuer, public readonly string $userid, + public readonly string $issuer, public readonly string $secret, - public readonly string $algo, - public readonly int $period, + public readonly string $algorithm, public readonly int $digits, + public readonly int $period, ) {} @@ -23,7 +23,7 @@ class Otpauth { foreach($checkArray as $c) { - !($c[0]($target))? :throw new InvalidArgumentException($c[1]); + ($c[0]($target))? :throw new InvalidArgumentException($c[1]); } }; $uriChecks = [ @@ -31,8 +31,11 @@ class Otpauth { return $arr !== false; }, $uri . " is not a valid URI."], [ function($arr) - { return \array_key_exists('scheme', $arr) && $arr['scheme'] === 'otpauth';}, - $uri . " has no scheme or the wrong scheme for an otpauth uri."], + { return \array_key_exists('scheme', $arr);}, + $uri . " has no scheme."], + [ function($arr) + { return $arr['scheme'] === 'otpauth';}, + $uri . " is not an otpauth:// uri."], [ function($arr) { return \array_key_exists('path', $arr);}, $uri . " isn't tagged for a user or issuer."], @@ -50,7 +53,7 @@ class Otpauth $parsedUri['query'] . " has no issuer information."], [ function($que) { return \array_key_exists('secret', $que);}, - $parsedUri['query'] . "has no secret key."] + $parsedUri['query'] . "has no secret key."], ]; $parsedQuery = []; @@ -66,45 +69,59 @@ class Otpauth $label = \explode(":", $parsedUri['path']); $runCheck($labelChecks, $label); - $applyDefaults = function(array &$arr, array $defaults) { - foreach($defaults as $k => $v) + $queryDefaults = [ + "algorithm" => "SHA1", + "period" => "30", + "digits" => "6", + "issuer" => \rawurldecode(\ltrim($label[0], "/")), + ]; + $parsedQuery = \array_merge($queryDefaults, $parsedQuery); + + $convertFields = function(array $conversion, array &$target) { + foreach ($conversion as $k => $v) { - if(\array_key_exists($k, $arr)) - { - $arr[$k] = $v; - } + $target[$k] = $v($target[$k]); } }; - $queryDefaults = [ - "algorithm" => "SHA1", - "period" => 30, - "digits" => 6, + $queryConversions = [ + "period" => '\intval', + "digits" => '\intval', + "secret" => '\rawurldecode' + ]; + $labelConversions = [ + 0 => '\rawurldecode', ]; - $applyDefaults($queryDefaults, $parsedQuery); + + $convertFields($queryConversions, $parsedQuery); + $convertFields($labelConversions, $label); + // END SCHEMEING - \ltrim($label[0], "/") !== $parsedQuery['issuer'] - || throw new InvalidArgumentException($uri . " has mismatching issuer information."); + \ltrim($label[0], "/") === $parsedQuery['issuer'] + || throw new InvalidArgumentException($uri . " has mismatching issuer information.\n" . + \ltrim($label[0], "/") . "\n" . + $parsedQuery['issuer']); - return self( - $parsedQuery['issuer'], + return new self( $label[1], + $parsedQuery['issuer'], $parsedQuery['secret'], $parsedQuery['algorithm'], - $parsedQuery['period'], - $parsedQuery['digits'] + $parsedQuery['digits'], + $parsedQuery['period'] ); } public function emitStr(): string { - $label = $this->provider . ":" . $this->userid; - $provider = "provider=" . $this->provider; - $algo = "algorithm=" . $this->algo; + $encodeIssuer = \rawurlencode($this->issuer); + $label = $encodeIssuer . ":" . $this->userid; + $issuer = "issuer=" . $encodeIssuer; + $algo = "algorithm=" . $this->algorithm; $digits = "digits=" . $this->digits; $period = "period=" . $this->period; - $secret = "secret=" . $this->secret; - $query = \implode("&", [$secret, $provider, $period, $digits, $algo]); + $secret = "secret=" . \rawurlencode($this->secret); + $query = \implode("&", [$secret, $issuer, $algo, $period, $digits]); return "otpauth://totp/" . $label . "?" . $query; } diff --git a/tests/URITest.php b/tests/URITest.php new file mode 100644 index 0000000..3dd38da --- /dev/null +++ b/tests/URITest.php @@ -0,0 +1,71 @@ + "otpauth", + "host" => "totp", + "path" => "ACME%20Co:john.doe@email.com/", + "query" => "secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30",]; + $queryComponents = [ "secret" => "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", + "issuer" => "ACME Co", + "algorithm" => "MD5", + "digits" => 8, + "period" => 60,]; + + $provisioningUri = Otpauth::fromString($string); + + $this->assertSame($provisioningUri->secret, $queryComponents['secret']); + $this->assertSame($provisioningUri->issuer, $queryComponents['issuer']); + $this->assertSame($provisioningUri->digits, $queryComponents['digits']); + $this->assertSame($provisioningUri->period, $queryComponents['period']); + $this->assertSame($provisioningUri->algorithm, $queryComponents['algorithm']); + + $calculatedUrl = $provisioningUri->emitStr(); + + $parsedOtp = \parse_url($calculatedUrl); + $parsedTest = \parse_url($string); + + $parsedOtpQuery = []; + $parsedTestQuery = []; + + \parse_str($parsedOtp['query'], $parsedOtpQuery); + unset($parsedOtp['query']); + \parse_str($parsedTest['query'], $parsedTestQuery); + unset($parsedTest['query']); + + $this->assertEqualsCanonicalizing($parsedOtp, $parsedTest); + $this->assertEqualsCanonicalizing($parsedOtpQuery, $parsedTestQuery); + } + + public function testIncompleteURI(): void + { + $string = 'otpauth://totp/Example:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=Example'; + $queryComponents = [ "secret" => "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", + "issuer" => "Example", + "algorithm" => "SHA1", + "digits" => 6, + "period" => 30,]; + + + + $provisioningUri = Otpauth::fromString($string); + + $this->assertSame($provisioningUri->secret, $queryComponents['secret']); + $this->assertSame($provisioningUri->algorithm, $queryComponents['algorithm']); + $this->assertSame($provisioningUri->digits, $queryComponents['digits']); + $this->assertSame($provisioningUri->period, $queryComponents['period']); + } +} + +?>