Deleted client specifics, more generic interfacing, more room for future customization

master
Brady McDonough 2 years ago
commit 38c716aa20

@ -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

@ -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
-

@ -0,0 +1,73 @@
<?php
namespace BradyMcD\TAATP\AntiCSRF;
use BradyMcD\TAATP\AntiCSRFInterface;
use BradyMcD\TAATP\SessionInterface;
define("CSRF_TOKEN_IDX", "antiCSRF_TAATP");
define("CSRF_EXPIRY_IDX", "antiCSRF_TAATP_Expiry");
class Base implements AntiCSRFInterface
{
private int $token;
private int $expiry;
public function __construct(
private SessionInterface $session,
private \Psr\Clock\ClockInterface $clock
)
{
assert($session->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
'<input type="hidden" name="'
. CSRF_TOKEN_IDX
. '" value="'
. $this->token
. '" />';
}
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());
}
}
?>

@ -0,0 +1,41 @@
<?php
namespace BradyMcD\TAATP\AntiCSRF;
use BradyMcD\TAATP\AntiCSRFInterface;
use BradyMcD\TAATP\SessionInterface;
class None implements AntiCSRFInterface
{
public function __construct(
private SessionInterface $session,
)
{}
public function match(): bool
{
return true;
}
public function expired(): bool
{
return false;
}
public function emit_str(): string
{
return "";
}
public function display()
{
echo $this->emit_str();
}
public function generate()
{}
public function regenerate()
{}
}
?>

@ -0,0 +1,22 @@
<?php
namespace BradyMcD\TAATP;
/**
*
*/
interface AntiCSRFInterface
{
public function __construct(
SessionInterface $session
);
public function match(): bool;
public function expired(): bool;
public function display();
public function emit_str(): string;
public function generate();
public function regenerate();
}
?>

@ -0,0 +1,13 @@
<?php
namespace BradyMcD\TAATP\Clock;
class Base implements \Psr\Clock\ClockInterface
{
function now(): \DateTimeImmutable
{
return new \DateTimeImmutable("now");
}
}
?>

@ -0,0 +1,20 @@
<?php
namespace BradyMcD\TAATP\Clock;
class Request implements \Psr\Clock\ClockInterface
{
private $time;
public function __construct()
{
$this->time = $_SERVER["REQUEST_TIME"];
}
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable($this->time);
}
}
?>

@ -0,0 +1,50 @@
<?php
namespace BradyMcD\TAATP;
use BradyMcD\TAATP\Required\PersistenceInterface;
use BradyMcD\TAATP\SessionInterface;
use BradyMcD\TAATP\AntiCSRFInterface;
use BradyMcD\TAATP\Workflow\UserManagement;
use BradyMcD\TAATP\Workflow\Authenticate;
/**
* The primary entrypoint of the module.
*/
class Factory
{
public function __construct(
private PersistenceInterface $db,
private RequestInterface $ri,
private null|SessionInterface $session,
private null|\PSR\Clock\ClockInterface $csrf_clock,
private null|AntiCSRFInterface $csrf,
private null|\PSR\Clock\ClockInterface $totp_clock,
private null|HashInterface $hash,
)
{
$this->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)
}
}
?>

@ -0,0 +1,32 @@
<?php
namespace BradyMcD\TAATP\Hash;
use BradyMcD\TAATP\HashInterface;
use ParagonIE\ConstantTime\Base32;
class HMAC_SHA1 implements HashInterface
{
DEFAULT_SECRET_SIZE = 32;
public function hash(string $k, string $v): string
{
$key = Base32::decode($k)
return \hash_hmac("sha1", \hex2bin($v), $key, true);
}
public function hash_numeric(string $k, int $v): string
{
return $this->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));
}
}
?>

@ -0,0 +1,12 @@
<?php
namespace BradyMcD\TAATP;
interface HashInterface
{
public function hash(string $k, string $v): string;
public function hash_numeric(string $k, int $v): string;
public function hash_type(): string;
}
?>

