From 38c716aa202e8aab16a9eede1258c5cc25aee0ac Mon Sep 17 00:00:00 2001 From: Brady McDonough Date: Sun, 28 Jan 2024 20:42:34 -0700 Subject: [PATCH] Deleted client specifics, more generic interfacing, more room for future customization --- README.md | 55 +++++++++++ TODOS.md | 7 ++ src/AntiCSRF/Base.php | 73 +++++++++++++++ src/AntiCSRF/None.php | 41 ++++++++ src/AntiCSRFInterface.php | 22 +++++ src/Clock/Base.php | 13 +++ src/Clock/Request.php | 20 ++++ src/Factory.php | 50 ++++++++++ src/Hash/HMAC_SHA1.php | 32 +++++++ src/HashInterface.php | 12 +++ src/RFC/_4226.php | 35 +++++++ src/RFC/_6238.php | 42 +++++++++ src/Required/PersistenceInterface.php | 53 +++++++++++ src/Required/RequestInterface.php | 31 +++++++ src/Session/Base.php | 40 ++++++++ src/SessionInterface.php | 17 ++++ src/URI/otpauth.php | 129 ++++++++++++++++++++++++++ src/Workflow/Authenticate.php | 74 +++++++++++++++ src/Workflow/UserManagement.php | 124 +++++++++++++++++++++++++ src/WorkflowInterface.php | 24 +++++ 20 files changed, 894 insertions(+) create mode 100644 README.md create mode 100644 TODOS.md create mode 100644 src/AntiCSRF/Base.php create mode 100644 src/AntiCSRF/None.php create mode 100644 src/AntiCSRFInterface.php create mode 100644 src/Clock/Base.php create mode 100644 src/Clock/Request.php create mode 100644 src/Factory.php create mode 100644 src/Hash/HMAC_SHA1.php create mode 100644 src/HashInterface.php create mode 100644 src/RFC/_4226.php create mode 100644 src/RFC/_6238.php create mode 100644 src/Required/PersistenceInterface.php create mode 100644 src/Required/RequestInterface.php create mode 100644 src/Session/Base.php create mode 100644 src/SessionInterface.php create mode 100644 src/URI/otpauth.php create mode 100644 src/Workflow/Authenticate.php create mode 100644 src/Workflow/UserManagement.php create mode 100644 src/WorkflowInterface.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5e2e48 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# TAATP +## TOTP with batteries included. + +The purpose of this module is to enable adoption of 2-step authentication without needing to read documentation geared towards the RFC6238 specification and without having to make security critical decisions. In general, I designed the module to be *almost* foolproof; rather than offering a validate function and a do-it-yourself attitude TAATP orchestrates the basic workflows required to bring a TOTP system online. + +## Using this module + +The primary entry-point for any use of this module is through the `Factory` class. The Factory accepts configuration via dependency injections on the constructor. The supported workflows are UserManagement and Authenticate + +### Required + +While this module does aim to be self contained, there are certain dependencies we can't ignore. These interfaces are located in the `BradyMcD\TAATP\Required` namespace. + +#### PersistenceInterface +This module needs to communicate with some persistent storage. Provide an implementation of this interface so that the TOTP system can talk to your database. + +#### RequestInterface +This module needs to accept input from your users. A default implementation exists to accept HTTP requests, but it needs to know what path to tell your users to send requests to. + +### Integrating + +The Factory class may return null when you request an `authenticate` workflow, this is to signal that the user doesn't have an authenticator registered to their account and we have nothing to display or respond to. + +#### The `WorkflowInterface` +Each Workflow implements this interface. It is split into `.view()`/`emit_str()` and `.response()` handlers and requires user data to instantiate. + +#### `taatp_factory.validate($userId).view()` +Call this after a user has logged in with their password. If they are registered it will display a challenge. + +#### `taatp_factory.validate($userId).response()` +Call this in your response handler. It will return true or false indicating if the submitted code is valid. It can also return null, this indicates that no response to the challenge was given. + +#### `taatp_factory.user_management($userId).view()` +Call this somewhere in your user settings page. If the user is enrolled this will display a challenge to unenroll from the TOTP program, otherwise it will generate a token and display a QR code to configure a TOTP app. + +#### `taatp_factory.user_management($userId).response()` +Call this in your response handler. It will return true or false indicating if a user was successfully (un)enrolled. + +### Optional + +Optional interfaces allow you to further customize how TAATP behaves and integrates with the rest of your code. Each interface has a basic default which will be used if no other customization is desired. + +#### SessionInterface +If you enforce some sort of organization in the `$_SESSION` superglobal, provide an instance of this interface to keep everything organized the way you like it. + +#### AntiCSRFInterface +If you already implement anti-CSRF measures for other forms implementing this interface can keep that feature consistent across your site. +If you employ a single request entrypoint or otherwise check for CSRF before calling this module use the stub implementation `.\AntiCSRF\None`. + +#### ClockInterface +Two seperate clocks are used by this module. One to support token expiries for the AntiCSRF feature and one to serve as the time input to the TOTP algorithm. The clock interface used is as described in PSR20. + +## Under the Hood + +This module stores the original provisioning uri used to enroll new users for totp authentication. In practice this means that if SHA1 falls out of fashion or Google Authenticator's defaults change or your security needs evolve diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..caf35e2 --- /dev/null +++ b/TODOS.md @@ -0,0 +1,7 @@ + + - Better customization of the TOTP algorithm. Currently everything is just Google Authenticator's defaults. + - User indexing is assumed to be a string in the provisioning uri + - Display is awful to code an open ended interface for, but I still should + - Error reporting should be enhanced, either through a messaging object or via `throw` + - The hash fed into the `_6238` object should be based on the hash referenced in the provisioning uri + - diff --git a/src/AntiCSRF/Base.php b/src/AntiCSRF/Base.php new file mode 100644 index 0000000..f678525 --- /dev/null +++ b/src/AntiCSRF/Base.php @@ -0,0 +1,73 @@ +live(), "A live session is required to match anti-CSRF tokens across requests."); + $this->generate(); + } + + public function match(): bool + { + if (\hash_equals($this->session->get(CSRF_TOKEN_IDX), $_REQUEST[CSRF_TOKEN_IDX])) + { + return !$this->expired(); + } + return false; + } + + public function expired(): bool + { + return $this->clock->now()->getTimestamp() >= $this->session->get(CSRF_EXPIRY_IDX); + } + + public function emit_str(): string + { + return + ''; + } + + public function display() + { + echo $this->emit_str(); + } + + private function expiry(): int + { + return $this->clock->now()->getTimestamp() + 3600; + } + + public function generate() + { + $this->session->try_store(CSRF_TOKEN_IDX, \bin2hex(\random_bytes(32))); + $this->token = $this->session->get(CSRF_TOKEN_IDX); + $this->session->try_store(CSRF_EXPIRY_IDX, $this->expiry()); + } + + public function regenerate() + { + $this->session->store(CSRF_TOKEN_IDX, \bin2hex(\random_bytes(32))); + $this->token = $this->session->get(CSRF_TOKEN_IDX); + $this->session->store(CSRF_EXPIRY_IDX, $this->expiry()); + } +} + +?> diff --git a/src/AntiCSRF/None.php b/src/AntiCSRF/None.php new file mode 100644 index 0000000..131b23a --- /dev/null +++ b/src/AntiCSRF/None.php @@ -0,0 +1,41 @@ +emit_str(); + } + + public function generate() + {} + + public function regenerate() + {} +} + +?> \ No newline at end of file diff --git a/src/AntiCSRFInterface.php b/src/AntiCSRFInterface.php new file mode 100644 index 0000000..92f0a35 --- /dev/null +++ b/src/AntiCSRFInterface.php @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/src/Clock/Base.php b/src/Clock/Base.php new file mode 100644 index 0000000..5cbd01c --- /dev/null +++ b/src/Clock/Base.php @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/src/Clock/Request.php b/src/Clock/Request.php new file mode 100644 index 0000000..1d6b5e3 --- /dev/null +++ b/src/Clock/Request.php @@ -0,0 +1,20 @@ +time = $_SERVER["REQUEST_TIME"]; + } + + public function now(): \DateTimeImmutable + { + return new \DateTimeImmutable($this->time); + } +} + +?> \ No newline at end of file diff --git a/src/Factory.php b/src/Factory.php new file mode 100644 index 0000000..e2c0055 --- /dev/null +++ b/src/Factory.php @@ -0,0 +1,50 @@ +session ? : $this->session = new Session\Base(); + $this->csrf_clock ? : $this->csrf_clock = new Clock\Request(); + $this->csrf ? : $this->csrf = new AntiCSRF\Base( + $this->session, + $this->csrf_clock + ); + $this->totp_clock ? : $this->totp_clock = new Clock\Base(); + $this->$hash ? : $this->hash = new Hash\HMAC_SHA1(); + } + + public function user_management(mixed $user_index): UserManagement + { + return new UserManagement($this->db, $this->ri, $this->csrf, $this->session, $user_index); + } + + public function authenticate(mixed $user_index): null|Authenticate + { + if(\is_null($db->get_secret($user_index))) + return null; + else + return new Authenticate($this->db, $this->ri, $this->csrf, $this->session, $user_index) + } +} + +?> diff --git a/src/Hash/HMAC_SHA1.php b/src/Hash/HMAC_SHA1.php new file mode 100644 index 0000000..18374aa --- /dev/null +++ b/src/Hash/HMAC_SHA1.php @@ -0,0 +1,32 @@ +hash($k, \dechex($v)); + } + + public function hash_type(): string + { + return "SHA1"; + } + + public static function keygen(): string + { + return Base32::encodeUpper(random_bytes(this.DEFAULT_SECRET_SIZE)); + } +} + +?> diff --git a/src/HashInterface.php b/src/HashInterface.php new file mode 100644 index 0000000..9963709 --- /dev/null +++ b/src/HashInterface.php @@ -0,0 +1,12 @@ + diff --git a/src/RFC/_4226.php b/src/RFC/_4226.php new file mode 100644 index 0000000..a1e0055 --- /dev/null +++ b/src/RFC/_4226.php @@ -0,0 +1,35 @@ +n, $this->n + ($this->driftModifier * $this->grace), $this->driftModifier) as $c) + { + $expected = + \bindec($this->hash->hash_numeric($this->key, $c)) % + \pow(10, $this->digits); + + if (\hash_equals((string)$expected, $q)) + { + $valid_count = $c; + break; + } + } + return $valid_count; + } +} + +?> \ No newline at end of file diff --git a/src/RFC/_6238.php b/src/RFC/_6238.php new file mode 100644 index 0000000..1c81575 --- /dev/null +++ b/src/RFC/_6238.php @@ -0,0 +1,42 @@ +clock->now()->getTimestamp()/$this->window; + $hotp = new _4226( + $this->key, + $window_n, + $this->grace, + $this->digits, + $this->hash, + -1 + ); + + $valid = $hotp->validate($q) * $this->window; + if ($valid != false && $valid <= $this->floor) + { + $valid = false; + // TODO: OTP reuse, should be reported rather than silent failing in harder security contexts + } + + return $valid; + } +} + +?> diff --git a/src/Required/PersistenceInterface.php b/src/Required/PersistenceInterface.php new file mode 100644 index 0000000..7fbc208 --- /dev/null +++ b/src/Required/PersistenceInterface.php @@ -0,0 +1,53 @@ + diff --git a/src/Required/RequestInterface.php b/src/Required/RequestInterface.php new file mode 100644 index 0000000..be4bc51 --- /dev/null +++ b/src/Required/RequestInterface.php @@ -0,0 +1,31 @@ + diff --git a/src/Session/Base.php b/src/Session/Base.php new file mode 100644 index 0000000..d50a66c --- /dev/null +++ b/src/Session/Base.php @@ -0,0 +1,40 @@ +ns($k); + if (!isset($_SESSION[$key])) + { + $_SESSION[$key] = $val; + return true; + } + return false; + } + + public function store(string $key, mixed $val) + { + $_SESSION[$this->ns($key)] = $val; + } + + public function get(string $key): mixed + { + return $_SESSION[$this->ns($key)]; + } +} + +?> diff --git a/src/SessionInterface.php b/src/SessionInterface.php new file mode 100644 index 0000000..ef00893 --- /dev/null +++ b/src/SessionInterface.php @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/src/URI/otpauth.php b/src/URI/otpauth.php new file mode 100644 index 0000000..a7aca86 --- /dev/null +++ b/src/URI/otpauth.php @@ -0,0 +1,129 @@ + $v) + { + if(\array_key_exists($k, $arr)) + { + $arr[$k] = $v; + } + } + } + $query_defaults = [ + "algorithm" => "SHA1", + "period" => 30, + "digits" => 6, + ]; + + $apply_defaults($query_defaults, $parsed_query) + // END SCHEMEING + \ltrim($label[0], "/") !== $parsed_query['issuer'] + || throw new \InvalidArgumentException($uri . " has mismatching issuer information."); + + return self( + $parsed_query['issuer'], + $label[1], + $parsed_query['secret'], + $parsed_query['algorithm'], + $parsed_query['period'], + $parsed_query['digits'] + ); + } + + public static function from_string(string $uri): self + { + $parsed = self._pre($uri); + $parsed_uri = $parsed[0]; + $parsed_query = $parsed[1]; + + $label = \explode(":", $parsed_uri['path']); + $issuer = \ltrim($label[0], "/"); + $user = $label[1]; + + $secret = $query['secret']; + + \array_key_exists('algorithm', $query) ? $algo = $query['algorithm'] : $algo = null; + return new self($issuer, $user, $secret, $algo); + } + + public function emit_str(): string + { + $label = $this->provider . ":" . $this->userid; + $provider = "provider=" . $this->provider; + $algo = "algorithm=" . $this->algo; + $digits = "digits=" . $this->digits; + $period = "period=" . $this->period; + $secret = "secret=" . $this->secret; + $query = \implode("&", [$secret, $provider, $period, $digits]) + + return "otpauth://totp/" $label . "?" . $query; + } + +} + +?> diff --git a/src/Workflow/Authenticate.php b/src/Workflow/Authenticate.php new file mode 100644 index 0000000..c2dbb7b --- /dev/null +++ b/src/Workflow/Authenticate.php @@ -0,0 +1,74 @@ +emit_str(); + } + + function emit_str(): string + { + $html = "
"; + $html .= $this->csrf->emit_str(); + $html .= "