@ -0,0 +1,35 @@
<?php
namespace BradyMcD\TAATP\RFC;
class _4226
{
public function __construct(
private string $key,
private int $n,
private int $grace,
private int $digits,
private \BradyMcD\TAATP\HashInterface $hash,
private int $driftModifier = 1,
)
{}
public function validate(string $q): int
{
$valid_count = false;
foreach (range($this->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;
}
}
?>

@ -0,0 +1,42 @@
<?php
namespace BradyMcD\TAATP\RFC;
use Psr\Clock\ClockInterface;
class _6238
{
public function __construct(
private string $key,
private int $window,
private int $floor,
private int $grace,
private int $digits,
private ClockInterface $clock,
private \BradyMcD\TAATP\HashInterface $hash,
)
{}
public function validate(string $q): bool|int
{
$window_n = $this->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;
}
}
?>

@ -0,0 +1,53 @@
<?php
/**
*
*/
namespace BradyMcD\TAATP\Required;
/**
* Implements the required persistence functions for a TOTP system.
* It is MANDATORY that this be implemented by the user of this module to make everything talk to their database.
*/
interface PersistenceInterface
{
/**
* Stores the otpauth URI for the user associated with the given indexing data.
* @param mixed $index Whatever data needed to index into your database and identify a particular user
* @param string $secret The secret datastring used to seed the TOTP rolling hash
* @return void
*/
public function store_secret(mixed $index, string $secret);
/**
* Removes the secret key for the user associated with the given indexing data.
* @param mixed $index Whatever data needed to index into your database and identify a particular user
*/
public function strip_secret(mixed $index);
/**
* As the name suggests One-Time-Passwords should only be usable one time!
* We store the timestamp of the last valid challenge to enforce this.
* @param mixed $index Whatever data needed to index into your database and identify a particular user
* @param int $timestamp A UNIX timestamp representing the last successful challenge time.
* Only codes generated at a time greater than the indicated time will be deemed valid.
* @return bool
*/
public function store_last_time(mixed $index, int $timestamp): bool;
/**
* Gets and returns the otpauth URI for the user associated with the given user_id.
* Return null if the user isn't enrolled for TOTP 2-factor authentication.
* @param mixed $index Whatever data needed to index into your database and identify a particular user
* @return null|string
*/
public function get_secret(mixed $index): null|string;
/**
* Gets and returns the last successful challenge timestamp to enforce the One-Time aspect of a TOTP.
* @param mixed $index Whatever data needed to index into your database and identify a particular user
* @return int
*/
public function get_last_time(mixed $index): int;
}
?>

@ -0,0 +1,31 @@
<?php
/**
*
*/
namespace BradyMcD\TAATP\Required;
/**
* Implements the required form generating and response handling functions
* for a TOTP system.
* It is MANDATORY that this be implemented by the user of this module.
*/
interface RequestInterface
{
/**
* Returns the properties required for a form.
* @param string $place One of "enroll", "unenroll" or "authenticate"
* @return string
*/
function form_props(string $place): string;
/**
* Returns a referred user response variable.
* @param string $k The key the user is sending
* @return string
*/
function get_resp(string $k): string;
}
?>

@ -0,0 +1,40 @@
<?php
namespace BradyMcD\TAATP\Session;
use BradyMcD\TAATP\SessionInterface;
class Base implements SessionInterface
{
private function ns($key): string
{
return "taatp_" . $key;
}
public function live(): bool
{
return session_status() === PHP_SESSION_ACTIVE;
}
public function try_store(string $k, mixed $val): bool
{
$key = $this->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)];
}
}
?>

@ -0,0 +1,17 @@
<?php
namespace BradyMcD\TAATP;
/**
* Implements a session interaction wrapper.
* It is SUGGESTED that this be implemented by the user of this module.
*/
interface SessionInterface
{
function live(): bool;
function try_store(string $key, mixed $val): bool;
function store(string $key, mixed $val);
function get(string $key);
}
?>

@ -0,0 +1,129 @@
<?php
namespace BradyMcD\TAATP\URI;
class Otpauth
{
public function __construct(
public readonly string $issuer,
public readonly string $userid,
public readonly string $secret,
public readonly string $algo,
public readonly int $period,
public readonly int $digits,
)
{}
public static function from_string(string $uri): self
{
// This is definitely from writing Scheme.
$run_check = function(array $check_array, $target)
{
foreach($check_array as $c)
{
!($c[0]($target))? :throw new \InvalidArgumentException($c[1]);
}
}
$uri_checks = [
[ function($arr)
{ 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."],
[ function($arr)
{ return \array_key_exists('path', $arr);},
$uri . " isn't tagged for a user or issuer."],
[ function($arr)
{ return \array_key_exists('query', $arr);},
$uri . " lacks query information."]
];
$parsed_uri = \parse_url($uri);
$run_check($uri_checks, $parsed_uri)
$query_checks = [
[ function($que)
{ return \array_key_exists('issuer', $que);},
$parsed_uri['query'] . " has no issuer information."],
[ function($que)
{ return \array_key_exists('secret', $que);},
$parsed_uri['query'] . "has no secret key."]
];
$parsed_query = [];
\parse_str($parsed['query'], $parsed_query);
$run_check($query_checks, $parsed_query);
$label_checks = [
[ function($lab)
{ return \count($lab) === 2;},
$parsed_uri['path'] . " doesn't have the correct number of components."]
];
$label = \explode(":", $parsed['path']);
$run_check($label_checks, $label);
$apply_defaults = function(array &$arr, array $defaults) {
foreach($defaults as $k => $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;
}
}
?>

@ -0,0 +1,74 @@
<?php
namespace BradyMcD\TAATP\Workflow;
use BradyMcD\TAATP\AntiCSRFInterface;
use BradyMcD\TAATP\Required\PersistenceInterface;
use BradyMcD\TAATP\SessionInterface;
use BradyMcD\TAATP\HashInterface;
use BradyMcD\TAATP\URI\Otpauth;
use BradyMcD\RFC\_6238;
class Authenticate implements WorkflowInterface
{
public function __construct(
private PersistenceInterface $db,
private RequestInterface $ri,
private AntiCSRFInterface $csrf,
private SessionInterface $session,
private mixed $user_index,
private HashInterface $hash,
)
{
}
function display()
{
echo $this->emit_str();
}
function emit_str(): string
{
$html = "<div id=\"authenticate\"><form %frm>";
$html .= $this->csrf->emit_str();
$html .= "<p>Please enter the code showing on your authenticator</p>";
$html .= "<intput name=\"totp_challenge\" id=\"totp_challenge\" type\"text\" />";
$html .= "<input type=\"submit\" value=\"Submit\" />";
$html .= "</form></div>";
$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;
}
}
?>

@ -0,0 +1,124 @@
<?php
namespace BradyMcD\TAATP;
use BradyMcD\TAATP\AntiCSRFInterface;
use BradyMcD\TAATP\Required\PersistenceInterface;
use BradyMcD\TAATP\SessionInterface;
use BradyMcD\TAATP\HashInterface;
use BradyMcD\TAATP\URI\Otpauth;
use BradyMcD\RFC\_6238;
use Chillerlan\QRCode;
class UserManagement implements WorkflowInterface
{
public function __construct(
private PersistenceInterface $db,
private RequestInterface $ri,
private AntiCSRFInterface $csrf,
private SessionInterface $session,
private mixed $user_index,
private HashInterface $hash,
)
{
}
function display()
{
echo $this->emit_str();
}
private function view_enroll_form(): string
{
$html = "<div id=\"enroll\"><form %frm>";
$html .= $this->csrf->emit_str();
$html .= "<p>To add an authenticator to your account, scan the QR code</p>";
$html .= "<img src=%qr alt=\"qr-code\" />";
$html .= "<label for=\"totp_challenge\">Enter the authentication code:</label>"
$html .= "<input name=\"totp_challenge\" id=\"totp_challenge\" type=\"text\" >";
$html .= "<input type=\"submit\" value=\"Submit\" />";
$html .= "</form></div>";
$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 = "<div id=\"unenroll\"><form %frm>";
$html .= $this->csrf->emit_str();
$html .= "<label for=\"totp_challenge\">To de-register your authenticator enter the current authentication code:</label>";
$html .= "<intput name=\"totp_challenge\" id=\"totp_challenge\" type\"text\" />";
$html .= "<input type=\"submit\" value=\"Submit\" />";
$html .= "</form></div>";
$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;
}
}
?>

@ -0,0 +1,24 @@
<?php
namespace BradyMcD\TAATP;
interface WorkflowInterface
{
/**
* echo's the workflow's relevant form.
* @return void
*/
public function view();
/**
* returns the workflow's relevant form.
* @return string
*/
public function emit_str():string;
/**
* Handles any response given to the workflow's form.
* @return bool
*/
public function response(): bool;
}
?>
Loading…
Cancel
Save