Please enter the code showing on your authenticator

"; + $html .= ""; + $html .= ""; + $html .= "
"; + + $values = [ + "%frm" => $this->ri->form_props("authenticate"), + ]; + + return \str_replace(\array_keys($values), $values, $html); + } + + function response(): bool + { + if (!this->csrf->match()) + { + return false; + } + + $p_uri = $this->db->get_secret($this->user_index); + + $totp = _6238( + $p_uri.secret, + $p_uri.period, + $this->db->get_last_time($this->user_index), + 2, + $p_uri.digits, + $this->clock, + $this->hash + ); + $flag = $totp.validate($this->ri->get_resp("totp_challenge")); + + if($flag) + { + $this->db->store_last_time($this->user_index, $flag); + return true; + } + return false; + } +} + +?> diff --git a/src/Workflow/UserManagement.php b/src/Workflow/UserManagement.php new file mode 100644 index 0000000..ca4f521 --- /dev/null +++ b/src/Workflow/UserManagement.php @@ -0,0 +1,124 @@ +emit_str(); + } + + private function view_enroll_form(): string + { + $html = "
"; + $html .= $this->csrf->emit_str(); + $html .= "

To add an authenticator to your account, scan the QR code

"; + $html .= "\"qr-code\""; + $html .= "" + $html .= ""; + $html .= ""; + $html .= "
"; + + $provisioning_uri = (new Otpauth( + "taatp", + $this->userIndex, + $this->hash->keygen(), + "SHA1", + 30, + 6 + ))->emit_str(); + $this->session->store("secret", $provisioning_uri); + + $values = [ + "%frm" => $this->ri->form_props("enroll"), + "%qr" => (new QRCode)->render($provisioning_uri), + ] + return \str_replace(\array_keys($values), $values, $html); + } + + private function view_unenroll_form(): string + { + $html = "
"; + $html .= $this->csrf->emit_str(); + $html .= ""; + $html .= ""; + $html .= ""; + $html .= "
"; + + $values = [ + "%frm" => $this->ri->form_props("unenroll"), + ]; + + return \str_replace(\array_keys($values), $values, $html); + } + + function emit_str(): string + { + if (\is_null($this->db->get_secret($this->user_index))) + { + $this->view_enroll_form(); + } + else + { + $this->view_unenroll_form(); + } + } + + function response(): bool + { + if (!$this->csrf->match()) + { + return false; + } + + $p_uri = $this->db->get_secret($this->user_index); + $enroll_flag = \is_null($p_uri); + $enroll_flag && $enroll_flag = $this->session->get('secret'); + + $totp = _6238( + $p_uri.secret, + $p_uri.period, + $enroll_flag? 0:$this->db->get_last_time($this->user_index), + 2, + $p_uri.digits, + $this->clock, + $this->hash + ); + $flag = $totp.validate($this->ri->get_resp("totp_challenge")); + + if($flag && $enroll_flag) + { + $this->db->store_last_time($this->user_index, $flag); + $this->db->store_secret($this->user_index, $p_uri); + return true; + } + else if($flag) + { + $this->db->strip_secret($this->user_index); + return true; + } + return false; + } +} + +?> diff --git a/src/WorkflowInterface.php b/src/WorkflowInterface.php new file mode 100644 index 0000000..bf890c4 --- /dev/null +++ b/src/WorkflowInterface.php @@ -0,0 +1,24 @@ +